1
0
Fork 0

feat(backend): Added flows endpoint for translator invites

This commit is contained in:
joostdecock 2023-07-09 16:26:19 +02:00
parent 064b058fd6
commit 1bc256fb05
12 changed files with 249 additions and 0 deletions

View file

@ -0,0 +1,75 @@
import { jwt, key, fields, parameters, response, errorExamples, jsonResponse } from './lib.mjs'
const common = {
tags: ['Workflows'],
security: [jwt, key],
}
const local = {
params: {
id: {
in: 'path',
name: 'id',
required: true,
description: "The Set's unique ID",
schema: {
example: 666,
type: 'integer',
},
},
},
}
// Paths
export const paths = {}
// Create set
paths['/flows/translator-invite/{auth}'] = {
post: {
...common,
summary: 'Sends out an invite to join a FreeSewing translation team',
description:
'Will trigger an invite to be sent out to the authenticated user that allows them to join a FreeSewing translation team',
parameters: [parameters.auth],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
language: {
description: `Language code of the translation team the user wants to join`,
type: 'string',
example: 'es',
enum: ['es', 'de', 'fr', 'nl', 'uk'],
},
},
},
},
},
},
responses: {
200: {
...response.status['200'],
...jsonResponse({
result: { ...fields.result, example: 'sent' },
}),
},
400: {
...response.status['400'],
description:
response.status['400'].description +
errorExamples(['languageMissing', 'languageInvalid']),
},
401: response.status['401'],
403: {
...response.status['403'],
description:
response.status['403'].description +
errorExamples(['accountStatusLacking', 'insufficientAccessLevel']),
},
500: response.status['500'],
},
},
}

View file

