From 99bd856acc17e2203b508c9595a962af9df655d2 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 11 Feb 2025 10:16:20 +0800 Subject: [PATCH] feat(core): add PUT and DELETE email-templates api (#7010) * feat(core): add PUT and DELETE email-templates api add PUT and DELETE email-templates api * fix(core): fix openapi docs errors fix openapi docs errors --- packages/core/src/queries/email-templates.ts | 53 ++++++++++++ .../routes/email-template/index.openapi.json | 81 +++++++++++++++++++ .../core/src/routes/email-template/index.ts | 64 +++++++++++++++ packages/core/src/routes/init.ts | 6 ++ .../idp-initiated-auth-config.openapi.json | 4 +- .../core/src/routes/sso-connector/index.ts | 6 +- .../src/routes/swagger/utils/documents.ts | 1 + .../src/routes/swagger/utils/parameters.ts | 1 + packages/core/src/tenants/Queries.ts | 2 + .../src/__mocks__/email-templates.ts | 31 +++++++ .../src/api/email-templates.ts | 15 ++++ .../src/helpers/email-templates.ts | 20 +++++ .../src/tests/api/email-templates.test.ts | 38 +++++++++ 13 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/queries/email-templates.ts create mode 100644 packages/core/src/routes/email-template/index.openapi.json create mode 100644 packages/core/src/routes/email-template/index.ts create mode 100644 packages/integration-tests/src/__mocks__/email-templates.ts create mode 100644 packages/integration-tests/src/api/email-templates.ts create mode 100644 packages/integration-tests/src/helpers/email-templates.ts create mode 100644 packages/integration-tests/src/tests/api/email-templates.test.ts diff --git a/packages/core/src/queries/email-templates.ts b/packages/core/src/queries/email-templates.ts new file mode 100644 index 000000000..36579dbbb --- /dev/null +++ b/packages/core/src/queries/email-templates.ts @@ -0,0 +1,53 @@ +import { + type EmailTemplate, + type EmailTemplateKeys, + EmailTemplates, + type CreateEmailTemplate, +} from '@logto/schemas'; +import { type CommonQueryMethods } from '@silverhand/slonik'; + +import SchemaQueries from '#src/utils/SchemaQueries.js'; + +import { type WellKnownCache } from '../caches/well-known.js'; +import { buildInsertIntoWithPool } from '../database/insert-into.js'; +import { convertToIdentifiers, type OmitAutoSetFields } from '../utils/sql.js'; + +export default class EmailTemplatesQueries extends SchemaQueries< + EmailTemplateKeys, + CreateEmailTemplate, + EmailTemplate +> { + constructor( + pool: CommonQueryMethods, + // TODO: Implement redis cache for email templates + private readonly wellKnownCache: WellKnownCache + ) { + super(pool, EmailTemplates); + } + + /** + * Upsert multiple email templates + * + * If the email template already exists with the same language tag, tenant ID, and template type, + * template details will be updated. + */ + async upsertMany( + emailTemplates: ReadonlyArray> + ): Promise { + const { fields } = convertToIdentifiers(EmailTemplates); + + return this.pool.transaction(async (transaction) => { + const insertIntoTransaction = buildInsertIntoWithPool(transaction)(EmailTemplates, { + returning: true, + onConflict: { + fields: [fields.tenantId, fields.languageTag, fields.templateType], + setExcludedFields: [fields.details], + }, + }); + + return Promise.all( + emailTemplates.map(async (emailTemplate) => insertIntoTransaction(emailTemplate)) + ); + }); + } +} diff --git a/packages/core/src/routes/email-template/index.openapi.json b/packages/core/src/routes/email-template/index.openapi.json new file mode 100644 index 000000000..809d55b01 --- /dev/null +++ b/packages/core/src/routes/email-template/index.openapi.json @@ -0,0 +1,81 @@ +{ + "tags": [ + { + "name": "Email templates", + "description": "Manage custom i18n email templates for various types of emails, such as sign-in verification codes and password resets." + }, + { + "name": "Dev feature" + } + ], + "paths": { + "/api/email-templates": { + "put": { + "summary": "Replace email templates", + "description": "Create or replace a list of email templates. If an email template with the same language tag and template type already exists, its details will be updated.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "templates": { + "type": "array", + "items": { + "properties": { + "languageTag": { + "description": "The language tag of the email template, e.g., `en` or `zh-CN`." + }, + "templateType": { + "description": "The type of the email template, e.g. `SignIn` or `ForgotPassword`" + }, + "details": { + "description": "The details of the email template.", + "properties": { + "subject": { + "description": "The template of the email subject." + }, + "content": { + "description": "The template of the email body." + }, + "contentType": { + "description": "The content type of the email body. (Only required by some specific email providers.)" + }, + "replyTo": { + "description": "The reply name template of the email. If not provided, the target email address will be used. (The render logic may differ based on the email provider.)" + }, + "sendFrom": { + "description": "The send from name template of the email. If not provided, the default Logto email address will be used. (The render logic may differ based on the email provider.)" + } + } + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The list of newly created or replaced email templates." + } + } + } + }, + "/api/email-templates/{id}": { + "delete": { + "summary": "Delete an email template", + "description": "Delete an email template by its ID.", + "responses": { + "204": { + "description": "The email template was deleted successfully." + }, + "404": { + "description": "The email template was not found." + } + } + } + } + } +} diff --git a/packages/core/src/routes/email-template/index.ts b/packages/core/src/routes/email-template/index.ts new file mode 100644 index 000000000..84abfdf8f --- /dev/null +++ b/packages/core/src/routes/email-template/index.ts @@ -0,0 +1,64 @@ +import { EmailTemplates } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { z } from 'zod'; + +import koaGuard from '#src/middleware/koa-guard.js'; + +import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; + +const pathPrefix = '/email-templates'; + +export default function emailTemplateRoutes( + ...[router, { queries }]: RouterInitArgs +) { + const { emailTemplates: emailTemplatesQueries } = queries; + + router.put( + pathPrefix, + koaGuard({ + body: z.object({ + templates: EmailTemplates.createGuard + .omit({ + id: true, + tenantId: true, + createdAt: true, + }) + .array() + .min(1), + }), + response: EmailTemplates.guard.array(), + status: [200, 422], + }), + async (ctx, next) => { + const { body } = ctx.guard; + + ctx.body = await emailTemplatesQueries.upsertMany( + body.templates.map((template) => ({ + id: generateStandardId(), + ...template, + })) + ); + + return next(); + } + ); + + router.delete( + `${pathPrefix}/:id`, + koaGuard({ + params: z.object({ + id: z.string(), + }), + status: [204, 404], + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + await emailTemplatesQueries.deleteById(id); + ctx.status = 204; + return next(); + } + ); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 2a341094c..d90bc39a0 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -30,6 +30,7 @@ import connectorRoutes from './connector/index.js'; import customPhraseRoutes from './custom-phrase.js'; import dashboardRoutes from './dashboard.js'; import domainRoutes from './domain.js'; +import emailTemplateRoutes from './email-template/index.js'; import experienceApiRoutes from './experience/index.js'; import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; @@ -103,6 +104,11 @@ const createRouters = (tenant: TenantContext) => { accountCentersRoutes(managementRouter, tenant); samlApplicationRoutes(managementRouter, tenant); + // TODO: @simeng remove this condition after the feature is enabled in production + if (EnvSet.values.isDevFeaturesEnabled) { + emailTemplateRoutes(managementRouter, tenant); + } + const anonymousRouter: AnonymousRouter = new Router(); const userRouter: UserRouter = new Router(); diff --git a/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json b/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json index 0411c73df..c2dd25a6b 100644 --- a/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json +++ b/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json @@ -1,10 +1,10 @@ { "tags": [ { - "name": "Dev feature" + "name": "Cloud only" }, { - "name": "Cloud only" + "name": "Dev feature" } ], "paths": { diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index 07f278784..0c79800ba 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -303,10 +303,8 @@ export default function singleSignOnConnectorsRoutes => { const entityId = `${camelcase(rootComponent)}Id`; + const shared = { in: 'path', description: `The unique identifier of the ${rootComponent diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index c81695560..119889fb5 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -34,6 +34,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js'; import { createVerificationStatusQueries } from '#src/queries/verification-status.js'; import { AccountCenterQueries } from '../queries/account-center.js'; +import EmailTemplatesQueries from '../queries/email-templates.js'; import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js'; import { VerificationRecordQueries } from '../queries/verification-records.js'; @@ -72,6 +73,7 @@ export default class Queries { verificationRecords = new VerificationRecordQueries(this.pool); accountCenters = new AccountCenterQueries(this.pool); tenants = createTenantQueries(this.pool); + emailTemplates = new EmailTemplatesQueries(this.pool, this.wellKnownCache); constructor( public readonly pool: CommonQueryMethods, diff --git a/packages/integration-tests/src/__mocks__/email-templates.ts b/packages/integration-tests/src/__mocks__/email-templates.ts new file mode 100644 index 000000000..161fca2d1 --- /dev/null +++ b/packages/integration-tests/src/__mocks__/email-templates.ts @@ -0,0 +1,31 @@ +import { type CreateEmailTemplate, TemplateType } from '@logto/schemas'; + +export const mockEmailTemplates: Array> = [ + { + languageTag: 'en', + templateType: TemplateType.SignIn, + details: { + subject: 'Sign In', + content: 'Sign in to your account', + contentType: 'text/html', + }, + }, + { + languageTag: 'en', + templateType: TemplateType.Register, + details: { + subject: 'Register', + content: 'Register for an account', + contentType: 'text/html', + }, + }, + { + languageTag: 'de', + templateType: TemplateType.SignIn, + details: { + subject: 'Sign In', + content: 'Sign in to your account', + contentType: 'text/plain', + }, + }, +]; diff --git a/packages/integration-tests/src/api/email-templates.ts b/packages/integration-tests/src/api/email-templates.ts new file mode 100644 index 000000000..a9b24771d --- /dev/null +++ b/packages/integration-tests/src/api/email-templates.ts @@ -0,0 +1,15 @@ +import { type CreateEmailTemplate, type EmailTemplate } from '@logto/schemas'; + +import { authedAdminApi } from './index.js'; + +const path = 'email-templates'; + +export class EmailTemplatesApi { + async create(templates: Array>): Promise { + return authedAdminApi.put(path, { json: { templates } }).json(); + } + + async delete(id: string): Promise { + await authedAdminApi.delete(`${path}/${id}`); + } +} diff --git a/packages/integration-tests/src/helpers/email-templates.ts b/packages/integration-tests/src/helpers/email-templates.ts new file mode 100644 index 000000000..79e030ba3 --- /dev/null +++ b/packages/integration-tests/src/helpers/email-templates.ts @@ -0,0 +1,20 @@ +import { type CreateEmailTemplate, type EmailTemplate } from '@logto/schemas'; + +import { EmailTemplatesApi } from '#src/api/email-templates.js'; + +export class EmailTemplatesApiTest extends EmailTemplatesApi { + #emailTemplates: EmailTemplate[] = []; + + override async create( + templates: Array> + ): Promise { + const created = await super.create(templates); + this.#emailTemplates.concat(created); + return created; + } + + async cleanUp(): Promise { + await Promise.all(this.#emailTemplates.map(async (template) => this.delete(template.id))); + this.#emailTemplates = []; + } +} diff --git a/packages/integration-tests/src/tests/api/email-templates.test.ts b/packages/integration-tests/src/tests/api/email-templates.test.ts new file mode 100644 index 000000000..057de5752 --- /dev/null +++ b/packages/integration-tests/src/tests/api/email-templates.test.ts @@ -0,0 +1,38 @@ +import { mockEmailTemplates } from '#src/__mocks__/email-templates.js'; +import { EmailTemplatesApiTest } from '#src/helpers/email-templates.js'; +import { devFeatureTest } from '#src/utils.js'; + +devFeatureTest.describe('email templates', () => { + const emailTemplatesApi = new EmailTemplatesApiTest(); + + afterEach(async () => { + await emailTemplatesApi.cleanUp(); + }); + + it('should create email templates successfully', async () => { + const created = await emailTemplatesApi.create(mockEmailTemplates); + expect(created).toHaveLength(mockEmailTemplates.length); + }); + + it('should update existing email template details for specified language and type', async () => { + const updatedTemplates: typeof mockEmailTemplates = mockEmailTemplates.map( + ({ details, ...rest }) => ({ + ...rest, + details: { + subject: `${details.subject} updated`, + content: `${details.content} updated`, + }, + }) + ); + + await emailTemplatesApi.create(mockEmailTemplates); + const created = await emailTemplatesApi.create(updatedTemplates); + + expect(created).toHaveLength(3); + + for (const [index, template] of created.entries()) { + expect(template.details.subject).toBe(updatedTemplates[index]!.details.subject); + expect(template.details.content).toBe(updatedTemplates[index]!.details.content); + } + }); +});