diff --git a/sites/backend/openapi/flows.mjs b/sites/backend/openapi/flows.mjs new file mode 100644 index 00000000000..b30151b6df7 --- /dev/null +++ b/sites/backend/openapi/flows.mjs @@ -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'], + }, + }, +} diff --git a/sites/backend/openapi/index.mjs b/sites/backend/openapi/index.mjs index 0425f1dfc94..9925f679a78 100644 --- a/sites/backend/openapi/index.mjs +++ b/sites/backend/openapi/index.mjs @@ -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, }, } diff --git a/sites/backend/openapi/lib.mjs b/sites/backend/openapi/lib.mjs index c162eb0d5f1..2f311d81fe2 100644 --- a/sites/backend/openapi/lib.mjs +++ b/sites/backend/openapi/lib.mjs @@ -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', diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index a9c2d999c88..178a1889c1b 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -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', diff --git a/sites/backend/src/controllers/flows.mjs b/sites/backend/src/controllers/flows.mjs new file mode 100644 index 00000000000..6966c35d78d --- /dev/null +++ b/sites/backend/src/controllers/flows.mjs @@ -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) +} diff --git a/sites/backend/src/models/flow.mjs b/sites/backend/src/models/flow.mjs new file mode 100644 index 00000000000..3090a1534e8 --- /dev/null +++ b/sites/backend/src/models/flow.mjs @@ -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) +} diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index b2731083d26..3bc8f969c2b 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -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 diff --git a/sites/backend/src/routes/flows.mjs b/sites/backend/src/routes/flows.mjs new file mode 100644 index 00000000000..9fe34d93dd4 --- /dev/null +++ b/sites/backend/src/routes/flows.mjs @@ -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) + ) +} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index 60851db0e10..3155af45471 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -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, } diff --git a/sites/backend/src/templates/email/index.mjs b/sites/backend/src/templates/email/index.mjs index 346530eb5c5..319d1fd3982 100644 --- a/sites/backend/src/templates/email/index.mjs +++ b/sites/backend/src/templates/email/index.mjs @@ -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 }, } diff --git a/sites/backend/src/templates/email/transinvite/index.mjs b/sites/backend/src/templates/email/transinvite/index.mjs new file mode 100644 index 00000000000..7c42889e504 --- /dev/null +++ b/sites/backend/src/templates/email/transinvite/index.mjs @@ -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 } diff --git a/sites/backend/src/templates/email/transinvite/transinvite.en.yaml b/sites/backend/src/templates/email/transinvite/transinvite.en.yaml new file mode 100644 index 00000000000..08af7f8536d --- /dev/null +++ b/sites/backend/src/templates/email/transinvite/transinvite.en.yaml @@ -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."