diff --git a/apps/api/package.json b/apps/api/package.json index bdaf08bca3..9ef2ae6a16 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -52,6 +52,7 @@ "@langfuse/core": "4.2.0", "@langfuse/otel": "4.2.0", "@langfuse/tracing": "4.2.0", + "@maily-to/render": "0.2.3", "@microsoft/microsoft-graph-client": "3.0.7", "@nestjs/common": "10.4.17", "@nestjs/config": "3.2.3", @@ -113,6 +114,7 @@ "load-esm": "1.0.3", "lodash": "4.17.21", "mammoth": "1.10.0", + "marked": "18.0.5", "mime-types": "3.0.2", "multer": "2.0.2", "nanoid": "3.3.7", @@ -133,6 +135,7 @@ "redis": "4.7.0", "reflect-metadata": "0.2.0", "rxjs": "7.8.1", + "sanitize-html": "2.17.5", "sharp": "0.34.5", "slugify": "1.6.6", "socket.io": "4.8.1", @@ -166,6 +169,7 @@ "@types/passport-jwt": "4.0.1", "@types/passport-local": "1.0.38", "@types/passport-microsoft": "2.1.0", + "@types/sanitize-html": "2.16.1", "@types/supertest": "6.0.2", "@types/unzipper": "0.10.11", "@types/uuid": "10.0.0", diff --git a/apps/api/src/announcements/handlers/announcement-email.handler.ts b/apps/api/src/announcements/handlers/announcement-email.handler.ts index 16feef19da..ca5dde6727 100644 --- a/apps/api/src/announcements/handlers/announcement-email.handler.ts +++ b/apps/api/src/announcements/handlers/announcement-email.handler.ts @@ -64,7 +64,7 @@ export class AnnouncementEmailHandler implements IEventHandler { const user = await this.userService.getUserById(userId); - const { createToken, emailTemplate } = await this.generateNewTokenAndEmail(userId); + const { createToken, emailContent } = await this.generateNewTokenAndEmail(userId); await this.sendEmailAndUpdateDatabase( user.tenantId, @@ -726,7 +726,7 @@ export class AuthService { email, oldTokenHash, createToken, - emailTemplate, + emailContent, expiryDate, reminderCount + 1, ); @@ -872,7 +872,7 @@ export class AuthService { ...defaultEmailSettings, }); - const { html, text } = magicLinkEmail; + const [text, html] = await Promise.all([magicLinkEmail.text, magicLinkEmail.html]); await this.emailService.sendEmailWithLogo( { diff --git a/apps/api/src/certificates/handlers/certificate-email.handler.ts b/apps/api/src/certificates/handlers/certificate-email.handler.ts index 0f22a0fa93..8d472bae58 100644 --- a/apps/api/src/certificates/handlers/certificate-email.handler.ts +++ b/apps/api/src/certificates/handlers/certificate-email.handler.ts @@ -14,8 +14,7 @@ import type { CertificateEmailRecipient } from "src/events/certificate/certifica import type { CertificateExpirationWarningEmailRecipient } from "src/events/certificate/certificate-expiration-warning-email.event"; type CertificateEmailEventType = - | CertificateExpirationWarningEmailEvent - | CertificateArchivedEmailEvent; + CertificateExpirationWarningEmailEvent | CertificateArchivedEmailEvent; const CertificateEmailEvents = [ CertificateExpirationWarningEmailEvent, @@ -56,12 +55,13 @@ export class CertificateEmailHandler implements IEventHandler { + const emailAdapter = { + sendMail: jest.fn(), + }; + const settingsService = { + getPlatformLogoBuffer: jest.fn().mockResolvedValue(Buffer.from("logo")), + getEmailBorderCircleBuffer: jest.fn().mockResolvedValue(Buffer.from("border")), + }; + const tenantRunner = { + runWithTenant: jest.fn((_tenantId: string, fn: () => Promise) => fn()), + }; + const configService = { + get: jest.fn((key: string) => { + if (key === "email.SMTP_EMAIL_FROM") return "noreply@example.com"; + if (key === "email.EMAIL_ADAPTER") return adapter; + return undefined; + }), + }; + + const service = new EmailService( + {} as never, + emailAdapter as never, + settingsService as never, + tenantRunner as never, + configService as never, + ); + + return { service, emailAdapter, tenantRunner }; +}; + +describe("EmailService", () => { + it("adds inline content ids for Mailhog mailbox rendering", async () => { + const { service, emailAdapter, tenantRunner } = makeService(); + + await service.sendEmailWithLogo( + { + to: "learner@example.com", + subject: "Subject", + html: `logo`, + }, + { tenantId: TENANT_ID }, + ); + + expect(tenantRunner.runWithTenant).toHaveBeenCalledWith(TENANT_ID, expect.any(Function)); + expect(emailAdapter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + from: "noreply@example.com", + attachments: [ + expect.objectContaining({ + filename: "logo.png", + cid: TENANT_LOGO_CID, + contentType: "image/png", + }), + expect.objectContaining({ + filename: "border-circle.png", + cid: BORDER_CIRCLE_CID, + contentType: "image/png", + }), + ], + }), + ); + }); + + it.each(["smtp", "ses"] as const)("adds inline content ids for %s", async (adapter) => { + const { service, emailAdapter } = makeService(adapter); + + await service.sendEmailWithLogo( + { + to: "learner@example.com", + subject: "Subject", + html: `logo`, + }, + { tenantId: TENANT_ID }, + ); + + const payload = emailAdapter.sendMail.mock.calls[0][0]; + expect(payload.attachments).toEqual([ + expect.objectContaining({ + filename: "logo.png", + cid: TENANT_LOGO_CID, + contentType: "image/png", + }), + expect.objectContaining({ + filename: "border-circle.png", + cid: BORDER_CIRCLE_CID, + contentType: "image/png", + }), + ]); + }); +}); diff --git a/apps/api/src/common/emails/emails.service.ts b/apps/api/src/common/emails/emails.service.ts index a1cb3dd9ae..33fc8a28db 100644 --- a/apps/api/src/common/emails/emails.service.ts +++ b/apps/api/src/common/emails/emails.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { SUPPORTED_LANGUAGES } from "@repo/shared"; +import { DEFAULT_TENANT_PRIMARY_COLOR, SUPPORTED_LANGUAGES, TENANT_LOGO_CID } from "@repo/shared"; import { sql } from "drizzle-orm"; import { DatabasePg } from "src/common"; @@ -19,7 +19,6 @@ import type { DefaultEmailSettings } from "src/events/types"; @Injectable() export class EmailService { - private readonly usingMailhogAdapter: boolean; private readonly fromEmail: string; constructor( @@ -29,10 +28,6 @@ export class EmailService { private readonly tenantRunner: TenantDbRunnerService, private configService: ConfigService, ) { - this.usingMailhogAdapter = - this.configService.get("email.EMAIL_ADAPTER") === - "mailhog"; - this.fromEmail = this.configService.get( "email.SMTP_EMAIL_FROM", ) as string; @@ -61,7 +56,7 @@ export class EmailService { filename: "logo.png", content: logoBuffer, contentType: "image/png", - ...(this.usingMailhogAdapter ? {} : { cid: "logo" }), + cid: TENANT_LOGO_CID, }); } @@ -70,15 +65,28 @@ export class EmailService { filename: "border-circle.png", content: borderCircleBuffer, contentType: "image/png", - ...(this.usingMailhogAdapter ? {} : { cid: "border-circle" }), + cid: "border-circle", }); } - const payload = { - ...(email as Email), + const baseEmail = { + to: email.to, + subject: email.subject, from: this.fromEmail, attachments: attachments.length > 0 ? attachments : undefined, }; + + let payload: Email; + if (email.text !== undefined && email.html !== undefined) { + payload = { ...baseEmail, text: email.text, html: email.html }; + } else if (email.text !== undefined) { + payload = { ...baseEmail, text: email.text }; + } else if (email.html !== undefined) { + payload = { ...baseEmail, html: email.html }; + } else { + throw new Error("Email content is missing"); + } + await this.emailAdapter.sendMail(payload); } @@ -92,7 +100,7 @@ export class EmailService { const companyName = globalSettings.companyInformation?.companyName || "Mentingo.com"; return { - primaryColor: globalSettings.primaryColor || "#4796FD", + primaryColor: globalSettings.primaryColor || DEFAULT_TENANT_PRIMARY_COLOR, companyName, language: language ?? (userId ? await this.getFinalLanguage(userId) : SUPPORTED_LANGUAGES.EN), @@ -106,7 +114,7 @@ export class EmailService { 'language', ${userSettingsColumn}->>'language', 'primaryColor', - COALESCE(NULLIF(${globalSettingsColumn}->>'primaryColor', ''), '#4796FD'), + COALESCE(NULLIF(${globalSettingsColumn}->>'primaryColor', ''), ${DEFAULT_TENANT_PRIMARY_COLOR}), 'companyName', COALESCE(NULLIF(${globalSettingsColumn} #>> '{companyInformation,companyName}', ''), 'Mentingo.com') ) diff --git a/apps/api/src/common/utils/postgresErrors.ts b/apps/api/src/common/utils/postgresErrors.ts new file mode 100644 index 0000000000..754c695bf7 --- /dev/null +++ b/apps/api/src/common/utils/postgresErrors.ts @@ -0,0 +1,18 @@ +const PG_UNIQUE_VIOLATION = "23505"; + +type PostgresErrorLike = { + code?: string; + constraint_name?: string; + constraint?: string; +}; + +export function isPostgresUniqueViolation(err: unknown, constraintName: string): boolean { + if (!err || typeof err !== "object") return false; + + const { code, constraint_name, constraint } = err as PostgresErrorLike; + + return ( + code === PG_UNIQUE_VIOLATION && + (constraint_name === constraintName || constraint === constraintName) + ); +} diff --git a/apps/api/src/course-chat/handlers/course-chat-mention-email.handler.ts b/apps/api/src/course-chat/handlers/course-chat-mention-email.handler.ts index 4ee4896926..bf7b33baca 100644 --- a/apps/api/src/course-chat/handlers/course-chat-mention-email.handler.ts +++ b/apps/api/src/course-chat/handlers/course-chat-mention-email.handler.ts @@ -35,9 +35,7 @@ const CourseChatMentionEmailEvents = [CourseChatUserMentionedEvent] as const; @Injectable() @EventsHandler(...CourseChatMentionEmailEvents) -export class CourseChatMentionEmailHandler - implements IEventHandler -{ +export class CourseChatMentionEmailHandler implements IEventHandler { constructor( private readonly courseChatRepository: CourseChatRepository, private readonly announcementRepository: AnnouncementsRepository, @@ -108,7 +106,7 @@ export class CourseChatMentionEmailHandler if (!courseContext) return; const authorName = `${message.userFirstName} ${message.userLastName}`; - const { text, html } = new BaseEmailTemplate({ + const emailTemplate = new BaseEmailTemplate({ heading: getCourseChatMentionEmailHeading(defaultEmailSettings.language), paragraphs: getCourseChatMentionEmailParagraphs(defaultEmailSettings.language, { recipientName: recipient.firstName, @@ -120,6 +118,7 @@ export class CourseChatMentionEmailHandler buttonLink: `${tenantOrigin}/course/${courseId}?tab=Discussion`, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { diff --git a/apps/api/src/courses/course.service.ts b/apps/api/src/courses/course.service.ts index d5086b9382..55bc67321c 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -4841,11 +4841,12 @@ export class CourseService { if (!coursesForLanguage?.length) return; - const { text, html } = new OverdueCoursesEmail({ + const emailTemplate = new OverdueCoursesEmail({ courses: coursesForLanguage, coursesLink: this.buildAdminCoursesUrl(tenantHost), ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); return this.emailService.sendEmailWithLogo( { diff --git a/apps/api/src/courses/handlers/course-due-date-reminder-email.handler.ts b/apps/api/src/courses/handlers/course-due-date-reminder-email.handler.ts index d3308d894e..f5e19d42be 100644 --- a/apps/api/src/courses/handlers/course-due-date-reminder-email.handler.ts +++ b/apps/api/src/courses/handlers/course-due-date-reminder-email.handler.ts @@ -23,9 +23,7 @@ import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service"; import type { CourseDueDateReminderRecipient } from "../types/course-due-date-reminder.types"; @EventsHandler(CourseDueDateReminderEmailEvent) -export class CourseDueDateReminderEmailHandler - implements IEventHandler -{ +export class CourseDueDateReminderEmailHandler implements IEventHandler { private readonly logger = new Logger(CourseDueDateReminderEmailHandler.name); constructor( @@ -60,13 +58,14 @@ export class CourseDueDateReminderEmailHandler private async sendCourseDueDateReminderEmail(recipient: CourseDueDateReminderRecipient) { const formattedDueDate = format(new Date(recipient.dueDate), "dd.MM.yyyy"); - const { text, html } = new CourseDueDateReminderEmail({ + const emailTemplate = new CourseDueDateReminderEmail({ courseName: recipient.courseName, courseLink: `${recipient.tenantHost.replace(/\/$/, "")}/course/${recipient.courseId}`, dueDate: formattedDueDate, daysBeforeDueDate: recipient.daysBeforeDueDate, ...recipient.defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { diff --git a/apps/api/src/courses/public-course-thumbnail.controller.ts b/apps/api/src/courses/public-course-thumbnail.controller.ts new file mode 100644 index 0000000000..de7162ce45 --- /dev/null +++ b/apps/api/src/courses/public-course-thumbnail.controller.ts @@ -0,0 +1,47 @@ +import { Controller, ForbiddenException, Get, Param, Req, Res } from "@nestjs/common"; +import { Request, Response } from "express"; +import { Validate } from "nestjs-typebox"; + +import { UUIDSchema, UUIDType } from "src/common"; +import { Public } from "src/common/decorators/public.decorator"; +import { TenantResolverService } from "src/storage/db/tenant-resolver.service"; + +import { PublicCourseThumbnailService } from "./public-course-thumbnail.service"; + +const PLACEHOLDER_SVG = `Course thumbnail`; + +const REDIRECT_CACHE_MAX_AGE_SECONDS = 1800; + +@Controller("public/course-thumbnail") +export class PublicCourseThumbnailController { + constructor( + private readonly publicCourseThumbnailService: PublicCourseThumbnailService, + private readonly tenantResolver: TenantResolverService, + ) {} + + @Get(":courseId") + @Public() + @Validate({ + request: [{ type: "param", name: "courseId", schema: UUIDSchema }], + }) + async getThumbnail( + @Param("courseId") courseId: UUIDType, + @Req() req: Request, + @Res() res: Response, + ) { + const tenantId = await this.tenantResolver.resolveTenantId(req); + if (!tenantId) throw new ForbiddenException("tenant.error.unresolved"); + + const url = await this.publicCourseThumbnailService.resolveSignedUrl(courseId, tenantId); + + if (!url) { + res.setHeader("Content-Type", "image/svg+xml"); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(PLACEHOLDER_SVG); + return; + } + + res.setHeader("Cache-Control", `public, max-age=${REDIRECT_CACHE_MAX_AGE_SECONDS}`); + res.redirect(302, url); + } +} diff --git a/apps/api/src/courses/public-course-thumbnail.module.ts b/apps/api/src/courses/public-course-thumbnail.module.ts new file mode 100644 index 0000000000..34b9e6d0ba --- /dev/null +++ b/apps/api/src/courses/public-course-thumbnail.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; + +import { FileModule } from "src/file/files.module"; + +import { PublicCourseThumbnailController } from "./public-course-thumbnail.controller"; +import { PublicCourseThumbnailService } from "./public-course-thumbnail.service"; + +@Module({ + imports: [FileModule], + controllers: [PublicCourseThumbnailController], + providers: [PublicCourseThumbnailService], +}) +export class PublicCourseThumbnailModule {} diff --git a/apps/api/src/courses/public-course-thumbnail.service.ts b/apps/api/src/courses/public-course-thumbnail.service.ts new file mode 100644 index 0000000000..4cd30bff98 --- /dev/null +++ b/apps/api/src/courses/public-course-thumbnail.service.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { eq } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { FileService } from "src/file/file.service"; +import { DB } from "src/storage/db/db.providers"; +import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service"; +import { courses } from "src/storage/schema"; + +import type { UUIDType } from "src/common"; + +@Injectable() +export class PublicCourseThumbnailService { + constructor( + @Inject(DB) private readonly db: DatabasePg, + private readonly fileService: FileService, + private readonly tenantRunner: TenantDbRunnerService, + ) {} + + async resolveSignedUrl(courseId: UUIDType, tenantId: UUIDType): Promise { + const course = await this.tenantRunner.runWithTenant(tenantId, async () => { + const [row] = await this.db + .select({ thumbnailS3Key: courses.thumbnailS3Key }) + .from(courses) + .where(eq(courses.id, courseId)) + .limit(1); + + return row; + }); + + if (!course) return null; + + if (!course.thumbnailS3Key) return null; + + return this.fileService.getFileUrl(course.thumbnailS3Key); + } +} diff --git a/apps/api/src/email-notification-templates/__tests__/assertSafeBlockUrls.spec.ts b/apps/api/src/email-notification-templates/__tests__/assertSafeBlockUrls.spec.ts new file mode 100644 index 0000000000..b4aa60f0ed --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/assertSafeBlockUrls.spec.ts @@ -0,0 +1,105 @@ +import { BadRequestException } from "@nestjs/common"; +import { TENANT_LOGO_VARIABLE } from "@repo/shared"; + +import { assertSafeBlockUrls } from "../utils/assertSafeBlockUrls"; + +import type { EmailTemplateBlocks } from "@repo/shared"; + +const doc = (content: EmailTemplateBlocks["content"]): EmailTemplateBlocks => ({ + type: "doc", + content, +}); + +const image = (src: string): EmailTemplateBlocks => ({ + type: "image", + attrs: { src }, +}); + +const button = (url: string): EmailTemplateBlocks => ({ + type: "button", + attrs: { url }, +}); + +const textWithLink = (href: string): EmailTemplateBlocks => ({ + type: "text", + text: "click", + marks: [{ type: "link", attrs: { href } }], +}); + +describe("assertSafeBlockUrls", () => { + it("allows http: image src", () => { + expect(() => assertSafeBlockUrls(doc([image("http://example.com/img.png")]))).not.toThrow(); + }); + + it("allows https: image src", () => { + expect(() => + assertSafeBlockUrls(doc([image("https://cdn.example.com/img.webp")])), + ).not.toThrow(); + }); + + it("allows mailto: link href", () => { + expect(() => assertSafeBlockUrls(doc([textWithLink("mailto:user@example.com")]))).not.toThrow(); + }); + + it("allows empty draft button urls", () => { + expect(() => assertSafeBlockUrls(doc([button("")]))).not.toThrow(); + }); + + it("allows whitespace-only draft button urls", () => { + expect(() => assertSafeBlockUrls(doc([button(" ")]))).not.toThrow(); + }); + + it("allows empty draft link hrefs", () => { + expect(() => assertSafeBlockUrls(doc([textWithLink("")]))).not.toThrow(); + }); + + it("allows relative image src starting with /", () => { + expect(() => + assertSafeBlockUrls(doc([image("/api/public/email-template-image/key.webp")])), + ).not.toThrow(); + }); + + it("rejects javascript: image src", () => { + expect(() => assertSafeBlockUrls(doc([image("javascript:alert(1)")]))).toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + }); + + it("rejects data: image src", () => { + expect(() => assertSafeBlockUrls(doc([image("data:image/png;base64,abc")]))).toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + }); + + it("rejects vbscript: link href", () => { + expect(() => assertSafeBlockUrls(doc([textWithLink("vbscript:msgbox(1)")]))).toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + }); + + it("rejects non-slash relative src", () => { + expect(() => assertSafeBlockUrls(doc([image("foo/bar.png")]))).toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + }); + + it("allows button with safe https: url", () => { + expect(() => assertSafeBlockUrls(doc([button("https://example.com")]))).not.toThrow(); + }); + + it("rejects button with javascript: url", () => { + expect(() => assertSafeBlockUrls(doc([button("javascript:alert(1)")]))).toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + }); + + it("allows the tenant logo placeholder as image src", () => { + expect(() => assertSafeBlockUrls(doc([image(TENANT_LOGO_VARIABLE)]))).not.toThrow(); + }); + + it("rejects arbitrary template placeholders in image src", () => { + expect(() => assertSafeBlockUrls(doc([image("{{user.avatar_url}}")]))).toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + }); +}); diff --git a/apps/api/src/email-notification-templates/__tests__/email-notification-templates.service.spec.ts b/apps/api/src/email-notification-templates/__tests__/email-notification-templates.service.spec.ts new file mode 100644 index 0000000000..a200adf095 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/email-notification-templates.service.spec.ts @@ -0,0 +1,926 @@ +import { BadRequestException, ConflictException, Logger } from "@nestjs/common"; +import { + DEFAULT_TENANT_PRIMARY_COLOR, + EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + EMAIL_TEMPLATE_STATUSES, + SUPPORTED_LANGUAGES, + TENANT_LOGO_CID_SRC, +} from "@repo/shared"; + +const mockRenderTemplateContent = jest.fn(); +jest.mock("../utils/renderTemplateContent", () => ({ + renderTemplateContent: (...args: unknown[]) => mockRenderTemplateContent(...args), +})); + +import { EmailNotificationTemplatesService } from "../email-templates.service"; +import { buildDefaultEmailTemplateBlocks } from "../utils/buildDefaultEmailTemplateBlocks"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; +import type { CurrentUserType } from "src/common/types/current-user.type"; + +const EN = SUPPORTED_LANGUAGES.EN; +const PL = SUPPORTED_LANGUAGES.PL; +const TEMPLATE_ID = "11111111-1111-1111-1111-111111111111"; +const TENANT_ID = "22222222-2222-2222-2222-222222222222"; +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; +const NAME_INDEX = "email_notification_templates_tenant_id_name_unique_idx"; + +const uniqueNameViolation = (overrides?: Record) => + Object.assign(new Error(`duplicate key value violates unique constraint "${NAME_INDEX}"`), { + code: "23505", + constraint_name: NAME_INDEX, + ...overrides, + }); + +const makeCurrentUser = (overrides?: Partial): CurrentUserType => ({ + userId: "33333333-3333-3333-3333-333333333333", + email: "admin@example.com", + roleSlugs: ["admin"], + permissions: [], + tenantId: TENANT_ID, + ...overrides, +}); + +const makeBlocks = (): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid1 }, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "Hello" }], + }, + ], +}); + +const linkedText = (href: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, + text: "linked", + marks: [{ type: "link", attrs: { href } }], +}); + +const imageBlocks = (src: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { src }, + }, + ], +}); + +const makeTemplate = (overrides?: Record) => ({ + id: TEMPLATE_ID, + name: "My Template", + status: EMAIL_TEMPLATE_STATUSES.DRAFT, + baseLanguage: EN, + availableLocales: [EN, PL], + subject: { [EN]: "Subject", [PL]: "Temat" }, + blocks: makeBlocks(), + strings: {} as EmailTemplateStrings, + archivedAt: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + tenantId: TENANT_ID, + ...overrides, +}); + +const fn = () => jest.fn() as jest.Mock; + +const makeRepository = () => { + const r = { + listTemplates: fn(), + createTemplate: fn(), + findById: fn(), + updateTemplate: fn(), + setStatus: fn(), + deleteTemplate: fn(), + deleteManyTemplates: fn(), + findByName: fn(), + findExistingNames: fn(), + findBlocksByIds: fn(), + duplicateFrom: fn(), + findTemplateBlocks: fn(), + findAutoTemplateNames: fn(), + }; + return r; +}; + +const makeFileService = () => ({ deleteFile: fn() }); +const makeEmailService = () => { + const service = { + sendEmailWithLogo: fn(), + getDefaultEmailProperties: fn(), + }; + service.getDefaultEmailProperties.mockResolvedValue({ + primaryColor: DEFAULT_TENANT_PRIMARY_COLOR, + companyName: "Mentingo.com", + language: EN, + }); + return service; +}; +const makeSettingsService = () => ({ + getPlatformLogoUrl: fn(), +}); + +const makeCleanupQueue = () => ({ + enqueueImageCleanup: fn(), +}); + +const createService = () => { + const repository = makeRepository(); + const fileService = makeFileService(); + const emailService = makeEmailService(); + const settingsService = makeSettingsService(); + const cleanupQueue = makeCleanupQueue(); + cleanupQueue.enqueueImageCleanup.mockResolvedValue(undefined); + const service = new EmailNotificationTemplatesService( + repository as never, + fileService as never, + emailService as never, + settingsService as never, + cleanupQueue as never, + ); + return { service, repository, fileService, emailService, settingsService, cleanupQueue }; +}; + +describe("EmailNotificationTemplatesService — validateLocales", () => { + it("throws BadRequestException for an unknown locale", async () => { + const { service, repository } = createService(); + repository.findByName.mockResolvedValue(undefined); + + await expect( + service.createTemplate({ + name: "T", + baseLanguage: EN, + availableLocales: ["xx" as never], + subject: { [EN]: "S" }, + blocks: makeBlocks(), + strings: {}, + }), + ).rejects.toThrow(new BadRequestException("emailTemplates.toast.invalidLocale")); + }); + + it("throws BadRequestException for duplicate locales", async () => { + const { service, repository } = createService(); + repository.findByName.mockResolvedValue(undefined); + + await expect( + service.createTemplate({ + name: "T", + baseLanguage: EN, + availableLocales: [EN, EN], + subject: { [EN]: "S" }, + blocks: makeBlocks(), + strings: {}, + }), + ).rejects.toThrow(new BadRequestException("emailTemplates.toast.duplicateLocales")); + }); + + it("throws BadRequestException when baseLanguage is not in availableLocales", async () => { + const { service, repository } = createService(); + repository.findByName.mockResolvedValue(undefined); + + await expect( + service.createTemplate({ + name: "T", + baseLanguage: EN, + availableLocales: [PL], + subject: { [EN]: "S" }, + blocks: makeBlocks(), + strings: {}, + }), + ).rejects.toThrow(new BadRequestException("emailTemplates.toast.baseLanguageMissing")); + }); +}); + +describe("EmailNotificationTemplatesService — ensureNameAvailable", () => { + it("throws ConflictException when name already exists", async () => { + const { service, repository } = createService(); + repository.findByName.mockResolvedValue({ id: "existing-id" }); + + await expect( + service.createTemplate({ + name: "My Template", + baseLanguage: EN, + availableLocales: [EN], + subject: { [EN]: "S" }, + blocks: makeBlocks(), + strings: {}, + }), + ).rejects.toThrow(new ConflictException("emailTemplates.toast.nameAlreadyExists")); + }); + + it("throws ConflictException when create hits a duplicate-name race", async () => { + const { service, repository } = createService(); + repository.findByName.mockResolvedValue(undefined); + repository.createTemplate.mockRejectedValue(uniqueNameViolation()); + + await expect( + service.createTemplate({ + name: "My Template", + baseLanguage: EN, + availableLocales: [EN], + subject: { [EN]: "S" }, + blocks: makeBlocks(), + strings: {}, + }), + ).rejects.toThrow(new ConflictException("emailTemplates.toast.nameAlreadyExists")); + }); +}); + +describe("EmailNotificationTemplatesService — rendered URL validation", () => { + it("allows saving freshly created default draft blocks with an empty button url", async () => { + const { service, repository } = createService(); + const blocks = buildDefaultEmailTemplateBlocks(EN); + const existing = makeTemplate({ blocks, strings: {} }); + repository.findById.mockResolvedValue(existing); + repository.updateTemplate.mockResolvedValue(existing); + + await expect( + service.updateTemplate( + TEMPLATE_ID, + { + blocks, + strings: {}, + }, + TENANT_ID, + ), + ).resolves.toBe(existing); + expect(repository.updateTemplate).toHaveBeenCalled(); + }); + + it("rejects unsafe hrefs introduced by translated strings on create", async () => { + const { service, repository } = createService(); + + await expect( + service.createTemplate({ + name: "Translated links", + baseLanguage: EN, + availableLocales: [EN, PL], + subject: { [EN]: "Subject", [PL]: "Temat" }, + blocks: makeBlocks(), + strings: { + [PL]: { [uuid1]: [linkedText("javascript:alert(1)")] }, + }, + }), + ).rejects.toThrow(new BadRequestException("emailTemplates.toast.invalidUrl")); + expect(repository.createTemplate).not.toHaveBeenCalled(); + }); + + it("rejects unsafe hrefs introduced by translated strings on update", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + + await expect( + service.updateTemplate( + TEMPLATE_ID, + { + strings: { + [PL]: { [uuid1]: [linkedText("javascript:alert(1)")] }, + }, + }, + TENANT_ID, + ), + ).rejects.toThrow(new BadRequestException("emailTemplates.toast.invalidUrl")); + expect(repository.updateTemplate).not.toHaveBeenCalled(); + }); +}); + +describe("EmailNotificationTemplatesService — auto-name on create", () => { + const uniqueViolation = () => uniqueNameViolation({ constraint_name: NAME_INDEX }); + + const autoNameInput = { + baseLanguage: EN, + availableLocales: [EN], + }; + + it("assigns 'Email template #' when name is omitted", async () => { + const { service, repository } = createService(); + repository.findAutoTemplateNames.mockResolvedValue(["Email template #2", "Email template #4"]); + repository.createTemplate.mockResolvedValue(makeTemplate({ name: "Email template #5" })); + + const result = await service.createTemplate(autoNameInput); + + expect(repository.createTemplate).toHaveBeenCalledWith( + expect.objectContaining({ name: "Email template #5" }), + ); + expect(result.name).toBe("Email template #5"); + }); + + it("ignores names that do not match the auto-name pattern", async () => { + const { service, repository } = createService(); + repository.findAutoTemplateNames.mockResolvedValue([ + "Email template #2", + "Email template #abc", + "Custom Email template #12", + ]); + repository.createTemplate.mockResolvedValue(makeTemplate({ name: "Email template #3" })); + + const result = await service.createTemplate(autoNameInput); + + expect(repository.createTemplate).toHaveBeenCalledWith( + expect.objectContaining({ name: "Email template #3" }), + ); + expect(result.name).toBe("Email template #3"); + }); + + it("rethrows unique-violation errors when no constraint field is present", async () => { + const { service, repository } = createService(); + repository.findAutoTemplateNames.mockResolvedValue(["Email template #4"]); + const err = uniqueNameViolation({ constraint_name: undefined, constraint: undefined }); + repository.createTemplate.mockRejectedValueOnce(err); + + await expect(service.createTemplate(autoNameInput)).rejects.toBe(err); + expect(repository.createTemplate).toHaveBeenCalledTimes(1); + }); + + it("throws ConflictException when the generated name conflicts", async () => { + const { service, repository } = createService(); + repository.findAutoTemplateNames.mockResolvedValue(["Email template #4"]); + repository.createTemplate.mockRejectedValue(uniqueViolation()); + + await expect(service.createTemplate(autoNameInput)).rejects.toThrow( + new ConflictException("emailTemplates.toast.nameAlreadyExists"), + ); + expect(repository.createTemplate).toHaveBeenCalledTimes(1); + }); + + it("rethrows non-unique errors without retrying", async () => { + const { service, repository } = createService(); + repository.findAutoTemplateNames.mockResolvedValue(["Email template #4"]); + const other = Object.assign(new Error("boom"), { code: "42P01" }); + repository.createTemplate.mockRejectedValue(other); + + await expect(service.createTemplate(autoNameInput)).rejects.toBe(other); + expect(repository.createTemplate).toHaveBeenCalledTimes(1); + }); +}); + +describe("EmailNotificationTemplatesService — duplicateTemplate / buildDuplicateName", () => { + it("builds 'Copy of X' when no collisions", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ name: "Alpha" })); + repository.findExistingNames.mockResolvedValue([]); + repository.duplicateFrom.mockResolvedValue(makeTemplate({ name: "Copy of Alpha" })); + + const result = await service.duplicateTemplate(TEMPLATE_ID); + + expect(repository.duplicateFrom).toHaveBeenCalledWith( + expect.objectContaining({ name: "Copy of Alpha" }), + ); + expect(result.name).toBe("Copy of Alpha"); + }); + + it("uses 'Copy of X (2)' when 'Copy of X' is taken", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ name: "Beta" })); + repository.findExistingNames.mockResolvedValue(["Copy of Beta"]); + repository.duplicateFrom.mockResolvedValue(makeTemplate({ name: "Copy of Beta (2)" })); + + await service.duplicateTemplate(TEMPLATE_ID); + + expect(repository.duplicateFrom).toHaveBeenCalledWith( + expect.objectContaining({ name: "Copy of Beta (2)" }), + ); + }); + + it("increments past (2) when (2) is also taken", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ name: "Gamma" })); + repository.findExistingNames.mockResolvedValue(["Copy of Gamma", "Copy of Gamma (2)"]); + repository.duplicateFrom.mockResolvedValue(makeTemplate({ name: "Copy of Gamma (3)" })); + + await service.duplicateTemplate(TEMPLATE_ID); + + expect(repository.duplicateFrom).toHaveBeenCalledWith( + expect.objectContaining({ name: "Copy of Gamma (3)" }), + ); + }); + + it("falls into while loop when all 20 candidates are taken", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ name: "Delta" })); + const base = "Copy of Delta"; + const allCandidates = [base, ...Array.from({ length: 20 }, (_, i) => `${base} (${i + 2})`)]; + repository.findExistingNames.mockResolvedValue(allCandidates); + repository.findByName.mockResolvedValueOnce({ id: "x" }).mockResolvedValueOnce(undefined); + repository.duplicateFrom.mockResolvedValue(makeTemplate({ name: `${base} (23)` })); + + await service.duplicateTemplate(TEMPLATE_ID); + + expect(repository.duplicateFrom).toHaveBeenCalledWith( + expect.objectContaining({ name: `${base} (23)` }), + ); + }); + + it("re-keys block uuids and remaps strings", async () => { + const { service, repository } = createService(); + const fragment = [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "Hello" }]; + repository.findById.mockResolvedValue( + makeTemplate({ + blocks: makeBlocks(), + strings: { [EN]: { [uuid1]: fragment } } as EmailTemplateStrings, + }), + ); + repository.findExistingNames.mockResolvedValue([]); + repository.duplicateFrom.mockResolvedValue(makeTemplate()); + + await service.duplicateTemplate(TEMPLATE_ID); + + const callArgs = (repository.duplicateFrom.mock.calls[0] as unknown[])[0] as { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + }; + expect(callArgs).toBeDefined(); + + const newUuid = callArgs.blocks.content?.[0]?.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR] as string; + expect(newUuid).toBeDefined(); + expect(newUuid).not.toBe(uuid1); + expect(callArgs.strings[EN]?.[newUuid]).toBeDefined(); + expect(callArgs.strings[EN]?.[uuid1]).toBeUndefined(); + }); +}); + +describe("EmailNotificationTemplatesService — previewTemplate", () => { + it("throws BadRequestException when requested language is not in availableLocales", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ availableLocales: [EN] })); + + await expect(service.previewTemplate(TEMPLATE_ID, TENANT_ID, PL)).rejects.toThrow( + new BadRequestException("emailTemplates.toast.previewLanguageUnavailable"), + ); + }); + + it("defaults to baseLanguage when no language is passed", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + mockRenderTemplateContent.mockResolvedValue({ language: EN, subject: "S", html: "" }); + + await service.previewTemplate(TEMPLATE_ID, TENANT_ID); + + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ language: EN }), + ); + }); + + it("passes the tenant primary color to renderTemplateContent", async () => { + const { service, repository, emailService, settingsService } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + settingsService.getPlatformLogoUrl.mockResolvedValue(null); + emailService.getDefaultEmailProperties.mockResolvedValue({ + primaryColor: "#ff00aa", + companyName: "Acme", + language: EN, + }); + mockRenderTemplateContent.mockResolvedValue({ language: EN, subject: "S", html: "" }); + + await service.previewTemplate(TEMPLATE_ID, TENANT_ID); + + expect(emailService.getDefaultEmailProperties).toHaveBeenCalledWith(TENANT_ID); + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ primaryColor: "#ff00aa" }), + ); + }); + + it("passes a tenant logo URL to renderTemplateContent when one exists", async () => { + const { service, repository, settingsService } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + settingsService.getPlatformLogoUrl.mockResolvedValue( + "/api/settings/platform-logo/image?v=logo", + ); + mockRenderTemplateContent.mockResolvedValue({ language: EN, subject: "S", html: "" }); + + await service.previewTemplate(TEMPLATE_ID, TENANT_ID); + + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ + tenantLogoSrc: "/api/settings/platform-logo/image?v=logo", + }), + ); + }); + + it("passes the default platform logo path to renderTemplateContent when the tenant has no logo", async () => { + const { service, repository, settingsService } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + settingsService.getPlatformLogoUrl.mockResolvedValue(null); + mockRenderTemplateContent.mockResolvedValue({ language: EN, subject: "S", html: "" }); + + await service.previewTemplate(TEMPLATE_ID, TENANT_ID); + + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ + tenantLogoSrc: "/app/assets/svgs/app-logo.svg", + }), + ); + }); +}); + +describe("EmailNotificationTemplatesService — sendTestEmail", () => { + it("throws BadRequestException when requested language is not in availableLocales", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ availableLocales: [EN] })); + + await expect(service.sendTestEmail(TEMPLATE_ID, makeCurrentUser(), PL)).rejects.toThrow( + new BadRequestException("emailTemplates.toast.previewLanguageUnavailable"), + ); + }); + + it("defaults to baseLanguage when no language is passed", async () => { + const { service, repository, emailService } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + mockRenderTemplateContent.mockResolvedValue({ + language: EN, + subject: "Subject", + html: "", + }); + emailService.sendEmailWithLogo.mockResolvedValue(undefined); + + await service.sendTestEmail(TEMPLATE_ID, makeCurrentUser()); + + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ language: EN }), + ); + }); + + it("calls emailService.sendEmailWithLogo with to, subject, html and tenantId", async () => { + const { service, repository, emailService } = createService(); + const currentUser = makeCurrentUser({ email: "test@example.com", tenantId: TENANT_ID }); + repository.findById.mockResolvedValue(makeTemplate()); + mockRenderTemplateContent.mockResolvedValue({ + language: EN, + subject: "My Subject", + html: "email", + }); + emailService.sendEmailWithLogo.mockResolvedValue(undefined); + + await service.sendTestEmail(TEMPLATE_ID, currentUser, EN); + + expect(emailService.sendEmailWithLogo).toHaveBeenCalledWith( + { to: "test@example.com", subject: "My Subject", html: "email" }, + { tenantId: TENANT_ID }, + ); + }); + + it("passes the tenant primary color to renderTemplateContent", async () => { + const { service, repository, emailService } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + emailService.getDefaultEmailProperties.mockResolvedValue({ + primaryColor: "#00aaff", + companyName: "Acme", + language: EN, + }); + mockRenderTemplateContent.mockResolvedValue({ + language: EN, + subject: "Subject", + html: "", + }); + emailService.sendEmailWithLogo.mockResolvedValue(undefined); + + await service.sendTestEmail(TEMPLATE_ID, makeCurrentUser()); + + expect(emailService.getDefaultEmailProperties).toHaveBeenCalledWith(TENANT_ID); + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ primaryColor: "#00aaff" }), + ); + }); + + it("renders test emails with the inline logo cid source", async () => { + const { service, repository, emailService } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + mockRenderTemplateContent.mockResolvedValue({ + language: EN, + subject: "Subject", + html: "", + }); + emailService.sendEmailWithLogo.mockResolvedValue(undefined); + + await service.sendTestEmail(TEMPLATE_ID, makeCurrentUser()); + + expect(mockRenderTemplateContent).toHaveBeenCalledWith( + expect.objectContaining({ tenantLogoSrc: TENANT_LOGO_CID_SRC }), + ); + }); +}); + +describe("EmailNotificationTemplatesService — updateTemplate", () => { + it("throws ConflictException when update hits a duplicate-name race", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + repository.findByName.mockResolvedValue(undefined); + repository.updateTemplate.mockRejectedValue(uniqueNameViolation()); + + await expect( + service.updateTemplate(TEMPLATE_ID, { name: "New name" }, TENANT_ID), + ).rejects.toThrow(new ConflictException("emailTemplates.toast.nameAlreadyExists")); + }); + + it("passes pruned strings to repository.updateTemplate", async () => { + const { service, repository } = createService(); + const orphanUuid = "bbbbbbbb-0000-4000-8000-000000000001"; + repository.findById.mockResolvedValue( + makeTemplate({ + strings: { + [EN]: { + [uuid1]: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "live" }], + [orphanUuid]: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "orphan" }], + }, + } as EmailTemplateStrings, + }), + ); + repository.findByName.mockResolvedValue(undefined); + repository.updateTemplate.mockResolvedValue(makeTemplate()); + + await service.updateTemplate(TEMPLATE_ID, {}, TENANT_ID); + + const updatedArg = (repository.updateTemplate.mock.calls[0] as unknown[])[1] as { + strings: EmailTemplateStrings; + }; + expect(updatedArg.strings[EN]?.[uuid1]).toBeDefined(); + expect(updatedArg.strings[EN]?.[orphanUuid]).toBeUndefined(); + }); + + it("updates blocks after an image is removed", async () => { + const { service, repository } = createService(); + const removedSrc = "/api/public/email-template-image/old-key.webp"; + const keptSrc = "/api/public/email-template-image/kept-key.webp"; + const oldBlocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, attrs: { src: removedSrc } }, + { type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, attrs: { src: keptSrc } }, + ], + }; + const newBlocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, attrs: { src: keptSrc } }], + }; + repository.findById.mockResolvedValue(makeTemplate({ blocks: oldBlocks })); + repository.updateTemplate.mockResolvedValue(makeTemplate({ blocks: newBlocks })); + + await service.updateTemplate(TEMPLATE_ID, { blocks: newBlocks as never }, TENANT_ID); + + expect(repository.updateTemplate).toHaveBeenCalledWith( + TEMPLATE_ID, + expect.objectContaining({ blocks: newBlocks }), + ); + }); + + it("does not fail the update when image cleanup enqueue fails after mutation", async () => { + const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + const { service, repository, cleanupQueue } = createService(); + const removedSrc = "/api/public/email-template-image/old-key.webp"; + const oldBlocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, attrs: { src: removedSrc } }], + }; + const newBlocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [], + }; + const updated = makeTemplate({ blocks: newBlocks }); + repository.findById.mockResolvedValue(makeTemplate({ blocks: oldBlocks })); + repository.updateTemplate.mockResolvedValue(updated); + cleanupQueue.enqueueImageCleanup.mockRejectedValue(new Error("redis unavailable")); + + await expect( + service.updateTemplate(TEMPLATE_ID, { blocks: newBlocks as never }, TENANT_ID), + ).resolves.toBe(updated); + + expect(cleanupQueue.enqueueImageCleanup).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + srcs: [removedSrc], + excludeTemplateId: TEMPLATE_ID, + }); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to enqueue email template image cleanup: redis unavailable", + ); + warnSpy.mockRestore(); + }); +}); + +describe("EmailNotificationTemplatesService — deleteTemplate", () => { + it("does not fail the delete when image cleanup enqueue fails after mutation", async () => { + const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + const { service, repository, cleanupQueue } = createService(); + const removedSrc = "/api/public/email-template-image/old-key.webp"; + const blocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, attrs: { src: removedSrc } }], + }; + repository.findById.mockResolvedValue(makeTemplate({ blocks })); + repository.deleteTemplate.mockResolvedValue(makeTemplate({ blocks })); + cleanupQueue.enqueueImageCleanup.mockRejectedValue(new Error("redis unavailable")); + + await expect(service.deleteTemplate(TEMPLATE_ID, TENANT_ID)).resolves.toBeUndefined(); + + expect(cleanupQueue.enqueueImageCleanup).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + srcs: [removedSrc], + excludeTemplateId: undefined, + }); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to enqueue email template image cleanup: redis unavailable", + ); + warnSpy.mockRestore(); + }); +}); + +describe("EmailNotificationTemplatesService — deleteManyTemplates", () => { + it("throws BadRequestException when ids array is empty", async () => { + const { service } = createService(); + + await expect(service.deleteManyTemplates([], TENANT_ID)).rejects.toThrow( + new BadRequestException("emailTemplates.toast.deleteFailed"), + ); + }); + + it("does not fail the delete when image cleanup enqueue fails after mutation", async () => { + const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + const { service, repository, cleanupQueue } = createService(); + const removedSrc = "/api/public/email-template-image/old-key.webp"; + const blocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, attrs: { src: removedSrc } }], + }; + repository.findBlocksByIds.mockResolvedValue([blocks]); + repository.deleteManyTemplates.mockResolvedValue([makeTemplate({ blocks })]); + cleanupQueue.enqueueImageCleanup.mockRejectedValue(new Error("redis unavailable")); + + await expect(service.deleteManyTemplates([TEMPLATE_ID], TENANT_ID)).resolves.toBeUndefined(); + + expect(cleanupQueue.enqueueImageCleanup).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + srcs: [removedSrc], + excludeTemplateId: undefined, + }); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to enqueue email template image cleanup: redis unavailable", + ); + warnSpy.mockRestore(); + }); +}); + +describe("EmailNotificationTemplatesService — purgeOrphanedImages", () => { + it("deletes unreferenced image keys and keeps referenced images", async () => { + const { service, repository, fileService } = createService(); + const removedSrc = `/api/public/email-template-image/${TENANT_ID}/email_template_image/old-key.webp`; + const keptSrc = `/api/public/email-template-image/${TENANT_ID}/email_template_image/kept-key.webp`; + const removedKey = `${TENANT_ID}/email_template_image/old-key.webp`; + const keptKey = `${TENANT_ID}/email_template_image/kept-key.webp`; + repository.findTemplateBlocks.mockResolvedValue([imageBlocks(keptSrc)]); + fileService.deleteFile.mockResolvedValue(undefined); + + await service.purgeOrphanedImages({ + tenantId: TENANT_ID, + srcs: [removedSrc, keptSrc, removedSrc], + excludeTemplateId: TEMPLATE_ID, + }); + + expect(repository.findTemplateBlocks).toHaveBeenCalledWith(TEMPLATE_ID); + expect(fileService.deleteFile).toHaveBeenCalledWith(removedKey); + expect(fileService.deleteFile).not.toHaveBeenCalledWith(keptKey); + }); + + it("does not delete extracted keys outside the current tenant email template image category", async () => { + const { service, repository, fileService } = createService(); + const safeSrc = `/api/public/email-template-image/${TENANT_ID}/email_template_image/current.webp`; + const differentTenantSrc = + "https://external.test/api/public/email-template-image/99999999-9999-9999-9999-999999999999/email_template_image/alien.webp"; + const differentCategorySrc = `https://external.test/api/public/email-template-image/${TENANT_ID}/course/course.webp`; + repository.findTemplateBlocks.mockResolvedValue([]); + fileService.deleteFile.mockResolvedValue(undefined); + + await service.purgeOrphanedImages({ + tenantId: TENANT_ID, + srcs: [safeSrc, differentTenantSrc, differentCategorySrc], + excludeTemplateId: TEMPLATE_ID, + }); + + expect(fileService.deleteFile).toHaveBeenCalledTimes(1); + expect(fileService.deleteFile).toHaveBeenCalledWith( + `${TENANT_ID}/email_template_image/current.webp`, + ); + }); + + it("keeps a same-tenant image when another template references its canonical key", async () => { + const { service, repository, fileService } = createService(); + const key = `${TENANT_ID}/email_template_image/current.webp`; + const craftedSrc = `https://external.test/api/public/email-template-image/${encodeURIComponent( + key, + )}`; + repository.findTemplateBlocks.mockResolvedValue([imageBlocks(craftedSrc)]); + fileService.deleteFile.mockResolvedValue(undefined); + + await service.purgeOrphanedImages({ + tenantId: TENANT_ID, + srcs: [craftedSrc], + excludeTemplateId: TEMPLATE_ID, + }); + + expect(repository.findTemplateBlocks).toHaveBeenCalledWith(TEMPLATE_ID); + expect(fileService.deleteFile).not.toHaveBeenCalled(); + }); +}); + +describe("EmailNotificationTemplatesService — status transitions", () => { + it("publishTemplate rejects templates with blocking diagnostics", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate({ subject: { [EN]: "" } })); + + await expect(service.publishTemplate(TEMPLATE_ID)).rejects.toThrow( + new BadRequestException("emailTemplates.toast.publishBlocked"), + ); + expect(repository.setStatus).not.toHaveBeenCalled(); + }); + + it("publishTemplate allows templates with a button missing its url warning", async () => { + const { service, repository } = createService(); + const template = makeTemplate({ + blocks: buildDefaultEmailTemplateBlocks(EN), + availableLocales: [EN], + strings: {}, + }); + repository.findById.mockResolvedValue(template); + repository.setStatus.mockResolvedValue({ + ...template, + status: EMAIL_TEMPLATE_STATUSES.PUBLISHED, + }); + + await expect(service.publishTemplate(TEMPLATE_ID)).resolves.toMatchObject({ + status: EMAIL_TEMPLATE_STATUSES.PUBLISHED, + }); + expect(repository.setStatus).toHaveBeenCalledWith( + TEMPLATE_ID, + EMAIL_TEMPLATE_STATUSES.PUBLISHED, + null, + ); + }); + + it("publishTemplate rejects unsafe hrefs introduced by translated strings", async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue( + makeTemplate({ + strings: { + [PL]: { [uuid1]: [linkedText("javascript:alert(1)")] }, + }, + }), + ); + + await expect(service.publishTemplate(TEMPLATE_ID)).rejects.toThrow( + new BadRequestException("emailTemplates.toast.invalidUrl"), + ); + expect(repository.setStatus).not.toHaveBeenCalled(); + }); + + const cases = [ + { + method: "publishTemplate" as const, + status: EMAIL_TEMPLATE_STATUSES.PUBLISHED, + archivedAtIsDate: false, + errorKey: "emailTemplates.toast.publishFailed", + }, + { + method: "makeDraftTemplate" as const, + status: EMAIL_TEMPLATE_STATUSES.DRAFT, + archivedAtIsDate: false, + errorKey: "emailTemplates.toast.makeDraftFailed", + }, + { + method: "archiveTemplate" as const, + status: EMAIL_TEMPLATE_STATUSES.ARCHIVED, + archivedAtIsDate: true, + errorKey: "emailTemplates.toast.archiveFailed", + }, + { + method: "unarchiveTemplate" as const, + status: EMAIL_TEMPLATE_STATUSES.DRAFT, + archivedAtIsDate: false, + errorKey: "emailTemplates.toast.unarchiveFailed", + }, + ] as const; + + for (const { method, status, archivedAtIsDate, errorKey } of cases) { + it(`${method} calls setStatus with status=${status}`, async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + repository.setStatus.mockResolvedValue(makeTemplate({ status })); + + await service[method](TEMPLATE_ID); + + expect(repository.setStatus).toHaveBeenCalledWith( + TEMPLATE_ID, + status, + archivedAtIsDate ? expect.any(String) : null, + ); + }); + + it(`${method} throws BadRequestException when repository returns null/undefined`, async () => { + const { service, repository } = createService(); + repository.findById.mockResolvedValue(makeTemplate()); + repository.setStatus.mockResolvedValue(undefined); + + await expect(service[method](TEMPLATE_ID)).rejects.toThrow(new BadRequestException(errorKey)); + }); + } +}); diff --git a/apps/api/src/email-notification-templates/__tests__/email-template-image.controller.e2e-spec.ts b/apps/api/src/email-notification-templates/__tests__/email-template-image.controller.e2e-spec.ts new file mode 100644 index 0000000000..58c4ec4272 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/email-template-image.controller.e2e-spec.ts @@ -0,0 +1,156 @@ +import { SYSTEM_ROLE_SLUGS } from "@repo/shared"; +import request from "supertest"; + +import { FileService } from "src/file/file.service"; +import { DB, DB_ADMIN } from "src/storage/db/db.providers"; + +import { createE2ETest } from "../../../test/create-e2e-test"; +import { createSettingsFactory } from "../../../test/factory/settings.factory"; +import { createUserFactory } from "../../../test/factory/user.factory"; +import { DEFAULT_TEST_TENANT_HOST } from "../../../test/helpers/tenant-helpers"; +import { cookieFor, truncateAllTables } from "../../../test/helpers/test-helpers"; + +import type { INestApplication } from "@nestjs/common"; +import type { DatabasePg } from "src/common"; + +jest.mock("load-esm", () => ({ + loadEsm: jest.fn(async () => ({ + fileTypeFromBuffer: async (buffer: Buffer) => { + if ( + buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return { mime: "image/png", ext: "png" }; + } + + if (buffer.subarray(0, 4).toString("ascii") === "%PDF") { + return { mime: "application/pdf", ext: "pdf" }; + } + + return undefined; + }, + })), +})); + +const validPngBuffer = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +]); + +const TENANT_HOST = DEFAULT_TEST_TENANT_HOST; + +describe("EmailTemplateImageController (e2e)", () => { + let app: INestApplication; + let db: DatabasePg; + let baseDb: DatabasePg; + let userFactory: ReturnType; + let settingsFactory: ReturnType; + const password = "Password123@@"; + + const mockFileService = { + uploadFile: jest.fn(), + }; + + beforeAll(async () => { + const { app: testApp } = await createE2ETest([ + { provide: FileService, useValue: mockFileService }, + ]); + app = testApp; + db = app.get(DB); + baseDb = app.get(DB_ADMIN); + userFactory = createUserFactory(db); + settingsFactory = createSettingsFactory(db); + }, 30000); + + afterAll(async () => { + await app.close(); + }, 10000); + + beforeEach(async () => { + jest.clearAllMocks(); + await settingsFactory.create({ userId: null }); + }); + + afterEach(async () => { + await truncateAllTables(baseDb, db); + }); + + describe("POST /api/email-notification-templates/images", () => { + it("returns 401 when not authenticated", async () => { + await request(app.getHttpServer()) + .post("/api/email-notification-templates/images") + .attach("file", validPngBuffer, { filename: "photo.png", contentType: "image/png" }) + .expect(401); + }); + + it("returns 403 when user lacks EMAIL_TEMPLATE_MANAGE permission", async () => { + const student = await userFactory + .withCredentials({ password }) + .withUserSettings(db) + .create({ role: SYSTEM_ROLE_SLUGS.STUDENT }); + + await request(app.getHttpServer()) + .post("/api/email-notification-templates/images") + .set("Cookie", await cookieFor(student, app)) + .attach("file", validPngBuffer, { filename: "photo.png", contentType: "image/png" }) + .expect(403); + }); + + it("returns proxy URL on authenticated PNG upload", async () => { + const admin = await userFactory + .withCredentials({ password }) + .withAdminSettings(db) + .create({ role: SYSTEM_ROLE_SLUGS.ADMIN }); + + const fileKey = `tenant-id/email_template_image/variants/uuid.webp`; + mockFileService.uploadFile.mockResolvedValue({ + fileKey, + fileUrl: "https://s3.example.com/uuid.webp", + contentType: "image/webp", + }); + + const response = await request(app.getHttpServer()) + .post("/api/email-notification-templates/images") + .set("Cookie", await cookieFor(admin, app)) + .attach("file", validPngBuffer, { filename: "photo.png", contentType: "image/png" }) + .expect(201); + + const { url } = response.body.data; + expect(url).toContain("/api/public/email-template-image/"); + expect(url).toContain(TENANT_HOST); + expect(url).toContain(encodeURIComponent(fileKey)); + }); + + it("rejects a file exceeding 10 MB with 400", async () => { + const admin = await userFactory + .withCredentials({ password }) + .withAdminSettings(db) + .create({ role: SYSTEM_ROLE_SLUGS.ADMIN }); + + const oversizedPngBuffer = Buffer.alloc(10 * 1024 * 1024 + 1, 0x89); + + await request(app.getHttpServer()) + .post("/api/email-notification-templates/images") + .set("Cookie", await cookieFor(admin, app)) + .attach("file", oversizedPngBuffer, { filename: "big.png", contentType: "image/png" }) + .expect(400); + }); + + it("rejects a PDF with 400", async () => { + const admin = await userFactory + .withCredentials({ password }) + .withAdminSettings(db) + .create({ role: SYSTEM_ROLE_SLUGS.ADMIN }); + + const fakePdfBuffer = Buffer.from("%PDF-1.4 fake pdf content"); + + await request(app.getHttpServer()) + .post("/api/email-notification-templates/images") + .set("Cookie", await cookieFor(admin, app)) + .attach("file", fakePdfBuffer, { filename: "doc.pdf", contentType: "application/pdf" }) + .expect(400); + }); + }); +}); diff --git a/apps/api/src/email-notification-templates/__tests__/emailTemplateImageUrl.spec.ts b/apps/api/src/email-notification-templates/__tests__/emailTemplateImageUrl.spec.ts new file mode 100644 index 0000000000..3abce91a93 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/emailTemplateImageUrl.spec.ts @@ -0,0 +1,133 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; + +import { EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH } from "../email-template-image.constants"; +import { + collectImageSrcs, + extractFileKeyFromImageUrl, + extractTenantEmailTemplateImageFileKeyFromUrl, + isEmailTemplateImageFileKeyForTenant, +} from "../utils/emailTemplateImageUrl"; + +import type { EmailTemplateNode } from "@repo/shared"; + +const imageNode = (src: string): EmailTemplateNode => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { src }, +}); +const TENANT_ID = "22222222-2222-2222-2222-222222222222"; +const OTHER_TENANT_ID = "99999999-9999-9999-9999-999999999999"; + +const paraNode = (...children: EmailTemplateNode[]): EmailTemplateNode => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + content: children, +}); + +const doc = (...children: EmailTemplateNode[]): EmailTemplateNode => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +describe("collectImageSrcs", () => { + it("yields src from a top-level image node", () => { + const node = imageNode("https://example.com/img.png"); + const srcs = [...collectImageSrcs(node)]; + expect(srcs).toEqual(["https://example.com/img.png"]); + }); + + it("walks nested content to find image nodes", () => { + const root = doc(paraNode(imageNode("https://example.com/nested.png"))); + const srcs = [...collectImageSrcs(root)]; + expect(srcs).toEqual(["https://example.com/nested.png"]); + }); + + it("yields multiple srcs when multiple image nodes are present", () => { + const root = doc(imageNode("https://a.com/1.png"), imageNode("https://b.com/2.png")); + const srcs = [...collectImageSrcs(root)]; + expect(srcs).toHaveLength(2); + expect(srcs).toContain("https://a.com/1.png"); + expect(srcs).toContain("https://b.com/2.png"); + }); + + it("skips image nodes with non-string src", () => { + const node: EmailTemplateNode = { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { src: null }, + }; + const srcs = [...collectImageSrcs(node)]; + expect(srcs).toHaveLength(0); + }); + + it("skips non-image nodes", () => { + const node = paraNode({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "hello" }); + const srcs = [...collectImageSrcs(node)]; + expect(srcs).toHaveLength(0); + }); + + it("returns empty for a node with no content", () => { + const node: EmailTemplateNode = { type: EMAIL_TEMPLATE_NODE_TYPES.DOC }; + const srcs = [...collectImageSrcs(node)]; + expect(srcs).toHaveLength(0); + }); +}); + +describe("extractFileKeyFromImageUrl", () => { + it("returns null when EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH is not present", () => { + const result = extractFileKeyFromImageUrl("https://example.com/other/path/image.png"); + expect(result).toBeNull(); + }); + + it("extracts the key after EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH", () => { + const key = "tenant-id/email_template_image/variants/abc.webp"; + const url = `https://example.com${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${key}`; + const result = extractFileKeyFromImageUrl(url); + expect(result).toBe(key); + }); + + it("decodes URI-encoded characters in the key", () => { + const rawKey = "tenant/email_template_image/variants/image with spaces.webp"; + const encodedKey = encodeURIComponent(rawKey); + const url = `https://example.com${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${encodedKey}`; + const result = extractFileKeyFromImageUrl(url); + expect(result).toBe(rawKey); + }); + + it("returns null when the URL contains malformed URI encoding", () => { + const result = extractFileKeyFromImageUrl( + `https://example.com${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}%E0%A4%A`, + ); + expect(result).toBeNull(); + }); +}); + +describe("isEmailTemplateImageFileKeyForTenant", () => { + it("accepts keys inside the current tenant email template image category", () => { + const key = `${TENANT_ID}/email_template_image/variants/abc.webp`; + expect(isEmailTemplateImageFileKeyForTenant(key, TENANT_ID)).toBe(true); + }); + + it("rejects keys from a different tenant", () => { + const key = `${OTHER_TENANT_ID}/email_template_image/variants/abc.webp`; + expect(isEmailTemplateImageFileKeyForTenant(key, TENANT_ID)).toBe(false); + }); + + it("rejects keys from a different category", () => { + const key = `${TENANT_ID}/course/variants/abc.webp`; + expect(isEmailTemplateImageFileKeyForTenant(key, TENANT_ID)).toBe(false); + }); +}); + +describe("extractTenantEmailTemplateImageFileKeyFromUrl", () => { + it("extracts a safe key for the current tenant email template image category", () => { + const key = `${TENANT_ID}/email_template_image/variants/abc.webp`; + const url = `https://example.com${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${encodeURIComponent(key)}`; + const result = extractTenantEmailTemplateImageFileKeyFromUrl(url, TENANT_ID); + expect(result).toBe(key); + }); + + it("returns null for a crafted URL pointing at another category key", () => { + const key = `${TENANT_ID}/course/variants/abc.webp`; + const url = `https://external.test${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${encodeURIComponent(key)}`; + const result = extractTenantEmailTemplateImageFileKeyFromUrl(url, TENANT_ID); + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/email-notification-templates/__tests__/flattenTranslationsForRender.spec.ts b/apps/api/src/email-notification-templates/__tests__/flattenTranslationsForRender.spec.ts new file mode 100644 index 0000000000..0dba3f3183 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/flattenTranslationsForRender.spec.ts @@ -0,0 +1,208 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; + +import { flattenTranslationsForRender } from "../utils/flattenTranslationsForRender"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; +const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + +const textNode = (text: string) => ({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }); + +const para = (uuid: string, ...textNodes: ReturnType[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid }, + content: textNodes, +}); + +const btn = (uuid: string, text: string, url = "https://example.com"): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, text, url }, +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +const EN = "en" as const; +const PL = "pl" as const; + +describe("flattenTranslationsForRender", () => { + it("replaces content of a non-BUTTON translatable node with the fragment", () => { + const fragment = [textNode("Polish paragraph")]; + const blocks = doc(para(uuid1, textNode("English paragraph"))); + const strings: EmailTemplateStrings = { [PL]: { [uuid1]: fragment } }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + const firstChild = result.content?.[0]; + expect(firstChild?.content).toEqual(fragment); + }); + + it("sets attrs.text from flattened fragment text for BUTTON nodes", () => { + const fragment = [textNode("Buy now")]; + const blocks = doc(btn(uuid1, "Click")); + const strings: EmailTemplateStrings = { [PL]: { [uuid1]: fragment } }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.attrs?.text).toBe("Buy now"); + }); + + it("flattens nested content in a BUTTON fragment", () => { + const fragment = [{ type: "paragraph", content: [textNode("nested text")] }]; + const blocks = doc(btn(uuid1, "old")); + const strings: EmailTemplateStrings = { [PL]: { [uuid1]: fragment } }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.attrs?.text).toBe("nested text"); + }); + + it("falls back to baseLanguage fragment when target language fragment is missing", () => { + const enFragment = [textNode("English text")]; + const blocks = doc(para(uuid1, textNode("original"))); + const strings: EmailTemplateStrings = { [EN]: { [uuid1]: enFragment } }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.content).toEqual(enFragment); + }); + + it("falls back to baseLanguage fragment when target language fragment is empty", () => { + const enFragment = [textNode("English text")]; + const blocks = doc(para(uuid1, textNode("original"))); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: enFragment }, + [PL]: { [uuid1]: [] }, + }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.content).toEqual(enFragment); + }); + + it("falls back to baseLanguage fragment when target fragment contains only whitespace", () => { + const enFragment = [textNode("English text")]; + const blocks = doc(para(uuid1, textNode("original"))); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: enFragment }, + [PL]: { [uuid1]: [textNode(" ")] }, + }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.content).toEqual(enFragment); + }); + + it("falls back to baseLanguage button text when target text is empty", () => { + const blocks = doc(btn(uuid1, "original")); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: [textNode("Buy now")] }, + [PL]: { [uuid1]: [] }, + }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.attrs?.text).toBe("Buy now"); + }); + + it("leaves node untouched when both target and base language fragments are missing", () => { + const originalContent = [textNode("original")]; + const blocks = doc(para(uuid1, ...originalContent)); + const strings: EmailTemplateStrings = {}; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.content).toEqual(originalContent); + }); + + it("does not mutate the original blocks argument", () => { + const enFragment = [textNode("EN text")]; + const blocks = doc(para(uuid1, textNode("original"))); + const strings: EmailTemplateStrings = { [EN]: { [uuid1]: enFragment } }; + + const blocksBefore = JSON.stringify(blocks); + flattenTranslationsForRender({ blocks, strings, language: EN, baseLanguage: EN }); + + expect(JSON.stringify(blocks)).toBe(blocksBefore); + }); + + it("ignores strings[baseLanguage] when rendering the base language", () => { + const blocks = doc(para(uuid1, textNode("fresh base edit"))); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: [textNode("stale leftover")] }, + }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: EN, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.content).toEqual([textNode("fresh base edit")]); + }); + + it("handles multiple nodes with different uuids independently", () => { + const enFrag1 = [textNode("EN para 1")]; + const enFrag2 = [textNode("EN para 2")]; + const plFrag2 = [textNode("PL para 2")]; + const blocks = doc(para(uuid1, textNode("a")), para(uuid2, textNode("b"))); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: enFrag1, [uuid2]: enFrag2 }, + [PL]: { [uuid2]: plFrag2 }, + }; + + const result = flattenTranslationsForRender({ + blocks, + strings, + language: PL, + baseLanguage: EN, + }); + + expect(result.content?.[0]?.content).toEqual(enFrag1); + expect(result.content?.[1]?.content).toEqual(plFrag2); + }); +}); diff --git a/apps/api/src/email-notification-templates/__tests__/pruneOrphanStrings.spec.ts b/apps/api/src/email-notification-templates/__tests__/pruneOrphanStrings.spec.ts new file mode 100644 index 0000000000..8c0e65fa14 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/pruneOrphanStrings.spec.ts @@ -0,0 +1,93 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; + +import { pruneOrphanStrings } from "../utils/pruneOrphanStrings"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; +const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; +const uuid3 = "aaaaaaaa-0000-4000-8000-000000000003"; + +const textNode = (text: string) => ({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +const para = (uuid: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid }, + content: [textNode("text")], +}); + +const EN = "en" as const; +const PL = "pl" as const; + +describe("pruneOrphanStrings", () => { + it("removes uuids not present in blocks", () => { + const blocks = doc(para(uuid1)); + const strings: EmailTemplateStrings = { + [EN]: { + [uuid1]: [textNode("keep")], + [uuid2]: [textNode("orphan")], + }, + }; + + const result = pruneOrphanStrings(blocks, strings); + + expect(result[EN]).toBeDefined(); + expect(result[EN]![uuid1]).toBeDefined(); + expect(result[EN]![uuid2]).toBeUndefined(); + }); + + it("keeps uuids that are present in blocks", () => { + const blocks = doc(para(uuid1), para(uuid2)); + const strings: EmailTemplateStrings = { + [EN]: { + [uuid1]: [textNode("one")], + [uuid2]: [textNode("two")], + }, + }; + + const result = pruneOrphanStrings(blocks, strings); + + expect(result[EN]![uuid1]).toBeDefined(); + expect(result[EN]![uuid2]).toBeDefined(); + }); + + it("drops language buckets that become empty", () => { + const blocks = doc(para(uuid1)); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: [textNode("keep")] }, + [PL]: { [uuid3]: [textNode("orphan")] }, + }; + + const result = pruneOrphanStrings(blocks, strings); + + expect(result[EN]).toBeDefined(); + expect(result[PL]).toBeUndefined(); + }); + + it("handles undefined byUuid entries gracefully", () => { + const blocks = doc(para(uuid1)); + const strings = { + [EN]: undefined, + } as unknown as EmailTemplateStrings; + + expect(() => pruneOrphanStrings(blocks, strings)).not.toThrow(); + const result = pruneOrphanStrings(blocks, strings); + expect(result[EN]).toBeUndefined(); + }); + + it("returns empty strings when no uuids in blocks match", () => { + const blocks = doc({ type: EMAIL_TEMPLATE_NODE_TYPES.DOC }); + const strings: EmailTemplateStrings = { + [EN]: { [uuid1]: [textNode("orphan")] }, + }; + + const result = pruneOrphanStrings(blocks, strings); + + expect(Object.keys(result)).toHaveLength(0); + }); +}); diff --git a/apps/api/src/email-notification-templates/__tests__/renderTemplateContent.spec.ts b/apps/api/src/email-notification-templates/__tests__/renderTemplateContent.spec.ts new file mode 100644 index 0000000000..560e8270be --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/renderTemplateContent.spec.ts @@ -0,0 +1,375 @@ +import { BadRequestException } from "@nestjs/common"; +import { + EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + TENANT_LOGO_CID_SRC, + TENANT_LOGO_VARIABLE, +} from "@repo/shared"; + +const mockRender = jest.fn(); +const mockSetPreviewText = jest.fn(); +const mockSetTheme = jest.fn(); +const MockMaily = jest.fn().mockImplementation(() => ({ + render: mockRender, + setPreviewText: mockSetPreviewText, + setTheme: mockSetTheme, +})); + +jest.mock("@maily-to/render", () => ({ Maily: MockMaily })); + +import { renderTemplateContent } from "../utils/renderTemplateContent"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; + +const EN = "en" as const; +const PL = "pl" as const; +const PRIMARY = "#4796FD"; + +const emptyBlocks: EmailTemplateBlocks = { type: EMAIL_TEMPLATE_NODE_TYPES.DOC, content: [] }; +const emptyStrings: EmailTemplateStrings = {}; +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; + +const logoBlocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { + src: TENANT_LOGO_VARIABLE, + height: "32", + }, + }, + ], +}; + +const translatedLinkBlocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid1 }, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "Base text" }], + }, + ], +}; + +const bareBodyHtml = () => + "" + + '' + + '' + + '" + + "
' + + '
content
' + + "
"; + +const bodyHtmlWithBlocks = (count: number) => { + const blocks = Array.from( + { length: count }, + (_, i) => `

Block ${i + 1}

`, + ).join(""); + return ( + "" + + '' + + '' + + '" + + "
' + + `
${blocks}
` + + "
" + ); +}; + +describe("renderTemplateContent", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRender.mockResolvedValue(bareBodyHtml()); + }); + + it("returns the html unchanged when the wrapper td cannot be found (passthrough)", async () => { + mockRender.mockResolvedValue("test"); + + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(result.html).toBe("test"); + }); + + it("returns the html unchanged when the card has no child blocks (passthrough)", async () => { + const raw = bareBodyHtml(); + mockRender.mockResolvedValue(raw); + + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(result.html).toBe(raw); + }); + + it("splits blocks into two full-width sections: top primaryColor, bottom body bg with white card", async () => { + mockRender.mockResolvedValue(bodyHtmlWithBlocks(4)); + + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: "#ff00aa", + }); + + expect(result.html).toContain("background-color:#ff00aa"); + expect(result.html).toContain("background-color:#fafafa"); + expect(result.html).toContain("background-color:#ffffff"); + expect(result.html).not.toContain("linear-gradient"); + }); + + it("puts ceil(N/2) blocks in the top (primary) section and the rest in the bottom section", async () => { + mockRender.mockResolvedValue(bodyHtmlWithBlocks(5)); + + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: "#ff00aa", + }); + + const topIdx = result.html.indexOf("background-color:#ff00aa"); + const bottomIdx = result.html.indexOf("background-color:#fafafa", topIdx + 1); + expect(topIdx).toBeGreaterThan(-1); + expect(bottomIdx).toBeGreaterThan(topIdx); + + const topSection = result.html.slice(topIdx, bottomIdx); + const bottomSection = result.html.slice(bottomIdx); + + for (const i of [1, 2, 3]) { + expect(topSection).toContain(`id="b${i}"`); + expect(bottomSection).not.toContain(`id="b${i}"`); + } + for (const i of [4, 5]) { + expect(bottomSection).toContain(`id="b${i}"`); + expect(topSection).not.toContain(`id="b${i}"`); + } + }); + + it("omits the bottom section when there is only one block", async () => { + mockRender.mockResolvedValue(bodyHtmlWithBlocks(1)); + + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: "#ff00aa", + }); + + expect(result.html).toContain("background-color:#ff00aa"); + const bottomBgOccurrences = (result.html.match(/background-color:#fafafa/g) ?? []).length; + expect(bottomBgOccurrences).toBe(1); + }); + + it("wraps the layout in a div with the body background so styles survive body stripping", async () => { + mockRender.mockResolvedValue(bodyHtmlWithBlocks(4)); + + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: "#ff00aa", + }); + + const bodyOpenMatch = result.html.match(/]*)>/); + const bodyOpenAttrs = bodyOpenMatch?.[1] ?? ""; + expect(bodyOpenAttrs).not.toContain("background-color"); + expect(bodyOpenAttrs).not.toContain("style="); + + expect(result.html).toMatch(/]*>\s*
/); + expect(result.html).not.toMatch(/]*>\s* { + await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + previewText: "Preview snippet", + }); + + expect(mockSetPreviewText).toHaveBeenCalledWith("Preview snippet"); + }); + + it("does not call setPreviewText when previewText is absent", async () => { + await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(mockSetPreviewText).not.toHaveBeenCalled(); + }); + + it("configures the maily theme with fafafa body bg and a 500px rounded white card, no gradient", async () => { + await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(mockSetTheme).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ backgroundColor: "#fafafa" }), + container: expect.objectContaining({ + backgroundColor: "#ffffff", + maxWidth: "500px", + borderRadius: "24px", + }), + }), + ); + const bodyTheme = mockSetTheme.mock.calls[0]?.[0]?.body ?? {}; + expect(bodyTheme).not.toHaveProperty("background"); + }); + + it("replaces the tenant logo variable with cid:logo before rendering by default", async () => { + await renderTemplateContent({ + blocks: logoBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(MockMaily).toHaveBeenCalledWith( + expect.objectContaining({ + content: [ + expect.objectContaining({ + attrs: expect.objectContaining({ src: TENANT_LOGO_CID_SRC }), + }), + ], + }), + ); + }); + + it("replaces the tenant logo variable with an explicit preview logo source", async () => { + await renderTemplateContent({ + blocks: logoBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: EN, + baseLanguage: EN, + primaryColor: PRIMARY, + tenantLogoSrc: "/api/settings/platform-logo/image?v=logo.png", + }); + + expect(MockMaily).toHaveBeenCalledWith( + expect.objectContaining({ + content: [ + expect.objectContaining({ + attrs: expect.objectContaining({ + src: "/api/settings/platform-logo/image?v=logo.png", + }), + }), + ], + }), + ); + }); + + it("picks the target language subject", async () => { + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "EN subject", [PL]: "PL subject" }, + language: PL, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(result.subject).toBe("PL subject"); + }); + + it("falls back to base language subject when target language subject is missing", async () => { + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "EN subject" }, + language: PL, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(result.subject).toBe("EN subject"); + }); + + it("returns empty string subject when neither target nor base subject is available", async () => { + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: {}, + language: PL, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(result.subject).toBe(""); + }); + + it("returns the resolved language in output", async () => { + const result = await renderTemplateContent({ + blocks: emptyBlocks, + strings: emptyStrings, + subject: { [EN]: "Subject" }, + language: PL, + baseLanguage: EN, + primaryColor: PRIMARY, + }); + + expect(result.language).toBe(PL); + }); + + it("rejects unsafe hrefs introduced by translated strings before rendering", async () => { + await expect( + renderTemplateContent({ + blocks: translatedLinkBlocks, + strings: { + [PL]: { + [uuid1]: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, + text: "Unsafe link", + marks: [{ type: "link", attrs: { href: "javascript:alert(1)" } }], + }, + ], + }, + }, + subject: { [EN]: "Subject" }, + language: PL, + baseLanguage: EN, + primaryColor: PRIMARY, + }), + ).rejects.toThrow(new BadRequestException("emailTemplates.toast.invalidUrl")); + expect(MockMaily).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/email-notification-templates/email-template-cleanup.queue.service.ts b/apps/api/src/email-notification-templates/email-template-cleanup.queue.service.ts new file mode 100644 index 0000000000..0a61ff745b --- /dev/null +++ b/apps/api/src/email-notification-templates/email-template-cleanup.queue.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; + +import { QUEUE_NAMES, QueueService } from "src/queue"; + +import type { EmailTemplateImageCleanupJobData } from "src/queue"; + +export const EMAIL_TEMPLATE_IMAGE_CLEANUP_JOB_NAME = "email-template-image-cleanup"; + +@Injectable() +export class EmailTemplateCleanupQueueService { + constructor(private readonly queueService: QueueService) {} + + async enqueueImageCleanup(data: EmailTemplateImageCleanupJobData): Promise { + await this.queueService.enqueue( + QUEUE_NAMES.EMAIL_TEMPLATE_IMAGE_CLEANUP, + EMAIL_TEMPLATE_IMAGE_CLEANUP_JOB_NAME, + data, + { + attempts: 3, + backoff: { type: "exponential", delay: 1000 }, + removeOnComplete: true, + removeOnFail: false, + }, + ); + } +} diff --git a/apps/api/src/email-notification-templates/email-template-cleanup.worker.ts b/apps/api/src/email-notification-templates/email-template-cleanup.worker.ts new file mode 100644 index 0000000000..e2173c15f9 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-template-cleanup.worker.ts @@ -0,0 +1,56 @@ +import { + Injectable, + InternalServerErrorException, + Logger, + type OnModuleDestroy, +} from "@nestjs/common"; +import { Worker } from "bullmq"; + +import { EMAIL_TEMPLATE_IMAGE_CLEANUP_JOB_NAME } from "src/email-notification-templates/email-template-cleanup.queue.service"; +import { EmailNotificationTemplatesService } from "src/email-notification-templates/email-templates.service"; +import { QUEUE_NAMES, QueueService } from "src/queue"; +import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service"; + +import type { Job } from "bullmq"; +import type { EmailTemplateImageCleanupJobData } from "src/queue"; + +@Injectable() +export class EmailTemplateCleanupWorker implements OnModuleDestroy { + private readonly logger = new Logger(EmailTemplateCleanupWorker.name); + private readonly worker: Worker; + + constructor( + private readonly queueService: QueueService, + private readonly emailTemplatesService: EmailNotificationTemplatesService, + private readonly tenantRunner: TenantDbRunnerService, + ) { + this.worker = new Worker( + QUEUE_NAMES.EMAIL_TEMPLATE_IMAGE_CLEANUP, + (job) => this.handleImageCleanup(job), + { + connection: this.queueService.getConnection(), + concurrency: Number(process.env.EMAIL_TEMPLATE_CLEANUP_WORKER_CONCURRENCY || 1), + }, + ); + + this.worker.on("failed", (job, err) => { + this.logger.error(`Email template image cleanup job ${job?.id} failed: ${err.message}`); + }); + } + + private async handleImageCleanup(job: Job): Promise { + if (job.name !== EMAIL_TEMPLATE_IMAGE_CLEANUP_JOB_NAME) { + throw new InternalServerErrorException( + `Unexpected email template image cleanup job name: ${job.name}`, + ); + } + + await this.tenantRunner.runWithTenant(job.data.tenantId, () => + this.emailTemplatesService.purgeOrphanedImages(job.data), + ); + } + + async onModuleDestroy() { + await this.worker.close(); + } +} diff --git a/apps/api/src/email-notification-templates/email-template-image.constants.ts b/apps/api/src/email-notification-templates/email-template-image.constants.ts new file mode 100644 index 0000000000..75f6f4862b --- /dev/null +++ b/apps/api/src/email-notification-templates/email-template-image.constants.ts @@ -0,0 +1,8 @@ +export const EMAIL_TEMPLATE_IMAGE_SIGNED_URL_TTL_SECONDS = 3600; +export const EMAIL_TEMPLATE_IMAGE_REDIRECT_CACHE_MAX_AGE_SECONDS = 1800; +export const EMAIL_TEMPLATE_IMAGE_PLACEHOLDER_CACHE_MAX_AGE_SECONDS = 3600; + +export const EMAIL_TEMPLATE_IMAGE_PLACEHOLDER_SVG = ``; + +export const EMAIL_TEMPLATE_IMAGE_CONTROLLER_PATH = "public/email-template-image"; +export const EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH = `/api/${EMAIL_TEMPLATE_IMAGE_CONTROLLER_PATH}/`; diff --git a/apps/api/src/email-notification-templates/email-template-image.controller.ts b/apps/api/src/email-notification-templates/email-template-image.controller.ts new file mode 100644 index 0000000000..319da2fca9 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-template-image.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + HttpStatus, + Post, + Req, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { ApiBody, ApiConsumes } from "@nestjs/swagger"; +import { ALLOWED_LESSON_IMAGE_FILE_TYPES, PERMISSIONS } from "@repo/shared"; +import { Request } from "express"; +import { Validate } from "nestjs-typebox"; + +import { baseResponse, BaseResponse } from "src/common"; +import { FILE_SIZE_BASE } from "src/common/constants"; +import { RequirePermission } from "src/common/decorators/require-permission.decorator"; +import { CurrentUser } from "src/common/decorators/user.decorator"; +import { PermissionsGuard } from "src/common/guards/permissions.guard"; +import { CurrentUserType } from "src/common/types/current-user.type"; +import { RESOURCE_CATEGORIES } from "src/file/file.constants"; +import { FileService } from "src/file/file.service"; +import { getBaseFileTypePipe } from "src/file/utils/baseFileTypePipe"; +import { buildFileTypeRegex } from "src/file/utils/fileTypeRegex"; +import { TenantResolverService } from "src/storage/db/tenant-resolver.service"; + +import { + emailTemplateImageUploadResponseSchema, + type EmailTemplateImageUploadResponse, +} from "./schemas/emailTemplateImage.schema"; +import { buildEmailTemplateImageUrl } from "./utils/buildEmailTemplateImageUrl"; + +@UseGuards(PermissionsGuard) +@Controller("email-notification-templates/images") +export class EmailTemplateImageController { + constructor( + private readonly fileService: FileService, + private readonly tenantResolver: TenantResolverService, + ) {} + + @Post() + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiBody({ + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + }, + required: ["file"], + }, + }) + @Validate({ + response: baseResponse(emailTemplateImageUploadResponseSchema), + }) + async upload( + @UploadedFile( + getBaseFileTypePipe( + buildFileTypeRegex(ALLOWED_LESSON_IMAGE_FILE_TYPES), + FILE_SIZE_BASE, + ).build({ + errorHttpStatusCode: HttpStatus.BAD_REQUEST, + }), + ) + file: Express.Multer.File, + @CurrentUser() currentUser: CurrentUserType, + @Req() req: Request, + ): Promise> { + const tenantHost = (await this.tenantResolver.resolveTenantHost(req)) ?? ""; + const { fileKey } = await this.fileService.uploadFile( + file, + RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE, + currentUser.tenantId, + { skipVariants: true }, + ); + const url = buildEmailTemplateImageUrl({ tenantHost, reference: fileKey }); + + return new BaseResponse({ url }); + } +} diff --git a/apps/api/src/email-notification-templates/email-templates.controller.ts b/apps/api/src/email-notification-templates/email-templates.controller.ts new file mode 100644 index 0000000000..ad5026a15e --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.controller.ts @@ -0,0 +1,253 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { + PERMISSIONS, + SUPPORTED_LANGUAGES, + type EmailTemplateStatus, + type SupportedLanguages, +} from "@repo/shared"; +import { Type } from "@sinclair/typebox"; +import { Validate } from "nestjs-typebox"; + +import { + baseResponse, + BaseResponse, + paginatedResponse, + PaginatedResponse, + UUIDSchema, + UUIDType, +} from "src/common"; +import { RequirePermission } from "src/common/decorators/require-permission.decorator"; +import { CurrentUser } from "src/common/decorators/user.decorator"; +import { PermissionsGuard } from "src/common/guards/permissions.guard"; +import { CurrentUserType } from "src/common/types/current-user.type"; + +import { EmailNotificationTemplatesService } from "./email-templates.service"; +import { + createEmailNotificationTemplateSchema, + type CreateEmailNotificationTemplate, +} from "./schemas/createEmailNotificationTemplate.schema"; +import { + emailNotificationTemplateSchema, + emailNotificationTemplatesListSchema, + emailTemplateStatusSchema, +} from "./schemas/emailNotificationTemplate.schema"; +import { previewEmailNotificationTemplateSchema } from "./schemas/previewEmailNotificationTemplate.schema"; +import { + updateEmailNotificationTemplateSchema, + type UpdateEmailNotificationTemplate, +} from "./schemas/updateEmailNotificationTemplate.schema"; + +@UseGuards(PermissionsGuard) +@Controller("email-notification-templates") +export class EmailNotificationTemplatesController { + constructor( + private readonly emailNotificationTemplatesService: EmailNotificationTemplatesService, + ) {} + + @Get() + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [ + { type: "query", name: "status", schema: Type.Optional(emailTemplateStatusSchema) }, + { type: "query", name: "name", schema: Type.Optional(Type.String()) }, + { type: "query", name: "page", schema: Type.Optional(Type.Number({ minimum: 1 })) }, + { type: "query", name: "perPage", schema: Type.Optional(Type.Number({ minimum: 1 })) }, + ], + response: paginatedResponse(emailNotificationTemplatesListSchema), + }) + async listTemplates( + @Query("status") status?: EmailTemplateStatus, + @Query("name") name?: string, + @Query("page") page?: number, + @Query("perPage") perPage?: number, + ) { + const result = await this.emailNotificationTemplatesService.listTemplates( + { page, perPage }, + { status, name }, + ); + + return new PaginatedResponse(result); + } + + @Delete("bulk") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "body", schema: Type.Array(UUIDSchema, { minItems: 1 }) }], + response: baseResponse(Type.Object({ message: Type.String() })), + }) + async deleteManyTemplates(@Body() ids: UUIDType[], @CurrentUser() currentUser: CurrentUserType) { + await this.emailNotificationTemplatesService.deleteManyTemplates(ids, currentUser.tenantId); + + return new BaseResponse({ message: "emailTemplates.toast.deletedSuccessfully" }); + } + + @Post() + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "body", schema: createEmailNotificationTemplateSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async createTemplate(@Body() body: CreateEmailNotificationTemplate) { + const template = await this.emailNotificationTemplatesService.createTemplate(body); + + return new BaseResponse(template); + } + + @Get(":id") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async getTemplate(@Param("id") id: UUIDType) { + const template = await this.emailNotificationTemplatesService.getTemplateById(id); + + return new BaseResponse(template); + } + + @Patch(":id") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [ + { type: "param", name: "id", schema: UUIDSchema }, + { type: "body", schema: updateEmailNotificationTemplateSchema }, + ], + response: baseResponse(emailNotificationTemplateSchema), + }) + async updateTemplate( + @Param("id") id: UUIDType, + @Body() body: UpdateEmailNotificationTemplate, + @CurrentUser() currentUser: CurrentUserType, + ) { + const template = await this.emailNotificationTemplatesService.updateTemplate( + id, + body, + currentUser.tenantId, + ); + + return new BaseResponse(template); + } + + @Post(":id/publish") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async publishTemplate(@Param("id") id: UUIDType) { + const template = await this.emailNotificationTemplatesService.publishTemplate(id); + + return new BaseResponse(template); + } + + @Post(":id/make-draft") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async makeTemplateDraft(@Param("id") id: UUIDType) { + const template = await this.emailNotificationTemplatesService.makeDraftTemplate(id); + + return new BaseResponse(template); + } + + @Post(":id/archive") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async archiveTemplate(@Param("id") id: UUIDType) { + const template = await this.emailNotificationTemplatesService.archiveTemplate(id); + + return new BaseResponse(template); + } + + @Delete(":id") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(Type.Object({ message: Type.String() })), + }) + async deleteTemplate(@Param("id") id: UUIDType, @CurrentUser() currentUser: CurrentUserType) { + await this.emailNotificationTemplatesService.deleteTemplate(id, currentUser.tenantId); + + return new BaseResponse({ message: "emailTemplates.toast.deletedSuccessfully" }); + } + + @Post(":id/unarchive") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async unarchiveTemplate(@Param("id") id: UUIDType) { + const template = await this.emailNotificationTemplatesService.unarchiveTemplate(id); + + return new BaseResponse(template); + } + + @Post(":id/preview") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [ + { type: "param", name: "id", schema: UUIDSchema }, + { type: "query", name: "language", schema: Type.Optional(Type.Enum(SUPPORTED_LANGUAGES)) }, + ], + response: baseResponse(previewEmailNotificationTemplateSchema), + }) + async previewTemplate( + @Param("id") id: UUIDType, + @Query("language") language: SupportedLanguages | undefined, + @CurrentUser() currentUser: CurrentUserType, + ) { + const preview = await this.emailNotificationTemplatesService.previewTemplate( + id, + currentUser.tenantId, + language, + ); + + return new BaseResponse(preview); + } + + @Post(":id/test-send") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [ + { type: "param", name: "id", schema: UUIDSchema }, + { type: "query", name: "language", schema: Type.Optional(Type.Enum(SUPPORTED_LANGUAGES)) }, + ], + response: baseResponse(Type.Object({ message: Type.String() })), + }) + async sendTestEmail( + @Param("id") id: UUIDType, + @Query("language") language: SupportedLanguages | undefined, + @CurrentUser() currentUser: CurrentUserType, + ) { + await this.emailNotificationTemplatesService.sendTestEmail(id, currentUser, language); + return new BaseResponse({ message: "emailTemplates.toast.testEmailSentSuccessfully" }); + } + + @Post(":id/duplicate") + @RequirePermission(PERMISSIONS.EMAIL_TEMPLATE_MANAGE) + @Validate({ + request: [{ type: "param", name: "id", schema: UUIDSchema }], + response: baseResponse(emailNotificationTemplateSchema), + }) + async duplicateTemplate(@Param("id") id: UUIDType) { + const template = await this.emailNotificationTemplatesService.duplicateTemplate(id); + + return new BaseResponse(template); + } +} diff --git a/apps/api/src/email-notification-templates/email-templates.module.ts b/apps/api/src/email-notification-templates/email-templates.module.ts new file mode 100644 index 0000000000..83835b9a86 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; + +import { EmailModule } from "src/common/emails/emails.module"; +import { FileModule } from "src/file/files.module"; +import { PermissionsModule } from "src/permissions/permissions.module"; +import { SettingsModule } from "src/settings/settings.module"; + +import { EmailTemplateCleanupQueueService } from "./email-template-cleanup.queue.service"; +import { EmailTemplateCleanupWorker } from "./email-template-cleanup.worker"; +import { EmailTemplateImageController } from "./email-template-image.controller"; +import { EmailNotificationTemplatesController } from "./email-templates.controller"; +import { EmailNotificationTemplatesRepository } from "./email-templates.repository"; +import { EmailNotificationTemplatesService } from "./email-templates.service"; + +@Module({ + imports: [PermissionsModule, FileModule, EmailModule, SettingsModule], + controllers: [EmailNotificationTemplatesController, EmailTemplateImageController], + providers: [ + EmailNotificationTemplatesService, + EmailNotificationTemplatesRepository, + EmailTemplateCleanupQueueService, + EmailTemplateCleanupWorker, + ], + exports: [EmailNotificationTemplatesService], +}) +export class EmailNotificationTemplatesModule {} diff --git a/apps/api/src/email-notification-templates/email-templates.repository.ts b/apps/api/src/email-notification-templates/email-templates.repository.ts new file mode 100644 index 0000000000..4ca22a0d88 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.repository.ts @@ -0,0 +1,192 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { and, count, desc, eq, ilike, inArray, ne, type SQL } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { addPagination } from "src/common/pagination"; +import { DB } from "src/storage/db/db.providers"; +import { emailNotificationTemplates } from "src/storage/schema"; + +import type { + EmailTemplateBlocks, + EmailTemplateStatus, + EmailTemplateStrings, + LocalizedText, + SupportedLanguages, +} from "@repo/shared"; +import type { UUIDType } from "src/common"; + +type CreateEmailNotificationTemplateRow = { + name: string; + baseLanguage: SupportedLanguages; + availableLocales: SupportedLanguages[]; + subject: LocalizedText; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; +}; + +@Injectable() +export class EmailNotificationTemplatesRepository { + constructor(@Inject(DB) private readonly db: DatabasePg) {} + + async listTemplates(pagination: { page: number; perPage: number }, conditions: SQL[] = []) { + return this.db.transaction(async (trx) => { + const templatesQuery = trx + .select() + .from(emailNotificationTemplates) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(emailNotificationTemplates.updatedAt)) + .$dynamic(); + + const data = await addPagination(templatesQuery, pagination.page, pagination.perPage); + + const [{ totalItems }] = await trx + .select({ totalItems: count() }) + .from(emailNotificationTemplates) + .where(conditions.length > 0 ? and(...conditions) : undefined); + + return { + data, + pagination: { ...pagination, totalItems }, + }; + }); + } + + async deleteManyTemplates(ids: UUIDType[]) { + return this.db + .delete(emailNotificationTemplates) + .where(inArray(emailNotificationTemplates.id, ids)) + .returning({ id: emailNotificationTemplates.id }); + } + + async createTemplate(input: CreateEmailNotificationTemplateRow) { + const [row] = await this.db + .insert(emailNotificationTemplates) + .values({ + name: input.name, + baseLanguage: input.baseLanguage, + availableLocales: input.availableLocales, + subject: input.subject, + blocks: input.blocks, + strings: input.strings, + }) + .returning(); + + return row; + } + + async deleteTemplate(id: UUIDType) { + const [row] = await this.db + .delete(emailNotificationTemplates) + .where(eq(emailNotificationTemplates.id, id)) + .returning({ id: emailNotificationTemplates.id }); + + return row; + } + + async findById(id: UUIDType) { + const [row] = await this.db + .select() + .from(emailNotificationTemplates) + .where(eq(emailNotificationTemplates.id, id)) + .limit(1); + + return row; + } + + async findBlocksByIds(ids: UUIDType[]): Promise { + const rows = await this.db + .select({ blocks: emailNotificationTemplates.blocks }) + .from(emailNotificationTemplates) + .where(inArray(emailNotificationTemplates.id, ids)); + + return rows.map((row) => row.blocks); + } + + async findByName(conditions: SQL[]) { + const [row] = await this.db + .select({ id: emailNotificationTemplates.id }) + .from(emailNotificationTemplates) + .where(and(...conditions)) + .limit(1); + + return row; + } + + async findExistingNames(names: string[]) { + if (names.length === 0) return [] as string[]; + + const rows = await this.db + .select({ name: emailNotificationTemplates.name }) + .from(emailNotificationTemplates) + .where(inArray(emailNotificationTemplates.name, names)); + + return rows.map((row) => row.name); + } + + async duplicateFrom(source: { + name: string; + baseLanguage: SupportedLanguages; + availableLocales: SupportedLanguages[]; + subject: LocalizedText; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + }) { + const [row] = await this.db + .insert(emailNotificationTemplates) + .values({ + name: source.name, + baseLanguage: source.baseLanguage, + availableLocales: source.availableLocales, + subject: source.subject, + blocks: source.blocks, + strings: source.strings, + }) + .returning(); + + return row; + } + + async setStatus(id: UUIDType, status: EmailTemplateStatus, archivedAt: string | null) { + const [row] = await this.db + .update(emailNotificationTemplates) + .set({ status, archivedAt }) + .where(eq(emailNotificationTemplates.id, id)) + .returning(); + + return row; + } + + async updateTemplate( + id: UUIDType, + updates: Partial, + ) { + const [row] = await this.db + .update(emailNotificationTemplates) + .set(updates) + .where(eq(emailNotificationTemplates.id, id)) + .returning(); + + return row; + } + + async findTemplateBlocks(excludeId?: UUIDType): Promise { + const conditions: SQL[] = []; + if (excludeId) conditions.push(ne(emailNotificationTemplates.id, excludeId)); + + const rows = await this.db + .select({ blocks: emailNotificationTemplates.blocks }) + .from(emailNotificationTemplates) + .where(conditions.length > 0 ? and(...conditions) : undefined); + + return rows.map((row) => row.blocks); + } + + async findAutoTemplateNames(): Promise { + const rows = await this.db + .select({ name: emailNotificationTemplates.name }) + .from(emailNotificationTemplates) + .where(ilike(emailNotificationTemplates.name, "Email template #%")); + + return rows.map((row) => row.name); + } +} diff --git a/apps/api/src/email-notification-templates/email-templates.service.ts b/apps/api/src/email-notification-templates/email-templates.service.ts new file mode 100644 index 0000000000..741a8275aa --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.service.ts @@ -0,0 +1,571 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { + computeEmailTemplateDiagnostics, + DEFAULT_PLATFORM_LOGO_PATH, + DEFAULT_TENANT_PRIMARY_COLOR, + EMAIL_TEMPLATE_STATUSES, + SUPPORTED_LANGUAGES, + TENANT_LOGO_CID_SRC, +} from "@repo/shared"; +import { eq, ilike, ne, type SQL } from "drizzle-orm"; + +import { EmailService } from "src/common/emails/emails.service"; +import { DEFAULT_PAGE_SIZE, parsePagination } from "src/common/pagination"; +import { isPostgresUniqueViolation } from "src/common/utils/postgresErrors"; +import { FileService } from "src/file/file.service"; +import { SettingsService } from "src/settings/settings.service"; +import { emailNotificationTemplates } from "src/storage/schema"; + +import { EmailTemplateCleanupQueueService } from "./email-template-cleanup.queue.service"; +import { EmailNotificationTemplatesRepository } from "./email-templates.repository"; +import { assertSafeBlockUrls } from "./utils/assertSafeBlockUrls"; +import { buildDefaultEmailTemplateBlocks } from "./utils/buildDefaultEmailTemplateBlocks"; +import { + collectImageSrcs, + extractTenantEmailTemplateImageFileKeyFromUrl, +} from "./utils/emailTemplateImageUrl"; +import { flattenTranslationsForRender } from "./utils/flattenTranslationsForRender"; +import { pruneOrphanStrings } from "./utils/pruneOrphanStrings"; +import { renderTemplateContent } from "./utils/renderTemplateContent"; + +import type { CreateEmailNotificationTemplate } from "./schemas/createEmailNotificationTemplate.schema"; +import type { UpdateEmailNotificationTemplate } from "./schemas/updateEmailNotificationTemplate.schema"; +import type { + EmailTemplateBlocks, + EmailTemplateStatus, + EmailTemplateStrings, + LocalizedText, + SupportedLanguages, +} from "@repo/shared"; +import type { UUIDType } from "src/common"; +import type { CurrentUserType } from "src/common/types/current-user.type"; +import type { EmailTemplateImageCleanupJobData } from "src/queue"; + +const EMAIL_TEMPLATE_NAME_UNIQUE_INDEX = "email_notification_templates_tenant_id_name_unique_idx"; +const EMAIL_TEMPLATE_AUTO_NAME_REGEX = /^Email template #([0-9]+)$/; + +@Injectable() +export class EmailNotificationTemplatesService { + private readonly logger = new Logger(EmailNotificationTemplatesService.name); + + constructor( + private readonly repository: EmailNotificationTemplatesRepository, + private readonly fileService: FileService, + private readonly emailService: EmailService, + private readonly settingsService: SettingsService, + private readonly cleanupQueue: EmailTemplateCleanupQueueService, + ) {} + + async listTemplates( + paginationQuery: { page?: number; perPage?: number }, + filters: { status?: EmailTemplateStatus; name?: string }, + ) { + const { page, perPage } = parsePagination(paginationQuery.page, paginationQuery.perPage, { + perPage: DEFAULT_PAGE_SIZE, + }); + const conditions = this.buildListConditions(filters); + + return this.repository.listTemplates({ page, perPage }, conditions); + } + + async createTemplate(input: CreateEmailNotificationTemplate) { + this.validateLocales(input.baseLanguage, input.availableLocales); + const blocks = input.blocks ?? buildDefaultEmailTemplateBlocks(input.baseLanguage); + const strings = input.strings ?? {}; + const subject = input.subject ?? {}; + + this.assertSafeRenderedBlockUrls({ + blocks, + strings, + availableLocales: input.availableLocales, + baseLanguage: input.baseLanguage, + }); + + if (input.name) { + await this.ensureNameAvailable(input.name); + const template = await this.createTemplateOrThrowNameConflict({ + ...input, + name: input.name, + subject, + blocks, + strings, + }); + if (!template) throw new BadRequestException("emailTemplates.toast.createFailed"); + return template; + } + + return this.createWithAutoName({ ...input, subject, blocks, strings }); + } + + private async createWithAutoName( + input: CreateEmailNotificationTemplate & { + subject: LocalizedText; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + }, + ) { + const name = await this.buildNextAutoTemplateName(); + + return this.createTemplateOrThrowNameConflict({ ...input, name }); + } + + async getTemplateById(id: UUIDType) { + const template = await this.repository.findById(id); + if (!template) throw new NotFoundException("emailTemplates.toast.notFound"); + + return template; + } + + async updateTemplate(id: UUIDType, input: UpdateEmailNotificationTemplate, tenantId: UUIDType) { + const existing = await this.getTemplateById(id); + const nextBaseLanguage = input.baseLanguage ?? existing.baseLanguage; + const nextAvailableLocales = input.availableLocales ?? existing.availableLocales; + + if (input.baseLanguage !== undefined || input.availableLocales !== undefined) { + this.validateLocales(nextBaseLanguage, nextAvailableLocales); + } + + if (input.name !== undefined && input.name !== existing.name) { + await this.ensureNameAvailable(input.name, id); + } + + const nextBlocks = input.blocks ?? existing.blocks; + const nextStrings = input.strings ?? existing.strings; + const pruned = pruneOrphanStrings(nextBlocks, nextStrings); + if ( + input.blocks !== undefined || + input.strings !== undefined || + input.baseLanguage !== undefined || + input.availableLocales !== undefined + ) { + this.assertSafeRenderedBlockUrls({ + blocks: nextBlocks, + strings: pruned, + availableLocales: nextAvailableLocales, + baseLanguage: nextBaseLanguage, + }); + } + + const prevSrcs = new Set(collectImageSrcs(existing.blocks)); + const nextSrcs = new Set(collectImageSrcs(nextBlocks)); + const removedSrcs = new Set([...prevSrcs].filter((s) => !nextSrcs.has(s))); + + let template; + try { + template = await this.repository.updateTemplate( + id, + this.buildTemplateUpdates(input, nextBlocks, pruned), + ); + } catch (err) { + if (isPostgresUniqueViolation(err, EMAIL_TEMPLATE_NAME_UNIQUE_INDEX)) { + throw new ConflictException("emailTemplates.toast.nameAlreadyExists"); + } + throw err; + } + if (!template) throw new BadRequestException("emailTemplates.toast.updateFailed"); + + await this.cleanupOrphanedImages(removedSrcs, tenantId, id); + + return template; + } + + async publishTemplate(id: UUIDType) { + const existing = await this.getTemplateById(id); + this.assertSafeRenderedBlockUrls({ + blocks: existing.blocks, + strings: existing.strings, + availableLocales: existing.availableLocales, + baseLanguage: existing.baseLanguage, + }); + this.assertPublishable(existing); + + const template = await this.repository.setStatus(id, EMAIL_TEMPLATE_STATUSES.PUBLISHED, null); + if (!template) throw new BadRequestException("emailTemplates.toast.publishFailed"); + + return template; + } + + async makeDraftTemplate(id: UUIDType) { + await this.getTemplateById(id); + + const template = await this.repository.setStatus(id, EMAIL_TEMPLATE_STATUSES.DRAFT, null); + if (!template) throw new BadRequestException("emailTemplates.toast.makeDraftFailed"); + + return template; + } + + async duplicateTemplate(id: UUIDType) { + const source = await this.getTemplateById(id); + + const rekeyed = this.rekeyBlockUuids(source.blocks, source.strings); + const name = await this.buildDuplicateName(source.name); + + const duplicate = await this.repository.duplicateFrom({ + name, + baseLanguage: source.baseLanguage, + availableLocales: source.availableLocales, + subject: source.subject, + blocks: rekeyed.blocks, + strings: rekeyed.strings, + }); + + if (!duplicate) throw new BadRequestException("emailTemplates.toast.duplicateFailed"); + + return duplicate; + } + + async previewTemplate(id: UUIDType, tenantId: UUIDType, language?: SupportedLanguages) { + const template = await this.getTemplateById(id); + + const resolvedLanguage = language ?? template.baseLanguage; + if (!template.availableLocales.includes(resolvedLanguage)) { + throw new BadRequestException("emailTemplates.toast.previewLanguageUnavailable"); + } + + const primaryColor = await this.resolveTenantPrimaryColor(tenantId); + const tenantLogoSrc = + (await this.settingsService.getPlatformLogoUrl()) ?? DEFAULT_PLATFORM_LOGO_PATH; + + return renderTemplateContent({ + blocks: template.blocks, + strings: template.strings, + subject: template.subject, + language: resolvedLanguage, + baseLanguage: template.baseLanguage, + primaryColor, + tenantLogoSrc, + }); + } + + async sendTestEmail(id: UUIDType, currentUser: CurrentUserType, language?: SupportedLanguages) { + const template = await this.getTemplateById(id); + + const resolvedLanguage = language ?? template.baseLanguage; + if (!template.availableLocales.includes(resolvedLanguage)) { + throw new BadRequestException("emailTemplates.toast.previewLanguageUnavailable"); + } + + const { primaryColor } = await this.emailService.getDefaultEmailProperties( + currentUser.tenantId, + ); + + const { subject, html } = await renderTemplateContent({ + blocks: template.blocks, + strings: template.strings, + subject: template.subject, + language: resolvedLanguage, + baseLanguage: template.baseLanguage, + primaryColor, + tenantLogoSrc: TENANT_LOGO_CID_SRC, + }); + + await this.emailService.sendEmailWithLogo( + { to: currentUser.email, subject, html }, + { tenantId: currentUser.tenantId }, + ); + } + + private async resolveTenantPrimaryColor(tenantId: UUIDType): Promise { + if (!tenantId) return DEFAULT_TENANT_PRIMARY_COLOR; + const { primaryColor } = await this.emailService.getDefaultEmailProperties(tenantId); + return primaryColor; + } + + async archiveTemplate(id: UUIDType) { + await this.getTemplateById(id); + + const template = await this.repository.setStatus( + id, + EMAIL_TEMPLATE_STATUSES.ARCHIVED, + new Date().toISOString(), + ); + if (!template) throw new BadRequestException("emailTemplates.toast.archiveFailed"); + + return template; + } + + async deleteTemplate(id: UUIDType, tenantId: UUIDType) { + const existing = await this.getTemplateById(id); + const srcs = new Set(collectImageSrcs(existing.blocks)); + + const deleted = await this.repository.deleteTemplate(id); + if (!deleted) throw new BadRequestException("emailTemplates.toast.deleteFailed"); + + await this.cleanupOrphanedImages(srcs, tenantId); + } + + async deleteManyTemplates(ids: UUIDType[], tenantId: UUIDType) { + if (ids.length === 0) throw new BadRequestException("emailTemplates.toast.deleteFailed"); + + const blocksList = await this.repository.findBlocksByIds(ids); + const allSrcs = new Set(blocksList.flatMap((blocks) => [...collectImageSrcs(blocks)])); + + const deleted = await this.repository.deleteManyTemplates(ids); + if (deleted.length === 0) throw new NotFoundException("emailTemplates.toast.notFound"); + + await this.cleanupOrphanedImages(allSrcs, tenantId); + } + + async unarchiveTemplate(id: UUIDType) { + await this.getTemplateById(id); + + const template = await this.repository.setStatus(id, EMAIL_TEMPLATE_STATUSES.DRAFT, null); + if (!template) throw new BadRequestException("emailTemplates.toast.unarchiveFailed"); + + return template; + } + + private async ensureNameAvailable(name: string, excludeId?: UUIDType) { + const existing = await this.findTemplateByName(name, excludeId); + if (existing) { + throw new ConflictException("emailTemplates.toast.nameAlreadyExists"); + } + } + + private async createTemplateOrThrowNameConflict( + input: CreateEmailNotificationTemplate & { + name: string; + subject: LocalizedText; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + }, + ) { + try { + return await this.repository.createTemplate(input); + } catch (err) { + if (isPostgresUniqueViolation(err, EMAIL_TEMPLATE_NAME_UNIQUE_INDEX)) { + throw new ConflictException("emailTemplates.toast.nameAlreadyExists"); + } + throw err; + } + } + + private async buildDuplicateName(sourceName: string) { + const base = `Copy of ${sourceName}`; + const candidates = [base, ...Array.from({ length: 20 }, (_, i) => `${base} (${i + 2})`)]; + const taken = new Set(await this.repository.findExistingNames(candidates)); + + for (const candidate of candidates) { + if (!taken.has(candidate)) return candidate; + } + + let counter = candidates.length + 1; + while (await this.findTemplateByName(`${base} (${counter})`)) counter += 1; + return `${base} (${counter})`; + } + + private validateLocales( + baseLanguage: SupportedLanguages, + availableLocales: SupportedLanguages[], + ) { + const supportedLanguageValues = Object.values(SUPPORTED_LANGUAGES) as SupportedLanguages[]; + + for (const locale of availableLocales) { + if (!supportedLanguageValues.includes(locale)) { + throw new BadRequestException("emailTemplates.toast.invalidLocale"); + } + } + + const uniqueLocales = new Set(availableLocales); + if (uniqueLocales.size !== availableLocales.length) { + throw new BadRequestException("emailTemplates.toast.duplicateLocales"); + } + + if (!uniqueLocales.has(baseLanguage)) { + throw new BadRequestException("emailTemplates.toast.baseLanguageMissing"); + } + } + + private rekeyBlockUuids( + blocks: EmailTemplateBlocks, + strings: EmailTemplateStrings, + ): { blocks: EmailTemplateBlocks; strings: EmailTemplateStrings } { + const idMap = new Map(); + + const rekey = (node: EmailTemplateBlocks): EmailTemplateBlocks => { + const next: EmailTemplateBlocks = { ...node }; + if (node.attrs) { + next.attrs = { ...node.attrs }; + const uuid = node.attrs.uuid; + if (typeof uuid === "string") { + const fresh = crypto.randomUUID(); + idMap.set(uuid, fresh); + next.attrs.uuid = fresh; + } + } + if (node.content) { + next.content = node.content.map(rekey); + } + return next; + }; + + const rekeyedBlocks = rekey(blocks); + const rekeyedStrings: EmailTemplateStrings = {}; + for (const [language, byUuid] of Object.entries(strings)) { + if (!byUuid) continue; + const remapped: Record = {}; + for (const [oldUuid, fragment] of Object.entries(byUuid)) { + const newUuid = idMap.get(oldUuid); + if (newUuid) remapped[newUuid] = fragment; + } + if (Object.keys(remapped).length > 0) { + rekeyedStrings[language as keyof EmailTemplateStrings] = remapped; + } + } + return { blocks: rekeyedBlocks, strings: rekeyedStrings }; + } + + private assertSafeRenderedBlockUrls(params: { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + availableLocales: SupportedLanguages[]; + baseLanguage: SupportedLanguages; + }): void { + for (const language of params.availableLocales) { + assertSafeBlockUrls( + flattenTranslationsForRender({ + blocks: params.blocks, + strings: params.strings, + language, + baseLanguage: params.baseLanguage, + }), + ); + } + } + + private assertPublishable(template: { + name?: string; + availableLocales: SupportedLanguages[]; + baseLanguage: SupportedLanguages; + subject: LocalizedText; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + }): void { + const diagnostics = computeEmailTemplateDiagnostics({ + name: template.name, + availableLocales: template.availableLocales, + baseLanguage: template.baseLanguage, + subject: template.subject, + blocks: template.blocks, + strings: template.strings, + }); + + if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + throw new BadRequestException("emailTemplates.toast.publishBlocked"); + } + } + + private async cleanupOrphanedImages( + srcs: Set, + tenantId: UUIDType, + excludeTemplateId?: UUIDType, + ): Promise { + if (srcs.size === 0) return; + try { + await this.cleanupQueue.enqueueImageCleanup({ + tenantId, + srcs: [...srcs], + excludeTemplateId, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Failed to enqueue email template image cleanup: ${message}`); + } + } + + async purgeOrphanedImages({ + tenantId, + srcs, + excludeTemplateId, + }: EmailTemplateImageCleanupJobData): Promise { + const keyList = [ + ...new Set( + srcs + .map((src) => extractTenantEmailTemplateImageFileKeyFromUrl(src, tenantId)) + .filter((key): key is string => Boolean(key)), + ), + ]; + const stillReferenced = await this.findReferencedImageKeys( + keyList, + tenantId, + excludeTemplateId, + ); + const orphaned = keyList.filter((key) => !stillReferenced.has(key)); + await Promise.all( + orphaned.map(async (key) => { + await this.fileService.deleteFile(key); + }), + ); + } + + private buildListConditions(filters: { status?: EmailTemplateStatus; name?: string }): SQL[] { + const conditions: SQL[] = []; + if (filters.status) conditions.push(eq(emailNotificationTemplates.status, filters.status)); + if (filters.name) conditions.push(ilike(emailNotificationTemplates.name, `%${filters.name}%`)); + + return conditions; + } + + private buildTemplateUpdates( + input: UpdateEmailNotificationTemplate, + blocks: EmailTemplateBlocks, + strings: EmailTemplateStrings, + ): Partial { + const updates: Partial = {}; + + if (input.name !== undefined) updates.name = input.name; + if (input.baseLanguage !== undefined) updates.baseLanguage = input.baseLanguage; + if (input.availableLocales !== undefined) updates.availableLocales = input.availableLocales; + if (input.subject !== undefined) updates.subject = input.subject; + updates.blocks = blocks; + updates.strings = strings; + + return updates; + } + + private async findTemplateByName(name: string, excludeId?: UUIDType) { + const conditions: SQL[] = [eq(emailNotificationTemplates.name, name)]; + if (excludeId) conditions.push(ne(emailNotificationTemplates.id, excludeId)); + + return this.repository.findByName(conditions); + } + + private async buildNextAutoTemplateName() { + const names = await this.repository.findAutoTemplateNames(); + const maxNumber = names.reduce((max, name) => { + const match = EMAIL_TEMPLATE_AUTO_NAME_REGEX.exec(name); + if (!match) return max; + + const value = Number(match[1]); + return Number.isInteger(value) && value > max ? value : max; + }, 0); + + return `Email template #${maxNumber + 1}`; + } + + private async findReferencedImageKeys( + keys: string[], + tenantId: UUIDType, + excludeTemplateId?: UUIDType, + ): Promise> { + const keySet = new Set(keys); + if (keySet.size === 0) return new Set(); + + const blocksList = await this.repository.findTemplateBlocks(excludeTemplateId); + const out = new Set(); + for (const blocks of blocksList) { + for (const src of collectImageSrcs(blocks)) { + const key = extractTenantEmailTemplateImageFileKeyFromUrl(src, tenantId); + if (key && keySet.has(key)) out.add(key); + } + } + + return out; + } +} diff --git a/apps/api/src/email-notification-templates/schemas/createEmailNotificationTemplate.schema.ts b/apps/api/src/email-notification-templates/schemas/createEmailNotificationTemplate.schema.ts new file mode 100644 index 0000000000..01d30edcb4 --- /dev/null +++ b/apps/api/src/email-notification-templates/schemas/createEmailNotificationTemplate.schema.ts @@ -0,0 +1,19 @@ +import { Type, type Static } from "@sinclair/typebox"; + +import { + emailTemplateBlocksSchema, + emailTemplateLanguageSchema, + emailTemplateStringsSchema, + localizedTextSchema, +} from "./emailNotificationTemplate.schema"; + +export const createEmailNotificationTemplateSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + baseLanguage: emailTemplateLanguageSchema, + availableLocales: Type.Array(emailTemplateLanguageSchema, { minItems: 1 }), + subject: Type.Optional(localizedTextSchema), + blocks: Type.Optional(emailTemplateBlocksSchema), + strings: Type.Optional(emailTemplateStringsSchema), +}); + +export type CreateEmailNotificationTemplate = Static; diff --git a/apps/api/src/email-notification-templates/schemas/emailNotificationTemplate.schema.ts b/apps/api/src/email-notification-templates/schemas/emailNotificationTemplate.schema.ts new file mode 100644 index 0000000000..9775c42bd1 --- /dev/null +++ b/apps/api/src/email-notification-templates/schemas/emailNotificationTemplate.schema.ts @@ -0,0 +1,76 @@ +import { EMAIL_TEMPLATE_STATUSES, SUPPORTED_LANGUAGES } from "@repo/shared"; +import { Type, type Static } from "@sinclair/typebox"; +import { createSelectSchema } from "drizzle-typebox"; + +import { emailNotificationTemplates } from "src/storage/schema"; +import { omitTenantId } from "src/utils/omitTenantId"; + +import type { EmailTemplateBlocks, EmailTemplateStrings, LocalizedText } from "@repo/shared"; + +export const emailTemplateLanguageSchema = Type.Enum(SUPPORTED_LANGUAGES); +export const emailTemplateStatusSchema = Type.Enum(EMAIL_TEMPLATE_STATUSES); + +export const tiptapJsonNodeSchema = Type.Recursive( + (self) => + Type.Object( + { + type: Type.Optional(Type.String()), + attrs: Type.Optional(Type.Record(Type.String(), Type.Any())), + content: Type.Optional(Type.Array(self)), + marks: Type.Optional( + Type.Array( + Type.Object( + { + type: Type.String(), + attrs: Type.Optional(Type.Record(Type.String(), Type.Any())), + }, + { additionalProperties: true }, + ), + ), + ), + text: Type.Optional(Type.String()), + }, + { additionalProperties: true }, + ), + { $id: "TiptapJsonNode" }, +); + +export const emailTemplateBlocksSchema = Type.Unsafe(tiptapJsonNodeSchema); + +export const emailTemplateStringsSchema = Type.Unsafe( + Type.Partial( + Type.Record( + emailTemplateLanguageSchema, + Type.Record(Type.String(), Type.Array(tiptapJsonNodeSchema)), + ), + ), +); + +export const localizedTextSchema = Type.Unsafe( + Type.Partial(Type.Record(emailTemplateLanguageSchema, Type.String())), +); + +export const emailNotificationTemplateSchema = Type.Composite([ + Type.Omit(omitTenantId(createSelectSchema(emailNotificationTemplates)), [ + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt", + ]), + Type.Object({ + subject: localizedTextSchema, + blocks: emailTemplateBlocksSchema, + strings: emailTemplateStringsSchema, + baseLanguage: emailTemplateLanguageSchema, + availableLocales: Type.Array(emailTemplateLanguageSchema), + status: emailTemplateStatusSchema, + archivedAt: Type.Union([Type.String(), Type.Null()]), + }), +]); + +export const emailNotificationTemplatesListSchema = Type.Array(emailNotificationTemplateSchema); + +export type EmailNotificationTemplate = Static; diff --git a/apps/api/src/email-notification-templates/schemas/emailTemplateImage.schema.ts b/apps/api/src/email-notification-templates/schemas/emailTemplateImage.schema.ts new file mode 100644 index 0000000000..474a9cec98 --- /dev/null +++ b/apps/api/src/email-notification-templates/schemas/emailTemplateImage.schema.ts @@ -0,0 +1,9 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const emailTemplateImageUploadResponseSchema = Type.Object({ + url: Type.String(), +}); + +export type EmailTemplateImageUploadResponse = Static< + typeof emailTemplateImageUploadResponseSchema +>; diff --git a/apps/api/src/email-notification-templates/schemas/previewEmailNotificationTemplate.schema.ts b/apps/api/src/email-notification-templates/schemas/previewEmailNotificationTemplate.schema.ts new file mode 100644 index 0000000000..a3fa65271f --- /dev/null +++ b/apps/api/src/email-notification-templates/schemas/previewEmailNotificationTemplate.schema.ts @@ -0,0 +1,12 @@ +import { SUPPORTED_LANGUAGES } from "@repo/shared"; +import { Type, type Static } from "@sinclair/typebox"; + +export const previewEmailNotificationTemplateSchema = Type.Object({ + language: Type.Enum(SUPPORTED_LANGUAGES), + subject: Type.String(), + html: Type.String(), +}); + +export type PreviewEmailNotificationTemplate = Static< + typeof previewEmailNotificationTemplateSchema +>; diff --git a/apps/api/src/email-notification-templates/schemas/updateEmailNotificationTemplate.schema.ts b/apps/api/src/email-notification-templates/schemas/updateEmailNotificationTemplate.schema.ts new file mode 100644 index 0000000000..eac9fb1570 --- /dev/null +++ b/apps/api/src/email-notification-templates/schemas/updateEmailNotificationTemplate.schema.ts @@ -0,0 +1,21 @@ +import { Type, type Static } from "@sinclair/typebox"; + +import { + emailTemplateBlocksSchema, + emailTemplateLanguageSchema, + emailTemplateStringsSchema, + localizedTextSchema, +} from "./emailNotificationTemplate.schema"; + +export const updateEmailNotificationTemplateSchema = Type.Partial( + Type.Object({ + name: Type.String({ minLength: 1, maxLength: 200 }), + baseLanguage: emailTemplateLanguageSchema, + availableLocales: Type.Array(emailTemplateLanguageSchema, { minItems: 1 }), + subject: localizedTextSchema, + blocks: emailTemplateBlocksSchema, + strings: emailTemplateStringsSchema, + }), +); + +export type UpdateEmailNotificationTemplate = Static; diff --git a/apps/api/src/email-notification-templates/utils/assertSafeBlockUrls.ts b/apps/api/src/email-notification-templates/utils/assertSafeBlockUrls.ts new file mode 100644 index 0000000000..7c26c4d84a --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/assertSafeBlockUrls.ts @@ -0,0 +1,56 @@ +import { BadRequestException } from "@nestjs/common"; +import { EMAIL_TEMPLATE_NODE_TYPES, TENANT_LOGO_VARIABLE } from "@repo/shared"; + +import type { EmailTemplateBlocks } from "@repo/shared"; + +const SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i; +const ALLOWED_SCHEMES = new Set(["http:", "https:", "mailto:"]); +const SAFE_IMAGE_SRC_PLACEHOLDERS = new Set([TENANT_LOGO_VARIABLE]); + +function isSafeUrl(value: string): boolean { + if (!value.trim()) return true; + + const match = SCHEME_REGEX.exec(value); + if (match) { + return ALLOWED_SCHEMES.has(match[0].toLowerCase()); + } + return value.startsWith("/"); +} + +function isSafeImageSrc(value: string): boolean { + return SAFE_IMAGE_SRC_PLACEHOLDERS.has(value) || isSafeUrl(value); +} + +function walkNode(node: EmailTemplateBlocks): void { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.IMAGE && typeof node.attrs?.src === "string") { + if (!isSafeImageSrc(node.attrs.src)) { + throw new BadRequestException("emailTemplates.toast.invalidUrl"); + } + } + + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON && typeof node.attrs?.url === "string") { + if (!isSafeUrl(node.attrs.url)) { + throw new BadRequestException("emailTemplates.toast.invalidUrl"); + } + } + + if (Array.isArray(node.marks)) { + for (const mark of node.marks) { + if (mark.type === "link" && typeof mark.attrs?.href === "string") { + if (!isSafeUrl(mark.attrs.href)) { + throw new BadRequestException("emailTemplates.toast.invalidUrl"); + } + } + } + } + + if (Array.isArray(node.content)) { + for (const child of node.content) { + walkNode(child); + } + } +} + +export function assertSafeBlockUrls(node: EmailTemplateBlocks): void { + walkNode(node); +} diff --git a/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.spec.ts b/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.spec.ts new file mode 100644 index 0000000000..41a002b641 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.spec.ts @@ -0,0 +1,64 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + SUPPORTED_LANGUAGES, + TENANT_LOGO_VARIABLE, +} from "@repo/shared"; + +import { buildDefaultEmailTemplateBlocks } from "./buildDefaultEmailTemplateBlocks"; + +describe("buildDefaultEmailTemplateBlocks", () => { + it("starts new English templates with logo, heading 2, paragraph, button, divider, and footer", () => { + const blocks = buildDefaultEmailTemplateBlocks(); + const content = blocks.content ?? []; + + expect(blocks.type).toBe(EMAIL_TEMPLATE_NODE_TYPES.DOC); + expect(content.map((node) => node.type)).toEqual([ + EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + EMAIL_TEMPLATE_NODE_TYPES.HEADING, + EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + EMAIL_TEMPLATE_NODE_TYPES.HORIZONTAL_RULE, + EMAIL_TEMPLATE_NODE_TYPES.FOOTER, + ]); + + expect(content[0]?.attrs).toMatchObject({ + src: TENANT_LOGO_VARIABLE, + alignment: "center", + width: null, + height: "32", + }); + expect(content[1]?.attrs).toMatchObject({ level: 2, textAlign: "center" }); + expect(content[1]?.content?.[0]?.text).toBe("Heading 2"); + expect(content[2]?.attrs).toMatchObject({ textAlign: "center" }); + expect(content[2]?.content?.[0]?.text).toBe("Paragraph text"); + expect(content[3]?.attrs).toMatchObject({ + text: "Button", + url: "", + alignment: "center", + variant: "filled", + borderRadius: "smooth", + }); + expect(content[5]?.attrs).toMatchObject({ textAlign: "center" }); + expect(content[5]?.content?.[0]?.text).toBe("Footer text"); + }); + + it("uses the selected base language for placeholder text", () => { + const blocks = buildDefaultEmailTemplateBlocks(SUPPORTED_LANGUAGES.PL); + const content = blocks.content ?? []; + + expect(content[1]?.content?.[0]?.text).toBe("Nagłówek 2"); + expect(content[2]?.content?.[0]?.text).toBe("Tekst akapitu"); + expect(content[3]?.attrs?.text).toBe("Przycisk"); + expect(content[5]?.content?.[0]?.text).toBe("Tekst stopki"); + }); + + it("stamps every top-level editable block with a uuid", () => { + const content = buildDefaultEmailTemplateBlocks().content ?? []; + const uuids = content.map((node) => node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]); + + expect(uuids).toHaveLength(6); + expect(uuids.every((uuid) => typeof uuid === "string" && uuid.length > 0)).toBe(true); + expect(new Set(uuids).size).toBe(6); + }); +}); diff --git a/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.ts b/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.ts new file mode 100644 index 0000000000..3b00b398b1 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.ts @@ -0,0 +1,128 @@ +import { randomUUID } from "crypto"; + +import { + EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + SUPPORTED_LANGUAGES, + TENANT_LOGO_VARIABLE, +} from "@repo/shared"; + +import type { EmailTemplateBlocks, SupportedLanguages } from "@repo/shared"; + +const DEFAULT_BUTTON_URL = ""; +const DEFAULT_TENANT_LOGO_HEIGHT = "32"; +const DEFAULT_CONTENT_ALIGNMENT = "center"; + +const DEFAULT_PLACEHOLDER_TEXT: Record< + SupportedLanguages, + { + heading: string; + paragraph: string; + button: string; + footer: string; + } +> = { + [SUPPORTED_LANGUAGES.EN]: { + heading: "Heading 2", + paragraph: "Paragraph text", + button: "Button", + footer: "Footer text", + }, + [SUPPORTED_LANGUAGES.PL]: { + heading: "Nagłówek 2", + paragraph: "Tekst akapitu", + button: "Przycisk", + footer: "Tekst stopki", + }, + [SUPPORTED_LANGUAGES.DE]: { + heading: "Überschrift 2", + paragraph: "Absatztext", + button: "Schaltfläche", + footer: "Fußzeilentext", + }, + [SUPPORTED_LANGUAGES.LT]: { + heading: "Antraštė 2", + paragraph: "Pastraipos tekstas", + button: "Mygtukas", + footer: "Poraštės tekstas", + }, + [SUPPORTED_LANGUAGES.CS]: { + heading: "Nadpis 2", + paragraph: "Text odstavce", + button: "Tlačítko", + footer: "Text zápatí", + }, + [SUPPORTED_LANGUAGES.ES]: { + heading: "Encabezado 2", + paragraph: "Texto de párrafo", + button: "Botón", + footer: "Texto del pie de página", + }, + [SUPPORTED_LANGUAGES.FR]: { + heading: "Titre 2", + paragraph: "Texte du paragraphe", + button: "Bouton", + footer: "Texte du pied de page", + }, +}; + +const textNode = (text: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, + text, +}); + +const withUuid = (attrs?: Record) => ({ + [EMAIL_TEMPLATE_NODE_UUID_ATTR]: randomUUID(), + ...attrs, +}); + +export const buildDefaultEmailTemplateBlocks = ( + baseLanguage: SupportedLanguages = SUPPORTED_LANGUAGES.EN, +): EmailTemplateBlocks => { + const placeholders = + DEFAULT_PLACEHOLDER_TEXT[baseLanguage] ?? DEFAULT_PLACEHOLDER_TEXT[SUPPORTED_LANGUAGES.EN]; + + return { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: withUuid({ + src: TENANT_LOGO_VARIABLE, + alignment: DEFAULT_CONTENT_ALIGNMENT, + width: null, + height: DEFAULT_TENANT_LOGO_HEIGHT, + }), + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.HEADING, + attrs: withUuid({ level: 2, textAlign: DEFAULT_CONTENT_ALIGNMENT }), + content: [textNode(placeholders.heading)], + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: withUuid({ textAlign: DEFAULT_CONTENT_ALIGNMENT }), + content: [textNode(placeholders.paragraph)], + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: withUuid({ + text: placeholders.button, + url: DEFAULT_BUTTON_URL, + alignment: DEFAULT_CONTENT_ALIGNMENT, + variant: "filled", + borderRadius: "smooth", + }), + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.HORIZONTAL_RULE, + attrs: withUuid(), + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.FOOTER, + attrs: withUuid({ textAlign: DEFAULT_CONTENT_ALIGNMENT }), + content: [textNode(placeholders.footer)], + }, + ], + }; +}; diff --git a/apps/api/src/email-notification-templates/utils/buildEmailTemplateImageUrl.ts b/apps/api/src/email-notification-templates/utils/buildEmailTemplateImageUrl.ts new file mode 100644 index 0000000000..1525f9a544 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/buildEmailTemplateImageUrl.ts @@ -0,0 +1,10 @@ +import { EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH } from "../email-template-image.constants"; + +export const buildEmailTemplateImageUrl = (params: { + tenantHost: string; + reference: string; +}): string => { + return ( + params.tenantHost + EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH + encodeURIComponent(params.reference) + ); +}; diff --git a/apps/api/src/email-notification-templates/utils/emailTemplateImageUrl.ts b/apps/api/src/email-notification-templates/utils/emailTemplateImageUrl.ts new file mode 100644 index 0000000000..fd140f43b1 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/emailTemplateImageUrl.ts @@ -0,0 +1,41 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; + +import { RESOURCE_CATEGORIES } from "src/file/file.constants"; + +import { EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH } from "../email-template-image.constants"; + +import type { EmailTemplateNode } from "@repo/shared"; + +export function* collectImageSrcs(node: EmailTemplateNode): Generator { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.IMAGE && typeof node.attrs?.src === "string") { + yield node.attrs.src; + } + for (const child of node.content ?? []) { + yield* collectImageSrcs(child); + } +} + +export const extractFileKeyFromImageUrl = (url: string): string | null => { + const idx = url.indexOf(EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH); + if (idx === -1) return null; + + try { + return decodeURIComponent(url.slice(idx + EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH.length)); + } catch { + return null; + } +}; + +export const isEmailTemplateImageFileKeyForTenant = (key: string, tenantId: string): boolean => { + const expectedPrefix = `${tenantId}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/`; + return key.startsWith(expectedPrefix); +}; + +export const extractTenantEmailTemplateImageFileKeyFromUrl = ( + url: string, + tenantId: string, +): string | null => { + const key = extractFileKeyFromImageUrl(url); + if (!key || !isEmailTemplateImageFileKeyForTenant(key, tenantId)) return null; + return key; +}; diff --git a/apps/api/src/email-notification-templates/utils/flattenTranslationsForRender.ts b/apps/api/src/email-notification-templates/utils/flattenTranslationsForRender.ts new file mode 100644 index 0000000000..cb69814d81 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/flattenTranslationsForRender.ts @@ -0,0 +1,81 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + cloneEmailTemplateNode, +} from "@repo/shared"; + +import type { + EmailTemplateBlocks, + EmailTemplateNode, + EmailTemplateStrings, + SupportedLanguages, + TranslationFragment, +} from "@repo/shared"; + +const readNodeUuid = (node: EmailTemplateNode): string | null => { + const raw = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + return typeof raw === "string" ? raw : null; +}; + +const fragmentToPlainString = (fragment: TranslationFragment): string => { + let out = ""; + for (const node of fragment) { + if (typeof node.text === "string") out += node.text; + else if (node.content) out += fragmentToPlainString(node.content as TranslationFragment); + } + return out; +}; + +const isFragmentEmpty = (fragment: TranslationFragment | undefined): boolean => { + if (!fragment || fragment.length === 0) return true; + return fragmentToPlainString(fragment).trim().length === 0; +}; + +const pickOverride = ( + strings: EmailTemplateStrings, + language: SupportedLanguages, + baseLanguage: SupportedLanguages, + uuid: string, +): TranslationFragment | undefined => { + if (language === baseLanguage) return undefined; + const target = strings[language]?.[uuid]; + if (!isFragmentEmpty(target)) return target; + const base = strings[baseLanguage]?.[uuid]; + if (!isFragmentEmpty(base)) return base; + return undefined; +}; + +export const flattenTranslationsForRender = (params: { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + language: SupportedLanguages; + baseLanguage: SupportedLanguages; +}): EmailTemplateBlocks => { + const { strings, language, baseLanguage } = params; + const blocks = cloneEmailTemplateNode(params.blocks); + + const walk = (node: EmailTemplateNode): void => { + if (node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) { + const uuid = readNodeUuid(node); + if (uuid) { + const override = pickOverride(strings, language, baseLanguage, uuid); + if (override) { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + if (!node.attrs) node.attrs = {}; + node.attrs.text = fragmentToPlainString(override); + } else { + node.content = override.map(cloneEmailTemplateNode); + } + } + } + } + + if (node.content) { + for (const child of node.content) walk(child); + } + }; + + walk(blocks); + return blocks; +}; diff --git a/apps/api/src/email-notification-templates/utils/pruneOrphanStrings.ts b/apps/api/src/email-notification-templates/utils/pruneOrphanStrings.ts new file mode 100644 index 0000000000..1377cfd086 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/pruneOrphanStrings.ts @@ -0,0 +1,32 @@ +import { EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; + +import type { EmailTemplateBlocks, EmailTemplateNode, EmailTemplateStrings } from "@repo/shared"; + +const collectUuids = (node: EmailTemplateNode, into: Set): void => { + const raw = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof raw === "string" && raw.length > 0) into.add(raw); + if (node.content) { + for (const child of node.content) collectUuids(child, into); + } +}; + +export const pruneOrphanStrings = ( + blocks: EmailTemplateBlocks, + strings: EmailTemplateStrings, +): EmailTemplateStrings => { + const liveUuids = new Set(); + collectUuids(blocks, liveUuids); + + const pruned: EmailTemplateStrings = {}; + for (const [language, byUuid] of Object.entries(strings)) { + if (!byUuid) continue; + const kept: Record = {}; + for (const [uuid, fragment] of Object.entries(byUuid)) { + if (liveUuids.has(uuid)) kept[uuid] = fragment; + } + if (Object.keys(kept).length > 0) { + pruned[language as keyof EmailTemplateStrings] = kept; + } + } + return pruned; +}; diff --git a/apps/api/src/email-notification-templates/utils/renderTemplateContent.ts b/apps/api/src/email-notification-templates/utils/renderTemplateContent.ts new file mode 100644 index 0000000000..031634b74f --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/renderTemplateContent.ts @@ -0,0 +1,174 @@ +import { Maily } from "@maily-to/render"; +import { EMAIL_TEMPLATE_NODE_TYPES, TENANT_LOGO_CID_SRC, TENANT_LOGO_VARIABLE } from "@repo/shared"; +import { load as loadHtml } from "cheerio"; + +import { assertSafeBlockUrls } from "./assertSafeBlockUrls"; +import { flattenTranslationsForRender } from "./flattenTranslationsForRender"; + +import type { + EmailTemplateBlocks, + EmailTemplateNode, + EmailTemplateStrings, + LocalizedText, + SupportedLanguages, +} from "@repo/shared"; + +export type RenderTemplateOutput = { + language: SupportedLanguages; + subject: string; + html: string; +}; + +const CARD_MAX_WIDTH_PX = 500; +const CARD_BORDER_RADIUS_PX = 24; +const CARD_PADDING_X_PX = 50; +const CARD_PADDING_TOP_PX = 32; +const CARD_PADDING_BOTTOM_PX = 32; +const BODY_BACKGROUND = "#fafafa"; +const CARD_BACKGROUND = "#ffffff"; +const HEADER_PADDING_TOP_PX = 50; + +export const renderTemplateContent = async (params: { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + subject: LocalizedText; + language: SupportedLanguages; + baseLanguage: SupportedLanguages; + primaryColor: string; + tenantLogoSrc?: string | null; + previewText?: string; +}): Promise => { + const monolingualBlocks = flattenTranslationsForRender({ + blocks: params.blocks, + strings: params.strings, + language: params.language, + baseLanguage: params.baseLanguage, + }); + assertSafeBlockUrls(monolingualBlocks); + replaceTenantLogoSrc(monolingualBlocks, params.tenantLogoSrc ?? TENANT_LOGO_CID_SRC); + + const maily = new Maily(monolingualBlocks as never); + if (params.previewText) maily.setPreviewText(params.previewText); + maily.setTheme({ + body: { + backgroundColor: BODY_BACKGROUND, + }, + container: { + backgroundColor: CARD_BACKGROUND, + maxWidth: `${CARD_MAX_WIDTH_PX}px`, + borderRadius: `${CARD_BORDER_RADIUS_PX}px`, + borderWidth: "0px", + borderColor: "transparent", + paddingTop: `${CARD_PADDING_TOP_PX}px`, + paddingBottom: `${CARD_PADDING_BOTTOM_PX}px`, + paddingLeft: `${CARD_PADDING_X_PX}px`, + paddingRight: `${CARD_PADDING_X_PX}px`, + }, + }); + const rawHtml = await maily.render(); + const html = applyEmailLayout(rawHtml, params.primaryColor); + + const localizedSubject = params.subject[params.language]; + const baseSubject = params.subject[params.baseLanguage]; + const subject = + localizedSubject && localizedSubject.trim().length > 0 ? localizedSubject : (baseSubject ?? ""); + + return { + language: params.language, + subject, + html, + }; +}; + +const replaceTenantLogoSrc = (node: EmailTemplateNode, tenantLogoSrc: string): void => { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.IMAGE && node.attrs?.src === TENANT_LOGO_VARIABLE) { + node.attrs = { + ...node.attrs, + src: tenantLogoSrc, + }; + } + + if (!Array.isArray(node.content)) return; + for (const child of node.content) { + replaceTenantLogoSrc(child, tenantLogoSrc); + } +}; + +const applyEmailLayout = (rawHtml: string, primaryColor: string): string => { + const $ = loadHtml(rawHtml); + + const outerTable = $("body > table").first(); + if (outerTable.length === 0) return rawHtml; + + const wrapperTd = outerTable.find("tbody > tr > td").first(); + if (wrapperTd.length === 0) return rawHtml; + + const nodes = wrapperTd + .children("table") + .first() + .children("tbody") + .first() + .children("tr") + .first() + .children("td") + .first() + .children() + .toArray(); + if (nodes.length === 0) return rawHtml; + + const topCount = Math.ceil(nodes.length / 2); + const topBlocksHtml = nodes + .slice(0, topCount) + .map((n) => $.html(n)) + .join(""); + const bottomBlocksHtml = nodes + .slice(topCount) + .map((n) => $.html(n)) + .join(""); + + const hasBottom = bottomBlocksHtml.length > 0; + + const topCornerRadius = hasBottom + ? `${CARD_BORDER_RADIUS_PX}px ${CARD_BORDER_RADIUS_PX}px 0 0` + : `${CARD_BORDER_RADIUS_PX}px`; + const bottomCornerRadius = `0 0 ${CARD_BORDER_RADIUS_PX}px ${CARD_BORDER_RADIUS_PX}px`; + + const buildCard = (borderRadius: string, tdPadding: string, blocksHtml: string) => + `
` + + `` + + `
${blocksHtml}
`; + + const topCardHtml = buildCard( + topCornerRadius, + `${CARD_PADDING_TOP_PX}px ${CARD_PADDING_X_PX}px ${hasBottom ? 0 : CARD_PADDING_BOTTOM_PX}px ${CARD_PADDING_X_PX}px`, + topBlocksHtml, + ); + + const bottomCardHtml = hasBottom + ? buildCard( + bottomCornerRadius, + `0 ${CARD_PADDING_X_PX}px ${CARD_PADDING_BOTTOM_PX}px ${CARD_PADDING_X_PX}px`, + bottomBlocksHtml, + ) + : ""; + + const topSectionHtml = + `
` + + `${topCardHtml}
`; + + const bottomSectionHtml = hasBottom + ? `
` + + `${bottomCardHtml}
` + : ""; + + const wrapperHtml = + `
` + + `${topSectionHtml}${bottomSectionHtml}` + + `
`; + + $("body").removeAttr("style"); + outerTable.replaceWith(wrapperHtml); + + return $.html(); +}; diff --git a/apps/api/src/file/file.constants.ts b/apps/api/src/file/file.constants.ts index ccd75a58c0..1861ef01a2 100644 --- a/apps/api/src/file/file.constants.ts +++ b/apps/api/src/file/file.constants.ts @@ -64,6 +64,7 @@ export const RESOURCE_CATEGORIES = { COURSE: "course", GLOBAL_SETTINGS: "global_settings", LIVE_TRAINING: "live_training", + EMAIL_TEMPLATE_IMAGE: "email_template_image", } as const; export type ResourceCategory = (typeof RESOURCE_CATEGORIES)[keyof typeof RESOURCE_CATEGORIES]; diff --git a/apps/api/src/file/file.service.ts b/apps/api/src/file/file.service.ts index 362ac65b4c..341748f161 100644 --- a/apps/api/src/file/file.service.ts +++ b/apps/api/src/file/file.service.ts @@ -199,7 +199,7 @@ export class FileService { file: Express.Multer.File, resource: string, tenantId?: UUIDType, - imageVariantOptions?: ImageVariantCreationOptions, + options?: ImageVariantCreationOptions & { skipVariants?: boolean }, ): Promise { if (file.size === 0) { throw new BadRequestException("files.toast.fileEmpty"); @@ -226,13 +226,17 @@ export class FileService { }); } - const imageVariantResult = await this.imageVariantService.createVariants({ - buffer: file.buffer, - resource, - mimeType: file.mimetype, - tenantId, - options: imageVariantOptions, - }); + const { skipVariants, ...imageVariantOptions } = options ?? {}; + + const imageVariantResult = skipVariants + ? null + : await this.imageVariantService.createVariants({ + buffer: file.buffer, + resource, + mimeType: file.mimetype, + tenantId, + options: imageVariantOptions, + }); if (imageVariantResult) { return { diff --git a/apps/api/src/public-email-template-image/__tests__/public-email-template-image.controller.e2e-spec.ts b/apps/api/src/public-email-template-image/__tests__/public-email-template-image.controller.e2e-spec.ts new file mode 100644 index 0000000000..13b66b1cbb --- /dev/null +++ b/apps/api/src/public-email-template-image/__tests__/public-email-template-image.controller.e2e-spec.ts @@ -0,0 +1,80 @@ +import request from "supertest"; + +import { RESOURCE_CATEGORIES } from "src/file/file.constants"; +import { FileService } from "src/file/file.service"; +import { DB_ADMIN } from "src/storage/db/db.providers"; + +import { createE2ETest } from "../../../test/create-e2e-test"; +import { createSettingsFactory } from "../../../test/factory/settings.factory"; +import { DEFAULT_TEST_TENANT_HOST } from "../../../test/helpers/tenant-helpers"; +import { truncateAllTables } from "../../../test/helpers/test-helpers"; + +import type { INestApplication } from "@nestjs/common"; +import type { DatabasePg } from "src/common"; + +describe("PublicEmailTemplateImageController (e2e)", () => { + let app: INestApplication; + let db: DatabasePg; + let baseDb: DatabasePg; + let settingsFactory: ReturnType; + let defaultTenantId: string; + + const mockFileService = { + getImageUrlByQuality: jest.fn(), + }; + + beforeAll(async () => { + const result = await createE2ETest([{ provide: FileService, useValue: mockFileService }]); + app = result.app; + db = result.db; + baseDb = app.get(DB_ADMIN); + defaultTenantId = result.defaultTenantId; + settingsFactory = createSettingsFactory(db); + }, 30000); + + afterAll(async () => { + await app.close(); + }, 10000); + + beforeEach(async () => { + jest.clearAllMocks(); + await settingsFactory.create({ userId: null }); + }); + + afterEach(async () => { + await truncateAllTables(baseDb, db); + }); + + describe("GET /api/public/email-template-image/:reference", () => { + it("redirects 302 to a signed URL when reference matches the Host tenant", async () => { + const signedUrl = "https://s3.example.com/signed-url?X-Amz-Expires=3600"; + mockFileService.getImageUrlByQuality.mockResolvedValue(signedUrl); + + const validRef = `${defaultTenantId}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/variants/uuid.webp`; + + const response = await request(app.getHttpServer()) + .get(`/api/public/email-template-image/${encodeURIComponent(validRef)}`) + .set("Referer", `${DEFAULT_TEST_TENANT_HOST}/`) + .redirects(0) + .expect(302); + + expect(response.headers.location).toBe(signedUrl); + expect(response.headers["cache-control"]).toContain("max-age=1800"); + }); + + it("returns placeholder SVG when reference belongs to a different tenant", async () => { + const alienTenantId = "99999999-9999-9999-9999-999999999999"; + const alienRef = `${alienTenantId}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/variants/uuid.webp`; + + const response = await request(app.getHttpServer()) + .get(`/api/public/email-template-image/${encodeURIComponent(alienRef)}`) + .set("Referer", `${DEFAULT_TEST_TENANT_HOST}/`) + .expect(200); + + expect(response.headers["content-type"]).toContain("image/svg+xml"); + expect(response.headers["cache-control"]).toContain("max-age=3600"); + expect(response.body.toString("utf8")).toContain(" { + const getImageUrlByQuality = jest.fn(); + const fileService = { getImageUrlByQuality } as unknown as FileService; + const service = new PublicEmailTemplateImageService(fileService); + return { service, getImageUrlByQuality }; +}; + +describe("PublicEmailTemplateImageService", () => { + describe("resolveSignedUrl", () => { + it("returns null when reference does not start with the tenant prefix", async () => { + const { service } = createService(); + const alienRef = `${OTHER_TENANT_ID}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/variants/uuid.webp`; + + const result = await service.resolveSignedUrl(alienRef, TENANT_ID); + + expect(result).toBeNull(); + }); + + it("returns null when reference has no tenant prefix at all", async () => { + const { service } = createService(); + + const result = await service.resolveSignedUrl( + "email_template_image/variants/uuid.webp", + TENANT_ID, + ); + + expect(result).toBeNull(); + }); + + it("resolves a valid variant reference through getImageUrlByQuality", async () => { + const { service, getImageUrlByQuality } = createService(); + const reference = `${TENANT_ID}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/variants/uuid.webp`; + const signedUrl = "https://s3.example.com/signed-url"; + getImageUrlByQuality.mockResolvedValue(signedUrl); + + const result = await service.resolveSignedUrl(reference, TENANT_ID); + + expect(getImageUrlByQuality).toHaveBeenCalledWith(reference); + expect(result).toBe(signedUrl); + }); + + it("decodes a percent-encoded reference before checking the prefix", async () => { + const { service, getImageUrlByQuality } = createService(); + const rawRef = `${TENANT_ID}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/variants/uuid.webp`; + const encoded = encodeURIComponent(rawRef); + const signedUrl = "https://s3.example.com/signed-url"; + getImageUrlByQuality.mockResolvedValue(signedUrl); + + const result = await service.resolveSignedUrl(encoded, TENANT_ID); + + expect(getImageUrlByQuality).toHaveBeenCalledWith(rawRef); + expect(result).toBe(signedUrl); + }); + }); +}); diff --git a/apps/api/src/public-email-template-image/public-email-template-image.controller.ts b/apps/api/src/public-email-template-image/public-email-template-image.controller.ts new file mode 100644 index 0000000000..7774d59ace --- /dev/null +++ b/apps/api/src/public-email-template-image/public-email-template-image.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Param, Req, Res } from "@nestjs/common"; +import { Request, Response } from "express"; + +import { Public } from "src/common/decorators/public.decorator"; +import { TenantResolverService } from "src/storage/db/tenant-resolver.service"; + +import { + EMAIL_TEMPLATE_IMAGE_CONTROLLER_PATH, + EMAIL_TEMPLATE_IMAGE_PLACEHOLDER_CACHE_MAX_AGE_SECONDS, + EMAIL_TEMPLATE_IMAGE_PLACEHOLDER_SVG, + EMAIL_TEMPLATE_IMAGE_REDIRECT_CACHE_MAX_AGE_SECONDS, +} from "../email-notification-templates/email-template-image.constants"; + +import { PublicEmailTemplateImageService } from "./public-email-template-image.service"; + +@Controller(EMAIL_TEMPLATE_IMAGE_CONTROLLER_PATH) +export class PublicEmailTemplateImageController { + constructor( + private readonly service: PublicEmailTemplateImageService, + private readonly tenantResolver: TenantResolverService, + ) {} + + @Get(":reference") + @Public() + async serve(@Param("reference") reference: string, @Req() req: Request, @Res() res: Response) { + const tenantId = await this.tenantResolver.resolveTenantId(req); + + const url = tenantId ? await this.service.resolveSignedUrl(reference, tenantId) : null; + + if (!url) { + res.setHeader( + "Cache-Control", + `public, max-age=${EMAIL_TEMPLATE_IMAGE_PLACEHOLDER_CACHE_MAX_AGE_SECONDS}`, + ); + res.type("image/svg+xml; charset=utf-8"); + res.send(EMAIL_TEMPLATE_IMAGE_PLACEHOLDER_SVG); + return; + } + + res.setHeader( + "Cache-Control", + `public, max-age=${EMAIL_TEMPLATE_IMAGE_REDIRECT_CACHE_MAX_AGE_SECONDS}`, + ); + res.redirect(302, url); + } +} diff --git a/apps/api/src/public-email-template-image/public-email-template-image.module.ts b/apps/api/src/public-email-template-image/public-email-template-image.module.ts new file mode 100644 index 0000000000..e6a1d710d4 --- /dev/null +++ b/apps/api/src/public-email-template-image/public-email-template-image.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; + +import { FileModule } from "src/file/files.module"; + +import { PublicEmailTemplateImageController } from "./public-email-template-image.controller"; +import { PublicEmailTemplateImageService } from "./public-email-template-image.service"; + +@Module({ + imports: [FileModule], + controllers: [PublicEmailTemplateImageController], + providers: [PublicEmailTemplateImageService], +}) +export class PublicEmailTemplateImageModule {} diff --git a/apps/api/src/public-email-template-image/public-email-template-image.service.ts b/apps/api/src/public-email-template-image/public-email-template-image.service.ts new file mode 100644 index 0000000000..db3b498773 --- /dev/null +++ b/apps/api/src/public-email-template-image/public-email-template-image.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; + +import { RESOURCE_CATEGORIES } from "src/file/file.constants"; +import { FileService } from "src/file/file.service"; + +import type { UUIDType } from "src/common"; + +@Injectable() +export class PublicEmailTemplateImageService { + constructor(private readonly fileService: FileService) {} + + async resolveSignedUrl(reference: string, tenantId: UUIDType): Promise { + const decoded = decodeURIComponent(reference); + const expectedPrefix = `${tenantId}/${RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE}/`; + + if (!decoded.startsWith(expectedPrefix)) return null; + + return this.fileService.getImageUrlByQuality(decoded); + } +} diff --git a/apps/api/src/queue/queue.types.ts b/apps/api/src/queue/queue.types.ts index 81846ad046..48ef914a6f 100644 --- a/apps/api/src/queue/queue.types.ts +++ b/apps/api/src/queue/queue.types.ts @@ -14,6 +14,7 @@ export const QUEUE_NAMES = { LUMA_COURSE_GENERATION_SYNC: "luma-course-generation-sync", AI_JUDGE_CONFIGURATION_GENERATION: "ai-judge-configuration-generation", MICROSOFT_CALENDAR_SYNC: "microsoft-calendar-sync", + EMAIL_TEMPLATE_IMAGE_CLEANUP: "email-template-image-cleanup", } as const; export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; @@ -75,3 +76,9 @@ export interface CourseDuplicationJobData { targetCourseId: UUIDType; actor: CurrentUserType; } + +export interface EmailTemplateImageCleanupJobData { + tenantId: UUIDType; + srcs: string[]; + excludeTemplateId?: UUIDType; +} diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index fb1001ea33..515a828443 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -9,6 +9,7 @@ import { ALLOWED_ARTICLES_SETTINGS, ALLOWED_NEWS_SETTINGS, ALLOWED_QA_SETTINGS, + DEFAULT_PLATFORM_LOGO_PATH, ENTITY_TYPES, FORM_TYPES, MAX_LOGIN_PAGE_DOCUMENTS, @@ -1056,7 +1057,7 @@ export class SettingsService { .where(isNull(settings.userId)); const logoUrl = - globalSettings?.platformLogoS3Key ?? `${CORS_ORIGIN}/app/assets/svgs/app-logo.svg`; + globalSettings?.platformLogoS3Key ?? `${CORS_ORIGIN}${DEFAULT_PLATFORM_LOGO_PATH}`; try { return await this.fileService.getFileBuffer(logoUrl); diff --git a/apps/api/src/storage/migrations/0181_add_email_notification_templates.sql b/apps/api/src/storage/migrations/0181_add_email_notification_templates.sql new file mode 100644 index 0000000000..c54fb66e85 --- /dev/null +++ b/apps/api/src/storage/migrations/0181_add_email_notification_templates.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS "email_notification_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "name" text NOT NULL, + "subject" jsonb DEFAULT '{}'::jsonb NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "blocks" jsonb DEFAULT '{"type":"doc","content":[]}'::jsonb NOT NULL, + "strings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "base_language" text DEFAULT 'en' NOT NULL, + "available_locales" text[] DEFAULT ARRAY['en']::text[] NOT NULL, + "archived_at" timestamp(3) with time zone, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "email_notification_templates" ADD CONSTRAINT "email_notification_templates_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "email_notification_templates_tenant_id_idx" ON "email_notification_templates" USING btree ("tenant_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "email_notification_templates_tenant_id_name_unique_idx" ON "email_notification_templates" USING btree ("tenant_id","name"); \ No newline at end of file diff --git a/apps/api/src/storage/migrations/0182_enable_email_notification_templates_rls.sql b/apps/api/src/storage/migrations/0182_enable_email_notification_templates_rls.sql new file mode 100644 index 0000000000..716bf2d491 --- /dev/null +++ b/apps/api/src/storage/migrations/0182_enable_email_notification_templates_rls.sql @@ -0,0 +1,14 @@ +DO $$ +BEGIN + ALTER TABLE public.email_notification_templates ENABLE ROW LEVEL SECURITY; + + BEGIN + CREATE POLICY email_notification_templates_tenant_isolation + ON public.email_notification_templates + USING (tenant_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid); + EXCEPTION + WHEN duplicate_object THEN NULL; + END; +END +$$; diff --git a/apps/api/src/storage/migrations/meta/0181_snapshot.json b/apps/api/src/storage/migrations/meta/0181_snapshot.json new file mode 100644 index 0000000000..3365f3026a --- /dev/null +++ b/apps/api/src/storage/migrations/meta/0181_snapshot.json @@ -0,0 +1,15449 @@ +{ + "id": "eb36f68f-0a0b-4fb1-b670-87bceb3bb087", + "prevId": "893607a2-3d24-4fe0-be9a-e89abcad47ff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "activity_logs_tenant_id_idx": { + "name": "activity_logs_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_tenant_timeframe_idx": { + "name": "activity_logs_tenant_timeframe_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_actor_idx": { + "name": "activity_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_action_idx": { + "name": "activity_logs_action_idx", + "columns": [ + { + "expression": "action_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_timeframe_idx": { + "name": "activity_logs_timeframe_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_resource_idx": { + "name": "activity_logs_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "activity_logs_actor_id_users_id_fk": { + "name": "activity_logs_actor_id_users_id_fk", + "tableFrom": "activity_logs", + "columnsFrom": [ + "actor_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "activity_logs_tenant_id_tenants_id_fk": { + "name": "activity_logs_tenant_id_tenants_id_fk", + "tableFrom": "activity_logs", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_judge_blocking_errors": { + "name": "ai_judge_blocking_errors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "configuration_id": { + "name": "configuration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_blocking_errors_tenant_id_idx": { + "name": "ai_judge_blocking_errors_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_judge_blocking_errors_configuration_id_created_at_idx": { + "name": "ai_judge_blocking_errors_configuration_id_created_at_idx", + "columns": [ + { + "expression": "configuration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_blocking_errors_configuration_id_ai_judge_configurations_id_fk": { + "name": "ai_judge_blocking_errors_configuration_id_ai_judge_configurations_id_fk", + "tableFrom": "ai_judge_blocking_errors", + "columnsFrom": [ + "configuration_id" + ], + "tableTo": "ai_judge_configurations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_blocking_errors_tenant_id_tenants_id_fk": { + "name": "ai_judge_blocking_errors_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_blocking_errors", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_judge_configurations": { + "name": "ai_judge_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_goal": { + "name": "task_goal", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "passing_threshold_percent": { + "name": "passing_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_configurations_tenant_id_idx": { + "name": "ai_judge_configurations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_configurations_ai_mentor_lesson_id_ai_mentor_lessons_id_fk": { + "name": "ai_judge_configurations_ai_mentor_lesson_id_ai_mentor_lessons_id_fk", + "tableFrom": "ai_judge_configurations", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "tableTo": "ai_mentor_lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_configurations_tenant_id_tenants_id_fk": { + "name": "ai_judge_configurations_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_configurations", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_judge_configurations_ai_mentor_lesson_id_unique": { + "name": "ai_judge_configurations_ai_mentor_lesson_id_unique", + "columns": [ + "ai_mentor_lesson_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.ai_judge_criteria": { + "name": "ai_judge_criteria", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "configuration_id": { + "name": "configuration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expected_behavior": { + "name": "expected_behavior", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_criteria_tenant_id_idx": { + "name": "ai_judge_criteria_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_judge_criteria_configuration_id_created_at_idx": { + "name": "ai_judge_criteria_configuration_id_created_at_idx", + "columns": [ + { + "expression": "configuration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_criteria_configuration_id_ai_judge_configurations_id_fk": { + "name": "ai_judge_criteria_configuration_id_ai_judge_configurations_id_fk", + "tableFrom": "ai_judge_criteria", + "columnsFrom": [ + "configuration_id" + ], + "tableTo": "ai_judge_configurations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_criteria_tenant_id_tenants_id_fk": { + "name": "ai_judge_criteria_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_criteria", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_judge_score_guidance": { + "name": "ai_judge_score_guidance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "criterion_id": { + "name": "criterion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "example": { + "name": "example", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_score_guidance_tenant_id_idx": { + "name": "ai_judge_score_guidance_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_judge_score_guidance_criterion_id_score_unique": { + "name": "ai_judge_score_guidance_criterion_id_score_unique", + "columns": [ + { + "expression": "criterion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_score_guidance_criterion_id_ai_judge_criteria_id_fk": { + "name": "ai_judge_score_guidance_criterion_id_ai_judge_criteria_id_fk", + "tableFrom": "ai_judge_score_guidance", + "columnsFrom": [ + "criterion_id" + ], + "tableTo": "ai_judge_criteria", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_score_guidance_tenant_id_tenants_id_fk": { + "name": "ai_judge_score_guidance_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_score_guidance", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_judgement_blocking_errors": { + "name": "ai_mentor_judgement_blocking_errors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "judgement_id": { + "name": "judgement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocking_error_id": { + "name": "blocking_error_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "blocking_error_description": { + "name": "blocking_error_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "learner_safe_feedback": { + "name": "learner_safe_feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_judgement_blocking_errors_tenant_id_idx": { + "name": "ai_mentor_judgement_blocking_errors_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_mentor_judgement_blocking_errors_judgement_id_blocking_error_id_unique": { + "name": "ai_mentor_judgement_blocking_errors_judgement_id_blocking_error_id_unique", + "columns": [ + { + "expression": "judgement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocking_error_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_judgement_blocking_errors_judgement_id_ai_mentor_judgements_id_fk": { + "name": "ai_mentor_judgement_blocking_errors_judgement_id_ai_mentor_judgements_id_fk", + "tableFrom": "ai_mentor_judgement_blocking_errors", + "columnsFrom": [ + "judgement_id" + ], + "tableTo": "ai_mentor_judgements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_judgement_blocking_errors_blocking_error_id_ai_judge_blocking_errors_id_fk": { + "name": "ai_mentor_judgement_blocking_errors_blocking_error_id_ai_judge_blocking_errors_id_fk", + "tableFrom": "ai_mentor_judgement_blocking_errors", + "columnsFrom": [ + "blocking_error_id" + ], + "tableTo": "ai_judge_blocking_errors", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "ai_mentor_judgement_blocking_errors_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgement_blocking_errors_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgement_blocking_errors", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_judgement_criteria": { + "name": "ai_mentor_judgement_criteria", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "judgement_id": { + "name": "judgement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "criterion_id": { + "name": "criterion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "criterion_title": { + "name": "criterion_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "awarded_points": { + "name": "awarded_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_score_at_judgement": { + "name": "max_score_at_judgement", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "learner_safe_feedback": { + "name": "learner_safe_feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_judgement_criteria_tenant_id_idx": { + "name": "ai_mentor_judgement_criteria_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_mentor_judgement_criteria_judgement_id_criterion_id_unique": { + "name": "ai_mentor_judgement_criteria_judgement_id_criterion_id_unique", + "columns": [ + { + "expression": "judgement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "criterion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_judgement_criteria_judgement_id_ai_mentor_judgements_id_fk": { + "name": "ai_mentor_judgement_criteria_judgement_id_ai_mentor_judgements_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", + "columnsFrom": [ + "judgement_id" + ], + "tableTo": "ai_mentor_judgements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_judgement_criteria_criterion_id_ai_judge_criteria_id_fk": { + "name": "ai_mentor_judgement_criteria_criterion_id_ai_judge_criteria_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", + "columnsFrom": [ + "criterion_id" + ], + "tableTo": "ai_judge_criteria", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "ai_mentor_judgement_criteria_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgement_criteria_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_judgements": { + "name": "ai_mentor_judgements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "configuration_id": { + "name": "configuration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "earned_points": { + "name": "earned_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_judgements_tenant_id_idx": { + "name": "ai_mentor_judgements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_judgements_thread_id_ai_mentor_threads_id_fk": { + "name": "ai_mentor_judgements_thread_id_ai_mentor_threads_id_fk", + "tableFrom": "ai_mentor_judgements", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "ai_mentor_threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_judgements_configuration_id_ai_judge_configurations_id_fk": { + "name": "ai_mentor_judgements_configuration_id_ai_judge_configurations_id_fk", + "tableFrom": "ai_mentor_judgements", + "columnsFrom": [ + "configuration_id" + ], + "tableTo": "ai_judge_configurations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "ai_mentor_judgements_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgements_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_mentor_judgements_thread_id_unique": { + "name": "ai_mentor_judgements_thread_id_unique", + "columns": [ + "thread_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.ai_mentor_lessons": { + "name": "ai_mentor_lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ai_mentor_instructions": { + "name": "ai_mentor_instructions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "name": { + "name": "name", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "avatar_reference": { + "name": "avatar_reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'roleplay'" + }, + "voice_mode": { + "name": "voice_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preset'" + }, + "tts_preset": { + "name": "tts_preset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'male'" + }, + "custom_tts_reference": { + "name": "custom_tts_reference", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_lessons_tenant_id_idx": { + "name": "ai_mentor_lessons_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_lessons_lesson_id_lessons_id_fk": { + "name": "ai_mentor_lessons_lesson_id_lessons_id_fk", + "tableFrom": "ai_mentor_lessons", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_lessons_tenant_id_tenants_id_fk": { + "name": "ai_mentor_lessons_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_lessons", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_student_lesson_progress": { + "name": "ai_mentor_student_lesson_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_lesson_progress_id": { + "name": "student_lesson_progress_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_score": { + "name": "min_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percentage": { + "name": "percentage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_student_lesson_progress_tenant_id_idx": { + "name": "ai_mentor_student_lesson_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_student_lesson_progress_student_lesson_progress_id_student_lesson_progress_id_fk": { + "name": "ai_mentor_student_lesson_progress_student_lesson_progress_id_student_lesson_progress_id_fk", + "tableFrom": "ai_mentor_student_lesson_progress", + "columnsFrom": [ + "student_lesson_progress_id" + ], + "tableTo": "student_lesson_progress", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_student_lesson_progress_tenant_id_tenants_id_fk": { + "name": "ai_mentor_student_lesson_progress_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_student_lesson_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_thread_messages": { + "name": "ai_mentor_thread_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_thread_messages_tenant_id_idx": { + "name": "ai_mentor_thread_messages_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_thread_messages_thread_id_ai_mentor_threads_id_fk": { + "name": "ai_mentor_thread_messages_thread_id_ai_mentor_threads_id_fk", + "tableFrom": "ai_mentor_thread_messages", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "ai_mentor_threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_thread_messages_tenant_id_tenants_id_fk": { + "name": "ai_mentor_thread_messages_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_thread_messages", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_threads": { + "name": "ai_mentor_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "user_language": { + "name": "user_language", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_threads_tenant_id_idx": { + "name": "ai_mentor_threads_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_threads_user_id_users_id_fk": { + "name": "ai_mentor_threads_user_id_users_id_fk", + "tableFrom": "ai_mentor_threads", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_threads_ai_mentor_lesson_id_ai_mentor_lessons_id_fk": { + "name": "ai_mentor_threads_ai_mentor_lesson_id_ai_mentor_lessons_id_fk", + "tableFrom": "ai_mentor_threads", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "tableTo": "ai_mentor_lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_threads_tenant_id_tenants_id_fk": { + "name": "ai_mentor_threads_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_threads", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all_users'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "send_email": { + "name": "send_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_template": { + "name": "email_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "announcements_tenant_id_idx": { + "name": "announcements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "announcements_author_id_users_id_fk": { + "name": "announcements_author_id_users_id_fk", + "tableFrom": "announcements", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "announcements_tenant_id_tenants_id_fk": { + "name": "announcements_tenant_id_tenants_id_fk", + "tableFrom": "announcements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.article_sections": { + "name": "article_sections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "article_sections_tenant_id_idx": { + "name": "article_sections_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "article_sections_tenant_id_tenants_id_fk": { + "name": "article_sections_tenant_id_tenants_id_fk", + "tableFrom": "article_sections", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.articles": { + "name": "articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "article_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "article_section_id": { + "name": "article_section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "articles_tenant_id_idx": { + "name": "articles_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "article_section_idx": { + "name": "article_section_idx", + "columns": [ + { + "expression": "article_section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "articles_article_section_id_article_sections_id_fk": { + "name": "articles_article_section_id_article_sections_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "article_section_id" + ], + "tableTo": "article_sections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "articles_author_id_users_id_fk": { + "name": "articles_author_id_users_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "articles_updated_by_id_users_id_fk": { + "name": "articles_updated_by_id_users_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "updated_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "articles_tenant_id_tenants_id_fk": { + "name": "articles_tenant_id_tenants_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_connections": { + "name": "calendar_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted_dek": { + "name": "refresh_token_encrypted_dek", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted_dek_iv": { + "name": "refresh_token_encrypted_dek_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted_dek_tag": { + "name": "refresh_token_encrypted_dek_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'syncing'" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_cursor": { + "name": "sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_window_start": { + "name": "sync_window_start", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_window_end": { + "name": "sync_window_end", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "window_built_at": { + "name": "window_built_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_completed_at": { + "name": "last_sync_completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_client_state": { + "name": "subscription_client_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_expires_at": { + "name": "subscription_expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_sync_enabled": { + "name": "outbound_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "outbound_calendar_id": { + "name": "outbound_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_error_code": { + "name": "outbound_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_outbound_sync_at": { + "name": "last_outbound_sync_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_connections_tenant_id_idx": { + "name": "calendar_connections_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_connections_tenant_user_provider_unique_idx": { + "name": "calendar_connections_tenant_user_provider_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_connections_subscription_idx": { + "name": "calendar_connections_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_connections_user_id_users_id_fk": { + "name": "calendar_connections_user_id_users_id_fk", + "tableFrom": "calendar_connections", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_connections_tenant_id_tenants_id_fk": { + "name": "calendar_connections_tenant_id_tenants_id_fk", + "tableFrom": "calendar_connections", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "uid": { + "name": "uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organizer_user_id": { + "name": "organizer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exdates": { + "name": "exdates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_events_tenant_id_idx": { + "name": "calendar_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_events_tenant_starts_ends_idx": { + "name": "calendar_events_tenant_starts_ends_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_events_tenant_uid_unique_idx": { + "name": "calendar_events_tenant_uid_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_events_organizer_user_id_users_id_fk": { + "name": "calendar_events_organizer_user_id_users_id_fk", + "tableFrom": "calendar_events", + "columnsFrom": [ + "organizer_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "calendar_events_tenant_id_tenants_id_fk": { + "name": "calendar_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_external_events": { + "name": "calendar_external_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "web_link": { + "name": "web_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_cancelled": { + "name": "is_cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_external_events_tenant_id_idx": { + "name": "calendar_external_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_external_events_calendar_event_unique_idx": { + "name": "calendar_external_events_calendar_event_unique_idx", + "columns": [ + { + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_external_events_tenant_connection_event_unique_idx": { + "name": "calendar_external_events_tenant_connection_event_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_external_events_tenant_user_idx": { + "name": "calendar_external_events_tenant_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_external_events_connection_id_calendar_connections_id_fk": { + "name": "calendar_external_events_connection_id_calendar_connections_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "connection_id" + ], + "tableTo": "calendar_connections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_external_events_calendar_event_id_calendar_events_id_fk": { + "name": "calendar_external_events_calendar_event_id_calendar_events_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_external_events_user_id_users_id_fk": { + "name": "calendar_external_events_user_id_users_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_external_events_tenant_id_tenants_id_fk": { + "name": "calendar_external_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_outbound_events": { + "name": "calendar_outbound_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_outbound_events_tenant_id_idx": { + "name": "calendar_outbound_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_outbound_events_connection_event_user_unique_idx": { + "name": "calendar_outbound_events_connection_event_user_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_outbound_events_connection_external_event_unique_idx": { + "name": "calendar_outbound_events_connection_external_event_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_outbound_events_calendar_event_idx": { + "name": "calendar_outbound_events_calendar_event_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_outbound_events_connection_id_calendar_connections_id_fk": { + "name": "calendar_outbound_events_connection_id_calendar_connections_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "connection_id" + ], + "tableTo": "calendar_connections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_outbound_events_calendar_event_id_calendar_events_id_fk": { + "name": "calendar_outbound_events_calendar_event_id_calendar_events_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_outbound_events_user_id_users_id_fk": { + "name": "calendar_outbound_events_user_id_users_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_outbound_events_tenant_id_tenants_id_fk": { + "name": "calendar_outbound_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "categories_tenant_id_idx": { + "name": "categories_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "categories_tenant_id_base_title_unique": { + "name": "categories_tenant_id_base_title_unique", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"title\"->>\"base_language\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "categories_tenant_id_tenants_id_fk": { + "name": "categories_tenant_id_tenants_id_fk", + "tableFrom": "categories", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.certificates": { + "name": "certificates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiration_warning_sent_at": { + "name": "expiration_warning_sent_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "certificates_tenant_id_idx": { + "name": "certificates_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "certificates_active_expiry_idx": { + "name": "certificates_active_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "certificates_user_course_idx": { + "name": "certificates_user_course_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "certificates_user_id_users_id_fk": { + "name": "certificates_user_id_users_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "certificates_course_id_courses_id_fk": { + "name": "certificates_course_id_courses_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "certificates_tenant_id_tenants_id_fk": { + "name": "certificates_tenant_id_tenants_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.chapters": { + "name": "chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_freemium": { + "name": "is_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lesson_count": { + "name": "lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "chapters_tenant_id_idx": { + "name": "chapters_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "chapters_tenant_id_course_id_idx": { + "name": "chapters_tenant_id_course_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chapters_course_id_courses_id_fk": { + "name": "chapters_course_id_courses_id_fk", + "tableFrom": "chapters", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chapters_author_id_users_id_fk": { + "name": "chapters_author_id_users_id_fk", + "tableFrom": "chapters", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "chapters_tenant_id_tenants_id_fk": { + "name": "chapters_tenant_id_tenants_id_fk", + "tableFrom": "chapters", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_chat_message_reactions": { + "name": "course_chat_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_chat_message_reactions_tenant_id_idx": { + "name": "course_chat_message_reactions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_message_reactions_message_id_reaction_idx": { + "name": "course_chat_message_reactions_message_id_reaction_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reaction", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_message_reactions_user_message_reaction_unique_idx": { + "name": "course_chat_message_reactions_user_message_reaction_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reaction", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_chat_message_reactions_message_id_course_chat_messages_id_fk": { + "name": "course_chat_message_reactions_message_id_course_chat_messages_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "message_id" + ], + "tableTo": "course_chat_messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_message_reactions_course_id_courses_id_fk": { + "name": "course_chat_message_reactions_course_id_courses_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_message_reactions_user_id_users_id_fk": { + "name": "course_chat_message_reactions_user_id_users_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_message_reactions_tenant_id_tenants_id_fk": { + "name": "course_chat_message_reactions_tenant_id_tenants_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_chat_messages": { + "name": "course_chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_chat_messages_tenant_id_idx": { + "name": "course_chat_messages_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_messages_course_id_created_at_idx": { + "name": "course_chat_messages_course_id_created_at_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_messages_thread_id_created_at_idx": { + "name": "course_chat_messages_thread_id_created_at_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_messages_parent_message_id_created_at_idx": { + "name": "course_chat_messages_parent_message_id_created_at_idx", + "columns": [ + { + "expression": "parent_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_chat_messages_thread_id_course_chat_threads_id_fk": { + "name": "course_chat_messages_thread_id_course_chat_threads_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "course_chat_threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_messages_course_id_courses_id_fk": { + "name": "course_chat_messages_course_id_courses_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_messages_user_id_users_id_fk": { + "name": "course_chat_messages_user_id_users_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_messages_parent_message_id_course_chat_messages_id_fk": { + "name": "course_chat_messages_parent_message_id_course_chat_messages_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "parent_message_id" + ], + "tableTo": "course_chat_messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "course_chat_messages_tenant_id_tenants_id_fk": { + "name": "course_chat_messages_tenant_id_tenants_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_chat_threads": { + "name": "course_chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_chat_threads_tenant_id_idx": { + "name": "course_chat_threads_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_threads_course_id_created_at_idx": { + "name": "course_chat_threads_course_id_created_at_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_threads_course_id_updated_at_idx": { + "name": "course_chat_threads_course_id_updated_at_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_chat_threads_course_id_courses_id_fk": { + "name": "course_chat_threads_course_id_courses_id_fk", + "tableFrom": "course_chat_threads", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_threads_created_by_user_id_users_id_fk": { + "name": "course_chat_threads_created_by_user_id_users_id_fk", + "tableFrom": "course_chat_threads", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_threads_tenant_id_tenants_id_fk": { + "name": "course_chat_threads_tenant_id_tenants_id_fk", + "tableFrom": "course_chat_threads", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_slugs": { + "name": "course_slugs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_short_id": { + "name": "course_short_id", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_slugs_tenant_id_idx": { + "name": "course_slugs_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_slug_course_short_id_lang_unique_idx": { + "name": "course_slug_course_short_id_lang_unique_idx", + "columns": [ + { + "expression": "course_short_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_slugs_course_short_id_courses_short_id_fk": { + "name": "course_slugs_course_short_id_courses_short_id_fk", + "tableFrom": "course_slugs", + "columnsFrom": [ + "course_short_id" + ], + "tableTo": "courses", + "columnsTo": [ + "short_id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "course_slugs_tenant_id_tenants_id_fk": { + "name": "course_slugs_tenant_id_tenants_id_fk", + "tableFrom": "course_slugs", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_student_mode": { + "name": "course_student_mode", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_student_mode_tenant_id_idx": { + "name": "course_student_mode_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_student_mode_user_id_users_id_fk": { + "name": "course_student_mode_user_id_users_id_fk", + "tableFrom": "course_student_mode", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_student_mode_course_id_courses_id_fk": { + "name": "course_student_mode_course_id_courses_id_fk", + "tableFrom": "course_student_mode", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_student_mode_tenant_id_tenants_id_fk": { + "name": "course_student_mode_tenant_id_tenants_id_fk", + "tableFrom": "course_student_mode", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_student_mode_user_id_course_id_unique": { + "name": "course_student_mode_user_id_course_id_unique", + "columns": [ + "user_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.course_students_stats": { + "name": "course_students_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "new_students_count": { + "name": "new_students_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_students_stats_tenant_id_idx": { + "name": "course_students_stats_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_students_stats_course_id_courses_id_fk": { + "name": "course_students_stats_course_id_courses_id_fk", + "tableFrom": "course_students_stats", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_students_stats_author_id_users_id_fk": { + "name": "course_students_stats_author_id_users_id_fk", + "tableFrom": "course_students_stats", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_students_stats_tenant_id_tenants_id_fk": { + "name": "course_students_stats_tenant_id_tenants_id_fk", + "tableFrom": "course_students_stats", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_students_stats_course_id_month_year_unique": { + "name": "course_students_stats_course_id_month_year_unique", + "columns": [ + "course_id", + "month", + "year" + ], + "nullsNotDistinct": false + } + } + }, + "public.courses": { + "name": "courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_id": { + "name": "short_id", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "thumbnail_s3_key": { + "name": "thumbnail_s3_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "thumbnail_position_y": { + "name": "thumbnail_position_y", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "has_certificate": { + "name": "has_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_in_cents": { + "name": "price_in_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_author_section": { + "name": "show_author_section", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "chapter_count": { + "name": "chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learning_outcomes": { + "name": "learning_outcomes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "duration_estimates": { + "name": "duration_estimates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "course_type": { + "name": "course_type", + "type": "course_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'regular'" + }, + "source_course_id": { + "name": "source_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"lessonSequenceEnabled\":false,\"quizFeedbackEnabled\":true,\"certificateSignature\":null,\"certificateFontColor\":null,\"certificateValidity\":null,\"videoCompletionTrackingEnabled\":true}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "courses_tenant_id_idx": { + "name": "courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "courses_short_id_unique_idx": { + "name": "courses_short_id_unique_idx", + "columns": [ + { + "expression": "short_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "courses_author_id_users_id_fk": { + "name": "courses_author_id_users_id_fk", + "tableFrom": "courses", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "courses_category_id_categories_id_fk": { + "name": "courses_category_id_categories_id_fk", + "tableFrom": "courses", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "courses_tenant_id_tenants_id_fk": { + "name": "courses_tenant_id_tenants_id_fk", + "tableFrom": "courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.courses_summary_stats": { + "name": "courses_summary_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "free_purchased_count": { + "name": "free_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_count": { + "name": "paid_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_after_freemium_count": { + "name": "paid_purchased_after_freemium_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_freemium_student_count": { + "name": "completed_freemium_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_course_student_count": { + "name": "completed_course_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "courses_summary_stats_tenant_id_idx": { + "name": "courses_summary_stats_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "courses_summary_stats_course_id_courses_id_fk": { + "name": "courses_summary_stats_course_id_courses_id_fk", + "tableFrom": "courses_summary_stats", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "courses_summary_stats_author_id_users_id_fk": { + "name": "courses_summary_stats_author_id_users_id_fk", + "tableFrom": "courses_summary_stats", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "courses_summary_stats_tenant_id_tenants_id_fk": { + "name": "courses_summary_stats_tenant_id_tenants_id_fk", + "tableFrom": "courses_summary_stats", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "courses_summary_stats_course_id_unique": { + "name": "courses_summary_stats_course_id_unique", + "columns": [ + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.create_tokens": { + "name": "create_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "reminder_count": { + "name": "reminder_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "create_tokens_tenant_id_idx": { + "name": "create_tokens_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "create_tokens_token_hash_idx": { + "name": "create_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "create_tokens_user_id_users_id_fk": { + "name": "create_tokens_user_id_users_id_fk", + "tableFrom": "create_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "create_tokens_tenant_id_tenants_id_fk": { + "name": "create_tokens_tenant_id_tenants_id_fk", + "tableFrom": "create_tokens", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requires_password_change": { + "name": "requires_password_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "credentials_tenant_id_idx": { + "name": "credentials_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credentials_tenant_id_tenants_id_fk": { + "name": "credentials_tenant_id_tenants_id_fk", + "tableFrom": "credentials", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.doc_chunks": { + "name": "doc_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "doc_chunks_tenant_id_idx": { + "name": "doc_chunks_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "doc_chunks_document_id_documents_id_fk": { + "name": "doc_chunks_document_id_documents_id_fk", + "tableFrom": "doc_chunks", + "columnsFrom": [ + "document_id" + ], + "tableTo": "documents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "doc_chunks_tenant_id_tenants_id_fk": { + "name": "doc_chunks_tenant_id_tenants_id_fk", + "tableFrom": "doc_chunks", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.document_to_ai_mentor_lesson": { + "name": "document_to_ai_mentor_lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "document_to_ai_mentor_lesson_tenant_id_idx": { + "name": "document_to_ai_mentor_lesson_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "document_to_ai_mentor_lesson_document_id_documents_id_fk": { + "name": "document_to_ai_mentor_lesson_document_id_documents_id_fk", + "tableFrom": "document_to_ai_mentor_lesson", + "columnsFrom": [ + "document_id" + ], + "tableTo": "documents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "document_to_ai_mentor_lesson_ai_mentor_lesson_id_ai_mentor_lessons_id_fk": { + "name": "document_to_ai_mentor_lesson_ai_mentor_lesson_id_ai_mentor_lessons_id_fk", + "tableFrom": "document_to_ai_mentor_lesson", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "tableTo": "ai_mentor_lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "document_to_ai_mentor_lesson_tenant_id_tenants_id_fk": { + "name": "document_to_ai_mentor_lesson_tenant_id_tenants_id_fk", + "tableFrom": "document_to_ai_mentor_lesson", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "document_to_ai_mentor_lesson_document_id_ai_mentor_lesson_id_unique": { + "name": "document_to_ai_mentor_lesson_document_id_ai_mentor_lesson_id_unique", + "columns": [ + "document_id", + "ai_mentor_lesson_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "check_sum": { + "name": "check_sum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "documents_tenant_id_idx": { + "name": "documents_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "documents_tenant_id_tenants_id_fk": { + "name": "documents_tenant_id_tenants_id_fk", + "tableFrom": "documents", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "documents_check_sum_unique": { + "name": "documents_check_sum_unique", + "columns": [ + "check_sum" + ], + "nullsNotDistinct": false + } + } + }, + "public.email_notification_templates": { + "name": "email_notification_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "blocks": { + "name": "blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"type\":\"doc\",\"content\":[]}'::jsonb" + }, + "strings": { + "name": "strings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "email_notification_templates_tenant_id_idx": { + "name": "email_notification_templates_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_notification_templates_tenant_id_name_unique_idx": { + "name": "email_notification_templates_tenant_id_name_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_notification_templates_tenant_id_tenants_id_fk": { + "name": "email_notification_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_notification_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.form_field_answers": { + "name": "form_field_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "form_field_id": { + "name": "form_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "label_snapshot": { + "name": "label_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "answered_language": { + "name": "answered_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "form_field_answers_tenant_id_idx": { + "name": "form_field_answers_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "form_field_answers_user_id_form_field_id_unique": { + "name": "form_field_answers_user_id_form_field_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "form_field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "form_field_answers_user_id_idx": { + "name": "form_field_answers_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "form_field_answers_form_field_id_form_fields_id_fk": { + "name": "form_field_answers_form_field_id_form_fields_id_fk", + "tableFrom": "form_field_answers", + "columnsFrom": [ + "form_field_id" + ], + "tableTo": "form_fields", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "form_field_answers_user_id_users_id_fk": { + "name": "form_field_answers_user_id_users_id_fk", + "tableFrom": "form_field_answers", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "form_field_answers_tenant_id_tenants_id_fk": { + "name": "form_field_answers_tenant_id_tenants_id_fk", + "tableFrom": "form_field_answers", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.form_fields": { + "name": "form_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "form_fields_tenant_id_idx": { + "name": "form_fields_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "form_fields_form_id_display_order_idx": { + "name": "form_fields_form_id_display_order_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "form_fields_form_id_forms_id_fk": { + "name": "form_fields_form_id_forms_id_fk", + "tableFrom": "form_fields", + "columnsFrom": [ + "form_id" + ], + "tableTo": "forms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "form_fields_tenant_id_tenants_id_fk": { + "name": "form_fields_tenant_id_tenants_id_fk", + "tableFrom": "form_fields", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "forms_tenant_id_idx": { + "name": "forms_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "forms_tenant_id_type_unique_idx": { + "name": "forms_tenant_id_type_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "forms_tenant_id_tenants_id_fk": { + "name": "forms_tenant_id_tenants_id_fk", + "tableFrom": "forms", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.group_announcements": { + "name": "group_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_announcements_tenant_id_idx": { + "name": "group_announcements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_announcements_group_id_groups_id_fk": { + "name": "group_announcements_group_id_groups_id_fk", + "tableFrom": "group_announcements", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_announcements_announcement_id_announcements_id_fk": { + "name": "group_announcements_announcement_id_announcements_id_fk", + "tableFrom": "group_announcements", + "columnsFrom": [ + "announcement_id" + ], + "tableTo": "announcements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_announcements_tenant_id_tenants_id_fk": { + "name": "group_announcements_tenant_id_tenants_id_fk", + "tableFrom": "group_announcements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_announcements_group_id_announcement_id_unique": { + "name": "group_announcements_group_id_announcement_id_unique", + "columns": [ + "group_id", + "announcement_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.group_courses": { + "name": "group_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enrolled_by": { + "name": "enrolled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_courses_tenant_id_idx": { + "name": "group_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_courses_group_id_groups_id_fk": { + "name": "group_courses_group_id_groups_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_courses_course_id_courses_id_fk": { + "name": "group_courses_course_id_courses_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_courses_enrolled_by_users_id_fk": { + "name": "group_courses_enrolled_by_users_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "enrolled_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "group_courses_calendar_event_id_calendar_events_id_fk": { + "name": "group_courses_calendar_event_id_calendar_events_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "group_courses_tenant_id_tenants_id_fk": { + "name": "group_courses_tenant_id_tenants_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_courses_calendar_event_id_unique": { + "name": "group_courses_calendar_event_id_unique", + "columns": [ + "calendar_event_id" + ], + "nullsNotDistinct": false + }, + "group_courses_group_id_course_id_unique": { + "name": "group_courses_group_id_course_id_unique", + "columns": [ + "group_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.group_learning_paths": { + "name": "group_learning_paths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_learning_paths_tenant_id_idx": { + "name": "group_learning_paths_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "group_learning_paths_learning_path_idx": { + "name": "group_learning_paths_learning_path_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_learning_paths_group_id_groups_id_fk": { + "name": "group_learning_paths_group_id_groups_id_fk", + "tableFrom": "group_learning_paths", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_learning_paths_learning_path_id_learning_paths_id_fk": { + "name": "group_learning_paths_learning_path_id_learning_paths_id_fk", + "tableFrom": "group_learning_paths", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_learning_paths_tenant_id_tenants_id_fk": { + "name": "group_learning_paths_tenant_id_tenants_id_fk", + "tableFrom": "group_learning_paths", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_learning_paths_group_id_learning_path_id_unique": { + "name": "group_learning_paths_group_id_learning_path_id_unique", + "columns": [ + "group_id", + "learning_path_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.group_users": { + "name": "group_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_users_tenant_id_idx": { + "name": "group_users_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_users_user_id_users_id_fk": { + "name": "group_users_user_id_users_id_fk", + "tableFrom": "group_users", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_users_group_id_groups_id_fk": { + "name": "group_users_group_id_groups_id_fk", + "tableFrom": "group_users", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_users_tenant_id_tenants_id_fk": { + "name": "group_users_tenant_id_tenants_id_fk", + "tableFrom": "group_users", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_users_user_id_group_id_unique": { + "name": "group_users_user_id_group_id_unique", + "columns": [ + "user_id", + "group_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "characteristic": { + "name": "characteristic", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "groups_tenant_id_idx": { + "name": "groups_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "groups_tenant_id_tenants_id_fk": { + "name": "groups_tenant_id_tenants_id_fk", + "tableFrom": "groups", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.integration_api_keys": { + "name": "integration_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "integration_api_keys_tenant_id_idx": { + "name": "integration_api_keys_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "integration_api_keys_key_prefix_idx": { + "name": "integration_api_keys_key_prefix_idx", + "columns": [ + { + "expression": "key_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "integration_api_keys_created_by_idx": { + "name": "integration_api_keys_created_by_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "integration_api_keys_created_by_user_id_users_id_fk": { + "name": "integration_api_keys_created_by_user_id_users_id_fk", + "tableFrom": "integration_api_keys", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "integration_api_keys_tenant_id_tenants_id_fk": { + "name": "integration_api_keys_tenant_id_tenants_id_fk", + "tableFrom": "integration_api_keys", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_certificates": { + "name": "learning_path_certificates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "learning_path_certificates_tenant_id_idx": { + "name": "learning_path_certificates_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_certificates_user_id_users_id_fk": { + "name": "learning_path_certificates_user_id_users_id_fk", + "tableFrom": "learning_path_certificates", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_certificates_learning_path_id_learning_paths_id_fk": { + "name": "learning_path_certificates_learning_path_id_learning_paths_id_fk", + "tableFrom": "learning_path_certificates", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_certificates_tenant_id_tenants_id_fk": { + "name": "learning_path_certificates_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_certificates", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_courses": { + "name": "learning_path_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "learning_path_courses_tenant_id_idx": { + "name": "learning_path_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_courses_path_id_course_id_unique_idx": { + "name": "learning_path_courses_path_id_course_id_unique_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_courses_path_id_display_order_unique_idx": { + "name": "learning_path_courses_path_id_display_order_unique_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_courses_path_id_display_order_idx": { + "name": "learning_path_courses_path_id_display_order_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_courses_learning_path_id_learning_paths_id_fk": { + "name": "learning_path_courses_learning_path_id_learning_paths_id_fk", + "tableFrom": "learning_path_courses", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_courses_course_id_courses_id_fk": { + "name": "learning_path_courses_course_id_courses_id_fk", + "tableFrom": "learning_path_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_courses_tenant_id_tenants_id_fk": { + "name": "learning_path_courses_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_entity_map": { + "name": "learning_path_entity_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_entity_id": { + "name": "source_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_entity_id": { + "name": "target_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "learning_path_entity_map_export_idx": { + "name": "learning_path_entity_map_export_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_entity_map_source_entity_idx": { + "name": "learning_path_entity_map_source_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_entity_map_source_unique_idx": { + "name": "learning_path_entity_map_source_unique_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_entity_map_export_id_learning_path_exports_id_fk": { + "name": "learning_path_entity_map_export_id_learning_path_exports_id_fk", + "tableFrom": "learning_path_entity_map", + "columnsFrom": [ + "export_id" + ], + "tableTo": "learning_path_exports", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_exports": { + "name": "learning_path_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_learning_path_id": { + "name": "source_learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_learning_path_id": { + "name": "target_learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "learning_path_exports_source_learning_path_idx": { + "name": "learning_path_exports_source_learning_path_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_exports_target_learning_path_idx": { + "name": "learning_path_exports_target_learning_path_idx", + "columns": [ + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_exports_source_target_unique_idx": { + "name": "learning_path_exports_source_target_unique_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_exports_source_tenant_id_tenants_id_fk": { + "name": "learning_path_exports_source_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_exports", + "columnsFrom": [ + "source_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_exports_target_tenant_id_tenants_id_fk": { + "name": "learning_path_exports_target_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_exports", + "columnsFrom": [ + "target_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_exports_target_learning_path_id_learning_paths_id_fk": { + "name": "learning_path_exports_target_learning_path_id_learning_paths_id_fk", + "tableFrom": "learning_path_exports", + "columnsFrom": [ + "target_learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_paths": { + "name": "learning_paths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "thumbnail_reference": { + "name": "thumbnail_reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "includes_certificate": { + "name": "includes_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"certificateSignature\":null,\"certificateFontColor\":null}'::jsonb" + }, + "sequence_enabled": { + "name": "sequence_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'regular'" + }, + "source_learning_path_id": { + "name": "source_learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "learning_paths_tenant_id_idx": { + "name": "learning_paths_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_paths_author_id_users_id_fk": { + "name": "learning_paths_author_id_users_id_fk", + "tableFrom": "learning_paths", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "learning_paths_tenant_id_tenants_id_fk": { + "name": "learning_paths_tenant_id_tenants_id_fk", + "tableFrom": "learning_paths", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.lesson_learning_time": { + "name": "lesson_learning_time", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "lesson_learning_time_tenant_id_idx": { + "name": "lesson_learning_time_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lesson_learning_time_user_course_idx": { + "name": "lesson_learning_time_user_course_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lesson_learning_time_user_id_users_id_fk": { + "name": "lesson_learning_time_user_id_users_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_learning_time_lesson_id_lessons_id_fk": { + "name": "lesson_learning_time_lesson_id_lessons_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_learning_time_course_id_courses_id_fk": { + "name": "lesson_learning_time_course_id_courses_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_learning_time_tenant_id_tenants_id_fk": { + "name": "lesson_learning_time_tenant_id_tenants_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lesson_learning_time_user_id_lesson_id_unique": { + "name": "lesson_learning_time_user_id_lesson_id_unique", + "columns": [ + "user_id", + "lesson_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.lesson_video_progress": { + "name": "lesson_video_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_entity_id": { + "name": "resource_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bucket_size_seconds": { + "name": "bucket_size_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "watched_ranges": { + "name": "watched_ranges", + "type": "int4multirange", + "primaryKey": false, + "notNull": true, + "default": "'{}'::int4multirange" + }, + "covered_bucket_count": { + "name": "covered_bucket_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "coverage_percent": { + "name": "coverage_percent", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_watch_seconds": { + "name": "active_watch_seconds", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_watched": { + "name": "is_watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "watched_at": { + "name": "watched_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "lesson_video_progress_tenant_id_idx": { + "name": "lesson_video_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lesson_video_progress_lesson_idx": { + "name": "lesson_video_progress_lesson_idx", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lesson_video_progress_resource_entity_idx": { + "name": "lesson_video_progress_resource_entity_idx", + "columns": [ + { + "expression": "resource_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lesson_video_progress_student_id_users_id_fk": { + "name": "lesson_video_progress_student_id_users_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_video_progress_lesson_id_lessons_id_fk": { + "name": "lesson_video_progress_lesson_id_lessons_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_video_progress_resource_entity_id_resource_entity_id_fk": { + "name": "lesson_video_progress_resource_entity_id_resource_entity_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "resource_entity_id" + ], + "tableTo": "resource_entity", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_video_progress_tenant_id_tenants_id_fk": { + "name": "lesson_video_progress_tenant_id_tenants_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lesson_video_progress_student_id_lesson_id_resource_entity_id_unique": { + "name": "lesson_video_progress_student_id_lesson_id_resource_entity_id_unique", + "columns": [ + "student_id", + "lesson_id", + "resource_entity_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.lessons": { + "name": "lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "threshold_score": { + "name": "threshold_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempts_limit": { + "name": "attempts_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quiz_cooldown_in_hours": { + "name": "quiz_cooldown_in_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_s3_key": { + "name": "file_s3_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "is_external": { + "name": "is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "lessons_tenant_id_idx": { + "name": "lessons_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lessons_tenant_id_chapter_id_type_idx": { + "name": "lessons_tenant_id_chapter_id_type_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lessons_chapter_id_chapters_id_fk": { + "name": "lessons_chapter_id_chapters_id_fk", + "tableFrom": "lessons", + "columnsFrom": [ + "chapter_id" + ], + "tableTo": "chapters", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lessons_tenant_id_tenants_id_fk": { + "name": "lessons_tenant_id_tenants_id_fk", + "tableFrom": "lessons", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_lessons": { + "name": "live_lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_link_id": { + "name": "live_training_link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_lessons_tenant_id_idx": { + "name": "live_lessons_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_lessons_lesson_language_unique_idx": { + "name": "live_lessons_lesson_language_unique_idx", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_lessons_training_link_idx": { + "name": "live_lessons_training_link_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_lessons_training_idx": { + "name": "live_lessons_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_lessons_live_training_id_live_trainings_id_fk": { + "name": "live_lessons_live_training_id_live_trainings_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_lessons_live_training_link_id_live_training_links_id_fk": { + "name": "live_lessons_live_training_link_id_live_training_links_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "live_training_link_id" + ], + "tableTo": "live_training_links", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_lessons_lesson_id_lessons_id_fk": { + "name": "live_lessons_lesson_id_lessons_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_lessons_tenant_id_tenants_id_fk": { + "name": "live_lessons_tenant_id_tenants_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_attendance": { + "name": "live_training_attendance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_session_participant_id": { + "name": "live_training_session_participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_session_id": { + "name": "live_training_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "left_at": { + "name": "left_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "livekit_participant_sid": { + "name": "livekit_participant_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_attendance_tenant_id_idx": { + "name": "live_training_attendance_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_attendance_session_user_idx": { + "name": "live_training_attendance_session_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_attendance_training_user_idx": { + "name": "live_training_attendance_training_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_attendance_joined_at_idx": { + "name": "live_training_attendance_joined_at_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_attendance_live_training_session_participant_id_live_training_session_participants_id_fk": { + "name": "live_training_attendance_live_training_session_participant_id_live_training_session_participants_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "live_training_session_participant_id" + ], + "tableTo": "live_training_session_participants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_attendance_live_training_session_id_live_training_sessions_id_fk": { + "name": "live_training_attendance_live_training_session_id_live_training_sessions_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "live_training_session_id" + ], + "tableTo": "live_training_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_attendance_live_training_id_live_trainings_id_fk": { + "name": "live_training_attendance_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_attendance_user_id_users_id_fk": { + "name": "live_training_attendance_user_id_users_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_attendance_tenant_id_tenants_id_fk": { + "name": "live_training_attendance_tenant_id_tenants_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_links": { + "name": "live_training_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'course'" + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_links_tenant_id_idx": { + "name": "live_training_links_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_links_training_entity_unique_idx": { + "name": "live_training_links_training_entity_unique_idx", + "columns": [ + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_links_training_idx": { + "name": "live_training_links_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_links_entity_idx": { + "name": "live_training_links_entity_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_links_live_training_id_live_trainings_id_fk": { + "name": "live_training_links_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_links", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_links_tenant_id_tenants_id_fk": { + "name": "live_training_links_tenant_id_tenants_id_fk", + "tableFrom": "live_training_links", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_members": { + "name": "live_training_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'host'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_members_tenant_id_idx": { + "name": "live_training_members_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_training_user_unique_idx": { + "name": "live_training_members_training_user_unique_idx", + "columns": [ + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_training_idx": { + "name": "live_training_members_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_user_idx": { + "name": "live_training_members_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_role_idx": { + "name": "live_training_members_role_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_members_live_training_id_live_trainings_id_fk": { + "name": "live_training_members_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_members", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_members_user_id_users_id_fk": { + "name": "live_training_members_user_id_users_id_fk", + "tableFrom": "live_training_members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_members_tenant_id_tenants_id_fk": { + "name": "live_training_members_tenant_id_tenants_id_fk", + "tableFrom": "live_training_members", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_session_participants": { + "name": "live_training_session_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_session_id": { + "name": "live_training_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_joined_at": { + "name": "first_joined_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_left_at": { + "name": "last_left_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "livekit_identity": { + "name": "livekit_identity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_session_participants_tenant_id_idx": { + "name": "live_training_session_participants_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_session_user_unique_idx": { + "name": "live_training_session_participants_session_user_unique_idx", + "columns": [ + { + "expression": "live_training_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_session_idx": { + "name": "live_training_session_participants_session_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_training_user_idx": { + "name": "live_training_session_participants_training_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_user_idx": { + "name": "live_training_session_participants_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_session_participants_live_training_session_id_live_training_sessions_id_fk": { + "name": "live_training_session_participants_live_training_session_id_live_training_sessions_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "live_training_session_id" + ], + "tableTo": "live_training_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_session_participants_live_training_id_live_trainings_id_fk": { + "name": "live_training_session_participants_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_session_participants_user_id_users_id_fk": { + "name": "live_training_session_participants_user_id_users_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_session_participants_tenant_id_tenants_id_fk": { + "name": "live_training_session_participants_tenant_id_tenants_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_sessions": { + "name": "live_training_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ended_by_user_id": { + "name": "ended_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "livekit_room_name": { + "name": "livekit_room_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "livekit_room_sid": { + "name": "livekit_room_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "peak_participant_count": { + "name": "peak_participant_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unique_participant_count": { + "name": "unique_participant_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_sessions_tenant_id_idx": { + "name": "live_training_sessions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_sessions_training_idx": { + "name": "live_training_sessions_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_sessions_status_idx": { + "name": "live_training_sessions_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_sessions_livekit_room_name_idx": { + "name": "live_training_sessions_livekit_room_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livekit_room_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_sessions_live_training_id_live_trainings_id_fk": { + "name": "live_training_sessions_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_sessions_started_by_user_id_users_id_fk": { + "name": "live_training_sessions_started_by_user_id_users_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "started_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_sessions_ended_by_user_id_users_id_fk": { + "name": "live_training_sessions_ended_by_user_id_users_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "ended_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_sessions_tenant_id_tenants_id_fk": { + "name": "live_training_sessions_tenant_id_tenants_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_trainings": { + "name": "live_trainings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "delivery_type": { + "name": "delivery_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'online'" + }, + "visibility_scope": { + "name": "visibility_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'linked_courses'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "max_participants": { + "name": "max_participants", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"viewerPermissions\":{\"microphoneEnabled\":false,\"cameraEnabled\":false}}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_trainings_tenant_id_idx": { + "name": "live_trainings_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_trainings_tenant_status_idx": { + "name": "live_trainings_tenant_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_trainings_author_idx": { + "name": "live_trainings_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_trainings_calendar_event_id_calendar_events_id_fk": { + "name": "live_trainings_calendar_event_id_calendar_events_id_fk", + "tableFrom": "live_trainings", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_trainings_author_id_users_id_fk": { + "name": "live_trainings_author_id_users_id_fk", + "tableFrom": "live_trainings", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_trainings_tenant_id_tenants_id_fk": { + "name": "live_trainings_tenant_id_tenants_id_fk", + "tableFrom": "live_trainings", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "live_trainings_calendar_event_id_unique": { + "name": "live_trainings_calendar_event_id_unique", + "columns": [ + "calendar_event_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.luma_course_generation_syncs": { + "name": "luma_course_generation_syncs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "draft_id": { + "name": "draft_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "luma_course_generation_syncs_tenant_id_idx": { + "name": "luma_course_generation_syncs_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "luma_course_generation_syncs_course_id_idx": { + "name": "luma_course_generation_syncs_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "luma_course_generation_syncs_status_idx": { + "name": "luma_course_generation_syncs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "luma_course_generation_syncs_course_id_courses_id_fk": { + "name": "luma_course_generation_syncs_course_id_courses_id_fk", + "tableFrom": "luma_course_generation_syncs", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "luma_course_generation_syncs_tenant_id_tenants_id_fk": { + "name": "luma_course_generation_syncs_tenant_id_tenants_id_fk", + "tableFrom": "luma_course_generation_syncs", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "luma_course_generation_syncs_course_id_unique": { + "name": "luma_course_generation_syncs_course_id_unique", + "columns": [ + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.magic_link_tokens": { + "name": "magic_link_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "magic_link_tokens_tenant_id_idx": { + "name": "magic_link_tokens_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "magic_link_tokens_token_hash_idx": { + "name": "magic_link_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "magic_link_tokens_user_id_users_id_fk": { + "name": "magic_link_tokens_user_id_users_id_fk", + "tableFrom": "magic_link_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "magic_link_tokens_tenant_id_tenants_id_fk": { + "name": "magic_link_tokens_tenant_id_tenants_id_fk", + "tableFrom": "magic_link_tokens", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.master_course_entity_map": { + "name": "master_course_entity_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_entity_id": { + "name": "source_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_entity_id": { + "name": "target_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "master_course_entity_map_export_idx": { + "name": "master_course_entity_map_export_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_entity_map_source_entity_idx": { + "name": "master_course_entity_map_source_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_entity_map_source_unique_idx": { + "name": "master_course_entity_map_source_unique_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "master_course_entity_map_export_id_master_course_exports_id_fk": { + "name": "master_course_entity_map_export_id_master_course_exports_id_fk", + "tableFrom": "master_course_entity_map", + "columnsFrom": [ + "export_id" + ], + "tableTo": "master_course_exports", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.master_course_exports": { + "name": "master_course_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_course_id": { + "name": "source_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_course_id": { + "name": "target_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "master_course_exports_source_course_idx": { + "name": "master_course_exports_source_course_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_exports_target_course_idx": { + "name": "master_course_exports_target_course_idx", + "columns": [ + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_exports_source_target_unique_idx": { + "name": "master_course_exports_source_target_unique_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "master_course_exports_source_tenant_id_tenants_id_fk": { + "name": "master_course_exports_source_tenant_id_tenants_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "source_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "master_course_exports_source_course_id_courses_id_fk": { + "name": "master_course_exports_source_course_id_courses_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "source_course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "master_course_exports_target_tenant_id_tenants_id_fk": { + "name": "master_course_exports_target_tenant_id_tenants_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "target_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "master_course_exports_target_course_id_courses_id_fk": { + "name": "master_course_exports_target_course_id_courses_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "target_course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.news": { + "name": "news", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "news_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "news_tenant_id_idx": { + "name": "news_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "news_author_id_users_id_fk": { + "name": "news_author_id_users_id_fk", + "tableFrom": "news", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "news_tenant_id_tenants_id_fk": { + "name": "news_tenant_id_tenants_id_fk", + "tableFrom": "news", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.outbox_events": { + "name": "outbox_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "outbox_events_tenant_id_idx": { + "name": "outbox_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "outbox_events_poll_idx": { + "name": "outbox_events_poll_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "outbox_events_tenant_id_tenants_id_fk": { + "name": "outbox_events_tenant_id_tenants_id_fk", + "tableFrom": "outbox_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_role_rule_sets": { + "name": "permission_role_rule_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_set_id": { + "name": "rule_set_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_role_rule_sets_tenant_id_idx": { + "name": "permission_role_rule_sets_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_role_rule_sets_role_id_rule_set_id_unique": { + "name": "permission_role_rule_sets_role_id_rule_set_id_unique", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_role_rule_sets_role_id_permission_roles_id_fk": { + "name": "permission_role_rule_sets_role_id_permission_roles_id_fk", + "tableFrom": "permission_role_rule_sets", + "columnsFrom": [ + "role_id" + ], + "tableTo": "permission_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_role_rule_sets_rule_set_id_permission_rule_sets_id_fk": { + "name": "permission_role_rule_sets_rule_set_id_permission_rule_sets_id_fk", + "tableFrom": "permission_role_rule_sets", + "columnsFrom": [ + "rule_set_id" + ], + "tableTo": "permission_rule_sets", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_role_rule_sets_tenant_id_tenants_id_fk": { + "name": "permission_role_rule_sets_tenant_id_tenants_id_fk", + "tableFrom": "permission_role_rule_sets", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_roles": { + "name": "permission_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_roles_tenant_id_idx": { + "name": "permission_roles_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_roles_tenant_id_slug_unique": { + "name": "permission_roles_tenant_id_slug_unique", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_roles_tenant_id_tenants_id_fk": { + "name": "permission_roles_tenant_id_tenants_id_fk", + "tableFrom": "permission_roles", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_rule_set_permissions": { + "name": "permission_rule_set_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "rule_set_id": { + "name": "rule_set_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_rule_set_permissions_tenant_id_idx": { + "name": "permission_rule_set_permissions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_rule_set_permissions_rule_set_id_permission_unique": { + "name": "permission_rule_set_permissions_rule_set_id_permission_unique", + "columns": [ + { + "expression": "rule_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_rule_set_permissions_rule_set_id_permission_rule_sets_id_fk": { + "name": "permission_rule_set_permissions_rule_set_id_permission_rule_sets_id_fk", + "tableFrom": "permission_rule_set_permissions", + "columnsFrom": [ + "rule_set_id" + ], + "tableTo": "permission_rule_sets", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_rule_set_permissions_tenant_id_tenants_id_fk": { + "name": "permission_rule_set_permissions_tenant_id_tenants_id_fk", + "tableFrom": "permission_rule_set_permissions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_rule_sets": { + "name": "permission_rule_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_rule_sets_tenant_id_idx": { + "name": "permission_rule_sets_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_rule_sets_tenant_id_slug_unique": { + "name": "permission_rule_sets_tenant_id_slug_unique", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_rule_sets_tenant_id_tenants_id_fk": { + "name": "permission_rule_sets_tenant_id_tenants_id_fk", + "tableFrom": "permission_rule_sets", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_user_roles": { + "name": "permission_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_user_roles_tenant_id_idx": { + "name": "permission_user_roles_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_user_roles_user_id_role_id_unique": { + "name": "permission_user_roles_user_id_role_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_user_roles_user_id_users_id_fk": { + "name": "permission_user_roles_user_id_users_id_fk", + "tableFrom": "permission_user_roles", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_user_roles_role_id_permission_roles_id_fk": { + "name": "permission_user_roles_role_id_permission_roles_id_fk", + "tableFrom": "permission_user_roles", + "columnsFrom": [ + "role_id" + ], + "tableTo": "permission_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_user_roles_tenant_id_tenants_id_fk": { + "name": "permission_user_roles_tenant_id_tenants_id_fk", + "tableFrom": "permission_user_roles", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.question_answer_options": { + "name": "question_answer_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_text": { + "name": "option_text", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "matched_word": { + "name": "matched_word", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scale_answer": { + "name": "scale_answer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "question_answer_options_tenant_id_idx": { + "name": "question_answer_options_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "question_answer_options_question_id_questions_id_fk": { + "name": "question_answer_options_question_id_questions_id_fk", + "tableFrom": "question_answer_options", + "columnsFrom": [ + "question_id" + ], + "tableTo": "questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "question_answer_options_tenant_id_tenants_id_fk": { + "name": "question_answer_options_tenant_id_tenants_id_fk", + "tableFrom": "question_answer_options", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_s3_key": { + "name": "photo_s3_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "solution_explanation": { + "name": "solution_explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "questions_tenant_id_idx": { + "name": "questions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "questions_lesson_id_lessons_id_fk": { + "name": "questions_lesson_id_lessons_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "questions_tenant_id_tenants_id_fk": { + "name": "questions_tenant_id_tenants_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions_and_answers": { + "name": "questions_and_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "questions_and_answers_tenant_id_idx": { + "name": "questions_and_answers_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "questions_and_answers_tenant_id_tenants_id_fk": { + "name": "questions_and_answers_tenant_id_tenants_id_fk", + "tableFrom": "questions_and_answers", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "correct_answers": { + "name": "correct_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wrong_answers": { + "name": "wrong_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "quiz_attempts_tenant_id_idx": { + "name": "quiz_attempts_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quiz_attempts_course_id_courses_id_fk": { + "name": "quiz_attempts_course_id_courses_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quiz_attempts_lesson_id_lessons_id_fk": { + "name": "quiz_attempts_lesson_id_lessons_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "quiz_attempts_tenant_id_tenants_id_fk": { + "name": "quiz_attempts_tenant_id_tenants_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.reset_tokens": { + "name": "reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "reset_tokens_tenant_id_idx": { + "name": "reset_tokens_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "reset_tokens_token_hash_idx": { + "name": "reset_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "reset_tokens_user_id_users_id_fk": { + "name": "reset_tokens_user_id_users_id_fk", + "tableFrom": "reset_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "reset_tokens_tenant_id_tenants_id_fk": { + "name": "reset_tokens_tenant_id_tenants_id_fk", + "tableFrom": "reset_tokens", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.resource_entity": { + "name": "resource_entity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "relationship_type": { + "name": "relationship_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'attachment'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "resource_entity_tenant_id_idx": { + "name": "resource_entity_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resource_entity_resource_idx": { + "name": "resource_entity_resource_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resource_entity_entity_idx": { + "name": "resource_entity_entity_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resource_entity_relationship_idx": { + "name": "resource_entity_relationship_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relationship_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "resource_entity_resource_id_resources_id_fk": { + "name": "resource_entity_resource_id_resources_id_fk", + "tableFrom": "resource_entity", + "columnsFrom": [ + "resource_id" + ], + "tableTo": "resources", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "resource_entity_tenant_id_tenants_id_fk": { + "name": "resource_entity_tenant_id_tenants_id_fk", + "tableFrom": "resource_entity", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resource_entity_resource_id_entity_id_entity_type_relationship_type_unique": { + "name": "resource_entity_resource_id_entity_id_entity_type_relationship_type_unique", + "columns": [ + "resource_id", + "entity_id", + "entity_type", + "relationship_type" + ], + "nullsNotDistinct": false + } + } + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reference": { + "name": "reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "resources_tenant_id_idx": { + "name": "resources_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "resources_uploaded_by_id_users_id_fk": { + "name": "resources_uploaded_by_id_users_id_fk", + "tableFrom": "resources", + "columnsFrom": [ + "uploaded_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "resources_tenant_id_tenants_id_fk": { + "name": "resources_tenant_id_tenants_id_fk", + "tableFrom": "resources", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_attempts": { + "name": "scorm_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sco_id": { + "name": "sco_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_attempts_tenant_id_idx": { + "name": "scorm_attempts_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_student_lesson_idx": { + "name": "scorm_attempts_student_lesson_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_student_package_idx": { + "name": "scorm_attempts_student_package_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_sco_id_idx": { + "name": "scorm_attempts_sco_id_idx", + "columns": [ + { + "expression": "sco_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_student_package_sco_attempt_unique_idx": { + "name": "scorm_attempts_student_package_sco_attempt_unique_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sco_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_attempts_student_id_users_id_fk": { + "name": "scorm_attempts_student_id_users_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_course_id_courses_id_fk": { + "name": "scorm_attempts_course_id_courses_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_lesson_id_lessons_id_fk": { + "name": "scorm_attempts_lesson_id_lessons_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_package_id_scorm_packages_id_fk": { + "name": "scorm_attempts_package_id_scorm_packages_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "package_id" + ], + "tableTo": "scorm_packages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_sco_id_scorm_scos_id_fk": { + "name": "scorm_attempts_sco_id_scorm_scos_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "sco_id" + ], + "tableTo": "scorm_scos", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_tenant_id_tenants_id_fk": { + "name": "scorm_attempts_tenant_id_tenants_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_packages": { + "name": "scorm_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "entity_type": { + "name": "entity_type", + "type": "scorm_package_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "standard": { + "name": "standard", + "type": "scorm_standard", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "original_file_reference": { + "name": "original_file_reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extracted_files_reference": { + "name": "extracted_files_reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_entry_point": { + "name": "manifest_entry_point", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "scorm_package_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_packages_tenant_id_idx": { + "name": "scorm_packages_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_packages_entity_idx": { + "name": "scorm_packages_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_packages_entity_unique_idx": { + "name": "scorm_packages_entity_unique_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_packages_tenant_id_tenants_id_fk": { + "name": "scorm_packages_tenant_id_tenants_id_fk", + "tableFrom": "scorm_packages", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_runtime_state": { + "name": "scorm_runtime_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completion_status": { + "name": "completion_status", + "type": "scorm_completion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "success_status": { + "name": "success_status", + "type": "scorm_success_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "score_raw": { + "name": "score_raw", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "score_min": { + "name": "score_min", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "score_max": { + "name": "score_max", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "score_scaled": { + "name": "score_scaled", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "lesson_location": { + "name": "lesson_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspend_data": { + "name": "suspend_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_time": { + "name": "session_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_time": { + "name": "total_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "progress_measure": { + "name": "progress_measure", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit": { + "name": "exit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_cmi_json": { + "name": "raw_cmi_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_runtime_state_tenant_id_idx": { + "name": "scorm_runtime_state_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_runtime_state_attempt_id_unique_idx": { + "name": "scorm_runtime_state_attempt_id_unique_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_runtime_state_attempt_id_scorm_attempts_id_fk": { + "name": "scorm_runtime_state_attempt_id_scorm_attempts_id_fk", + "tableFrom": "scorm_runtime_state", + "columnsFrom": [ + "attempt_id" + ], + "tableTo": "scorm_attempts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_runtime_state_tenant_id_tenants_id_fk": { + "name": "scorm_runtime_state_tenant_id_tenants_id_fk", + "tableFrom": "scorm_runtime_state", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_scos": { + "name": "scorm_scos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_identifier": { + "name": "organization_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier_ref": { + "name": "identifier_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_identifier": { + "name": "resource_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scorm_type": { + "name": "scorm_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_path": { + "name": "launch_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_identifier": { + "name": "parent_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "item_metadata_json": { + "name": "item_metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resource_metadata_json": { + "name": "resource_metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_scos_tenant_id_idx": { + "name": "scorm_scos_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_scos_package_id_idx": { + "name": "scorm_scos_package_id_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_scos_lesson_id_idx": { + "name": "scorm_scos_lesson_id_idx", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_scos_package_identifier_unique_idx": { + "name": "scorm_scos_package_identifier_unique_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_scos_package_id_scorm_packages_id_fk": { + "name": "scorm_scos_package_id_scorm_packages_id_fk", + "tableFrom": "scorm_scos", + "columnsFrom": [ + "package_id" + ], + "tableTo": "scorm_packages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_scos_lesson_id_lessons_id_fk": { + "name": "scorm_scos_lesson_id_lessons_id_fk", + "tableFrom": "scorm_scos", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_scos_tenant_id_tenants_id_fk": { + "name": "scorm_scos_tenant_id_tenants_id_fk", + "tableFrom": "scorm_scos", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.search_documents": { + "name": "search_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "search_documents_tenant_id_idx": { + "name": "search_documents_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "search_documents_vector_idx": { + "name": "search_documents_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "search_documents_language_entity_type_idx": { + "name": "search_documents_language_entity_type_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "search_documents_entity_idx": { + "name": "search_documents_entity_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "search_documents_document_unique_idx": { + "name": "search_documents_document_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "search_documents_tenant_id_tenants_id_fk": { + "name": "search_documents_tenant_id_tenants_id_fk", + "tableFrom": "search_documents", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_dek": { + "name": "encrypted_dek", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_dek_iv": { + "name": "encrypted_dek_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_dek_tag": { + "name": "encrypted_dek_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'AES-256-GCM'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "secrets_tenant_id_idx": { + "name": "secrets_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "secrets_tenant_secret_name_uq": { + "name": "secrets_tenant_secret_name_uq", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "secrets_name_idx": { + "name": "secrets_name_idx", + "columns": [ + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "secrets_tenant_id_tenants_id_fk": { + "name": "secrets_tenant_id_tenants_id_fk", + "tableFrom": "secrets", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "settings_tenant_id_idx": { + "name": "settings_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "settings_tenant_id_tenants_id_fk": { + "name": "settings_tenant_id_tenants_id_fk", + "tableFrom": "settings", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.student_chapter_progress": { + "name": "student_chapter_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_lesson_count": { + "name": "completed_lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_as_freemium": { + "name": "completed_as_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_chapter_progress_tenant_id_idx": { + "name": "student_chapter_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_chapter_progress_student_id_users_id_fk": { + "name": "student_chapter_progress_student_id_users_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_chapter_progress_course_id_courses_id_fk": { + "name": "student_chapter_progress_course_id_courses_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_chapter_progress_chapter_id_chapters_id_fk": { + "name": "student_chapter_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "chapter_id" + ], + "tableTo": "chapters", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_chapter_progress_tenant_id_tenants_id_fk": { + "name": "student_chapter_progress_tenant_id_tenants_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_chapter_progress_student_id_course_id_chapter_id_unique": { + "name": "student_chapter_progress_student_id_course_id_chapter_id_unique", + "columns": [ + "student_id", + "course_id", + "chapter_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_courses": { + "name": "student_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "finished_chapter_count": { + "name": "finished_chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "course_completion_metadata": { + "name": "course_completion_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'enrolled'" + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "enrolled_by_group_id": { + "name": "enrolled_by_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_courses_tenant_id_idx": { + "name": "student_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_courses_tenant_id_course_status_student_idx": { + "name": "student_courses_tenant_id_course_status_student_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_courses_student_id_users_id_fk": { + "name": "student_courses_student_id_users_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_courses_course_id_courses_id_fk": { + "name": "student_courses_course_id_courses_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_courses_enrolled_by_group_id_groups_id_fk": { + "name": "student_courses_enrolled_by_group_id_groups_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "enrolled_by_group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_courses_tenant_id_tenants_id_fk": { + "name": "student_courses_tenant_id_tenants_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_courses_student_id_course_id_unique": { + "name": "student_courses_student_id_course_id_unique", + "columns": [ + "student_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_learning_path_courses": { + "name": "student_learning_path_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_learning_path_courses_tenant_id_idx": { + "name": "student_learning_path_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_learning_path_courses_student_path_idx": { + "name": "student_learning_path_courses_student_path_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_learning_path_courses_course_idx": { + "name": "student_learning_path_courses_course_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_learning_path_courses_student_id_users_id_fk": { + "name": "student_learning_path_courses_student_id_users_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_path_courses_learning_path_id_learning_paths_id_fk": { + "name": "student_learning_path_courses_learning_path_id_learning_paths_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_path_courses_course_id_courses_id_fk": { + "name": "student_learning_path_courses_course_id_courses_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_path_courses_tenant_id_tenants_id_fk": { + "name": "student_learning_path_courses_tenant_id_tenants_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_learning_path_courses_student_id_learning_path_id_course_id_unique": { + "name": "student_learning_path_courses_student_id_learning_path_id_course_id_unique", + "columns": [ + "student_id", + "learning_path_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_learning_paths": { + "name": "student_learning_paths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "enrollment_type": { + "name": "enrollment_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_learning_paths_tenant_id_idx": { + "name": "student_learning_paths_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_learning_paths_student_id_users_id_fk": { + "name": "student_learning_paths_student_id_users_id_fk", + "tableFrom": "student_learning_paths", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_paths_learning_path_id_learning_paths_id_fk": { + "name": "student_learning_paths_learning_path_id_learning_paths_id_fk", + "tableFrom": "student_learning_paths", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_paths_tenant_id_tenants_id_fk": { + "name": "student_learning_paths_tenant_id_tenants_id_fk", + "tableFrom": "student_learning_paths", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_learning_paths_student_id_learning_path_id_unique": { + "name": "student_learning_paths_student_id_learning_path_id_unique", + "columns": [ + "student_id", + "learning_path_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_lesson_progress": { + "name": "student_lesson_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_question_count": { + "name": "completed_question_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quiz_score": { + "name": "quiz_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_quiz_passed": { + "name": "is_quiz_passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_started": { + "name": "is_started", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "language_answered": { + "name": "language_answered", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_lesson_progress_tenant_id_idx": { + "name": "student_lesson_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_lesson_progress_completed_quiz_score_idx": { + "name": "student_lesson_progress_completed_quiz_score_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"student_lesson_progress\".\"completed_at\" IS NOT NULL AND \"student_lesson_progress\".\"quiz_score\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "student_lesson_progress_student_id_users_id_fk": { + "name": "student_lesson_progress_student_id_users_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "student_lesson_progress_chapter_id_chapters_id_fk": { + "name": "student_lesson_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "chapter_id" + ], + "tableTo": "chapters", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_lesson_progress_lesson_id_lessons_id_fk": { + "name": "student_lesson_progress_lesson_id_lessons_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_lesson_progress_tenant_id_tenants_id_fk": { + "name": "student_lesson_progress_tenant_id_tenants_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_lesson_progress_student_id_lesson_id_chapter_id_unique": { + "name": "student_lesson_progress_student_id_lesson_id_chapter_id_unique", + "columns": [ + "student_id", + "lesson_id", + "chapter_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_question_answers": { + "name": "student_question_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_question_answers_tenant_id_idx": { + "name": "student_question_answers_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_question_answers_question_id_questions_id_fk": { + "name": "student_question_answers_question_id_questions_id_fk", + "tableFrom": "student_question_answers", + "columnsFrom": [ + "question_id" + ], + "tableTo": "questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_question_answers_student_id_users_id_fk": { + "name": "student_question_answers_student_id_users_id_fk", + "tableFrom": "student_question_answers", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_question_answers_tenant_id_tenants_id_fk": { + "name": "student_question_answers_tenant_id_tenants_id_fk", + "tableFrom": "student_question_answers", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_question_answers_question_id_student_id_unique": { + "name": "student_question_answers_question_id_student_id_unique", + "columns": [ + "question_id", + "student_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.support_sessions": { + "name": "support_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "original_user_id": { + "name": "original_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_tenant_id": { + "name": "original_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hashed_grant_token": { + "name": "hashed_grant_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_expires_at": { + "name": "grant_expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": { + "support_sessions_hashed_grant_token_unique": { + "name": "support_sessions_hashed_grant_token_unique", + "columns": [ + { + "expression": "hashed_grant_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_status_idx": { + "name": "support_sessions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_original_user_idx": { + "name": "support_sessions_original_user_idx", + "columns": [ + { + "expression": "original_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_target_tenant_idx": { + "name": "support_sessions_target_tenant_idx", + "columns": [ + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_target_user_idx": { + "name": "support_sessions_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "support_sessions_original_user_id_users_id_fk": { + "name": "support_sessions_original_user_id_users_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "original_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "support_sessions_original_tenant_id_tenants_id_fk": { + "name": "support_sessions_original_tenant_id_tenants_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "original_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "support_sessions_target_tenant_id_tenants_id_fk": { + "name": "support_sessions_target_tenant_id_tenants_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "target_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "support_sessions_target_user_id_users_id_fk": { + "name": "support_sessions_target_user_id_users_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "target_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "is_managing": { + "name": "is_managing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "unique_host_idx": { + "name": "unique_host_idx", + "columns": [ + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_announcements": { + "name": "user_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_announcements_tenant_id_idx": { + "name": "user_announcements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_announcements_user_id_users_id_fk": { + "name": "user_announcements_user_id_users_id_fk", + "tableFrom": "user_announcements", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_announcements_announcement_id_announcements_id_fk": { + "name": "user_announcements_announcement_id_announcements_id_fk", + "tableFrom": "user_announcements", + "columnsFrom": [ + "announcement_id" + ], + "tableTo": "announcements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_announcements_tenant_id_tenants_id_fk": { + "name": "user_announcements_tenant_id_tenants_id_fk", + "tableFrom": "user_announcements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_announcements_user_id_announcement_id_unique": { + "name": "user_announcements_user_id_announcement_id_unique", + "columns": [ + "user_id", + "announcement_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.user_details": { + "name": "user_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_phone_number": { + "name": "contact_phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_details_tenant_id_idx": { + "name": "user_details_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_details_user_id_users_id_fk": { + "name": "user_details_user_id_users_id_fk", + "tableFrom": "user_details", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_details_tenant_id_tenants_id_fk": { + "name": "user_details_tenant_id_tenants_id_fk", + "tableFrom": "user_details", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_details_user_id_unique": { + "name": "user_details_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dashboard": { + "name": "dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "courses": { + "name": "courses", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "announcements": { + "name": "announcements", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "profile": { + "name": "profile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_information": { + "name": "provider_information", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_onboarding_tenant_id_idx": { + "name": "user_onboarding_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_onboarding_user_id_users_id_fk": { + "name": "user_onboarding_user_id_users_id_fk", + "tableFrom": "user_onboarding", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_onboarding_tenant_id_tenants_id_fk": { + "name": "user_onboarding_tenant_id_tenants_id_fk", + "tableFrom": "user_onboarding", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_onboarding_user_id_unique": { + "name": "user_onboarding_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.user_statistics": { + "name": "user_statistics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_date": { + "name": "last_activity_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activity_history": { + "name": "activity_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_statistics_tenant_id_idx": { + "name": "user_statistics_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_statistics_user_id_users_id_fk": { + "name": "user_statistics_user_id_users_id_fk", + "tableFrom": "user_statistics", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_statistics_tenant_id_tenants_id_fk": { + "name": "user_statistics_tenant_id_tenants_id_fk", + "tableFrom": "user_statistics", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_statistics_user_id_unique": { + "name": "user_statistics_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_reference": { + "name": "avatar_reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "users_tenant_id_idx": { + "name": "users_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "users_tenant_id_email_unique_idx": { + "name": "users_tenant_id_email_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.article_status": { + "name": "article_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.course_type": { + "name": "course_type", + "schema": "public", + "values": [ + "default", + "scorm" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "draft", + "published", + "private" + ] + }, + "public.news_status": { + "name": "news_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.scorm_completion_status": { + "name": "scorm_completion_status", + "schema": "public", + "values": [ + "completed", + "incomplete", + "not_attempted", + "unknown" + ] + }, + "public.scorm_package_entity_type": { + "name": "scorm_package_entity_type", + "schema": "public", + "values": [ + "course", + "lesson" + ] + }, + "public.scorm_package_status": { + "name": "scorm_package_status", + "schema": "public", + "values": [ + "processing", + "ready", + "failed" + ] + }, + "public.scorm_standard": { + "name": "scorm_standard", + "schema": "public", + "values": [ + "scorm_1_2", + "scorm_2004" + ] + }, + "public.scorm_success_status": { + "name": "scorm_success_status", + "schema": "public", + "values": [ + "passed", + "failed", + "unknown" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/src/storage/migrations/meta/0182_snapshot.json b/apps/api/src/storage/migrations/meta/0182_snapshot.json new file mode 100644 index 0000000000..2fc7e4a522 --- /dev/null +++ b/apps/api/src/storage/migrations/meta/0182_snapshot.json @@ -0,0 +1,15449 @@ +{ + "id": "5e7af74c-d0cd-4b52-b614-23ad1fbcfddf", + "prevId": "eb36f68f-0a0b-4fb1-b670-87bceb3bb087", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role": { + "name": "actor_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "activity_logs_tenant_id_idx": { + "name": "activity_logs_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_tenant_timeframe_idx": { + "name": "activity_logs_tenant_timeframe_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_actor_idx": { + "name": "activity_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_action_idx": { + "name": "activity_logs_action_idx", + "columns": [ + { + "expression": "action_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_timeframe_idx": { + "name": "activity_logs_timeframe_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_logs_resource_idx": { + "name": "activity_logs_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "activity_logs_actor_id_users_id_fk": { + "name": "activity_logs_actor_id_users_id_fk", + "tableFrom": "activity_logs", + "columnsFrom": [ + "actor_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "activity_logs_tenant_id_tenants_id_fk": { + "name": "activity_logs_tenant_id_tenants_id_fk", + "tableFrom": "activity_logs", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_judge_blocking_errors": { + "name": "ai_judge_blocking_errors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "configuration_id": { + "name": "configuration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_blocking_errors_tenant_id_idx": { + "name": "ai_judge_blocking_errors_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_judge_blocking_errors_configuration_id_created_at_idx": { + "name": "ai_judge_blocking_errors_configuration_id_created_at_idx", + "columns": [ + { + "expression": "configuration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_blocking_errors_configuration_id_ai_judge_configurations_id_fk": { + "name": "ai_judge_blocking_errors_configuration_id_ai_judge_configurations_id_fk", + "tableFrom": "ai_judge_blocking_errors", + "columnsFrom": [ + "configuration_id" + ], + "tableTo": "ai_judge_configurations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_blocking_errors_tenant_id_tenants_id_fk": { + "name": "ai_judge_blocking_errors_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_blocking_errors", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_judge_configurations": { + "name": "ai_judge_configurations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_goal": { + "name": "task_goal", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "passing_threshold_percent": { + "name": "passing_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_configurations_tenant_id_idx": { + "name": "ai_judge_configurations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_configurations_ai_mentor_lesson_id_ai_mentor_lessons_id_fk": { + "name": "ai_judge_configurations_ai_mentor_lesson_id_ai_mentor_lessons_id_fk", + "tableFrom": "ai_judge_configurations", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "tableTo": "ai_mentor_lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_configurations_tenant_id_tenants_id_fk": { + "name": "ai_judge_configurations_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_configurations", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_judge_configurations_ai_mentor_lesson_id_unique": { + "name": "ai_judge_configurations_ai_mentor_lesson_id_unique", + "columns": [ + "ai_mentor_lesson_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.ai_judge_criteria": { + "name": "ai_judge_criteria", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "configuration_id": { + "name": "configuration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expected_behavior": { + "name": "expected_behavior", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_criteria_tenant_id_idx": { + "name": "ai_judge_criteria_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_judge_criteria_configuration_id_created_at_idx": { + "name": "ai_judge_criteria_configuration_id_created_at_idx", + "columns": [ + { + "expression": "configuration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_criteria_configuration_id_ai_judge_configurations_id_fk": { + "name": "ai_judge_criteria_configuration_id_ai_judge_configurations_id_fk", + "tableFrom": "ai_judge_criteria", + "columnsFrom": [ + "configuration_id" + ], + "tableTo": "ai_judge_configurations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_criteria_tenant_id_tenants_id_fk": { + "name": "ai_judge_criteria_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_criteria", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_judge_score_guidance": { + "name": "ai_judge_score_guidance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "criterion_id": { + "name": "criterion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "example": { + "name": "example", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_judge_score_guidance_tenant_id_idx": { + "name": "ai_judge_score_guidance_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_judge_score_guidance_criterion_id_score_unique": { + "name": "ai_judge_score_guidance_criterion_id_score_unique", + "columns": [ + { + "expression": "criterion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_judge_score_guidance_criterion_id_ai_judge_criteria_id_fk": { + "name": "ai_judge_score_guidance_criterion_id_ai_judge_criteria_id_fk", + "tableFrom": "ai_judge_score_guidance", + "columnsFrom": [ + "criterion_id" + ], + "tableTo": "ai_judge_criteria", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_judge_score_guidance_tenant_id_tenants_id_fk": { + "name": "ai_judge_score_guidance_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_score_guidance", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_judgement_blocking_errors": { + "name": "ai_mentor_judgement_blocking_errors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "judgement_id": { + "name": "judgement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocking_error_id": { + "name": "blocking_error_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "blocking_error_description": { + "name": "blocking_error_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "learner_safe_feedback": { + "name": "learner_safe_feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_judgement_blocking_errors_tenant_id_idx": { + "name": "ai_mentor_judgement_blocking_errors_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_mentor_judgement_blocking_errors_judgement_id_blocking_error_id_unique": { + "name": "ai_mentor_judgement_blocking_errors_judgement_id_blocking_error_id_unique", + "columns": [ + { + "expression": "judgement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocking_error_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_judgement_blocking_errors_judgement_id_ai_mentor_judgements_id_fk": { + "name": "ai_mentor_judgement_blocking_errors_judgement_id_ai_mentor_judgements_id_fk", + "tableFrom": "ai_mentor_judgement_blocking_errors", + "columnsFrom": [ + "judgement_id" + ], + "tableTo": "ai_mentor_judgements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_judgement_blocking_errors_blocking_error_id_ai_judge_blocking_errors_id_fk": { + "name": "ai_mentor_judgement_blocking_errors_blocking_error_id_ai_judge_blocking_errors_id_fk", + "tableFrom": "ai_mentor_judgement_blocking_errors", + "columnsFrom": [ + "blocking_error_id" + ], + "tableTo": "ai_judge_blocking_errors", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "ai_mentor_judgement_blocking_errors_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgement_blocking_errors_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgement_blocking_errors", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_judgement_criteria": { + "name": "ai_mentor_judgement_criteria", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "judgement_id": { + "name": "judgement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "criterion_id": { + "name": "criterion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "criterion_title": { + "name": "criterion_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "awarded_points": { + "name": "awarded_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_score_at_judgement": { + "name": "max_score_at_judgement", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "learner_safe_feedback": { + "name": "learner_safe_feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_judgement_criteria_tenant_id_idx": { + "name": "ai_mentor_judgement_criteria_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "ai_mentor_judgement_criteria_judgement_id_criterion_id_unique": { + "name": "ai_mentor_judgement_criteria_judgement_id_criterion_id_unique", + "columns": [ + { + "expression": "judgement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "criterion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_judgement_criteria_judgement_id_ai_mentor_judgements_id_fk": { + "name": "ai_mentor_judgement_criteria_judgement_id_ai_mentor_judgements_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", + "columnsFrom": [ + "judgement_id" + ], + "tableTo": "ai_mentor_judgements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_judgement_criteria_criterion_id_ai_judge_criteria_id_fk": { + "name": "ai_mentor_judgement_criteria_criterion_id_ai_judge_criteria_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", + "columnsFrom": [ + "criterion_id" + ], + "tableTo": "ai_judge_criteria", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "ai_mentor_judgement_criteria_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgement_criteria_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_judgements": { + "name": "ai_mentor_judgements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "configuration_id": { + "name": "configuration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "earned_points": { + "name": "earned_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_judgements_tenant_id_idx": { + "name": "ai_mentor_judgements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_judgements_thread_id_ai_mentor_threads_id_fk": { + "name": "ai_mentor_judgements_thread_id_ai_mentor_threads_id_fk", + "tableFrom": "ai_mentor_judgements", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "ai_mentor_threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_judgements_configuration_id_ai_judge_configurations_id_fk": { + "name": "ai_mentor_judgements_configuration_id_ai_judge_configurations_id_fk", + "tableFrom": "ai_mentor_judgements", + "columnsFrom": [ + "configuration_id" + ], + "tableTo": "ai_judge_configurations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "ai_mentor_judgements_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgements_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_mentor_judgements_thread_id_unique": { + "name": "ai_mentor_judgements_thread_id_unique", + "columns": [ + "thread_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.ai_mentor_lessons": { + "name": "ai_mentor_lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ai_mentor_instructions": { + "name": "ai_mentor_instructions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "name": { + "name": "name", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "avatar_reference": { + "name": "avatar_reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'roleplay'" + }, + "voice_mode": { + "name": "voice_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preset'" + }, + "tts_preset": { + "name": "tts_preset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'male'" + }, + "custom_tts_reference": { + "name": "custom_tts_reference", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_lessons_tenant_id_idx": { + "name": "ai_mentor_lessons_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_lessons_lesson_id_lessons_id_fk": { + "name": "ai_mentor_lessons_lesson_id_lessons_id_fk", + "tableFrom": "ai_mentor_lessons", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_lessons_tenant_id_tenants_id_fk": { + "name": "ai_mentor_lessons_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_lessons", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_student_lesson_progress": { + "name": "ai_mentor_student_lesson_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_lesson_progress_id": { + "name": "student_lesson_progress_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_score": { + "name": "min_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percentage": { + "name": "percentage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_student_lesson_progress_tenant_id_idx": { + "name": "ai_mentor_student_lesson_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_student_lesson_progress_student_lesson_progress_id_student_lesson_progress_id_fk": { + "name": "ai_mentor_student_lesson_progress_student_lesson_progress_id_student_lesson_progress_id_fk", + "tableFrom": "ai_mentor_student_lesson_progress", + "columnsFrom": [ + "student_lesson_progress_id" + ], + "tableTo": "student_lesson_progress", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_student_lesson_progress_tenant_id_tenants_id_fk": { + "name": "ai_mentor_student_lesson_progress_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_student_lesson_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_thread_messages": { + "name": "ai_mentor_thread_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_thread_messages_tenant_id_idx": { + "name": "ai_mentor_thread_messages_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_thread_messages_thread_id_ai_mentor_threads_id_fk": { + "name": "ai_mentor_thread_messages_thread_id_ai_mentor_threads_id_fk", + "tableFrom": "ai_mentor_thread_messages", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "ai_mentor_threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_thread_messages_tenant_id_tenants_id_fk": { + "name": "ai_mentor_thread_messages_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_thread_messages", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ai_mentor_threads": { + "name": "ai_mentor_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "user_language": { + "name": "user_language", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "ai_mentor_threads_tenant_id_idx": { + "name": "ai_mentor_threads_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ai_mentor_threads_user_id_users_id_fk": { + "name": "ai_mentor_threads_user_id_users_id_fk", + "tableFrom": "ai_mentor_threads", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_threads_ai_mentor_lesson_id_ai_mentor_lessons_id_fk": { + "name": "ai_mentor_threads_ai_mentor_lesson_id_ai_mentor_lessons_id_fk", + "tableFrom": "ai_mentor_threads", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "tableTo": "ai_mentor_lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ai_mentor_threads_tenant_id_tenants_id_fk": { + "name": "ai_mentor_threads_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_threads", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all_users'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "send_email": { + "name": "send_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_template": { + "name": "email_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "announcements_tenant_id_idx": { + "name": "announcements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "announcements_author_id_users_id_fk": { + "name": "announcements_author_id_users_id_fk", + "tableFrom": "announcements", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "announcements_tenant_id_tenants_id_fk": { + "name": "announcements_tenant_id_tenants_id_fk", + "tableFrom": "announcements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.article_sections": { + "name": "article_sections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "article_sections_tenant_id_idx": { + "name": "article_sections_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "article_sections_tenant_id_tenants_id_fk": { + "name": "article_sections_tenant_id_tenants_id_fk", + "tableFrom": "article_sections", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.articles": { + "name": "articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "article_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "article_section_id": { + "name": "article_section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "articles_tenant_id_idx": { + "name": "articles_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "article_section_idx": { + "name": "article_section_idx", + "columns": [ + { + "expression": "article_section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "articles_article_section_id_article_sections_id_fk": { + "name": "articles_article_section_id_article_sections_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "article_section_id" + ], + "tableTo": "article_sections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "articles_author_id_users_id_fk": { + "name": "articles_author_id_users_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "articles_updated_by_id_users_id_fk": { + "name": "articles_updated_by_id_users_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "updated_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "articles_tenant_id_tenants_id_fk": { + "name": "articles_tenant_id_tenants_id_fk", + "tableFrom": "articles", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_connections": { + "name": "calendar_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted_dek": { + "name": "refresh_token_encrypted_dek", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted_dek_iv": { + "name": "refresh_token_encrypted_dek_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted_dek_tag": { + "name": "refresh_token_encrypted_dek_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'syncing'" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_cursor": { + "name": "sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_window_start": { + "name": "sync_window_start", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_window_end": { + "name": "sync_window_end", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "window_built_at": { + "name": "window_built_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_completed_at": { + "name": "last_sync_completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_client_state": { + "name": "subscription_client_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_expires_at": { + "name": "subscription_expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_sync_enabled": { + "name": "outbound_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "outbound_calendar_id": { + "name": "outbound_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_error_code": { + "name": "outbound_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_outbound_sync_at": { + "name": "last_outbound_sync_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_connections_tenant_id_idx": { + "name": "calendar_connections_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_connections_tenant_user_provider_unique_idx": { + "name": "calendar_connections_tenant_user_provider_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_connections_subscription_idx": { + "name": "calendar_connections_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_connections_user_id_users_id_fk": { + "name": "calendar_connections_user_id_users_id_fk", + "tableFrom": "calendar_connections", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_connections_tenant_id_tenants_id_fk": { + "name": "calendar_connections_tenant_id_tenants_id_fk", + "tableFrom": "calendar_connections", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "uid": { + "name": "uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organizer_user_id": { + "name": "organizer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exdates": { + "name": "exdates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_events_tenant_id_idx": { + "name": "calendar_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_events_tenant_starts_ends_idx": { + "name": "calendar_events_tenant_starts_ends_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_events_tenant_uid_unique_idx": { + "name": "calendar_events_tenant_uid_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_events_organizer_user_id_users_id_fk": { + "name": "calendar_events_organizer_user_id_users_id_fk", + "tableFrom": "calendar_events", + "columnsFrom": [ + "organizer_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "calendar_events_tenant_id_tenants_id_fk": { + "name": "calendar_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_external_events": { + "name": "calendar_external_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "web_link": { + "name": "web_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_cancelled": { + "name": "is_cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_external_events_tenant_id_idx": { + "name": "calendar_external_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_external_events_calendar_event_unique_idx": { + "name": "calendar_external_events_calendar_event_unique_idx", + "columns": [ + { + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_external_events_tenant_connection_event_unique_idx": { + "name": "calendar_external_events_tenant_connection_event_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_external_events_tenant_user_idx": { + "name": "calendar_external_events_tenant_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_external_events_connection_id_calendar_connections_id_fk": { + "name": "calendar_external_events_connection_id_calendar_connections_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "connection_id" + ], + "tableTo": "calendar_connections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_external_events_calendar_event_id_calendar_events_id_fk": { + "name": "calendar_external_events_calendar_event_id_calendar_events_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_external_events_user_id_users_id_fk": { + "name": "calendar_external_events_user_id_users_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_external_events_tenant_id_tenants_id_fk": { + "name": "calendar_external_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_external_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_outbound_events": { + "name": "calendar_outbound_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "calendar_outbound_events_tenant_id_idx": { + "name": "calendar_outbound_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_outbound_events_connection_event_user_unique_idx": { + "name": "calendar_outbound_events_connection_event_user_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_outbound_events_connection_external_event_unique_idx": { + "name": "calendar_outbound_events_connection_external_event_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_outbound_events_calendar_event_idx": { + "name": "calendar_outbound_events_calendar_event_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_outbound_events_connection_id_calendar_connections_id_fk": { + "name": "calendar_outbound_events_connection_id_calendar_connections_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "connection_id" + ], + "tableTo": "calendar_connections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_outbound_events_calendar_event_id_calendar_events_id_fk": { + "name": "calendar_outbound_events_calendar_event_id_calendar_events_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_outbound_events_user_id_users_id_fk": { + "name": "calendar_outbound_events_user_id_users_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "calendar_outbound_events_tenant_id_tenants_id_fk": { + "name": "calendar_outbound_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_outbound_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "categories_tenant_id_idx": { + "name": "categories_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "categories_tenant_id_base_title_unique": { + "name": "categories_tenant_id_base_title_unique", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"title\"->>\"base_language\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "categories_tenant_id_tenants_id_fk": { + "name": "categories_tenant_id_tenants_id_fk", + "tableFrom": "categories", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.certificates": { + "name": "certificates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiration_warning_sent_at": { + "name": "expiration_warning_sent_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "certificates_tenant_id_idx": { + "name": "certificates_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "certificates_active_expiry_idx": { + "name": "certificates_active_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "certificates_user_course_idx": { + "name": "certificates_user_course_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "certificates_user_id_users_id_fk": { + "name": "certificates_user_id_users_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "certificates_course_id_courses_id_fk": { + "name": "certificates_course_id_courses_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "certificates_tenant_id_tenants_id_fk": { + "name": "certificates_tenant_id_tenants_id_fk", + "tableFrom": "certificates", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.chapters": { + "name": "chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_freemium": { + "name": "is_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lesson_count": { + "name": "lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "chapters_tenant_id_idx": { + "name": "chapters_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "chapters_tenant_id_course_id_idx": { + "name": "chapters_tenant_id_course_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chapters_course_id_courses_id_fk": { + "name": "chapters_course_id_courses_id_fk", + "tableFrom": "chapters", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chapters_author_id_users_id_fk": { + "name": "chapters_author_id_users_id_fk", + "tableFrom": "chapters", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "chapters_tenant_id_tenants_id_fk": { + "name": "chapters_tenant_id_tenants_id_fk", + "tableFrom": "chapters", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_chat_message_reactions": { + "name": "course_chat_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_chat_message_reactions_tenant_id_idx": { + "name": "course_chat_message_reactions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_message_reactions_message_id_reaction_idx": { + "name": "course_chat_message_reactions_message_id_reaction_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reaction", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_message_reactions_user_message_reaction_unique_idx": { + "name": "course_chat_message_reactions_user_message_reaction_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reaction", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_chat_message_reactions_message_id_course_chat_messages_id_fk": { + "name": "course_chat_message_reactions_message_id_course_chat_messages_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "message_id" + ], + "tableTo": "course_chat_messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_message_reactions_course_id_courses_id_fk": { + "name": "course_chat_message_reactions_course_id_courses_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_message_reactions_user_id_users_id_fk": { + "name": "course_chat_message_reactions_user_id_users_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_message_reactions_tenant_id_tenants_id_fk": { + "name": "course_chat_message_reactions_tenant_id_tenants_id_fk", + "tableFrom": "course_chat_message_reactions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_chat_messages": { + "name": "course_chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_chat_messages_tenant_id_idx": { + "name": "course_chat_messages_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_messages_course_id_created_at_idx": { + "name": "course_chat_messages_course_id_created_at_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_messages_thread_id_created_at_idx": { + "name": "course_chat_messages_thread_id_created_at_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_messages_parent_message_id_created_at_idx": { + "name": "course_chat_messages_parent_message_id_created_at_idx", + "columns": [ + { + "expression": "parent_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_chat_messages_thread_id_course_chat_threads_id_fk": { + "name": "course_chat_messages_thread_id_course_chat_threads_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "course_chat_threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_messages_course_id_courses_id_fk": { + "name": "course_chat_messages_course_id_courses_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_messages_user_id_users_id_fk": { + "name": "course_chat_messages_user_id_users_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_messages_parent_message_id_course_chat_messages_id_fk": { + "name": "course_chat_messages_parent_message_id_course_chat_messages_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "parent_message_id" + ], + "tableTo": "course_chat_messages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "course_chat_messages_tenant_id_tenants_id_fk": { + "name": "course_chat_messages_tenant_id_tenants_id_fk", + "tableFrom": "course_chat_messages", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_chat_threads": { + "name": "course_chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_chat_threads_tenant_id_idx": { + "name": "course_chat_threads_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_threads_course_id_created_at_idx": { + "name": "course_chat_threads_course_id_created_at_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_chat_threads_course_id_updated_at_idx": { + "name": "course_chat_threads_course_id_updated_at_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_chat_threads_course_id_courses_id_fk": { + "name": "course_chat_threads_course_id_courses_id_fk", + "tableFrom": "course_chat_threads", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_threads_created_by_user_id_users_id_fk": { + "name": "course_chat_threads_created_by_user_id_users_id_fk", + "tableFrom": "course_chat_threads", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_chat_threads_tenant_id_tenants_id_fk": { + "name": "course_chat_threads_tenant_id_tenants_id_fk", + "tableFrom": "course_chat_threads", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_slugs": { + "name": "course_slugs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_short_id": { + "name": "course_short_id", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_slugs_tenant_id_idx": { + "name": "course_slugs_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "course_slug_course_short_id_lang_unique_idx": { + "name": "course_slug_course_short_id_lang_unique_idx", + "columns": [ + { + "expression": "course_short_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_slugs_course_short_id_courses_short_id_fk": { + "name": "course_slugs_course_short_id_courses_short_id_fk", + "tableFrom": "course_slugs", + "columnsFrom": [ + "course_short_id" + ], + "tableTo": "courses", + "columnsTo": [ + "short_id" + ], + "onUpdate": "cascade", + "onDelete": "cascade" + }, + "course_slugs_tenant_id_tenants_id_fk": { + "name": "course_slugs_tenant_id_tenants_id_fk", + "tableFrom": "course_slugs", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.course_student_mode": { + "name": "course_student_mode", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_student_mode_tenant_id_idx": { + "name": "course_student_mode_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_student_mode_user_id_users_id_fk": { + "name": "course_student_mode_user_id_users_id_fk", + "tableFrom": "course_student_mode", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_student_mode_course_id_courses_id_fk": { + "name": "course_student_mode_course_id_courses_id_fk", + "tableFrom": "course_student_mode", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_student_mode_tenant_id_tenants_id_fk": { + "name": "course_student_mode_tenant_id_tenants_id_fk", + "tableFrom": "course_student_mode", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_student_mode_user_id_course_id_unique": { + "name": "course_student_mode_user_id_course_id_unique", + "columns": [ + "user_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.course_students_stats": { + "name": "course_students_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "new_students_count": { + "name": "new_students_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "course_students_stats_tenant_id_idx": { + "name": "course_students_stats_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "course_students_stats_course_id_courses_id_fk": { + "name": "course_students_stats_course_id_courses_id_fk", + "tableFrom": "course_students_stats", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_students_stats_author_id_users_id_fk": { + "name": "course_students_stats_author_id_users_id_fk", + "tableFrom": "course_students_stats", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "course_students_stats_tenant_id_tenants_id_fk": { + "name": "course_students_stats_tenant_id_tenants_id_fk", + "tableFrom": "course_students_stats", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "course_students_stats_course_id_month_year_unique": { + "name": "course_students_stats_course_id_month_year_unique", + "columns": [ + "course_id", + "month", + "year" + ], + "nullsNotDistinct": false + } + } + }, + "public.courses": { + "name": "courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_id": { + "name": "short_id", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "thumbnail_s3_key": { + "name": "thumbnail_s3_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "thumbnail_position_y": { + "name": "thumbnail_position_y", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "has_certificate": { + "name": "has_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_in_cents": { + "name": "price_in_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_author_section": { + "name": "show_author_section", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "chapter_count": { + "name": "chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learning_outcomes": { + "name": "learning_outcomes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "duration_estimates": { + "name": "duration_estimates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "course_type": { + "name": "course_type", + "type": "course_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'regular'" + }, + "source_course_id": { + "name": "source_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"lessonSequenceEnabled\":false,\"quizFeedbackEnabled\":true,\"certificateSignature\":null,\"certificateFontColor\":null,\"certificateValidity\":null,\"videoCompletionTrackingEnabled\":true}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "courses_tenant_id_idx": { + "name": "courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "courses_short_id_unique_idx": { + "name": "courses_short_id_unique_idx", + "columns": [ + { + "expression": "short_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "courses_author_id_users_id_fk": { + "name": "courses_author_id_users_id_fk", + "tableFrom": "courses", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "courses_category_id_categories_id_fk": { + "name": "courses_category_id_categories_id_fk", + "tableFrom": "courses", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "courses_tenant_id_tenants_id_fk": { + "name": "courses_tenant_id_tenants_id_fk", + "tableFrom": "courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.courses_summary_stats": { + "name": "courses_summary_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "free_purchased_count": { + "name": "free_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_count": { + "name": "paid_purchased_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_purchased_after_freemium_count": { + "name": "paid_purchased_after_freemium_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_freemium_student_count": { + "name": "completed_freemium_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_course_student_count": { + "name": "completed_course_student_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "courses_summary_stats_tenant_id_idx": { + "name": "courses_summary_stats_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "courses_summary_stats_course_id_courses_id_fk": { + "name": "courses_summary_stats_course_id_courses_id_fk", + "tableFrom": "courses_summary_stats", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "courses_summary_stats_author_id_users_id_fk": { + "name": "courses_summary_stats_author_id_users_id_fk", + "tableFrom": "courses_summary_stats", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "courses_summary_stats_tenant_id_tenants_id_fk": { + "name": "courses_summary_stats_tenant_id_tenants_id_fk", + "tableFrom": "courses_summary_stats", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "courses_summary_stats_course_id_unique": { + "name": "courses_summary_stats_course_id_unique", + "columns": [ + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.create_tokens": { + "name": "create_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "reminder_count": { + "name": "reminder_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "create_tokens_tenant_id_idx": { + "name": "create_tokens_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "create_tokens_token_hash_idx": { + "name": "create_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "create_tokens_user_id_users_id_fk": { + "name": "create_tokens_user_id_users_id_fk", + "tableFrom": "create_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "create_tokens_tenant_id_tenants_id_fk": { + "name": "create_tokens_tenant_id_tenants_id_fk", + "tableFrom": "create_tokens", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requires_password_change": { + "name": "requires_password_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "credentials_tenant_id_idx": { + "name": "credentials_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credentials_tenant_id_tenants_id_fk": { + "name": "credentials_tenant_id_tenants_id_fk", + "tableFrom": "credentials", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.doc_chunks": { + "name": "doc_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "doc_chunks_tenant_id_idx": { + "name": "doc_chunks_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "doc_chunks_document_id_documents_id_fk": { + "name": "doc_chunks_document_id_documents_id_fk", + "tableFrom": "doc_chunks", + "columnsFrom": [ + "document_id" + ], + "tableTo": "documents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "doc_chunks_tenant_id_tenants_id_fk": { + "name": "doc_chunks_tenant_id_tenants_id_fk", + "tableFrom": "doc_chunks", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.document_to_ai_mentor_lesson": { + "name": "document_to_ai_mentor_lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "document_to_ai_mentor_lesson_tenant_id_idx": { + "name": "document_to_ai_mentor_lesson_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "document_to_ai_mentor_lesson_document_id_documents_id_fk": { + "name": "document_to_ai_mentor_lesson_document_id_documents_id_fk", + "tableFrom": "document_to_ai_mentor_lesson", + "columnsFrom": [ + "document_id" + ], + "tableTo": "documents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "document_to_ai_mentor_lesson_ai_mentor_lesson_id_ai_mentor_lessons_id_fk": { + "name": "document_to_ai_mentor_lesson_ai_mentor_lesson_id_ai_mentor_lessons_id_fk", + "tableFrom": "document_to_ai_mentor_lesson", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "tableTo": "ai_mentor_lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "document_to_ai_mentor_lesson_tenant_id_tenants_id_fk": { + "name": "document_to_ai_mentor_lesson_tenant_id_tenants_id_fk", + "tableFrom": "document_to_ai_mentor_lesson", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "document_to_ai_mentor_lesson_document_id_ai_mentor_lesson_id_unique": { + "name": "document_to_ai_mentor_lesson_document_id_ai_mentor_lesson_id_unique", + "columns": [ + "document_id", + "ai_mentor_lesson_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "check_sum": { + "name": "check_sum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "documents_tenant_id_idx": { + "name": "documents_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "documents_tenant_id_tenants_id_fk": { + "name": "documents_tenant_id_tenants_id_fk", + "tableFrom": "documents", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "documents_check_sum_unique": { + "name": "documents_check_sum_unique", + "columns": [ + "check_sum" + ], + "nullsNotDistinct": false + } + } + }, + "public.email_notification_templates": { + "name": "email_notification_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "blocks": { + "name": "blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"type\":\"doc\",\"content\":[]}'::jsonb" + }, + "strings": { + "name": "strings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "email_notification_templates_tenant_id_idx": { + "name": "email_notification_templates_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_notification_templates_tenant_id_name_unique_idx": { + "name": "email_notification_templates_tenant_id_name_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_notification_templates_tenant_id_tenants_id_fk": { + "name": "email_notification_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_notification_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.form_field_answers": { + "name": "form_field_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "form_field_id": { + "name": "form_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "label_snapshot": { + "name": "label_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "answered_language": { + "name": "answered_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "form_field_answers_tenant_id_idx": { + "name": "form_field_answers_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "form_field_answers_user_id_form_field_id_unique": { + "name": "form_field_answers_user_id_form_field_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "form_field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "form_field_answers_user_id_idx": { + "name": "form_field_answers_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "form_field_answers_form_field_id_form_fields_id_fk": { + "name": "form_field_answers_form_field_id_form_fields_id_fk", + "tableFrom": "form_field_answers", + "columnsFrom": [ + "form_field_id" + ], + "tableTo": "form_fields", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "form_field_answers_user_id_users_id_fk": { + "name": "form_field_answers_user_id_users_id_fk", + "tableFrom": "form_field_answers", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "form_field_answers_tenant_id_tenants_id_fk": { + "name": "form_field_answers_tenant_id_tenants_id_fk", + "tableFrom": "form_field_answers", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.form_fields": { + "name": "form_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "form_fields_tenant_id_idx": { + "name": "form_fields_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "form_fields_form_id_display_order_idx": { + "name": "form_fields_form_id_display_order_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "form_fields_form_id_forms_id_fk": { + "name": "form_fields_form_id_forms_id_fk", + "tableFrom": "form_fields", + "columnsFrom": [ + "form_id" + ], + "tableTo": "forms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "form_fields_tenant_id_tenants_id_fk": { + "name": "form_fields_tenant_id_tenants_id_fk", + "tableFrom": "form_fields", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "forms_tenant_id_idx": { + "name": "forms_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "forms_tenant_id_type_unique_idx": { + "name": "forms_tenant_id_type_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "forms_tenant_id_tenants_id_fk": { + "name": "forms_tenant_id_tenants_id_fk", + "tableFrom": "forms", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.group_announcements": { + "name": "group_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_announcements_tenant_id_idx": { + "name": "group_announcements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_announcements_group_id_groups_id_fk": { + "name": "group_announcements_group_id_groups_id_fk", + "tableFrom": "group_announcements", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_announcements_announcement_id_announcements_id_fk": { + "name": "group_announcements_announcement_id_announcements_id_fk", + "tableFrom": "group_announcements", + "columnsFrom": [ + "announcement_id" + ], + "tableTo": "announcements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_announcements_tenant_id_tenants_id_fk": { + "name": "group_announcements_tenant_id_tenants_id_fk", + "tableFrom": "group_announcements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_announcements_group_id_announcement_id_unique": { + "name": "group_announcements_group_id_announcement_id_unique", + "columns": [ + "group_id", + "announcement_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.group_courses": { + "name": "group_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enrolled_by": { + "name": "enrolled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_courses_tenant_id_idx": { + "name": "group_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_courses_group_id_groups_id_fk": { + "name": "group_courses_group_id_groups_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_courses_course_id_courses_id_fk": { + "name": "group_courses_course_id_courses_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_courses_enrolled_by_users_id_fk": { + "name": "group_courses_enrolled_by_users_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "enrolled_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "group_courses_calendar_event_id_calendar_events_id_fk": { + "name": "group_courses_calendar_event_id_calendar_events_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "group_courses_tenant_id_tenants_id_fk": { + "name": "group_courses_tenant_id_tenants_id_fk", + "tableFrom": "group_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_courses_calendar_event_id_unique": { + "name": "group_courses_calendar_event_id_unique", + "columns": [ + "calendar_event_id" + ], + "nullsNotDistinct": false + }, + "group_courses_group_id_course_id_unique": { + "name": "group_courses_group_id_course_id_unique", + "columns": [ + "group_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.group_learning_paths": { + "name": "group_learning_paths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_learning_paths_tenant_id_idx": { + "name": "group_learning_paths_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "group_learning_paths_learning_path_idx": { + "name": "group_learning_paths_learning_path_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_learning_paths_group_id_groups_id_fk": { + "name": "group_learning_paths_group_id_groups_id_fk", + "tableFrom": "group_learning_paths", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_learning_paths_learning_path_id_learning_paths_id_fk": { + "name": "group_learning_paths_learning_path_id_learning_paths_id_fk", + "tableFrom": "group_learning_paths", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_learning_paths_tenant_id_tenants_id_fk": { + "name": "group_learning_paths_tenant_id_tenants_id_fk", + "tableFrom": "group_learning_paths", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_learning_paths_group_id_learning_path_id_unique": { + "name": "group_learning_paths_group_id_learning_path_id_unique", + "columns": [ + "group_id", + "learning_path_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.group_users": { + "name": "group_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "group_users_tenant_id_idx": { + "name": "group_users_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "group_users_user_id_users_id_fk": { + "name": "group_users_user_id_users_id_fk", + "tableFrom": "group_users", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_users_group_id_groups_id_fk": { + "name": "group_users_group_id_groups_id_fk", + "tableFrom": "group_users", + "columnsFrom": [ + "group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "group_users_tenant_id_tenants_id_fk": { + "name": "group_users_tenant_id_tenants_id_fk", + "tableFrom": "group_users", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_users_user_id_group_id_unique": { + "name": "group_users_user_id_group_id_unique", + "columns": [ + "user_id", + "group_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "characteristic": { + "name": "characteristic", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "groups_tenant_id_idx": { + "name": "groups_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "groups_tenant_id_tenants_id_fk": { + "name": "groups_tenant_id_tenants_id_fk", + "tableFrom": "groups", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.integration_api_keys": { + "name": "integration_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "integration_api_keys_tenant_id_idx": { + "name": "integration_api_keys_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "integration_api_keys_key_prefix_idx": { + "name": "integration_api_keys_key_prefix_idx", + "columns": [ + { + "expression": "key_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "integration_api_keys_created_by_idx": { + "name": "integration_api_keys_created_by_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "integration_api_keys_created_by_user_id_users_id_fk": { + "name": "integration_api_keys_created_by_user_id_users_id_fk", + "tableFrom": "integration_api_keys", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "integration_api_keys_tenant_id_tenants_id_fk": { + "name": "integration_api_keys_tenant_id_tenants_id_fk", + "tableFrom": "integration_api_keys", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_certificates": { + "name": "learning_path_certificates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "learning_path_certificates_tenant_id_idx": { + "name": "learning_path_certificates_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_certificates_user_id_users_id_fk": { + "name": "learning_path_certificates_user_id_users_id_fk", + "tableFrom": "learning_path_certificates", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_certificates_learning_path_id_learning_paths_id_fk": { + "name": "learning_path_certificates_learning_path_id_learning_paths_id_fk", + "tableFrom": "learning_path_certificates", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_certificates_tenant_id_tenants_id_fk": { + "name": "learning_path_certificates_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_certificates", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_courses": { + "name": "learning_path_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "learning_path_courses_tenant_id_idx": { + "name": "learning_path_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_courses_path_id_course_id_unique_idx": { + "name": "learning_path_courses_path_id_course_id_unique_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_courses_path_id_display_order_unique_idx": { + "name": "learning_path_courses_path_id_display_order_unique_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_courses_path_id_display_order_idx": { + "name": "learning_path_courses_path_id_display_order_idx", + "columns": [ + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_courses_learning_path_id_learning_paths_id_fk": { + "name": "learning_path_courses_learning_path_id_learning_paths_id_fk", + "tableFrom": "learning_path_courses", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_courses_course_id_courses_id_fk": { + "name": "learning_path_courses_course_id_courses_id_fk", + "tableFrom": "learning_path_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_courses_tenant_id_tenants_id_fk": { + "name": "learning_path_courses_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_entity_map": { + "name": "learning_path_entity_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_entity_id": { + "name": "source_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_entity_id": { + "name": "target_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "learning_path_entity_map_export_idx": { + "name": "learning_path_entity_map_export_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_entity_map_source_entity_idx": { + "name": "learning_path_entity_map_source_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_entity_map_source_unique_idx": { + "name": "learning_path_entity_map_source_unique_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_entity_map_export_id_learning_path_exports_id_fk": { + "name": "learning_path_entity_map_export_id_learning_path_exports_id_fk", + "tableFrom": "learning_path_entity_map", + "columnsFrom": [ + "export_id" + ], + "tableTo": "learning_path_exports", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_path_exports": { + "name": "learning_path_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_learning_path_id": { + "name": "source_learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_learning_path_id": { + "name": "target_learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "learning_path_exports_source_learning_path_idx": { + "name": "learning_path_exports_source_learning_path_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_exports_target_learning_path_idx": { + "name": "learning_path_exports_target_learning_path_idx", + "columns": [ + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "learning_path_exports_source_target_unique_idx": { + "name": "learning_path_exports_source_target_unique_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_path_exports_source_tenant_id_tenants_id_fk": { + "name": "learning_path_exports_source_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_exports", + "columnsFrom": [ + "source_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_exports_target_tenant_id_tenants_id_fk": { + "name": "learning_path_exports_target_tenant_id_tenants_id_fk", + "tableFrom": "learning_path_exports", + "columnsFrom": [ + "target_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "learning_path_exports_target_learning_path_id_learning_paths_id_fk": { + "name": "learning_path_exports_target_learning_path_id_learning_paths_id_fk", + "tableFrom": "learning_path_exports", + "columnsFrom": [ + "target_learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.learning_paths": { + "name": "learning_paths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "thumbnail_reference": { + "name": "thumbnail_reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "includes_certificate": { + "name": "includes_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"certificateSignature\":null,\"certificateFontColor\":null}'::jsonb" + }, + "sequence_enabled": { + "name": "sequence_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'regular'" + }, + "source_learning_path_id": { + "name": "source_learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "learning_paths_tenant_id_idx": { + "name": "learning_paths_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "learning_paths_author_id_users_id_fk": { + "name": "learning_paths_author_id_users_id_fk", + "tableFrom": "learning_paths", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "learning_paths_tenant_id_tenants_id_fk": { + "name": "learning_paths_tenant_id_tenants_id_fk", + "tableFrom": "learning_paths", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.lesson_learning_time": { + "name": "lesson_learning_time", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "lesson_learning_time_tenant_id_idx": { + "name": "lesson_learning_time_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lesson_learning_time_user_course_idx": { + "name": "lesson_learning_time_user_course_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lesson_learning_time_user_id_users_id_fk": { + "name": "lesson_learning_time_user_id_users_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_learning_time_lesson_id_lessons_id_fk": { + "name": "lesson_learning_time_lesson_id_lessons_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_learning_time_course_id_courses_id_fk": { + "name": "lesson_learning_time_course_id_courses_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_learning_time_tenant_id_tenants_id_fk": { + "name": "lesson_learning_time_tenant_id_tenants_id_fk", + "tableFrom": "lesson_learning_time", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lesson_learning_time_user_id_lesson_id_unique": { + "name": "lesson_learning_time_user_id_lesson_id_unique", + "columns": [ + "user_id", + "lesson_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.lesson_video_progress": { + "name": "lesson_video_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_entity_id": { + "name": "resource_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bucket_size_seconds": { + "name": "bucket_size_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "watched_ranges": { + "name": "watched_ranges", + "type": "int4multirange", + "primaryKey": false, + "notNull": true, + "default": "'{}'::int4multirange" + }, + "covered_bucket_count": { + "name": "covered_bucket_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "coverage_percent": { + "name": "coverage_percent", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_watch_seconds": { + "name": "active_watch_seconds", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_watched": { + "name": "is_watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "watched_at": { + "name": "watched_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "lesson_video_progress_tenant_id_idx": { + "name": "lesson_video_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lesson_video_progress_lesson_idx": { + "name": "lesson_video_progress_lesson_idx", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lesson_video_progress_resource_entity_idx": { + "name": "lesson_video_progress_resource_entity_idx", + "columns": [ + { + "expression": "resource_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lesson_video_progress_student_id_users_id_fk": { + "name": "lesson_video_progress_student_id_users_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_video_progress_lesson_id_lessons_id_fk": { + "name": "lesson_video_progress_lesson_id_lessons_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_video_progress_resource_entity_id_resource_entity_id_fk": { + "name": "lesson_video_progress_resource_entity_id_resource_entity_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "resource_entity_id" + ], + "tableTo": "resource_entity", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lesson_video_progress_tenant_id_tenants_id_fk": { + "name": "lesson_video_progress_tenant_id_tenants_id_fk", + "tableFrom": "lesson_video_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lesson_video_progress_student_id_lesson_id_resource_entity_id_unique": { + "name": "lesson_video_progress_student_id_lesson_id_resource_entity_id_unique", + "columns": [ + "student_id", + "lesson_id", + "resource_entity_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.lessons": { + "name": "lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "threshold_score": { + "name": "threshold_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempts_limit": { + "name": "attempts_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quiz_cooldown_in_hours": { + "name": "quiz_cooldown_in_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_s3_key": { + "name": "file_s3_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "is_external": { + "name": "is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "lessons_tenant_id_idx": { + "name": "lessons_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "lessons_tenant_id_chapter_id_type_idx": { + "name": "lessons_tenant_id_chapter_id_type_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lessons_chapter_id_chapters_id_fk": { + "name": "lessons_chapter_id_chapters_id_fk", + "tableFrom": "lessons", + "columnsFrom": [ + "chapter_id" + ], + "tableTo": "chapters", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lessons_tenant_id_tenants_id_fk": { + "name": "lessons_tenant_id_tenants_id_fk", + "tableFrom": "lessons", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_lessons": { + "name": "live_lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_link_id": { + "name": "live_training_link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_lessons_tenant_id_idx": { + "name": "live_lessons_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_lessons_lesson_language_unique_idx": { + "name": "live_lessons_lesson_language_unique_idx", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_lessons_training_link_idx": { + "name": "live_lessons_training_link_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_lessons_training_idx": { + "name": "live_lessons_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_lessons_live_training_id_live_trainings_id_fk": { + "name": "live_lessons_live_training_id_live_trainings_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_lessons_live_training_link_id_live_training_links_id_fk": { + "name": "live_lessons_live_training_link_id_live_training_links_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "live_training_link_id" + ], + "tableTo": "live_training_links", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_lessons_lesson_id_lessons_id_fk": { + "name": "live_lessons_lesson_id_lessons_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_lessons_tenant_id_tenants_id_fk": { + "name": "live_lessons_tenant_id_tenants_id_fk", + "tableFrom": "live_lessons", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_attendance": { + "name": "live_training_attendance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_session_participant_id": { + "name": "live_training_session_participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_session_id": { + "name": "live_training_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "left_at": { + "name": "left_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "livekit_participant_sid": { + "name": "livekit_participant_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_attendance_tenant_id_idx": { + "name": "live_training_attendance_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_attendance_session_user_idx": { + "name": "live_training_attendance_session_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_attendance_training_user_idx": { + "name": "live_training_attendance_training_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_attendance_joined_at_idx": { + "name": "live_training_attendance_joined_at_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_attendance_live_training_session_participant_id_live_training_session_participants_id_fk": { + "name": "live_training_attendance_live_training_session_participant_id_live_training_session_participants_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "live_training_session_participant_id" + ], + "tableTo": "live_training_session_participants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_attendance_live_training_session_id_live_training_sessions_id_fk": { + "name": "live_training_attendance_live_training_session_id_live_training_sessions_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "live_training_session_id" + ], + "tableTo": "live_training_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_attendance_live_training_id_live_trainings_id_fk": { + "name": "live_training_attendance_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_attendance_user_id_users_id_fk": { + "name": "live_training_attendance_user_id_users_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_attendance_tenant_id_tenants_id_fk": { + "name": "live_training_attendance_tenant_id_tenants_id_fk", + "tableFrom": "live_training_attendance", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_links": { + "name": "live_training_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'course'" + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_links_tenant_id_idx": { + "name": "live_training_links_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_links_training_entity_unique_idx": { + "name": "live_training_links_training_entity_unique_idx", + "columns": [ + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_links_training_idx": { + "name": "live_training_links_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_links_entity_idx": { + "name": "live_training_links_entity_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_links_live_training_id_live_trainings_id_fk": { + "name": "live_training_links_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_links", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_links_tenant_id_tenants_id_fk": { + "name": "live_training_links_tenant_id_tenants_id_fk", + "tableFrom": "live_training_links", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_members": { + "name": "live_training_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'host'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_members_tenant_id_idx": { + "name": "live_training_members_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_training_user_unique_idx": { + "name": "live_training_members_training_user_unique_idx", + "columns": [ + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_training_idx": { + "name": "live_training_members_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_user_idx": { + "name": "live_training_members_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_members_role_idx": { + "name": "live_training_members_role_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_members_live_training_id_live_trainings_id_fk": { + "name": "live_training_members_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_members", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_members_user_id_users_id_fk": { + "name": "live_training_members_user_id_users_id_fk", + "tableFrom": "live_training_members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_members_tenant_id_tenants_id_fk": { + "name": "live_training_members_tenant_id_tenants_id_fk", + "tableFrom": "live_training_members", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_session_participants": { + "name": "live_training_session_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_session_id": { + "name": "live_training_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_joined_at": { + "name": "first_joined_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_left_at": { + "name": "last_left_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "livekit_identity": { + "name": "livekit_identity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_session_participants_tenant_id_idx": { + "name": "live_training_session_participants_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_session_user_unique_idx": { + "name": "live_training_session_participants_session_user_unique_idx", + "columns": [ + { + "expression": "live_training_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_session_idx": { + "name": "live_training_session_participants_session_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_training_user_idx": { + "name": "live_training_session_participants_training_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_session_participants_user_idx": { + "name": "live_training_session_participants_user_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_session_participants_live_training_session_id_live_training_sessions_id_fk": { + "name": "live_training_session_participants_live_training_session_id_live_training_sessions_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "live_training_session_id" + ], + "tableTo": "live_training_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_session_participants_live_training_id_live_trainings_id_fk": { + "name": "live_training_session_participants_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_session_participants_user_id_users_id_fk": { + "name": "live_training_session_participants_user_id_users_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_session_participants_tenant_id_tenants_id_fk": { + "name": "live_training_session_participants_tenant_id_tenants_id_fk", + "tableFrom": "live_training_session_participants", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_training_sessions": { + "name": "live_training_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "live_training_id": { + "name": "live_training_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ended_by_user_id": { + "name": "ended_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "livekit_room_name": { + "name": "livekit_room_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "livekit_room_sid": { + "name": "livekit_room_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "peak_participant_count": { + "name": "peak_participant_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unique_participant_count": { + "name": "unique_participant_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_training_sessions_tenant_id_idx": { + "name": "live_training_sessions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_sessions_training_idx": { + "name": "live_training_sessions_training_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "live_training_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_sessions_status_idx": { + "name": "live_training_sessions_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_training_sessions_livekit_room_name_idx": { + "name": "live_training_sessions_livekit_room_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livekit_room_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_training_sessions_live_training_id_live_trainings_id_fk": { + "name": "live_training_sessions_live_training_id_live_trainings_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "live_training_id" + ], + "tableTo": "live_trainings", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_training_sessions_started_by_user_id_users_id_fk": { + "name": "live_training_sessions_started_by_user_id_users_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "started_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_sessions_ended_by_user_id_users_id_fk": { + "name": "live_training_sessions_ended_by_user_id_users_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "ended_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_training_sessions_tenant_id_tenants_id_fk": { + "name": "live_training_sessions_tenant_id_tenants_id_fk", + "tableFrom": "live_training_sessions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.live_trainings": { + "name": "live_trainings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "delivery_type": { + "name": "delivery_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'online'" + }, + "visibility_scope": { + "name": "visibility_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'linked_courses'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "max_participants": { + "name": "max_participants", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"viewerPermissions\":{\"microphoneEnabled\":false,\"cameraEnabled\":false}}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "live_trainings_tenant_id_idx": { + "name": "live_trainings_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_trainings_tenant_status_idx": { + "name": "live_trainings_tenant_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "live_trainings_author_idx": { + "name": "live_trainings_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "live_trainings_calendar_event_id_calendar_events_id_fk": { + "name": "live_trainings_calendar_event_id_calendar_events_id_fk", + "tableFrom": "live_trainings", + "columnsFrom": [ + "calendar_event_id" + ], + "tableTo": "calendar_events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "live_trainings_author_id_users_id_fk": { + "name": "live_trainings_author_id_users_id_fk", + "tableFrom": "live_trainings", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "live_trainings_tenant_id_tenants_id_fk": { + "name": "live_trainings_tenant_id_tenants_id_fk", + "tableFrom": "live_trainings", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "live_trainings_calendar_event_id_unique": { + "name": "live_trainings_calendar_event_id_unique", + "columns": [ + "calendar_event_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.luma_course_generation_syncs": { + "name": "luma_course_generation_syncs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "draft_id": { + "name": "draft_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "luma_course_generation_syncs_tenant_id_idx": { + "name": "luma_course_generation_syncs_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "luma_course_generation_syncs_course_id_idx": { + "name": "luma_course_generation_syncs_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "luma_course_generation_syncs_status_idx": { + "name": "luma_course_generation_syncs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "luma_course_generation_syncs_course_id_courses_id_fk": { + "name": "luma_course_generation_syncs_course_id_courses_id_fk", + "tableFrom": "luma_course_generation_syncs", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "luma_course_generation_syncs_tenant_id_tenants_id_fk": { + "name": "luma_course_generation_syncs_tenant_id_tenants_id_fk", + "tableFrom": "luma_course_generation_syncs", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "luma_course_generation_syncs_course_id_unique": { + "name": "luma_course_generation_syncs_course_id_unique", + "columns": [ + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.magic_link_tokens": { + "name": "magic_link_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "magic_link_tokens_tenant_id_idx": { + "name": "magic_link_tokens_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "magic_link_tokens_token_hash_idx": { + "name": "magic_link_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "magic_link_tokens_user_id_users_id_fk": { + "name": "magic_link_tokens_user_id_users_id_fk", + "tableFrom": "magic_link_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "magic_link_tokens_tenant_id_tenants_id_fk": { + "name": "magic_link_tokens_tenant_id_tenants_id_fk", + "tableFrom": "magic_link_tokens", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.master_course_entity_map": { + "name": "master_course_entity_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_entity_id": { + "name": "source_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_entity_id": { + "name": "target_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "master_course_entity_map_export_idx": { + "name": "master_course_entity_map_export_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_entity_map_source_entity_idx": { + "name": "master_course_entity_map_source_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_entity_map_source_unique_idx": { + "name": "master_course_entity_map_source_unique_idx", + "columns": [ + { + "expression": "export_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "master_course_entity_map_export_id_master_course_exports_id_fk": { + "name": "master_course_entity_map_export_id_master_course_exports_id_fk", + "tableFrom": "master_course_entity_map", + "columnsFrom": [ + "export_id" + ], + "tableTo": "master_course_exports", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.master_course_exports": { + "name": "master_course_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "source_tenant_id": { + "name": "source_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_course_id": { + "name": "source_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_course_id": { + "name": "target_course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "master_course_exports_source_course_idx": { + "name": "master_course_exports_source_course_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_exports_target_course_idx": { + "name": "master_course_exports_target_course_idx", + "columns": [ + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "master_course_exports_source_target_unique_idx": { + "name": "master_course_exports_source_target_unique_idx", + "columns": [ + { + "expression": "source_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "master_course_exports_source_tenant_id_tenants_id_fk": { + "name": "master_course_exports_source_tenant_id_tenants_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "source_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "master_course_exports_source_course_id_courses_id_fk": { + "name": "master_course_exports_source_course_id_courses_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "source_course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "master_course_exports_target_tenant_id_tenants_id_fk": { + "name": "master_course_exports_target_tenant_id_tenants_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "target_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "master_course_exports_target_course_id_courses_id_fk": { + "name": "master_course_exports_target_course_id_courses_id_fk", + "tableFrom": "master_course_exports", + "columnsFrom": [ + "target_course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.news": { + "name": "news", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "news_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "news_tenant_id_idx": { + "name": "news_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "news_author_id_users_id_fk": { + "name": "news_author_id_users_id_fk", + "tableFrom": "news", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "news_tenant_id_tenants_id_fk": { + "name": "news_tenant_id_tenants_id_fk", + "tableFrom": "news", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.outbox_events": { + "name": "outbox_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "outbox_events_tenant_id_idx": { + "name": "outbox_events_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "outbox_events_poll_idx": { + "name": "outbox_events_poll_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "outbox_events_tenant_id_tenants_id_fk": { + "name": "outbox_events_tenant_id_tenants_id_fk", + "tableFrom": "outbox_events", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_role_rule_sets": { + "name": "permission_role_rule_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_set_id": { + "name": "rule_set_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_role_rule_sets_tenant_id_idx": { + "name": "permission_role_rule_sets_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_role_rule_sets_role_id_rule_set_id_unique": { + "name": "permission_role_rule_sets_role_id_rule_set_id_unique", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_role_rule_sets_role_id_permission_roles_id_fk": { + "name": "permission_role_rule_sets_role_id_permission_roles_id_fk", + "tableFrom": "permission_role_rule_sets", + "columnsFrom": [ + "role_id" + ], + "tableTo": "permission_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_role_rule_sets_rule_set_id_permission_rule_sets_id_fk": { + "name": "permission_role_rule_sets_rule_set_id_permission_rule_sets_id_fk", + "tableFrom": "permission_role_rule_sets", + "columnsFrom": [ + "rule_set_id" + ], + "tableTo": "permission_rule_sets", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_role_rule_sets_tenant_id_tenants_id_fk": { + "name": "permission_role_rule_sets_tenant_id_tenants_id_fk", + "tableFrom": "permission_role_rule_sets", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_roles": { + "name": "permission_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_roles_tenant_id_idx": { + "name": "permission_roles_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_roles_tenant_id_slug_unique": { + "name": "permission_roles_tenant_id_slug_unique", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_roles_tenant_id_tenants_id_fk": { + "name": "permission_roles_tenant_id_tenants_id_fk", + "tableFrom": "permission_roles", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_rule_set_permissions": { + "name": "permission_rule_set_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "rule_set_id": { + "name": "rule_set_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_rule_set_permissions_tenant_id_idx": { + "name": "permission_rule_set_permissions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_rule_set_permissions_rule_set_id_permission_unique": { + "name": "permission_rule_set_permissions_rule_set_id_permission_unique", + "columns": [ + { + "expression": "rule_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_rule_set_permissions_rule_set_id_permission_rule_sets_id_fk": { + "name": "permission_rule_set_permissions_rule_set_id_permission_rule_sets_id_fk", + "tableFrom": "permission_rule_set_permissions", + "columnsFrom": [ + "rule_set_id" + ], + "tableTo": "permission_rule_sets", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_rule_set_permissions_tenant_id_tenants_id_fk": { + "name": "permission_rule_set_permissions_tenant_id_tenants_id_fk", + "tableFrom": "permission_rule_set_permissions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_rule_sets": { + "name": "permission_rule_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_rule_sets_tenant_id_idx": { + "name": "permission_rule_sets_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_rule_sets_tenant_id_slug_unique": { + "name": "permission_rule_sets_tenant_id_slug_unique", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_rule_sets_tenant_id_tenants_id_fk": { + "name": "permission_rule_sets_tenant_id_tenants_id_fk", + "tableFrom": "permission_rule_sets", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.permission_user_roles": { + "name": "permission_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "permission_user_roles_tenant_id_idx": { + "name": "permission_user_roles_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_user_roles_user_id_role_id_unique": { + "name": "permission_user_roles_user_id_role_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_user_roles_user_id_users_id_fk": { + "name": "permission_user_roles_user_id_users_id_fk", + "tableFrom": "permission_user_roles", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_user_roles_role_id_permission_roles_id_fk": { + "name": "permission_user_roles_role_id_permission_roles_id_fk", + "tableFrom": "permission_user_roles", + "columnsFrom": [ + "role_id" + ], + "tableTo": "permission_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_user_roles_tenant_id_tenants_id_fk": { + "name": "permission_user_roles_tenant_id_tenants_id_fk", + "tableFrom": "permission_user_roles", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.question_answer_options": { + "name": "question_answer_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_text": { + "name": "option_text", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "matched_word": { + "name": "matched_word", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scale_answer": { + "name": "scale_answer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "question_answer_options_tenant_id_idx": { + "name": "question_answer_options_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "question_answer_options_question_id_questions_id_fk": { + "name": "question_answer_options_question_id_questions_id_fk", + "tableFrom": "question_answer_options", + "columnsFrom": [ + "question_id" + ], + "tableTo": "questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "question_answer_options_tenant_id_tenants_id_fk": { + "name": "question_answer_options_tenant_id_tenants_id_fk", + "tableFrom": "question_answer_options", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_s3_key": { + "name": "photo_s3_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "solution_explanation": { + "name": "solution_explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "questions_tenant_id_idx": { + "name": "questions_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "questions_lesson_id_lessons_id_fk": { + "name": "questions_lesson_id_lessons_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "questions_tenant_id_tenants_id_fk": { + "name": "questions_tenant_id_tenants_id_fk", + "tableFrom": "questions", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions_and_answers": { + "name": "questions_and_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "base_language": { + "name": "base_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "available_locales": { + "name": "available_locales", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['en']::text[]" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "questions_and_answers_tenant_id_idx": { + "name": "questions_and_answers_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "questions_and_answers_tenant_id_tenants_id_fk": { + "name": "questions_and_answers_tenant_id_tenants_id_fk", + "tableFrom": "questions_and_answers", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "correct_answers": { + "name": "correct_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wrong_answers": { + "name": "wrong_answers", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "quiz_attempts_tenant_id_idx": { + "name": "quiz_attempts_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quiz_attempts_course_id_courses_id_fk": { + "name": "quiz_attempts_course_id_courses_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "quiz_attempts_lesson_id_lessons_id_fk": { + "name": "quiz_attempts_lesson_id_lessons_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "quiz_attempts_tenant_id_tenants_id_fk": { + "name": "quiz_attempts_tenant_id_tenants_id_fk", + "tableFrom": "quiz_attempts", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.reset_tokens": { + "name": "reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "reset_tokens_tenant_id_idx": { + "name": "reset_tokens_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "reset_tokens_token_hash_idx": { + "name": "reset_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "reset_tokens_user_id_users_id_fk": { + "name": "reset_tokens_user_id_users_id_fk", + "tableFrom": "reset_tokens", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "reset_tokens_tenant_id_tenants_id_fk": { + "name": "reset_tokens_tenant_id_tenants_id_fk", + "tableFrom": "reset_tokens", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.resource_entity": { + "name": "resource_entity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "relationship_type": { + "name": "relationship_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'attachment'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "resource_entity_tenant_id_idx": { + "name": "resource_entity_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resource_entity_resource_idx": { + "name": "resource_entity_resource_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resource_entity_entity_idx": { + "name": "resource_entity_entity_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resource_entity_relationship_idx": { + "name": "resource_entity_relationship_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relationship_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "resource_entity_resource_id_resources_id_fk": { + "name": "resource_entity_resource_id_resources_id_fk", + "tableFrom": "resource_entity", + "columnsFrom": [ + "resource_id" + ], + "tableTo": "resources", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "resource_entity_tenant_id_tenants_id_fk": { + "name": "resource_entity_tenant_id_tenants_id_fk", + "tableFrom": "resource_entity", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resource_entity_resource_id_entity_id_entity_type_relationship_type_unique": { + "name": "resource_entity_resource_id_entity_id_entity_type_relationship_type_unique", + "columns": [ + "resource_id", + "entity_id", + "entity_type", + "relationship_type" + ], + "nullsNotDistinct": false + } + } + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reference": { + "name": "reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "resources_tenant_id_idx": { + "name": "resources_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "resources_uploaded_by_id_users_id_fk": { + "name": "resources_uploaded_by_id_users_id_fk", + "tableFrom": "resources", + "columnsFrom": [ + "uploaded_by_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "resources_tenant_id_tenants_id_fk": { + "name": "resources_tenant_id_tenants_id_fk", + "tableFrom": "resources", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_attempts": { + "name": "scorm_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sco_id": { + "name": "sco_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_attempts_tenant_id_idx": { + "name": "scorm_attempts_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_student_lesson_idx": { + "name": "scorm_attempts_student_lesson_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_student_package_idx": { + "name": "scorm_attempts_student_package_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_sco_id_idx": { + "name": "scorm_attempts_sco_id_idx", + "columns": [ + { + "expression": "sco_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_attempts_student_package_sco_attempt_unique_idx": { + "name": "scorm_attempts_student_package_sco_attempt_unique_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sco_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_attempts_student_id_users_id_fk": { + "name": "scorm_attempts_student_id_users_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_course_id_courses_id_fk": { + "name": "scorm_attempts_course_id_courses_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_lesson_id_lessons_id_fk": { + "name": "scorm_attempts_lesson_id_lessons_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_package_id_scorm_packages_id_fk": { + "name": "scorm_attempts_package_id_scorm_packages_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "package_id" + ], + "tableTo": "scorm_packages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_sco_id_scorm_scos_id_fk": { + "name": "scorm_attempts_sco_id_scorm_scos_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "sco_id" + ], + "tableTo": "scorm_scos", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_attempts_tenant_id_tenants_id_fk": { + "name": "scorm_attempts_tenant_id_tenants_id_fk", + "tableFrom": "scorm_attempts", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_packages": { + "name": "scorm_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "entity_type": { + "name": "entity_type", + "type": "scorm_package_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "standard": { + "name": "standard", + "type": "scorm_standard", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "original_file_reference": { + "name": "original_file_reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extracted_files_reference": { + "name": "extracted_files_reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_entry_point": { + "name": "manifest_entry_point", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "scorm_package_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_packages_tenant_id_idx": { + "name": "scorm_packages_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_packages_entity_idx": { + "name": "scorm_packages_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_packages_entity_unique_idx": { + "name": "scorm_packages_entity_unique_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_packages_tenant_id_tenants_id_fk": { + "name": "scorm_packages_tenant_id_tenants_id_fk", + "tableFrom": "scorm_packages", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_runtime_state": { + "name": "scorm_runtime_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completion_status": { + "name": "completion_status", + "type": "scorm_completion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "success_status": { + "name": "success_status", + "type": "scorm_success_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "score_raw": { + "name": "score_raw", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "score_min": { + "name": "score_min", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "score_max": { + "name": "score_max", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "score_scaled": { + "name": "score_scaled", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "lesson_location": { + "name": "lesson_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspend_data": { + "name": "suspend_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_time": { + "name": "session_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_time": { + "name": "total_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "progress_measure": { + "name": "progress_measure", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit": { + "name": "exit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_cmi_json": { + "name": "raw_cmi_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_runtime_state_tenant_id_idx": { + "name": "scorm_runtime_state_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_runtime_state_attempt_id_unique_idx": { + "name": "scorm_runtime_state_attempt_id_unique_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_runtime_state_attempt_id_scorm_attempts_id_fk": { + "name": "scorm_runtime_state_attempt_id_scorm_attempts_id_fk", + "tableFrom": "scorm_runtime_state", + "columnsFrom": [ + "attempt_id" + ], + "tableTo": "scorm_attempts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_runtime_state_tenant_id_tenants_id_fk": { + "name": "scorm_runtime_state_tenant_id_tenants_id_fk", + "tableFrom": "scorm_runtime_state", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.scorm_scos": { + "name": "scorm_scos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_identifier": { + "name": "organization_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier_ref": { + "name": "identifier_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_identifier": { + "name": "resource_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scorm_type": { + "name": "scorm_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_path": { + "name": "launch_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_identifier": { + "name": "parent_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "item_metadata_json": { + "name": "item_metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resource_metadata_json": { + "name": "resource_metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "scorm_scos_tenant_id_idx": { + "name": "scorm_scos_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_scos_package_id_idx": { + "name": "scorm_scos_package_id_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_scos_lesson_id_idx": { + "name": "scorm_scos_lesson_id_idx", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scorm_scos_package_identifier_unique_idx": { + "name": "scorm_scos_package_identifier_unique_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "scorm_scos_package_id_scorm_packages_id_fk": { + "name": "scorm_scos_package_id_scorm_packages_id_fk", + "tableFrom": "scorm_scos", + "columnsFrom": [ + "package_id" + ], + "tableTo": "scorm_packages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_scos_lesson_id_lessons_id_fk": { + "name": "scorm_scos_lesson_id_lessons_id_fk", + "tableFrom": "scorm_scos", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "scorm_scos_tenant_id_tenants_id_fk": { + "name": "scorm_scos_tenant_id_tenants_id_fk", + "tableFrom": "scorm_scos", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.search_documents": { + "name": "search_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "search_documents_tenant_id_idx": { + "name": "search_documents_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "search_documents_vector_idx": { + "name": "search_documents_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "search_documents_language_entity_type_idx": { + "name": "search_documents_language_entity_type_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "search_documents_entity_idx": { + "name": "search_documents_entity_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "search_documents_document_unique_idx": { + "name": "search_documents_document_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "search_documents_tenant_id_tenants_id_fk": { + "name": "search_documents_tenant_id_tenants_id_fk", + "tableFrom": "search_documents", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_dek": { + "name": "encrypted_dek", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_dek_iv": { + "name": "encrypted_dek_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_dek_tag": { + "name": "encrypted_dek_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'AES-256-GCM'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "secrets_tenant_id_idx": { + "name": "secrets_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "secrets_tenant_secret_name_uq": { + "name": "secrets_tenant_secret_name_uq", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "secrets_name_idx": { + "name": "secrets_name_idx", + "columns": [ + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "secrets_tenant_id_tenants_id_fk": { + "name": "secrets_tenant_id_tenants_id_fk", + "tableFrom": "secrets", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "settings_tenant_id_idx": { + "name": "settings_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "settings_tenant_id_tenants_id_fk": { + "name": "settings_tenant_id_tenants_id_fk", + "tableFrom": "settings", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.student_chapter_progress": { + "name": "student_chapter_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_lesson_count": { + "name": "completed_lesson_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_as_freemium": { + "name": "completed_as_freemium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_chapter_progress_tenant_id_idx": { + "name": "student_chapter_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_chapter_progress_student_id_users_id_fk": { + "name": "student_chapter_progress_student_id_users_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_chapter_progress_course_id_courses_id_fk": { + "name": "student_chapter_progress_course_id_courses_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_chapter_progress_chapter_id_chapters_id_fk": { + "name": "student_chapter_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "chapter_id" + ], + "tableTo": "chapters", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_chapter_progress_tenant_id_tenants_id_fk": { + "name": "student_chapter_progress_tenant_id_tenants_id_fk", + "tableFrom": "student_chapter_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_chapter_progress_student_id_course_id_chapter_id_unique": { + "name": "student_chapter_progress_student_id_course_id_chapter_id_unique", + "columns": [ + "student_id", + "course_id", + "chapter_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_courses": { + "name": "student_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "finished_chapter_count": { + "name": "finished_chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "course_completion_metadata": { + "name": "course_completion_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'enrolled'" + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "enrolled_by_group_id": { + "name": "enrolled_by_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_courses_tenant_id_idx": { + "name": "student_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_courses_tenant_id_course_status_student_idx": { + "name": "student_courses_tenant_id_course_status_student_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_courses_student_id_users_id_fk": { + "name": "student_courses_student_id_users_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_courses_course_id_courses_id_fk": { + "name": "student_courses_course_id_courses_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_courses_enrolled_by_group_id_groups_id_fk": { + "name": "student_courses_enrolled_by_group_id_groups_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "enrolled_by_group_id" + ], + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "student_courses_tenant_id_tenants_id_fk": { + "name": "student_courses_tenant_id_tenants_id_fk", + "tableFrom": "student_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_courses_student_id_course_id_unique": { + "name": "student_courses_student_id_course_id_unique", + "columns": [ + "student_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_learning_path_courses": { + "name": "student_learning_path_courses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_learning_path_courses_tenant_id_idx": { + "name": "student_learning_path_courses_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_learning_path_courses_student_path_idx": { + "name": "student_learning_path_courses_student_path_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "learning_path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_learning_path_courses_course_idx": { + "name": "student_learning_path_courses_course_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_learning_path_courses_student_id_users_id_fk": { + "name": "student_learning_path_courses_student_id_users_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_path_courses_learning_path_id_learning_paths_id_fk": { + "name": "student_learning_path_courses_learning_path_id_learning_paths_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_path_courses_course_id_courses_id_fk": { + "name": "student_learning_path_courses_course_id_courses_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "course_id" + ], + "tableTo": "courses", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_path_courses_tenant_id_tenants_id_fk": { + "name": "student_learning_path_courses_tenant_id_tenants_id_fk", + "tableFrom": "student_learning_path_courses", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_learning_path_courses_student_id_learning_path_id_course_id_unique": { + "name": "student_learning_path_courses_student_id_learning_path_id_course_id_unique", + "columns": [ + "student_id", + "learning_path_id", + "course_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_learning_paths": { + "name": "student_learning_paths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "learning_path_id": { + "name": "learning_path_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_started'" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "enrollment_type": { + "name": "enrollment_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_learning_paths_tenant_id_idx": { + "name": "student_learning_paths_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_learning_paths_student_id_users_id_fk": { + "name": "student_learning_paths_student_id_users_id_fk", + "tableFrom": "student_learning_paths", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_paths_learning_path_id_learning_paths_id_fk": { + "name": "student_learning_paths_learning_path_id_learning_paths_id_fk", + "tableFrom": "student_learning_paths", + "columnsFrom": [ + "learning_path_id" + ], + "tableTo": "learning_paths", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_learning_paths_tenant_id_tenants_id_fk": { + "name": "student_learning_paths_tenant_id_tenants_id_fk", + "tableFrom": "student_learning_paths", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_learning_paths_student_id_learning_path_id_unique": { + "name": "student_learning_paths_student_id_learning_path_id_unique", + "columns": [ + "student_id", + "learning_path_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_lesson_progress": { + "name": "student_lesson_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_question_count": { + "name": "completed_question_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quiz_score": { + "name": "quiz_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_quiz_passed": { + "name": "is_quiz_passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_started": { + "name": "is_started", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "language_answered": { + "name": "language_answered", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_lesson_progress_tenant_id_idx": { + "name": "student_lesson_progress_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "student_lesson_progress_completed_quiz_score_idx": { + "name": "student_lesson_progress_completed_quiz_score_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"student_lesson_progress\".\"completed_at\" IS NOT NULL AND \"student_lesson_progress\".\"quiz_score\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "student_lesson_progress_student_id_users_id_fk": { + "name": "student_lesson_progress_student_id_users_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "student_lesson_progress_chapter_id_chapters_id_fk": { + "name": "student_lesson_progress_chapter_id_chapters_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "chapter_id" + ], + "tableTo": "chapters", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_lesson_progress_lesson_id_lessons_id_fk": { + "name": "student_lesson_progress_lesson_id_lessons_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "lesson_id" + ], + "tableTo": "lessons", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_lesson_progress_tenant_id_tenants_id_fk": { + "name": "student_lesson_progress_tenant_id_tenants_id_fk", + "tableFrom": "student_lesson_progress", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_lesson_progress_student_id_lesson_id_chapter_id_unique": { + "name": "student_lesson_progress_student_id_lesson_id_chapter_id_unique", + "columns": [ + "student_id", + "lesson_id", + "chapter_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.student_question_answers": { + "name": "student_question_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "student_id": { + "name": "student_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "student_question_answers_tenant_id_idx": { + "name": "student_question_answers_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "student_question_answers_question_id_questions_id_fk": { + "name": "student_question_answers_question_id_questions_id_fk", + "tableFrom": "student_question_answers", + "columnsFrom": [ + "question_id" + ], + "tableTo": "questions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_question_answers_student_id_users_id_fk": { + "name": "student_question_answers_student_id_users_id_fk", + "tableFrom": "student_question_answers", + "columnsFrom": [ + "student_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "student_question_answers_tenant_id_tenants_id_fk": { + "name": "student_question_answers_tenant_id_tenants_id_fk", + "tableFrom": "student_question_answers", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "student_question_answers_question_id_student_id_unique": { + "name": "student_question_answers_question_id_student_id_unique", + "columns": [ + "question_id", + "student_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.support_sessions": { + "name": "support_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "original_user_id": { + "name": "original_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_tenant_id": { + "name": "original_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hashed_grant_token": { + "name": "hashed_grant_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_expires_at": { + "name": "grant_expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": { + "support_sessions_hashed_grant_token_unique": { + "name": "support_sessions_hashed_grant_token_unique", + "columns": [ + { + "expression": "hashed_grant_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_status_idx": { + "name": "support_sessions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_original_user_idx": { + "name": "support_sessions_original_user_idx", + "columns": [ + { + "expression": "original_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_target_tenant_idx": { + "name": "support_sessions_target_tenant_idx", + "columns": [ + { + "expression": "target_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "support_sessions_target_user_idx": { + "name": "support_sessions_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "support_sessions_original_user_id_users_id_fk": { + "name": "support_sessions_original_user_id_users_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "original_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "support_sessions_original_tenant_id_tenants_id_fk": { + "name": "support_sessions_original_tenant_id_tenants_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "original_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "support_sessions_target_tenant_id_tenants_id_fk": { + "name": "support_sessions_target_tenant_id_tenants_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "target_tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "support_sessions_target_user_id_users_id_fk": { + "name": "support_sessions_target_user_id_users_id_fk", + "tableFrom": "support_sessions", + "columnsFrom": [ + "target_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "is_managing": { + "name": "is_managing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "unique_host_idx": { + "name": "unique_host_idx", + "columns": [ + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_announcements": { + "name": "user_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_announcements_tenant_id_idx": { + "name": "user_announcements_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_announcements_user_id_users_id_fk": { + "name": "user_announcements_user_id_users_id_fk", + "tableFrom": "user_announcements", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_announcements_announcement_id_announcements_id_fk": { + "name": "user_announcements_announcement_id_announcements_id_fk", + "tableFrom": "user_announcements", + "columnsFrom": [ + "announcement_id" + ], + "tableTo": "announcements", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_announcements_tenant_id_tenants_id_fk": { + "name": "user_announcements_tenant_id_tenants_id_fk", + "tableFrom": "user_announcements", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_announcements_user_id_announcement_id_unique": { + "name": "user_announcements_user_id_announcement_id_unique", + "columns": [ + "user_id", + "announcement_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.user_details": { + "name": "user_details", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_phone_number": { + "name": "contact_phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_details_tenant_id_idx": { + "name": "user_details_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_details_user_id_users_id_fk": { + "name": "user_details_user_id_users_id_fk", + "tableFrom": "user_details", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_details_tenant_id_tenants_id_fk": { + "name": "user_details_tenant_id_tenants_id_fk", + "tableFrom": "user_details", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_details_user_id_unique": { + "name": "user_details_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dashboard": { + "name": "dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "courses": { + "name": "courses", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "announcements": { + "name": "announcements", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "profile": { + "name": "profile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_information": { + "name": "provider_information", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_onboarding_tenant_id_idx": { + "name": "user_onboarding_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_onboarding_user_id_users_id_fk": { + "name": "user_onboarding_user_id_users_id_fk", + "tableFrom": "user_onboarding", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_onboarding_tenant_id_tenants_id_fk": { + "name": "user_onboarding_tenant_id_tenants_id_fk", + "tableFrom": "user_onboarding", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_onboarding_user_id_unique": { + "name": "user_onboarding_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.user_statistics": { + "name": "user_statistics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_date": { + "name": "last_activity_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activity_history": { + "name": "activity_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "user_statistics_tenant_id_idx": { + "name": "user_statistics_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_statistics_user_id_users_id_fk": { + "name": "user_statistics_user_id_users_id_fk", + "tableFrom": "user_statistics", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_statistics_tenant_id_tenants_id_fk": { + "name": "user_statistics_tenant_id_tenants_id_fk", + "tableFrom": "user_statistics", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_statistics_user_id_unique": { + "name": "user_statistics_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_reference": { + "name": "avatar_reference", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + } + }, + "indexes": { + "users_tenant_id_idx": { + "name": "users_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "users_tenant_id_email_unique_idx": { + "name": "users_tenant_id_email_unique_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.article_status": { + "name": "article_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.course_type": { + "name": "course_type", + "schema": "public", + "values": [ + "default", + "scorm" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "draft", + "published", + "private" + ] + }, + "public.news_status": { + "name": "news_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.scorm_completion_status": { + "name": "scorm_completion_status", + "schema": "public", + "values": [ + "completed", + "incomplete", + "not_attempted", + "unknown" + ] + }, + "public.scorm_package_entity_type": { + "name": "scorm_package_entity_type", + "schema": "public", + "values": [ + "course", + "lesson" + ] + }, + "public.scorm_package_status": { + "name": "scorm_package_status", + "schema": "public", + "values": [ + "processing", + "ready", + "failed" + ] + }, + "public.scorm_standard": { + "name": "scorm_standard", + "schema": "public", + "values": [ + "scorm_1_2", + "scorm_2004" + ] + }, + "public.scorm_success_status": { + "name": "scorm_success_status", + "schema": "public", + "values": [ + "passed", + "failed", + "unknown" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/src/storage/migrations/meta/_journal.json b/apps/api/src/storage/migrations/meta/_journal.json index 530ad6f7e1..3e635cd160 100644 --- a/apps/api/src/storage/migrations/meta/_journal.json +++ b/apps/api/src/storage/migrations/meta/_journal.json @@ -1268,6 +1268,20 @@ "when": 1785344688595, "tag": "0180_backfill_course_duration_estimates", "breakpoints": true + }, + { + "idx": 181, + "version": "7", + "when": 1785400000000, + "tag": "0181_add_email_notification_templates", + "breakpoints": true + }, + { + "idx": 182, + "version": "7", + "when": 1785400001000, + "tag": "0182_enable_email_notification_templates_rls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/storage/schema/index.ts b/apps/api/src/storage/schema/index.ts index 9f4110b3d3..1bdac36921 100644 --- a/apps/api/src/storage/schema/index.ts +++ b/apps/api/src/storage/schema/index.ts @@ -26,6 +26,7 @@ import { ANNOUNCEMENT_SOURCE_TYPES, ANNOUNCEMENT_STATUSES, COURSE_GENERATION_SYNC_STATUS, + EMAIL_TEMPLATE_STATUSES, MICROSOFT_CALENDAR_CONNECTION_STATUSES, MICROSOFT_CALENDAR_OUTBOUND_STATUSES, ANNOUNCEMENT_AUDIENCES, @@ -100,6 +101,9 @@ import type { AnnouncementSourceType, AnnouncementStatus, CourseGenerationSyncStatus, + EmailTemplateBlocks, + EmailTemplateStatus, + EmailTemplateStrings, LiveTrainingDeliveryType, LiveTrainingLinkEntityType, LiveTrainingMemberRole, @@ -1891,6 +1895,35 @@ export const groupAnnouncements = pgTable( })), ); +export const emailNotificationTemplates = pgTable( + "email_notification_templates", + { + ...id, + ...timestamps, + name: text("name").notNull(), + subject: jsonb("subject").$type().default({}).notNull(), + status: text("status") + .$type() + .notNull() + .default(EMAIL_TEMPLATE_STATUSES.DRAFT), + blocks: jsonb("blocks") + .$type() + .default({ type: "doc", content: [] }) + .notNull(), + strings: jsonb("strings").$type().default({}).notNull(), + baseLanguage, + availableLocales, + archivedAt: timestampWithTimezone({ name: "archived_at" }), + tenantId, + }, + withTenantIdIndex("email_notification_templates", (table) => ({ + tenantNameUniqueIdx: uniqueIndex("email_notification_templates_tenant_id_name_unique_idx").on( + table.tenantId, + table.name, + ), + })), +); + export const documents = pgTable( "documents", { diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 6f8fe18e3d..ef4e30d792 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -12815,6 +12815,550 @@ } } }, + "/api/email-notification-templates": { + "get": { + "operationId": "EmailNotificationTemplatesController_listTemplates", + "parameters": [ + { + "name": "status", + "required": false, + "in": "query", + "schema": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "type": "number" + } + }, + { + "name": "perPage", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListTemplatesResponse" + } + } + } + } + } + }, + "post": { + "operationId": "EmailNotificationTemplatesController_createTemplate", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateBody" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/bulk": { + "delete": { + "operationId": "EmailNotificationTemplatesController_deleteManyTemplates", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteManyTemplatesBody" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteManyTemplatesResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}": { + "get": { + "operationId": "EmailNotificationTemplatesController_getTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTemplateResponse" + } + } + } + } + } + }, + "patch": { + "operationId": "EmailNotificationTemplatesController_updateTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplateBody" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplateResponse" + } + } + } + } + } + }, + "delete": { + "operationId": "EmailNotificationTemplatesController_deleteTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/publish": { + "post": { + "operationId": "EmailNotificationTemplatesController_publishTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/make-draft": { + "post": { + "operationId": "EmailNotificationTemplatesController_makeTemplateDraft", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MakeTemplateDraftResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/archive": { + "post": { + "operationId": "EmailNotificationTemplatesController_archiveTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/unarchive": { + "post": { + "operationId": "EmailNotificationTemplatesController_unarchiveTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnarchiveTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/preview": { + "post": { + "operationId": "EmailNotificationTemplatesController_previewTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "language", + "required": false, + "in": "query", + "schema": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/test-send": { + "post": { + "operationId": "EmailNotificationTemplatesController_sendTestEmail", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "language", + "required": false, + "in": "query", + "schema": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendTestEmailResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/{id}/duplicate": { + "post": { + "operationId": "EmailNotificationTemplatesController_duplicateTemplate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DuplicateTemplateResponse" + } + } + } + } + } + } + }, + "/api/email-notification-templates/images": { + "post": { + "operationId": "EmailTemplateImageController_upload", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadResponse" + } + } + } + } + } + } + }, + "/api/public/course-thumbnail/{courseId}": { + "get": { + "operationId": "PublicCourseThumbnailController_getThumbnail", + "parameters": [ + { + "name": "courseId", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/public/email-template-image/{reference}": { + "get": { + "operationId": "PublicEmailTemplateImageController_serve", + "parameters": [ + { + "name": "reference", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, "/api/integration/tenants": { "get": { "operationId": "IntegrationController_getTenants", @@ -17794,6 +18338,10 @@ "const": "announcement.delete", "type": "string" }, + { + "const": "email_template.manage", + "type": "string" + }, { "const": "news.read_public", "type": "string" @@ -54046,6 +54594,6519 @@ "data" ] }, + "ListTemplatesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "totalItems": { + "type": "number" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + } + }, + "required": [ + "totalItems", + "page", + "perPage" + ] + }, + "appliedFilters": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "data", + "pagination" + ] + }, + "DeleteManyTemplatesBody": { + "minItems": 1, + "type": "array", + "items": { + "format": "uuid", + "type": "string" + } + }, + "DeleteManyTemplatesResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "data" + ] + }, + "CreateTemplateBody": { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "maxLength": 200, + "type": "string" + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "required": [ + "baseLanguage", + "availableLocales" + ] + }, + "CreateTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "GetTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "UpdateTemplateBody": { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "maxLength": 200, + "type": "string" + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "UpdateTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "PublishTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "MakeTemplateDraftResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "ArchiveTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "DeleteTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "data" + ] + }, + "UnarchiveTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "PreviewTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "language": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "subject": { + "type": "string" + }, + "html": { + "type": "string" + } + }, + "required": [ + "language", + "subject", + "html" + ] + } + }, + "required": [ + "data" + ] + }, + "SendTestEmailResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "data" + ] + }, + "DuplicateTemplateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "type": "string" + }, + "fr": { + "type": "string" + } + } + }, + "blocks": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + }, + "strings": { + "type": "object", + "properties": { + "en": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "pl": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "de": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "lt": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "cs": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "es": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + }, + "fr": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "type": "array", + "items": { + "$id": "TiptapJsonNode", + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + }, + "content": { + "type": "array", + "items": { + "$ref": "TiptapJsonNode" + } + }, + "marks": { + "type": "array", + "items": { + "additionalProperties": true, + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "attrs": { + "type": "object", + "patternProperties": { + "^(.*)$": {} + } + } + }, + "required": [ + "type" + ] + } + }, + "text": { + "type": "string" + } + } + } + } + } + } + } + }, + "baseLanguage": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + }, + "availableLocales": { + "type": "array", + "items": { + "anyOf": [ + { + "const": "en", + "type": "string" + }, + { + "const": "pl", + "type": "string" + }, + { + "const": "de", + "type": "string" + }, + { + "const": "lt", + "type": "string" + }, + { + "const": "cs", + "type": "string" + }, + { + "const": "es", + "type": "string" + }, + { + "const": "fr", + "type": "string" + } + ] + } + }, + "status": { + "anyOf": [ + { + "const": "draft", + "type": "string" + }, + { + "const": "published", + "type": "string" + }, + { + "const": "archived", + "type": "string" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "subject", + "blocks", + "strings", + "baseLanguage", + "availableLocales", + "status", + "archivedAt" + ] + } + }, + "required": [ + "data" + ] + }, + "UploadResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + }, + "required": [ + "data" + ] + }, "GetTenantsResponse": { "type": "object", "properties": { diff --git a/apps/api/src/user/handlers/notify-admins.handler.ts b/apps/api/src/user/handlers/notify-admins.handler.ts index 7bf32bbdab..5a35818e54 100644 --- a/apps/api/src/user/handlers/notify-admins.handler.ts +++ b/apps/api/src/user/handlers/notify-admins.handler.ts @@ -57,11 +57,12 @@ export class NotifyAdminsHandler implements IEventHandler { adminId, ); - const { text, html } = new NewUserEmail({ + const emailTemplate = new NewUserEmail({ userName: `${firstName} ${lastName}`, profileLink: `${baseOrigin}/profile/${user.id}`, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); return this.emailService.sendEmailWithLogo( { @@ -94,12 +95,13 @@ export class NotifyAdminsHandler implements IEventHandler { adminId, ); - const { text, html } = new FinishedCourseEmail({ + const emailTemplate = new FinishedCourseEmail({ userName, courseName: courseTitle, progressLink: `${baseOrigin}/course/${courseId}`, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); return this.emailService.sendEmailWithLogo( { diff --git a/apps/api/src/user/handlers/notify-users.handler.ts b/apps/api/src/user/handlers/notify-users.handler.ts index 43a3fc4784..868dc918b2 100644 --- a/apps/api/src/user/handlers/notify-users.handler.ts +++ b/apps/api/src/user/handlers/notify-users.handler.ts @@ -225,11 +225,12 @@ export class NotifyUsersHandler implements IEventHandler { const invitingUsername = invitedByUserName || `${invitingUser?.firstName} ${invitingUser?.lastName}` || "Admin"; - const { text, html } = new UserInviteEmail({ + const emailTemplate = new UserInviteEmail({ invitedByUserName: invitingUsername, createPasswordLink: url, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { @@ -279,11 +280,12 @@ export class NotifyUsersHandler implements IEventHandler { user.id, ); - const { text, html } = new UserFirstLoginEmail({ + const emailTemplate = new UserFirstLoginEmail({ name: user.firstName, coursesUrl: `${baseOrigin}/courses`, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { @@ -313,10 +315,11 @@ export class NotifyUsersHandler implements IEventHandler { createToken: token, }); - const { text, html } = new CreatePasswordReminderEmail({ + const emailTemplate = new CreatePasswordReminderEmail({ createPasswordLink, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { @@ -361,10 +364,11 @@ export class NotifyUsersHandler implements IEventHandler { userId, ); - const { text, html } = new WelcomeEmail({ + const emailTemplate = new WelcomeEmail({ coursesLink: `${baseOrigin}/courses`, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { @@ -402,12 +406,13 @@ export class NotifyUsersHandler implements IEventHandler { studentId, ); - const { text, html } = new UserAssignedToCourseEmail({ + const emailTemplate = new UserAssignedToCourseEmail({ courseName, courseLink: `${baseOrigin}/course/${courseId}`, formatedCourseDueDate: dueDatesByStudent[studentId] ?? null, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); return await this.emailService.sendEmailWithLogo( { @@ -448,11 +453,12 @@ export class NotifyUsersHandler implements IEventHandler { user.userId, ); - const { text, html } = new UserShortInactivityEmail({ + const emailTemplate = new UserShortInactivityEmail({ courseName, courseLink, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); return this.emailService.sendEmailWithLogo( { @@ -493,11 +499,12 @@ export class NotifyUsersHandler implements IEventHandler { user.userId, ); - const { text, html } = new UserLongInactivityEmail({ + const emailTemplate = new UserLongInactivityEmail({ courseName: course?.courseName, courseLink, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); return this.emailService.sendEmailWithLogo( { @@ -529,12 +536,13 @@ export class NotifyUsersHandler implements IEventHandler { user.id, ); - const { text, html } = new UserFinishedChapterEmail({ + const emailTemplate = new UserFinishedChapterEmail({ courseName, courseLink, chapterName, ...defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); const subject = getEmailSubject("userChapterFinishedEmail", defaultEmailSettings.language, { chapterName, @@ -569,12 +577,13 @@ export class NotifyUsersHandler implements IEventHandler { user.id, ); - const { text, html } = new UserFinishedCourseEmail({ + const emailTemplate = new UserFinishedCourseEmail({ buttonLink, courseName, ...defaultEmailSettings, hasCertificate, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); await this.emailService.sendEmailWithLogo( { diff --git a/apps/api/src/user/services/user-password-email.service.ts b/apps/api/src/user/services/user-password-email.service.ts index 245fcac51c..73da8edffc 100644 --- a/apps/api/src/user/services/user-password-email.service.ts +++ b/apps/api/src/user/services/user-password-email.service.ts @@ -54,7 +54,7 @@ export class UserPasswordEmailService { hasCredentials: true, }); - const preparedResetEmails = this.preparePasswordResetEmails(recipients, tenantOrigin); + const preparedResetEmails = await this.preparePasswordResetEmails(recipients, tenantOrigin); const result = { sentCount: preparedResetEmails.emails.length, @@ -93,8 +93,11 @@ export class UserPasswordEmailService { const resetRecipients = recipients.filter(({ hasCredentials }) => hasCredentials); const creationRecipients = recipients.filter(({ hasCredentials }) => !hasCredentials); - const preparedResetEmails = this.preparePasswordResetEmails(resetRecipients, tenantOrigin); - const preparedCreationEmails = this.preparePasswordCreationEmails( + const preparedResetEmails = await this.preparePasswordResetEmails( + resetRecipients, + tenantOrigin, + ); + const preparedCreationEmails = await this.preparePasswordCreationEmails( creationRecipients, tenantOrigin, ); @@ -157,7 +160,10 @@ export class UserPasswordEmailService { currentUser.tenantId, ); - const preparedCreationEmails = this.preparePasswordCreationEmails(recipients, tenantOrigin); + const preparedCreationEmails = await this.preparePasswordCreationEmails( + recipients, + tenantOrigin, + ); const result = { sentCount: preparedCreationEmails.emails.length, @@ -196,7 +202,7 @@ export class UserPasswordEmailService { recipient.tenantId, ); - const preparedResetEmails = this.preparePasswordResetEmails([recipient], tenantOrigin); + const preparedResetEmails = await this.preparePasswordResetEmails([recipient], tenantOrigin); await this.userPasswordEmailRepository.insertResetTokens( preparedResetEmails.tokenRows, @@ -210,7 +216,7 @@ export class UserPasswordEmailService { return [...new Set(userIds)]; } - private preparePasswordResetEmails( + private async preparePasswordResetEmails( recipients: UserPasswordEmailRecipient[], tenantOrigin: string, ) { @@ -234,21 +240,22 @@ export class UserPasswordEmailService { resetLink: buildCreateNewPasswordLink(tenantOrigin, { resetToken }), ...recipient.defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); emails.push({ userId: recipient.id, to: recipient.email, tenantId: recipient.tenantId, subject: getEmailSubject("passwordRecoveryEmail", recipient.defaultEmailSettings.language), - text: emailTemplate.text, - html: emailTemplate.html, + text, + html, }); } return { tokenRows, emails }; } - private preparePasswordCreationEmails( + private async preparePasswordCreationEmails( recipients: UserPasswordEmailRecipient[], tenantOrigin: string, ) { @@ -272,14 +279,15 @@ export class UserPasswordEmailService { createPasswordLink: buildCreateNewPasswordLink(tenantOrigin, { createToken }), ...recipient.defaultEmailSettings, }); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); emails.push({ userId: recipient.id, to: recipient.email, tenantId: recipient.tenantId, subject: getEmailSubject("passwordReminderEmail", recipient.defaultEmailSettings.language), - text: emailTemplate.text, - html: emailTemplate.html, + text, + html, }); } diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index 9da23f87ab..50406859b6 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -259,6 +259,7 @@ export interface CurrentUserResponse { | "announcement.read" | "announcement.create" | "announcement.delete" + | "email_template.manage" | "news.read_public" | "news.manage" | "news.manage_own" @@ -7709,6 +7710,516 @@ export interface FinishScormAttemptResponse { }; } +export interface ListTemplatesResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }[]; + pagination: { + totalItems: number; + page: number; + perPage: number; + }; + appliedFilters?: object; +} + +/** @minItems 1 */ +export type DeleteManyTemplatesBody = string[]; + +export interface DeleteManyTemplatesResponse { + data: { + message: string; + }; +} + +export interface CreateTemplateBody { + /** + * @minLength 1 + * @maxLength 200 + */ + name?: string; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + /** @minItems 1 */ + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + subject?: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks?: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings?: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; +} + +export interface CreateTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface GetTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface UpdateTemplateBody { + /** + * @minLength 1 + * @maxLength 200 + */ + name?: string; + baseLanguage?: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + /** @minItems 1 */ + availableLocales?: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + subject?: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks?: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings?: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; +} + +export interface UpdateTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface PublishTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface MakeTemplateDraftResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface ArchiveTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface DeleteTemplateResponse { + data: { + message: string; + }; +} + +export interface UnarchiveTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface PreviewTemplateResponse { + data: { + language: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + subject: string; + html: string; + }; +} + +export interface SendTestEmailResponse { + data: { + message: string; + }; +} + +export interface DuplicateTemplateResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + name: string; + subject: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: string; + fr?: string; + }; + blocks: { + type?: string; + attrs?: object; + content?: any[]; + marks?: { + type: string; + attrs?: object; + [key: string]: any; + }[]; + text?: string; + [key: string]: any; + }; + strings: { + en?: object; + pl?: object; + de?: object; + lt?: object; + cs?: object; + es?: object; + fr?: object; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es" | "fr")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface UploadResponse { + data: { + url: string; + }; +} + export interface GetTenantsResponse { data: { /** @format uuid */ @@ -15346,6 +15857,286 @@ export class API extends HttpClient + this.request({ + path: `/api/email-notification-templates`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerCreateTemplate + * @request POST:/api/email-notification-templates + */ + emailNotificationTemplatesControllerCreateTemplate: ( + data: CreateTemplateBody, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerDeleteManyTemplates + * @request DELETE:/api/email-notification-templates/bulk + */ + emailNotificationTemplatesControllerDeleteManyTemplates: ( + data: DeleteManyTemplatesBody, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/bulk`, + method: "DELETE", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerGetTemplate + * @request GET:/api/email-notification-templates/{id} + */ + emailNotificationTemplatesControllerGetTemplate: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/email-notification-templates/${id}`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerUpdateTemplate + * @request PATCH:/api/email-notification-templates/{id} + */ + emailNotificationTemplatesControllerUpdateTemplate: ( + id: string, + data: UpdateTemplateBody, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/${id}`, + method: "PATCH", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerDeleteTemplate + * @request DELETE:/api/email-notification-templates/{id} + */ + emailNotificationTemplatesControllerDeleteTemplate: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/email-notification-templates/${id}`, + method: "DELETE", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerPublishTemplate + * @request POST:/api/email-notification-templates/{id}/publish + */ + emailNotificationTemplatesControllerPublishTemplate: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/email-notification-templates/${id}/publish`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerMakeTemplateDraft + * @request POST:/api/email-notification-templates/{id}/make-draft + */ + emailNotificationTemplatesControllerMakeTemplateDraft: ( + id: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/${id}/make-draft`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerArchiveTemplate + * @request POST:/api/email-notification-templates/{id}/archive + */ + emailNotificationTemplatesControllerArchiveTemplate: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/email-notification-templates/${id}/archive`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerUnarchiveTemplate + * @request POST:/api/email-notification-templates/{id}/unarchive + */ + emailNotificationTemplatesControllerUnarchiveTemplate: ( + id: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/${id}/unarchive`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerPreviewTemplate + * @request POST:/api/email-notification-templates/{id}/preview + */ + emailNotificationTemplatesControllerPreviewTemplate: ( + id: string, + query?: { + language?: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/${id}/preview`, + method: "POST", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerSendTestEmail + * @request POST:/api/email-notification-templates/{id}/test-send + */ + emailNotificationTemplatesControllerSendTestEmail: ( + id: string, + query?: { + language?: "en" | "pl" | "de" | "lt" | "cs" | "es" | "fr"; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/${id}/test-send`, + method: "POST", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailNotificationTemplatesControllerDuplicateTemplate + * @request POST:/api/email-notification-templates/{id}/duplicate + */ + emailNotificationTemplatesControllerDuplicateTemplate: ( + id: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/${id}/duplicate`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @name EmailTemplateImageControllerUpload + * @request POST:/api/email-notification-templates/images + */ + emailTemplateImageControllerUpload: ( + data: { + /** @format binary */ + file: File; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/email-notification-templates/images`, + method: "POST", + body: data, + type: ContentType.FormData, + format: "json", + ...params, + }), + + /** + * No description + * + * @name PublicCourseThumbnailControllerGetThumbnail + * @request GET:/api/public/course-thumbnail/{courseId} + */ + publicCourseThumbnailControllerGetThumbnail: (courseId: string, params: RequestParams = {}) => + this.request({ + path: `/api/public/course-thumbnail/${courseId}`, + method: "GET", + ...params, + }), + + /** + * No description + * + * @name PublicEmailTemplateImageControllerServe + * @request GET:/api/public/email-template-image/{reference} + */ + publicEmailTemplateImageControllerServe: (reference: string, params: RequestParams = {}) => + this.request({ + path: `/api/public/email-template-image/${reference}`, + method: "GET", + ...params, + }), + /** * @description Returns all tenants accessible to the current integration API key. Use this endpoint first to discover which tenant IDs you can operate on. For the rest of integration endpoints, pass one of those IDs in the X-Tenant-Id header. * diff --git a/apps/web/app/api/mutations/admin/useArchiveEmailTemplate.ts b/apps/web/app/api/mutations/admin/useArchiveEmailTemplate.ts new file mode 100644 index 0000000000..8463f17bcc --- /dev/null +++ b/apps/web/app/api/mutations/admin/useArchiveEmailTemplate.ts @@ -0,0 +1,40 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { EMAIL_TEMPLATE_QUERY_KEY } from "~/api/queries/admin/useEmailTemplate"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useArchiveEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (id: string) => { + const response = await ApiClient.api.emailNotificationTemplatesControllerArchiveTemplate(id); + return response.data; + }, + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EMAIL_TEMPLATE_QUERY_KEY, id] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.archivedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("emailTemplates.toast.archiveFailed"), + ), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useCreateEmailTemplate.ts b/apps/web/app/api/mutations/admin/useCreateEmailTemplate.ts new file mode 100644 index 0000000000..968e5fa22c --- /dev/null +++ b/apps/web/app/api/mutations/admin/useCreateEmailTemplate.ts @@ -0,0 +1,42 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +import type { CreateTemplateBody } from "~/api/generated-api"; + +type CreateEmailTemplateOptions = { + data: CreateTemplateBody; +}; + +export function useCreateEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (options: CreateEmailTemplateOptions) => { + const response = await ApiClient.api.emailNotificationTemplatesControllerCreateTemplate( + options.data, + ); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.createdSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage(error, t, t("emailTemplates.toast.createFailed")), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useDeleteEmailTemplate.ts b/apps/web/app/api/mutations/admin/useDeleteEmailTemplate.ts new file mode 100644 index 0000000000..a2182dd640 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useDeleteEmailTemplate.ts @@ -0,0 +1,31 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useDeleteEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (id: string) => + await ApiClient.api.emailNotificationTemplatesControllerDeleteTemplate(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + + toast({ + description: t("emailTemplates.toast.deletedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + description: getTranslatedApiErrorMessage(error, t, t("emailTemplates.toast.deleteFailed")), + variant: "destructive", + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useDeleteManyEmailTemplates.ts b/apps/web/app/api/mutations/admin/useDeleteManyEmailTemplates.ts new file mode 100644 index 0000000000..dccfab9deb --- /dev/null +++ b/apps/web/app/api/mutations/admin/useDeleteManyEmailTemplates.ts @@ -0,0 +1,31 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useDeleteManyEmailTemplates() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (ids: string[]) => + await ApiClient.api.emailNotificationTemplatesControllerDeleteManyTemplates(ids), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + + toast({ + description: t("emailTemplates.toast.deletedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + description: getTranslatedApiErrorMessage(error, t, t("emailTemplates.toast.deleteFailed")), + variant: "destructive", + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useDuplicateEmailTemplate.ts b/apps/web/app/api/mutations/admin/useDuplicateEmailTemplate.ts new file mode 100644 index 0000000000..eb377f8994 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useDuplicateEmailTemplate.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useDuplicateEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (id: string) => { + const response = + await ApiClient.api.emailNotificationTemplatesControllerDuplicateTemplate(id); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.duplicatedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("emailTemplates.toast.duplicateFailed"), + ), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useMakeDraftEmailTemplate.ts b/apps/web/app/api/mutations/admin/useMakeDraftEmailTemplate.ts new file mode 100644 index 0000000000..e5f41fd801 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useMakeDraftEmailTemplate.ts @@ -0,0 +1,41 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { EMAIL_TEMPLATE_QUERY_KEY } from "~/api/queries/admin/useEmailTemplate"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useMakeDraftEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (id: string) => { + const response = + await ApiClient.api.emailNotificationTemplatesControllerMakeTemplateDraft(id); + return response.data; + }, + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EMAIL_TEMPLATE_QUERY_KEY, id] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.madeDraftSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("emailTemplates.toast.makeDraftFailed"), + ), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/usePublishEmailTemplate.ts b/apps/web/app/api/mutations/admin/usePublishEmailTemplate.ts new file mode 100644 index 0000000000..3988e98327 --- /dev/null +++ b/apps/web/app/api/mutations/admin/usePublishEmailTemplate.ts @@ -0,0 +1,40 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { EMAIL_TEMPLATE_QUERY_KEY } from "~/api/queries/admin/useEmailTemplate"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function usePublishEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (id: string) => { + const response = await ApiClient.api.emailNotificationTemplatesControllerPublishTemplate(id); + return response.data; + }, + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EMAIL_TEMPLATE_QUERY_KEY, id] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.publishedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("emailTemplates.toast.publishFailed"), + ), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useSendTestEmail.ts b/apps/web/app/api/mutations/admin/useSendTestEmail.ts new file mode 100644 index 0000000000..c043a8f2be --- /dev/null +++ b/apps/web/app/api/mutations/admin/useSendTestEmail.ts @@ -0,0 +1,38 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +import type { SupportedLanguages } from "@repo/shared"; + +export function useSendTestEmail() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ id, language }: { id: string; language?: SupportedLanguages }) => { + const response = await ApiClient.api.emailNotificationTemplatesControllerSendTestEmail(id, { + language, + }); + return response.data; + }, + onSuccess: () => { + toast({ + variant: "default", + description: t("emailTemplates.toast.testEmailSentSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("emailTemplates.toast.testEmailSendFailed"), + ), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useUnarchiveEmailTemplate.ts b/apps/web/app/api/mutations/admin/useUnarchiveEmailTemplate.ts new file mode 100644 index 0000000000..2a7e97e00b --- /dev/null +++ b/apps/web/app/api/mutations/admin/useUnarchiveEmailTemplate.ts @@ -0,0 +1,41 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { EMAIL_TEMPLATE_QUERY_KEY } from "~/api/queries/admin/useEmailTemplate"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useUnarchiveEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (id: string) => { + const response = + await ApiClient.api.emailNotificationTemplatesControllerUnarchiveTemplate(id); + return response.data; + }, + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EMAIL_TEMPLATE_QUERY_KEY, id] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.unarchivedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("emailTemplates.toast.unarchiveFailed"), + ), + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/admin/useUpdateEmailTemplate.ts b/apps/web/app/api/mutations/admin/useUpdateEmailTemplate.ts new file mode 100644 index 0000000000..104aceb3c0 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useUpdateEmailTemplate.ts @@ -0,0 +1,46 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { ALL_EMAIL_TEMPLATES_QUERY_KEY } from "~/api/queries/admin/useAllEmailTemplates"; +import { EMAIL_TEMPLATE_QUERY_KEY } from "~/api/queries/admin/useEmailTemplate"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +import type { UpdateTemplateBody } from "~/api/generated-api"; + +type UpdateEmailTemplateOptions = { + id: string; + data: UpdateTemplateBody; +}; + +export function useUpdateEmailTemplate() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ id, data }: UpdateEmailTemplateOptions) => { + const response = await ApiClient.api.emailNotificationTemplatesControllerUpdateTemplate( + id, + data, + ); + return response.data; + }, + onSuccess: (_data, { id }) => { + queryClient.invalidateQueries({ queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [EMAIL_TEMPLATE_QUERY_KEY, id] }); + + toast({ + variant: "default", + description: t("emailTemplates.toast.updatedSuccessfully"), + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage(error, t, t("emailTemplates.toast.updateFailed")), + }); + }, + }); +} diff --git a/apps/web/app/api/queries/admin/useAllEmailTemplates.ts b/apps/web/app/api/queries/admin/useAllEmailTemplates.ts new file mode 100644 index 0000000000..f91b4cd6f5 --- /dev/null +++ b/apps/web/app/api/queries/admin/useAllEmailTemplates.ts @@ -0,0 +1,49 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; + +import { ApiClient } from "~/api/api-client"; + +import type { EmailTemplateStatus } from "@repo/shared"; +import type { ListTemplatesResponse } from "~/api/generated-api"; + +export const ALL_EMAIL_TEMPLATES_QUERY_KEY = "email-templates"; + +export type AllEmailTemplatesParams = { + status?: EmailTemplateStatus; + name?: string; + page?: number; + perPage?: number; +}; + +type QueryOptions = { + enabled?: boolean; +}; + +export const allEmailTemplatesOptions = ( + searchParams?: AllEmailTemplatesParams, + options: QueryOptions = { enabled: true }, +) => ({ + placeholderData: (previousData: ListTemplatesResponse | undefined) => previousData, + queryKey: [ALL_EMAIL_TEMPLATES_QUERY_KEY, searchParams], + queryFn: async () => { + const { data } = await ApiClient.api.emailNotificationTemplatesControllerListTemplates({ + ...(searchParams?.status && { status: searchParams.status }), + ...(searchParams?.name && { name: searchParams.name }), + ...(searchParams?.page && { page: searchParams.page }), + ...(searchParams?.perPage && { perPage: searchParams.perPage }), + }); + + return data; + }, + ...options, +}); + +export function useAllEmailTemplates( + searchParams?: AllEmailTemplatesParams, + options?: QueryOptions, +) { + return useQuery(allEmailTemplatesOptions(searchParams, options)); +} + +export function useAllEmailTemplatesSuspense(searchParams?: AllEmailTemplatesParams) { + return useSuspenseQuery(allEmailTemplatesOptions(searchParams)); +} diff --git a/apps/web/app/api/queries/admin/useEmailTemplate.ts b/apps/web/app/api/queries/admin/useEmailTemplate.ts new file mode 100644 index 0000000000..06ca17c89c --- /dev/null +++ b/apps/web/app/api/queries/admin/useEmailTemplate.ts @@ -0,0 +1,29 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; + +import { ApiClient } from "~/api/api-client"; + +import type { GetTemplateResponse } from "~/api/generated-api"; + +export const EMAIL_TEMPLATE_QUERY_KEY = "email-template"; + +type QueryOptions = { + enabled?: boolean; +}; + +export const emailTemplateOptions = (id: string, options: QueryOptions = { enabled: true }) => ({ + queryKey: [EMAIL_TEMPLATE_QUERY_KEY, id], + queryFn: async () => { + const { data } = await ApiClient.api.emailNotificationTemplatesControllerGetTemplate(id); + return data; + }, + select: (data: GetTemplateResponse) => data.data, + ...options, +}); + +export function useEmailTemplate(id: string, options?: QueryOptions) { + return useQuery(emailTemplateOptions(id, options)); +} + +export function useEmailTemplateSuspense(id: string) { + return useSuspenseQuery(emailTemplateOptions(id)); +} diff --git a/apps/web/app/components/PageWrapper/PageWrapper.tsx b/apps/web/app/components/PageWrapper/PageWrapper.tsx index b646641b6e..34123e0485 100644 --- a/apps/web/app/components/PageWrapper/PageWrapper.tsx +++ b/apps/web/app/components/PageWrapper/PageWrapper.tsx @@ -1,3 +1,5 @@ +import { Fragment, type HTMLAttributes, type ReactNode } from "react"; + import { BreadcrumbItem, BreadcrumbLink, @@ -6,8 +8,6 @@ import { } from "~/components/ui/breadcrumb"; import { cn } from "~/lib/utils"; -import type { HTMLAttributes, ReactNode } from "react"; - type PageWrapperProps = HTMLAttributes & { breadcrumbs?: { title: string; href: string }[]; isBarebones?: boolean; @@ -34,15 +34,17 @@ export const Breadcrumbs = ({ breadcrumbs = [] }: BreadcrumbsProps) => { return ( {breadcrumbs.slice(0, lastIndex).map(({ href, title }, index) => ( - - - {title} - + + + + {title} + + - + ))} renderWith().render( - + ({ + archive: vi.fn(), + duplicate: vi.fn(), + makeDraft: vi.fn(), + publish: vi.fn(), + sendTestEmail: vi.fn(), + toast: vi.fn(), + unarchive: vi.fn(), + update: vi.fn(), + useEmailTemplate: vi.fn(), +})); + +vi.mock("~/api/queries/admin/useEmailTemplate", () => ({ + useEmailTemplate: mocks.useEmailTemplate, +})); + +vi.mock("~/api/mutations/admin/useArchiveEmailTemplate", () => ({ + useArchiveEmailTemplate: () => ({ mutate: mocks.archive, isPending: false }), +})); + +vi.mock("~/api/mutations/admin/useDuplicateEmailTemplate", () => ({ + useDuplicateEmailTemplate: () => ({ mutateAsync: mocks.duplicate, isPending: false }), +})); + +vi.mock("~/api/mutations/admin/useMakeDraftEmailTemplate", () => ({ + useMakeDraftEmailTemplate: () => ({ mutate: mocks.makeDraft, isPending: false }), +})); + +vi.mock("~/api/mutations/admin/usePublishEmailTemplate", () => ({ + usePublishEmailTemplate: () => ({ mutate: mocks.publish, isPending: false }), +})); + +vi.mock("~/api/mutations/admin/useSendTestEmail", () => ({ + useSendTestEmail: () => ({ mutate: mocks.sendTestEmail, isPending: false }), +})); + +vi.mock("~/api/mutations/admin/useUnarchiveEmailTemplate", () => ({ + useUnarchiveEmailTemplate: () => ({ mutate: mocks.unarchive, isPending: false }), +})); + +vi.mock("~/api/mutations/admin/useUpdateEmailTemplate", () => ({ + useUpdateEmailTemplate: () => ({ mutateAsync: mocks.update, isPending: false }), +})); + +vi.mock("~/components/ui/use-toast", () => ({ + toast: mocks.toast, + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("./components/BuilderCanvas/EmailTemplateEditor", () => ({ + EmailTemplateEditor: () =>
Builder canvas
, +})); + +vi.mock("./components/SubjectInput/SubjectInput", () => ({ + SubjectInput: ({ + ariaLabel, + onChange, + testId, + value, + }: { + ariaLabel?: string; + onChange: (value: string) => void; + testId?: string; + value: string; + }) => ( + onChange(event.target.value)} + value={value} + /> + ), +})); + +const RemixStub = createRemixStub([ + { + path: "/admin/email-templates/:id", + Component: EditEmailTemplatePage, + }, +]); + +const makeTemplate = (overrides: Partial = {}) => ({ + id: overrides.id ?? "template-1", + createdAt: overrides.createdAt ?? "2026-07-28T10:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-07-28T10:00:00.000Z", + name: overrides.name ?? "Welcome notification", + subject: overrides.subject ?? { en: "Welcome" }, + blocks: overrides.blocks ?? { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { uuid: "node-1" }, + content: [{ type: "text", text: "Hello" }], + }, + ], + }, + strings: overrides.strings ?? {}, + baseLanguage: overrides.baseLanguage ?? "en", + availableLocales: overrides.availableLocales ?? ["en"], + status: overrides.status ?? EMAIL_TEMPLATE_STATUSES.DRAFT, + archivedAt: overrides.archivedAt ?? null, +}); + +const renderPage = () => + renderWith().render(); + +describe("EditEmailTemplatePage", () => { + beforeEach(() => { + vi.clearAllMocks(); + const template = makeTemplate(); + mocks.useEmailTemplate.mockReturnValue({ + data: template, + isLoading: false, + isError: false, + }); + mocks.update.mockResolvedValue({ data: template }); + mocks.duplicate.mockResolvedValue({ data: makeTemplate({ id: "duplicated-template" }) }); + }); + + it("renders the builder page and saves changed subject content", async () => { + const userEvent = user.setup(); + + renderPage(); + + expect(screen.getByTestId("edit-email-template-page")).toBeInTheDocument(); + + await userEvent.clear(screen.getByTestId("edit-email-template-subject-input")); + await userEvent.type( + screen.getByTestId("edit-email-template-subject-input"), + "Updated subject", + ); + await userEvent.click(screen.getByTestId("edit-email-template-save-button")); + + await waitFor(() => { + expect(mocks.update).toHaveBeenCalledWith({ + id: "template-1", + data: expect.objectContaining({ + name: "Welcome notification", + subject: { en: "Updated subject" }, + blocks: expect.any(Object), + strings: {}, + baseLanguage: "en", + availableLocales: ["en"], + }), + }); + }); + }); + + it("renames the template when the heading edit is committed", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.click(screen.getByTestId("edit-email-template-name-button")); + await userEvent.clear(screen.getByTestId("edit-email-template-name-input")); + await userEvent.type(screen.getByTestId("edit-email-template-name-input"), "Renamed template"); + await userEvent.keyboard("{Enter}"); + + await waitFor(() => { + expect(mocks.update).toHaveBeenCalledWith({ + id: "template-1", + data: { name: "Renamed template" }, + }); + }); + }); + + it("saves dirty form values before sending a test email", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.clear(screen.getByTestId("edit-email-template-subject-input")); + await userEvent.type( + screen.getByTestId("edit-email-template-subject-input"), + "Preview subject", + ); + await userEvent.click(screen.getByTestId("edit-email-template-send-test-button")); + + await waitFor(() => { + expect(mocks.update).toHaveBeenCalled(); + expect(mocks.sendTestEmail).toHaveBeenCalledWith({ id: "template-1", language: "en" }); + }); + }); + + it("blocks saving a published template when diagnostics contain errors", async () => { + const userEvent = user.setup(); + mocks.useEmailTemplate.mockReturnValue({ + data: makeTemplate({ + status: EMAIL_TEMPLATE_STATUSES.PUBLISHED, + subject: {}, + blocks: { type: "doc", content: [] }, + }), + isLoading: false, + isError: false, + }); + + renderPage(); + + await userEvent.click(screen.getByTestId("edit-email-template-save-button")); + + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + description: expect.stringContaining("Cannot save"), + }), + ); + }); + + it("archives templates and saves dirty values before status changes", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.clear(screen.getByTestId("edit-email-template-subject-input")); + await userEvent.type( + screen.getByTestId("edit-email-template-subject-input"), + "Ready to archive", + ); + await userEvent.click(screen.getByTestId("edit-email-template-status-select")); + await userEvent.click(await screen.findByRole("option", { name: "Archived" })); + + await waitFor(() => { + expect(mocks.update).toHaveBeenCalledWith({ + id: "template-1", + data: expect.objectContaining({ + subject: { en: "Ready to archive" }, + }), + }); + expect(mocks.archive).toHaveBeenCalledWith("template-1"); + }); + }); + + it("unarchives archived templates through the draft status option", async () => { + const userEvent = user.setup(); + mocks.useEmailTemplate.mockReturnValue({ + data: makeTemplate({ status: EMAIL_TEMPLATE_STATUSES.ARCHIVED }), + isLoading: false, + isError: false, + }); + + renderPage(); + + await userEvent.click(screen.getByTestId("edit-email-template-status-select")); + await userEvent.click(await screen.findByRole("option", { name: "Draft" })); + + expect(mocks.unarchive).toHaveBeenCalledWith("template-1"); + }); + + it("publishes valid templates and duplicates templates from builder actions", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.click(screen.getByTestId("edit-email-template-status-select")); + await userEvent.click(await screen.findByRole("option", { name: "Published" })); + + expect(mocks.publish).toHaveBeenCalledWith("template-1"); + + await userEvent.click(screen.getByTestId("edit-email-template-duplicate-button")); + + expect(mocks.duplicate).toHaveBeenCalledWith("template-1"); + }); + + it("blocks publishing when diagnostics contain errors", async () => { + const userEvent = user.setup(); + mocks.useEmailTemplate.mockReturnValue({ + data: makeTemplate({ + subject: {}, + blocks: { type: "doc", content: [] }, + }), + isLoading: false, + isError: false, + }); + + renderPage(); + + await userEvent.click(screen.getByTestId("edit-email-template-status-select")); + await userEvent.click(await screen.findByRole("option", { name: "Published" })); + + expect(mocks.publish).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + description: expect.stringContaining("Cannot publish"), + }), + ); + }); + + it("adds a new language and saves subject content for that language", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.click(screen.getByTestId("edit-email-template-language-select")); + await userEvent.click( + await screen.findByTestId(`edit-email-template-language-option-${SUPPORTED_LANGUAGES.PL}`), + ); + await userEvent.click( + await screen.findByTestId("edit-email-template-language-create-confirm-button"), + ); + await userEvent.type(screen.getByTestId("edit-email-template-subject-input"), "Witaj"); + await userEvent.click(screen.getByTestId("edit-email-template-save-button")); + + await waitFor(() => { + expect(mocks.update).toHaveBeenCalledWith({ + id: "template-1", + data: expect.objectContaining({ + subject: { en: "Welcome", pl: "Witaj" }, + availableLocales: ["en", "pl"], + strings: { pl: {} }, + }), + }); + }); + }); + + it("shows load failure when the template query errors", () => { + mocks.useEmailTemplate.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + + renderPage(); + + expect(screen.getByText("Could not load this email template.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/EditEmailTemplate.page.tsx b/apps/web/app/modules/Admin/EmailTemplates/EditEmailTemplate.page.tsx new file mode 100644 index 0000000000..2480825c25 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/EditEmailTemplate.page.tsx @@ -0,0 +1,502 @@ +import { useNavigate, useParams } from "@remix-run/react"; +import { + EMAIL_TEMPLATE_NODE_UUID_ATTR, + EMAIL_TEMPLATE_STATUSES, + computeEmailTemplateDiagnostics, + groupEmailTemplateDiagnostics, +} from "@repo/shared"; +import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormProvider } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { useArchiveEmailTemplate } from "~/api/mutations/admin/useArchiveEmailTemplate"; +import { useDuplicateEmailTemplate } from "~/api/mutations/admin/useDuplicateEmailTemplate"; +import { useMakeDraftEmailTemplate } from "~/api/mutations/admin/useMakeDraftEmailTemplate"; +import { usePublishEmailTemplate } from "~/api/mutations/admin/usePublishEmailTemplate"; +import { useSendTestEmail } from "~/api/mutations/admin/useSendTestEmail"; +import { useUnarchiveEmailTemplate } from "~/api/mutations/admin/useUnarchiveEmailTemplate"; +import { useUpdateEmailTemplate } from "~/api/mutations/admin/useUpdateEmailTemplate"; +import { useEmailTemplate } from "~/api/queries/admin/useEmailTemplate"; +import { LanguageSelector } from "~/components/LanguageSelector/LanguageSelector"; +import { PageWrapper } from "~/components/PageWrapper"; +import { Button } from "~/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { toast } from "~/components/ui/use-toast"; +import { cn } from "~/lib/utils"; +import Loader from "~/modules/common/Loader/Loader"; +import { setPageTitle } from "~/utils/setPageTitle"; + +import { InlineDiagnosticStack } from "./components/InlineDiagnosticNote/InlineDiagnosticStack"; +import { SubjectInput } from "./components/SubjectInput/SubjectInput"; +import { useEditEmailTemplateForm } from "./hooks/useEditEmailTemplateForm"; +import { swapBaseLanguageContent } from "./utils/swapBaseLanguageContent"; + +import type { MetaFunction } from "@remix-run/react"; +import type { + EmailTemplateBlocks, + EmailTemplateStatus, + EmailTemplateStrings, + SupportedLanguages, + TranslationFragment, +} from "@repo/shared"; + +const collectNodeUuids = (blocks: EmailTemplateBlocks): Set => { + const uuids = new Set(); + const walk = (node: EmailTemplateBlocks) => { + const uuid = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof uuid === "string" && uuid.length > 0) uuids.add(uuid); + node.content?.forEach(walk); + }; + walk(blocks); + return uuids; +}; + +const EmailTemplateEditor = lazy(() => + import("./components/BuilderCanvas/EmailTemplateEditor").then((m) => ({ + default: m.EmailTemplateEditor, + })), +); + +export const meta: MetaFunction = ({ matches }) => setPageTitle(matches, "pages.editEmailTemplate"); + +export default function EditEmailTemplatePage() { + const { t } = useTranslation(); + const { id } = useParams(); + + const { data: template, isLoading, isError } = useEmailTemplate(id ?? "", { enabled: !!id }); + + const breadcrumbs = [ + { title: t("emailTemplates.breadcrumbs.list"), href: "/admin/email-templates" }, + { + title: template?.name ?? t("emailTemplates.breadcrumbs.edit"), + href: `/admin/email-templates/${id}`, + }, + ]; + + if (isLoading || !id) { + return ( + + + + ); + } + + if (isError || !template) { + return ( + +

{t("emailTemplates.edit.loadFailed")}

+
+ ); + } + + return ; +} + +type EditEmailTemplateBuilderProps = { + template: NonNullable["data"]>; + breadcrumbs: { title: string; href: string }[]; +}; + +function EditEmailTemplateBuilder({ template, breadcrumbs }: EditEmailTemplateBuilderProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [currentLanguage, setCurrentLanguage] = useState(template.baseLanguage); + + const { form, onSubmit, isSubmitting } = useEditEmailTemplateForm(template); + + const { mutate: publish, isPending: isPublishing } = usePublishEmailTemplate(); + const { mutate: makeDraft, isPending: isMakingDraft } = useMakeDraftEmailTemplate(); + const { mutate: archive, isPending: isArchiving } = useArchiveEmailTemplate(); + const { mutate: unarchive, isPending: isUnarchiving } = useUnarchiveEmailTemplate(); + const { mutateAsync: duplicate } = useDuplicateEmailTemplate(); + const { mutateAsync: rename, isPending: isRenaming } = useUpdateEmailTemplate(); + const { mutate: sendTestEmail, isPending: isSendingTestEmail } = useSendTestEmail(); + + const [isEditingName, setIsEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(template.name); + const [isNameOverflowing, setIsNameOverflowing] = useState(false); + const nameInputRef = useRef(null); + const nameButtonRef = useRef(null); + + useEffect(() => { + if (!isEditingName) setNameDraft(template.name); + }, [template.name, isEditingName]); + + useEffect(() => { + if (isEditingName) { + nameInputRef.current?.focus(); + nameInputRef.current?.select(); + } + }, [isEditingName]); + + useEffect(() => { + if (isEditingName) return; + const el = nameButtonRef.current; + if (!el) return; + const check = () => setIsNameOverflowing(el.scrollWidth > el.clientWidth); + check(); + const ro = new ResizeObserver(check); + ro.observe(el); + return () => ro.disconnect(); + }, [isEditingName, template.name]); + + const availableLocales = form.watch("availableLocales"); + const baseLanguage = form.watch("baseLanguage"); + const blocks = form.watch("blocks"); + const strings = form.watch("strings"); + const subject = form.watch("subject"); + const isDirty = form.formState.isDirty; + + const diagnostics = useMemo( + () => + computeEmailTemplateDiagnostics({ + name: template.name, + availableLocales, + baseLanguage, + subject, + blocks, + strings, + }), + [template.name, availableLocales, baseLanguage, subject, blocks, strings], + ); + + const blockingErrorCount = useMemo( + () => diagnostics.filter((d) => d.severity === "error").length, + [diagnostics], + ); + const knownNodeUuids = useMemo(() => collectNodeUuids(blocks), [blocks]); + const diagnosticGroups = useMemo( + () => groupEmailTemplateDiagnostics(diagnostics, knownNodeUuids), + [diagnostics, knownNodeUuids], + ); + + const submitForm = form.handleSubmit(onSubmit); + const handleSave = async () => { + if (template.status === EMAIL_TEMPLATE_STATUSES.PUBLISHED && blockingErrorCount > 0) { + toast({ + variant: "destructive", + description: t("emailTemplates.publishDiagnostics.blockedToast.save"), + }); + return; + } + await submitForm(); + }; + + const isStatusChanging = isPublishing || isMakingDraft || isArchiving || isUnarchiving; + + const handleStatusChange = async (nextStatus: EmailTemplateStatus) => { + if (nextStatus === template.status || isStatusChanging) return; + if (nextStatus === EMAIL_TEMPLATE_STATUSES.PUBLISHED && blockingErrorCount > 0) { + toast({ + variant: "destructive", + description: t("emailTemplates.publishDiagnostics.blockedToast.publish"), + }); + return; + } + if (form.formState.isDirty) { + await submitForm(); + } + switch (nextStatus) { + case EMAIL_TEMPLATE_STATUSES.PUBLISHED: + publish(template.id); + return; + case EMAIL_TEMPLATE_STATUSES.DRAFT: + if (template.status === EMAIL_TEMPLATE_STATUSES.ARCHIVED) { + unarchive(template.id); + } else { + makeDraft(template.id); + } + return; + case EMAIL_TEMPLATE_STATUSES.ARCHIVED: + archive(template.id); + return; + } + }; + + const handleDuplicate = async () => { + const duplicatedTemplate = await duplicate(template.id); + navigate(`/admin/email-templates/${duplicatedTemplate.data.id}`); + }; + + const handleSendTestEmail = async () => { + if (form.formState.isDirty) { + await submitForm(); + } + sendTestEmail({ id: template.id, language: currentLanguage }); + }; + + const handleBlocksChange = useCallback( + (next: EmailTemplateBlocks) => { + form.setValue("blocks", next, { shouldDirty: true }); + }, + [form], + ); + + const handleTranslationsChange = useCallback( + (nextForLanguage: Record) => { + const nextStrings: EmailTemplateStrings = { + ...strings, + [currentLanguage]: nextForLanguage, + }; + form.setValue("strings", nextStrings, { shouldDirty: true }); + }, + [form, strings, currentLanguage], + ); + + const handleAddLanguage = useCallback( + (language: SupportedLanguages) => { + form.setValue("availableLocales", [...availableLocales, language], { shouldDirty: true }); + const nextStrings: EmailTemplateStrings = { ...strings, [language]: {} }; + form.setValue("strings", nextStrings, { shouldDirty: true }); + }, + [form, availableLocales, strings], + ); + + const commitNameEdit = useCallback(async () => { + const trimmed = nameDraft.trim(); + if (!trimmed || trimmed === template.name) { + setNameDraft(template.name); + setIsEditingName(false); + return; + } + try { + await rename({ id: template.id, data: { name: trimmed } }); + form.setValue("name", trimmed, { shouldDirty: false }); + setIsEditingName(false); + } catch { + setNameDraft(template.name); + } + }, [nameDraft, template.id, template.name, rename, form]); + + const cancelNameEdit = useCallback(() => { + setNameDraft(template.name); + setIsEditingName(false); + }, [template.name]); + + const handleRemoveLanguage = useCallback( + (language: SupportedLanguages) => { + form.setValue( + "availableLocales", + availableLocales.filter((l) => l !== language), + { shouldDirty: true }, + ); + const nextStrings: EmailTemplateStrings = { ...strings }; + delete nextStrings[language]; + form.setValue("strings", nextStrings, { shouldDirty: true }); + }, + [form, availableLocales, strings], + ); + + const handleSetBaseLanguage = useCallback( + (language: SupportedLanguages) => { + if (language === baseLanguage) return; + const { blocks: nextBlocks, strings: nextStrings } = swapBaseLanguageContent({ + blocks, + strings, + oldBase: baseLanguage, + newBase: language, + }); + form.setValue("blocks", nextBlocks, { shouldDirty: true }); + form.setValue("strings", nextStrings, { shouldDirty: true }); + form.setValue("baseLanguage", language, { shouldDirty: true }); + }, + [form, baseLanguage, blocks, strings], + ); + + const handleLanguageChange = useCallback( + (language: SupportedLanguages) => { + if (language === currentLanguage) return; + setCurrentLanguage(language); + }, + [currentLanguage], + ); + + const handleSubjectChange = useCallback( + (value: string) => { + form.setValue("subject", { ...subject, [currentLanguage]: value }, { shouldDirty: true }); + }, + [form, subject, currentLanguage], + ); + + return ( + + +
+
+
+ {isEditingName ? ( + setNameDraft(e.target.value)} + onBlur={cancelNameEdit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitNameEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelNameEdit(); + } + }} + maxLength={200} + size={Math.max(nameDraft.length + 1, 8)} + disabled={isRenaming} + className="block max-w-full rounded border border-primary-500 bg-white px-2 py-0.5 text-lg font-semibold whitespace-nowrap focus:outline-none disabled:opacity-60" + aria-label={t("emailTemplates.form.field.name")} + /> + ) : ( + <> + + {isNameOverflowing && ( +
+ )} + + )} +
+
+ + + +
+
+
+ `edit-email-template-language-option-${language}`, + createConfirmButton: "edit-email-template-language-create-confirm-button", + deleteButton: "edit-email-template-language-delete-button", + deleteConfirmButton: "edit-email-template-language-delete-confirm-button", + setBaseLanguageButton: "edit-email-template-language-set-base-button", + setBaseLanguageConfirmButton: + "edit-email-template-language-set-base-confirm-button", + }} + /> + +
+
+
+ + +
+ }> + + + {diagnosticGroups.orphan.length > 0 && ( +
+ +
+ )} +
+
+ + + ); +} diff --git a/apps/web/app/modules/Admin/EmailTemplates/EmailTemplates.page.test.tsx b/apps/web/app/modules/Admin/EmailTemplates/EmailTemplates.page.test.tsx new file mode 100644 index 0000000000..a33d6e676b --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/EmailTemplates.page.test.tsx @@ -0,0 +1,276 @@ +import { createRemixStub } from "@remix-run/testing"; +import { EMAIL_TEMPLATE_STATUSES, SUPPORTED_LANGUAGES } from "@repo/shared"; +import { screen, waitFor } from "@testing-library/react"; +import user from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWith } from "~/utils/testUtils"; + +import EmailTemplatesPage from "./EmailTemplates.page"; + +import type { ListTemplatesResponse } from "~/api/generated-api"; + +const mocks = vi.hoisted(() => ({ + createEmailTemplate: vi.fn(), + deleteEmailTemplate: vi.fn(), + deleteManyEmailTemplates: vi.fn(), + useAllEmailTemplates: vi.fn(), +})); + +vi.mock("~/api/queries/admin/useAllEmailTemplates", () => ({ + useAllEmailTemplates: mocks.useAllEmailTemplates, +})); + +vi.mock("~/api/mutations/admin/useCreateEmailTemplate", () => ({ + useCreateEmailTemplate: () => ({ + mutateAsync: mocks.createEmailTemplate, + isPending: false, + }), +})); + +vi.mock("~/api/mutations/admin/useDeleteEmailTemplate", () => ({ + useDeleteEmailTemplate: () => ({ + mutate: mocks.deleteEmailTemplate, + }), +})); + +vi.mock("~/api/mutations/admin/useDeleteManyEmailTemplates", () => ({ + useDeleteManyEmailTemplates: () => ({ + mutate: mocks.deleteManyEmailTemplates, + }), +})); + +vi.mock("~/modules/Dashboard/Settings/Language/LanguageStore", () => ({ + useLanguageStore: (selector: (state: { language: "en" }) => unknown) => + selector({ language: SUPPORTED_LANGUAGES.EN }), +})); + +const RemixStub = createRemixStub([ + { + path: "/", + Component: EmailTemplatesPage, + }, + { + path: "/admin/email-templates/:id", + Component: () =>
, + }, +]); + +const makeTemplate = ( + overrides: Partial = {}, +): ListTemplatesResponse["data"][number] => ({ + id: overrides.id ?? "template-1", + createdAt: overrides.createdAt ?? "2026-07-28T10:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-07-28T10:00:00.000Z", + name: overrides.name ?? "Welcome notification", + subject: overrides.subject ?? { en: "Welcome" }, + blocks: overrides.blocks ?? { type: "doc", content: [] }, + strings: overrides.strings ?? {}, + baseLanguage: overrides.baseLanguage ?? "en", + availableLocales: overrides.availableLocales ?? ["en"], + status: overrides.status ?? EMAIL_TEMPLATE_STATUSES.DRAFT, + archivedAt: overrides.archivedAt ?? null, +}); + +const makeListResponse = ( + templates: ListTemplatesResponse["data"], + pagination: Partial = {}, +): ListTemplatesResponse => ({ + data: templates, + pagination: { + totalItems: pagination.totalItems ?? templates.length, + page: pagination.page ?? 1, + perPage: pagination.perPage ?? 20, + }, +}); + +const renderPage = () => renderWith().render(); + +describe("EmailTemplatesPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.useAllEmailTemplates.mockReturnValue({ + data: makeListResponse([makeTemplate()]), + isLoading: false, + isError: false, + }); + mocks.createEmailTemplate.mockResolvedValue({ data: makeTemplate({ id: "created-template" }) }); + }); + + it("renders the template list and opens a row in the builder", async () => { + const userEvent = user.setup(); + + renderPage(); + + expect(screen.getByRole("heading", { name: "Email templates" })).toBeInTheDocument(); + expect(screen.getByText("Welcome notification")).toBeInTheDocument(); + expect(screen.getByText("Draft")).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("email-templates-row-template-1")); + + expect(await screen.findByTestId("email-template-builder-route")).toBeInTheDocument(); + }); + + it("creates a new template using the current UI language and navigates to it", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.click(screen.getByTestId("email-templates-create-button")); + + await waitFor(() => { + expect(mocks.createEmailTemplate).toHaveBeenCalledWith({ + data: { + baseLanguage: SUPPORTED_LANGUAGES.EN, + availableLocales: [SUPPORTED_LANGUAGES.EN], + }, + }); + }); + expect(await screen.findByTestId("email-template-builder-route")).toBeInTheDocument(); + }); + + it("deletes a selected template through the confirmation dialog", async () => { + const userEvent = user.setup(); + + renderPage(); + + await userEvent.click(screen.getByTestId("email-templates-row-checkbox-template-1")); + await userEvent.click(screen.getByTestId("email-templates-delete-selected-button")); + await userEvent.click(await screen.findByTestId("email-templates-delete-confirm-button")); + + expect(mocks.deleteEmailTemplate).toHaveBeenCalledWith( + "template-1", + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(mocks.deleteManyEmailTemplates).not.toHaveBeenCalled(); + }); + + it("bulk deletes multiple selected templates through the confirmation dialog", async () => { + const userEvent = user.setup(); + mocks.useAllEmailTemplates.mockReturnValue({ + data: makeListResponse([ + makeTemplate({ id: "template-1", name: "Welcome notification" }), + makeTemplate({ id: "template-2", name: "Reminder notification" }), + ]), + isLoading: false, + isError: false, + }); + + renderPage(); + + await userEvent.click(screen.getByTestId("email-templates-row-checkbox-template-1")); + await userEvent.click(screen.getByTestId("email-templates-row-checkbox-template-2")); + await userEvent.click(screen.getByTestId("email-templates-delete-selected-button")); + await userEvent.click(await screen.findByTestId("email-templates-delete-confirm-button")); + + expect(mocks.deleteManyEmailTemplates).toHaveBeenCalledWith( + ["template-1", "template-2"], + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(mocks.deleteEmailTemplate).not.toHaveBeenCalled(); + }); + + it("updates query params when filters and pagination controls change", async () => { + const userEvent = user.setup(); + mocks.useAllEmailTemplates.mockReturnValue({ + data: makeListResponse([makeTemplate()], { totalItems: 45, page: 1, perPage: 20 }), + isLoading: false, + isError: false, + }); + + renderPage(); + + await userEvent.click(screen.getByTestId("email-templates-pagination-next")); + + await waitFor(() => { + expect(mocks.useAllEmailTemplates).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 2, perPage: 20 }), + ); + }); + + await userEvent.type(screen.getByTestId("email-templates-name-filter"), "Quarterly"); + + await waitFor( + () => { + expect(mocks.useAllEmailTemplates).toHaveBeenLastCalledWith( + expect.objectContaining({ name: "Quarterly", page: 1, perPage: 20 }), + ); + }, + { timeout: 1000 }, + ); + + const publishedStatusOptionTestId = `email-templates-status-filter-option-${EMAIL_TEMPLATE_STATUSES.PUBLISHED}`; + let publishedStatusOption = screen.queryByTestId(publishedStatusOptionTestId); + + for (let attempt = 0; attempt < 3 && !publishedStatusOption; attempt++) { + const statusFilter = screen.getByTestId("email-templates-status-filter"); + await waitFor(() => expect(statusFilter).toBeEnabled()); + await userEvent.click(statusFilter); + publishedStatusOption = await screen + .findByTestId(publishedStatusOptionTestId, {}, { timeout: 1000 }) + .catch(() => null); + } + + if (!publishedStatusOption) { + throw new Error("Email template status filter did not open."); + } + + await userEvent.click(publishedStatusOption); + + await waitFor(() => { + expect(mocks.useAllEmailTemplates).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: "Quarterly", + status: EMAIL_TEMPLATE_STATUSES.PUBLISHED, + page: 1, + perPage: 20, + }), + ); + }); + + await userEvent.click(screen.getByTestId("email-templates-pagination-items-per-page")); + await userEvent.click( + await screen.findByTestId("email-templates-pagination-items-per-page-option-50"), + ); + + await waitFor(() => { + expect(mocks.useAllEmailTemplates).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: "Quarterly", + status: EMAIL_TEMPLATE_STATUSES.PUBLISHED, + page: 1, + perPage: 50, + }), + ); + }); + }); + + it("shows loading, error, and empty states from the templates query", () => { + mocks.useAllEmailTemplates.mockReturnValueOnce({ + data: undefined, + isLoading: true, + isError: false, + }); + const { unmount } = renderPage(); + expect(screen.getByText("Loading templates...")).toBeInTheDocument(); + unmount(); + + mocks.useAllEmailTemplates.mockReturnValueOnce({ + data: undefined, + isLoading: false, + isError: true, + }); + const errorRender = renderPage(); + expect(screen.getByText("Could not load email templates.")).toBeInTheDocument(); + errorRender.unmount(); + + mocks.useAllEmailTemplates.mockReturnValueOnce({ + data: makeListResponse([]), + isLoading: false, + isError: false, + }); + renderPage(); + expect(screen.getByText("No email templates yet.")).toBeInTheDocument(); + expect(screen.queryByText("No data found")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/EmailTemplates.page.tsx b/apps/web/app/modules/Admin/EmailTemplates/EmailTemplates.page.tsx new file mode 100644 index 0000000000..6214a4132d --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/EmailTemplates.page.tsx @@ -0,0 +1,349 @@ +import { useNavigate } from "@remix-run/react"; +import { EMAIL_TEMPLATE_STATUSES } from "@repo/shared"; +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type RowSelectionState, + type SortingState, +} from "@tanstack/react-table"; +import { isEmpty } from "lodash-es"; +import { Plus, Trash } from "lucide-react"; +import { useMemo, useState, useTransition } from "react"; +import { useTranslation } from "react-i18next"; +import { match } from "ts-pattern"; + +import { useCreateEmailTemplate } from "~/api/mutations/admin/useCreateEmailTemplate"; +import { useDeleteEmailTemplate } from "~/api/mutations/admin/useDeleteEmailTemplate"; +import { useDeleteManyEmailTemplates } from "~/api/mutations/admin/useDeleteManyEmailTemplates"; +import { useAllEmailTemplates } from "~/api/queries/admin/useAllEmailTemplates"; +import { PageWrapper } from "~/components/PageWrapper"; +import { + ITEMS_PER_PAGE_OPTIONS, + Pagination, + type ItemsPerPageOption, +} from "~/components/Pagination/Pagination"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { cn } from "~/lib/utils"; +import { + type FilterConfig, + type FilterValue, + SearchFilter, +} from "~/modules/common/SearchFilter/SearchFilter"; +import { useLanguageStore } from "~/modules/Dashboard/Settings/Language/LanguageStore"; +import { setPageTitle } from "~/utils/setPageTitle"; + +import { getEmailTemplatesColumns } from "./emailTemplates.columns"; + +import type { MetaFunction } from "@remix-run/react"; +import type { EmailTemplateStatus } from "@repo/shared"; + +export const meta: MetaFunction = ({ matches }) => setPageTitle(matches, "pages.emailTemplates"); + +type FilterParams = { + name?: string; + status?: EmailTemplateStatus; +}; + +const DEFAULT_PER_PAGE: ItemsPerPageOption = 20; + +const EmailTemplatesPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isPending, startTransition] = useTransition(); + + const [filters, setFilters] = useState({}); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); + const [sorting, setSorting] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + const [lastSelectedRowIndex, setLastSelectedRowIndex] = useState(0); + + const queryParams = useMemo(() => ({ ...filters, page, perPage }), [filters, page, perPage]); + + const { data: response, isLoading, isError } = useAllEmailTemplates(queryParams); + const templates = response?.data; + const paginationInfo = response?.pagination; + + const { mutateAsync: createEmailTemplate, isPending: isCreating } = useCreateEmailTemplate(); + const { mutate: deleteEmailTemplate } = useDeleteEmailTemplate(); + const { mutate: deleteManyEmailTemplates } = useDeleteManyEmailTemplates(); + const uiLanguage = useLanguageStore((state) => state.language); + + const statusOptions = useMemo( + () => + Object.values(EMAIL_TEMPLATE_STATUSES).map((status) => ({ + value: status, + label: t(`emailTemplates.status.${status}`), + })), + [t], + ); + + const filterConfig: FilterConfig[] = [ + { + name: "name", + type: "text", + placeholder: t("emailTemplates.list.searchPlaceholder"), + testId: "email-templates-name-filter", + }, + { + name: "status", + type: "select", + placeholder: t("common.other.allStatuses"), + options: statusOptions, + testId: "email-templates-status-filter", + optionTestId: (option) => `email-templates-status-filter-option-${option.value}`, + }, + ]; + + const handleFilterChange = (name: string, value: FilterValue) => { + startTransition(() => { + setPage(1); + setFilters((prev) => ({ ...prev, [name]: value })); + }); + }; + + const handlePageChange = (nextPage: number) => { + startTransition(() => setPage(nextPage)); + }; + + const handlePerPageChange = (nextPerPage: string) => { + const parsed = Number(nextPerPage); + const nextValue = ( + ITEMS_PER_PAGE_OPTIONS.includes(parsed as (typeof ITEMS_PER_PAGE_OPTIONS)[number]) + ? parsed + : DEFAULT_PER_PAGE + ) as ItemsPerPageOption; + startTransition(() => { + setPage(1); + setPerPage(nextValue); + }); + }; + + const columns = useMemo( + () => getEmailTemplatesColumns({ lastSelectedRowIndex, setLastSelectedRowIndex, t }), + [lastSelectedRowIndex, t], + ); + + const table = useReactTable({ + getRowId: (row) => row.id, + data: templates ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onRowSelectionChange: setRowSelection, + state: { sorting, rowSelection }, + }); + + const selectedTemplateIds = table.getSelectedRowModel().rows.map((row) => row.original.id); + const bodyRows = table.getRowModel().rows; + const columnCount = columns.length; + const totalItems = paginationInfo?.totalItems ?? 0; + + const handleCreate = async () => { + const data = await createEmailTemplate({ + data: { + baseLanguage: uiLanguage, + availableLocales: [uiLanguage], + }, + }); + navigate(`/admin/email-templates/${data.data.id}`); + }; + + const handleDelete = () => { + if (selectedTemplateIds.length === 1) { + deleteEmailTemplate(selectedTemplateIds[0], { + onSuccess: () => setRowSelection({}), + }); + return; + } + + deleteManyEmailTemplates(selectedTemplateIds, { + onSuccess: () => setRowSelection({}), + }); + }; + + const deleteModalTitle = + selectedTemplateIds.length === 1 + ? t("emailTemplates.deleteModal.titleSingle") + : t("emailTemplates.deleteModal.titleMultiple"); + + const deleteModalDescription = + selectedTemplateIds.length === 1 + ? t("emailTemplates.deleteModal.descriptionSingle") + : t("emailTemplates.deleteModal.descriptionMultiple", { + count: selectedTemplateIds.length, + }); + + return ( + +
+
+

{t("emailTemplates.list.title")}

+

{t("emailTemplates.list.subHeader")}

+
+
+ +
+
+ +
+ + + + + + + {deleteModalTitle} + {deleteModalDescription} + + + + + + + + + + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, index) => ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {match({ isLoading, isError, isEmpty: bodyRows.length === 0 }) + .with({ isLoading: true }, () => ( + + + {t("emailTemplates.list.loading")} + + + )) + .with({ isError: true }, () => ( + + + {t("emailTemplates.list.loadFailed")} + + + )) + .with({ isEmpty: true }, () => ( + + + {t("emailTemplates.list.empty")} + + + )) + .otherwise(() => + bodyRows.map((row) => ( + navigate(`/admin/email-templates/${row.original.id}`)} + className="cursor-pointer hover:bg-neutral-100" + > + {row.getVisibleCells().map((cell, index) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )), + )} + +
+ {totalItems > 0 && ( + `email-templates-pagination-page-${page}`, + itemsPerPage: "email-templates-pagination-items-per-page", + itemsPerPageOption: (itemsPerPage) => + `email-templates-pagination-items-per-page-option-${itemsPerPage}`, + }} + /> + )} +
+
+ ); +}; + +export default EmailTemplatesPage; diff --git a/apps/web/app/modules/Admin/EmailTemplates/components/BuilderCanvas/EmailTemplateEditor.test.tsx b/apps/web/app/modules/Admin/EmailTemplates/components/BuilderCanvas/EmailTemplateEditor.test.tsx new file mode 100644 index 0000000000..b21ebe10e5 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/components/BuilderCanvas/EmailTemplateEditor.test.tsx @@ -0,0 +1,869 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, SUPPORTED_LANGUAGES } from "@repo/shared"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { AxiosError } from "axios"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiClient } from "~/api/api-client"; +import { renderWith } from "~/utils/testUtils"; + +vi.mock("~/api/api-client"); +vi.mock("@maily-to/core", () => ({ + Editor: vi.fn( + ({ extensions, onUpdate }: { extensions: unknown[]; onUpdate?: (editor: unknown) => void }) => { + (globalThis as Record).__capturedExtensions = extensions; + (globalThis as Record).__capturedOnUpdate = onUpdate; + const renderedUuids = ((globalThis as Record).__mailyRenderedUuids ?? [ + "aaaaaaaa-0000-4000-8000-000000000001", + ]) as string[]; + return ( + <> + {renderedUuids.map((uuid, index) => ( +
+ {index === 0 ? "Base text" : "New text"} +
+ ))} + + ); + }, + ), +})); +vi.mock("~/modules/Admin/EmailTemplates/tiptap/maily-styles", () => ({ + useMailyEditorStyles: vi.fn(), +})); +vi.mock("~/hooks/usePlatformLogo", () => ({ + usePlatformLogo: () => ({ + data: "https://tenant.example.com/logo.png", + isFetched: true, + }), +})); + +const mockToast = vi.fn(); +vi.mock("~/components/ui/use-toast", () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})); + +import { + EmailTemplateEditor, + buildInlineDiagnosticSpacingCss, + measureInlineDiagnosticAnchors, +} from "./EmailTemplateEditor"; + +import type { + EmailTemplateBlocks, + EmailTemplateDiagnostic, + EmailTemplateStrings, +} from "@repo/shared"; + +const emptyContent: EmailTemplateBlocks = { type: EMAIL_TEMPLATE_NODE_TYPES.DOC, content: [] }; +const emptyStrings: EmailTemplateStrings = {}; + +beforeEach(() => { + (globalThis as Record).__mailyRenderedUuids = [uuid1]; +}); + +const makeAxiosError = (status: number, message: string): AxiosError => + Object.assign(new AxiosError("request failed"), { + response: { status, data: { message }, headers: {}, config: {}, statusText: "" }, + }) as AxiosError; + +const captureOnImageUpload = () => { + renderWith({ withQuery: true }).render( + , + ); + const extensions: Array<{ options?: Record }> = ( + globalThis as Record + ).__capturedExtensions as never; + const imageUploadExt = extensions?.find((ext) => ext?.options && "onImageUpload" in ext.options); + const onImageUpload = imageUploadExt?.options?.onImageUpload as + | ((file: Blob) => Promise) + | undefined; + if (!onImageUpload) throw new Error("onImageUpload handler not found on ImageUploadExtension"); + return onImageUpload; +}; + +describe("EmailTemplateEditor — upload handler", () => { + it("calls emailTemplateImageControllerUpload and returns the proxied URL", async () => { + const proxiedUrl = "https://tenant.local/api/public/email-template-image/encoded-key"; + const mockedUpload = vi.fn().mockResolvedValue({ + data: { data: { url: proxiedUrl } }, + }); + (ApiClient.api as Record).emailTemplateImageControllerUpload = mockedUpload; + + const onImageUpload = captureOnImageUpload(); + const fakeFile = new Blob(["fake-image"], { type: "image/png" }); + let result: string | undefined; + + await act(async () => { + result = await onImageUpload(fakeFile); + }); + + await waitFor(() => { + expect(mockedUpload).toHaveBeenCalledTimes(1); + }); + expect(result).toBe(proxiedUrl); + }); + + it("throws and shows a toast when the upload fails", async () => { + const uploadError = new Error("Network error"); + const mockedUpload = vi.fn().mockRejectedValue(uploadError); + (ApiClient.api as Record).emailTemplateImageControllerUpload = mockedUpload; + + const onImageUpload = captureOnImageUpload(); + const fakeFile = new Blob(["fake-image"], { type: "image/png" }); + + await expect( + act(async () => { + await onImageUpload(fakeFile); + }), + ).rejects.toThrow("Network error"); + }); + + it("shows the tooLarge toast on 413 status", async () => { + const mockedUpload = vi.fn().mockRejectedValue(makeAxiosError(413, "Payload Too Large")); + (ApiClient.api as Record).emailTemplateImageControllerUpload = mockedUpload; + + const onImageUpload = captureOnImageUpload(); + const fakeFile = new Blob(["fake-image"], { type: "image/png" }); + + await expect( + act(async () => { + await onImageUpload(fakeFile); + }), + ).rejects.toThrow(); + + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ description: expect.stringContaining("too large") }), + ); + }); + + it("shows the tooLarge toast on 400 with expected size message", async () => { + const mockedUpload = vi + .fn() + .mockRejectedValue( + makeAxiosError(400, "Validation failed (expected size is less than 10485760)"), + ); + (ApiClient.api as Record).emailTemplateImageControllerUpload = mockedUpload; + + const onImageUpload = captureOnImageUpload(); + const fakeFile = new Blob(["fake-image"], { type: "image/png" }); + + await expect( + act(async () => { + await onImageUpload(fakeFile); + }), + ).rejects.toThrow(); + + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ description: expect.stringContaining("too large") }), + ); + }); + + it("shows the invalidType toast on 400 with files.toast.invalidFileType message", async () => { + const mockedUpload = vi + .fn() + .mockRejectedValue(makeAxiosError(400, "files.toast.invalidFileType")); + (ApiClient.api as Record).emailTemplateImageControllerUpload = mockedUpload; + + const onImageUpload = captureOnImageUpload(); + const fakeFile = new Blob(["fake-image"], { type: "application/pdf" }); + + await expect( + act(async () => { + await onImageUpload(fakeFile); + }), + ).rejects.toThrow(); + + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ description: expect.stringContaining("Invalid image type") }), + ); + }); +}); + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; + +const paraNode = (uuid: string, text: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { uuid }, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }], +}); + +const docNode = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +const makeEditorStub = (json: EmailTemplateBlocks) => + ({ getJSON: () => json }) as unknown as import("@tiptap/core").Editor; + +const captureOnUpdate = (props: { + language: (typeof SUPPORTED_LANGUAGES)[keyof typeof SUPPORTED_LANGUAGES]; + baseLanguage: (typeof SUPPORTED_LANGUAGES)[keyof typeof SUPPORTED_LANGUAGES]; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + onBlocksChange: ReturnType; + onStringsChange: ReturnType; +}) => { + renderWith({ withQuery: true }).render(); + const onUpdate = (globalThis as Record).__capturedOnUpdate as + | ((editor: unknown) => void) + | undefined; + if (!onUpdate) throw new Error("onUpdate handler not captured from MailyEditor"); + return onUpdate; +}; + +describe("EmailTemplateEditor — translation mode wiring", () => { + it("routes edits to onBlocksChange when language equals baseLanguage", () => { + const onBlocksChange = vi.fn(); + const onStringsChange = vi.fn(); + const blocks = docNode(paraNode(uuid1, "hello")); + + const onUpdate = captureOnUpdate({ + language: SUPPORTED_LANGUAGES.EN, + baseLanguage: SUPPORTED_LANGUAGES.EN, + blocks, + strings: emptyStrings, + onBlocksChange, + onStringsChange, + }); + + const newDoc = docNode(paraNode(uuid1, "updated")); + act(() => onUpdate(makeEditorStub(newDoc))); + + expect(onBlocksChange).toHaveBeenCalledTimes(1); + }); + + it("does not call onStringsChange when language equals baseLanguage", () => { + const onBlocksChange = vi.fn(); + const onStringsChange = vi.fn(); + const blocks = docNode(paraNode(uuid1, "hello")); + + const onUpdate = captureOnUpdate({ + language: SUPPORTED_LANGUAGES.EN, + baseLanguage: SUPPORTED_LANGUAGES.EN, + blocks, + strings: emptyStrings, + onBlocksChange, + onStringsChange, + }); + + const newDoc = docNode(paraNode(uuid1, "updated")); + act(() => onUpdate(makeEditorStub(newDoc))); + + expect(onStringsChange).not.toHaveBeenCalled(); + }); + + it("calls onStringsChange with extracted strings when in translation mode", () => { + const onBlocksChange = vi.fn(); + const onStringsChange = vi.fn(); + const blocks = docNode(paraNode(uuid1, "base text")); + + const onUpdate = captureOnUpdate({ + language: SUPPORTED_LANGUAGES.PL, + baseLanguage: SUPPORTED_LANGUAGES.EN, + blocks, + strings: emptyStrings, + onBlocksChange, + onStringsChange, + }); + + const translatedDoc = docNode(paraNode(uuid1, "Polish text")); + act(() => onUpdate(makeEditorStub(translatedDoc))); + + expect(onStringsChange).toHaveBeenCalledTimes(1); + const stringsArg = onStringsChange.mock.calls[0]?.[0] as Record; + expect(stringsArg[uuid1]).toBeDefined(); + }); + + it("also calls onBlocksChange in translation mode to propagate structural changes", () => { + const onBlocksChange = vi.fn(); + const onStringsChange = vi.fn(); + const blocks = docNode(paraNode(uuid1, "base text")); + + const onUpdate = captureOnUpdate({ + language: SUPPORTED_LANGUAGES.PL, + baseLanguage: SUPPORTED_LANGUAGES.EN, + blocks, + strings: emptyStrings, + onBlocksChange, + onStringsChange, + }); + + const translatedDoc = docNode(paraNode(uuid1, "translated")); + act(() => onUpdate(makeEditorStub(translatedDoc))); + + expect(onBlocksChange).toHaveBeenCalledTimes(1); + }); + + it("restores base content via applyStructuralChangesToBase when in translation mode", () => { + const onBlocksChange = vi.fn(); + const onStringsChange = vi.fn(); + const baseText = "original base text"; + const blocks = docNode(paraNode(uuid1, baseText)); + + const onUpdate = captureOnUpdate({ + language: SUPPORTED_LANGUAGES.PL, + baseLanguage: SUPPORTED_LANGUAGES.EN, + blocks, + strings: emptyStrings, + onBlocksChange, + onStringsChange, + }); + + const translatedDoc = docNode(paraNode(uuid1, "Polish text")); + act(() => onUpdate(makeEditorStub(translatedDoc))); + + const blocksArg = onBlocksChange.mock.calls[0]?.[0] as EmailTemplateBlocks; + const firstChild = blocksArg?.content?.[0]; + const firstText = firstChild?.content?.[0]?.text; + expect(firstText).toBe(baseText); + }); +}); + +describe("EmailTemplateEditor — inline diagnostics", () => { + it("renders inline diagnostics on initial editor render without waiting for user edits", async () => { + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }; + + renderWith({ withQuery: true }).render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Translation is empty")).toBeInTheDocument(); + }); + }); + + it("keeps existing empty translation diagnostics visible when their node is focused", async () => { + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }; + + renderWith({ withQuery: true }).render( + <> + + + , + ); + + const node = screen.getByText("Base text"); + node.focus(); + fireEvent.focusIn(node); + + await waitFor(() => { + expect(screen.getByText("Translation is empty")).toBeInTheDocument(); + }); + }); + + it("defers empty translation diagnostics for a newly created node until the user leaves it", async () => { + const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid2, + }; + const onBlocksChange = vi.fn(); + + const { rerender } = renderWith({ withQuery: true }).render( + <> + + + , + ); + + const onUpdate = (globalThis as Record).__capturedOnUpdate as + | ((editor: unknown) => void) + | undefined; + if (!onUpdate) throw new Error("onUpdate handler not captured from MailyEditor"); + + act(() => { + onUpdate(makeEditorStub(docNode(paraNode(uuid1, "Base text"), paraNode(uuid2, "")))); + }); + (globalThis as Record).__mailyRenderedUuids = [uuid1, uuid2]; + + rerender( + <> + + + , + ); + + expect(screen.queryByText("Translation is empty")).not.toBeInTheDocument(); + + const node = screen.getByText("New text"); + node.focus(); + fireEvent.focusIn(node); + + await waitFor(() => { + expect(screen.queryByText("Translation is empty")).not.toBeInTheDocument(); + }); + + const outsideButton = screen.getByRole("button", { name: "Outside editor" }); + outsideButton.focus(); + fireEvent.focusOut(node); + + await waitFor(() => { + expect(screen.getByText("Translation is empty")).toBeInTheDocument(); + }); + }); + + it("shows diagnostics for untouched siblings when multiple nodes are created", async () => { + const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + const uuid3 = "aaaaaaaa-0000-4000-8000-000000000003"; + const diagnosticsByNodeUuid = new Map([ + [ + uuid2, + [ + { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid2, + }, + ], + ], + [ + uuid3, + [ + { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid3, + }, + ], + ], + ]); + const onBlocksChange = vi.fn(); + + const { rerender } = renderWith({ withQuery: true }).render( + , + ); + + const onUpdate = (globalThis as Record).__capturedOnUpdate as + | ((editor: unknown) => void) + | undefined; + if (!onUpdate) throw new Error("onUpdate handler not captured from MailyEditor"); + + act(() => { + onUpdate( + makeEditorStub( + docNode(paraNode(uuid1, "Base text"), paraNode(uuid2, ""), paraNode(uuid3, "")), + ), + ); + }); + (globalThis as Record).__mailyRenderedUuids = [uuid1, uuid2, uuid3]; + + rerender( + , + ); + + const activeNewNode = document.querySelector(`[data-uuid="${uuid2}"]`); + if (!(activeNewNode instanceof HTMLElement)) throw new Error("New node not rendered"); + activeNewNode.focus(); + fireEvent.focusIn(activeNewNode); + + await waitFor(() => { + expect(screen.getAllByText("Translation is empty")).toHaveLength(1); + }); + }); + + it("shows diagnostics for a programmatically created node when focus never enters it", async () => { + const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid2, + }; + const onBlocksChange = vi.fn(); + + const { rerender } = renderWith({ withQuery: true }).render( + , + ); + + const onUpdate = (globalThis as Record).__capturedOnUpdate as + | ((editor: unknown) => void) + | undefined; + if (!onUpdate) throw new Error("onUpdate handler not captured from MailyEditor"); + + act(() => { + onUpdate(makeEditorStub(docNode(paraNode(uuid1, "Base text"), paraNode(uuid2, "")))); + }); + (globalThis as Record).__mailyRenderedUuids = [uuid1, uuid2]; + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText("Translation is empty")).toBeInTheDocument(); + }); + }); +}); + +describe("measureInlineDiagnosticAnchors", () => { + it("creates an anchor for a matching data-uuid node", () => { + const root = document.createElement("div"); + const node = document.createElement("p"); + node.dataset.uuid = uuid1; + root.appendChild(node); + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }; + + root.getBoundingClientRect = vi.fn(() => ({ + bottom: 100, + height: 100, + left: 10, + right: 110, + top: 0, + width: 100, + x: 10, + y: 0, + toJSON: vi.fn(), + })); + node.getBoundingClientRect = vi.fn(() => ({ + bottom: 40, + height: 20, + left: 20, + right: 80, + top: 20, + width: 60, + x: 20, + y: 20, + toJSON: vi.fn(), + })); + + const anchors = measureInlineDiagnosticAnchors(root, new Map([[uuid1, [diagnostic]]])); + + expect(anchors).toEqual([ + expect.objectContaining({ + uuid: uuid1, + diagnostics: [diagnostic], + top: 41, + left: 10, + width: 60, + height: 24, + }), + ]); + }); + + it("creates an anchor from the ProseMirror resolver when data-uuid is missing from the DOM", () => { + const root = document.createElement("div"); + const node = document.createElement("a"); + root.appendChild(node); + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "button_url_missing", + nodeUuid: uuid1, + }; + + root.getBoundingClientRect = vi.fn(() => ({ + bottom: 200, + height: 200, + left: 10, + right: 210, + top: 10, + width: 200, + x: 10, + y: 10, + toJSON: vi.fn(), + })); + node.getBoundingClientRect = vi.fn(() => ({ + bottom: 57, + height: 32, + left: 14, + right: 114, + top: 25, + width: 100, + x: 14, + y: 25, + toJSON: vi.fn(), + })); + + const anchors = measureInlineDiagnosticAnchors( + root, + new Map([[uuid1, [diagnostic]]]), + () => node, + ); + + expect(anchors).toEqual([ + expect.objectContaining({ + uuid: uuid1, + diagnostics: [diagnostic], + top: 48, + left: 4, + width: 100, + height: 24, + }), + ]); + }); + + it("does not create an anchor when a node is no longer targetable", () => { + const root = document.createElement("div"); + + const anchors = measureInlineDiagnosticAnchors(root, new Map([[uuid1, []]])); + + expect(anchors).toEqual([]); + }); + + it("moves later anchors down when diagnostic notes would overlap", () => { + const root = document.createElement("div"); + const firstNode = document.createElement("p"); + const secondNode = document.createElement("p"); + const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + firstNode.dataset.uuid = uuid1; + secondNode.dataset.uuid = uuid2; + root.append(firstNode, secondNode); + + root.getBoundingClientRect = vi.fn(() => ({ + bottom: 200, + height: 200, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: vi.fn(), + })); + firstNode.getBoundingClientRect = vi.fn(() => ({ + bottom: 40, + height: 20, + left: 0, + right: 100, + top: 20, + width: 100, + x: 0, + y: 20, + toJSON: vi.fn(), + })); + secondNode.getBoundingClientRect = vi.fn(() => ({ + bottom: 52, + height: 8, + left: 0, + right: 100, + top: 44, + width: 100, + x: 0, + y: 44, + toJSON: vi.fn(), + })); + + const anchors = measureInlineDiagnosticAnchors( + root, + new Map([ + [ + uuid1, + [ + { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }, + ], + ], + [ + uuid2, + [ + { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid2, + }, + ], + ], + ]), + ); + + expect(anchors[0].top).toBe(41); + expect(anchors[1].top).toBe(69); + }); + + it("uses rendered note height when reserving space", () => { + const root = document.createElement("div"); + const node = document.createElement("p"); + const note = document.createElement("div"); + node.dataset.uuid = uuid1; + note.dataset.inlineDiagnosticAnchor = uuid1; + root.append(node, note); + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }; + + root.getBoundingClientRect = vi.fn(() => ({ + bottom: 140, + height: 140, + left: 0, + right: 200, + top: 0, + width: 200, + x: 0, + y: 0, + toJSON: vi.fn(), + })); + node.getBoundingClientRect = vi.fn(() => ({ + bottom: 40, + height: 20, + left: 0, + right: 100, + top: 20, + width: 100, + x: 0, + y: 20, + toJSON: vi.fn(), + })); + note.getBoundingClientRect = vi.fn(() => ({ + bottom: 88, + height: 47, + left: 0, + right: 100, + top: 41, + width: 100, + x: 0, + y: 41, + toJSON: vi.fn(), + })); + + const anchors = measureInlineDiagnosticAnchors(root, new Map([[uuid1, [diagnostic]]])); + + expect(anchors[0].height).toBe(47); + }); + + it("keeps diagnostic note width inside the editor canvas", () => { + const root = document.createElement("div"); + const node = document.createElement("p"); + node.dataset.uuid = uuid1; + root.appendChild(node); + const diagnostic: EmailTemplateDiagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }; + + root.getBoundingClientRect = vi.fn(() => ({ + bottom: 100, + height: 100, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: vi.fn(), + })); + node.getBoundingClientRect = vi.fn(() => ({ + bottom: 40, + height: 20, + left: 80, + right: 160, + top: 20, + width: 80, + x: 80, + y: 20, + toJSON: vi.fn(), + })); + + const anchors = measureInlineDiagnosticAnchors(root, new Map([[uuid1, [diagnostic]]])); + + expect(anchors[0].left + anchors[0].width).toBeLessThanOrEqual(96); + }); + + it("builds scoped CSS that reserves space under nodes with diagnostic notes", () => { + const css = buildInlineDiagnosticSpacingCss([ + { + uuid: uuid1, + diagnostics: [ + { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + }, + ], + top: 48, + left: 0, + width: 240, + height: 24, + }, + ]); + + expect(css).toBe(`[data-uuid="${uuid1}"]{margin-bottom:44px!important;}`); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/components/BuilderCanvas/EmailTemplateEditor.tsx b/apps/web/app/modules/Admin/EmailTemplates/components/BuilderCanvas/EmailTemplateEditor.tsx new file mode 100644 index 0000000000..74816e5bfe --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/components/BuilderCanvas/EmailTemplateEditor.tsx @@ -0,0 +1,728 @@ +import { Editor as MailyEditor } from "@maily-to/core"; +import { + button, + columns, + divider, + footer, + heading1, + heading2, + heading3, + image, + section, + spacer, + text, +} from "@maily-to/core/blocks"; +import { ImageUploadExtension } from "@maily-to/core/extensions"; +import { ALLOWED_LESSON_IMAGE_FILE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; +import { AxiosError } from "axios"; +import { Braces, PanelTop } from "lucide-react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { match } from "ts-pattern"; + +import { ApiClient } from "~/api/api-client"; +import { toast } from "~/components/ui/use-toast"; +import { usePlatformLogo } from "~/hooks/usePlatformLogo"; +import Loader from "~/modules/common/Loader/Loader"; + +import { ButtonFallbackExtension } from "../../tiptap/button-fallback"; +import { DisableMailyVariableExtension } from "../../tiptap/disable-maily-variable"; +import { buildTranslatedPlaceholder } from "../../tiptap/localized-placeholder"; +import { LogoUrlLockExtension } from "../../tiptap/logo-url-lock"; +import { useMailyEditorStyles } from "../../tiptap/maily-styles"; +import { UuidExtension, stampContent } from "../../tiptap/uuid-extension"; +import { VariableHighlightExtension } from "../../tiptap/variable-highlight"; +import { applyStructuralChangesToBase } from "../../utils/applyStructuralChangesToBase"; +import { collectBasePlaceholders } from "../../utils/collectBasePlaceholders"; +import { extractStringsFromDoc } from "../../utils/extractStringsFromDoc"; +import { flattenForLanguage } from "../../utils/flattenForLanguage"; +import { insertVariablePlaceholder } from "../../utils/insertVariablePlaceholder"; +import { + TENANT_LOGO_PLACEHOLDER_SRC, + TENANT_LOGO_VARIABLE, + insertLogoHeader, + packTenantLogoInDoc, + resolveEffectiveLogoUrl, + resolveTenantLogoInDoc, +} from "../../utils/logoHeader"; +import { + InlineDiagnosticStack, + groupInlineDiagnostics, +} from "../InlineDiagnosticNote/InlineDiagnosticStack"; + +import type { BlockGroupItem, BlockItem } from "@maily-to/core/blocks"; +import type { + EmailTemplateBlocks, + EmailTemplateDiagnostic, + EmailTemplateNode, + EmailTemplateStrings, + SupportedLanguages, + TranslationFragment, +} from "@repo/shared"; +import type { Editor } from "@tiptap/core"; +import type { TFunction } from "i18next"; + +type EmailTemplateEditorProps = { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + language: SupportedLanguages; + baseLanguage: SupportedLanguages; + onBlocksChange: (blocks: EmailTemplateBlocks) => void; + onStringsChange: (nextForLanguage: Record) => void; + diagnosticsByNodeUuid?: Map; +}; + +type InlineDiagnosticAnchor = { + uuid: string; + diagnostics: EmailTemplateDiagnostic[]; + top: number; + left: number; + width: number; + height: number; +}; + +const INLINE_DIAGNOSTIC_ROW_HEIGHT = 24; +const INLINE_DIAGNOSTIC_GAP = 4; +const INLINE_DIAGNOSTIC_TOP_OFFSET = 1; +const INLINE_DIAGNOSTIC_EXTRA_NODE_SPACE = 16; +const INLINE_DIAGNOSTIC_CANVAS_PADDING = 4; + +const translateBlock = (t: TFunction, block: BlockItem, key: string): BlockItem => ({ + ...block, + title: t(`emailTemplates.builder.blocks.${key}.title`), + description: t(`emailTemplates.builder.blocks.${key}.description`), +}); + +const buildBlocks = (t: TFunction, tenantLogoUrl: string | null): BlockGroupItem[] => { + const logoHeader: BlockItem = { + title: t("emailTemplates.builder.blocks.logoHeader.title"), + description: t("emailTemplates.builder.blocks.logoHeader.description"), + searchTerms: ["logo", "header", "brand", "tenant"], + icon: , + command: insertLogoHeader(tenantLogoUrl), + }; + + const variablePlaceholder: BlockItem = { + title: t("emailTemplates.builder.blocks.variable.title"), + description: t("emailTemplates.builder.blocks.variable.description"), + searchTerms: ["variable", "placeholder", "var", "{{"], + icon: , + command: insertVariablePlaceholder(), + }; + + return [ + { + title: t("emailTemplates.builder.blocks.groups.text"), + commands: [ + translateBlock(t, text, "text"), + translateBlock(t, heading1, "heading1"), + translateBlock(t, heading2, "heading2"), + translateBlock(t, heading3, "heading3"), + variablePlaceholder, + ], + }, + { + title: t("emailTemplates.builder.blocks.groups.media"), + commands: [translateBlock(t, image, "image"), logoHeader], + }, + { + title: t("emailTemplates.builder.blocks.groups.structure"), + commands: [ + translateBlock(t, section, "section"), + translateBlock(t, columns, "columns"), + translateBlock(t, divider, "divider"), + translateBlock(t, spacer, "spacer"), + ], + }, + { + title: t("emailTemplates.builder.blocks.groups.interactive"), + commands: [translateBlock(t, button, "button")], + }, + { + title: t("emailTemplates.builder.blocks.groups.footer"), + commands: [translateBlock(t, footer, "footer")], + }, + ]; +}; + +type ResolveDiagnosticNode = (uuid: string) => HTMLElement | null; + +const findDiagnosticTarget = ( + root: HTMLElement, + uuid: string, + resolveDiagnosticNode?: ResolveDiagnosticNode, +): HTMLElement | undefined => { + const dataUuidTarget = Array.from(root.querySelectorAll("[data-uuid]")).find( + (element) => + element.dataset.uuid === uuid && + !element.closest("[data-inline-diagnostic-layer]"), + ); + if (dataUuidTarget) return dataUuidTarget; + + const resolvedTarget = resolveDiagnosticNode?.(uuid); + if (!resolvedTarget || !root.contains(resolvedTarget)) return undefined; + if (resolvedTarget.closest("[data-inline-diagnostic-layer]")) return undefined; + return resolvedTarget; +}; + +const findEditorNodeByUuid = ( + editor: Editor | null, + root: HTMLElement, + uuid: string, +): HTMLElement | null => { + if (!editor) return null; + + let targetPos: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (targetPos !== null) return false; + if (node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR] === uuid) { + targetPos = pos; + return false; + } + return true; + }); + + if (targetPos === null) return null; + const domNode = editor.view.nodeDOM(targetPos); + let element: HTMLElement | null = null; + if (domNode instanceof HTMLElement) { + element = domNode; + } else if (domNode instanceof Element) { + element = domNode as HTMLElement; + } else { + element = domNode?.parentElement ?? null; + } + + return element && root.contains(element) ? element : null; +}; + +const findClosestDiagnosticTarget = ( + root: HTMLElement, + target: Node | null, +): HTMLElement | null => { + const element = + target?.nodeType === Node.ELEMENT_NODE ? (target as Element) : target?.parentElement; + const diagnosticTarget = element?.closest("[data-uuid]"); + if (!diagnosticTarget || !root.contains(diagnosticTarget)) return null; + if (diagnosticTarget.closest("[data-inline-diagnostic-layer]")) return null; + return diagnosticTarget; +}; + +const getMutationTargetElement = (target: Node): Element | null => { + if (target.nodeType === Node.ELEMENT_NODE) return target as Element; + return target.parentElement; +}; + +const isInlineDiagnosticLayerMutation = (mutation: MutationRecord): boolean => { + const target = getMutationTargetElement(mutation.target); + return Boolean(target?.closest("[data-inline-diagnostic-layer]")); +}; + +const buildQuotedDataSelector = (attribute: string, value: string): string => + `[${attribute}=${JSON.stringify(value)}]`; + +const getRenderedDiagnosticHeight = (root: HTMLElement, uuid: string): number | undefined => { + const note = root.querySelector( + buildQuotedDataSelector("data-inline-diagnostic-anchor", uuid), + ); + const height = note?.getBoundingClientRect().height; + return height && height > 0 ? height : undefined; +}; + +const getFallbackDiagnosticHeight = (diagnostics: EmailTemplateDiagnostic[]): number => { + const displayItemCount = groupInlineDiagnostics(diagnostics).length; + return ( + displayItemCount * INLINE_DIAGNOSTIC_ROW_HEIGHT + + Math.max(0, displayItemCount - 1) * INLINE_DIAGNOSTIC_GAP + ); +}; + +const buildAnchoredNoteBox = (rootRect: DOMRect, targetRect: DOMRect) => { + const rawLeft = targetRect.left - rootRect.left; + const maximumWidth = Math.max( + 1, + rootRect.right - targetRect.left - INLINE_DIAGNOSTIC_CANVAS_PADDING, + ); + const width = Math.max(1, Math.min(targetRect.width, maximumWidth)); + const left = Math.max( + 0, + Math.min(rawLeft, rootRect.width - width - INLINE_DIAGNOSTIC_CANVAS_PADDING), + ); + return { left, width }; +}; + +export const measureInlineDiagnosticAnchors = ( + root: HTMLElement, + diagnosticsByNodeUuid: Map | undefined, + resolveDiagnosticNode?: ResolveDiagnosticNode, +): InlineDiagnosticAnchor[] => { + const anchors: InlineDiagnosticAnchor[] = []; + const rootRect = root.getBoundingClientRect(); + + for (const [uuid, diagnostics] of diagnosticsByNodeUuid ?? []) { + if (diagnostics.length === 0) continue; + const target = findDiagnosticTarget(root, uuid, resolveDiagnosticNode); + if (!target) continue; + + const targetRect = target.getBoundingClientRect(); + const noteBox = buildAnchoredNoteBox(rootRect, targetRect); + anchors.push({ + uuid, + diagnostics, + top: targetRect.bottom - rootRect.top + INLINE_DIAGNOSTIC_TOP_OFFSET, + left: noteBox.left, + width: noteBox.width, + height: getRenderedDiagnosticHeight(root, uuid) ?? getFallbackDiagnosticHeight(diagnostics), + }); + } + + return anchors + .sort((left, right) => left.top - right.top) + .reduce((positioned, anchor) => { + const previous = positioned[positioned.length - 1]; + const top = previous + ? Math.max(anchor.top, previous.top + previous.height + INLINE_DIAGNOSTIC_GAP) + : anchor.top; + positioned.push({ ...anchor, top }); + return positioned; + }, []); +}; + +const buildUuidSelector = (uuid: string): string => buildQuotedDataSelector("data-uuid", uuid); + +export const buildInlineDiagnosticSpacingCss = (anchors: InlineDiagnosticAnchor[]): string => + anchors + .map((anchor) => { + const space = Math.ceil( + anchor.height + INLINE_DIAGNOSTIC_GAP + INLINE_DIAGNOSTIC_EXTRA_NODE_SPACE, + ); + // Maily writes block spacing inline, so the reserved diagnostic gap must win that cascade. + return `${buildUuidSelector(anchor.uuid)}{margin-bottom:${space}px!important;}`; + }) + .join("\n"); + +const areDiagnosticsEqual = ( + left: EmailTemplateDiagnostic[], + right: EmailTemplateDiagnostic[], +): boolean => + left.length === right.length && + left.every((diagnostic, index) => { + const other = right[index]; + return ( + diagnostic.severity === other.severity && + diagnostic.reason === other.reason && + diagnostic.language === other.language && + diagnostic.nodeUuid === other.nodeUuid && + diagnostic.nodeType === other.nodeType && + diagnostic.detail === other.detail + ); + }); + +const areAnchorsEqual = ( + left: InlineDiagnosticAnchor[], + right: InlineDiagnosticAnchor[], +): boolean => + left.length === right.length && + left.every((anchor, index) => { + const other = right[index]; + return ( + anchor.uuid === other.uuid && + anchor.top === other.top && + anchor.left === other.left && + anchor.width === other.width && + anchor.height === other.height && + areDiagnosticsEqual(anchor.diagnostics, other.diagnostics) + ); + }); + +const getActiveDiagnosticNodeUuid = (root: HTMLElement): string | null => { + if (!root.contains(root.ownerDocument.activeElement)) return null; + const selection = root.ownerDocument.getSelection(); + const selectedTarget = selection ? findClosestDiagnosticTarget(root, selection.anchorNode) : null; + const activeTarget = findClosestDiagnosticTarget(root, root.ownerDocument.activeElement); + return selectedTarget?.dataset.uuid ?? activeTarget?.dataset.uuid ?? null; +}; + +const filterDeferredDiagnostics = ( + diagnosticsByNodeUuid: Map | undefined, + activeNodeUuid: string | null, + deferredNodeUuids: Set, + pendingNodeUuids: Set, +): Map | undefined => { + if ( + !diagnosticsByNodeUuid || + (pendingNodeUuids.size === 0 && (!activeNodeUuid || !deferredNodeUuids.has(activeNodeUuid))) + ) { + return diagnosticsByNodeUuid; + } + let changed = false; + const next = new Map(); + for (const [uuid, diagnostics] of diagnosticsByNodeUuid) { + const shouldDefer = + pendingNodeUuids.has(uuid) || (uuid === activeNodeUuid && deferredNodeUuids.has(uuid)); + const visibleDiagnostics = shouldDefer + ? diagnostics.filter((diagnostic) => diagnostic.reason !== "empty_translation") + : diagnostics; + if (visibleDiagnostics.length !== diagnostics.length) changed = true; + next.set(uuid, visibleDiagnostics); + } + return changed ? next : diagnosticsByNodeUuid; +}; + +const collectTemplateNodeUuids = (blocks: EmailTemplateBlocks): Set => { + const uuids = new Set(); + const walk = (node: EmailTemplateNode) => { + const uuid = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof uuid === "string" && uuid.length > 0) uuids.add(uuid); + node.content?.forEach(walk); + }; + walk(blocks); + return uuids; +}; + +export const EmailTemplateEditor = (props: EmailTemplateEditorProps) => { + const { data: tenantLogoUrl, isFetched } = usePlatformLogo(); + if (!isFetched) return ; + return ( + + ); +}; + +const EmailTemplateEditorInner = ({ + blocks, + strings, + language, + baseLanguage, + onBlocksChange, + onStringsChange, + diagnosticsByNodeUuid, + logoUrl, +}: EmailTemplateEditorProps & { logoUrl: string }) => { + const { t } = useTranslation(); + useMailyEditorStyles(); + const isBase = language === baseLanguage; + const editorRootRef = useRef(null); + const editorRef = useRef(null); + const activeNodeSyncFrameRef = useRef(null); + const knownNodeUuidsRef = useRef | null>(null); + const pendingDeferredNodeUuidsRef = useRef>(new Set()); + knownNodeUuidsRef.current ??= collectTemplateNodeUuids(blocks); + const [editorUpdateCount, setEditorUpdateCount] = useState(0); + const [activeDiagnosticNodeUuid, setActiveDiagnosticNodeUuid] = useState(null); + const [deferredNewNodeUuids, setDeferredNewNodeUuids] = useState>(() => new Set()); + const [pendingDeferredNodeUuids, setPendingDeferredNodeUuids] = useState>( + () => new Set(), + ); + const [diagnosticAnchors, setDiagnosticAnchors] = useState([]); + const [diagnosticBottomPadding, setDiagnosticBottomPadding] = useState(32); + const visibleDiagnosticsByNodeUuid = useMemo( + () => + filterDeferredDiagnostics( + diagnosticsByNodeUuid, + activeDiagnosticNodeUuid, + deferredNewNodeUuids, + pendingDeferredNodeUuids, + ), + [ + activeDiagnosticNodeUuid, + deferredNewNodeUuids, + diagnosticsByNodeUuid, + pendingDeferredNodeUuids, + ], + ); + const diagnosticSpacingCss = useMemo( + () => buildInlineDiagnosticSpacingCss(diagnosticAnchors), + [diagnosticAnchors], + ); + + const basePlaceholdersRef = useRef>({}); + basePlaceholdersRef.current = useMemo(() => collectBasePlaceholders(blocks), [blocks]); + + const logoUrlsRef = useRef([]); + logoUrlsRef.current = [TENANT_LOGO_VARIABLE, TENANT_LOGO_PLACEHOLDER_SRC, logoUrl]; + + const initialContent = useMemo( + () => { + const stamped = stampContent(blocks as never) as EmailTemplateBlocks; + const flattened = flattenForLanguage({ + blocks: stamped, + strings, + language, + baseLanguage, + }); + return resolveTenantLogoInDoc(flattened, logoUrl); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [language], + ); + + const blockPalette = useMemo(() => buildBlocks(t, logoUrl), [t, logoUrl]); + + const editorExtensions = useMemo(() => { + const uploadEmailTemplateImage = async (file: Blob): Promise => { + const formData = new FormData(); + formData.append("file", file, "email-template-image"); + + try { + const response = await ApiClient.api.emailTemplateImageControllerUpload( + formData as unknown as { file: File }, + { + headers: { "Content-Type": "multipart/form-data" }, + transformRequest: () => formData, + }, + ); + return response.data.data.url; + } catch (error) { + const status = error instanceof AxiosError ? error.response?.status : undefined; + const message = + error instanceof AxiosError + ? (error.response?.data as { message?: string } | undefined)?.message + : undefined; + + const toastKey = match({ status, message }) + .when( + ({ status: s }) => s === 413, + () => "emailTemplates.image.tooLarge" as const, + ) + .when( + ({ status: s, message: m }) => + s === 400 && typeof m === "string" && m.includes("expected size"), + () => "emailTemplates.image.tooLarge" as const, + ) + .when( + ({ status: s, message: m }) => + s === 400 && typeof m === "string" && m.includes("files.toast.invalidFileType"), + () => "emailTemplates.image.invalidType" as const, + ) + .otherwise(() => "emailTemplates.image.uploadFailed" as const); + + toast({ description: t(toastKey), variant: "destructive" }); + throw error; + } + }; + + const getBasePlaceholder = isBase + ? undefined + : (uuid: string) => basePlaceholdersRef.current[uuid] ?? null; + + return [ + UuidExtension, + ButtonFallbackExtension, + VariableHighlightExtension, + DisableMailyVariableExtension, + buildTranslatedPlaceholder(t, getBasePlaceholder), + ImageUploadExtension.configure({ + allowedMimeTypes: ALLOWED_LESSON_IMAGE_FILE_TYPES, + onImageUpload: uploadEmailTemplateImage, + }), + LogoUrlLockExtension.configure({ + getLogoUrls: () => logoUrlsRef.current, + }), + ]; + }, [t, isBase]); + + const syncActiveDiagnosticNode = useCallback(() => { + const root = editorRootRef.current; + const nextActiveNodeUuid = root ? getActiveDiagnosticNodeUuid(root) : null; + const pendingNodeUuids = pendingDeferredNodeUuidsRef.current; + if (pendingNodeUuids.size > 0) { + setDeferredNewNodeUuids((deferred) => { + const next = new Set(deferred); + pendingNodeUuids.forEach((uuid) => { + if (uuid !== nextActiveNodeUuid) next.delete(uuid); + }); + return next.size === deferred.size ? deferred : next; + }); + pendingDeferredNodeUuidsRef.current = new Set(); + setPendingDeferredNodeUuids(pendingDeferredNodeUuidsRef.current); + } + setActiveDiagnosticNodeUuid((current) => { + if (current && current !== nextActiveNodeUuid) { + setDeferredNewNodeUuids((deferred) => { + if (!deferred.has(current)) return deferred; + const next = new Set(deferred); + next.delete(current); + return next; + }); + } + return current === nextActiveNodeUuid ? current : nextActiveNodeUuid; + }); + }, []); + + const scheduleActiveDiagnosticNodeSync = useCallback(() => { + if (activeNodeSyncFrameRef.current !== null) { + cancelAnimationFrame(activeNodeSyncFrameRef.current); + } + activeNodeSyncFrameRef.current = requestAnimationFrame(() => { + activeNodeSyncFrameRef.current = null; + syncActiveDiagnosticNode(); + }); + }, [syncActiveDiagnosticNode]); + + const handleUpdate = (editor: Editor) => { + editorRef.current = editor; + setEditorUpdateCount((count) => count + 1); + const doc = editor.getJSON() as EmailTemplateBlocks; + const packed = packTenantLogoInDoc(doc, logoUrl); + const nextNodeUuids = collectTemplateNodeUuids(packed); + const previousNodeUuids = knownNodeUuidsRef.current ?? collectTemplateNodeUuids(blocks); + const newNodeUuids = Array.from(nextNodeUuids).filter((uuid) => !previousNodeUuids.has(uuid)); + knownNodeUuidsRef.current = nextNodeUuids; + if (newNodeUuids.length > 0) { + setPendingDeferredNodeUuids((current) => { + const next = new Set(current); + newNodeUuids.forEach((uuid) => next.add(uuid)); + pendingDeferredNodeUuidsRef.current = next; + return next; + }); + setDeferredNewNodeUuids((current) => { + const next = new Set(current); + newNodeUuids.forEach((uuid) => next.add(uuid)); + return next; + }); + scheduleActiveDiagnosticNodeSync(); + } + if (isBase) { + onBlocksChange(packed); + } else { + onStringsChange(extractStringsFromDoc(doc)); + onBlocksChange(applyStructuralChangesToBase(packed, blocks)); + } + }; + + useLayoutEffect(() => { + const root = editorRootRef.current; + if (!root) { + setActiveDiagnosticNodeUuid(null); + return; + } + const scheduleSync = () => { + scheduleActiveDiagnosticNodeSync(); + }; + + root.addEventListener("focusin", scheduleSync); + root.addEventListener("focusout", scheduleSync); + root.addEventListener("keyup", scheduleSync); + root.addEventListener("mouseup", scheduleSync); + root.ownerDocument.addEventListener("selectionchange", scheduleSync); + scheduleSync(); + + return () => { + if (activeNodeSyncFrameRef.current !== null) { + cancelAnimationFrame(activeNodeSyncFrameRef.current); + activeNodeSyncFrameRef.current = null; + } + root.removeEventListener("focusin", scheduleSync); + root.removeEventListener("focusout", scheduleSync); + root.removeEventListener("keyup", scheduleSync); + root.removeEventListener("mouseup", scheduleSync); + root.ownerDocument.removeEventListener("selectionchange", scheduleSync); + }; + }, [scheduleActiveDiagnosticNodeSync]); + + useLayoutEffect(() => { + const root = editorRootRef.current; + if (!root) { + setDiagnosticAnchors([]); + return; + } + let frame: number | null = null; + const measure = () => { + frame = null; + const anchors = measureInlineDiagnosticAnchors(root, visibleDiagnosticsByNodeUuid, (uuid) => + findEditorNodeByUuid(editorRef.current, root, uuid), + ); + const overlayBottom = anchors.reduce( + (bottom, anchor) => Math.max(bottom, anchor.top + anchor.height), + 0, + ); + setDiagnosticAnchors((current) => (areAnchorsEqual(current, anchors) ? current : anchors)); + const nextBottomPadding = Math.max(32, overlayBottom - root.clientHeight + 48); + setDiagnosticBottomPadding((current) => + current === nextBottomPadding ? current : nextBottomPadding, + ); + }; + const scheduleMeasure = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }; + + // Maily/ProseMirror owns the editor DOM, so diagnostics stay in an overlay instead of + // inserting sidecar siblings that can be reconciled away or serialized into content. + measure(); + // The first pass creates anchors; the rAF pass reads the rendered note height after wrapping. + scheduleMeasure(); + + const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); + resizeObserver?.observe(root); + + const mutationObserver = + typeof MutationObserver === "undefined" + ? null + : new MutationObserver((mutations) => { + if (mutations.every(isInlineDiagnosticLayerMutation)) return; + scheduleMeasure(); + }); + mutationObserver?.observe(root, { + attributes: true, + attributeFilter: ["class", "data-uuid", "style"], + characterData: true, + childList: true, + subtree: true, + }); + + return () => { + if (frame !== null) cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + }; + }, [editorUpdateCount, language, visibleDiagnosticsByNodeUuid]); + + return ( +
+ {diagnosticSpacingCss && } + { + editorRef.current = editor; + }} + onUpdate={handleUpdate} + extensions={editorExtensions} + blocks={blockPalette} + config={{ + hasMenuBar: false, + bodyClassName: "mly:mt-0 mly:rounded-none mly:border-0 mly:bg-transparent mly:p-0", + }} + /> +
+ {diagnosticAnchors.map((anchor) => ( +
+ +
+ ))} +
+
+ ); +}; + +export default EmailTemplateEditor; diff --git a/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticNote.test.tsx b/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticNote.test.tsx new file mode 100644 index 0000000000..9ad13ee823 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticNote.test.tsx @@ -0,0 +1,124 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { renderWith } from "~/utils/testUtils"; + +import { InlineDiagnosticNote } from "./InlineDiagnosticNote"; +import { InlineDiagnosticStack } from "./InlineDiagnosticStack"; + +import type { EmailTemplateDiagnostic } from "@repo/shared"; + +const renderNote = (diagnostic: EmailTemplateDiagnostic) => + renderWith().render(); + +describe("InlineDiagnosticNote", () => { + it("uses warning classes for warning diagnostics", () => { + renderNote({ severity: "warning", reason: "footer_missing" }); + + expect(screen.getByText("Footer is missing").closest("div")).toHaveClass( + "border-yellow-200", + "bg-yellow-50", + "text-yellow-900", + ); + }); + + it("uses error classes for error diagnostics", () => { + renderNote({ severity: "error", reason: "button_label_missing" }); + + expect(screen.getByText("Button label is required").closest("div")).toHaveClass( + "border-red-200", + "bg-red-50", + "text-red-900", + ); + }); + + it("shows a language tag when the diagnostic has a language", () => { + renderNote({ + severity: "warning", + reason: "unchanged_from_base", + language: "pl", + }); + + expect(screen.getByText("[PL]")).toBeInTheDocument(); + }); + + it("shows the language tag for the current or base language too", () => { + renderNote({ + severity: "warning", + reason: "empty_translation", + language: "en", + }); + + expect(screen.getByText("[EN]")).toBeInTheDocument(); + }); + + it("renders the translated reason label and detail", () => { + renderNote({ + severity: "error", + reason: "invalid_url_protocol", + detail: "href: ftp:", + }); + + expect(screen.getByText("URL uses a disallowed protocol")).toBeInTheDocument(); + expect(screen.getByText("href: ftp:")).toBeInTheDocument(); + }); +}); + +describe("InlineDiagnosticStack", () => { + it("renders one note with all languages for matching translation diagnostics", () => { + renderWith().render( + , + ); + + expect(screen.getAllByText("Translation is empty")).toHaveLength(1); + expect(screen.getByText("[EN]")).toBeInTheDocument(); + expect(screen.getByText("[DE]")).toBeInTheDocument(); + expect(screen.getByText("[PL]")).toBeInTheDocument(); + }); + + it("keeps matching translation diagnostics separate when severities differ", () => { + renderWith().render( + , + ); + + expect(screen.getAllByText("Translation is empty")).toHaveLength(2); + expect(screen.getByText("[EN]").closest("div")).toHaveClass("border-red-200"); + expect(screen.getByText("[DE]").closest("div")).toHaveClass("border-yellow-200"); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticNote.tsx b/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticNote.tsx new file mode 100644 index 0000000000..0b9f6defb0 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticNote.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; + +import { cn } from "~/lib/utils"; + +import type { EmailTemplateDiagnostic, SupportedLanguages } from "@repo/shared"; + +type InlineDiagnosticNoteProps = { + diagnostic: EmailTemplateDiagnostic; + languages?: SupportedLanguages[]; +}; + +export const InlineDiagnosticNote = ({ diagnostic, languages }: InlineDiagnosticNoteProps) => { + const { t } = useTranslation(); + const isError = diagnostic.severity === "error"; + const languageTags = languages ?? (diagnostic.language ? [diagnostic.language] : []); + + return ( +
+ + {t(`emailTemplates.publishDiagnostics.reasons.${diagnostic.reason}`)} + + {diagnostic.detail && ( + + {diagnostic.detail} + + )} + {languageTags.length > 0 && ( + + {languageTags.map((language) => ( + + [{language.toUpperCase()}] + + ))} + + )} +
+ ); +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticStack.tsx b/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticStack.tsx new file mode 100644 index 0000000000..c850bfa1fe --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/components/InlineDiagnosticNote/InlineDiagnosticStack.tsx @@ -0,0 +1,69 @@ +import { InlineDiagnosticNote } from "./InlineDiagnosticNote"; + +import type { EmailTemplateDiagnostic, SupportedLanguages } from "@repo/shared"; + +type InlineDiagnosticStackProps = { + diagnostics: EmailTemplateDiagnostic[]; +}; + +type InlineDiagnosticDisplayItem = { + diagnostic: EmailTemplateDiagnostic; + languages?: SupportedLanguages[]; +}; + +const makeDiagnosticGroupKey = (diagnostic: EmailTemplateDiagnostic): string => + [ + diagnostic.severity, + diagnostic.reason, + diagnostic.nodeUuid ?? "", + diagnostic.nodeType ?? "", + diagnostic.detail ?? "", + ].join(":"); + +export const groupInlineDiagnostics = ( + diagnostics: EmailTemplateDiagnostic[], +): InlineDiagnosticDisplayItem[] => { + const items: InlineDiagnosticDisplayItem[] = []; + const byKey = new Map(); + + for (const diagnostic of diagnostics) { + if (!diagnostic.language) { + items.push({ diagnostic }); + continue; + } + + const key = makeDiagnosticGroupKey(diagnostic); + const existing = byKey.get(key); + if (!existing) { + const item: InlineDiagnosticDisplayItem = { + diagnostic, + languages: [diagnostic.language], + }; + byKey.set(key, item); + items.push(item); + continue; + } + if (!existing.languages?.includes(diagnostic.language)) { + existing.languages = [...(existing.languages ?? []), diagnostic.language]; + } + } + + return items; +}; + +export const InlineDiagnosticStack = ({ diagnostics }: InlineDiagnosticStackProps) => { + if (diagnostics.length === 0) return null; + const items = groupInlineDiagnostics(diagnostics); + + return ( +
+ {items.map(({ diagnostic, languages }, index) => ( + + ))} +
+ ); +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/components/SubjectInput/SubjectInput.tsx b/apps/web/app/modules/Admin/EmailTemplates/components/SubjectInput/SubjectInput.tsx new file mode 100644 index 0000000000..c5d955f2d8 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/components/SubjectInput/SubjectInput.tsx @@ -0,0 +1,79 @@ +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Placeholder } from "@tiptap/extension-placeholder"; +import { Text } from "@tiptap/extension-text"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { useEffect, useRef } from "react"; + +import { VariableHighlightExtension } from "../../tiptap/variable-highlight"; + +const SingleLineDocument = Document.extend({ content: "paragraph" }); + +const buildContent = (v: string) => + v + ? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: v }] }] } + : { type: "doc", content: [{ type: "paragraph" }] }; + +type SubjectInputProps = { + id?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + ariaLabel?: string; + testId?: string; +}; + +export const SubjectInput = ({ + id, + value, + onChange, + placeholder, + ariaLabel, + testId, +}: SubjectInputProps) => { + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const editor = useEditor( + { + extensions: [ + SingleLineDocument, + Paragraph, + Text, + VariableHighlightExtension, + Placeholder.configure({ placeholder: placeholder ?? "" }), + ], + content: buildContent(value), + editorProps: { + attributes: { + class: "email-subject-input block w-full text-sm text-neutral-900 focus:outline-none", + ...(id ? { id } : {}), + ...(ariaLabel ? { "aria-label": ariaLabel } : {}), + }, + handleKeyDown: (_view, event) => { + if (event.key === "Enter") { + event.preventDefault(); + return true; + } + return false; + }, + }, + onUpdate: ({ editor: e }) => { + onChangeRef.current(e.getText()); + }, + }, + [placeholder], + ); + + useEffect(() => { + if (!editor) return; + if (editor.getText() === value) return; + editor.commands.setContent(buildContent(value), false); + }, [editor, value]); + + return ( +
+ +
+ ); +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/emailTemplates.columns.tsx b/apps/web/app/modules/Admin/EmailTemplates/emailTemplates.columns.tsx new file mode 100644 index 0000000000..8c4c769903 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/emailTemplates.columns.tsx @@ -0,0 +1,143 @@ +import { format } from "date-fns"; + +import { Icon } from "~/components/Icon"; +import { languageOptions } from "~/components/LanguageSelector/languageOptions"; +import SortButton from "~/components/TableSortButton/TableSortButton"; +import { Badge } from "~/components/ui/badge"; +import { Checkbox } from "~/components/ui/checkbox"; +import { handleRowSelectionRange } from "~/utils/tableRangeSelection"; + +import type { EmailTemplateStatus } from "@repo/shared"; +import type { ColumnDef } from "@tanstack/react-table"; +import type { TFunction } from "i18next"; +import type { Dispatch, SetStateAction } from "react"; +import type { ListTemplatesResponse } from "~/api/generated-api"; + +export type EmailTemplateRow = ListTemplatesResponse["data"][number]; + +type GetEmailTemplatesColumnsOptions = { + lastSelectedRowIndex: number; + setLastSelectedRowIndex: Dispatch>; + t: TFunction; +}; + +const StatusBadge = ({ status, t }: { status: EmailTemplateStatus; t: TFunction }) => { + const label = t(`emailTemplates.status.${status}`); + + if (status === "published") { + return ( + + {label} + + ); + } + if (status === "draft") { + return ( + + {label} + + ); + } + return ( + + {label} + + ); +}; + +export const getEmailTemplatesColumns = ({ + lastSelectedRowIndex, + setLastSelectedRowIndex, + t, +}: GetEmailTemplatesColumnsOptions): ColumnDef[] => [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t("emailTemplates.list.columns.selectAll")} + /> + ), + cell: ({ row, table }) => ( + { + event.stopPropagation(); + handleRowSelectionRange({ + table, + event, + lastSelectedRowIndex, + setLastSelectedRowIndex, + id: row.id, + value: row.getIsSelected(), + }); + }} + /> + ), + enableSorting: false, + }, + { + accessorKey: "name", + header: ({ column }) => ( + column={column}> + {t("emailTemplates.list.columns.name")} + + ), + cell: ({ row }) =>
{row.original.name}
, + }, + { + accessorKey: "status", + header: ({ column }) => ( + column={column}> + {t("emailTemplates.list.columns.status")} + + ), + cell: ({ row }) => , + }, + { + accessorKey: "availableLocales", + header: t("emailTemplates.list.columns.languages"), + enableSorting: false, + cell: ({ row }) => { + const { baseLanguage, availableLocales } = row.original; + const sortedLocales = [...availableLocales].sort((a, b) => { + if (a === baseLanguage) return -1; + if (b === baseLanguage) return 1; + return 0; + }); + return ( +
+ {sortedLocales.map((locale) => { + const option = languageOptions.find((item) => item.key === locale); + if (!option) return null; + const isBase = locale === baseLanguage; + const label = t(option.translationKey); + return isBase ? ( + + + + ) : ( + + ); + })} +
+ ); + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + column={column}> + {t("emailTemplates.list.columns.updatedAt")} + + ), + cell: ({ row }) => row.original.updatedAt && format(new Date(row.original.updatedAt), "PPpp"), + }, +]; diff --git a/apps/web/app/modules/Admin/EmailTemplates/hooks/useEditEmailTemplateForm.tsx b/apps/web/app/modules/Admin/EmailTemplates/hooks/useEditEmailTemplateForm.tsx new file mode 100644 index 0000000000..5c5c25d16a --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/hooks/useEditEmailTemplateForm.tsx @@ -0,0 +1,50 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { useForm } from "react-hook-form"; + +import { useUpdateEmailTemplate } from "~/api/mutations/admin/useUpdateEmailTemplate"; + +import { editEmailTemplateFormSchema } from "../validators/editEmailTemplateFormSchema"; + +import type { EditEmailTemplateFormValues } from "../validators/editEmailTemplateFormSchema"; +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; +import type { GetTemplateResponse } from "~/api/generated-api"; + +type Template = GetTemplateResponse["data"]; + +const EMPTY_DOC: EmailTemplateBlocks = { type: EMAIL_TEMPLATE_NODE_TYPES.DOC, content: [] }; + +export const useEditEmailTemplateForm = (template: Template, onSuccess?: () => void) => { + const { mutateAsync: updateEmailTemplate, isPending } = useUpdateEmailTemplate(); + + const form = useForm({ + resolver: zodResolver(editEmailTemplateFormSchema), + mode: "onChange", + defaultValues: { + name: template.name, + baseLanguage: template.baseLanguage, + availableLocales: template.availableLocales, + subject: template.subject ?? {}, + blocks: (template.blocks as EmailTemplateBlocks | undefined) ?? EMPTY_DOC, + strings: (template.strings as EmailTemplateStrings | undefined) ?? {}, + }, + }); + + const onSubmit = async (values: EditEmailTemplateFormValues) => { + await updateEmailTemplate({ + id: template.id, + data: { + name: values.name, + baseLanguage: values.baseLanguage, + availableLocales: values.availableLocales, + subject: values.subject, + blocks: values.blocks, + strings: values.strings, + }, + }); + form.reset(values, { keepValues: true }); + onSuccess?.(); + }; + + return { form, onSubmit, isSubmitting: isPending }; +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/__tests__/uuid-extension.test.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/__tests__/uuid-extension.test.ts new file mode 100644 index 0000000000..6889b6aba3 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/__tests__/uuid-extension.test.ts @@ -0,0 +1,168 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { Editor } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Heading } from "@tiptap/extension-heading"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { describe, expect, it, beforeEach } from "vitest"; + +import { UuidExtension, stampContent } from "../uuid-extension"; + +const buildEditor = ( + content: object = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH }], + }, +) => + new Editor({ + extensions: [ + Document, + Paragraph, + Text, + Heading.configure({ levels: [1, 2, 3] }), + UuidExtension, + ], + content: stampContent(content as never), + }); + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const uuids = (editor: Editor) => { + const collected: (string | null | undefined)[] = []; + editor.state.doc.descendants((node) => { + if ( + node.type.name === EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH || + node.type.name === EMAIL_TEMPLATE_NODE_TYPES.HEADING + ) { + collected.push((node.attrs?.uuid ?? null) as string | null); + } + }); + return collected; +}; + +describe("stampContent (helper)", () => { + it("stamps a fresh uuid on tracked nodes missing one", () => { + const stamped = stampContent({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH }, + { type: EMAIL_TEMPLATE_NODE_TYPES.HEADING, attrs: { level: 1 } }, + ], + }); + expect(stamped.content?.[0]?.attrs?.uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(stamped.content?.[1]?.attrs?.uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(stamped.content?.[0]?.attrs?.uuid).not.toBe(stamped.content?.[1]?.attrs?.uuid); + }); + + it("replaces duplicate uuids with fresh ones", () => { + const dup = "00000000-0000-4000-8000-000000000000"; + const stamped = stampContent({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, attrs: { uuid: dup } }, + { type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, attrs: { uuid: dup } }, + ], + }); + expect(stamped.content?.[0]?.attrs?.uuid).toBe(dup); + expect(stamped.content?.[1]?.attrs?.uuid).not.toBe(dup); + expect(stamped.content?.[1]?.attrs?.uuid).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("preserves existing unique uuids", () => { + const a = "00000000-0000-4000-8000-00000000000a"; + const b = "00000000-0000-4000-8000-00000000000b"; + const stamped = stampContent({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, attrs: { uuid: a } }, + { type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, attrs: { uuid: b } }, + ], + }); + expect(stamped.content?.[0]?.attrs?.uuid).toBe(a); + expect(stamped.content?.[1]?.attrs?.uuid).toBe(b); + }); +}); + +describe("UuidExtension", () => { + let editor: Editor; + + beforeEach(() => { + editor = buildEditor(); + }); + + it("initial content is stamped (via stampContent helper)", () => { + const stamped = uuids(editor); + expect(stamped).toHaveLength(1); + expect(stamped[0]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("preserves uuids when unrelated nodes are inserted", () => { + const before = uuids(editor)[0]; + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "hello" }], + }); + const after = uuids(editor); + expect(after[0]).toBe(before); + expect(after[1]).toMatch(/^[0-9a-f-]{36}$/); + expect(after[1]).not.toBe(before); + }); + + it("mints a new uuid when a paste introduces a duplicate", () => { + const [firstUuid] = uuids(editor); + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { uuid: firstUuid }, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "dup" }], + }); + const after = uuids(editor); + expect(after).toHaveLength(2); + expect(after[0]).toBe(firstUuid); + expect(after[1]).not.toBe(firstUuid); + expect(after[1]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("keeps stable uuids across a round-trip through JSON (undo/redo pattern)", () => { + const before = uuids(editor); + editor.commands.setContent(editor.getJSON()); + const after = uuids(editor); + expect(after).toEqual(before); + }); + + it("keeps or fresh-stamps a uuid after a paragraph→heading type change", () => { + editor.commands.setNode(EMAIL_TEMPLATE_NODE_TYPES.HEADING, { level: 2 }); + const stamped = uuids(editor); + expect(stamped).toHaveLength(1); + expect(stamped[0]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("allows deleting a node without errors", () => { + editor.commands.insertContentAt(editor.state.doc.content.size, { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "second" }], + }); + const before = uuids(editor); + expect(before).toHaveLength(2); + + editor.commands.setContent({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH }], + }); + const after = uuids(editor); + expect(after).toHaveLength(1); + expect(after[0]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("onCreate stamps content that was not pre-stamped (after microtask)", async () => { + const bareEditor = new Editor({ + extensions: [Document, Paragraph, Text, UuidExtension], + content: { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH }], + }, + }); + await flushMicrotasks(); + const stamped = uuids(bareEditor); + expect(stamped[0]).toMatch(/^[0-9a-f-]{36}$/); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/__tests__/variable-highlight.test.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/__tests__/variable-highlight.test.ts new file mode 100644 index 0000000000..e04e3d45e2 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/__tests__/variable-highlight.test.ts @@ -0,0 +1,202 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { Editor } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { TextSelection } from "@tiptap/pm/state"; +import { describe, expect, it } from "vitest"; + +import { VariableHighlightExtension, variableHighlightPluginKey } from "../variable-highlight"; + +import type { DecorationSet } from "@tiptap/pm/view"; + +const buildEditor = (text = "") => + new Editor({ + extensions: [Document, Paragraph, Text, VariableHighlightExtension], + content: { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + content: text ? [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }] : undefined, + }, + ], + }, + }); + +const paragraphText = (editor: Editor): string => { + const first = editor.state.doc.firstChild; + return first?.textContent ?? ""; +}; + +const setCaret = (editor: Editor, pos: number) => { + const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)); + editor.view.dispatch(tr); +}; + +const decorationClassesAt = (editor: Editor, from: number, to: number): string[] => { + const set = variableHighlightPluginKey.getState(editor.state) as DecorationSet | undefined; + if (!set) return []; + return set + .find(from, to) + .flatMap((d) => + ((d as unknown as { type: { attrs: { class?: string } } }).type.attrs.class ?? "").split( + /\s+/, + ), + ) + .filter(Boolean); +}; + +const pressKey = (editor: Editor, key: string): boolean => { + const event = new KeyboardEvent("keydown", { key }); + return ( + (editor.view.someProp("handleKeyDown", (fn) => fn(editor.view, event)) as + | boolean + | undefined) ?? false + ); +}; + +const typeChar = (editor: Editor, ch: string): boolean => { + const { from, to } = editor.state.selection; + return ( + (editor.view.someProp("handleTextInput", (fn) => fn(editor.view, from, to, ch)) as + | boolean + | undefined) ?? false + ); +}; + +describe("VariableHighlightExtension - auto-close `{{`", () => { + it("expands `{{` into `{{}}` and drops the caret between the pairs", () => { + const editor = buildEditor(); + setCaret(editor, 1); + editor.commands.insertContent("{"); + const handled = typeChar(editor, "{"); + expect(handled).toBe(true); + expect(paragraphText(editor)).toBe("{{}}"); + expect(editor.state.selection.from).toBe(3); + }); + + it("does NOT auto-close when the next two chars are already `}}`", () => { + const editor = buildEditor("{{}}"); + setCaret(editor, 3); + const handled = typeChar(editor, "{"); + expect(handled).toBe(false); + }); + + it("leaves single `{` alone when there's no preceding brace", () => { + const editor = buildEditor("hello"); + setCaret(editor, 6); + const handled = typeChar(editor, "{"); + expect(handled).toBe(false); + }); +}); + +describe("VariableHighlightExtension - decoration state", () => { + it("marks `{{name}}` as a pill when the caret is outside", () => { + const editor = buildEditor("hi {{userName}} there"); + setCaret(editor, 1); + const classes = decorationClassesAt(editor, 4, 16); + expect(classes).toContain("email-variable-pill"); + expect(classes).not.toContain("email-variable-active"); + }); + + it("switches to active class when the caret is strictly inside the braces", () => { + const editor = buildEditor("hi {{userName}} there"); + setCaret(editor, 6); + const classes = decorationClassesAt(editor, 4, 16); + expect(classes).toContain("email-variable-active"); + expect(classes).not.toContain("email-variable-pill"); + }); + + it("treats caret exactly at the edges as OUTSIDE (pill state)", () => { + const editor = buildEditor("{{x}}"); + setCaret(editor, 1); + expect(decorationClassesAt(editor, 1, 6)).toContain("email-variable-pill"); + setCaret(editor, 6); + expect(decorationClassesAt(editor, 1, 6)).toContain("email-variable-pill"); + }); + + it("highlights empty `{{}}` as a variable too", () => { + const editor = buildEditor("{{}}"); + setCaret(editor, 1); + expect(decorationClassesAt(editor, 1, 5)).toContain("email-variable-pill"); + }); + + it("uses the invalid class when the name contains a disallowed char (space)", () => { + const editor = buildEditor("{{bad name}}"); + setCaret(editor, 1); + expect(decorationClassesAt(editor, 1, 13)).toContain("email-variable-pill-invalid"); + setCaret(editor, 6); + expect(decorationClassesAt(editor, 1, 13)).toContain("email-variable-active-invalid"); + }); + + it("treats empty `{{}}` as valid (no danger flash for one keystroke)", () => { + const editor = buildEditor("{{}}"); + setCaret(editor, 1); + const classes = decorationClassesAt(editor, 1, 5); + expect(classes).toContain("email-variable-pill"); + expect(classes).not.toContain("email-variable-pill-invalid"); + }); + + it("accepts allowed chars: letters, digits, `_`, `.`", () => { + const editor = buildEditor("{{user_1.name}}"); + setCaret(editor, 1); + const classes = decorationClassesAt(editor, 1, 16); + expect(classes).toContain("email-variable-pill"); + expect(classes).not.toContain("email-variable-pill-invalid"); + }); + + it("flags non-ASCII in the name as invalid", () => { + const editor = buildEditor("{{użytkownik}}"); + setCaret(editor, 1); + expect(decorationClassesAt(editor, 1, 15)).toContain("email-variable-pill-invalid"); + }); +}); + +describe("VariableHighlightExtension - keyboard atomicity", () => { + it("Backspace at the trailing edge deletes the whole `{{...}}`", () => { + const editor = buildEditor("before {{name}} after"); + setCaret(editor, 16); + const handled = pressKey(editor, "Backspace"); + expect(handled).toBe(true); + expect(paragraphText(editor)).toBe("before after"); + }); + + it("Delete at the leading edge deletes the whole `{{...}}`", () => { + const editor = buildEditor("before {{name}} after"); + setCaret(editor, 8); + const handled = pressKey(editor, "Delete"); + expect(handled).toBe(true); + expect(paragraphText(editor)).toBe("before after"); + }); + + it("Backspace inside an active pill still deletes one char (falls through)", () => { + const editor = buildEditor("{{name}}"); + setCaret(editor, 6); + const handled = pressKey(editor, "Backspace"); + expect(handled).toBe(false); + }); + + it("ArrowLeft at the trailing edge jumps to the leading edge (skips the pill)", () => { + const editor = buildEditor("a{{x}}b"); + setCaret(editor, 7); + const handled = pressKey(editor, "ArrowLeft"); + expect(handled).toBe(true); + expect(editor.state.selection.from).toBe(2); + }); + + it("ArrowRight at the leading edge jumps to the trailing edge (skips the pill)", () => { + const editor = buildEditor("a{{x}}b"); + setCaret(editor, 2); + const handled = pressKey(editor, "ArrowRight"); + expect(handled).toBe(true); + expect(editor.state.selection.from).toBe(7); + }); + + it("Backspace does nothing special when caret isn't adjacent to a pill", () => { + const editor = buildEditor("hello"); + setCaret(editor, 3); + const handled = pressKey(editor, "Backspace"); + expect(handled).toBe(false); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/button-fallback.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/button-fallback.ts new file mode 100644 index 0000000000..b150867b41 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/button-fallback.ts @@ -0,0 +1,85 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +const FALLBACK_ATTR = "fallbackText"; +const FALLBACK_DATA_ATTR = "data-fallback-text"; +const UUID_ATTR = "uuid"; + +const attrPluginKey = new PluginKey("email-template-button-fallback-attr"); +const decoPluginKey = new PluginKey("email-template-button-fallback-deco"); + +export const ButtonFallbackExtension = Extension.create({ + name: "emailTemplateButtonFallback", + + addGlobalAttributes() { + return [ + { + types: [EMAIL_TEMPLATE_NODE_TYPES.BUTTON], + attributes: { + [FALLBACK_ATTR]: { + default: null, + parseHTML: (element) => + element.getAttribute(FALLBACK_DATA_ATTR) === "true" ? "true" : null, + renderHTML: () => ({}), + keepOnSplit: true, + }, + }, + }, + ]; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: attrPluginKey, + appendTransaction: (transactions, oldState, newState) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + + const oldByUuid = new Map(); + oldState.doc.descendants((node) => { + if (node.type.name !== EMAIL_TEMPLATE_NODE_TYPES.BUTTON) return; + const uuid = node.attrs?.[UUID_ATTR] as string | undefined; + if (uuid) oldByUuid.set(uuid, (node.attrs?.text as string) ?? ""); + }); + + const clears: Array<{ pos: number; attrs: Record }> = []; + newState.doc.descendants((node, pos) => { + if (node.type.name !== EMAIL_TEMPLATE_NODE_TYPES.BUTTON) return; + if (node.attrs?.[FALLBACK_ATTR] !== "true") return; + const uuid = node.attrs?.[UUID_ATTR] as string | undefined; + if (!uuid) return; + const prev = oldByUuid.get(uuid); + if (prev !== undefined && prev !== (node.attrs?.text as string)) { + clears.push({ pos, attrs: { ...node.attrs, [FALLBACK_ATTR]: null } }); + } + }); + + if (!clears.length) return null; + const tr = newState.tr; + for (const { pos, attrs } of clears) tr.setNodeMarkup(pos, undefined, attrs); + return tr; + }, + }), + new Plugin({ + key: decoPluginKey, + props: { + decorations(state) { + const decos: Decoration[] = []; + state.doc.descendants((node, pos) => { + if (node.type.name !== EMAIL_TEMPLATE_NODE_TYPES.BUTTON) return; + if (node.attrs?.[FALLBACK_ATTR] !== "true") return; + decos.push( + Decoration.node(pos, pos + node.nodeSize, { [FALLBACK_DATA_ATTR]: "true" }), + ); + }); + return DecorationSet.create(state.doc, decos); + }, + }, + }), + ]; + }, +}); + +export default ButtonFallbackExtension; diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/disable-maily-variable.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/disable-maily-variable.ts new file mode 100644 index 0000000000..ad7364dedf --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/disable-maily-variable.ts @@ -0,0 +1,8 @@ +import { VariableExtension } from "@maily-to/core/extensions"; +export const DisableMailyVariableExtension = VariableExtension.configure({ + variables: [], + suggestion: { + char: "\0", + allow: () => false, + }, +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/localized-placeholder.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/localized-placeholder.ts new file mode 100644 index 0000000000..242cd66d92 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/localized-placeholder.ts @@ -0,0 +1,46 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { Placeholder } from "@tiptap/extension-placeholder"; + +import type { TFunction } from "i18next"; + +const STRUCTURAL_NODES: string[] = [ + EMAIL_TEMPLATE_NODE_TYPES.COLUMNS, + EMAIL_TEMPLATE_NODE_TYPES.COLUMN, + EMAIL_TEMPLATE_NODE_TYPES.SECTION, + "repeat", + "show", + "blockquote", +]; + +const TRANSLATABLE_PLACEHOLDER_NODES: string[] = [ + EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + EMAIL_TEMPLATE_NODE_TYPES.HEADING, + EMAIL_TEMPLATE_NODE_TYPES.FOOTER, +]; + +export type GetBasePlaceholder = (uuid: string) => string | null; + +export const buildTranslatedPlaceholder = (t: TFunction, getBasePlaceholder?: GetBasePlaceholder) => + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + if (getBasePlaceholder && TRANSLATABLE_PLACEHOLDER_NODES.includes(node.type.name)) { + const uuid = (node.attrs as { uuid?: string }).uuid; + if (uuid) { + const base = getBasePlaceholder(uuid); + if (base) return base; + } + } + if (node.type.name === EMAIL_TEMPLATE_NODE_TYPES.HEADING) { + const level = (node.attrs as { level?: number }).level ?? 1; + return t("emailTemplates.builder.placeholder.heading", { level }); + } + if (node.type.name === "htmlCodeBlock") { + return t("emailTemplates.builder.placeholder.htmlCode"); + } + if (STRUCTURAL_NODES.includes(node.type.name)) { + return ""; + } + return t("emailTemplates.builder.placeholder.writeSomethingOrSlash"); + }, + }); diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/logo-url-lock.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/logo-url-lock.ts new file mode 100644 index 0000000000..af771e816e --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/logo-url-lock.ts @@ -0,0 +1,82 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { Extension } from "@tiptap/core"; +import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state"; + +export const LOGO_LOCKED_CLASS = "email-template-logo-selected"; + +type LogoUrlLockOptions = { + getLogoUrls: () => string[]; +}; + +const readSrc = (attrs: Record | null | undefined): string | null => { + const src = attrs?.src; + return typeof src === "string" ? src : null; +}; + +export const LogoUrlLockExtension = Extension.create({ + name: "emailTemplateLogoUrlLock", + + addOptions() { + return { getLogoUrls: () => [] }; + }, + + addProseMirrorPlugins() { + const getLogoUrls = () => new Set(this.options.getLogoUrls()); + + return [ + new Plugin({ + key: new PluginKey("email-template-logo-url-lock"), + + appendTransaction(transactions, oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) return null; + + const logoUrls = getLogoUrls(); + if (logoUrls.size === 0) return null; + + const tr = newState.tr; + let modified = false; + + newState.doc.descendants((newNode, pos) => { + if (newNode.type.name !== EMAIL_TEMPLATE_NODE_TYPES.IMAGE) return; + + const oldNode = oldState.doc.nodeAt(pos); + if (!oldNode || oldNode.type.name !== EMAIL_TEMPLATE_NODE_TYPES.IMAGE) return; + + const oldSrc = readSrc(oldNode.attrs); + const newSrc = readSrc(newNode.attrs); + if (oldSrc === newSrc) return; + if (!oldSrc || !logoUrls.has(oldSrc)) return; + + tr.setNodeAttribute(pos, "src", oldSrc); + modified = true; + }); + + return modified ? tr : null; + }, + + view(editorView) { + const findContainer = () => editorView.dom.closest("#mly-editor"); + + const applyClass = () => { + const container = findContainer(); + if (!container) return; + const logoUrls = getLogoUrls(); + const { selection } = editorView.state; + const node = selection instanceof NodeSelection ? selection.node : null; + const isLogo = + node?.type.name === EMAIL_TEMPLATE_NODE_TYPES.IMAGE && + logoUrls.has(readSrc(node.attrs) ?? ""); + container.classList.toggle(LOGO_LOCKED_CLASS, isLogo); + }; + applyClass(); + return { + update: applyClass, + destroy() { + findContainer()?.classList.remove(LOGO_LOCKED_CLASS); + }, + }; + }, + }), + ]; + }, +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/maily-styles.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/maily-styles.ts new file mode 100644 index 0000000000..d63fbb313a --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/maily-styles.ts @@ -0,0 +1,101 @@ +import mailyStyleContent from "@maily-to/core/style.css?raw"; +import { useEffect } from "react"; + +const MAILY_STYLE_TAG_ID = "maily-editor-styles"; + +const unwrapUtilitiesLayer = (css: string): string => { + const match = css.match(/@layer\s+utilities\s*\{/); + if (!match || match.index === undefined) return css; + const openStart = match.index; + const bodyStart = openStart + match[0].length; + let depth = 1; + let i = bodyStart; + while (i < css.length && depth > 0) { + const c = css[i]; + if (c === "{") depth++; + else if (c === "}") depth--; + i++; + } + if (depth !== 0) return css; + return css.slice(0, openStart) + css.slice(bodyStart, i - 1) + css.slice(i); +}; +const MAILY_OVERRIDES = ` +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url('https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.19') format('woff2'); +} + +#mly-editor { + margin-top: 1rem; + margin-left: 0.5rem; +} + +#mly-editor .ProseMirror { + font-family: 'Inter', sans-serif; +} +#mly-editor .ProseMirror h1 { font-size: 36px; line-height: 40px; font-weight: 800; } +#mly-editor .ProseMirror h2 { font-size: 30px; line-height: 36px; font-weight: 700; } +#mly-editor .ProseMirror h3 { font-size: 24px; line-height: 38px; font-weight: 600; } +#mly-editor .ProseMirror p, +#mly-editor .ProseMirror li { font-size: 15px; line-height: 26.25px; color: #374151; } +#mly-editor .ProseMirror a[data-type="button"] { + font-size: 14px; + font-weight: 500; +} +#mly-editor .ProseMirror [data-type="footer"] { + font-size: 14px; + line-height: 24px; + color: #64748B; +} + +#mly-editor .ProseMirror .is-empty::before { + content: attr(data-placeholder); + float: left; + color: #94a3b8; + pointer-events: none; + height: 0; + white-space: pre-wrap; +} + +#mly-editor .ProseMirror [data-fallback-text="true"] button { + opacity: 0.4; +} + +.hide-number-controls::-webkit-outer-spin-button, +.hide-number-controls::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.hide-number-controls[type="number"] { + -moz-appearance: textfield; + appearance: textfield; +} + +button:has(> svg.lucide-eye) { + display: none !important; +} +div.mly\\:w-px:has(+ button > svg.lucide-eye) { + display: none !important; +} + +#mly-editor .ProseMirror .mly-image-drop-zone { + width: fit-content; + max-width: 100%; +} + +#mly-editor.email-template-logo-selected button:has(> svg.lucide-image-down) { + display: none !important; +} +`; + +export const useMailyEditorStyles = (): void => { + useEffect(() => { + if (document.getElementById(MAILY_STYLE_TAG_ID)) return; + const style = document.createElement("style"); + style.id = MAILY_STYLE_TAG_ID; + style.textContent = unwrapUtilitiesLayer(mailyStyleContent) + MAILY_OVERRIDES; + document.head.appendChild(style); + }, []); +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/uuid-extension.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/uuid-extension.ts new file mode 100644 index 0000000000..453a692a32 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/uuid-extension.ts @@ -0,0 +1,132 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { v4 as uuidv4 } from "uuid"; + +import type { JSONContent } from "@tiptap/core"; + +export const UUID_NODE_TYPES = [ + EMAIL_TEMPLATE_NODE_TYPES.HEADING, + EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + EMAIL_TEMPLATE_NODE_TYPES.FOOTER, + EMAIL_TEMPLATE_NODE_TYPES.SECTION, + EMAIL_TEMPLATE_NODE_TYPES.COLUMNS, + EMAIL_TEMPLATE_NODE_TYPES.COLUMN, + EMAIL_TEMPLATE_NODE_TYPES.DIVIDER, + EMAIL_TEMPLATE_NODE_TYPES.SPACER, + EMAIL_TEMPLATE_NODE_TYPES.HORIZONTAL_RULE, +] as const; + +const UUID_ATTR = "uuid"; +const UUID_DATA_ATTR = "data-uuid"; + +export const uuidPluginKey = new PluginKey("email-template-uuid"); + +const makeUuid = () => uuidv4(); + +const isTracked = (typeName: string) => (UUID_NODE_TYPES as readonly string[]).includes(typeName); + +const stampMissingAndDuplicates = ( + doc: import("@tiptap/pm/model").Node, +): Array<{ pos: number; uuid: string }> => { + const seen = new Set(); + const positions: Array<{ pos: number; uuid: string | null }> = []; + doc.descendants((node, pos) => { + if (!isTracked(node.type.name)) return; + const uuidAttr = (node.attrs?.[UUID_ATTR] ?? null) as string | null; + positions.push({ pos, uuid: uuidAttr }); + }); + + const updates: Array<{ pos: number; uuid: string }> = []; + for (const { pos, uuid } of positions) { + if (!uuid || seen.has(uuid)) { + const fresh = makeUuid(); + seen.add(fresh); + updates.push({ pos, uuid: fresh }); + } else { + seen.add(uuid); + } + } + return updates; +}; + +export const UuidExtension = Extension.create({ + name: "emailTemplateUuid", + + addGlobalAttributes() { + return [ + { + types: [...UUID_NODE_TYPES], + attributes: { + [UUID_ATTR]: { + default: null, + parseHTML: (element) => element.getAttribute(UUID_DATA_ATTR), + renderHTML: (attrs) => { + const value = attrs[UUID_ATTR]; + return value ? { [UUID_DATA_ATTR]: value } : {}; + }, + keepOnSplit: true, + }, + }, + }, + ]; + }, + + onCreate() { + const updates = stampMissingAndDuplicates(this.editor.state.doc); + if (!updates.length) return; + const tr = this.editor.state.tr; + for (const { pos, uuid } of updates) { + const node = tr.doc.nodeAt(pos); + if (!node) continue; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, [UUID_ATTR]: uuid }); + } + if (tr.steps.length) this.editor.view.dispatch(tr); + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: uuidPluginKey, + appendTransaction: (transactions, _oldState, newState) => { + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return null; + + const updates = stampMissingAndDuplicates(newState.doc); + if (!updates.length) return null; + + const tr = newState.tr; + for (const { pos, uuid } of updates) { + const node = tr.doc.nodeAt(pos); + if (!node) continue; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, [UUID_ATTR]: uuid }); + } + return tr.steps.length ? tr : null; + }, + }), + ]; + }, +}); + +export const stampContent = (json: JSONContent): JSONContent => { + const seen = new Set(); + + const walk = (node: JSONContent): JSONContent => { + const attrs = { ...(node.attrs ?? {}) }; + if (node.type && isTracked(node.type)) { + const existing = attrs[UUID_ATTR] as string | null | undefined; + if (!existing || seen.has(existing)) { + attrs[UUID_ATTR] = makeUuid(); + } + seen.add(attrs[UUID_ATTR] as string); + } + const content = node.content?.map(walk); + return { ...node, attrs, ...(content ? { content } : {}) }; + }; + + return walk(json); +}; + +export default UuidExtension; diff --git a/apps/web/app/modules/Admin/EmailTemplates/tiptap/variable-highlight.ts b/apps/web/app/modules/Admin/EmailTemplates/tiptap/variable-highlight.ts new file mode 100644 index 0000000000..c836f42628 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/tiptap/variable-highlight.ts @@ -0,0 +1,161 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +export const variableHighlightPluginKey = new PluginKey("email-template-variable-highlight"); + +const VARIABLE_REGEX = /\{\{[^{}]*\}\}/g; + +const VALID_NAME = /^[A-Za-z0-9_.]*$/; + +const ACTIVE_VALID_CLASS = "email-variable-active !text-primary-700 font-medium"; +const ACTIVE_INVALID_CLASS = "email-variable-active-invalid !text-error-600 font-medium"; +const PILL_VALID_CLASS = + "email-variable-pill inline-block px-1.5 py-px mx-px rounded-md bg-primary-50 !text-primary-700 font-medium leading-tight caret-transparent"; +const PILL_INVALID_CLASS = + "email-variable-pill-invalid inline-block px-1.5 py-px mx-px rounded-md bg-error-50 !text-error-600 font-medium leading-tight caret-transparent"; + +type Range = { from: number; to: number; valid: boolean }; + +const findVariableRanges = (doc: ProseMirrorNode): Range[] => { + const ranges: Range[] = []; + doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + const text = node.text; + VARIABLE_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = VARIABLE_REGEX.exec(text)) !== null) { + const from = pos + match.index; + const to = from + match[0].length; + const name = match[0].slice(2, -2); + ranges.push({ from, to, valid: VALID_NAME.test(name) }); + } + }); + return ranges; +}; + +const isCaretActive = (range: Range, caret: number): boolean => + caret > range.from && caret < range.to; + +const rangeEndingAt = (ranges: Range[], pos: number): Range | null => + ranges.find((r) => r.to === pos) ?? null; + +const rangeStartingAt = (ranges: Range[], pos: number): Range | null => + ranges.find((r) => r.from === pos) ?? null; + +const rangeContaining = (ranges: Range[], pos: number): Range | null => + ranges.find((r) => pos > r.from && pos < r.to) ?? null; + +const buildDecorations = (state: EditorState): DecorationSet => { + const ranges = findVariableRanges(state.doc); + if (!ranges.length) return DecorationSet.empty; + const caret = state.selection.empty ? state.selection.from : -1; + const decos = ranges.map((r) => { + const active = isCaretActive(r, caret); + let cls; + if (active) { + cls = r.valid ? ACTIVE_VALID_CLASS : ACTIVE_INVALID_CLASS; + } else { + cls = r.valid ? PILL_VALID_CLASS : PILL_INVALID_CLASS; + } + return Decoration.inline(r.from, r.to, { class: cls }); + }); + return DecorationSet.create(state.doc, decos); +}; + +const handleTextInput = (view: EditorView, from: number, to: number, text: string): boolean => { + if (text !== "{") return false; + const { doc, selection } = view.state; + if (!selection.empty) return false; + if (from === 0) return false; + const before = doc.textBetween(from - 1, from, "\n", "\n"); + if (before !== "{") return false; + const ahead = doc.textBetween(to, Math.min(to + 2, doc.content.size), "\n", "\n"); + if (ahead === "}}") return false; + const tr = view.state.tr.insertText("{}}", from, to); + tr.setSelection(TextSelection.create(tr.doc, from + 1)); + view.dispatch(tr); + return true; +}; + +const deleteRange = (view: EditorView, range: Range): boolean => { + const tr: Transaction = view.state.tr.delete(range.from, range.to); + view.dispatch(tr); + return true; +}; + +const moveCaretTo = (view: EditorView, pos: number): boolean => { + const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)); + view.dispatch(tr); + return true; +}; + +const handleKeyDown = (view: EditorView, event: KeyboardEvent): boolean => { + if (event.metaKey || event.ctrlKey || event.altKey) return false; + const { selection } = view.state; + if (!selection.empty) return false; + const caret = selection.from; + const ranges = findVariableRanges(view.state.doc); + if (!ranges.length) return false; + + switch (event.key) { + case "Backspace": { + const range = rangeEndingAt(ranges, caret); + if (range) return deleteRange(view, range); + return false; + } + case "Delete": { + const range = rangeStartingAt(ranges, caret); + if (range) return deleteRange(view, range); + return false; + } + case "ArrowLeft": { + const range = rangeEndingAt(ranges, caret); + if (range) return moveCaretTo(view, range.from); + return false; + } + case "ArrowRight": { + const range = rangeStartingAt(ranges, caret); + if (range) return moveCaretTo(view, range.to); + return false; + } + case "Enter": { + const range = rangeContaining(ranges, caret); + if (range) return moveCaretTo(view, range.to); + return false; + } + default: + return false; + } +}; + +export const VariableHighlightExtension = Extension.create({ + name: "emailTemplateVariableHighlight", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: variableHighlightPluginKey, + state: { + init: (_config, state) => buildDecorations(state), + apply: (tr, oldSet, _oldState, newState) => { + if (!tr.docChanged && !tr.selectionSet) return oldSet.map(tr.mapping, tr.doc); + return buildDecorations(newState); + }, + }, + props: { + decorations(state) { + return this.getState(state) ?? DecorationSet.empty; + }, + handleTextInput, + handleKeyDown, + }, + }), + ]; + }, +}); + +export default VariableHighlightExtension; diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/__tests__/emailNotificationTemplateDiagnostics.test.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/__tests__/emailNotificationTemplateDiagnostics.test.ts new file mode 100644 index 0000000000..2711d70bfa --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/__tests__/emailNotificationTemplateDiagnostics.test.ts @@ -0,0 +1,524 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + SUPPORTED_LANGUAGES, + TENANT_LOGO_VARIABLE, + computeEmailTemplateDiagnostics, + groupEmailTemplateDiagnostics, +} from "@repo/shared"; +import { describe, expect, it } from "vitest"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; + +const para = (uuid: string, text?: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid }, + content: text ? [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }] : [], +}); + +const button = (uuid: string, text: string, url: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, text, url }, +}); + +const image = (uuid: string, src: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, src }, +}); + +const logoBrandingNode = (): EmailTemplateBlocks => image("logo-uuid", TENANT_LOGO_VARIABLE); + +const footerNode = (): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.FOOTER, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: "footer-uuid" }, + content: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "footer text" }], +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +const base = SUPPORTED_LANGUAGES.EN; +const other = SUPPORTED_LANGUAGES.PL; + +const defaultSubject = { [base]: "Hello" }; +const defaultStrings: EmailTemplateStrings = {}; + +describe("computeEmailTemplateDiagnostics — name_missing", () => { + it("flags empty name", () => { + const result = computeEmailTemplateDiagnostics({ + name: "", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "name_missing")).toBe(true); + }); + + it("flags whitespace-only name", () => { + const result = computeEmailTemplateDiagnostics({ + name: " ", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "name_missing")).toBe(true); + }); + + it("does not flag a valid name", () => { + const result = computeEmailTemplateDiagnostics({ + name: "My template", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "name_missing")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — no_language_versions", () => { + it("flags empty availableLocales", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "no_language_versions")).toBe(true); + }); +}); + +describe("computeEmailTemplateDiagnostics — subject_missing", () => { + it("flags missing base-language subject", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: {}, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "subject_missing" && d.language === base)).toBe(true); + }); + + it("flags whitespace-only subject", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: { [base]: " " }, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "subject_missing")).toBe(true); + }); +}); + +describe("computeEmailTemplateDiagnostics — body_missing", () => { + it("flags a doc with no translatable nodes", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "body_missing")).toBe(true); + }); + + it("does not flag when a paragraph is present", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "text")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "body_missing")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — footer_missing", () => { + it("emits a warning when no footer node is present", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + const d = result.find((x) => x.reason === "footer_missing"); + expect(d).toBeDefined(); + expect(d?.severity).toBe("warning"); + }); + + it("does not flag when a footer node is present", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body"), footerNode()), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "footer_missing")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — logo_branding_missing", () => { + it("emits a warning when no tenant logo node is present", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body"), footerNode()), + strings: defaultStrings, + }); + const d = result.find((x) => x.reason === "logo_branding_missing"); + expect(d).toBeDefined(); + expect(d?.severity).toBe("warning"); + }); + + it("does not flag when a tenant logo node is present", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(logoBrandingNode(), para(uuid1, "body"), footerNode()), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "logo_branding_missing")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — button_label_missing / button_url_missing", () => { + it("flags button with empty text", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "", "https://example.com")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "button_label_missing")).toBe(true); + }); + + it("flags button with empty url as a warning", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "")), + strings: defaultStrings, + }); + const diagnostic = result.find((d) => d.reason === "button_url_missing"); + expect(diagnostic?.severity).toBe("warning"); + }); + + it("does not flag a complete button", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "https://example.com")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "button_label_missing")).toBe(false); + expect(result.some((d) => d.reason === "button_url_missing")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — invalid_url_protocol", () => { + it("flags button with javascript: protocol", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "javascript:alert(1)")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(true); + }); + + it("flags image with invalid protocol", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(image(uuid1, "ftp://example.com/img.png")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(true); + }); + + it("does not flag a variable url that parses after substitution", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "{{site.url}}/path")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(false); + }); + + it("does not flag an unparseable url that contains a variable", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "{{not-a-url}}")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(false); + }); + + it("flags an unparseable url without variables", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "not a url at all")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(true); + }); + + it("accepts https: protocol", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "https://example.com")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(false); + }); + + it("accepts root-relative image URLs", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(image(uuid1, "/api/public/email-template-image/foo.webp")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(false); + }); + + it("accepts mailto: protocol", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click", "mailto:hi@example.com")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "invalid_url_protocol")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — empty_translation", () => { + it("flags empty base-language paragraph as error", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1)), + strings: defaultStrings, + }); + const d = result.find((x) => x.reason === "empty_translation" && x.language === base); + expect(d).toBeDefined(); + expect(d?.severity).toBe("error"); + }); + + it("flags empty non-base-language paragraph as warning", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base, other], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "base text")), + strings: { + [base]: { [uuid1]: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "base text" }] }, + [other]: { [uuid1]: [] }, + }, + }); + const d = result.find((x) => x.reason === "empty_translation" && x.language === other); + expect(d).toBeDefined(); + expect(d?.severity).toBe("warning"); + }); + + it("uses attrs.text for button base-language check", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(button(uuid1, "Click me", "https://example.com")), + strings: defaultStrings, + }); + expect(result.some((d) => d.reason === "empty_translation")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — unchanged_from_base", () => { + it("warns when non-base translation matches base text", () => { + const fragment = [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "same text" }]; + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base, other], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "same text")), + strings: { + [base]: { [uuid1]: fragment }, + [other]: { [uuid1]: fragment }, + }, + }); + const d = result.find((x) => x.reason === "unchanged_from_base" && x.language === other); + expect(d).toBeDefined(); + expect(d?.severity).toBe("warning"); + }); + + it("does not warn when non-base translation differs", () => { + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base, other], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "hello")), + strings: { + [base]: { [uuid1]: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "hello" }] }, + [other]: { [uuid1]: [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "cześć" }] }, + }, + }); + expect(result.some((d) => d.reason === "unchanged_from_base")).toBe(false); + }); + + it("does not emit unchanged_from_base for the base language itself", () => { + const fragment = [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "text" }]; + const result = computeEmailTemplateDiagnostics({ + name: "T", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "text")), + strings: { [base]: { [uuid1]: fragment } }, + }); + expect(result.some((d) => d.reason === "unchanged_from_base")).toBe(false); + }); +}); + +describe("computeEmailTemplateDiagnostics — severity shape", () => { + it("returns error severity for name_missing", () => { + const result = computeEmailTemplateDiagnostics({ + name: "", + availableLocales: [base], + baseLanguage: base, + subject: defaultSubject, + blocks: doc(para(uuid1, "body")), + strings: defaultStrings, + }); + const d = result.find((x) => x.reason === "name_missing"); + expect(d?.severity).toBe("error"); + }); +}); + +describe("groupEmailTemplateDiagnostics", () => { + const unknownUuid = "bbbbbbbb-0000-4000-8000-000000000002"; + + it("places diagnostics with a known uuid in byNodeUuid", () => { + const diagnostic = { + severity: "warning", + reason: "empty_translation", + nodeUuid: uuid1, + } as const; + + const result = groupEmailTemplateDiagnostics([diagnostic], new Set([uuid1])); + + expect(result.byNodeUuid.get(uuid1)).toEqual([diagnostic]); + expect(result.orphan).toEqual([]); + }); + + it("places diagnostics with an unknown uuid in orphan", () => { + const diagnostic = { + severity: "error", + reason: "button_url_missing", + nodeUuid: unknownUuid, + } as const; + + const result = groupEmailTemplateDiagnostics([diagnostic], new Set([uuid1])); + + expect(result.byNodeUuid.has(unknownUuid)).toBe(false); + expect(result.orphan).toEqual([diagnostic]); + }); + + it("places diagnostics without a uuid in orphan", () => { + const diagnostic = { + severity: "warning", + reason: "footer_missing", + } as const; + + const result = groupEmailTemplateDiagnostics([diagnostic], new Set([uuid1])); + + expect(result.byNodeUuid.size).toBe(0); + expect(result.orphan).toEqual([diagnostic]); + }); + + it("orders diagnostics within a bucket with errors first", () => { + const warning = { + severity: "warning", + reason: "empty_translation", + language: "pl", + nodeUuid: uuid1, + } as const; + const secondWarning = { + severity: "warning", + reason: "button_url_missing", + language: "en", + nodeUuid: uuid1, + } as const; + const secondError = { + severity: "error", + reason: "button_label_missing", + language: "en", + nodeUuid: uuid1, + } as const; + + const result = groupEmailTemplateDiagnostics( + [warning, secondWarning, secondError], + new Set([uuid1]), + ); + + expect(result.byNodeUuid.get(uuid1)).toEqual([secondError, secondWarning, warning]); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/__tests__/swapBaseLanguageContent.test.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/__tests__/swapBaseLanguageContent.test.ts new file mode 100644 index 0000000000..669113f85f --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/__tests__/swapBaseLanguageContent.test.ts @@ -0,0 +1,91 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; +import { describe, expect, it } from "vitest"; + +import { swapBaseLanguageContent } from "../swapBaseLanguageContent"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; + +const uuidPara = "aaaaaaaa-0000-4000-8000-000000000001"; +const uuidBtn = "aaaaaaaa-0000-4000-8000-000000000002"; + +const textNode = (text: string) => ({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }); + +const para = (uuid: string, ...textNodes: ReturnType[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid }, + content: textNodes, +}); + +const btn = (uuid: string, text: string, url = "https://example.com"): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, text, url }, +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +describe("swapBaseLanguageContent", () => { + it("moves current blocks content into strings[oldBase] and deletes strings[newBase]", () => { + const blocks = doc(para(uuidPara, textNode("Hello"))); + const strings: EmailTemplateStrings = { + pl: { [uuidPara]: [textNode("Cześć")] }, + }; + + const result = swapBaseLanguageContent({ blocks, strings, oldBase: "en", newBase: "pl" }); + + expect(result.blocks.content?.[0]?.content).toEqual([textNode("Cześć")]); + expect(result.strings.en?.[uuidPara]).toEqual([textNode("Hello")]); + expect(result.strings.pl).toBeUndefined(); + }); + + it("swaps button attrs.text via strings[newBase]", () => { + const blocks = doc(btn(uuidBtn, "Buy")); + const strings: EmailTemplateStrings = { + pl: { [uuidBtn]: [textNode("Kup")] }, + }; + + const result = swapBaseLanguageContent({ blocks, strings, oldBase: "en", newBase: "pl" }); + + expect(result.blocks.content?.[0]?.attrs?.text).toBe("Kup"); + expect(result.strings.en?.[uuidBtn]).toEqual([textNode("Buy")]); + expect(result.strings.pl).toBeUndefined(); + }); + + it("keeps blocks content when strings[newBase] does not have the uuid", () => { + const blocks = doc(para(uuidPara, textNode("Hello"))); + const strings: EmailTemplateStrings = { pl: {} }; + + const result = swapBaseLanguageContent({ blocks, strings, oldBase: "en", newBase: "pl" }); + + expect(result.blocks.content?.[0]?.content).toEqual([textNode("Hello")]); + expect(result.strings.en?.[uuidPara]).toEqual([textNode("Hello")]); + expect(result.strings.pl).toBeUndefined(); + }); + + it("preserves other locales untouched", () => { + const blocks = doc(para(uuidPara, textNode("Hello"))); + const deFrag = [textNode("Hallo")]; + const strings: EmailTemplateStrings = { + de: { [uuidPara]: deFrag }, + pl: { [uuidPara]: [textNode("Cześć")] }, + }; + + const result = swapBaseLanguageContent({ blocks, strings, oldBase: "en", newBase: "pl" }); + + expect(result.strings.de?.[uuidPara]).toEqual(deFrag); + }); + + it("does not mutate the original blocks or strings arguments", () => { + const blocks = doc(para(uuidPara, textNode("Hello"))); + const strings: EmailTemplateStrings = { pl: { [uuidPara]: [textNode("Cześć")] } }; + const blocksBefore = JSON.stringify(blocks); + const stringsBefore = JSON.stringify(strings); + + swapBaseLanguageContent({ blocks, strings, oldBase: "en", newBase: "pl" }); + + expect(JSON.stringify(blocks)).toBe(blocksBefore); + expect(JSON.stringify(strings)).toBe(stringsBefore); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/applyStructuralChangesToBase.test.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/applyStructuralChangesToBase.test.ts new file mode 100644 index 0000000000..91b186445e --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/applyStructuralChangesToBase.test.ts @@ -0,0 +1,125 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; +import { describe, expect, it } from "vitest"; + +import { applyStructuralChangesToBase } from "./applyStructuralChangesToBase"; + +import type { EmailTemplateBlocks } from "@repo/shared"; + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; +const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + +const textNode = (text: string) => ({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }); + +const para = (uuid: string, ...children: ReturnType[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, extraAttr: "preserved" }, + content: children, +}); + +const btn = (uuid: string, text: string, url = "https://example.com"): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, text, url }, +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +describe("applyStructuralChangesToBase", () => { + it("restores content from base for a matching non-BUTTON translatable node", () => { + const base = doc(para(uuid1, textNode("base text"))); + const translated = doc(para(uuid1, textNode("translated text"))); + + const result = applyStructuralChangesToBase(translated, base); + + expect(result.content?.[0]?.content).toEqual([textNode("base text")]); + }); + + it("preserves other attrs when restoring content for a non-BUTTON node", () => { + const base = doc(para(uuid1, textNode("base"))); + const translated = doc({ + ...para(uuid1, textNode("translated")), + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid1, extraAttr: "preserved" }, + }); + + const result = applyStructuralChangesToBase(translated, base); + + expect(result.content?.[0]?.attrs?.extraAttr).toBe("preserved"); + }); + + it("restores attrs.text from base for BUTTON nodes", () => { + const base = doc(btn(uuid1, "Base button text")); + const changed = doc(btn(uuid1, "Changed text")); + + const result = applyStructuralChangesToBase(changed, base); + + expect(result.content?.[0]?.attrs?.text).toBe("Base button text"); + }); + + it("preserves other attrs on BUTTON when restoring text", () => { + const base = doc(btn(uuid1, "Base", "https://original.com")); + const changed = doc({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { + [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid1, + text: "Changed", + url: "https://new.com", + }, + }); + + const result = applyStructuralChangesToBase(changed, base); + + expect(result.content?.[0]?.attrs?.url).toBe("https://new.com"); + expect(result.content?.[0]?.attrs?.text).toBe("Base"); + }); + + it("passes through nodes without a matching uuid in base", () => { + const base = doc(para(uuid1, textNode("base"))); + const changed = doc(para(uuid1, textNode("base")), para(uuid2, textNode("new node"))); + + const result = applyStructuralChangesToBase(changed, base); + + expect(result.content?.[1]?.content).toEqual([textNode("new node")]); + }); + + it("recurses into non-translatable nodes", () => { + const innerPara = para(uuid1, textNode("base inner")); + const section: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.SECTION, + content: [innerPara], + }; + const baseDoc = doc(section); + + const changedInner = para(uuid1, textNode("changed inner")); + const changedSection: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.SECTION, + content: [changedInner], + }; + const changedDoc = doc(changedSection); + + const result = applyStructuralChangesToBase(changedDoc, baseDoc); + + expect(result.content?.[0]?.content?.[0]?.content).toEqual([textNode("base inner")]); + }); + + it("does not mutate the doc argument", () => { + const base = doc(para(uuid1, textNode("base"))); + const changed = doc(para(uuid1, textNode("changed"))); + const snapshot = JSON.stringify(changed); + + applyStructuralChangesToBase(changed, base); + + expect(JSON.stringify(changed)).toBe(snapshot); + }); + + it("does not mutate the base argument", () => { + const base = doc(para(uuid1, textNode("base"))); + const changed = doc(para(uuid1, textNode("changed"))); + const snapshot = JSON.stringify(base); + + applyStructuralChangesToBase(changed, base); + + expect(JSON.stringify(base)).toBe(snapshot); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/applyStructuralChangesToBase.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/applyStructuralChangesToBase.ts new file mode 100644 index 0000000000..7bf254f08f --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/applyStructuralChangesToBase.ts @@ -0,0 +1,47 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + cloneEmailTemplateNode, +} from "@repo/shared"; + +import type { EmailTemplateBlocks, EmailTemplateNode } from "@repo/shared"; + +export const applyStructuralChangesToBase = ( + doc: EmailTemplateBlocks, + base: EmailTemplateBlocks, +): EmailTemplateBlocks => { + const originalByUuid = new Map(); + const collect = (node: EmailTemplateNode) => { + const raw = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof raw === "string") originalByUuid.set(raw, node); + if (node.content) node.content.forEach(collect); + }; + collect(base); + + const walk = (node: EmailTemplateNode): EmailTemplateNode => { + const raw = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + const original = typeof raw === "string" ? originalByUuid.get(raw) : undefined; + + if (original && node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + return { + ...node, + attrs: { ...(node.attrs ?? {}), text: original.attrs?.text ?? "" }, + }; + } + const restored: EmailTemplateNode = { ...node }; + if (node.attrs) restored.attrs = { ...node.attrs }; + if (original.content) restored.content = original.content.map(cloneEmailTemplateNode); + else delete restored.content; + return restored; + } + + if (node.content) { + return { ...node, content: node.content.map(walk) }; + } + return node; + }; + + return walk(doc) as EmailTemplateBlocks; +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/collectBasePlaceholders.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/collectBasePlaceholders.ts new file mode 100644 index 0000000000..a66bf9f946 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/collectBasePlaceholders.ts @@ -0,0 +1,40 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, +} from "@repo/shared"; + +import type { EmailTemplateBlocks, EmailTemplateNode } from "@repo/shared"; + +const flattenText = (nodes: EmailTemplateNode[] | undefined): string => { + if (!nodes) return ""; + let out = ""; + for (const node of nodes) { + if (typeof node.text === "string") out += node.text; + if (node.content) out += flattenText(node.content); + } + return out; +}; + +export const collectBasePlaceholders = (blocks: EmailTemplateBlocks): Record => { + const map: Record = {}; + + const walk = (node: EmailTemplateNode): void => { + if (node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) { + const uuid = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof uuid === "string" && uuid.length > 0) { + const text = + node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON + ? typeof node.attrs?.text === "string" + ? node.attrs.text + : "" + : flattenText(node.content); + map[uuid] = text; + } + } + if (node.content) for (const child of node.content) walk(child); + }; + + walk(blocks); + return map; +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/extractStringsFromDoc.test.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/extractStringsFromDoc.test.ts new file mode 100644 index 0000000000..177684a616 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/extractStringsFromDoc.test.ts @@ -0,0 +1,89 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; +import { describe, expect, it } from "vitest"; + +import { extractStringsFromDoc } from "./extractStringsFromDoc"; + +import type { EmailTemplateBlocks } from "@repo/shared"; + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; +const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + +const textNode = (text: string) => ({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }); + +const para = (uuid: string, ...children: ReturnType[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid }, + content: children, +}); + +const btn = (uuid: string, text: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, text, url: "https://example.com" }, +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +describe("extractStringsFromDoc", () => { + it("extracts content array for a non-BUTTON translatable node", () => { + const content = [textNode("hello")]; + const document = doc({ ...para(uuid1), content }); + + const result = extractStringsFromDoc(document); + + expect(result[uuid1]).toEqual(content); + }); + + it("extracts button attrs.text as a single-item fragment", () => { + const document = doc(btn(uuid1, "Click me")); + + const result = extractStringsFromDoc(document); + + expect(result[uuid1]).toEqual([{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text: "Click me" }]); + }); + + it("produces an empty fragment for a button with empty text", () => { + const document = doc(btn(uuid1, "")); + + const result = extractStringsFromDoc(document); + + expect(result[uuid1]).toEqual([]); + }); + + it("skips nodes without a string uuid", () => { + const nodeWithoutUuid: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: {}, + content: [textNode("no uuid")], + }; + const document = doc(nodeWithoutUuid); + + const result = extractStringsFromDoc(document); + + expect(Object.keys(result)).toHaveLength(0); + }); + + it("walks but does not record non-translatable structural nodes", () => { + const section: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.SECTION, + content: [para(uuid1, textNode("inside section"))], + }; + const document = doc(section); + + const result = extractStringsFromDoc(document); + + expect(Object.keys(result)).toContain(uuid1); + expect(Object.keys(result)).not.toContain("section-uuid"); + }); + + it("records multiple translatable nodes by their uuids", () => { + const document = doc(para(uuid1, textNode("first")), para(uuid2, textNode("second"))); + + const result = extractStringsFromDoc(document); + + expect(result[uuid1]).toEqual([textNode("first")]); + expect(result[uuid2]).toEqual([textNode("second")]); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/extractStringsFromDoc.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/extractStringsFromDoc.ts new file mode 100644 index 0000000000..47684b251f --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/extractStringsFromDoc.ts @@ -0,0 +1,32 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, +} from "@repo/shared"; + +import type { EmailTemplateBlocks, EmailTemplateNode, TranslationFragment } from "@repo/shared"; + +export const extractStringsFromDoc = ( + doc: EmailTemplateBlocks, +): Record => { + const out: Record = {}; + + const walk = (node: EmailTemplateNode): void => { + if (node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) { + const uuid = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof uuid === "string") { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + const raw = node.attrs?.text; + const text = typeof raw === "string" ? raw : ""; + out[uuid] = text.length > 0 ? [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }] : []; + } else { + out[uuid] = (node.content ?? []) as TranslationFragment; + } + } + } + if (node.content) for (const child of node.content) walk(child); + }; + + walk(doc); + return out; +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/flattenForLanguage.test.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/flattenForLanguage.test.ts new file mode 100644 index 0000000000..53e1216b85 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/flattenForLanguage.test.ts @@ -0,0 +1,125 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, EMAIL_TEMPLATE_NODE_UUID_ATTR } from "@repo/shared"; +import { describe, expect, it } from "vitest"; + +import { BUTTON_FALLBACK_ATTR, flattenForLanguage } from "./flattenForLanguage"; + +import type { EmailTemplateBlocks, EmailTemplateStrings } from "@repo/shared"; + +const uuid1 = "aaaaaaaa-0000-4000-8000-000000000001"; +const uuid2 = "aaaaaaaa-0000-4000-8000-000000000002"; + +const textNode = (text: string) => ({ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }); + +const para = (uuid: string, ...children: ReturnType[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid }, + content: children, +}); + +const btn = (uuid: string, text: string, url = "https://example.com"): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: { [EMAIL_TEMPLATE_NODE_UUID_ATTR]: uuid, text, url }, +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +const EN = "en" as const; +const PL = "pl" as const; + +describe("flattenForLanguage", () => { + it("overlays content for a non-BUTTON node from the target language", () => { + const plFragment = [textNode("PL text")]; + const blocks = doc(para(uuid1, textNode("EN text"))); + const strings: EmailTemplateStrings = { [PL]: { [uuid1]: plFragment } }; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.content).toEqual(plFragment); + }); + + it("sets attrs.text from flattened fragment for BUTTON nodes", () => { + const plFragment = [textNode("PL button")]; + const blocks = doc(btn(uuid1, "EN button")); + const strings: EmailTemplateStrings = { [PL]: { [uuid1]: plFragment } }; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.attrs?.text).toBe("PL button"); + expect(result.content?.[0]?.attrs?.[BUTTON_FALLBACK_ATTR]).toBeUndefined(); + }); + + it("clears content for non-base paragraph when target locale has no override", () => { + const blocks = doc(para(uuid1, textNode("EN text"))); + const strings: EmailTemplateStrings = {}; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.content).toEqual([]); + }); + + it("clears content for non-base paragraph when target locale override is empty", () => { + const blocks = doc(para(uuid1, textNode("EN text"))); + const strings: EmailTemplateStrings = { [PL]: { [uuid1]: [] } }; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.content).toEqual([]); + }); + + it("marks BUTTON node with fallback attribute when non-base locale override is missing", () => { + const blocks = doc(btn(uuid1, "EN button")); + const strings: EmailTemplateStrings = {}; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.attrs?.text).toBe("EN button"); + expect(result.content?.[0]?.attrs?.[BUTTON_FALLBACK_ATTR]).toBe("true"); + }); + + it("does NOT mark BUTTON with fallback attribute when base text itself is empty", () => { + const blocks = doc(btn(uuid1, "")); + const strings: EmailTemplateStrings = {}; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.attrs?.[BUTTON_FALLBACK_ATTR]).toBeUndefined(); + }); + + it("leaves node untouched for base language even when strings are missing", () => { + const originalContent = [textNode("original")]; + const blocks = doc(para(uuid1, ...originalContent)); + const strings: EmailTemplateStrings = {}; + + const result = flattenForLanguage({ blocks, strings, language: EN, baseLanguage: EN }); + + expect(result.content?.[0]?.content).toEqual(originalContent); + }); + + it("does not mutate the original blocks argument", () => { + const blocks = doc(para(uuid1, textNode("original"))); + const strings: EmailTemplateStrings = { + [PL]: { [uuid1]: [textNode("PL text")] }, + }; + const snapshot = JSON.stringify(blocks); + + flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(JSON.stringify(blocks)).toBe(snapshot); + }); + + it("handles multiple nodes independently for non-base language", () => { + const plFrag2 = [textNode("PL 2")]; + const blocks = doc(para(uuid1, textNode("a")), para(uuid2, textNode("b"))); + const strings: EmailTemplateStrings = { + [PL]: { [uuid2]: plFrag2 }, + }; + + const result = flattenForLanguage({ blocks, strings, language: PL, baseLanguage: EN }); + + expect(result.content?.[0]?.content).toEqual([]); + expect(result.content?.[1]?.content).toEqual(plFrag2); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/flattenForLanguage.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/flattenForLanguage.ts new file mode 100644 index 0000000000..c881953a99 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/flattenForLanguage.ts @@ -0,0 +1,77 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + cloneEmailTemplateNode, +} from "@repo/shared"; + +import type { + EmailTemplateBlocks, + EmailTemplateNode, + EmailTemplateStrings, + SupportedLanguages, + TranslationFragment, +} from "@repo/shared"; + +export const BUTTON_FALLBACK_ATTR = "fallbackText"; + +const readNodeUuid = (node: EmailTemplateNode): string | null => { + const raw = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + return typeof raw === "string" ? raw : null; +}; + +const fragmentToPlainString = (fragment: TranslationFragment): string => { + let out = ""; + for (const node of fragment) { + if (typeof node.text === "string") out += node.text; + else if (node.content) out += fragmentToPlainString(node.content as TranslationFragment); + } + return out; +}; + +const isFragmentEmpty = (fragment: TranslationFragment | undefined): boolean => { + if (!fragment || fragment.length === 0) return true; + return fragmentToPlainString(fragment).trim().length === 0; +}; + +export const flattenForLanguage = (params: { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + language: SupportedLanguages; + baseLanguage: SupportedLanguages; +}): EmailTemplateBlocks => { + const { strings, language, baseLanguage } = params; + const isBase = language === baseLanguage; + const blocks = cloneEmailTemplateNode(params.blocks); + + const walk = (node: EmailTemplateNode): void => { + if (node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type) && !isBase) { + const uuid = readNodeUuid(node); + if (uuid) { + const localeOverride = strings[language]?.[uuid]; + if (localeOverride && !isFragmentEmpty(localeOverride)) { + applyOverride(node, localeOverride); + } else if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + const baseButtonText = typeof node.attrs?.text === "string" ? node.attrs.text : ""; + if (!node.attrs) node.attrs = {}; + if (baseButtonText) node.attrs[BUTTON_FALLBACK_ATTR] = "true"; + } else { + node.content = []; + } + } + } + if (node.content) for (const child of node.content) walk(child); + }; + + walk(blocks); + return blocks; +}; + +const applyOverride = (node: EmailTemplateNode, override: TranslationFragment): void => { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + if (!node.attrs) node.attrs = {}; + node.attrs.text = fragmentToPlainString(override); + } else { + node.content = override.map(cloneEmailTemplateNode); + } +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/insertVariablePlaceholder.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/insertVariablePlaceholder.ts new file mode 100644 index 0000000000..7d30ec04cd --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/insertVariablePlaceholder.ts @@ -0,0 +1,17 @@ +import { TextSelection } from "@tiptap/pm/state"; + +import type { CommandProps } from "@maily-to/core/blocks"; + +export const insertVariablePlaceholder = + () => + ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertContent("{{}}") + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, tr.selection.from - 2)); + return true; + }) + .run(); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/logoHeader.test.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/logoHeader.test.ts new file mode 100644 index 0000000000..5a2f11f476 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/logoHeader.test.ts @@ -0,0 +1,156 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; +import { describe, expect, it } from "vitest"; + +import { + TENANT_LOGO_HEIGHT, + TENANT_LOGO_PLACEHOLDER_SRC, + TENANT_LOGO_VARIABLE, + packTenantLogoInDoc, + resolveEffectiveLogoUrl, + resolveTenantLogoInDoc, +} from "./logoHeader"; + +import type { EmailTemplateBlocks } from "@repo/shared"; + +const logoUrl = "https://tenant.example.com/logo.png"; + +const imageNode = (src: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { uuid: "img-uuid", src, alignment: "center" }, +}); + +const doc = (...children: EmailTemplateBlocks[]): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: children, +}); + +describe("resolveEffectiveLogoUrl", () => { + it("uses the tenant logo when one is configured", () => { + expect(resolveEffectiveLogoUrl(logoUrl)).toBe(logoUrl); + }); + + it("uses the Mentingo logo fallback when no tenant logo is configured", () => { + const result = resolveEffectiveLogoUrl(null); + expect(result).toBeTruthy(); + expect(result).not.toBe(TENANT_LOGO_VARIABLE); + }); +}); + +describe("resolveTenantLogoInDoc", () => { + it("returns the doc unchanged (same reference) when logoUrl is null", () => { + const original = doc(imageNode(TENANT_LOGO_VARIABLE)); + const result = resolveTenantLogoInDoc(original, null); + expect(result).toBe(original); + }); + + it("replaces TENANT_LOGO_VARIABLE src with the real logo url", () => { + const blocks = doc(imageNode(TENANT_LOGO_VARIABLE)); + const result = resolveTenantLogoInDoc(blocks, logoUrl); + expect(result.content?.[0]?.attrs?.src).toBe(logoUrl); + }); + + it("replaces TENANT_LOGO_VARIABLE src with the Mentingo logo fallback", () => { + const blocks = doc(imageNode(TENANT_LOGO_VARIABLE)); + const result = resolveTenantLogoInDoc(blocks, TENANT_LOGO_PLACEHOLDER_SRC); + expect(result).not.toBe(blocks); + expect(result.content?.[0]?.attrs?.src).not.toBe(TENANT_LOGO_VARIABLE); + }); + + it("returns the doc unchanged when there is no image with TENANT_LOGO_VARIABLE", () => { + const blocks = doc(imageNode("https://other.com/img.png")); + const result = resolveTenantLogoInDoc(blocks, logoUrl); + expect(result).toBe(blocks); + }); + + it("preserves other attrs on the image node", () => { + const blocks = doc(imageNode(TENANT_LOGO_VARIABLE)); + const result = resolveTenantLogoInDoc(blocks, logoUrl); + expect(result.content?.[0]?.attrs?.alignment).toBe("center"); + }); + + it("normalizes the tenant logo width to null and height to 32 so Maily's auto-fit override is bypassed", () => { + const blocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { + uuid: "img-uuid", + src: TENANT_LOGO_VARIABLE, + alignment: "center", + width: "auto", + height: TENANT_LOGO_HEIGHT, + }, + }, + ], + }; + const result = resolveTenantLogoInDoc(blocks, logoUrl); + expect(result.content?.[0]?.attrs).toMatchObject({ + src: logoUrl, + width: null, + height: TENANT_LOGO_HEIGHT, + alignment: "center", + }); + }); +}); + +describe("packTenantLogoInDoc", () => { + it("returns the doc unchanged when logoUrl is null", () => { + const blocks = doc(imageNode(logoUrl)); + const result = packTenantLogoInDoc(blocks, null); + expect(result).toBe(blocks); + }); + + it("replaces the real logo url with TENANT_LOGO_VARIABLE", () => { + const blocks = doc(imageNode(logoUrl)); + const result = packTenantLogoInDoc(blocks, logoUrl); + expect(result.content?.[0]?.attrs?.src).toBe(TENANT_LOGO_VARIABLE); + }); + + it("replaces the Mentingo logo fallback with TENANT_LOGO_VARIABLE", () => { + const blocks = doc(imageNode(TENANT_LOGO_PLACEHOLDER_SRC)); + const result = packTenantLogoInDoc(blocks, null); + expect(result.content?.[0]?.attrs?.src).toBe(TENANT_LOGO_VARIABLE); + }); + + it("returns doc unchanged when logo url is not found in the doc", () => { + const blocks = doc(imageNode("https://different.com/img.png")); + const result = packTenantLogoInDoc(blocks, logoUrl); + expect(result).toBe(blocks); + }); + + it("normalizes the tenant logo's width/height even when Maily's auto-fit has overwritten them to numeric values", () => { + const blocks: EmailTemplateBlocks = { + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { + uuid: "img-uuid", + src: logoUrl, + alignment: "center", + width: 512, + height: 128, + aspectRatio: 4, + }, + }, + ], + }; + const result = packTenantLogoInDoc(blocks, logoUrl); + expect(result.content?.[0]?.attrs).toMatchObject({ + src: TENANT_LOGO_VARIABLE, + width: "auto", + height: TENANT_LOGO_HEIGHT, + aspectRatio: 4, + }); + }); +}); + +describe("resolveTenantLogoInDoc + packTenantLogoInDoc round-trip", () => { + it("round-trips correctly: resolve then pack returns TENANT_LOGO_VARIABLE", () => { + const blocks = doc(imageNode(TENANT_LOGO_VARIABLE)); + const resolved = resolveTenantLogoInDoc(blocks, logoUrl); + const packed = packTenantLogoInDoc(resolved, logoUrl); + expect(packed.content?.[0]?.attrs?.src).toBe(TENANT_LOGO_VARIABLE); + }); +}); diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/logoHeader.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/logoHeader.ts new file mode 100644 index 0000000000..746914e3b0 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/logoHeader.ts @@ -0,0 +1,91 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, TENANT_LOGO_VARIABLE } from "@repo/shared"; +import { v4 as uuid } from "uuid"; + +import mentingoLogoUrl from "~/assets/svgs/app-logo.svg?url"; + +import type { CommandProps } from "@maily-to/core/blocks"; +import type { EmailTemplateBlocks, EmailTemplateNode } from "@repo/shared"; + +export { TENANT_LOGO_VARIABLE } from "@repo/shared"; +export const TENANT_LOGO_HEIGHT = "32"; +export const TENANT_LOGO_PLACEHOLDER_SRC = mentingoLogoUrl; + +export const resolveEffectiveLogoUrl = (tenantLogoUrl: string | null): string => + tenantLogoUrl && tenantLogoUrl.length > 0 ? tenantLogoUrl : TENANT_LOGO_PLACEHOLDER_SRC; + +export const insertLogoHeader = + (logoUrl: string | null) => + ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { + uuid: uuid(), + src: logoUrl ?? TENANT_LOGO_VARIABLE, + alignment: "center", + width: null, + height: TENANT_LOGO_HEIGHT, + }, + }) + .run(); + +const walk = ( + node: EmailTemplateNode, + transform: (n: EmailTemplateNode) => EmailTemplateNode, +): EmailTemplateNode => { + const next = transform(node); + if (!next.content) return next; + return { ...next, content: next.content.map((child) => walk(child, transform)) }; +}; + +const hasImageWithSrc = (node: EmailTemplateNode, src: string): boolean => { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.IMAGE && node.attrs?.src === src) return true; + return node.content?.some((child) => hasImageWithSrc(child, src)) ?? false; +}; + +export const resolveTenantLogoInDoc = ( + doc: EmailTemplateBlocks, + logoUrl: string | null, +): EmailTemplateBlocks => { + if (!logoUrl) return doc; + if (!hasImageWithSrc(doc, TENANT_LOGO_VARIABLE)) return doc; + return walk(doc, (node) => { + if (node.type !== EMAIL_TEMPLATE_NODE_TYPES.IMAGE) return node; + if (node.attrs?.src !== TENANT_LOGO_VARIABLE) return node; + return { + ...node, + attrs: { + ...node.attrs, + src: logoUrl, + width: null, + height: TENANT_LOGO_HEIGHT, + }, + }; + }); +}; + +export const packTenantLogoInDoc = ( + doc: EmailTemplateBlocks, + logoUrl: string | null, +): EmailTemplateBlocks => { + const targets = new Set([TENANT_LOGO_PLACEHOLDER_SRC]); + if (logoUrl) targets.add(logoUrl); + if (![...targets].some((t) => hasImageWithSrc(doc, t))) return doc; + return walk(doc, (node) => { + if (node.type !== EMAIL_TEMPLATE_NODE_TYPES.IMAGE) return node; + const src = node.attrs?.src; + if (typeof src !== "string" || !targets.has(src)) return node; + return { + ...node, + attrs: { + ...node.attrs, + src: TENANT_LOGO_VARIABLE, + width: "auto", + height: TENANT_LOGO_HEIGHT, + }, + }; + }); +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/utils/swapBaseLanguageContent.ts b/apps/web/app/modules/Admin/EmailTemplates/utils/swapBaseLanguageContent.ts new file mode 100644 index 0000000000..96e3491842 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/utils/swapBaseLanguageContent.ts @@ -0,0 +1,78 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + cloneEmailTemplateNode, +} from "@repo/shared"; + +import type { + EmailTemplateBlocks, + EmailTemplateNode, + EmailTemplateStrings, + SupportedLanguages, + TranslationFragment, +} from "@repo/shared"; + +const readNodeUuid = (node: EmailTemplateNode): string | null => { + const raw = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + return typeof raw === "string" ? raw : null; +}; + +const fragmentToPlainString = (fragment: TranslationFragment): string => { + let out = ""; + for (const node of fragment) { + if (typeof node.text === "string") out += node.text; + else if (node.content) out += fragmentToPlainString(node.content as TranslationFragment); + } + return out; +}; + +const extractFragmentFromNode = (node: EmailTemplateNode): TranslationFragment => { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + const raw = node.attrs?.text; + const text = typeof raw === "string" ? raw : ""; + return text.length > 0 ? [{ type: EMAIL_TEMPLATE_NODE_TYPES.TEXT, text }] : []; + } + return (node.content ?? []).map(cloneEmailTemplateNode) as TranslationFragment; +}; + +const applyFragmentToNode = (node: EmailTemplateNode, fragment: TranslationFragment): void => { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + if (!node.attrs) node.attrs = {}; + node.attrs.text = fragmentToPlainString(fragment); + } else { + node.content = fragment.map(cloneEmailTemplateNode); + } +}; + +export const swapBaseLanguageContent = (params: { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + oldBase: SupportedLanguages; + newBase: SupportedLanguages; +}): { blocks: EmailTemplateBlocks; strings: EmailTemplateStrings } => { + const { oldBase, newBase } = params; + const blocks = cloneEmailTemplateNode(params.blocks); + const nextStrings: EmailTemplateStrings = { ...params.strings }; + const newBaseFragments = nextStrings[newBase] ?? {}; + const oldBaseFragments: Record = {}; + + const walk = (node: EmailTemplateNode): void => { + if (node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) { + const uuid = readNodeUuid(node); + if (uuid) { + oldBaseFragments[uuid] = extractFragmentFromNode(node); + const promoted = newBaseFragments[uuid]; + if (promoted) applyFragmentToNode(node, promoted); + } + } + if (node.content) for (const child of node.content) walk(child); + }; + + walk(blocks); + + nextStrings[oldBase] = oldBaseFragments; + delete nextStrings[newBase]; + + return { blocks, strings: nextStrings }; +}; diff --git a/apps/web/app/modules/Admin/EmailTemplates/validators/editEmailTemplateFormSchema.ts b/apps/web/app/modules/Admin/EmailTemplates/validators/editEmailTemplateFormSchema.ts new file mode 100644 index 0000000000..a10c031775 --- /dev/null +++ b/apps/web/app/modules/Admin/EmailTemplates/validators/editEmailTemplateFormSchema.ts @@ -0,0 +1,68 @@ +import { EMAIL_TEMPLATE_NODE_TYPES, SUPPORTED_LANGUAGES } from "@repo/shared"; +import { z } from "zod"; + +import type { EmailTemplateBlocks, EmailTemplateStrings, SupportedLanguages } from "@repo/shared"; + +const supportedLanguageValues = Object.values(SUPPORTED_LANGUAGES) as [ + SupportedLanguages, + ...SupportedLanguages[], +]; + +type TiptapJsonNode = { + type?: string; + attrs?: Record; + content?: TiptapJsonNode[]; + marks?: { + type: string; + attrs?: Record; + }[]; + text?: string; +}; + +const tiptapJsonNodeSchema: z.ZodType = z + .object({ + type: z.string().optional(), + attrs: z.record(z.string(), z.unknown()).optional(), + content: z.lazy(() => z.array(tiptapJsonNodeSchema)).optional(), + marks: z + .array( + z + .object({ + type: z.string(), + attrs: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(), + ) + .optional(), + text: z.string().optional(), + }) + .passthrough(); + +export const editEmailTemplateFormSchema = z + .object({ + name: z + .string() + .min(1, { message: "emailTemplates.form.errors.nameRequired" }) + .max(200, { message: "emailTemplates.form.errors.nameTooLong" }), + baseLanguage: z.enum(supportedLanguageValues), + availableLocales: z + .array(z.enum(supportedLanguageValues)) + .min(1, { message: "emailTemplates.form.errors.localesRequired" }), + subject: z.record(z.enum(supportedLanguageValues), z.string()).default({}), + blocks: tiptapJsonNodeSchema.default({ type: EMAIL_TEMPLATE_NODE_TYPES.DOC, content: [] }), + strings: z + .record(z.enum(supportedLanguageValues), z.record(z.string(), z.array(tiptapJsonNodeSchema))) + .default({}), + }) + .refine((data) => data.availableLocales.includes(data.baseLanguage), { + message: "emailTemplates.form.errors.baseLanguageMissing", + path: ["baseLanguage"], + }); + +export type EditEmailTemplateFormValues = Omit< + z.infer, + "blocks" | "strings" +> & { + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; +}; diff --git a/apps/web/e2e/data/email-templates/handles.ts b/apps/web/e2e/data/email-templates/handles.ts new file mode 100644 index 0000000000..401ccf41e5 --- /dev/null +++ b/apps/web/e2e/data/email-templates/handles.ts @@ -0,0 +1,38 @@ +import type { SupportedLanguages } from "@repo/shared"; + +export const EMAIL_TEMPLATES_HANDLES = { + PAGE: "email-templates-page", + CREATE_BUTTON: "email-templates-create-button", + DELETE_SELECTED_BUTTON: "email-templates-delete-selected-button", + DELETE_CONFIRM_BUTTON: "email-templates-delete-confirm-button", + NAME_FILTER: "email-templates-name-filter", + STATUS_FILTER: "email-templates-status-filter", + statusFilterOption: (status: string) => `email-templates-status-filter-option-${status}`, + PAGINATION_NEXT: "email-templates-pagination-next", + PAGINATION_PREVIOUS: "email-templates-pagination-previous", + paginationPage: (page: number) => `email-templates-pagination-page-${page}`, + PAGINATION_ITEMS_PER_PAGE: "email-templates-pagination-items-per-page", + paginationItemsPerPageOption: (itemsPerPage: number) => + `email-templates-pagination-items-per-page-option-${itemsPerPage}`, + row: (id: string) => `email-templates-row-${id}`, + rowCheckbox: (id: string) => `email-templates-row-checkbox-${id}`, +}; + +export const EDIT_EMAIL_TEMPLATE_HANDLES = { + PAGE: "edit-email-template-page", + NAME_BUTTON: "edit-email-template-name-button", + NAME_INPUT: "edit-email-template-name-input", + STATUS_SELECT: "edit-email-template-status-select", + SAVE_BUTTON: "edit-email-template-save-button", + DUPLICATE_BUTTON: "edit-email-template-duplicate-button", + SEND_TEST_BUTTON: "edit-email-template-send-test-button", + SUBJECT_INPUT: "edit-email-template-subject-input", + LANGUAGE_SELECT: "edit-email-template-language-select", + languageOption: (language: SupportedLanguages) => + `edit-email-template-language-option-${language}`, + LANGUAGE_CREATE_CONFIRM_BUTTON: "edit-email-template-language-create-confirm-button", + LANGUAGE_DELETE_BUTTON: "edit-email-template-language-delete-button", + LANGUAGE_DELETE_CONFIRM_BUTTON: "edit-email-template-language-delete-confirm-button", + LANGUAGE_SET_BASE_BUTTON: "edit-email-template-language-set-base-button", + LANGUAGE_SET_BASE_CONFIRM_BUTTON: "edit-email-template-language-set-base-confirm-button", +}; diff --git a/apps/web/e2e/factories/email-template.factory.ts b/apps/web/e2e/factories/email-template.factory.ts new file mode 100644 index 0000000000..64517b37ec --- /dev/null +++ b/apps/web/e2e/factories/email-template.factory.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; + +import type { FixtureApiClient } from "../utils/api-client"; +import type { + CreateTemplateBody, + GetTemplateResponse, + UpdateTemplateBody, +} from "~/api/generated-api"; + +export type EmailTemplateFactoryRecord = GetTemplateResponse["data"]; +export type EmailTemplateFactoryCreateInput = Partial; +export type EmailTemplateFactoryUpdateInput = UpdateTemplateBody; + +const createEmailTemplateName = () => `Email template ${randomUUID().slice(0, 8)}`; + +export class EmailTemplateFactory { + constructor(private readonly apiClient: FixtureApiClient) {} + + async create(input: EmailTemplateFactoryCreateInput = {}): Promise { + const response = await this.apiClient.api.emailNotificationTemplatesControllerCreateTemplate({ + name: input.name ?? createEmailTemplateName(), + baseLanguage: input.baseLanguage ?? "en", + availableLocales: input.availableLocales ?? ["en"], + subject: input.subject ?? { en: "Smoke subject" }, + blocks: input.blocks ?? { type: "doc", content: [] }, + strings: input.strings ?? {}, + }); + + return this.getById(response.data.data.id); + } + + async getById(id: string): Promise { + const response = await this.apiClient.api.emailNotificationTemplatesControllerGetTemplate(id); + return response.data.data; + } + + async update( + id: string, + data: EmailTemplateFactoryUpdateInput, + ): Promise { + const response = await this.apiClient.api.emailNotificationTemplatesControllerUpdateTemplate( + id, + data, + ); + return response.data.data; + } + + async delete(id: string): Promise { + await this.apiClient.api.emailNotificationTemplatesControllerDeleteTemplate(id); + } + + async safeGetById(id: string): Promise { + try { + return await this.getById(id); + } catch { + return null; + } + } +} diff --git a/apps/web/e2e/factories/index.ts b/apps/web/e2e/factories/index.ts index 9a66029fec..ed63dbab10 100644 --- a/apps/web/e2e/factories/index.ts +++ b/apps/web/e2e/factories/index.ts @@ -2,6 +2,7 @@ import { ArticleFactory } from "./article.factory"; import { CategoryFactory } from "./category.factory"; import { CourseFactory } from "./course.factory"; import { CurriculumFactory } from "./curriculum.factory"; +import { EmailTemplateFactory } from "./email-template.factory"; import { EnrollmentFactory } from "./enrollment.factory"; import { GroupFactory } from "./group.factory"; import { LiveTrainingFactory } from "./live-training.factory"; @@ -18,6 +19,7 @@ export type FixtureFactories = { createCourseFactory: () => CourseFactory; createCurriculumFactory: () => CurriculumFactory; createEnrollmentFactory: () => EnrollmentFactory; + createEmailTemplateFactory: () => EmailTemplateFactory; createGroupFactory: () => GroupFactory; createLiveTrainingFactory: () => LiveTrainingFactory; createNewsFactory: () => NewsFactory; @@ -32,6 +34,7 @@ export const createFixtureFactories = (apiClient: FixtureApiClient): FixtureFact let courseFactory: CourseFactory | undefined; let curriculumFactory: CurriculumFactory | undefined; let enrollmentFactory: EnrollmentFactory | undefined; + let emailTemplateFactory: EmailTemplateFactory | undefined; let groupFactory: GroupFactory | undefined; let liveTrainingFactory: LiveTrainingFactory | undefined; let newsFactory: NewsFactory | undefined; @@ -60,6 +63,10 @@ export const createFixtureFactories = (apiClient: FixtureApiClient): FixtureFact enrollmentFactory ??= new EnrollmentFactory(apiClient); return enrollmentFactory; }, + createEmailTemplateFactory: () => { + emailTemplateFactory ??= new EmailTemplateFactory(apiClient); + return emailTemplateFactory; + }, createGroupFactory: () => { groupFactory ??= new GroupFactory(apiClient); return groupFactory; @@ -92,6 +99,7 @@ export { CategoryFactory } from "./category.factory"; export { CourseFactory } from "./course.factory"; export { CurriculumFactory } from "./curriculum.factory"; export { EnrollmentFactory } from "./enrollment.factory"; +export { EmailTemplateFactory } from "./email-template.factory"; export { GroupFactory } from "./group.factory"; export { NewsFactory } from "./news.factory"; export { QAFactory } from "./qa.factory"; diff --git a/apps/web/e2e/flows/email-templates/open-edit-email-template-page.flow.ts b/apps/web/e2e/flows/email-templates/open-edit-email-template-page.flow.ts new file mode 100644 index 0000000000..87be6425d3 --- /dev/null +++ b/apps/web/e2e/flows/email-templates/open-edit-email-template-page.flow.ts @@ -0,0 +1,8 @@ +import { expect, type Page } from "@playwright/test"; + +import { EDIT_EMAIL_TEMPLATE_HANDLES } from "../../data/email-templates/handles"; + +export const openEditEmailTemplatePageFlow = async (page: Page, id: string) => { + await page.goto(`/admin/email-templates/${id}`); + await expect(page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.PAGE)).toBeVisible(); +}; diff --git a/apps/web/e2e/flows/email-templates/open-email-templates-page.flow.ts b/apps/web/e2e/flows/email-templates/open-email-templates-page.flow.ts new file mode 100644 index 0000000000..5a397cbd59 --- /dev/null +++ b/apps/web/e2e/flows/email-templates/open-email-templates-page.flow.ts @@ -0,0 +1,8 @@ +import { expect, type Page } from "@playwright/test"; + +import { EMAIL_TEMPLATES_HANDLES } from "../../data/email-templates/handles"; + +export const openEmailTemplatesPageFlow = async (page: Page) => { + await page.goto("/admin/email-templates"); + await expect(page.getByTestId(EMAIL_TEMPLATES_HANDLES.PAGE)).toBeVisible(); +}; diff --git a/apps/web/e2e/specs/admin/email-template-builder.spec.ts b/apps/web/e2e/specs/admin/email-template-builder.spec.ts new file mode 100644 index 0000000000..232aed4212 --- /dev/null +++ b/apps/web/e2e/specs/admin/email-template-builder.spec.ts @@ -0,0 +1,72 @@ +import { USER_ROLE } from "~/config/userRoles"; + +import { + EDIT_EMAIL_TEMPLATE_HANDLES, + EMAIL_TEMPLATES_HANDLES, +} from "../../data/email-templates/handles"; +import { expect, test } from "../../fixtures/test.fixture"; +import { openEditEmailTemplatePageFlow } from "../../flows/email-templates/open-edit-email-template-page.flow"; +import { openEmailTemplatesPageFlow } from "../../flows/email-templates/open-email-templates-page.flow"; + +test("admin can create, edit, and delete an email template", async ({ + cleanup, + factories, + withWorkerPage, +}) => { + await withWorkerPage(USER_ROLE.admin, async ({ page }) => { + const emailTemplateFactory = factories.createEmailTemplateFactory(); + const updatedName = `E2E email template ${Date.now()}`; + const updatedSubject = `E2E subject ${Date.now()}`; + let templateId = ""; + + cleanup.add(async () => { + if (!templateId) return; + const existingTemplate = await emailTemplateFactory.safeGetById(templateId); + if (existingTemplate) await emailTemplateFactory.delete(templateId); + }); + + await openEmailTemplatesPageFlow(page); + await page.getByTestId(EMAIL_TEMPLATES_HANDLES.CREATE_BUTTON).click(); + await expect(page).toHaveURL(/\/admin\/email-templates\/[^/]+$/); + + templateId = page.url().split("/").at(-1) ?? ""; + if (!templateId) throw new Error("Created email template id was not present in the URL"); + + await expect(page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.PAGE)).toBeVisible(); + + await page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.NAME_BUTTON).click(); + await page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.NAME_INPUT).fill(updatedName); + await page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.NAME_INPUT).press("Enter"); + + await expect + .poll(async () => emailTemplateFactory.getById(templateId)) + .toMatchObject({ + name: updatedName, + }); + + const subjectEditor = page + .getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.SUBJECT_INPUT) + .locator(".ProseMirror"); + await subjectEditor.fill(updatedSubject); + await page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.SAVE_BUTTON).click(); + + await expect + .poll(async () => { + const template = await emailTemplateFactory.getById(templateId); + return template.subject.en; + }) + .toBe(updatedSubject); + + await openEditEmailTemplatePageFlow(page, templateId); + await expect(page.getByTestId(EDIT_EMAIL_TEMPLATE_HANDLES.NAME_BUTTON)).toHaveText(updatedName); + + await openEmailTemplatesPageFlow(page); + await expect(page.getByTestId(EMAIL_TEMPLATES_HANDLES.row(templateId))).toBeVisible(); + await page.getByTestId(EMAIL_TEMPLATES_HANDLES.rowCheckbox(templateId)).click(); + await page.getByTestId(EMAIL_TEMPLATES_HANDLES.DELETE_SELECTED_BUTTON).click(); + await page.getByTestId(EMAIL_TEMPLATES_HANDLES.DELETE_CONFIRM_BUTTON).click(); + + await expect.poll(async () => emailTemplateFactory.safeGetById(templateId)).toBeNull(); + templateId = ""; + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 244de1fc82..a8ab8504a7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -71,10 +71,11 @@ "@tanstack/react-query": "5.40.1", "@tanstack/react-query-devtools": "5.40.1", "@tanstack/react-table": "8.20.1", + "@maily-to/core": "^0.3.7", "@tiptap/core": "2.27.1", "@tiptap/extension-color": "2.27.1", "@tiptap/extension-document": "2.27.1", - "@tiptap/extension-heading": "^3.13.0", + "@tiptap/extension-heading": "2.27.1", "@tiptap/extension-highlight": "2.27.1", "@tiptap/extension-image": "2.27.1", "@tiptap/extension-link": "2.27.1", @@ -91,6 +92,7 @@ "@tiptap/pm": "2.27.1", "@tiptap/react": "2.27.1", "@tiptap/starter-kit": "2.27.1", + "@tiptap/suggestion": "2.27.1", "@types/crypto-js": "4.2.2", "@types/react-syntax-highlighter": "15.5.13", "@vimeo/player": "2.30.2", @@ -143,6 +145,7 @@ "tailwindcss-animate": "1.0.7", "ts-pattern": "5.2.0", "tus-js-client": "4.1.0", + "uuid": "11.1.0", "vaul": "1.1.2", "video.js": "8.23.6", "videojs-contrib-quality-levels": "4.1.0", diff --git a/apps/web/routes.ts b/apps/web/routes.ts index b179b0de36..856ffdd524 100644 --- a/apps/web/routes.ts +++ b/apps/web/routes.ts @@ -78,6 +78,8 @@ export const routes: ( route("groups", "modules/Admin/Groups/Groups.page.tsx"); route("groups/new", "modules/Admin/Groups/CreateGroup.page.tsx"); route("groups/:id", "modules/Admin/Groups/EditGroup.page.tsx"); + route("email-templates", "modules/Admin/EmailTemplates/EmailTemplates.page.tsx"); + route("email-templates/:id", "modules/Admin/EmailTemplates/EditEmailTemplate.page.tsx"); route("promotion-codes", "modules/Admin/PromotionCodes/PromotionCodes.page.tsx"); route("promotion-codes/new", "modules/Admin/PromotionCodes/CreatePromotionCode.page.tsx"); route( diff --git a/docs/specs/email-notification-templates-business-spec.md b/docs/specs/email-notification-templates-business-spec.md new file mode 100644 index 0000000000..d73514bdbf --- /dev/null +++ b/docs/specs/email-notification-templates-business-spec.md @@ -0,0 +1,59 @@ +# Email Notification Templates Business Spec + +## Business Overview + +Email notification templates let HR and L&D teams control the messages Mentingo sends around learning workflows without relying on engineering for every wording or layout update. Administrators can create reusable email templates, edit subject lines and body content, manage translations, send themselves a test email, and decide whether a template is draft, published, or archived. + +The feature matters because learning communication is part of the learner experience. Clear, branded, localized email content helps learners recognize required actions, understand training context, and trust that a message belongs to their organization. + +The main workflow starts from the admin email templates list. A manager creates or opens a template, edits the subject and email body in a visual builder, chooses available languages, reviews inline diagnostics next to the exact content that needs attention, and publishes only when blocking errors are resolved. + +## Who Uses It + +- HR and L&D administrators maintain organization-specific learning emails so learners receive clear and branded communication. +- Training operations managers duplicate existing templates when a new campaign or language variant needs similar structure with different wording. +- Platform administrators review template status, archive obsolete templates, and send test emails before learner-facing delivery changes go live. + +## Feature Functions + +- Create and edit email templates with a subject line and visual body blocks; new templates start from a centered branded logo, heading 2, paragraph, call-to-action button, divider, and footer structure, with starter placeholder text populated in the selected base language. +- Manage multilingual versions by selecting available locales, setting the base language, and editing translated content in the same builder. +- Show diagnostics inline beside affected builder nodes so missing text, missing button targets, invalid URLs, and untranslated content are easier to fix in context. +- Keep orphan diagnostics, such as missing template name, missing footer, or missing logo branding, at the bottom of the builder when they do not belong to a specific content block. +- Send test emails in an available language with tenant colors and the tenant logo, or Mentingo branding when no tenant logo is configured, resolved into the rendered message before publishing. +- Duplicate templates with fresh internal block identifiers so copied templates can be edited independently. +- Move templates through draft, published, archived, and restored draft states. +- Delete one or several templates from the admin list when they are no longer needed. +- Restrict the feature to users with email template management access. + +## End-User Value + +Email templates improve operational consistency by letting non-engineering administrators update learner communication quickly. Inline diagnostics reduce publishing friction because editors can see what is wrong where they are working, instead of interpreting a detached checklist. Multilingual editing supports organizations that deliver training across language groups, and test sends help teams verify the learner-facing result before publication. + +## How It Works + +An administrator opens the email templates area, filters or selects a template, and edits it in a builder with a subject card and email body canvas. New templates start with a simple centered branded layout: organization logo, secondary heading, paragraph text, call-to-action button, divider, and footer, and the starter text follows the base language chosen at creation. The builder keeps structural content in the base template and stores translated fragments by language, so the editor can switch languages while preserving the same email layout. + +Mentingo calculates diagnostics from the template name, available languages, subject, body blocks, button configuration, URLs, footer, logo branding, and translation content. Warnings are shown in yellow and do not block publishing. Errors are shown in red and continue to block save/publish flows for published templates or publish attempts. Diagnostics attached to a known block appear next to that block; diagnostics without a live block target appear below the template body with visual spacing. + +Draft and published templates can keep a button without a target URL when an administrator wants the button as a visual placeholder or intends to finish the destination later. Mentingo shows the missing button URL as a nonblocking warning note, while the backend URL safety layer still rejects dangerous URL schemes such as script-based links. + +When an administrator adds a new content block in a translated version, Mentingo waits until the editor leaves that new block before showing its missing-translation warning. Other diagnostics still appear immediately, and untouched new blocks or programmatic changes do not keep warnings hidden after focus is resolved. + +When templates are rendered for backend preview or test-send flows, Mentingo uses the selected available language with tenant branding such as the primary color and logo. If the tenant has not configured a logo, the builder and rendered output use the Mentingo logo instead of exposing the internal logo placeholder. Preview HTML uses a browser-readable logo URL, while sent test emails embed the logo as an inline email image so mailbox clients can display it inside the message body. The backend rejects unsupported or duplicate language configuration, rejects unavailable preview/test languages, checks template name uniqueness, prunes translations for deleted blocks, and queues unused uploaded email images for cleanup after updates or deletes. + +## Key Technical Context + +- The admin routes are `admin/email-templates` and `admin/email-templates/:id`, gated in route access by `PERMISSIONS.EMAIL_TEMPLATE_MANAGE`. +- The main frontend module is `apps/web/app/modules/Admin/EmailTemplates`, including the list page, edit page, Maily-based builder, language selector, diagnostics, and image upload handling. +- The main API module is `apps/api/src/email-notification-templates`, with endpoints for list, create, update, publish, make draft, archive, unarchive, delete, duplicate, preview, and test-send. +- Shared language, diagnostic, and branding contracts live in `@repo/shared`, including supported languages, email template node types, `computeEmailTemplateDiagnostics`, and the tenant-logo variable/CID constants. +- Backend template creation seeds the default body blocks with localized placeholder text based on the selected base language when no custom blocks are supplied. +- A BullMQ cleanup worker purges uploaded email-template images only after confirming they are no longer referenced by another template. +- Inline diagnostics are a frontend safety layer; backend validation still protects language configuration, URL safety, preview/test language availability, logo-variable rendering, and template persistence. + +## Test Evidence + +Backend unit tests cover locale validation, unique template names, auto-generated names, duplicate naming and block re-keying, preview and test-send language behavior, tenant color and logo rendering, inline email logo attachments, translation pruning, queued image cleanup, deletion, and status transitions. Focused URL-safety coverage verifies that freshly created default draft blocks can be saved with an empty starter button URL, publishing is not blocked by that warning, and unsafe protocols are still rejected. + +Frontend unit tests cover the email builder upload handler, translation-mode wiring, inline diagnostic rendering and placement safeguards, delayed missing-translation warnings for newly added blocks, inline note severity styling, language tag visibility, and diagnostic reason rendering. Playwright E2E coverage verifies that an admin can create a template, rename it, edit and save the subject, reload the edit page, see the template in the list, and delete it. diff --git a/docs/test-plans/email-template-builder-test-plan.md b/docs/test-plans/email-template-builder-test-plan.md new file mode 100644 index 0000000000..c0349c65d7 --- /dev/null +++ b/docs/test-plans/email-template-builder-test-plan.md @@ -0,0 +1,189 @@ +# Email Template Builder Test Plan + +## Current Coverage Summary + +The email template builder has useful unit coverage, but it does not have full automated coverage. + +Covered today: + +- Backend email notification template service behavior, including locale validation, name conflicts, auto-naming, duplication, preview rendering, test sends, update/delete image cleanup, orphan image purge, and status transitions. +- Backend image upload controller E2E behavior for authentication, permission checks, successful image upload, oversized files, and invalid file types. +- Frontend builder editor unit behavior for image upload handling, base-language edits, translation-mode edits, inline diagnostics, diagnostic anchoring, string extraction, language flattening, logo handling, and structural base-content restoration. + +Not covered today: + +- Main email notification template API controller E2E flows for list, create, update, delete, publish, archive, unarchive, preview, test-send, and duplicate. +- Full frontend page behavior for the email template list and edit builder pages. +- Browser-level Playwright coverage for the real administrator builder workflow. +- Automated tests in the `@repo/email-templates` package itself; it currently has generated HTML fixtures but no package-level test script. + +## Goals + +- Add API E2E tests for all externally exposed email notification template endpoints. +- Add frontend component/page tests around the list and edit-builder workflow orchestration. +- Add one high-value Playwright E2E scenario that exercises the real UI and API integration. +- Optionally add automated smoke coverage for the standalone React email template package. + +## API E2E Coverage + +Add a new E2E spec for `EmailNotificationTemplatesController`. + +Target routes: + +- `GET /api/email-notification-templates` +- `POST /api/email-notification-templates` +- `GET /api/email-notification-templates/:id` +- `PATCH /api/email-notification-templates/:id` +- `DELETE /api/email-notification-templates/:id` +- `DELETE /api/email-notification-templates/bulk` +- `POST /api/email-notification-templates/:id/publish` +- `POST /api/email-notification-templates/:id/make-draft` +- `POST /api/email-notification-templates/:id/archive` +- `POST /api/email-notification-templates/:id/unarchive` +- `POST /api/email-notification-templates/:id/preview` +- `POST /api/email-notification-templates/:id/test-send` +- `POST /api/email-notification-templates/:id/duplicate` + +Required assertions: + +- Unauthenticated requests return `401`. +- Users without `PERMISSIONS.EMAIL_TEMPLATE_MANAGE` return `403`. +- Authorized admins can create, list, fetch, update, duplicate, delete, and bulk-delete templates. +- Status transition endpoints move templates to the expected status. +- Publishing is blocked when diagnostics contain blocking errors. +- Preview returns rendered `subject`, `html`, and resolved `language`. +- Test-send sends to the current user through the test email adapter. +- Cross-tenant access is blocked by tenant scoping. +- Responses use `BaseResponse` or `PaginatedResponse` shapes. + +## Backend Unit Coverage + +Keep existing service and utility tests, then add missing unit coverage only where E2E would be too heavy. + +Recommended additions: + +- Controller-level tests are not required if the E2E spec covers route validation, permissions, response wrappers, and delegation. +- Add unit tests only for any uncovered helper extracted during E2E setup. +- Add a cleanup queue/worker unit test if the worker behavior is not indirectly covered by service tests. + +## Frontend List Page Coverage + +Add component tests for `EmailTemplates.page.tsx`. + +Required assertions: + +- Loading state renders while templates are loading. +- Error state renders when the list query fails. +- Empty state renders when the API returns no templates. +- Template rows render with expected names/statuses. +- Name and status filters update query parameters and reset pagination. +- Pagination controls update page/per-page state. +- Create button calls the create mutation with the current UI language and navigates to the edit page. +- Row click navigates to the edit page. +- Single selected row calls the single-delete mutation. +- Multiple selected rows call the bulk-delete mutation. + +## Frontend Edit Builder Page Coverage + +Add component tests for `EditEmailTemplate.page.tsx`. + +Required assertions: + +- Loading and load-failed states render correctly. +- Save calls the update mutation with current `name`, `subject`, `blocks`, `strings`, `baseLanguage`, and `availableLocales`. +- Save is blocked for published templates when blocking diagnostics exist. +- Publish is blocked when blocking diagnostics exist. +- Publish, make-draft, archive, and unarchive call the correct mutation for each target status. +- Dirty form state is saved before a status change. +- Duplicate calls the duplicate mutation and navigates to the duplicated template. +- Send test email saves a dirty form before calling the test-send mutation. +- Inline rename commits on Enter and cancels on Escape/blur. +- Adding a language updates `availableLocales` and initializes `strings[language]`. +- Removing a language removes it from `availableLocales` and deletes `strings[language]`. +- Setting a new base language uses `swapBaseLanguageContent`. +- Subject edits write to the selected language. + +## Existing Frontend Builder Unit Coverage To Preserve + +Keep the current tests around: + +- `EmailTemplateEditor` upload and editor-update behavior. +- Inline diagnostics and diagnostic anchor measurement. +- `extractStringsFromDoc`. +- `flattenForLanguage`. +- `applyStructuralChangesToBase`. +- `swapBaseLanguageContent`. +- `logoHeader`. +- Tiptap extensions for variable highlighting and UUID handling. + +When adding new behavior to the builder canvas, prefer focused unit tests around the smallest changed utility or editor callback before adding broad page tests. + +## Playwright E2E Coverage + +Add one high-value browser scenario for the administrator workflow. + +Scenario: + +1. Log in as an admin with `EMAIL_TEMPLATE_MANAGE`. +2. Open the email templates list. +3. Create a new template. +4. Rename it. +5. Edit the base-language subject and body. +6. Add a second language. +7. Verify diagnostics appear for missing or unchanged translation content. +8. Fill the translated subject/body enough to clear blocking errors. +9. Save the template. +10. Send a test email. +11. Publish the template. +12. Assert the final status is published in the UI and via API-backed state. + +Use existing E2E factories, fixtures, selectors, and cleanup patterns. Add stable `data-testid` handles where the builder currently lacks reliable selectors. + +## `@repo/email-templates` Package Coverage + +The package currently has snapshot fixtures but no test script. Add a small package-level smoke test only if the team wants automated guardrails around the rendered React email exports. + +Recommended smoke checks: + +- Every exported template used by the fixture generator is exported. +- Rendering each template with fixed sample props returns non-empty HTML. +- The rendered HTML contains the expected subject/body anchor text for the sample case. +- No template throws during render. + +Avoid asserting large HTML snapshots unless they are intentionally maintained as a regression baseline. + +## Suggested Implementation Order + +1. Add API E2E coverage for create/list/get/update/delete/publish. +2. Add API E2E coverage for preview/test-send/duplicate/archive/unarchive/bulk-delete. +3. Add list page component tests. +4. Add edit builder page component tests. +5. Add missing stable E2E selectors. +6. Add the Playwright administrator workflow. +7. Decide whether to add package-level smoke tests for `@repo/email-templates`. + +## Validation Commands + +Run focused commands after each group of changes: + +```sh +pnpm --filter=api test -- email-notification-templates +pnpm --filter=api test:e2e -- email-template +pnpm --filter=web test -- EmailTemplates +pnpm --filter=web test -- EmailTemplateEditor +``` + +Run broader checks before merging: + +```sh +pnpm lint-tsc-api +pnpm lint-tsc-web +pnpm --filter=web test:e2e -- email-template +``` + +If package-level smoke tests are added: + +```sh +pnpm --filter @repo/email-templates build +pnpm --filter @repo/email-templates test +``` diff --git a/licenses.config.yaml b/licenses.config.yaml index 88e97dee9a..81793cc56d 100644 --- a/licenses.config.yaml +++ b/licenses.config.yaml @@ -32,3 +32,6 @@ allowedPackages: - caniuse-lite@1.0.30001684 # CC-BY-4.0 - "@promptbook/utils@0.69.5" # CC-BY-4.0 - spdx-exceptions@2.5.0 # CC-BY-3.0 + - "@maily-to/core@0.3.7" # MIT + - "@maily-to/render@0.2.3" # MIT + - slick@1.12.2 # MIT diff --git a/packages/email-templates/package.json b/packages/email-templates/package.json index e7120c3db8..40f2b5e980 100644 --- a/packages/email-templates/package.json +++ b/packages/email-templates/package.json @@ -9,14 +9,16 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "dev": "email dev -p 3001 --dir ./src/templates", - "export": "email export --dir ./src/templates" + "export": "email export --dir ./src/templates", + "test": "pnpm run build && node --test test/*.test.mjs" }, "dependencies": { - "@react-email/components": "^0.0.17", + "@react-email/components": "^0.5.1", + "@react-email/render": "^1.3.4", + "@repo/shared": "workspace:*", "react": "^18.2.0", - "react-email": "^2.1.4", - "@repo/shared": "workspace:*" + "react-dom": "18.3.1", + "react-email": "^4.0.4" }, "devDependencies": { "@babel/generator": "^7.24.7", @@ -27,6 +29,7 @@ "@repo/typescript-config": "workspace:*", "@types/node": "22.15.0", "@types/react": "^18.2.61", + "@types/react-dom": "18.3.1", "tsup": "^8.0.2", "typescript": "^5.4.5" } diff --git a/packages/email-templates/scripts/snapshot-fixtures.mjs b/packages/email-templates/scripts/snapshot-fixtures.mjs new file mode 100644 index 0000000000..e593acb9d7 --- /dev/null +++ b/packages/email-templates/scripts/snapshot-fixtures.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// Renders every email template with fixed sample props and writes HTML fixtures. +// Used as a regression baseline around the react-email major-version bump. +// +// Usage: +// pnpm --filter @repo/email-templates build (rebuild dist first) +// node packages/email-templates/scripts/snapshot-fixtures.mjs +// +// Diff test/fixtures/ before vs after the bump. Anything more than cosmetic +// (whitespace / attribute ordering) means the templates need adjustment. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const pkg = require("../dist/index.cjs"); + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = join(here, "..", "test", "fixtures"); +mkdirSync(outDir, { recursive: true }); + +const defaults = { primaryColor: "#4796FD", companyName: "Mentingo", language: "en" }; + +const cases = [ + ["AnnouncementEmail", { title: "System maintenance", content: "We will be doing maintenance tomorrow.", buttonLink: "https://example.com/notice", ...defaults }], + ["BaseEmailTemplate", { heading: "Hello", paragraphs: ["Line one.", "Line two."], buttonText: "Open", buttonLink: "https://example.com", primaryColor: defaults.primaryColor, companyName: defaults.companyName }], + ["CertificateExpirationWarningEmail", { courseName: "TypeScript Fundamentals", courseLink: "https://example.com/course", expiresAt: "2026-08-01", ...defaults }], + ["CertificateExpiredEmail", { courseName: "TypeScript Fundamentals", courseLink: "https://example.com/course", reason: "expired", ...defaults }], + ["CourseDueDateReminderEmail", { courseName: "TypeScript Fundamentals", courseLink: "https://example.com/course", dueDate: "2026-08-01", daysBeforeDueDate: 7, ...defaults }], + ["CreatePasswordReminderEmail", { createPasswordLink: "https://example.com/create-password", ...defaults }], + ["FinishedCourseEmail", { userName: "Jane Doe", courseName: "TypeScript Fundamentals", progressLink: "https://example.com/course", ...defaults }], + ["LiveTrainingEndedEmail", { title: "Kickoff meeting", content: "Thanks for attending.", liveTrainingLink: "https://example.com/lt", ...defaults }], + ["LiveTrainingReminderEmail", { title: "Kickoff meeting", content: "Starts in 30 minutes.", liveTrainingLink: "https://example.com/lt", ...defaults }], + ["LiveTrainingStartedEmail", { title: "Kickoff meeting", content: "The session has started.", liveTrainingLink: "https://example.com/lt", ...defaults }], + ["MagicLinkEmail", { magicLink: "https://example.com/magic?token=abc", ...defaults }], + ["NewUserEmail", { userName: "Jane Doe", profileLink: "https://example.com/profile", ...defaults }], + ["OverdueCoursesEmail", { + courses: [ + { + courseTitle: "TypeScript Fundamentals", + groups: [ + { groupName: "Team Alpha", dueDate: "2026-07-01", students: [{ name: "Jane Doe", email: "jane@example.com" }, { name: "John Roe", email: "john@example.com" }] }, + ], + }, + ], + coursesLink: "https://example.com/courses", + ...defaults, + }], + ["PasswordRecoveryEmail", { name: "Jane", resetLink: "https://example.com/reset?token=abc", ...defaults }], + ["UserAssignedToCourseEmail", { courseName: "TypeScript Fundamentals", ...defaults }], + ["UserFinishedChapterEmail", { courseName: "TypeScript Fundamentals", ...defaults }], + ["UserFinishedCourseEmail", { courseName: "TypeScript Fundamentals", ...defaults }], + ["UserFirstLoginEmail", { name: "Jane", coursesUrl: "https://example.com/courses", ...defaults }], + ["UserInviteEmail", { invitedByUserName: "Admin User", createPasswordLink: "https://example.com/create-password", ...defaults }], + ["UserLongInactivityEmail", { courseName: "TypeScript Fundamentals", courseLink: "https://example.com/course", ...defaults }], + ["UserShortInactivityEmail", { courseName: "TypeScript Fundamentals", courseLink: "https://example.com/course", ...defaults }], + ["WelcomeEmail", { coursesLink: "https://example.com/courses", ...defaults }], +]; + +const rendered = []; +for (const [name, props] of cases) { + const Ctor = pkg[name]; + if (!Ctor) { + console.error(`missing export: ${name}`); + process.exitCode = 1; + continue; + } + const instance = new Ctor(props); + const htmlRaw = instance.html; + const html = typeof htmlRaw?.then === "function" ? await htmlRaw : htmlRaw; + writeFileSync(join(outDir, `${name}.html`), html); + rendered.push(name); +} + +console.log(`wrote ${rendered.length} fixtures to ${outDir}`); diff --git a/packages/email-templates/src/email-content.ts b/packages/email-templates/src/email-content.ts index 4922cb28a9..4f89b180e6 100644 --- a/packages/email-templates/src/email-content.ts +++ b/packages/email-templates/src/email-content.ts @@ -1,4 +1,4 @@ export interface EmailContent { - text: string; - html: string; + text: Promise; + html: Promise; } diff --git a/packages/email-templates/src/email-factory.ts b/packages/email-templates/src/email-factory.ts index 5ea0af36b3..7e71c98947 100644 --- a/packages/email-templates/src/email-factory.ts +++ b/packages/email-templates/src/email-factory.ts @@ -1,8 +1,11 @@ -import { render } from "@react-email/components"; +import { renderToStaticMarkup } from "react-dom/server"; + import { EmailContent } from "./email-content"; +import type { ReactElement } from "react"; + export function emailTemplateFactory( - template: (...args: T) => Parameters[0], + template: (...args: T) => ReactElement, ): new (...args: T) => EmailContent { return class implements EmailContent { private readonly args: T; @@ -15,12 +18,41 @@ export function emailTemplateFactory( return this.args; } - get text(): string { - return render(template(...this.props), { plainText: true }); + get text(): Promise { + return Promise.resolve(toPlainText(this.renderDocument())); + } + + get html(): Promise { + return Promise.resolve(this.renderDocument()); } - get html(): string { - return render(template(...this.props)); + private renderDocument(): string { + const html = renderToStaticMarkup(template(...this.props)); + return `${html.replace(//, "")}`; } }; } + +const toPlainText = (html: string): string => + decodeHtmlEntities( + html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, "$2 $1") + .replace(/<\/(p|div|section|tr|table|h[1-6]|li)>/gi, "\n") + .replace(//gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/[ \t\f\v]+/g, " ") + .replace(/\s*\n\s*/g, "\n") + .trim(), + ); + +const decodeHtmlEntities = (text: string): string => + text + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); diff --git a/packages/email-templates/test/fixtures/AnnouncementEmail.html b/packages/email-templates/test/fixtures/AnnouncementEmail.html new file mode 100644 index 0000000000..4a1a979070 --- /dev/null +++ b/packages/email-templates/test/fixtures/AnnouncementEmail.html @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/BaseEmailTemplate.html b/packages/email-templates/test/fixtures/BaseEmailTemplate.html new file mode 100644 index 0000000000..a00a864303 --- /dev/null +++ b/packages/email-templates/test/fixtures/BaseEmailTemplate.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/CertificateExpirationWarningEmail.html b/packages/email-templates/test/fixtures/CertificateExpirationWarningEmail.html new file mode 100644 index 0000000000..85e3343093 --- /dev/null +++ b/packages/email-templates/test/fixtures/CertificateExpirationWarningEmail.html @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/CertificateExpiredEmail.html b/packages/email-templates/test/fixtures/CertificateExpiredEmail.html new file mode 100644 index 0000000000..1bb74898cb --- /dev/null +++ b/packages/email-templates/test/fixtures/CertificateExpiredEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/CourseDueDateReminderEmail.html b/packages/email-templates/test/fixtures/CourseDueDateReminderEmail.html new file mode 100644 index 0000000000..045cd3f4df --- /dev/null +++ b/packages/email-templates/test/fixtures/CourseDueDateReminderEmail.html @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/CreatePasswordReminderEmail.html b/packages/email-templates/test/fixtures/CreatePasswordReminderEmail.html new file mode 100644 index 0000000000..be4cb95e61 --- /dev/null +++ b/packages/email-templates/test/fixtures/CreatePasswordReminderEmail.html @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/FinishedCourseEmail.html b/packages/email-templates/test/fixtures/FinishedCourseEmail.html new file mode 100644 index 0000000000..b8f3698e97 --- /dev/null +++ b/packages/email-templates/test/fixtures/FinishedCourseEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/LiveTrainingEndedEmail.html b/packages/email-templates/test/fixtures/LiveTrainingEndedEmail.html new file mode 100644 index 0000000000..06cfcf97ae --- /dev/null +++ b/packages/email-templates/test/fixtures/LiveTrainingEndedEmail.html @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/LiveTrainingReminderEmail.html b/packages/email-templates/test/fixtures/LiveTrainingReminderEmail.html new file mode 100644 index 0000000000..530c3b2bf9 --- /dev/null +++ b/packages/email-templates/test/fixtures/LiveTrainingReminderEmail.html @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/LiveTrainingStartedEmail.html b/packages/email-templates/test/fixtures/LiveTrainingStartedEmail.html new file mode 100644 index 0000000000..ac2b0f47ca --- /dev/null +++ b/packages/email-templates/test/fixtures/LiveTrainingStartedEmail.html @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/MagicLinkEmail.html b/packages/email-templates/test/fixtures/MagicLinkEmail.html new file mode 100644 index 0000000000..f4c631ac8a --- /dev/null +++ b/packages/email-templates/test/fixtures/MagicLinkEmail.html @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/NewUserEmail.html b/packages/email-templates/test/fixtures/NewUserEmail.html new file mode 100644 index 0000000000..b71b0cf4af --- /dev/null +++ b/packages/email-templates/test/fixtures/NewUserEmail.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/OverdueCoursesEmail.html b/packages/email-templates/test/fixtures/OverdueCoursesEmail.html new file mode 100644 index 0000000000..a74be69655 --- /dev/null +++ b/packages/email-templates/test/fixtures/OverdueCoursesEmail.html @@ -0,0 +1,471 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/PasswordRecoveryEmail.html b/packages/email-templates/test/fixtures/PasswordRecoveryEmail.html new file mode 100644 index 0000000000..24cc8be278 --- /dev/null +++ b/packages/email-templates/test/fixtures/PasswordRecoveryEmail.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserAssignedToCourseEmail.html b/packages/email-templates/test/fixtures/UserAssignedToCourseEmail.html new file mode 100644 index 0000000000..d4a33f422b --- /dev/null +++ b/packages/email-templates/test/fixtures/UserAssignedToCourseEmail.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserFinishedChapterEmail.html b/packages/email-templates/test/fixtures/UserFinishedChapterEmail.html new file mode 100644 index 0000000000..0353bdf7c6 --- /dev/null +++ b/packages/email-templates/test/fixtures/UserFinishedChapterEmail.html @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserFinishedCourseEmail.html b/packages/email-templates/test/fixtures/UserFinishedCourseEmail.html new file mode 100644 index 0000000000..9f43cf7d26 --- /dev/null +++ b/packages/email-templates/test/fixtures/UserFinishedCourseEmail.html @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserFirstLoginEmail.html b/packages/email-templates/test/fixtures/UserFirstLoginEmail.html new file mode 100644 index 0000000000..0aef921bc4 --- /dev/null +++ b/packages/email-templates/test/fixtures/UserFirstLoginEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserInviteEmail.html b/packages/email-templates/test/fixtures/UserInviteEmail.html new file mode 100644 index 0000000000..61dedfc254 --- /dev/null +++ b/packages/email-templates/test/fixtures/UserInviteEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserLongInactivityEmail.html b/packages/email-templates/test/fixtures/UserLongInactivityEmail.html new file mode 100644 index 0000000000..0d82533e33 --- /dev/null +++ b/packages/email-templates/test/fixtures/UserLongInactivityEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/UserShortInactivityEmail.html b/packages/email-templates/test/fixtures/UserShortInactivityEmail.html new file mode 100644 index 0000000000..0b7c570664 --- /dev/null +++ b/packages/email-templates/test/fixtures/UserShortInactivityEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/fixtures/WelcomeEmail.html b/packages/email-templates/test/fixtures/WelcomeEmail.html new file mode 100644 index 0000000000..da900554ca --- /dev/null +++ b/packages/email-templates/test/fixtures/WelcomeEmail.html @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/packages/email-templates/test/smoke.test.mjs b/packages/email-templates/test/smoke.test.mjs new file mode 100644 index 0000000000..cbae5b7248 --- /dev/null +++ b/packages/email-templates/test/smoke.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +const require = createRequire(import.meta.url); +const templates = require("../dist/index.cjs"); + +const defaults = { primaryColor: "#4796FD", companyName: "Mentingo", language: "en" }; + +const cases = [ + [ + "AnnouncementEmail", + { + title: "System maintenance", + content: "We will be doing maintenance tomorrow.", + buttonLink: "https://example.com/notice", + ...defaults, + }, + ], + [ + "BaseEmailTemplate", + { + heading: "Hello", + paragraphs: ["Line one.", "Line two."], + buttonText: "Open", + buttonLink: "https://example.com", + primaryColor: defaults.primaryColor, + companyName: defaults.companyName, + }, + ], + [ + "CertificateExpirationWarningEmail", + { + courseName: "TypeScript Fundamentals", + courseLink: "https://example.com/course", + expiresAt: "2026-08-01", + ...defaults, + }, + ], + [ + "CertificateExpiredEmail", + { + courseName: "TypeScript Fundamentals", + courseLink: "https://example.com/course", + reason: "expired", + ...defaults, + }, + ], + [ + "CourseDueDateReminderEmail", + { + courseName: "TypeScript Fundamentals", + courseLink: "https://example.com/course", + dueDate: "2026-08-01", + daysBeforeDueDate: 7, + ...defaults, + }, + ], + [ + "CreatePasswordReminderEmail", + { createPasswordLink: "https://example.com/create-password", ...defaults }, + ], + [ + "FinishedCourseEmail", + { + userName: "Jane Doe", + courseName: "TypeScript Fundamentals", + progressLink: "https://example.com/course", + ...defaults, + }, + ], + [ + "LiveTrainingEndedEmail", + { + title: "Kickoff meeting", + content: "Thanks for attending.", + liveTrainingLink: "https://example.com/lt", + ...defaults, + }, + ], + [ + "LiveTrainingReminderEmail", + { + title: "Kickoff meeting", + content: "Starts in 30 minutes.", + liveTrainingLink: "https://example.com/lt", + ...defaults, + }, + ], + [ + "LiveTrainingStartedEmail", + { + title: "Kickoff meeting", + content: "The session has started.", + liveTrainingLink: "https://example.com/lt", + ...defaults, + }, + ], + ["MagicLinkEmail", { magicLink: "https://example.com/magic?token=abc", ...defaults }], + [ + "NewUserEmail", + { userName: "Jane Doe", profileLink: "https://example.com/profile", ...defaults }, + ], + [ + "OverdueCoursesEmail", + { + courses: [ + { + courseTitle: "TypeScript Fundamentals", + groups: [ + { + groupName: "Team Alpha", + dueDate: "2026-07-01", + students: [ + { name: "Jane Doe", email: "jane@example.com" }, + { name: "John Roe", email: "john@example.com" }, + ], + }, + ], + }, + ], + coursesLink: "https://example.com/courses", + ...defaults, + }, + ], + [ + "PasswordRecoveryEmail", + { name: "Jane", resetLink: "https://example.com/reset?token=abc", ...defaults }, + ], + ["UserAssignedToCourseEmail", { courseName: "TypeScript Fundamentals", ...defaults }], + ["UserFinishedChapterEmail", { courseName: "TypeScript Fundamentals", ...defaults }], + ["UserFinishedCourseEmail", { courseName: "TypeScript Fundamentals", ...defaults }], + ["UserFirstLoginEmail", { name: "Jane", coursesUrl: "https://example.com/courses", ...defaults }], + [ + "UserInviteEmail", + { + invitedByUserName: "Admin User", + createPasswordLink: "https://example.com/create-password", + ...defaults, + }, + ], + [ + "UserLongInactivityEmail", + { + courseName: "TypeScript Fundamentals", + courseLink: "https://example.com/course", + ...defaults, + }, + ], + [ + "UserShortInactivityEmail", + { + courseName: "TypeScript Fundamentals", + courseLink: "https://example.com/course", + ...defaults, + }, + ], + ["WelcomeEmail", { coursesLink: "https://example.com/courses", ...defaults }], +]; + +describe("@repo/email-templates", () => { + for (const [name, props] of cases) { + it(`renders ${name} to html and text`, async () => { + const Template = templates[name]; + + assert.equal(typeof Template, "function"); + + const instance = new Template(props); + const [html, text] = await Promise.all([instance.html, instance.text]); + + assert.match(html, /^ 0); + assert.doesNotMatch(text, /<[^>]+>/); + }); + } +}); diff --git a/packages/prompts/src/generated-prompts.ts b/packages/prompts/src/generated-prompts.ts index c0e15dddae..98d69a0a0f 100644 --- a/packages/prompts/src/generated-prompts.ts +++ b/packages/prompts/src/generated-prompts.ts @@ -1,5 +1,5 @@ /* AUTO-GENERATED FILE - DO NOT EDIT BY HAND */ -/* Generated At: 7/23/2026, 2:00:35 PM */ +/* Generated At: 7/31/2026, 9:15:16 AM */ export const promptTemplates = { aiJudgeConfigurationGeneratorBase: { diff --git a/packages/shared/plugin.mjs b/packages/shared/plugin.mjs index 686864c094..0ec518045a 100644 --- a/packages/shared/plugin.mjs +++ b/packages/shared/plugin.mjs @@ -6,7 +6,7 @@ const generateCentralBarrel = () => { const srcDir = path.resolve(process.cwd(), "src"); const files = globSync("**/*.{ts,tsx}", { cwd: srcDir, - ignore: ["**/index.ts", "**/*.d.ts"], + ignore: ["**/index.ts", "**/*.d.ts", "**/__tests__/**", "**/*.spec.ts", "**/*.test.ts"], }); files.sort(); diff --git a/packages/shared/src/constants/emailTemplateNodeTypes.ts b/packages/shared/src/constants/emailTemplateNodeTypes.ts new file mode 100644 index 0000000000..51f940be29 --- /dev/null +++ b/packages/shared/src/constants/emailTemplateNodeTypes.ts @@ -0,0 +1,28 @@ +export const EMAIL_TEMPLATE_NODE_TYPES = { + DOC: "doc", + PARAGRAPH: "paragraph", + HEADING: "heading", + TEXT: "text", + IMAGE: "image", + BUTTON: "button", + FOOTER: "footer", + DIVIDER: "divider", + SPACER: "spacer", + SECTION: "section", + COLUMNS: "columns", + COLUMN: "column", + HORIZONTAL_RULE: "horizontalRule", + VARIABLE: "variable", +} as const; + +export type EmailTemplateNodeType = + (typeof EMAIL_TEMPLATE_NODE_TYPES)[keyof typeof EMAIL_TEMPLATE_NODE_TYPES]; + +export const TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES = new Set([ + EMAIL_TEMPLATE_NODE_TYPES.HEADING, + EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + EMAIL_TEMPLATE_NODE_TYPES.FOOTER, +]); + +export const EMAIL_TEMPLATE_NODE_UUID_ATTR = "uuid"; diff --git a/packages/shared/src/constants/emailTemplates.ts b/packages/shared/src/constants/emailTemplates.ts new file mode 100644 index 0000000000..dac888298f --- /dev/null +++ b/packages/shared/src/constants/emailTemplates.ts @@ -0,0 +1,14 @@ +export const EMAIL_TEMPLATE_STATUSES = { + DRAFT: "draft", + PUBLISHED: "published", + ARCHIVED: "archived", +} as const; + +export type EmailTemplateStatus = + (typeof EMAIL_TEMPLATE_STATUSES)[keyof typeof EMAIL_TEMPLATE_STATUSES]; + +export const TENANT_LOGO_VARIABLE = "{{branding.logo_url}}"; +export const TENANT_LOGO_CID = "logo"; +export const TENANT_LOGO_CID_SRC = `cid:${TENANT_LOGO_CID}`; +export const DEFAULT_TENANT_PRIMARY_COLOR = "#4796FD"; +export const DEFAULT_PLATFORM_LOGO_PATH = "/app/assets/svgs/app-logo.svg"; diff --git a/packages/shared/src/constants/permissions.ts b/packages/shared/src/constants/permissions.ts index ff155d96c4..20e65f8763 100644 --- a/packages/shared/src/constants/permissions.ts +++ b/packages/shared/src/constants/permissions.ts @@ -67,6 +67,7 @@ export const PERMISSIONS = { ANNOUNCEMENT_READ: "announcement.read", ANNOUNCEMENT_CREATE: "announcement.create", ANNOUNCEMENT_DELETE: "announcement.delete", + EMAIL_TEMPLATE_MANAGE: "email_template.manage", NEWS_READ_PUBLIC: "news.read_public", NEWS_MANAGE: "news.manage", NEWS_MANAGE_OWN: "news.manage_own", @@ -248,6 +249,7 @@ export const SYSTEM_ROLE_PERMISSIONS: Record = { PERMISSIONS.ANNOUNCEMENT_READ, PERMISSIONS.ANNOUNCEMENT_CREATE, PERMISSIONS.ANNOUNCEMENT_DELETE, + PERMISSIONS.EMAIL_TEMPLATE_MANAGE, PERMISSIONS.NEWS_MANAGE, PERMISSIONS.NEWS_READ_PUBLIC, PERMISSIONS.ARTICLE_MANAGE, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1d8a652e7c..49ca5d587e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,8 @@ export * from "./constants/course"; export * from "./constants/courseChat"; export * from "./constants/courseDuplication"; export * from "./constants/courseEnrollment"; +export * from "./constants/emailTemplateNodeTypes"; +export * from "./constants/emailTemplates"; export * from "./constants/entityTypes"; export * from "./constants/features"; export * from "./constants/fileTypes"; @@ -44,11 +46,14 @@ export * from "./constants/voiceAction"; export * from "./constants/voiceModeState"; export * from "./types/Announcement"; export * from "./types/audioTypes"; +export * from "./types/emailNotificationTemplate"; export * from "./types/localization"; export * from "./types/onboarding"; export * from "./types/videoUploadTypes"; export * from "./types/voiceSocketEvents"; +export * from "./utils/EmailTemplateDiagnostics"; export * from "./utils/certificate"; +export * from "./utils/emailTemplateNode"; export * from "./utils/permissions"; export * from "./utils/uiMessage"; export * from "./utils/videoCoverage"; diff --git a/packages/shared/src/types/emailNotificationTemplate.ts b/packages/shared/src/types/emailNotificationTemplate.ts new file mode 100644 index 0000000000..e23830a952 --- /dev/null +++ b/packages/shared/src/types/emailNotificationTemplate.ts @@ -0,0 +1,37 @@ +import type { LocalizedText } from "./localization"; +import type { SupportedLanguages } from "../constants/languages"; +import type { EmailTemplateStatus } from "../constants/emailTemplates"; + +export interface EmailTemplateNode { + type?: string; + attrs?: Record; + content?: EmailTemplateNode[]; + marks?: Array<{ type: string; attrs?: Record }>; + text?: string; + [key: string]: unknown; +} + +export type NodeUuid = string; + +export type TranslationFragment = EmailTemplateNode[]; + +export type EmailTemplateStrings = Partial< + Record> +>; + +export type EmailTemplateBlocks = EmailTemplateNode; + +export type EmailNotificationTemplateRow = { + id: string; + name: string; + subject: LocalizedText; + status: EmailTemplateStatus; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; + baseLanguage: SupportedLanguages; + availableLocales: SupportedLanguages[]; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; + tenantId: string; +}; diff --git a/packages/shared/src/utils/EmailTemplateDiagnostics.ts b/packages/shared/src/utils/EmailTemplateDiagnostics.ts new file mode 100644 index 0000000000..d0b9525c1a --- /dev/null +++ b/packages/shared/src/utils/EmailTemplateDiagnostics.ts @@ -0,0 +1,285 @@ +import { + EMAIL_TEMPLATE_NODE_TYPES, + TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES, + EMAIL_TEMPLATE_NODE_UUID_ATTR, +} from "../constants/emailTemplateNodeTypes"; +import { TENANT_LOGO_VARIABLE } from "../constants/emailTemplates"; + +import type { + EmailTemplateBlocks, + EmailTemplateNode, + EmailTemplateStrings, +} from "../types/emailNotificationTemplate"; +import type { LocalizedText } from "../types/localization"; +import type { SupportedLanguages } from "../constants/languages"; + +export type EmailTemplateDiagnosticSeverity = "error" | "warning"; + +export type EmailTemplateDiagnosticReason = + | "name_missing" + | "no_language_versions" + | "subject_missing" + | "body_missing" + | "button_label_missing" + | "button_url_missing" + | "empty_translation" + | "invalid_url_protocol" + | "unchanged_from_base" + | "footer_missing" + | "logo_branding_missing"; + +export type EmailTemplateDiagnostic = { + severity: EmailTemplateDiagnosticSeverity; + language?: SupportedLanguages; + nodeUuid?: string; + nodeType?: string; + reason: EmailTemplateDiagnosticReason; + detail?: string; +}; + +export type EmailTemplateDiagnosticGroups = { + byNodeUuid: Map; + orphan: EmailTemplateDiagnostic[]; +}; + +const ALLOWED_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); +const URL_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i; +const VARIABLE_PATTERN = /\{\{[^}]+\}\}/g; +const URL_ATTR_BY_NODE_TYPE: Record = { + [EMAIL_TEMPLATE_NODE_TYPES.BUTTON]: ["url"], + [EMAIL_TEMPLATE_NODE_TYPES.IMAGE]: ["src", "href"], +}; + +const compareDiagnostics = ( + left: EmailTemplateDiagnostic, + right: EmailTemplateDiagnostic, +): number => { + if (left.severity !== right.severity) return left.severity === "error" ? -1 : 1; + const reasonOrder = left.reason.localeCompare(right.reason); + if (reasonOrder !== 0) return reasonOrder; + return (left.language ?? "").localeCompare(right.language ?? ""); +}; + +const flattenText = (nodes: EmailTemplateNode[] | undefined): string => { + if (!nodes) return ""; + let out = ""; + for (const node of nodes) { + if (typeof node.text === "string") out += node.text; + if (node.content) out += flattenText(node.content); + } + return out; +}; + +const walkAllTranslatableNodes = ( + blocks: EmailTemplateBlocks, + visit: (uuid: string, node: EmailTemplateNode) => void, +) => { + const walk = (node: EmailTemplateNode) => { + if (node.type && TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) { + const uuid = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR]; + if (typeof uuid === "string" && uuid.length > 0) visit(uuid, node); + } + if (node.content) for (const child of node.content) walk(child); + }; + walk(blocks); +}; + +const walkAllNodes = (blocks: EmailTemplateBlocks, visit: (node: EmailTemplateNode) => void) => { + const walk = (node: EmailTemplateNode) => { + visit(node); + if (node.content) for (const child of node.content) walk(child); + }; + walk(blocks); +}; + +const getInvalidUrlProtocolDetail = (value: string): string | null => { + const schemeMatch = URL_SCHEME_PATTERN.exec(value); + if (schemeMatch) { + const protocol = schemeMatch[0].toLowerCase(); + return ALLOWED_URL_PROTOCOLS.has(protocol) ? null : protocol; + } + + if (value.startsWith("/")) return null; + + return "unparseable"; +}; + +export const computeEmailTemplateDiagnostics = (input: { + name?: string; + availableLocales: SupportedLanguages[]; + baseLanguage: SupportedLanguages; + subject: LocalizedText; + blocks: EmailTemplateBlocks; + strings: EmailTemplateStrings; +}): EmailTemplateDiagnostic[] => { + const diagnostics: EmailTemplateDiagnostic[] = []; + + if (typeof input.name !== "string" || !input.name.trim()) { + diagnostics.push({ severity: "error", reason: "name_missing" }); + } + + if (input.availableLocales.length === 0) { + diagnostics.push({ severity: "error", reason: "no_language_versions" }); + } + + const baseSubject = input.subject?.[input.baseLanguage]; + if (typeof baseSubject !== "string" || !baseSubject.trim()) { + diagnostics.push({ + severity: "error", + language: input.baseLanguage, + reason: "subject_missing", + }); + } + + let translatableNodeCount = 0; + let hasFooterNode = false; + let hasLogoBrandingNode = false; + walkAllNodes(input.blocks, (node) => { + if (!node.type) return; + if (TRANSLATABLE_EMAIL_TEMPLATE_NODE_TYPES.has(node.type)) translatableNodeCount += 1; + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.FOOTER) hasFooterNode = true; + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.IMAGE && node.attrs?.src === TENANT_LOGO_VARIABLE) { + hasLogoBrandingNode = true; + } + }); + + if (translatableNodeCount === 0) { + diagnostics.push({ + severity: "error", + language: input.baseLanguage, + reason: "body_missing", + }); + } + + if (!hasFooterNode) { + diagnostics.push({ severity: "warning", reason: "footer_missing" }); + } + + if (!hasLogoBrandingNode) { + diagnostics.push({ severity: "warning", reason: "logo_branding_missing" }); + } + + walkAllNodes(input.blocks, (node) => { + if (node.type !== EMAIL_TEMPLATE_NODE_TYPES.BUTTON) return; + const uuid = node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR] as string | undefined; + const rawText = node.attrs?.text; + const buttonLabel = typeof rawText === "string" ? rawText.trim() : ""; + if (!buttonLabel) { + diagnostics.push({ + severity: "error", + language: input.baseLanguage, + nodeUuid: uuid, + nodeType: node.type, + reason: "button_label_missing", + }); + } + const rawUrl = node.attrs?.url; + const buttonUrl = typeof rawUrl === "string" ? rawUrl.trim() : ""; + if (!buttonUrl) { + diagnostics.push({ + severity: "warning", + language: input.baseLanguage, + nodeUuid: uuid, + nodeType: node.type, + reason: "button_url_missing", + }); + } + }); + + walkAllNodes(input.blocks, (node) => { + if (!node.type) return; + const attrs = URL_ATTR_BY_NODE_TYPE[node.type]; + if (!attrs) return; + for (const attr of attrs) { + const raw = node.attrs?.[attr]; + if (typeof raw !== "string" || !raw.trim()) continue; + const hasVariable = VARIABLE_PATTERN.test(raw); + VARIABLE_PATTERN.lastIndex = 0; + const normalized = hasVariable ? raw.replace(VARIABLE_PATTERN, "x") : raw; + const invalidProtocolDetail = getInvalidUrlProtocolDetail(normalized); + if (!invalidProtocolDetail || (hasVariable && invalidProtocolDetail === "unparseable")) { + continue; + } + diagnostics.push({ + severity: "error", + language: input.baseLanguage, + nodeUuid: node.attrs?.[EMAIL_TEMPLATE_NODE_UUID_ATTR] as string | undefined, + nodeType: node.type, + reason: "invalid_url_protocol", + detail: `${attr}: ${invalidProtocolDetail}`, + }); + } + }); + + for (const language of input.availableLocales) { + walkAllTranslatableNodes(input.blocks, (uuid, node) => { + const fragment = input.strings[language]?.[uuid]; + const isEmptyFragment = !fragment || fragment.length === 0; + let flat: string; + if (language === input.baseLanguage && isEmptyFragment) { + if (node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON) { + const raw = node.attrs?.text; + flat = typeof raw === "string" ? raw.trim() : ""; + } else { + flat = flattenText(node.content).trim(); + } + } else { + flat = flattenText(fragment).trim(); + } + + const isButton = node.type === EMAIL_TEMPLATE_NODE_TYPES.BUTTON; + if (!flat && !isButton) { + diagnostics.push({ + severity: language === input.baseLanguage ? "error" : "warning", + language, + nodeUuid: uuid, + nodeType: node.type, + reason: "empty_translation", + }); + } + }); + + if (language !== input.baseLanguage) { + walkAllTranslatableNodes(input.blocks, (uuid, node) => { + const base = flattenText(input.strings[input.baseLanguage]?.[uuid]).trim(); + const localized = flattenText(input.strings[language]?.[uuid]).trim(); + if (base && localized && base === localized) { + diagnostics.push({ + severity: "warning", + language, + nodeUuid: uuid, + nodeType: node.type, + reason: "unchanged_from_base", + }); + } + }); + } + } + + return diagnostics; +}; + +export const groupEmailTemplateDiagnostics = ( + diagnostics: EmailTemplateDiagnostic[], + knownNodeUuids: Set, +): EmailTemplateDiagnosticGroups => { + const byNodeUuid = new Map(); + const orphan: EmailTemplateDiagnostic[] = []; + + for (const diagnostic of diagnostics) { + if (diagnostic.nodeUuid && knownNodeUuids.has(diagnostic.nodeUuid)) { + const nodeDiagnostics = byNodeUuid.get(diagnostic.nodeUuid) ?? []; + nodeDiagnostics.push(diagnostic); + byNodeUuid.set(diagnostic.nodeUuid, nodeDiagnostics); + } else { + orphan.push(diagnostic); + } + } + + for (const nodeDiagnostics of byNodeUuid.values()) { + nodeDiagnostics.sort(compareDiagnostics); + } + orphan.sort(compareDiagnostics); + + return { byNodeUuid, orphan }; +}; diff --git a/packages/shared/src/utils/emailTemplateNode.ts b/packages/shared/src/utils/emailTemplateNode.ts new file mode 100644 index 0000000000..c238d3303c --- /dev/null +++ b/packages/shared/src/utils/emailTemplateNode.ts @@ -0,0 +1,9 @@ +import type { EmailTemplateNode } from "../types/emailNotificationTemplate"; + +export const cloneEmailTemplateNode = (node: EmailTemplateNode): EmailTemplateNode => { + const clone: EmailTemplateNode = { ...node }; + if (node.attrs) clone.attrs = { ...node.attrs }; + if (node.marks) clone.marks = node.marks.map((mark) => ({ ...mark })); + if (node.content) clone.content = node.content.map(cloneEmailTemplateNode); + return clone; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecdb131bb3..e6e217f5ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@langfuse/tracing': specifier: 4.2.0 version: 4.2.0(@opentelemetry/api@1.9.0) + '@maily-to/render': + specifier: 0.2.3 + version: 0.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@microsoft/microsoft-graph-client': specifier: 3.0.7 version: 3.0.7 @@ -269,6 +272,9 @@ importers: mammoth: specifier: 1.10.0 version: 1.10.0 + marked: + specifier: 18.0.5 + version: 18.0.5 mime-types: specifier: 3.0.2 version: 3.0.2 @@ -329,6 +335,9 @@ importers: rxjs: specifier: 7.8.1 version: 7.8.1 + sanitize-html: + specifier: 2.17.5 + version: 2.17.5 sharp: specifier: 0.34.5 version: 0.34.5 @@ -423,6 +432,9 @@ importers: '@types/passport-microsoft': specifier: 2.1.0 version: 2.1.0 + '@types/sanitize-html': + specifier: 2.16.1 + version: 2.16.1 '@types/supertest': specifier: 6.0.2 version: 6.0.2 @@ -539,6 +551,9 @@ importers: '@livekit/components-styles': specifier: 1.2.0 version: 1.2.0 + '@maily-to/core': + specifier: ^0.3.7 + version: 0.3.7(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@types/react-dom@18.3.1)(@types/react@18.3.12)(prosemirror-model@1.25.4)(prosemirror-state@1.4.3)(prosemirror-view@1.41.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@popperjs/core': specifier: 2.11.8 version: 2.11.8 @@ -663,8 +678,8 @@ importers: specifier: 2.27.1 version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) '@tiptap/extension-heading': - specifier: ^3.13.0 - version: 3.13.0(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 2.27.1 + version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) '@tiptap/extension-highlight': specifier: 2.27.1 version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) @@ -713,6 +728,9 @@ importers: '@tiptap/starter-kit': specifier: 2.27.1 version: 2.27.1 + '@tiptap/suggestion': + specifier: 2.27.1 + version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) '@types/crypto-js': specifier: 4.2.2 version: 4.2.2 @@ -872,6 +890,9 @@ importers: tus-js-client: specifier: 4.1.0 version: 4.1.0 + uuid: + specifier: 11.1.0 + version: 11.1.0 vaul: specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -889,10 +910,10 @@ importers: version: 2.1.4 vite-plugin-pwa: specifier: 1.3.0 - version: 1.3.0(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1) + version: 1.3.0(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1) vite-plugin-svgr: specifier: 4.2.0 - version: 4.2.0(rollup@4.62.2)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + version: 4.2.0(rollup@4.62.2)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) zod: specifier: 3.25.76 version: 3.25.76 @@ -905,7 +926,7 @@ importers: version: 1.49.0 '@remix-run/dev': specifier: 2.15.0 - version: 2.15.0(@remix-run/react@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.4.5))(@types/node@22.15.0)(terser@5.36.0)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5))(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + version: 2.15.0(@remix-run/react@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.4.5))(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5))(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) '@remix-run/testing': specifier: 2.15.0 version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.4.5) @@ -953,10 +974,10 @@ importers: version: 6.21.0(eslint@8.57.1)(typescript@5.4.5) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.3.4(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + version: 4.3.4(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) '@vitest/browser': specifier: ^2.0.4 - version: 2.1.6(@types/node@22.15.0)(playwright@1.49.0)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))(vitest@2.1.6)(webdriverio@8.40.6) + version: 2.1.6(@types/node@22.15.0)(playwright@1.49.0)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))(vitest@2.1.6)(webdriverio@8.40.6) '@vitest/ui': specifier: ^2.0.4 version: 2.1.6(vitest@2.1.6) @@ -1013,31 +1034,37 @@ importers: version: 5.4.5 vite: specifier: ^5.1.0 - version: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + version: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) vite-plugin-static-copy: specifier: ^1.0.6 - version: 1.0.6(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + version: 1.0.6(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) vite-tsconfig-paths: specifier: 5.0.0 - version: 5.0.0(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + version: 5.0.0(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) vitest: specifier: ^2.0.4 - version: 2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0) + version: 2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0) packages/email-templates: dependencies: '@react-email/components': - specifier: ^0.0.17 - version: 0.0.17(@types/react@18.3.12)(react@18.3.1) + specifier: ^0.5.1 + version: 0.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/render': + specifier: ^1.3.4 + version: 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@repo/shared': specifier: workspace:* version: link:../shared react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) react-email: - specifier: ^2.1.4 - version: 2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.2)(eslint@8.57.1)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)) + specifier: ^4.0.4 + version: 4.3.2 devDependencies: '@babel/generator': specifier: ^7.24.7 @@ -1063,9 +1090,12 @@ importers: '@types/react': specifier: ^18.2.61 version: 18.3.12 + '@types/react-dom': + specifier: 18.3.1 + version: 18.3.1 tsup: specifier: ^8.0.2 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@1.21.6)(postcss@8.4.38)(tsx@4.20.6)(typescript@5.4.5)(yaml@2.6.1) + version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(typescript@5.4.5)(yaml@2.6.1) typescript: specifier: ^5.4.5 version: 5.4.5 @@ -1140,7 +1170,7 @@ importers: version: 22.15.0 tsup: specifier: ^8.0.2 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@1.21.6)(postcss@8.4.49)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1) + version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1168,7 +1198,7 @@ importers: version: 22.15.0 tsup: specifier: ^8.0.2 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@1.21.6)(postcss@8.4.49)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1) + version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1184,7 +1214,7 @@ importers: version: 11.0.3 tsup: specifier: ^8.0.2 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@1.21.6)(postcss@8.4.49)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1) + version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1589,10 +1619,6 @@ packages: resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.24.5': - resolution: {integrity: sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==} - engines: {node: '>=6.9.0'} - '@babel/core@7.26.0': resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} engines: {node: '>=6.9.0'} @@ -1754,11 +1780,6 @@ packages: resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.24.5': - resolution: {integrity: sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.26.2': resolution: {integrity: sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==} engines: {node: '>=6.0.0'} @@ -2380,15 +2401,9 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - '@emotion/is-prop-valid@0.8.8': - resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} - '@emotion/is-prop-valid@1.2.2': resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} - '@emotion/memoize@0.7.4': - resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} - '@emotion/memoize@0.8.1': resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} @@ -2397,17 +2412,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild/aix-ppc64@0.19.11': - resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -2445,12 +2454,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.19.11': - resolution: {integrity: sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.19.12': resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} engines: {node: '>=12'} @@ -2487,12 +2490,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.19.11': - resolution: {integrity: sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.19.12': resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} engines: {node: '>=12'} @@ -2529,12 +2526,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.19.11': - resolution: {integrity: sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.19.12': resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} engines: {node: '>=12'} @@ -2571,12 +2562,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.19.11': - resolution: {integrity: sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.19.12': resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} engines: {node: '>=12'} @@ -2613,12 +2598,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.19.11': - resolution: {integrity: sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.19.12': resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} engines: {node: '>=12'} @@ -2655,12 +2634,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.19.11': - resolution: {integrity: sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.19.12': resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} engines: {node: '>=12'} @@ -2697,12 +2670,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.19.11': - resolution: {integrity: sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.19.12': resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} engines: {node: '>=12'} @@ -2739,12 +2706,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.19.11': - resolution: {integrity: sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.19.12': resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} engines: {node: '>=12'} @@ -2781,12 +2742,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.19.11': - resolution: {integrity: sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.19.12': resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} engines: {node: '>=12'} @@ -2823,12 +2778,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.19.11': - resolution: {integrity: sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.19.12': resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} engines: {node: '>=12'} @@ -2865,12 +2814,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.19.11': - resolution: {integrity: sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.19.12': resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} engines: {node: '>=12'} @@ -2907,12 +2850,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.19.11': - resolution: {integrity: sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.19.12': resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} engines: {node: '>=12'} @@ -2949,12 +2886,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.19.11': - resolution: {integrity: sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.19.12': resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} engines: {node: '>=12'} @@ -2991,12 +2922,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.19.11': - resolution: {integrity: sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.19.12': resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} engines: {node: '>=12'} @@ -3033,12 +2958,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.19.11': - resolution: {integrity: sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.19.12': resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} engines: {node: '>=12'} @@ -3075,12 +2994,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.19.11': - resolution: {integrity: sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.19.12': resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} engines: {node: '>=12'} @@ -3123,12 +3036,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.19.11': - resolution: {integrity: sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.19.12': resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} engines: {node: '>=12'} @@ -3177,12 +3084,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.19.11': - resolution: {integrity: sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.19.12': resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} engines: {node: '>=12'} @@ -3225,12 +3126,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.19.11': - resolution: {integrity: sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.19.12': resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} engines: {node: '>=12'} @@ -3267,12 +3162,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.19.11': - resolution: {integrity: sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.19.12': resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} engines: {node: '>=12'} @@ -3309,12 +3198,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.19.11': - resolution: {integrity: sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.19.12': resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} engines: {node: '>=12'} @@ -3351,12 +3234,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.19.11': - resolution: {integrity: sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.19.12': resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} engines: {node: '>=12'} @@ -3920,6 +3797,9 @@ packages: resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -4426,6 +4306,18 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@maily-to/core@0.3.7': + resolution: {integrity: sha512-r+7dNgbL0CIfYbV4BXwdI+KRaovaok9z/2m7Nexx1jE1Eg0ABPmgkaitb43luSPgb1QRYCPpj7B/FymSbGaFoQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18 || ^19 + + '@maily-to/render@0.2.3': + resolution: {integrity: sha512-cH9PxSkcpWMlBi/4M2BrNrWfRFHiJeK+RIJxiAXuB73jCAcBE6/jYw3XeE6TR6EAxH7b+z4CV6XGkTBVGriFNg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true @@ -4799,63 +4691,6 @@ packages: '@nestjs/platform-socket.io': optional: true - '@next/env@14.1.4': - resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==} - - '@next/swc-darwin-arm64@14.1.4': - resolution: {integrity: sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@14.1.4': - resolution: {integrity: sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@14.1.4': - resolution: {integrity: sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-arm64-musl@14.1.4': - resolution: {integrity: sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-x64-gnu@14.1.4': - resolution: {integrity: sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-linux-x64-musl@14.1.4': - resolution: {integrity: sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-win32-arm64-msvc@14.1.4': - resolution: {integrity: sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-ia32-msvc@14.1.4': - resolution: {integrity: sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@next/swc-win32-x64-msvc@14.1.4': - resolution: {integrity: sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4893,9 +4728,6 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true - '@one-ini/wasm@0.1.1': - resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -5632,9 +5464,6 @@ packages: engines: {node: '>=18'} hasBin: true - '@radix-ui/colors@1.0.1': - resolution: {integrity: sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==} - '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -5806,19 +5635,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.0': - resolution: {integrity: sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-collapsible@1.1.1': resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==} peerDependencies: @@ -5871,15 +5687,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-compose-refs@1.0.1': - resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-compose-refs@1.1.0': resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -6287,19 +6094,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.1': - resolution: {integrity: sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -6625,15 +6419,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.0.2': - resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -6808,19 +6593,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.1.1': - resolution: {integrity: sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-tooltip@1.1.3': resolution: {integrity: sha512-Z4w1FIS0BqVFI2c1jZvb/uDVJijJjJ2ZMuPV81oVgTZ7g3BZxobplnMVvXtFWgtozdvYJ+MFWtwkM5S2HnAong==} peerDependencies: @@ -7314,147 +7086,150 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-email/body@0.0.8': - resolution: {integrity: sha512-gqdkNYlIaIw0OdpWu8KjIcQSIFvx7t2bZpXVxMMvBS859Ia1+1X3b5RNbjI3S1ZqLddUf7owOHkO4MiXGE+nxg==} + '@react-email/body@0.1.0': + resolution: {integrity: sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/button@0.0.15': - resolution: {integrity: sha512-9Zi6SO3E8PoHYDfcJTecImiHLyitYWmIRs0HE3Ogra60ZzlWP2EXu+AZqwQnhXuq+9pbgwBWNWxB5YPetNPTNA==} + '@react-email/button@0.2.0': + resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/code-block@0.0.4': - resolution: {integrity: sha512-xjVLi/9dFNJ70N7hYme+21eQWa3b9/kgp4V+FKQJkQCuIMobxPRCIGM5jKD/0Vo2OqrE5chYv/dkg/aP8a8sPg==} + '@react-email/code-block@0.1.0': + resolution: {integrity: sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/code-inline@0.0.2': - resolution: {integrity: sha512-0cmgbbibFeOJl0q04K9jJlPDuJ+SEiX/OG6m3Ko7UOkG3TqjRD8Dtvkij6jNDVfUh/zESpqJCP2CxrCLLMUjdA==} + '@react-email/code-inline@0.0.5': + resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/column@0.0.10': - resolution: {integrity: sha512-MnP8Mnwipr0X3XtdD6jMLckb0sI5/IlS6Kl/2F6/rsSWBJy5Gg6nizlekTdkwDmy0kNSe3/1nGU0Zqo98pl63Q==} + '@react-email/column@0.0.13': + resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/components@0.0.17': - resolution: {integrity: sha512-x5gGQaK0QchbwHvUrCBVnE8GCWdO5osTVuTSA54Fwzels6ZDeNTHEYRx9gI3Nwcf/dkoVYkVH4rzWST0SF0MLA==} + '@react-email/components@0.5.7': + resolution: {integrity: sha512-ECyVoyDcev2FSQ7C0buXaIJ0+6MRDXNUbCOZwBRrlLdCCRjap2b4+MHrYSTXFzo5kqfjjRoyo/2PbJXFQni67g==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/container@0.0.12': - resolution: {integrity: sha512-HFu8Pu5COPFfeZxSL+wKv/TV5uO/sp4zQ0XkRCdnGkj/xoq0lqOHVDL4yC2Pu6fxXF/9C3PHDA++5uEYV5WVJw==} + '@react-email/container@0.0.15': + resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/font@0.0.6': - resolution: {integrity: sha512-sZZFvEZ4U3vNCAZ8wXqIO3DuGJR2qE/8m2fEH+tdqwa532zGO3zW+UlCTg0b9455wkJSzEBeaWik0IkNvjXzxw==} + '@react-email/font@0.0.9': + resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/head@0.0.8': - resolution: {integrity: sha512-8/NI0gtQmLIilAe6rebK1TWw3IXHxtrR02rInkQq8yQ7zKbYbzx7Q/FhmsJgAk+uYh2Er/KhgYJ0sHZyDhfMTQ==} + '@react-email/head@0.0.12': + resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/heading@0.0.12': - resolution: {integrity: sha512-eB7mpnAvDmwvQLoPuwEiPRH4fPXWe6ltz6Ptbry2BlI88F0a2k11Ghb4+sZHBqg7vVw/MKbqEgtLqr3QJ/KfCQ==} + '@react-email/heading@0.0.15': + resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/hr@0.0.8': - resolution: {integrity: sha512-JLVvpCg2wYKEB+n/PGCggWG9fRU5e4lxsGdpK5SDLsCL0ic3OLKSpHMfeE+ZSuw0GixAVVQN7F64PVJHQkd4MQ==} + '@react-email/hr@0.0.11': + resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/html@0.0.8': - resolution: {integrity: sha512-arII3wBNLpeJtwyIJXPaILm5BPKhA+nvdC1F9QkuKcOBJv2zXctn8XzPqyGqDfdplV692ulNJP7XY55YqbKp6w==} + '@react-email/html@0.0.11': + resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/img@0.0.8': - resolution: {integrity: sha512-jx/rPuKo31tV18fu7P5rRqelaH5wkhg83Dq7uLwJpfqhbi4KFBGeBfD0Y3PiLPPoh+WvYf+Adv9W2ghNW8nOMQ==} + '@react-email/img@0.0.11': + resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/link@0.0.8': - resolution: {integrity: sha512-nVikuTi8WJHa6Baad4VuRUbUCa/7EtZ1Qy73TRejaCHn+vhetc39XGqHzKLNh+Z/JFL8Hv9g+4AgG16o2R0ogQ==} + '@react-email/link@0.0.12': + resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/markdown@0.0.10': - resolution: {integrity: sha512-MH0xO+NJ4IuJcx9nyxbgGKAMXyudFjCZ0A2GQvuWajemW9qy2hgnJ3mW3/z5lwcenG+JPn7JyO/iZpizQ7u1tA==} + '@react-email/markdown@0.0.16': + resolution: {integrity: sha512-KSUHmoBMYhvc6iGwlIDkm0DRGbGQ824iNjLMCJsBVUoKHGQYs7F/N3b1tnS1YzRUX+GwHIexSsHuIUEi1m+8OQ==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/preview@0.0.9': - resolution: {integrity: sha512-2fyAA/zzZYfYmxfyn3p2YOIU30klyA6Dq4ytyWq4nfzQWWglt5hNDE0cMhObvRtfjM9ghMSVtoELAb0MWiF/kw==} + '@react-email/preview@0.0.13': + resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/render@0.0.13': - resolution: {integrity: sha512-lmBizrV+rQeSa3GjiL8/kPU0gENqO/wv+4xrlWANabp9UY3lTLXzy7HMRSE8YFBES9AbxP5VX1iRKuEnsoBDew==} + '@react-email/render@1.4.0': + resolution: {integrity: sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw==} engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/row@0.0.8': - resolution: {integrity: sha512-JsB6pxs/ZyjYpEML3nbwJRGAerjcN/Pa/QG48XUwnT/MioDWrUuyQuefw+CwCrSUZ2P1IDrv2tUD3/E3xzcoKw==} + '@react-email/row@0.0.12': + resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/section@0.0.12': - resolution: {integrity: sha512-UCD/N/BeOTN4h3VZBUaFdiSem6HnpuxD1Q51TdBFnqeNqS5hBomp8LWJJ9s4gzwHWk1XPdNfLA3I/fJwulJshg==} + '@react-email/section@0.0.16': + resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/tailwind@0.0.16': - resolution: {integrity: sha512-uMifPxCEHaHLhpS1kVCMGyTeEL+aMYzHT4bgj8CkgCiBoF9wNNfIVMUlHGzHUTv4ZTEPaMfZgC/Hi8RqzL/Ogw==} + '@react-email/tailwind@1.2.2': + resolution: {integrity: sha512-heO9Khaqxm6Ulm6p7HQ9h01oiiLRrZuuEQuYds/O7Iyp3c58sMVHZGIxiRXO/kSs857NZQycpjewEVKF3jhNTw==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/text@0.0.8': - resolution: {integrity: sha512-uvN2TNWMrfC9wv/LLmMLbbEN1GrMWZb9dBK14eYxHHAEHCeyvGb5ePZZ2MPyzO7Y5yTC+vFEnCEr76V+hWMxCQ==} + '@react-email/text@0.1.5': + resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} engines: {node: '>=18.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: - react: ^18.2.0 + react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-stately/autocomplete@3.0.0-beta.3': resolution: {integrity: sha512-YfP/TrvkOCp6j7oqpZxJSvmSeXn+XtbKSOiBOuo+m2zCIhW2ncThmDB9uAUOkpmikDv/LkGKni40RQE8USdGdA==} @@ -8733,6 +8508,94 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.3': + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@tailwindcss/typography@0.5.15': resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} peerDependencies: @@ -8821,6 +8684,15 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-code-block-lowlight@2.27.2': + resolution: {integrity: sha512-v6NKStBbQ/XCc1NnCi3ObsL1DsxadSIBtUQNA/B+urkPgn5LEy72HAGlf0xwjRaNkAGSaTASLKmc84L5q5zlGQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-code-block': ^2.7.0 + '@tiptap/pm': ^2.7.0 + highlight.js: ^11 + lowlight: ^2 || ^3 + '@tiptap/extension-code-block@2.27.1': resolution: {integrity: sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==} peerDependencies: @@ -8855,6 +8727,12 @@ packages: '@tiptap/core': ^2.7.0 '@tiptap/pm': ^2.7.0 + '@tiptap/extension-focus@2.27.2': + resolution: {integrity: sha512-zDi+QjVXr1PuHCJqTG97wVud5Q2lTIr1iRdIsq9mfdbxtt4iolFNyfQbbdkPDlODA7qZjiOwhGNP6B/MMCqMaw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-gapcursor@2.27.1': resolution: {integrity: sha512-A9e1jr+jGhDWzNSXtIO6PYVYhf5j/udjbZwMja+wCE/3KvZU9V3IrnGKz1xNW+2Q2BDOe1QO7j5uVL9ElR6nTA==} peerDependencies: @@ -8871,11 +8749,6 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/extension-heading@3.13.0': - resolution: {integrity: sha512-8VKWX8waYPtUWN97J89em9fOtxNteh6pvUEd0htcOAtoxjt2uZjbW5N4lKyWhNKifZBrVhH2Cc2NUPuftCVgxw==} - peerDependencies: - '@tiptap/core': ^3.13.0 - '@tiptap/extension-highlight@2.27.1': resolution: {integrity: sha512-ntuYX09tvHQE/R/8WbTOxbFuQhRr2jhTkKz/gLwDD2o8IhccSy3f0nm+mVmVamKQnbsBBbLohojd5IGOnX9f1A==} peerDependencies: @@ -8914,6 +8787,13 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-mention@2.27.2': + resolution: {integrity: sha512-uHxVf8RISscb4xgCEJmDSNcFQmzlBTKJh7fp2QAXWIF4Xtrg3zD08PIXUvvHapoluGD9OdBugW4YCu1PJ3xWNw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/suggestion': ^2.7.0 + '@tiptap/extension-ordered-list@2.27.1': resolution: {integrity: sha512-U1/sWxc2TciozQsZjH35temyidYUjvroHj3PUPzPyh19w2fwKh1NSbFybWuoYs6jS3XnMSwnM2vF52tOwvfEmA==} peerDependencies: @@ -8967,6 +8847,11 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-text-align@2.27.2': + resolution: {integrity: sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-text-style@2.27.1': resolution: {integrity: sha512-NagQ9qLk0Ril83gfrk+C65SvTqPjL3WVnLF2arsEVnCrxcx3uDOvdJW67f/K5HEwEHsoqJ4Zq9Irco/koXrOXA==} peerDependencies: @@ -8977,6 +8862,11 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-underline@2.27.2': + resolution: {integrity: sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm@2.27.1': resolution: {integrity: sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==} @@ -8991,6 +8881,12 @@ packages: '@tiptap/starter-kit@2.27.1': resolution: {integrity: sha512-uQQlP0Nmn9eq19qm8YoOeloEfmcGbPpB1cujq54Q6nPgxaBozR7rE7tXbFTinxRW2+Hr7XyNWhpjB7DMNkdU2Q==} + '@tiptap/suggestion@2.27.1': + resolution: {integrity: sha512-yTy75ZMYgVWM18cl7YxLqMJ7TorQTGysSd1aKmBA9qd8uzYlvLMmHKE9qBDxM9HXODBz1DA/BLLm9esv2enmFw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tokenizer/inflate@0.2.7': resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} engines: {node: '>=18'} @@ -9327,9 +9223,6 @@ packages: '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} - '@types/prismjs@1.26.5': - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} @@ -9345,9 +9238,6 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react@18.2.47': - resolution: {integrity: sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==} - '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} @@ -9360,8 +9250,8 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - '@types/scheduler@0.23.0': - resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -9423,9 +9313,6 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@types/webpack@5.28.5': - resolution: {integrity: sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==} - '@types/which@2.0.2': resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} @@ -9811,10 +9698,6 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - abbrev@2.0.0: - resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - abort-controller-x@0.4.3: resolution: {integrity: sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA==} @@ -10082,13 +9965,6 @@ packages: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} - autoprefixer@10.4.14: - resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - autoprefixer@10.4.20: resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} @@ -10490,6 +10366,10 @@ packages: cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + cheerio@1.1.2: resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} engines: {node: '>=20.18.1'} @@ -10506,6 +10386,10 @@ packages: resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} engines: {node: '>= 14.16.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -10531,6 +10415,9 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cjs-module-lexer@1.4.1: resolution: {integrity: sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==} @@ -10588,18 +10475,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} - clsx@1.2.1: - resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} - engines: {node: '>=6'} - clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} - clsx@2.1.0: - resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} - engines: {node: '>=6'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -10658,14 +10537,14 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -10718,8 +10597,8 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - config-chain@1.1.13: - resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} consola@2.15.3: resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} @@ -10728,6 +10607,10 @@ packages: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} engines: {node: ^14.18.0 || >=16.10.0} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} @@ -11018,6 +10901,9 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debounce@2.0.0: resolution: {integrity: sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==} engines: {node: '>=18'} @@ -11396,11 +11282,6 @@ packages: resolution: {integrity: sha512-3Ve9cd5ziLByUdigw6zovVeWJjVs8QHVmqOB0sJ0WNeVPcwf4p18GnxMmVvlFmYRloUwf5suNuorea4QzwBIOA==} hasBin: true - editorconfig@1.0.4: - resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} - engines: {node: '>=14'} - hasBin: true - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -11458,9 +11339,6 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - engine.io-client@6.5.4: - resolution: {integrity: sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==} - engine.io-client@6.6.2: resolution: {integrity: sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==} @@ -11480,6 +11358,10 @@ packages: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.24.4: + resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -11488,6 +11370,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -11575,11 +11461,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.19.11: - resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.19.12: resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} engines: {node: '>=12'} @@ -11604,6 +11485,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-goat@3.0.0: + resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==} + engines: {node: '>=10'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -11640,11 +11525,6 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-config-turbo@1.10.12: - resolution: {integrity: sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==} - peerDependencies: - eslint: '>6.6.0' - eslint-config-turbo@2.3.3: resolution: {integrity: sha512-cM9wSBYowQIrjx2MPCzFE6jTnG4vpTPJKZ/O+Ps3CqrmGK/wtNOsY6WHGMwLtKY/nNbgRahAJH6jGVF6k2coOg==} peerDependencies: @@ -11743,11 +11623,6 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@1.10.12: - resolution: {integrity: sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==} - peerDependencies: - eslint: '>6.6.0' - eslint-plugin-turbo@2.3.3: resolution: {integrity: sha512-j8UEA0Z+NNCsjZep9G5u5soDQHcXq/x4amrwulk6eHF1U91H2qAjp5I4jQcvJewmccCJbVp734PkHHTRnosjpg==} peerDependencies: @@ -11929,6 +11804,9 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -12167,17 +12045,6 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@10.17.4: - resolution: {integrity: sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - framer-motion@12.23.22: resolution: {integrity: sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==} peerDependencies: @@ -12371,12 +12238,6 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.3.4: - resolution: {integrity: sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==} - engines: {node: '>=16 || 14 >=14.17'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -12564,6 +12425,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + header-case@1.0.1: resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==} @@ -12577,6 +12442,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} @@ -12610,9 +12479,15 @@ packages: htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} @@ -12973,6 +12848,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-lower-case@1.1.3: resolution: {integrity: sha512-+5A1e/WJpLLXZEDlgz4G//WYSHyQBD32qa4Jd3Lw06qQlv3fJHnp3YIHjTQSGzHMgzmVKz2ZP3rBxTHkPw/lxA==} @@ -13022,6 +12901,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -13088,6 +12971,14 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-upper-case@1.1.2: resolution: {integrity: sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw==} @@ -13131,6 +13022,9 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -13166,10 +13060,6 @@ packages: resolution: {integrity: sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==} engines: {node: '>= 0.4'} - jackspeak@2.3.6: - resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} - engines: {node: '>=14'} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -13326,6 +13216,14 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} @@ -13342,18 +13240,9 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-beautify@1.15.1: - resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} - engines: {node: '>=14'} - hasBin: true - js-cookie@2.2.1: resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} - js-cookie@3.0.5: - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} - engines: {node: '>=14'} - js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -13454,6 +13343,11 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + juice@11.1.1: + resolution: {integrity: sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w==} + engines: {node: '>=18.17'} + hasBin: true + jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} @@ -13564,6 +13458,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -13579,12 +13476,87 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -13756,6 +13728,14 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -13803,6 +13783,9 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -13817,6 +13800,11 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-react@0.483.0: + resolution: {integrity: sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.16.0: resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} peerDependencies: @@ -13897,20 +13885,20 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@7.0.4: - resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} - engines: {node: '>= 16'} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} hasBin: true math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - md-to-react-email@5.0.2: - resolution: {integrity: sha512-x6kkpdzIzUhecda/yahltfEl53mH26QdWu4abUF9+S0Jgam8P//Ciro8cdhyMHnT5MQUJYrIbO6ORM2UxPiNNA==} - peerDependencies: - react: 18.x - mdast-util-definitions@5.1.2: resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} @@ -14016,6 +14004,9 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + mensch@0.3.4: + resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} + merge-descriptors@1.0.1: resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} @@ -14308,10 +14299,6 @@ packages: resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.1: - resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} - engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -14482,6 +14469,11 @@ packages: react: '*' react-dom: '*' + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@3.3.7: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -14519,22 +14511,6 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - next@14.1.4: - resolution: {integrity: sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==} - engines: {node: '>=18.17.0'} - deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - sass: - optional: true - nice-grpc-client-middleware-retry@3.1.11: resolution: {integrity: sha512-xW/imz/kNG2g0DwTfH2eYEGrg1chSLrXtvGp9fg2qkhTgGFfAS/Pq3+t+9G8KThcC4hK/xlEyKvZWKk++33S6A==} @@ -14593,6 +14569,9 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-html-parser@7.1.0: + resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -14619,11 +14598,6 @@ packages: engines: {node: '>=6'} hasBin: true - nopt@7.2.1: - resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - hasBin: true - normalize-package-data@5.0.0: resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -14680,6 +14654,11 @@ packages: nwsapi@2.2.13: resolution: {integrity: sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==} + nypm@0.6.0: + resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + oas-kit-common@1.0.8: resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} @@ -14817,6 +14796,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -14947,6 +14930,9 @@ packages: resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} engines: {node: '>=6'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -15055,6 +15041,9 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.0: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} @@ -15144,6 +15133,9 @@ packages: pkg-types@1.2.1: resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} @@ -15271,10 +15263,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.38: resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} engines: {node: ^10 || ^12 || >=14} @@ -15283,6 +15271,10 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -15403,6 +15395,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -15423,19 +15420,10 @@ packages: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} - prism-react-renderer@2.1.0: - resolution: {integrity: sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==} - peerDependencies: - react: '>=16.0.0' - prismjs@1.27.0: resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} engines: {node: '>=6'} - prismjs@1.29.0: - resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} - engines: {node: '>=6'} - prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -15553,9 +15541,6 @@ packages: prosemirror-view@1.41.4: resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} - proto-list@1.2.4: - resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -15730,8 +15715,8 @@ packages: react: '>=16.4.0' react-dom: '>=16.4.0' - react-email@2.1.6: - resolution: {integrity: sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==} + react-email@4.3.2: + resolution: {integrity: sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA==} engines: {node: '>=18.0.0'} hasBin: true @@ -15804,6 +15789,9 @@ packages: react: ^16.8.0 || ^17 || ^18 react-dom: ^16.8.0 || ^17 || ^18 + react-promise-suspense@0.3.4: + resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -16280,6 +16268,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.5: + resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==} + sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -16492,6 +16483,9 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} + slick@1.12.2: + resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + slugify@1.6.6: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} @@ -16513,10 +16507,6 @@ packages: socket.io-adapter@2.5.5: resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} - socket.io-client@4.7.3: - resolution: {integrity: sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==} - engines: {node: '>=10.0.0'} - socket.io-client@4.8.0: resolution: {integrity: sha512-C0jdhD5yQahMws9alf/yvtsMGTaIDBnZ8Rb5HU56svyq0l5LIrGzIDZZD5pHQlmzxLuU91Gz+VpQMKgCTNYtkw==} engines: {node: '>=10.0.0'} @@ -16529,10 +16519,6 @@ packages: resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} engines: {node: '>=10.0.0'} - socket.io@4.7.3: - resolution: {integrity: sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==} - engines: {node: '>=10.2.0'} - socket.io@4.7.5: resolution: {integrity: sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==} engines: {node: '>=10.2.0'} @@ -16553,16 +16539,6 @@ packages: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonner@1.3.1: - resolution: {integrity: sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - - source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -16644,10 +16620,6 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} - stacktrace-parser@0.1.10: - resolution: {integrity: sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==} - engines: {node: '>=6'} - standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -16662,6 +16634,10 @@ packages: std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -16828,19 +16804,6 @@ packages: react: '>= 16.8.0' react-dom: '>= 16.8.0' - styled-jsx@5.1.1: - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - stylis@4.3.2: resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} @@ -16915,9 +16878,6 @@ packages: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} - tailwind-merge@2.2.0: - resolution: {integrity: sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==} - tailwind-merge@2.4.0: resolution: {integrity: sha512-49AwoOQNKdqKPd9CViyH5wJoSKsCDjUlzL8DxuGp3P1FsGY36NJDAa18jLZcaHAUUuTj+JB8IAo8zWgBNvBF7A==} @@ -16926,20 +16886,22 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' - tailwindcss@3.4.0: - resolution: {integrity: sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==} - engines: {node: '>=14.0.0'} - hasBin: true - tailwindcss@3.4.15: resolution: {integrity: sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==} engines: {node: '>=14.0.0'} hasBin: true + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} @@ -17040,6 +17002,9 @@ packages: tinyexec@0.3.1: resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.10: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} engines: {node: '>=12.0.0'} @@ -17313,10 +17278,6 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - type-fest@0.7.1: - resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} - engines: {node: '>=8'} - type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} @@ -17379,11 +17340,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.1.6: - resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} - engines: {node: '>=14.17'} - hasBin: true - typescript@5.4.5: resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} engines: {node: '>=14.17'} @@ -17673,6 +17629,10 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -17703,6 +17663,10 @@ packages: typescript: optional: true + valid-data-url@3.0.1: + resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} + engines: {node: '>=10'} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -17901,6 +17865,10 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-resource-inliner@8.0.0: + resolution: {integrity: sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA==} + engines: {node: '>=10.0.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -18215,10 +18183,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - xmlhttprequest-ssl@2.0.0: - resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} - engines: {node: '>=0.4.0'} - xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -18227,6 +18191,22 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -18275,6 +18255,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -18287,6 +18271,10 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -19375,26 +19363,6 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.24.5': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.2 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.24.5) - '@babel/helpers': 7.26.0 - '@babel/parser': 7.26.2 - '@babel/template': 7.25.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.26.0': dependencies: '@ampproject/remapping': 2.3.0 @@ -19433,7 +19401,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.25.9': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.29.7 '@babel/helper-annotate-as-pure@7.29.7': dependencies: @@ -19463,7 +19431,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.25.9 '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -19503,8 +19471,8 @@ snapshots: '@babel/helper-member-expression-to-functions@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -19517,8 +19485,8 @@ snapshots: '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -19529,21 +19497,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.24.5)': - dependencies: - '@babel/core': 7.24.5 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.25.9 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -19558,7 +19517,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.25.9': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.29.7 '@babel/helper-optimise-call-expression@7.29.7': dependencies: @@ -19582,7 +19541,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -19597,15 +19556,15 @@ snapshots: '@babel/helper-simple-access@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -19641,10 +19600,6 @@ snapshots: '@babel/template': 7.25.9 '@babel/types': 7.26.0 - '@babel/parser@7.24.5': - dependencies: - '@babel/types': 7.26.0 - '@babel/parser@7.26.2': dependencies: '@babel/types': 7.26.0 @@ -20424,18 +20379,10 @@ snapshots: '@emotion/hash@0.9.2': {} - '@emotion/is-prop-valid@0.8.8': - dependencies: - '@emotion/memoize': 0.7.4 - optional: true - '@emotion/is-prop-valid@1.2.2': dependencies: '@emotion/memoize': 0.8.1 - '@emotion/memoize@0.7.4': - optional: true - '@emotion/memoize@0.8.1': {} '@emotion/unitless@0.8.1': {} @@ -20450,9 +20397,6 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.8.1 - '@esbuild/aix-ppc64@0.19.11': - optional: true - '@esbuild/aix-ppc64@0.19.12': optional: true @@ -20471,9 +20415,6 @@ snapshots: '@esbuild/android-arm64@0.18.20': optional: true - '@esbuild/android-arm64@0.19.11': - optional: true - '@esbuild/android-arm64@0.19.12': optional: true @@ -20492,9 +20433,6 @@ snapshots: '@esbuild/android-arm@0.18.20': optional: true - '@esbuild/android-arm@0.19.11': - optional: true - '@esbuild/android-arm@0.19.12': optional: true @@ -20513,9 +20451,6 @@ snapshots: '@esbuild/android-x64@0.18.20': optional: true - '@esbuild/android-x64@0.19.11': - optional: true - '@esbuild/android-x64@0.19.12': optional: true @@ -20534,9 +20469,6 @@ snapshots: '@esbuild/darwin-arm64@0.18.20': optional: true - '@esbuild/darwin-arm64@0.19.11': - optional: true - '@esbuild/darwin-arm64@0.19.12': optional: true @@ -20555,9 +20487,6 @@ snapshots: '@esbuild/darwin-x64@0.18.20': optional: true - '@esbuild/darwin-x64@0.19.11': - optional: true - '@esbuild/darwin-x64@0.19.12': optional: true @@ -20576,9 +20505,6 @@ snapshots: '@esbuild/freebsd-arm64@0.18.20': optional: true - '@esbuild/freebsd-arm64@0.19.11': - optional: true - '@esbuild/freebsd-arm64@0.19.12': optional: true @@ -20597,9 +20523,6 @@ snapshots: '@esbuild/freebsd-x64@0.18.20': optional: true - '@esbuild/freebsd-x64@0.19.11': - optional: true - '@esbuild/freebsd-x64@0.19.12': optional: true @@ -20618,9 +20541,6 @@ snapshots: '@esbuild/linux-arm64@0.18.20': optional: true - '@esbuild/linux-arm64@0.19.11': - optional: true - '@esbuild/linux-arm64@0.19.12': optional: true @@ -20639,9 +20559,6 @@ snapshots: '@esbuild/linux-arm@0.18.20': optional: true - '@esbuild/linux-arm@0.19.11': - optional: true - '@esbuild/linux-arm@0.19.12': optional: true @@ -20660,9 +20577,6 @@ snapshots: '@esbuild/linux-ia32@0.18.20': optional: true - '@esbuild/linux-ia32@0.19.11': - optional: true - '@esbuild/linux-ia32@0.19.12': optional: true @@ -20681,9 +20595,6 @@ snapshots: '@esbuild/linux-loong64@0.18.20': optional: true - '@esbuild/linux-loong64@0.19.11': - optional: true - '@esbuild/linux-loong64@0.19.12': optional: true @@ -20702,9 +20613,6 @@ snapshots: '@esbuild/linux-mips64el@0.18.20': optional: true - '@esbuild/linux-mips64el@0.19.11': - optional: true - '@esbuild/linux-mips64el@0.19.12': optional: true @@ -20723,9 +20631,6 @@ snapshots: '@esbuild/linux-ppc64@0.18.20': optional: true - '@esbuild/linux-ppc64@0.19.11': - optional: true - '@esbuild/linux-ppc64@0.19.12': optional: true @@ -20744,9 +20649,6 @@ snapshots: '@esbuild/linux-riscv64@0.18.20': optional: true - '@esbuild/linux-riscv64@0.19.11': - optional: true - '@esbuild/linux-riscv64@0.19.12': optional: true @@ -20765,9 +20667,6 @@ snapshots: '@esbuild/linux-s390x@0.18.20': optional: true - '@esbuild/linux-s390x@0.19.11': - optional: true - '@esbuild/linux-s390x@0.19.12': optional: true @@ -20786,9 +20685,6 @@ snapshots: '@esbuild/linux-x64@0.18.20': optional: true - '@esbuild/linux-x64@0.19.11': - optional: true - '@esbuild/linux-x64@0.19.12': optional: true @@ -20810,9 +20706,6 @@ snapshots: '@esbuild/netbsd-x64@0.18.20': optional: true - '@esbuild/netbsd-x64@0.19.11': - optional: true - '@esbuild/netbsd-x64@0.19.12': optional: true @@ -20837,9 +20730,6 @@ snapshots: '@esbuild/openbsd-x64@0.18.20': optional: true - '@esbuild/openbsd-x64@0.19.11': - optional: true - '@esbuild/openbsd-x64@0.19.12': optional: true @@ -20861,9 +20751,6 @@ snapshots: '@esbuild/sunos-x64@0.18.20': optional: true - '@esbuild/sunos-x64@0.19.11': - optional: true - '@esbuild/sunos-x64@0.19.12': optional: true @@ -20882,9 +20769,6 @@ snapshots: '@esbuild/win32-arm64@0.18.20': optional: true - '@esbuild/win32-arm64@0.19.11': - optional: true - '@esbuild/win32-arm64@0.19.12': optional: true @@ -20903,9 +20787,6 @@ snapshots: '@esbuild/win32-ia32@0.18.20': optional: true - '@esbuild/win32-ia32@0.19.11': - optional: true - '@esbuild/win32-ia32@0.19.12': optional: true @@ -20924,9 +20805,6 @@ snapshots: '@esbuild/win32-x64@0.18.20': optional: true - '@esbuild/win32-x64@0.19.11': - optional: true - '@esbuild/win32-x64@0.19.12': optional: true @@ -21668,6 +21546,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} @@ -21870,6 +21753,66 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@maily-to/core@0.3.7(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@types/react-dom@18.3.1)(@types/react@18.3.12)(prosemirror-model@1.25.4)(prosemirror-state@1.4.3)(prosemirror-view@1.41.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tailwindcss/postcss': 4.3.3 + '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-code-block-lowlight': 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(highlight.js@11.11.1)(lowlight@3.3.0) + '@tiptap/extension-color': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))) + '@tiptap/extension-document': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-dropcursor': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/extension-focus': 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/extension-heading': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-horizontal-rule': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/extension-image': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-link': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/extension-list-item': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-mention': 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(@tiptap/suggestion@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)) + '@tiptap/extension-paragraph': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-placeholder': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/extension-text-align': 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-text-style': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extension-underline': 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/pm': 2.27.1 + '@tiptap/react': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tiptap/starter-kit': 2.27.1 + '@tiptap/suggestion': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + clsx: 2.1.1 + fast-deep-equal: 3.1.3 + highlight.js: 11.11.1 + lowlight: 3.3.0 + lucide-react: 0.483.0(react@18.3.1) + react: 18.3.1 + react-colorful: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwindcss: 4.3.3 + tippy.js: 6.3.7 + uuid: 11.1.0 + y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.3)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) + transitivePeerDependencies: + - '@tiptap/extension-code-block' + - '@types/react' + - '@types/react-dom' + - prosemirror-model + - prosemirror-state + - prosemirror-view + - react-dom + - y-protocols + - yjs + + '@maily-to/render@0.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-email/components': 0.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/render': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + juice: 11.1.1 + node-html-parser: 7.1.0 + react: 18.3.1 + transitivePeerDependencies: + - react-dom + '@mapbox/node-pre-gyp@1.0.11': dependencies: detect-libc: 2.1.2 @@ -22221,35 +22164,6 @@ snapshots: optionalDependencies: '@nestjs/platform-socket.io': 10.4.4(@nestjs/common@10.4.17(reflect-metadata@0.2.0)(rxjs@7.8.1))(@nestjs/websockets@10.4.4)(rxjs@7.8.1) - '@next/env@14.1.4': {} - - '@next/swc-darwin-arm64@14.1.4': - optional: true - - '@next/swc-darwin-x64@14.1.4': - optional: true - - '@next/swc-linux-arm64-gnu@14.1.4': - optional: true - - '@next/swc-linux-arm64-musl@14.1.4': - optional: true - - '@next/swc-linux-x64-gnu@14.1.4': - optional: true - - '@next/swc-linux-x64-musl@14.1.4': - optional: true - - '@next/swc-win32-arm64-msvc@14.1.4': - optional: true - - '@next/swc-win32-ia32-msvc@14.1.4': - optional: true - - '@next/swc-win32-x64-msvc@14.1.4': - optional: true - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -22305,8 +22219,6 @@ snapshots: transitivePeerDependencies: - encoding - '@one-ini/wasm@0.1.1': {} - '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -23320,8 +23232,6 @@ snapshots: - bare-buffer - supports-color - '@radix-ui/colors@1.0.1': {} - '@radix-ui/number@1.1.0': {} '@radix-ui/number@1.1.1': {} @@ -23403,15 +23313,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -23496,22 +23397,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-collapsible@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-id': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-collapsible@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -23544,18 +23429,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -23580,19 +23453,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.26.0 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-compose-refs@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -23619,12 +23479,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-context@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-context@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -23687,12 +23541,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-direction@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-direction@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -23705,19 +23553,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -23800,12 +23635,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-focus-guards@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-focus-guards@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -23824,17 +23653,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -23905,13 +23723,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-id@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-id@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -24094,29 +23905,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-popover@1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.3.1) - aria-hidden: 1.2.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.7(@types/react@18.2.47)(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -24163,24 +23951,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-popper@1.2.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-rect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/rect': 1.1.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-popper@1.2.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24235,16 +24005,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-portal@1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-portal@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24275,16 +24035,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-presence@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-presence@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -24325,15 +24075,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -24398,23 +24139,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-direction': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-id': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -24561,21 +24285,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-slot@1.0.2(@types/react@18.3.12)(react@18.3.1)': - dependencies: - '@babel/runtime': 7.26.0 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.12 - - '@radix-ui/react-slot@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-slot@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -24692,21 +24401,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-toggle-group@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-direction': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-toggle-group@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -24737,17 +24431,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-toggle@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-toggle@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -24800,26 +24483,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-tooltip@1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-context': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-tooltip@1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -24860,12 +24523,6 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 - '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -24878,13 +24535,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -24907,13 +24557,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -24935,12 +24578,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -24965,13 +24602,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-use-rect@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - '@radix-ui/rect': 1.1.0 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/rect': 1.1.0 @@ -24986,13 +24616,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-use-size@1.1.0(@types/react@18.2.47)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.47)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.2.47 - '@radix-ui/react-use-size@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -25007,15 +24630,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -25670,117 +25284,115 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@react-email/body@0.0.8(react@18.3.1)': + '@react-email/body@0.1.0(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/button@0.0.15(react@18.3.1)': + '@react-email/button@0.2.0(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/code-block@0.0.4(react@18.3.1)': + '@react-email/code-block@0.1.0(react@18.3.1)': dependencies: - prismjs: 1.29.0 + prismjs: 1.30.0 react: 18.3.1 - '@react-email/code-inline@0.0.2(react@18.3.1)': + '@react-email/code-inline@0.0.5(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/column@0.0.10(react@18.3.1)': + '@react-email/column@0.0.13(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/components@0.0.17(@types/react@18.3.12)(react@18.3.1)': + '@react-email/components@0.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@react-email/body': 0.0.8(react@18.3.1) - '@react-email/button': 0.0.15(react@18.3.1) - '@react-email/code-block': 0.0.4(react@18.3.1) - '@react-email/code-inline': 0.0.2(react@18.3.1) - '@react-email/column': 0.0.10(react@18.3.1) - '@react-email/container': 0.0.12(react@18.3.1) - '@react-email/font': 0.0.6(react@18.3.1) - '@react-email/head': 0.0.8(react@18.3.1) - '@react-email/heading': 0.0.12(@types/react@18.3.12)(react@18.3.1) - '@react-email/hr': 0.0.8(react@18.3.1) - '@react-email/html': 0.0.8(react@18.3.1) - '@react-email/img': 0.0.8(react@18.3.1) - '@react-email/link': 0.0.8(react@18.3.1) - '@react-email/markdown': 0.0.10(react@18.3.1) - '@react-email/preview': 0.0.9(react@18.3.1) - '@react-email/render': 0.0.13 - '@react-email/row': 0.0.8(react@18.3.1) - '@react-email/section': 0.0.12(react@18.3.1) - '@react-email/tailwind': 0.0.16(react@18.3.1) - '@react-email/text': 0.0.8(react@18.3.1) + '@react-email/body': 0.1.0(react@18.3.1) + '@react-email/button': 0.2.0(react@18.3.1) + '@react-email/code-block': 0.1.0(react@18.3.1) + '@react-email/code-inline': 0.0.5(react@18.3.1) + '@react-email/column': 0.0.13(react@18.3.1) + '@react-email/container': 0.0.15(react@18.3.1) + '@react-email/font': 0.0.9(react@18.3.1) + '@react-email/head': 0.0.12(react@18.3.1) + '@react-email/heading': 0.0.15(react@18.3.1) + '@react-email/hr': 0.0.11(react@18.3.1) + '@react-email/html': 0.0.11(react@18.3.1) + '@react-email/img': 0.0.11(react@18.3.1) + '@react-email/link': 0.0.12(react@18.3.1) + '@react-email/markdown': 0.0.16(react@18.3.1) + '@react-email/preview': 0.0.13(react@18.3.1) + '@react-email/render': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/row': 0.0.12(react@18.3.1) + '@react-email/section': 0.0.16(react@18.3.1) + '@react-email/tailwind': 1.2.2(react@18.3.1) + '@react-email/text': 0.1.5(react@18.3.1) react: 18.3.1 transitivePeerDependencies: - - '@types/react' + - react-dom - '@react-email/container@0.0.12(react@18.3.1)': + '@react-email/container@0.0.15(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/font@0.0.6(react@18.3.1)': + '@react-email/font@0.0.9(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/head@0.0.8(react@18.3.1)': + '@react-email/head@0.0.12(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/heading@0.0.12(@types/react@18.3.12)(react@18.3.1)': + '@react-email/heading@0.0.15(react@18.3.1)': dependencies: - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) react: 18.3.1 - transitivePeerDependencies: - - '@types/react' - '@react-email/hr@0.0.8(react@18.3.1)': + '@react-email/hr@0.0.11(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/html@0.0.8(react@18.3.1)': + '@react-email/html@0.0.11(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/img@0.0.8(react@18.3.1)': + '@react-email/img@0.0.11(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/link@0.0.8(react@18.3.1)': + '@react-email/link@0.0.12(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/markdown@0.0.10(react@18.3.1)': + '@react-email/markdown@0.0.16(react@18.3.1)': dependencies: - md-to-react-email: 5.0.2(react@18.3.1) + marked: 15.0.12 react: 18.3.1 - '@react-email/preview@0.0.9(react@18.3.1)': + '@react-email/preview@0.0.13(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/render@0.0.13': + '@react-email/render@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: html-to-text: 9.0.5 - js-beautify: 1.15.1 + prettier: 3.9.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-promise-suspense: 0.3.4 - '@react-email/row@0.0.8(react@18.3.1)': + '@react-email/row@0.0.12(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/section@0.0.12(react@18.3.1)': + '@react-email/section@0.0.16(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/tailwind@0.0.16(react@18.3.1)': + '@react-email/tailwind@1.2.2(react@18.3.1)': dependencies: react: 18.3.1 - '@react-email/text@0.0.8(react@18.3.1)': + '@react-email/text@0.1.5(react@18.3.1)': dependencies: react: 18.3.1 @@ -26244,7 +25856,7 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@remix-run/dev@2.15.0(@remix-run/react@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.4.5))(@types/node@22.15.0)(terser@5.36.0)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5))(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))': + '@remix-run/dev@2.15.0(@remix-run/react@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.4.5))(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5))(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))': dependencies: '@babel/core': 7.26.0 '@babel/generator': 7.26.2 @@ -26261,7 +25873,7 @@ snapshots: '@remix-run/router': 1.21.0 '@remix-run/server-runtime': 2.15.0(typescript@5.4.5) '@types/mdx': 2.0.13 - '@vanilla-extract/integration': 6.5.0(@types/node@22.15.0)(terser@5.36.0) + '@vanilla-extract/integration': 6.5.0(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) arg: 5.0.2 cacache: 17.1.4 chalk: 4.1.2 @@ -26300,11 +25912,11 @@ snapshots: tar-fs: 2.1.1 tsconfig-paths: 4.2.0 valibot: 0.41.0(typescript@5.4.5) - vite-node: 1.6.0(@types/node@22.15.0)(terser@5.36.0) + vite-node: 1.6.0(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) ws: 7.5.10 optionalDependencies: typescript: 5.4.5 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -27313,8 +26925,10 @@ snapshots: '@swc/core-win32-ia32-msvc': 1.3.101 '@swc/core-win32-x64-msvc': 1.3.101 '@swc/helpers': 0.5.2 + optional: true - '@swc/counter@0.1.3': {} + '@swc/counter@0.1.3': + optional: true '@swc/helpers@0.5.2': dependencies: @@ -27323,12 +26937,82 @@ snapshots: '@swc/types@0.1.17': dependencies: '@swc/counter': 0.1.3 + optional: true '@szmarczak/http-timer@5.0.1': dependencies: defer-to-connect: 2.0.1 optional: true + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.4 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/postcss@4.3.3': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.24 + tailwindcss: 4.3.3 + '@tailwindcss/typography@0.5.15(tailwindcss@3.4.15(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)))': dependencies: lodash.castarray: 4.4.0 @@ -27417,6 +27101,14 @@ snapshots: dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-code-block-lowlight@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(highlight.js@11.11.1)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-code-block': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/pm': 2.27.1 + highlight.js: 11.11.1 + lowlight: 3.3.0 + '@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) @@ -27446,20 +27138,21 @@ snapshots: '@tiptap/pm': 2.27.1 tippy.js: 6.3.7 - '@tiptap/extension-gapcursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-focus@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) '@tiptap/pm': 2.27.1 - '@tiptap/extension-hard-break@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-gapcursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/pm': 2.27.1 - '@tiptap/extension-heading@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-hard-break@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-heading@3.13.0(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-heading@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) @@ -27495,6 +27188,12 @@ snapshots: dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-mention@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(@tiptap/suggestion@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))': + dependencies: + '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/pm': 2.27.1 + '@tiptap/suggestion': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + '@tiptap/extension-ordered-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) @@ -27538,6 +27237,10 @@ snapshots: dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-text-align@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + dependencies: + '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) @@ -27546,6 +27249,10 @@ snapshots: dependencies: '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-underline@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + dependencies: + '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/pm@2.27.1': dependencies: prosemirror-changeset: 2.3.1 @@ -27603,6 +27310,11 @@ snapshots: '@tiptap/extension-text-style': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) '@tiptap/pm': 2.27.1 + '@tiptap/suggestion@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + dependencies: + '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/pm': 2.27.1 + '@tokenizer/inflate@0.2.7': dependencies: debug: 4.4.3 @@ -27702,7 +27414,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.26.2 + '@babel/parser': 7.29.7 '@babel/types': 7.26.0 '@types/babel__traverse@7.20.6': @@ -28035,8 +27747,6 @@ snapshots: pg-protocol: 1.7.1 pg-types: 2.2.0 - '@types/prismjs@1.26.5': {} - '@types/prop-types@15.7.13': {} '@types/qs@6.9.17': {} @@ -28051,12 +27761,6 @@ snapshots: dependencies: '@types/react': 18.3.12 - '@types/react@18.2.47': - dependencies: - '@types/prop-types': 15.7.13 - '@types/scheduler': 0.23.0 - csstype: 3.1.3 - '@types/react@18.3.12': dependencies: '@types/prop-types': 15.7.13 @@ -28070,7 +27774,9 @@ snapshots: '@types/retry@0.12.0': {} - '@types/scheduler@0.23.0': {} + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 '@types/semver@7.5.8': {} @@ -28133,17 +27839,6 @@ snapshots: '@types/uuid@10.0.0': {} - '@types/webpack@5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.11)': - dependencies: - '@types/node': 24.10.0 - tapable: 2.2.1 - webpack: 5.96.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.24.0) - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack-cli - '@types/which@2.0.2': optional: true @@ -28447,7 +28142,7 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@vanilla-extract/integration@6.5.0(@types/node@22.15.0)(terser@5.36.0)': + '@vanilla-extract/integration@6.5.0(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)': dependencies: '@babel/core': 7.26.0 '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) @@ -28460,8 +28155,8 @@ snapshots: lodash: 4.17.21 mlly: 1.7.3 outdent: 0.8.0 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) - vite-node: 1.6.0(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) + vite-node: 1.6.0(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -28505,28 +28200,28 @@ snapshots: native-promise-only: 0.8.1 weakmap-polyfill: 2.0.4 - '@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))': + '@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - supports-color - '@vitest/browser@2.1.6(@types/node@22.15.0)(playwright@1.49.0)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))(vitest@2.1.6)(webdriverio@8.40.6)': + '@vitest/browser@2.1.6(@types/node@22.15.0)(playwright@1.49.0)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))(vitest@2.1.6)(webdriverio@8.40.6)': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) - '@vitest/mocker': 2.1.6(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + '@vitest/mocker': 2.1.6(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) '@vitest/utils': 2.1.6 magic-string: 0.30.14 msw: 2.6.6(@types/node@22.15.0)(typescript@5.4.5) sirv: 3.0.0 tinyrainbow: 1.2.0 - vitest: 2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0) + vitest: 2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0) ws: 8.18.0 optionalDependencies: playwright: 1.49.0 @@ -28545,14 +28240,14 @@ snapshots: chai: 5.1.2 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.6(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))': + '@vitest/mocker@2.1.6(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))': dependencies: '@vitest/spy': 2.1.6 estree-walker: 3.0.3 magic-string: 0.30.14 optionalDependencies: msw: 2.6.6(@types/node@22.15.0)(typescript@5.4.5) - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) '@vitest/pretty-format@2.1.6': dependencies: @@ -28582,7 +28277,7 @@ snapshots: sirv: 3.0.0 tinyglobby: 0.2.10 tinyrainbow: 1.2.0 - vitest: 2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0) + vitest: 2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0) '@vitest/utils@2.1.6': dependencies: @@ -28750,8 +28445,6 @@ snapshots: abbrev@1.1.1: optional: true - abbrev@2.0.0: {} - abort-controller-x@0.4.3: {} abort-controller@3.0.0: @@ -29047,16 +28740,6 @@ snapshots: attr-accept@2.2.5: {} - autoprefixer@10.4.14(postcss@8.4.38): - dependencies: - browserslist: 4.24.2 - caniuse-lite: 1.0.30001684 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.4.38 - postcss-value-parser: 4.2.0 - autoprefixer@10.4.20(postcss@8.4.49): dependencies: browserslist: 4.24.2 @@ -29126,8 +28809,8 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: - '@babel/template': 7.25.9 - '@babel/types': 7.26.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 @@ -29577,6 +29260,20 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 9.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.21.0 + whatwg-mimetype: 4.0.0 + cheerio@1.1.2: dependencies: cheerio-select: 2.1.0 @@ -29619,6 +29316,10 @@ snapshots: dependencies: readdirp: 4.0.2 + chokidar@4.0.3: + dependencies: + readdirp: 4.0.2 + chownr@1.1.4: {} chownr@2.0.0: {} @@ -29640,6 +29341,10 @@ snapshots: ci-info@3.9.0: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + cjs-module-lexer@1.4.1: {} class-variance-authority@0.7.0: @@ -29691,12 +29396,8 @@ snapshots: clone@1.0.4: {} - clsx@1.2.1: {} - clsx@2.0.0: {} - clsx@2.1.0: {} - clsx@2.1.1: {} cluster-key-slot@1.1.2: {} @@ -29749,10 +29450,10 @@ snapshots: commander@10.0.1: {} - commander@11.1.0: {} - commander@12.1.0: {} + commander@13.1.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -29816,15 +29517,14 @@ snapshots: confbox@0.1.8: {} - config-chain@1.1.13: - dependencies: - ini: 1.3.8 - proto-list: 1.2.4 + confbox@0.2.4: {} consola@2.15.3: {} consola@3.2.3: {} + consola@3.4.2: {} + console-control-strings@1.1.0: optional: true @@ -30125,6 +29825,8 @@ snapshots: date-fns@3.6.0: {} + dayjs@1.11.21: {} + debounce@2.0.0: {} debug@2.6.9: @@ -30395,13 +30097,6 @@ snapshots: which: 4.0.0 optional: true - editorconfig@1.0.4: - dependencies: - '@one-ini/wasm': 0.1.1 - commander: 10.0.1 - minimatch: 9.0.1 - semver: 7.7.3 - ee-first@1.1.1: {} ejs@3.1.10: @@ -30447,18 +30142,6 @@ snapshots: dependencies: once: 1.4.0 - engine.io-client@6.5.4: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 - engine.io-parser: 5.2.3 - ws: 8.17.1 - xmlhttprequest-ssl: 2.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - engine.io-client@6.6.2: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -30511,10 +30194,17 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 + enhanced-resolve@5.24.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -30754,32 +30444,6 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 - esbuild@0.19.11: - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.11 - '@esbuild/android-arm': 0.19.11 - '@esbuild/android-arm64': 0.19.11 - '@esbuild/android-x64': 0.19.11 - '@esbuild/darwin-arm64': 0.19.11 - '@esbuild/darwin-x64': 0.19.11 - '@esbuild/freebsd-arm64': 0.19.11 - '@esbuild/freebsd-x64': 0.19.11 - '@esbuild/linux-arm': 0.19.11 - '@esbuild/linux-arm64': 0.19.11 - '@esbuild/linux-ia32': 0.19.11 - '@esbuild/linux-loong64': 0.19.11 - '@esbuild/linux-mips64el': 0.19.11 - '@esbuild/linux-ppc64': 0.19.11 - '@esbuild/linux-riscv64': 0.19.11 - '@esbuild/linux-s390x': 0.19.11 - '@esbuild/linux-x64': 0.19.11 - '@esbuild/netbsd-x64': 0.19.11 - '@esbuild/openbsd-x64': 0.19.11 - '@esbuild/sunos-x64': 0.19.11 - '@esbuild/win32-arm64': 0.19.11 - '@esbuild/win32-ia32': 0.19.11 - '@esbuild/win32-x64': 0.19.11 - esbuild@0.19.12: optionalDependencies: '@esbuild/aix-ppc64': 0.19.12 @@ -30890,6 +30554,8 @@ snapshots: escalade@3.2.0: {} + escape-goat@3.0.0: {} + escape-html@1.0.3: {} escape-string-regexp@1.0.5: {} @@ -30912,19 +30578,10 @@ snapshots: dependencies: eslint: 8.42.0 - eslint-config-prettier@9.0.0(eslint@8.57.1): - dependencies: - eslint: 8.57.1 - eslint-config-prettier@9.1.0(eslint@8.57.1): dependencies: eslint: 8.57.1 - eslint-config-turbo@1.10.12(eslint@8.57.1): - dependencies: - eslint: 8.57.1 - eslint-plugin-turbo: 1.10.12(eslint@8.57.1) - eslint-config-turbo@2.3.3(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -31138,11 +30795,6 @@ snapshots: string.prototype.matchall: 4.0.11 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@1.10.12(eslint@8.57.1): - dependencies: - dotenv: 16.0.3 - eslint: 8.57.1 - eslint-plugin-turbo@2.3.3(eslint@8.57.1): dependencies: dotenv: 16.0.3 @@ -31497,6 +31149,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.1.1: {} + extend@3.0.2: {} external-editor@3.1.0: @@ -31515,8 +31169,7 @@ snapshots: transitivePeerDependencies: - supports-color - fast-deep-equal@2.0.1: - optional: true + fast-deep-equal@2.0.1: {} fast-deep-equal@3.1.3: {} @@ -31769,14 +31422,6 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@10.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - tslib: 2.8.1 - optionalDependencies: - '@emotion/is-prop-valid': 0.8.8 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - framer-motion@12.23.22(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.23.21 @@ -32015,14 +31660,6 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.3.4: - dependencies: - foreground-child: 3.3.0 - jackspeak: 2.3.6 - minimatch: 9.0.5 - minipass: 7.1.2 - path-scurry: 1.11.1 - glob@10.4.5: dependencies: foreground-child: 3.3.0 @@ -32306,6 +31943,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + he@1.2.0: {} + header-case@1.0.1: dependencies: no-case: 2.3.2 @@ -32317,6 +31956,8 @@ snapshots: highlight.js@10.7.3: {} + highlight.js@11.11.1: {} + highlightjs-vue@1.0.0: {} hoist-non-react-statics@3.3.2: @@ -32356,6 +31997,13 @@ snapshots: domutils: 3.2.2 entities: 6.0.1 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -32363,6 +32011,13 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-cache-semantics@4.1.1: optional: true @@ -32811,6 +32466,8 @@ snapshots: is-interactive@1.0.0: {} + is-interactive@2.0.0: {} + is-lower-case@1.1.3: dependencies: lower-case: 1.1.4 @@ -32844,6 +32501,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-reference@3.0.3: @@ -32907,6 +32566,10 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-upper-case@1.1.2: dependencies: upper-case: 1.1.3 @@ -32943,6 +32606,8 @@ snapshots: isexe@3.1.1: optional: true + isomorphic.js@0.2.5: {} + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -32950,7 +32615,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.26.0 - '@babel/parser': 7.26.2 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -32960,7 +32625,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.26.0 - '@babel/parser': 7.26.2 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 @@ -32996,12 +32661,6 @@ snapshots: reflect.getprototypeof: 1.0.7 set-function-name: 2.0.2 - jackspeak@2.3.6: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -33398,6 +33057,10 @@ snapshots: jiti@1.21.6: {} + jiti@2.4.2: {} + + jiti@2.7.0: {} + jose@5.10.0: {} jose@6.2.3: {} @@ -33408,18 +33071,8 @@ snapshots: js-base64@3.7.8: {} - js-beautify@1.15.1: - dependencies: - config-chain: 1.1.13 - editorconfig: 1.0.4 - glob: 10.4.5 - js-cookie: 3.0.5 - nopt: 7.2.1 - js-cookie@2.2.1: {} - js-cookie@3.0.5: {} - js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1 @@ -33562,6 +33215,15 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + juice@11.1.1: + dependencies: + cheerio: 1.0.0 + commander: 12.1.0 + entities: 7.0.1 + mensch: 0.3.4 + slick: 1.12.2 + web-resource-inliner: 8.0.0 + jwa@1.4.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -33638,6 +33300,10 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -33651,6 +33317,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lie@3.1.1: dependencies: immediate: 3.0.6 @@ -33659,6 +33329,55 @@ snapshots: dependencies: immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@2.1.0: {} lilconfig@3.1.2: {} @@ -33830,6 +33549,16 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-symbols@6.0.0: + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.2.0 + log-update@6.1.0: dependencies: ansi-escapes: 7.0.0 @@ -33879,6 +33608,12 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@10.4.3: {} lru-cache@11.2.2: {} @@ -33889,6 +33624,10 @@ snapshots: lru-cache@7.18.3: {} + lucide-react@0.483.0(react@18.3.1): + dependencies: + react: 18.3.1 + lucide-react@1.16.0(react@18.3.1): dependencies: react: 18.3.1 @@ -33972,14 +33711,11 @@ snapshots: markdown-table@3.0.4: {} - marked@7.0.4: {} + marked@15.0.12: {} - math-intrinsics@1.1.0: {} + marked@18.0.5: {} - md-to-react-email@5.0.2(react@18.3.1): - dependencies: - marked: 7.0.4 - react: 18.3.1 + math-intrinsics@1.1.0: {} mdast-util-definitions@5.1.2: dependencies: @@ -34271,6 +34007,8 @@ snapshots: memoize-one@5.2.1: {} + mensch@0.3.4: {} + merge-descriptors@1.0.1: {} merge-descriptors@1.0.3: {} @@ -34767,10 +34505,6 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimatch@9.0.1: - dependencies: - brace-expansion: 2.0.1 - minimatch@9.0.3: dependencies: brace-expansion: 2.0.1 @@ -34968,6 +34702,8 @@ snapshots: stacktrace-js: 2.0.2 stylis: 4.3.4 + nanoid@3.3.16: {} + nanoid@3.3.7: {} native-promise-only@0.8.1: {} @@ -35011,32 +34747,6 @@ snapshots: netmask@2.0.2: {} - next@14.1.4(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@next/env': 14.1.4 - '@swc/helpers': 0.5.2 - busboy: 1.6.0 - caniuse-lite: 1.0.30001684 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.24.5)(react@18.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 14.1.4 - '@next/swc-darwin-x64': 14.1.4 - '@next/swc-linux-arm64-gnu': 14.1.4 - '@next/swc-linux-arm64-musl': 14.1.4 - '@next/swc-linux-x64-gnu': 14.1.4 - '@next/swc-linux-x64-musl': 14.1.4 - '@next/swc-win32-arm64-msvc': 14.1.4 - '@next/swc-win32-ia32-msvc': 14.1.4 - '@next/swc-win32-x64-msvc': 14.1.4 - '@opentelemetry/api': 1.9.0 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nice-grpc-client-middleware-retry@3.1.11: dependencies: abort-controller-x: 0.4.3 @@ -35102,6 +34812,11 @@ snapshots: detect-libc: 2.1.2 optional: true + node-html-parser@7.1.0: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + node-int64@0.4.0: {} node-plop@0.26.3: @@ -35133,10 +34848,6 @@ snapshots: abbrev: 1.1.1 optional: true - nopt@7.2.1: - dependencies: - abbrev: 2.0.0 - normalize-package-data@5.0.0: dependencies: hosted-git-info: 6.1.3 @@ -35197,6 +34908,14 @@ snapshots: nwsapi@2.2.13: {} + nypm@0.6.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.3.1 + tinyexec: 0.3.2 + oas-kit-common@1.0.8: dependencies: fast-safe-stringify: 2.1.1 @@ -35369,6 +35088,18 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + ora@8.2.0: + dependencies: + chalk: 5.3.0 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.0 + orderedmap@2.1.1: {} os-name@4.0.1: @@ -35526,6 +35257,8 @@ snapshots: parse-ms@2.1.0: {} + parse-srcset@1.0.2: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -35631,6 +35364,8 @@ snapshots: pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.0: {} pause@0.0.1: {} @@ -35716,6 +35451,12 @@ snapshots: mlly: 1.7.3 pathe: 1.1.2 + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + platform@1.3.6: {} playwright-core@1.49.0: {} @@ -35738,13 +35479,6 @@ snapshots: dependencies: postcss: 8.4.49 - postcss-import@15.1.0(postcss@8.4.38): - dependencies: - postcss: 8.4.38 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.8 - postcss-import@15.1.0(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -35752,24 +35486,11 @@ snapshots: read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.4.38): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.4.38 - postcss-js@4.0.1(postcss@8.4.49): dependencies: camelcase-css: 2.0.1 postcss: 8.4.49 - postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)): - dependencies: - lilconfig: 3.1.2 - yaml: 2.4.2 - optionalDependencies: - postcss: 8.4.38 - ts-node: 10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5) - postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)): dependencies: lilconfig: 3.1.2 @@ -35778,21 +35499,12 @@ snapshots: postcss: 8.4.49 ts-node: 10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5) - postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.38)(tsx@4.20.6)(yaml@2.6.1): - dependencies: - lilconfig: 3.1.2 - optionalDependencies: - jiti: 1.21.6 - postcss: 8.4.38 - tsx: 4.20.6 - yaml: 2.6.1 - - postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.49)(tsx@4.20.6)(yaml@2.6.1): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(yaml@2.6.1): dependencies: lilconfig: 3.1.2 optionalDependencies: - jiti: 1.21.6 - postcss: 8.4.49 + jiti: 2.7.0 + postcss: 8.5.24 tsx: 4.20.6 yaml: 2.6.1 @@ -35829,11 +35541,6 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.4.49) string-hash: 1.1.3 - postcss-nested@6.2.0(postcss@8.4.38): - dependencies: - postcss: 8.4.38 - postcss-selector-parser: 6.1.2 - postcss-nested@6.2.0(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -35856,21 +35563,21 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.31: + postcss@8.4.38: dependencies: nanoid: 3.3.7 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.4.38: + postcss@8.4.49: dependencies: nanoid: 3.3.7 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.4.49: + postcss@8.5.24: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -35916,6 +35623,8 @@ snapshots: prettier@3.4.1: {} + prettier@3.9.6: {} + pretty-bytes@5.6.0: {} pretty-bytes@6.1.1: {} @@ -35936,16 +35645,8 @@ snapshots: dependencies: parse-ms: 2.1.0 - prism-react-renderer@2.1.0(react@18.3.1): - dependencies: - '@types/prismjs': 1.26.5 - clsx: 1.2.1 - react: 18.3.1 - prismjs@1.27.0: {} - prismjs@1.29.0: {} - prismjs@1.30.0: {} proc-log@3.0.0: {} @@ -36105,8 +35806,6 @@ snapshots: prosemirror-state: 1.4.3 prosemirror-transform: 1.10.5 - proto-list@1.2.4: {} - protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -36479,60 +36178,28 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 - react-email@2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.2)(eslint@8.57.1)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)): + react-email@4.3.2: dependencies: - '@babel/core': 7.24.5 - '@babel/parser': 7.24.5 - '@radix-ui/colors': 1.0.1 - '@radix-ui/react-collapsible': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popover': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.1.0(@types/react@18.2.47)(react@18.3.1) - '@radix-ui/react-toggle-group': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.2.47)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@swc/core': 1.3.101(@swc/helpers@0.5.2) - '@types/react': 18.2.47 - '@types/react-dom': 18.3.1 - '@types/webpack': 5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.11) - autoprefixer: 10.4.14(postcss@8.4.38) - chalk: 4.1.2 - chokidar: 3.5.3 - clsx: 2.1.0 - commander: 11.1.0 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7 + chokidar: 4.0.3 + commander: 13.1.0 debounce: 2.0.0 - esbuild: 0.19.11 - eslint-config-prettier: 9.0.0(eslint@8.57.1) - eslint-config-turbo: 1.10.12(eslint@8.57.1) - framer-motion: 10.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - glob: 10.3.4 - log-symbols: 4.1.0 - mime-types: 2.1.35 - next: 14.1.4(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + esbuild: 0.25.12 + glob: 11.0.3 + jiti: 2.4.2 + log-symbols: 7.0.1 + mime-types: 3.0.2 normalize-path: 3.0.0 - ora: 5.4.1 - postcss: 8.4.38 - prism-react-renderer: 2.1.0(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - socket.io: 4.7.3 - socket.io-client: 4.7.3 - sonner: 1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - source-map-js: 1.0.2 - stacktrace-parser: 0.1.10 - tailwind-merge: 2.2.0 - tailwindcss: 3.4.0(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)) - typescript: 5.1.6 + nypm: 0.6.0 + ora: 8.2.0 + prompts: 2.4.2 + socket.io: 4.8.1 + tsconfig-paths: 4.2.0 transitivePeerDependencies: - - '@opentelemetry/api' - - '@swc/helpers' - - babel-plugin-macros - bufferutil - - eslint - - sass - supports-color - - ts-node - - uglify-js - utf-8-validate - - webpack-cli react-fast-compare@3.2.2: {} @@ -36623,15 +36290,11 @@ snapshots: react-fast-compare: 3.2.2 warning: 4.0.3 - react-refresh@0.14.2: {} - - react-remove-scroll-bar@2.3.6(@types/react@18.2.47)(react@18.3.1): + react-promise-suspense@0.3.4: dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.2.47)(react@18.3.1) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.2.47 + fast-deep-equal: 2.0.1 + + react-refresh@0.14.2: {} react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1): dependencies: @@ -36649,17 +36312,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - react-remove-scroll@2.5.7(@types/react@18.2.47)(react@18.3.1): - dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.6(@types/react@18.2.47)(react@18.3.1) - react-style-singleton: 2.2.1(@types/react@18.2.47)(react@18.3.1) - tslib: 2.8.1 - use-callback-ref: 1.3.2(@types/react@18.2.47)(react@18.3.1) - use-sidecar: 1.1.2(@types/react@18.2.47)(react@18.3.1) - optionalDependencies: - '@types/react': 18.2.47 - react-remove-scroll@2.5.7(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 @@ -36743,15 +36395,6 @@ snapshots: '@react-types/shared': 3.32.1(react@18.3.1) react: 18.3.1 - react-style-singleton@2.2.1(@types/react@18.2.47)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - invariant: 2.2.4 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.2.47 - react-style-singleton@2.2.1(@types/react@18.3.12)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -36761,14 +36404,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - react-style-singleton@2.2.3(@types/react@18.2.47)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.2.47 - react-style-singleton@2.2.3(@types/react@18.3.12)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -37322,6 +36957,16 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.5: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.24 + sax@1.4.1: {} saxes@6.0.0: @@ -37606,6 +37251,8 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + slick@1.12.2: {} + slugify@1.6.6: {} smart-buffer@4.2.0: {} @@ -37630,17 +37277,6 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 - engine.io-client: 6.5.4 - socket.io-parser: 4.2.4 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - socket.io-client@4.8.0: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -37670,20 +37306,6 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3: - dependencies: - accepts: 1.3.8 - base64id: 2.0.0 - cors: 2.8.6 - debug: 4.3.7 - engine.io: 6.5.5 - socket.io-adapter: 2.5.5 - socket.io-parser: 4.2.4 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - socket.io@4.7.5: dependencies: accepts: 1.3.8 @@ -37733,13 +37355,6 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 - sonner@1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - source-map-js@1.0.2: {} - source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -37817,10 +37432,6 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 - stacktrace-parser@0.1.10: - dependencies: - type-fest: 0.7.1 - standard-as-callback@2.1.0: {} statuses@2.0.1: {} @@ -37829,6 +37440,8 @@ snapshots: std-env@3.8.0: {} + stdin-discarder@0.2.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -38043,13 +37656,6 @@ snapshots: stylis: 4.3.2 tslib: 2.6.2 - styled-jsx@5.1.1(@babel/core@7.24.5)(react@18.3.1): - dependencies: - client-only: 0.0.1 - react: 18.3.1 - optionalDependencies: - '@babel/core': 7.24.5 - stylis@4.3.2: {} stylis@4.3.4: {} @@ -38158,43 +37764,12 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.8.1 - tailwind-merge@2.2.0: - dependencies: - '@babel/runtime': 7.26.0 - tailwind-merge@2.4.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.15(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5))): dependencies: tailwindcss: 3.4.15(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)) - tailwindcss@3.4.0(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)): - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.5.3 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.2 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.6 - lilconfig: 2.1.0 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.4.38 - postcss-import: 15.1.0(postcss@8.4.38) - postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)) - postcss-nested: 6.2.0(postcss@8.4.38) - postcss-selector-parser: 6.1.2 - resolve: 1.22.8 - sucrase: 3.35.0 - transitivePeerDependencies: - - ts-node - tailwindcss@3.4.15(ts-node@10.9.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(@types/node@22.15.0)(typescript@5.4.5)): dependencies: '@alloc/quick-lru': 5.2.0 @@ -38222,8 +37797,12 @@ snapshots: transitivePeerDependencies: - ts-node + tailwindcss@4.3.3: {} + tapable@2.2.1: {} + tapable@2.3.3: {} + tar-fs@2.1.1: dependencies: chownr: 1.1.4 @@ -38280,18 +37859,6 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 - terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.11)(webpack@5.96.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.11)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.36.0 - webpack: 5.96.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.24.0) - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.2) - esbuild: 0.19.11 - terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.12)(webpack@5.87.0(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.12)): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -38364,6 +37931,8 @@ snapshots: tinyexec@0.3.1: {} + tinyexec@0.3.2: {} + tinyglobby@0.2.10: dependencies: fdir: 6.4.2(picomatch@4.0.2) @@ -38559,7 +38128,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@1.21.6)(postcss@8.4.38)(tsx@4.20.6)(typescript@5.4.5)(yaml@2.6.1): + tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(typescript@5.4.5)(yaml@2.6.1): dependencies: bundle-require: 5.0.0(esbuild@0.24.0) cac: 6.7.14 @@ -38569,7 +38138,7 @@ snapshots: esbuild: 0.24.0 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.4.38)(tsx@4.20.6)(yaml@2.6.1) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(yaml@2.6.1) resolve-from: 5.0.0 rollup: 4.27.4 source-map: 0.8.0-beta.0 @@ -38579,7 +38148,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.2) - postcss: 8.4.38 + postcss: 8.5.24 typescript: 5.4.5 transitivePeerDependencies: - jiti @@ -38587,7 +38156,7 @@ snapshots: - tsx - yaml - tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@1.21.6)(postcss@8.4.49)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1): + tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.2))(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.6.1): dependencies: bundle-require: 5.0.0(esbuild@0.24.0) cac: 6.7.14 @@ -38597,7 +38166,7 @@ snapshots: esbuild: 0.24.0 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.4.49)(tsx@4.20.6)(yaml@2.6.1) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.24)(tsx@4.20.6)(yaml@2.6.1) resolve-from: 5.0.0 rollup: 4.27.4 source-map: 0.8.0-beta.0 @@ -38607,7 +38176,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.2) - postcss: 8.4.49 + postcss: 8.5.24 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -38673,8 +38242,6 @@ snapshots: type-fest@0.21.3: {} - type-fest@0.7.1: {} - type-fest@2.19.0: optional: true @@ -38761,8 +38328,6 @@ snapshots: typescript@5.1.3: {} - typescript@5.1.6: {} - typescript@5.4.5: {} typescript@5.5.4: {} @@ -38988,13 +38553,6 @@ snapshots: urlpattern-polyfill@10.0.0: optional: true - use-callback-ref@1.3.2(@types/react@18.2.47)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.2.47 - use-callback-ref@1.3.2(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 @@ -39009,14 +38567,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - use-sidecar@1.1.2(@types/react@18.2.47)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.2.47 - use-sidecar@1.1.2(@types/react@18.3.12)(react@18.3.1): dependencies: detect-node-es: 1.1.0 @@ -39071,6 +38621,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.0: {} + uuid@8.3.2: {} uuid@9.0.1: {} @@ -39094,6 +38646,8 @@ snapshots: optionalDependencies: typescript: 5.4.5 + valid-data-url@3.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -39186,13 +38740,13 @@ snapshots: dependencies: video.js: 8.23.6 - vite-node@1.6.0(@types/node@22.15.0)(terser@5.36.0): + vite-node@1.6.0(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - '@types/node' - less @@ -39204,13 +38758,13 @@ snapshots: - supports-color - terser - vite-node@2.1.6(@types/node@22.15.0)(terser@5.36.0): + vite-node@2.1.6(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.5.4 pathe: 1.1.2 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - '@types/node' - less @@ -39230,48 +38784,48 @@ snapshots: magic-string: 0.30.14 minimatch: 10.0.1 - vite-plugin-pwa@1.3.0(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))(workbox-build@7.4.1(@types/babel__core@7.20.5))(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.10 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) workbox-build: 7.4.1(@types/babel__core@7.20.5) workbox-window: 7.4.1 transitivePeerDependencies: - supports-color - vite-plugin-static-copy@1.0.6(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)): + vite-plugin-static-copy@1.0.6(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)): dependencies: chokidar: 3.6.0 fast-glob: 3.3.2 fs-extra: 11.2.0 picocolors: 1.1.1 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) - vite-plugin-svgr@4.2.0(rollup@4.62.2)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)): + vite-plugin-svgr@4.2.0(rollup@4.62.2)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)): dependencies: '@rollup/pluginutils': 5.1.3(rollup@4.62.2) '@svgr/core': 8.1.0(typescript@5.4.5) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.4.5)) - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.0.0(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)): + vite-tsconfig-paths@5.0.0(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)): dependencies: debug: 4.3.7 globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.4.5) optionalDependencies: - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.11(@types/node@22.15.0)(terser@5.36.0): + vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0): dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -39279,12 +38833,13 @@ snapshots: optionalDependencies: '@types/node': 22.15.0 fsevents: 2.3.3 + lightningcss: 1.32.0 terser: 5.36.0 - vitest@2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0): + vitest@2.1.6(@types/node@22.15.0)(@vitest/browser@2.1.6)(@vitest/ui@2.1.6)(jsdom@24.1.3(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(terser@5.36.0): dependencies: '@vitest/expect': 2.1.6 - '@vitest/mocker': 2.1.6(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0)) + '@vitest/mocker': 2.1.6(msw@2.6.6(@types/node@22.15.0)(typescript@5.4.5))(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0)) '@vitest/pretty-format': 2.1.6 '@vitest/runner': 2.1.6 '@vitest/snapshot': 2.1.6 @@ -39300,12 +38855,12 @@ snapshots: tinyexec: 0.3.1 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.11(@types/node@22.15.0)(terser@5.36.0) - vite-node: 2.1.6(@types/node@22.15.0)(terser@5.36.0) + vite: 5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) + vite-node: 2.1.6(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.15.0 - '@vitest/browser': 2.1.6(@types/node@22.15.0)(playwright@1.49.0)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(terser@5.36.0))(vitest@2.1.6)(webdriverio@8.40.6) + '@vitest/browser': 2.1.6(@types/node@22.15.0)(playwright@1.49.0)(typescript@5.4.5)(vite@5.4.11(@types/node@22.15.0)(lightningcss@1.32.0)(terser@5.36.0))(vitest@2.1.6)(webdriverio@8.40.6) '@vitest/ui': 2.1.6(vitest@2.1.6) jsdom: 24.1.3(canvas@2.11.2) transitivePeerDependencies: @@ -39376,6 +38931,14 @@ snapshots: web-namespaces@2.0.1: {} + web-resource-inliner@8.0.0: + dependencies: + ansi-colors: 4.1.3 + escape-goat: 3.0.0 + htmlparser2: 9.1.0 + mime: 2.6.0 + valid-data-url: 3.0.1 + web-streams-polyfill@3.3.3: {} web-streams-polyfill@4.0.0-beta.3: {} @@ -39483,12 +39046,12 @@ snapshots: webpack@5.96.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.12): dependencies: '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 + '@types/estree': 1.0.9 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.14.0 - browserslist: 4.24.2 + browserslist: 4.28.5 chrome-trace-event: 1.0.4 enhanced-resolve: 5.17.1 es-module-lexer: 1.5.4 @@ -39510,36 +39073,6 @@ snapshots: - esbuild - uglify-js - webpack@5.96.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.24.0): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.0 - browserslist: 4.24.2 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.11)(webpack@5.96.1(@swc/core@1.3.101(@swc/helpers@0.5.2))(esbuild@0.19.11)) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webrtc-adapter@9.0.5: dependencies: sdp: 3.2.2 @@ -39855,12 +39388,24 @@ snapshots: xmlchars@2.2.0: {} - xmlhttprequest-ssl@2.0.0: {} - xmlhttprequest-ssl@2.1.2: {} xtend@4.0.2: {} + y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.3)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.4 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + + y-protocols@1.0.7(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + yjs: 13.6.31 + y18n@5.0.8: {} yallist@3.1.1: {} @@ -39903,12 +39448,18 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yn@3.1.1: {} yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + yoctocolors@2.2.0: {} + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2