feat(backend): Added flows endpoint for translator invites
This commit is contained in:
parent
064b058fd6
commit
1bc256fb05
12 changed files with 249 additions and 0 deletions
75
sites/backend/openapi/flows.mjs
Normal file
75
sites/backend/openapi/flows.mjs
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import { paths as patternPaths } from './patterns.mjs'
|
||||||
import { paths as setPaths } from './sets.mjs'
|
import { paths as setPaths } from './sets.mjs'
|
||||||
import { paths as curatedSetPaths } from './curated-sets.mjs'
|
import { paths as curatedSetPaths } from './curated-sets.mjs'
|
||||||
import { paths as userPaths } from './users.mjs'
|
import { paths as userPaths } from './users.mjs'
|
||||||
|
import { paths as flowPaths } from './flows.mjs'
|
||||||
|
|
||||||
const description = `
|
const description = `
|
||||||
## What am I looking at? 🤔
|
## What am I looking at? 🤔
|
||||||
|
@ -53,5 +54,6 @@ export const openapi = {
|
||||||
...setPaths,
|
...setPaths,
|
||||||
...curatedSetPaths,
|
...curatedSetPaths,
|
||||||
...userPaths,
|
...userPaths,
|
||||||
|
...flowPaths,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ export const errors = {
|
||||||
keyLevelExceedsRoleLevel:
|
keyLevelExceedsRoleLevel:
|
||||||
'The `level` field in the request body is higher than the `level` of the user creating the key. This is not allowed.',
|
'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',
|
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',
|
levelMissing: 'The `level` field was missing from the request body',
|
||||||
levelNotNumeric: 'The `level` field in the request body was a number',
|
levelNotNumeric: 'The `level` field in the request body was a number',
|
||||||
mfaActive: 'MFA is already activated on the account',
|
mfaActive: 'MFA is already activated on the account',
|
||||||
|
|
|
@ -26,6 +26,9 @@ const envToBool = (input = 'no') => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save ourselves some typing
|
||||||
|
const crowdinProject = 'https://translate.freesewing.org/project/freesewing/'
|
||||||
|
|
||||||
// Construct config object
|
// Construct config object
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
// Environment
|
// Environment
|
||||||
|
@ -65,6 +68,15 @@ const baseConfig = {
|
||||||
github: {
|
github: {
|
||||||
token: process.env.BACKEND_GITHUB_TOKEN,
|
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: {
|
jwt: {
|
||||||
secretOrKey: encryptionKey,
|
secretOrKey: encryptionKey,
|
||||||
issuer: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
|
issuer: process.env.BACKEND_JWT_ISSUER || 'freesewing.org',
|
||||||
|
@ -72,6 +84,7 @@ const baseConfig = {
|
||||||
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
|
expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d',
|
||||||
},
|
},
|
||||||
languages: ['en', 'de', 'es', 'fr', 'nl'],
|
languages: ['en', 'de', 'es', 'fr', 'nl'],
|
||||||
|
translations: ['de', 'es', 'fr', 'nl', 'uk'],
|
||||||
measies: measurements,
|
measies: measurements,
|
||||||
mfa: {
|
mfa: {
|
||||||
service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing',
|
service: process.env.BACKEND_MFA_SERVICE || 'FreeSewing',
|
||||||
|
|
14
sites/backend/src/controllers/flows.mjs
Normal file
14
sites/backend/src/controllers/flows.mjs
Normal 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)
|
||||||
|
}
|
67
sites/backend/src/models/flow.mjs
Normal file
67
sites/backend/src/models/flow.mjs
Normal 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)
|
||||||
|
}
|
|
@ -125,6 +125,23 @@ UserModel.prototype.loadAuthenticatedUser = async function (user) {
|
||||||
return this
|
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
|
* Checks this.record and sets a boolean to indicate whether
|
||||||
* the user exists or not
|
* the user exists or not
|
||||||
|
|
17
sites/backend/src/routes/flows.mjs
Normal file
17
sites/backend/src/routes/flows.mjs
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import { patternsRoutes } from './patterns.mjs'
|
||||||
import { confirmationsRoutes } from './confirmations.mjs'
|
import { confirmationsRoutes } from './confirmations.mjs'
|
||||||
import { curatedSetsRoutes } from './curated-sets.mjs'
|
import { curatedSetsRoutes } from './curated-sets.mjs'
|
||||||
import { issuesRoutes } from './issues.mjs'
|
import { issuesRoutes } from './issues.mjs'
|
||||||
|
import { flowsRoutes } from './flows.mjs'
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
apikeysRoutes,
|
apikeysRoutes,
|
||||||
|
@ -14,4 +15,5 @@ export const routes = {
|
||||||
confirmationsRoutes,
|
confirmationsRoutes,
|
||||||
curatedSetsRoutes,
|
curatedSetsRoutes,
|
||||||
issuesRoutes,
|
issuesRoutes,
|
||||||
|
flowsRoutes,
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { passwordreset, translations as passwordresetTranslations } from './pass
|
||||||
import { signup, translations as signupTranslations } from './signup/index.mjs'
|
import { signup, translations as signupTranslations } from './signup/index.mjs'
|
||||||
import { signupaea, translations as signupaeaTranslations } from './signup-aea/index.mjs'
|
import { signupaea, translations as signupaeaTranslations } from './signup-aea/index.mjs'
|
||||||
import { signupaed, translations as signupaedTranslations } from './signup-aed/index.mjs'
|
import { signupaed, translations as signupaedTranslations } from './signup-aed/index.mjs'
|
||||||
|
import { transinvite, translations as transinviteTranslations } from './transinvite/index.mjs'
|
||||||
// Shared translations
|
// Shared translations
|
||||||
import en from '../../../public/locales/en/shared.json' assert { type: 'json' }
|
import en from '../../../public/locales/en/shared.json' assert { type: 'json' }
|
||||||
import de from '../../../public/locales/de/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,
|
||||||
'signup-aea': signupaea,
|
'signup-aea': signupaea,
|
||||||
'signup-aed': signupaed,
|
'signup-aed': signupaed,
|
||||||
|
transinvite,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const translations = {
|
export const translations = {
|
||||||
|
@ -36,5 +38,6 @@ export const translations = {
|
||||||
signup: signupTranslations,
|
signup: signupTranslations,
|
||||||
'signup-aea': signupaeaTranslations,
|
'signup-aea': signupaeaTranslations,
|
||||||
'signup-aed': signupaedTranslations,
|
'signup-aed': signupaedTranslations,
|
||||||
|
transinvite: transinviteTranslations,
|
||||||
shared: { en, de, es, fr, nl },
|
shared: { en, de, es, fr, nl },
|
||||||
}
|
}
|
||||||
|
|
32
sites/backend/src/templates/email/transinvite/index.mjs
Normal file
32
sites/backend/src/templates/email/transinvite/index.mjs
Normal 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 }
|
|
@ -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."
|
Loading…
Add table
Add a link
Reference in a new issue