@ -6,6 +6,7 @@ import { paths as patternPaths } from './patterns.mjs'
import { paths as setPaths } from './sets.mjs'
import { paths as curatedSetPaths } from './curated-sets.mjs'
import { paths as userPaths } from './users.mjs'
import { paths as flowPaths } from './flows.mjs'
const description = `
## What am I looking at? 🤔
@ -53,5 +54,6 @@ export const openapi = {
...setPaths,
...curatedSetPaths,
...userPaths,
...flowPaths,
},
}

View file

@ -22,6 +22,7 @@ export const errors = {
keyLevelExceedsRoleLevel:
'The `level` field in the request body is higher than the `level` of the user creating the key. This is not allowed.',
languageMissing: 'The `langauge` field was missing from the request body',
languageInvalid: 'The `langauge` field holds a value that is invalid',
levelMissing: 'The `level` field was missing from the request body',
levelNotNumeric: 'The `level` field in the request body was a number',
mfaActive: 'MFA is already activated on the account',

View file

@ -26,6 +26,9 @@ const envToBool = (input = 'no') => {
return false
}
// Save ourselves some typing
const crowdinProject = 'https://translate.freesewing.org/project/freesewing/'
// Construct config object
const baseConfig = {
// Environment
@ -65,6 +68,15 @@ const baseConfig = {
github: {
token: process.env.BACKEND_GITHUB_TOKEN,
},
crowdin: {
invites: {
nl: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_NL,
fr: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_FR,
de: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_DE,
es: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_ES,
uk: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_UK,
},
},
jwt: {
secretOrKey: encryptionKey,
issuer: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
@ -72,6 +84,7 @@ const baseConfig = {
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
},
languages: ['en', 'de', 'es', 'fr', 'nl'],
translations: ['de', 'es', 'fr', 'nl', 'uk'],
measies: measurements,
mfa: {
service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing',

View file

@ -0,0 +1,14 @@
import { FlowModel } from '../models/flow.mjs'
export function FlowsController() {}
/*
* Send out an invite for a translator who wants to join the team
* See: https://freesewing.dev/reference/backend/api
*/
FlowsController.prototype.sendTranslatorInvite = async (req, res, tools) => {
const Flow = new FlowModel(tools)
await Flow.sendTranslatorInvite(req)
return Flow.sendResponse(res)
}

View file

@ -0,0 +1,67 @@
import { log } from '../utils/log.mjs'
import { UserModel } from './user.mjs'
import { i18nUrl } from '../utils/index.mjs'
export function FlowModel(tools) {
this.config = tools.config
this.mailer = tools.email
this.rbac = tools.rbac
this.User = new UserModel(tools)
return this
}
FlowModel.prototype.sendTranslatorInvite = async function ({ body, user }) {
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
if (!body.language) return this.setResponse(400, 'languageMissing')
if (!this.config.translations.includes(body.language))
return this.setResponse(400, 'languageInvalid')
// Load user making the call
await this.User.revealAuthenticatedUser(user)
// Send the invite email
await this.mailer.send({
template: 'transinvite',
language: body.language,
to: this.User.clear.email,
replacements: {
actionUrl: this.config.crowdin.invites[body.language],
whyUrl: i18nUrl(body.language, `/docs/faq/email/why-transinvite`),
supportUrl: i18nUrl(body.language, `/patrons/join`),
},
})
return this.setResponse(200, 'sent', {})
}
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
FlowModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
this.response = {
status,
body: {
result: 'success',
...data,
},
}
if (status > 201) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
} else this.error = false
if (status === 404) this.response.body = null
return this
}
/*
* Helper method to send response
*/
FlowModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}

View file

@ -125,6 +125,23 @@ UserModel.prototype.loadAuthenticatedUser = async function (user) {
return this
}
/*
* Loads & reveals the user that is making the API request
e
* Stores result in this.record
*/
UserModel.prototype.revealAuthenticatedUser = async function (user) {
if (!user) return this
this.record = await this.prisma.user.findUnique({
where: { id: user.uid },
include: {
apikeys: true,
},
})
return this.reveal()
}
/*
* Checks this.record and sets a boolean to indicate whether
* the user exists or not

View file

@ -0,0 +1,17 @@
import { FlowsController } from '../controllers/flows.mjs'
const Flow = new FlowsController()
const jwt = ['jwt', { session: false }]
const bsc = ['basic', { session: false }]
export function flowsRoutes(tools) {
const { app, passport } = tools
// Send a translator invite
app.post('/flows/translator-invite/jwt', passport.authenticate(...jwt), (req, res) =>
Flow.sendTranslatorInvite(req, res, tools)
)
app.post('/flows/translator-invite/key', passport.authenticate(...bsc), (req, res) =>
Flow.sendTranslatorInvite(req, res, tools)
)
}

View file

@ -5,6 +5,7 @@ import { patternsRoutes } from './patterns.mjs'
import { confirmationsRoutes } from './confirmations.mjs'
import { curatedSetsRoutes } from './curated-sets.mjs'
import { issuesRoutes } from './issues.mjs'
import { flowsRoutes } from './flows.mjs'
export const routes = {
apikeysRoutes,
@ -14,4 +15,5 @@ export const routes = {
confirmationsRoutes,
curatedSetsRoutes,
issuesRoutes,
flowsRoutes,
}

View file

@ -6,6 +6,7 @@ import { passwordreset, translations as passwordresetTranslations } from './pass
import { signup, translations as signupTranslations } from './signup/index.mjs'
import { signupaea, translations as signupaeaTranslations } from './signup-aea/index.mjs'
import { signupaed, translations as signupaedTranslations } from './signup-aed/index.mjs'
import { transinvite, translations as transinviteTranslations } from './transinvite/index.mjs'
// Shared translations
import en from '../../../public/locales/en/shared.json' assert { type: 'json' }
import de from '../../../public/locales/de/shared.json' assert { type: 'json' }
@ -25,6 +26,7 @@ export const templates = {
signup,
'signup-aea': signupaea,
'signup-aed': signupaed,
transinvite,
}
export const translations = {
@ -36,5 +38,6 @@ export const translations = {
signup: signupTranslations,
'signup-aea': signupaeaTranslations,
'signup-aed': signupaedTranslations,
transinvite: transinviteTranslations,
shared: { en, de, es, fr, nl },
}

View file

@ -0,0 +1,32 @@
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from '../shared/blocks.mjs'
// Translations
import en from '../../../../public/locales/en/transinvite.json' assert { type: 'json' }
import de from '../../../../public/locales/de/transinvite.json' assert { type: 'json' }
import es from '../../../../public/locales/es/transinvite.json' assert { type: 'json' }
import fr from '../../../../public/locales/fr/transinvite.json' assert { type: 'json' }
import nl from '../../../../public/locales/nl/transinvite.json' assert { type: 'json' }
export const transinvite = {
html: wrap.html(`
${headingRow.html}
${lead1Row.html}
${buttonRow.html}
${closingRow.html}
`),
text: wrap.text(`
{{{ heading }}}
{{{ textLead }}}
{{{ actionUrl }}}
{{{ closing }}}
{{{ greeting }}},
joost
PS: {{{ text-ps }}} : {{{ supportUrl }}}
`),
}
export const translations = { en, de, es, fr, nl }

View file

@ -0,0 +1,6 @@
subject: "[FreeSewing] Your invitation to join FreeSewing's the translation team"
heading: Join a FreeSewing translation team
lead: 'To join the FreeSewing translation team, click the big black rectangle below:'
text-lead: 'To join the FreeSewing translation team, click the link below:'
button: Join the translation team
closing: "This will take you to Crowdin, the translation platform we use."