diff --git a/apps/api/package.json b/apps/api/package.json index bdaf08bca3..35ce15c16b 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..907da326c1 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; + +export type AutomationStepRecordInput = { + parentId: UUIDType | null; + automationId: UUIDType; + type: AutomationType; + typeContext: TypeContext; +}; +export type TypeContext = { + name: string; + providedVariables: Array<{ + key: string; + value: unknown; + }>; +}; + +export type SendEmailActionContext = TypeContext & { + templateId: string; + language?: string; + variableMapping: Record; +}; + +export type AutomationStepUpdateInput = { + type: AutomationType; + typeContext: TypeContext; +}; + +export type AutomationStepBulkUpdate = { + id: UUIDType; + parentId: UUIDType | null; + automationId: UUIDType; + type: AutomationType; + typeContext: TypeContext; +}; + +export type AutomationActionStep = { + typeContext: TypeContext; +}; +export type AutomationLogRecord = InferSelectModel; +export type AutomationLogRecordInput = InferInsertModel; + +export const AutomationStepSchema = Type.Omit(omitTenantId(createSelectSchema(automationSteps)), [ + "createdAt", + "updatedAt", +]); + +export type AutomationStep = Static; diff --git a/apps/api/src/announcements/types/automations.types.ts b/apps/api/src/announcements/types/automations.types.ts new file mode 100644 index 0000000000..9897811340 --- /dev/null +++ b/apps/api/src/announcements/types/automations.types.ts @@ -0,0 +1,60 @@ +import { + UserInviteEvent, + UsersImportInviteEmailsEvent, + UserPasswordReminderEvent, + UserWelcomeEvent, + UserFirstLoginEvent, + UsersAssignedToCourseEvent, + UsersShortInactivityEvent, + UsersLongInactivityEvent, + UserChapterFinishedEvent, + UserCourseFinishedEvent, + UserRegisteredEvent, + UserPasswordCreatedEvent, + CourseCompletedEvent, + CertificateExpirationWarningEmailEvent, + CertificateArchivedEmailEvent, + AnnouncementPublishedEvent, + CourseChatUserMentionedEvent, + CourseDueDateReminderEmailEvent, +} from "src/events"; + +import type { AutomationEventTypes } from "src/automations/handlers/automations-handler"; + +export enum AutomationStatus { + Enabled = "enabled", + Disabled = "disabled", + Archived = "archived", + Draft = "draft", +} + +export const automationTypes = ["action", "condition", "trigger"] as const; + +export type AutomationType = (typeof automationTypes)[number]; + +export const AutomationStepType = { + Action: "action", + Condition: "condition", + Trigger: "trigger", +} as const; + +export const AutomationEventNames: Record = { + [UserInviteEvent.name]: "user_invited", + [UsersImportInviteEmailsEvent.name]: "users_imported_invite", + [UserPasswordReminderEvent.name]: "user_password_reminder", + [UserWelcomeEvent.name]: "user_welcome", + [UserFirstLoginEvent.name]: "user_first_login", + [UsersAssignedToCourseEvent.name]: "users_assigned_to_course", + [UsersShortInactivityEvent.name]: "users_short_inactivity", + [UsersLongInactivityEvent.name]: "users_long_inactivity", + [UserChapterFinishedEvent.name]: "user_chapter_finished", + [UserCourseFinishedEvent.name]: "user_course_finished", + [UserRegisteredEvent.name]: "user_registered", + [UserPasswordCreatedEvent.name]: "user_password_created", + [CourseCompletedEvent.name]: "course_completed", + [CertificateExpirationWarningEmailEvent.name]: "certificate_expiration_warning", + [CertificateArchivedEmailEvent.name]: "certificate_archived", + [AnnouncementPublishedEvent.name]: "announcement_published", + [CourseChatUserMentionedEvent.name]: "course_chat_user_mentioned", + [CourseDueDateReminderEmailEvent.name]: "course_due_date_reminder", +}; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0e3ec0aacf..34b31bb567 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -25,6 +25,7 @@ import { AuthModule } from "./auth/auth.module"; import { GoogleStrategy } from "./auth/strategy/google.strategy"; import { MicrosoftStrategy } from "./auth/strategy/microsoft.strategy"; import { SlackStrategy } from "./auth/strategy/slack.strategy"; +import { AutomationsModule } from "./automations/automations.module"; import { BunnyStreamModule } from "./bunny/bunnyStream.module"; import { CacheModule } from "./cache/cache.module"; import { CalendarModule } from "./calendar/calendar.module"; @@ -48,6 +49,8 @@ import { PermissionsGuard } from "./common/guards/permissions.guard"; import { StagingGuard } from "./common/guards/staging.guard"; import { CourseChatModule } from "./course-chat/course-chat.module"; import { CourseModule } from "./courses/course.module"; +import { PublicCourseThumbnailModule } from "./courses/public-course-thumbnail.module"; +import { EmailNotificationTemplatesModule } from "./email-notification-templates/email-templates.module"; import { EventsModule } from "./events/events.module"; import { FileModule } from "./file/files.module"; import { GlobalSearchModule } from "./global-search/global-search.module"; @@ -63,6 +66,7 @@ import { LumaModule } from "./luma/luma.module"; import { NewsModule } from "./news/news.module"; import { OutboxModule } from "./outbox/outbox.module"; import { PermissionsModule } from "./permissions/permissions.module"; +import { PublicEmailTemplateImageModule } from "./public-email-template-image/public-email-template-image.module"; import { QuestionsModule } from "./questions/question.module"; import { AppThrottlerGuard } from "./rate-limit/app-throttler.guard"; import { RedisThrottlerStorage } from "./rate-limit/redis-throttler.storage"; @@ -168,6 +172,9 @@ import type { RedisClient } from "src/redis"; ScormModule, CertificatesModule, AnnouncementsModule, + EmailNotificationTemplatesModule, + PublicCourseThumbnailModule, + PublicEmailTemplateImageModule, IngestionModule, IntegrationModule, LearningTimeModule, @@ -185,6 +192,7 @@ import type { RedisClient } from "src/redis"; LumaModule, LiveTrainingModule, CalendarModule, + AutomationsModule, ], controllers: [], providers: [ diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 505af23ae2..338798ca2e 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -675,7 +675,7 @@ export class AuthService { email: string, oldTokenHash: string, createToken: string, - emailTemplate: { text: string; html: string }, + emailTemplate: { text: Promise | string; html: Promise | string }, expiryDate: Date, reminderCount: number, ) { @@ -695,12 +695,14 @@ export class AuthService { userId, ); + const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]); + await this.emailService.sendEmailWithLogo( { to: email, subject: getEmailSubject("passwordReminderEmail", defaultEmailSettings.language), - text: emailTemplate.text, - html: emailTemplate.html, + text, + html, }, { tenantId }, ); @@ -876,7 +878,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/automations/automation-logs/automation-logs.controller.spec.ts b/apps/api/src/automations/automation-logs/automation-logs.controller.spec.ts new file mode 100644 index 0000000000..63c760f738 --- /dev/null +++ b/apps/api/src/automations/automation-logs/automation-logs.controller.spec.ts @@ -0,0 +1,84 @@ +import { Test } from "@nestjs/testing"; + +import { BaseResponse } from "src/common"; + +import { AutomationLogsRepository } from "../repositories/automation-logs/automation-logs"; + +import { AutomationLogsController } from "./automation-logs.controller"; + +import type { TestingModule } from "@nestjs/testing"; +import type { UUIDType } from "src/common"; + +describe("AutomationLogsController", () => { + let controller: AutomationLogsController; + let repository: jest.Mocked; + + const automationId = "auto-1" as UUIDType; + + const mockLogs = [ + { id: "log-1", automationId, status: "success", createdAt: "2025-07-01" }, + { id: "log-2", automationId, status: "failed", createdAt: "2025-07-02" }, + ]; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AutomationLogsController], + providers: [ + { + provide: AutomationLogsRepository, + useValue: { + getAll: jest.fn(), + GetByAutomationId: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AutomationLogsController); + repository = module.get(AutomationLogsRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getAll", () => { + it("returns all logs wrapped in BaseResponse", async () => { + repository.getAll.mockResolvedValue(mockLogs as any); + + const result = await controller.getAll(); + + expect(repository.getAll).toHaveBeenCalled(); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(mockLogs); + }); + + it("returns empty array when no logs exist", async () => { + repository.getAll.mockResolvedValue([]); + + const result = await controller.getAll(); + + expect(result.data).toEqual([]); + }); + }); + + describe("getByAutomationId", () => { + it("returns logs for specific automation wrapped in BaseResponse", async () => { + repository.GetByAutomationId.mockResolvedValue(mockLogs as any); + + const result = await controller.getByAutomationId(automationId); + + expect(repository.GetByAutomationId).toHaveBeenCalledWith(automationId); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(mockLogs); + }); + + it("returns empty array when no logs for automation", async () => { + repository.GetByAutomationId.mockResolvedValue([]); + + const result = await controller.getByAutomationId(automationId); + + expect(result.data).toEqual([]); + }); + }); +}); diff --git a/apps/api/src/automations/automation-logs/automation-logs.controller.ts b/apps/api/src/automations/automation-logs/automation-logs.controller.ts new file mode 100644 index 0000000000..f380bbcf2c --- /dev/null +++ b/apps/api/src/automations/automation-logs/automation-logs.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Param } from "@nestjs/common"; +import { PERMISSIONS } from "@repo/shared"; + +import { BaseResponse, UUIDType } from "src/common"; +import { RequirePermission } from "src/common/decorators/require-permission.decorator"; + +import { AutomationLogsRepository } from "../repositories/automation-logs/automation-logs"; + +@RequirePermission(PERMISSIONS.AUTOMATION_MANAGE) +@Controller("automation-logs") +export class AutomationLogsController { + constructor(private readonly automationLogsRepository: AutomationLogsRepository) {} + + @Get() + async getAll() { + const logs = await this.automationLogsRepository.getAll(); + return new BaseResponse(logs); + } + + @Get("automation/:automationId") + async getByAutomationId(@Param("automationId") automationId: UUIDType) { + const logs = await this.automationLogsRepository.GetByAutomationId(automationId); + return new BaseResponse(logs); + } +} diff --git a/apps/api/src/automations/automation-runner/automation-data-resolver.service.ts b/apps/api/src/automations/automation-runner/automation-data-resolver.service.ts new file mode 100644 index 0000000000..60fdb32610 --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-data-resolver.service.ts @@ -0,0 +1,660 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { and, desc, eq, isNotNull } from "drizzle-orm"; + +import { AnnouncementsRepository } from "src/announcements/announcements.repository"; +import { DatabasePg } from "src/common"; +import { resolveTenantOrigin } from "src/common/helpers/resolveTenantOrigin"; +import { CourseChatRepository } from "src/course-chat/course-chat.repository"; +import { CourseService } from "src/courses/course.service"; +import { + AnnouncementPublishedEvent, + CertificateArchivedEmailEvent, + CertificateExpirationWarningEmailEvent, + CourseChatUserMentionedEvent, + CourseCompletedEvent, + CourseDueDateReminderEmailEvent, + UserChapterFinishedEvent, + UserCourseFinishedEvent, + UserFirstLoginEvent, + UserInviteEvent, + UserPasswordCreatedEvent, + UserPasswordReminderEvent, + UserRegisteredEvent, + UsersAssignedToCourseEvent, + UsersImportInviteEmailsEvent, + UsersLongInactivityEvent, + UsersShortInactivityEvent, + UserWelcomeEvent, +} from "src/events"; +import { DB_ADMIN } from "src/storage/db/db.providers"; +import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service"; +import { courses, studentCourses, users } from "src/storage/schema"; +import { UserService } from "src/user/user.service"; + +import type { AutomationResolvedRecipient } from "./automation-data-resolver.types"; +import type { AutomationEventTypes } from "../handlers/automations-handler"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class AutomationDataResolverService { + private readonly logger = new Logger(AutomationDataResolverService.name); + + constructor( + @Inject(DB_ADMIN) private readonly dbAdmin: DatabasePg, + private readonly userService: UserService, + private readonly courseService: CourseService, + private readonly announcementsRepository: AnnouncementsRepository, + private readonly courseChatRepository: CourseChatRepository, + private readonly tenantRunner: TenantDbRunnerService, + ) {} + + async resolve(event: AutomationEventTypes): Promise { + if (event instanceof UserInviteEvent) { + return this.resolveUserInvite(event); + } + if (event instanceof UsersImportInviteEmailsEvent) { + return this.resolveUsersImportInvite(event); + } + if (event instanceof UserPasswordReminderEvent) { + return this.resolveUserPasswordReminder(event); + } + if (event instanceof UserWelcomeEvent) { + return this.resolveUserWelcome(event); + } + if (event instanceof UserFirstLoginEvent) { + return this.resolveUserFirstLogin(event); + } + if (event instanceof UsersAssignedToCourseEvent) { + return this.resolveUsersAssignedToCourse(event); + } + if (event instanceof UsersShortInactivityEvent) { + return this.resolveUsersShortInactivity(event); + } + if (event instanceof UsersLongInactivityEvent) { + return this.resolveUsersLongInactivity(event); + } + if (event instanceof UserChapterFinishedEvent) { + return this.resolveUserChapterFinished(event); + } + if (event instanceof UserCourseFinishedEvent) { + return this.resolveUserCourseFinished(event); + } + if (event instanceof UserRegisteredEvent) { + return this.resolveUserRegistered(event); + } + if (event instanceof UserPasswordCreatedEvent) { + return this.resolveUserPasswordCreated(event); + } + if (event instanceof CourseCompletedEvent) { + return this.resolveCourseCompleted(event); + } + if (event instanceof CertificateExpirationWarningEmailEvent) { + return this.resolveCertificateExpirationWarning(event); + } + if (event instanceof CertificateArchivedEmailEvent) { + return this.resolveCertificateArchived(event); + } + if (event instanceof AnnouncementPublishedEvent) { + return this.resolveAnnouncementPublished(event); + } + if (event instanceof CourseChatUserMentionedEvent) { + return this.resolveCourseChatUserMentioned(event); + } + if (event instanceof CourseDueDateReminderEmailEvent) { + return this.resolveCourseDueDateReminder(event); + } + + this.logger.warn(`No resolver for event: ${(event as object).constructor.name}`); + return []; + } + + private async resolveUserInvite(event: UserInviteEvent): Promise { + const { email, userId, tenantId, token, invitedByUserName } = event.userInvite; + const user = await this.getUserSafe(userId); + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + const inviteLink = `${origin}/create-password?createToken=${token}`; + + return [ + { + userId, + userEmail: email, + tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: email, + inviteLink, + invitedByUserName: invitedByUserName ?? "Admin", + }, + }, + ]; + } + + private async resolveUsersImportInvite( + event: UsersImportInviteEmailsEvent, + ): Promise { + const { tenantId, recipients, invitedByUserName } = event.usersImportInviteEmails; + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + const results: AutomationResolvedRecipient[] = []; + + for (const recipient of recipients) { + const user = await this.getUserSafe(recipient.userId); + const inviteLink = `${origin}/create-password?createToken=${recipient.token}`; + + results.push({ + userId: recipient.userId, + userEmail: recipient.email, + tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: recipient.email, + inviteLink, + invitedByUserName: invitedByUserName ?? "Admin", + }, + }); + } + + return results; + } + + private async resolveUserPasswordReminder( + event: UserPasswordReminderEvent, + ): Promise { + const { email, userId, tenantId, token, origin } = event.userPasswordReminder; + const user = await this.getUserSafe(userId); + const baseOrigin = await resolveTenantOrigin(this.dbAdmin, tenantId, origin); + const resetPasswordLink = `${baseOrigin}/create-password?createToken=${token}`; + + return [ + { + userId, + userEmail: email, + tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: email, + resetPasswordLink, + }, + }, + ]; + } + + private async resolveUserWelcome( + event: UserWelcomeEvent, + ): Promise { + const { email, userId, tenantId, origin } = event.userWelcome; + const user = await this.getUserSafe(userId); + const baseOrigin = await resolveTenantOrigin(this.dbAdmin, tenantId, origin); + + return [ + { + userId, + userEmail: email, + tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: email, + platformUrl: `${baseOrigin}/courses`, + }, + }, + ]; + } + + private async resolveUserFirstLogin( + event: UserFirstLoginEvent, + ): Promise { + const { userId } = event.userFirstLogin; + const user = await this.userService.getUserById(userId, this.dbAdmin); + const origin = await resolveTenantOrigin(this.dbAdmin, user.tenantId); + + return [ + { + userId, + userEmail: user.email, + tenantId: user.tenantId, + variables: { + userFirstName: user.firstName, + userLastName: user.lastName, + userEmail: user.email, + loginDate: new Date().toISOString(), + platformUrl: `${origin}/courses`, + }, + }, + ]; + } + + private async resolveUsersAssignedToCourse( + event: UsersAssignedToCourseEvent, + ): Promise { + const { studentIds, courseId } = event.usersAssignedToCourse; + const tenantId = await this.getCourseTenantId(courseId); + + return this.tenantRunner.runWithTenant(tenantId, async () => { + const { courseName } = await this.courseService.getCourseEmailData(courseId); + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + const courseUrl = `${origin}/course/${courseId}`; + + const dueDates = await this.courseService.getStudentsDueDatesForCourse(courseId, studentIds); + + const results: AutomationResolvedRecipient[] = []; + + for (const studentId of studentIds) { + const user = await this.getUserSafe(studentId); + if (!user) continue; + + results.push({ + userId: studentId, + userEmail: user.email, + tenantId, + variables: { + userFirstName: user.firstName, + userLastName: user.lastName, + userEmail: user.email, + courseName: courseName ?? "", + courseUrl, + dueDate: dueDates[studentId] ?? "", + }, + }); + } + + return results; + }); + } + + private async resolveUsersShortInactivity( + event: UsersShortInactivityEvent, + ): Promise { + const { tenantId, users: inactiveUsers } = event.usersShortInactivity; + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + return inactiveUsers.map((u) => ({ + userEmail: u.email, + tenantId, + variables: { + userFirstName: u.name.split(" ")[0] ?? "", + userLastName: u.name.split(" ").slice(1).join(" ") ?? "", + userEmail: u.email, + courseName: "", + courseUrl: `${origin}/courses`, + daysInactive: "", + }, + })); + } + + private async resolveUsersLongInactivity( + event: UsersLongInactivityEvent, + ): Promise { + const { tenantId, users: inactiveUsers } = event.usersLongInactivity; + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + return inactiveUsers.map((u) => ({ + userEmail: u.email, + tenantId, + variables: { + userFirstName: u.name.split(" ")[0] ?? "", + userLastName: u.name.split(" ").slice(1).join(" ") ?? "", + userEmail: u.email, + courseName: "", + courseUrl: `${origin}/courses`, + daysInactive: "", + }, + })); + } + + private async resolveUserChapterFinished( + event: UserChapterFinishedEvent, + ): Promise { + const { courseId, chapterId, userId, actor } = event.chapterFinishedData; + const user = await this.userService.getUserById(userId, this.dbAdmin); + const tenantId = actor.tenantId; + + return this.tenantRunner.runWithTenant(tenantId, async () => { + const { courseName } = await this.courseService.getCourseEmailData(courseId); + const chapterName = await this.courseService.getChapterName(chapterId); + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + return [ + { + userId, + userEmail: user.email, + tenantId, + variables: { + userFirstName: user.firstName, + userLastName: user.lastName, + userEmail: user.email, + courseName: courseName ?? "", + chapterName: chapterName ?? "", + courseUrl: `${origin}/course/${courseId}`, + }, + }, + ]; + }); + } + + private async resolveUserCourseFinished( + event: UserCourseFinishedEvent, + ): Promise { + const { courseId, userId, actor } = event.courseFinishedData; + const user = await this.userService.getUserById(userId, this.dbAdmin); + const tenantId = actor.tenantId; + + return this.tenantRunner.runWithTenant(tenantId, async () => { + const { courseName, hasCertificate } = await this.courseService.getCourseEmailData(courseId); + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + const certificateUrl = hasCertificate ? `${origin}/certificates` : ""; + + return [ + { + userId, + userEmail: user.email, + tenantId, + variables: { + userFirstName: user.firstName, + userLastName: user.lastName, + userEmail: user.email, + courseName: courseName ?? "", + finishedAt: new Date().toISOString(), + certificateUrl, + hasCertificate: String(hasCertificate ?? false), + courseUrl: `${origin}/course/${courseId}`, + }, + }, + ]; + }); + } + + private async resolveUserRegistered( + event: UserRegisteredEvent, + ): Promise { + const { id, firstName, lastName, email } = event.user; + const tenantId = await this.getUserTenantId(id); + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + return [ + { + userId: id, + userEmail: email, + tenantId, + variables: { + userFirstName: firstName, + userLastName: lastName, + userEmail: email, + registrationDate: new Date().toISOString(), + profileLink: `${origin}/admin/users/${id}`, + userName: [firstName, lastName].filter(Boolean).join(" "), + }, + }, + ]; + } + + private async resolveUserPasswordCreated( + event: UserPasswordCreatedEvent, + ): Promise { + const { id, firstName, lastName, email } = event.user; + const tenantId = await this.getUserTenantId(id); + + return [ + { + userId: id, + userEmail: email, + tenantId, + variables: { + userFirstName: firstName, + userLastName: lastName, + userEmail: email, + createdAt: new Date().toISOString(), + }, + }, + ]; + } + + private async resolveCourseCompleted( + event: CourseCompletedEvent, + ): Promise { + const { courseId, userName, courseTitle } = event.courseCompletionData; + const tenantId = await this.getCourseTenantId(courseId); + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + const completedStudent = await this.getLastCompletedStudentForCourse(courseId); + + const nameParts = userName.split(" "); + const firstName = nameParts[0] ?? ""; + const lastName = nameParts.slice(1).join(" ") ?? ""; + + return [ + { + userEmail: completedStudent?.email ?? "", + tenantId, + variables: { + userFirstName: firstName, + userLastName: lastName, + userEmail: completedStudent?.email ?? "", + courseName: courseTitle, + finishedAt: new Date().toISOString(), + userName, + progressLink: `${origin}/admin/courses/${courseId}/progress`, + }, + }, + ]; + } + + private async resolveCertificateExpirationWarning( + event: CertificateExpirationWarningEmailEvent, + ): Promise { + const { certificates } = event.certificateExpirationWarningEmailData; + + const results: AutomationResolvedRecipient[] = []; + + for (const cert of certificates) { + const user = await this.getUserSafe(cert.userId); + + results.push({ + userId: cert.userId, + userEmail: cert.userEmail, + tenantId: cert.tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: cert.userEmail, + certificateName: cert.courseName, + expirationDate: cert.expiresAt, + daysLeft: "", + courseUrl: cert.courseLink, + }, + }); + } + + return results; + } + + private async resolveCertificateArchived( + event: CertificateArchivedEmailEvent, + ): Promise { + const { certificates, reason } = event.certificateArchivedEmailData; + + const results: AutomationResolvedRecipient[] = []; + + for (const cert of certificates) { + const user = await this.getUserSafe(cert.userId); + + results.push({ + userId: cert.userId, + userEmail: cert.userEmail, + tenantId: cert.tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: cert.userEmail, + certificateName: cert.courseName, + archivedAt: new Date().toISOString(), + courseUrl: cert.courseLink, + archiveReason: reason ?? "expired", + }, + }); + } + + return results; + } + + private async resolveAnnouncementPublished( + event: AnnouncementPublishedEvent, + ): Promise { + const { announcementId } = event.announcementPublishedData; + const [announcement] = await this.announcementsRepository.getAnnouncementById(announcementId); + + if (!announcement) return []; + + const tenantId = announcement.tenantId; + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + const title = String(Object.values(announcement.title ?? {})[0] ?? ""); + const content = String(Object.values(announcement.content ?? {})[0] ?? ""); + const announcementUrl = `${origin}/announcements`; + + const recipients = + await this.announcementsRepository.getAnnouncementEmailRecipients(announcementId); + + if (recipients.length === 0) return []; + + const results: AutomationResolvedRecipient[] = []; + + for (const recipient of recipients) { + const user = await this.getUserSafe(recipient.id); + + results.push({ + userId: recipient.id, + userEmail: recipient.email, + tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + announcementTitle: title, + announcementContent: content, + announcementUrl, + }, + }); + } + + return results; + } + + private async resolveCourseChatUserMentioned( + event: CourseChatUserMentionedEvent, + ): Promise { + const { tenantId, courseId, currentUser, messageId, mentionedUserIds } = + event.courseChatUserMentionedData; + + return this.tenantRunner.runWithTenant(tenantId, async () => { + const message = await this.courseChatRepository.getMessageById(messageId); + if (!message) return []; + + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + const { courseName } = await this.courseService.getCourseEmailData(courseId); + const authorName = `${message.userFirstName} ${message.userLastName}`; + const chatUrl = `${origin}/course/${courseId}?tab=Discussion`; + + const results: AutomationResolvedRecipient[] = []; + + for (const mentionedUserId of mentionedUserIds) { + if (mentionedUserId === currentUser.userId) continue; + const user = await this.getUserSafe(mentionedUserId); + if (!user) continue; + + results.push({ + userId: mentionedUserId, + userEmail: user.email, + tenantId, + variables: { + userFirstName: user.firstName, + userLastName: user.lastName, + authorFullName: authorName, + courseName: courseName ?? "", + messageContent: message.content ?? "", + chatUrl, + }, + }); + } + + return results; + }); + } + + private async resolveCourseDueDateReminder( + event: CourseDueDateReminderEmailEvent, + ): Promise { + const { recipients } = event.courseDueDateReminderEmailData; + + const results: AutomationResolvedRecipient[] = []; + + for (const r of recipients) { + const user = await this.getUserSafe(r.studentId); + + results.push({ + userId: r.studentId, + userEmail: r.studentEmail, + tenantId: r.tenantId, + variables: { + userFirstName: user?.firstName ?? "", + userLastName: user?.lastName ?? "", + userEmail: r.studentEmail, + courseName: r.courseName, + dueDate: r.dueDate, + daysLeft: String(r.daysBeforeDueDate), + courseUrl: `${r.tenantHost.replace(/\/$/, "")}/course/${r.courseId}`, + }, + }); + } + + return results; + } + + private async getUserSafe(userId: UUIDType) { + try { + return await this.userService.getUserById(userId, this.dbAdmin); + } catch { + this.logger.warn(`Could not resolve user ${userId} for automation data`); + return null; + } + } + + private async getUserTenantId(userId: UUIDType): Promise { + const [user] = await this.dbAdmin + .select({ tenantId: users.tenantId }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) throw new Error(`Cannot resolve tenant for user ${userId}`); + return user.tenantId; + } + + private async getCourseTenantId(courseId: UUIDType): Promise { + const [course] = await this.dbAdmin + .select({ tenantId: courses.tenantId }) + .from(courses) + .where(eq(courses.id, courseId)) + .limit(1); + + if (!course) throw new Error(`Cannot resolve tenant for course ${courseId}`); + return course.tenantId; + } + + private async getLastCompletedStudentForCourse(courseId: UUIDType) { + const [student] = await this.dbAdmin + .select({ + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(studentCourses) + .innerJoin(users, eq(users.id, studentCourses.studentId)) + .where(and(eq(studentCourses.courseId, courseId), isNotNull(studentCourses.completedAt))) + .orderBy(desc(studentCourses.completedAt)) + .limit(1); + + return student ?? null; + } +} diff --git a/apps/api/src/automations/automation-runner/automation-data-resolver.types.ts b/apps/api/src/automations/automation-runner/automation-data-resolver.types.ts new file mode 100644 index 0000000000..0e572a3d39 --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-data-resolver.types.ts @@ -0,0 +1,8 @@ +import type { UUIDType } from "src/common"; + +export type AutomationResolvedRecipient = { + userId?: UUIDType; + userEmail: string; + tenantId: UUIDType; + variables: Record; +}; diff --git a/apps/api/src/automations/automation-runner/automation-runner.service.ts b/apps/api/src/automations/automation-runner/automation-runner.service.ts new file mode 100644 index 0000000000..fea3604021 --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-runner.service.ts @@ -0,0 +1,339 @@ +import { BadRequestException, Inject, Injectable, Logger } from "@nestjs/common"; +import Handlebars from "handlebars"; + +import { AutomationStepType } from "src/announcements/types/automations.types"; +import { type UUIDType, DatabasePg } from "src/common"; +import { EmailService } from "src/common/emails/emails.service"; +import { SettingsService } from "src/settings/settings.service"; +import { DB_ADMIN } from "src/storage/db/db.providers"; +import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service"; + +import { AutomationStepsService } from "../automations-steps/automations-steps.service"; +import { AutomationsService } from "../automations.service"; +import { AutomationLogsRepository } from "../repositories/automation-logs/automation-logs"; + +import { AutomationDataResolverService } from "./automation-data-resolver.service"; +import { + AutomationSystemTemplateRendererService, + isSystemTemplateId, +} from "./automation-system-template-renderer.service"; +import { AutomationTemplateService } from "./automation-template.service"; + +import type { AutomationResolvedRecipient } from "./automation-data-resolver.types"; +import type { AutomationEventTypes } from "../handlers/automations-handler"; +import type { SupportedLanguages } from "@repo/shared"; +import type { + AutomationStep, + SendEmailActionContext, + TypeContext, +} from "src/announcements/types/automations-source.types"; + +const USER_DEFAULT_LANGUAGE = "user_default"; + +@Injectable() +export class AutomationRunnerService { + private readonly logger = new Logger(AutomationRunnerService.name); + + constructor( + private readonly automationStepsService: AutomationStepsService, + private readonly dataResolver: AutomationDataResolverService, + private readonly templateService: AutomationTemplateService, + private readonly systemTemplateRenderer: AutomationSystemTemplateRendererService, + private readonly emailService: EmailService, + private readonly settingsService: SettingsService, + private readonly automationService: AutomationsService, + private readonly automationLogsRepository: AutomationLogsRepository, + private readonly tenantRunner: TenantDbRunnerService, + + @Inject(DB_ADMIN) + private readonly dbAdmin: DatabasePg, + ) {} + + async startAutomation(automationId: UUIDType, event: AutomationEventTypes) { + const automationSteps = await this.automationStepsService.getAllAutomationSteps(automationId); + + const resolvedRecipients = await this.dataResolver.resolve(event); + + const automationToRun = await this.automationService.getAutomationById(automationId); + + const emails = resolvedRecipients.map((recipient) => recipient.userEmail); + + const tenantId = automationSteps[0].tenantId; + + console.log(automationId); + + if (resolvedRecipients.length === 0) { + this.logger.warn( + `No recipients resolved for automation ${automationId} (event: ${event.constructor.name})`, + ); + return; + } + + try { + await this.executeAutomationSteps(automationSteps, resolvedRecipients); + } catch (error: any) { + await this.tenantRunner.runWithTenant(tenantId, async () => { + await this.automationLogsRepository.create({ + status: "failed", + automationId: automationToRun.id, + automationName: automationToRun.name.en ?? "Unknown", + eventName: event.constructor.name, + emailAddresses: emails, + errorName: error.name, + }); + }); + + return; + } + await this.tenantRunner.runWithTenant(tenantId, async () => { + await this.automationLogsRepository.create({ + status: "success", + automationId: automationToRun.id, + automationName: automationToRun.name.en ?? "Unknown", + eventName: event.constructor.name, + emailAddresses: emails, + }); + }); + } + + private async executeAutomationSteps( + steps: AutomationStep[], + recipients: AutomationResolvedRecipient[], + ) { + const root = steps.find((step) => step.parentId === null); + + if (!root) { + throw new BadRequestException("automationSteps.toast.stepTreeBuildFailed"); + } + + await this.executeStep(root, steps, recipients); + } + + private async executeStep( + step: AutomationStep, + steps: AutomationStep[], + recipients: AutomationResolvedRecipient[], + ) { + await this.executeSingleStep(step, recipients); + + const children = steps.filter((child) => child.parentId === step.id); + + for (const child of children) { + await this.executeStep(child, steps, recipients); + } + } + + private async executeSingleStep(step: AutomationStep, recipients: AutomationResolvedRecipient[]) { + switch (step.type) { + case AutomationStepType.Trigger: + this.logger.debug(`Trigger: ${JSON.stringify(step.typeContext)}`); + break; + + case AutomationStepType.Condition: + this.logger.debug(`Condition: ${JSON.stringify(step.typeContext)}`); + break; + + case AutomationStepType.Action: + console.log("action worked"); + await this.handleAction(step.typeContext as TypeContext, recipients); + break; + + default: + throw new BadRequestException(`Unknown step type ${step.type}`); + } + } + + private async handleAction( + actionContext: TypeContext, + recipients: AutomationResolvedRecipient[], + ) { + switch (actionContext.name) { + case "send_email": + await this.handleSendEmailAction(actionContext as SendEmailActionContext, recipients); + break; + default: + this.logger.warn(`Unknown action: ${actionContext.name}`); + } + } + + private async handleSendEmailAction( + actionContext: SendEmailActionContext, + recipients: AutomationResolvedRecipient[], + ) { + const config = (actionContext as unknown as { config?: Record }).config; + const templateId = actionContext.templateId ?? (config?.emailTemplate as string | undefined); + const language = actionContext.language ?? (config?.language as string | undefined); + const variableMapping = + actionContext.variableMapping ?? + (config?.placeholderValues as Record | undefined) ?? + {}; + + if (!templateId) { + this.logger.error("Email template not found: templateId is missing from action context"); + return; + } + + const isUserDefault = language === USER_DEFAULT_LANGUAGE; + + if (isSystemTemplateId(templateId)) { + await this.handleSystemTemplateEmail(templateId, recipients, isUserDefault, language); + } else { + await this.handleCustomTemplateEmail( + templateId, + recipients, + isUserDefault, + language, + variableMapping, + ); + } + } + + private async handleSystemTemplateEmail( + templateId: string, + recipients: AutomationResolvedRecipient[], + isUserDefault: boolean, + languageOverride?: string, + ) { + for (const recipient of recipients) { + const recipientLanguage = isUserDefault + ? await this.resolveRecipientLanguage(recipient.userId) + : (languageOverride as SupportedLanguages | undefined); + + const rendered = await this.systemTemplateRenderer.render( + templateId, + recipient.variables, + recipient.tenantId, + recipient.userId, + recipientLanguage, + ); + + if (!rendered) { + this.logger.error(`System template render failed: ${templateId}`); + continue; + } + + await this.emailService.sendEmailWithLogo( + { + to: recipient.userEmail, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + }, + { tenantId: recipient.tenantId }, + ); + + this.logger.debug( + `[Automation] Sent system email to ${recipient.userEmail}: subject="${rendered.subject}"`, + ); + } + } + + private async handleCustomTemplateEmail( + templateId: string, + recipients: AutomationResolvedRecipient[], + isUserDefault: boolean, + languageOverride?: string, + variableMapping: Record = {}, + ) { + if (!isUserDefault) { + const template = await this.templateService.getTemplate( + templateId, + languageOverride as SupportedLanguages | undefined, + ); + if (!template) { + this.logger.error(`Email template not found: ${templateId}`); + return; + } + + for (const recipient of recipients) { + const renderedSubject = this.replacePlaceholders( + template.subject, + variableMapping, + recipient.variables, + ); + const renderedBody = this.replacePlaceholders( + template.body, + variableMapping, + recipient.variables, + ); + await this.emailService.sendEmailWithLogo( + { + to: recipient.userEmail, + subject: renderedSubject, + text: renderedSubject, + html: renderedBody, + }, + { tenantId: recipient.tenantId }, + ); + + this.logger.debug( + `[Automation] Sent custom email to ${recipient.userEmail}: subject="${renderedSubject}"`, + ); + } + } else { + for (const recipient of recipients) { + const recipientLanguage = await this.resolveRecipientLanguage(recipient.userId); + + const template = await this.templateService.getTemplate(templateId, recipientLanguage); + + if (!template) { + this.logger.error(`Email template not found: ${templateId} (lang: ${recipientLanguage})`); + continue; + } + + const renderedSubject = this.replacePlaceholders( + template.subject, + variableMapping, + recipient.variables, + ); + const renderedBody = this.replacePlaceholders( + template.body, + variableMapping, + recipient.variables, + ); + + await this.emailService.sendEmailWithLogo( + { + to: recipient.userEmail, + subject: renderedSubject, + text: renderedSubject, + html: renderedBody, + }, + { tenantId: recipient.tenantId }, + ); + + this.logger.debug( + `[Automation] Sent custom email to ${recipient.userEmail} (lang: ${recipientLanguage}): subject="${renderedSubject}"`, + ); + } + } + } + + private async resolveRecipientLanguage(userId?: UUIDType): Promise { + if (!userId) return "en"; + + try { + const userSettings = await this.settingsService.getUserSettings(userId, this.dbAdmin); + return userSettings.language ?? "en"; + } catch { + this.logger.debug(`Could not resolve language for user ${userId}, defaulting to "en"`); + return "en"; + } + } + + private replacePlaceholders( + templateContent: string, + variableMapping: Record, + resolvedVariables: Record, + ): string { + const context: Record = {}; + + for (const [placeholder, variableKey] of Object.entries(variableMapping)) { + const cleanKey = placeholder.replace(/^\{\{\s*|\s*\}\}$/g, ""); + context[cleanKey] = resolvedVariables[variableKey] ?? ""; + } + + const template = Handlebars.compile(templateContent, { noEscape: true }); + return template(context); + } +} diff --git a/apps/api/src/automations/automation-runner/automation-simulation.service.spec.ts b/apps/api/src/automations/automation-runner/automation-simulation.service.spec.ts new file mode 100644 index 0000000000..257fcab7de --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-simulation.service.spec.ts @@ -0,0 +1,231 @@ +import { Test } from "@nestjs/testing"; + +import { AutomationSimulationService } from "./automation-simulation.service"; +import { AutomationSystemTemplatePreviewService } from "./automation-system-template-preview.service"; +import { AutomationTemplateService } from "./automation-template.service"; + +import type { RunSimulationBody, SimulationNodeDto } from "./automation-simulation.types"; +import type { TestingModule } from "@nestjs/testing"; + +describe("AutomationSimulationService", () => { + let service: AutomationSimulationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationSimulationService, + { + provide: AutomationSystemTemplatePreviewService, + useValue: { + renderPreview: jest.fn().mockResolvedValue({ + subject: "Test Subject", + html: "

Test

", + }), + }, + }, + { + provide: AutomationTemplateService, + useValue: { + getTemplate: jest.fn().mockResolvedValue(null), + }, + }, + ], + }).compile(); + + service = module.get(AutomationSimulationService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("runSimulation — system template validation", () => { + const buildNodes = (overrides?: { + triggerType?: string; + emailTemplate?: string; + language?: string; + placeholderValues?: Record; + }): SimulationNodeDto[] => [ + { + id: "trigger-1", + kind: "trigger", + type: (overrides?.triggerType ?? "user_invited") as any, + label: "Trigger", + parentId: null, + children: ["action-1"], + config: {}, + }, + { + id: "action-1", + kind: "action", + type: "send_email" as any, + label: "Send email", + parentId: "trigger-1", + children: [], + config: { + emailTemplate: overrides?.emailTemplate ?? "user_invite", + language: overrides?.language ?? "user_default", + placeholderValues: overrides?.placeholderValues ?? {}, + }, + }, + ]; + + it("does NOT require placeholder mapping for system templates", async () => { + const body: RunSimulationBody = { + nodes: buildNodes({ + emailTemplate: "user_invite", + placeholderValues: {}, // No mappings — should pass for system templates + }), + language: "en", + }; + + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("success"); + + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.status).toBe("valid"); + expect(actionResult?.errors).toHaveLength(0); + }); + + it("does NOT require placeholder mapping for any system template (welcome)", async () => { + const body: RunSimulationBody = { + nodes: buildNodes({ + triggerType: "user_welcome", + emailTemplate: "welcome", + placeholderValues: {}, + }), + language: "pl", + }; + + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("success"); + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.status).toBe("valid"); + }); + + it("does NOT require placeholder mapping for certificate_expiration_warning", async () => { + const body: RunSimulationBody = { + nodes: buildNodes({ + triggerType: "certificate_expiration_warning", + emailTemplate: "certificate_expiration_warning", + placeholderValues: {}, + }), + language: "en", + }; + + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("success"); + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.status).toBe("valid"); + }); + + it("reports errors for custom template with unmapped placeholders", async () => { + const body: RunSimulationBody = { + nodes: buildNodes({ + emailTemplate: "custom-template-uuid", + placeholderValues: { + recipientName: "", // empty = unmapped + courseTitle: "courseName", // mapped + }, + }), + language: "en", + }; + + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("failed"); + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.status).toBe("invalid"); + expect(actionResult?.errors.some((e) => e.field === "placeholderValues.recipientName")).toBe( + true, + ); + }); + + it("passes validation for custom template with all placeholders mapped", async () => { + const body: RunSimulationBody = { + nodes: buildNodes({ + emailTemplate: "custom-template-uuid", + placeholderValues: { + recipientName: "userFirstName", + courseTitle: "courseName", + }, + }), + language: "en", + }; + + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("success"); + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.status).toBe("valid"); + expect(actionResult?.errors).toHaveLength(0); + }); + + it("fails when emailTemplate is missing", async () => { + const nodes: SimulationNodeDto[] = [ + { + id: "trigger-1", + kind: "trigger", + type: "user_invited" as any, + label: "Trigger", + parentId: null, + children: ["action-1"], + config: {}, + }, + { + id: "action-1", + kind: "action", + type: "send_email" as any, + label: "Send email", + parentId: "trigger-1", + children: [], + config: { + language: "user_default", + }, + }, + ]; + + const body: RunSimulationBody = { nodes, language: "en" }; + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("failed"); + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.errors.some((e) => e.field === "emailTemplate")).toBe(true); + }); + + it("fails when language is missing", async () => { + const nodes: SimulationNodeDto[] = [ + { + id: "trigger-1", + kind: "trigger", + type: "user_invited" as any, + label: "Trigger", + parentId: null, + children: ["action-1"], + config: {}, + }, + { + id: "action-1", + kind: "action", + type: "send_email" as any, + label: "Send email", + parentId: "trigger-1", + children: [], + config: { + emailTemplate: "user_invite", + }, + }, + ]; + + const body: RunSimulationBody = { nodes, language: "en" }; + const result = await service.runSimulation(body); + + expect(result.overallStatus).toBe("failed"); + const actionResult = result.nodeResults.find((n) => n.kind === "action"); + expect(actionResult?.errors.some((e) => e.field === "language")).toBe(true); + }); + }); +}); diff --git a/apps/api/src/automations/automation-runner/automation-simulation.service.ts b/apps/api/src/automations/automation-runner/automation-simulation.service.ts new file mode 100644 index 0000000000..9c96802d01 --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-simulation.service.ts @@ -0,0 +1,465 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { getStepDefinition, type SupportedLanguages, type TriggerType } from "@repo/shared"; + +import { CORS_ORIGIN } from "src/auth/consts"; + +import { AutomationSystemTemplatePreviewService } from "./automation-system-template-preview.service"; +import { AutomationTemplateService } from "./automation-template.service"; + +import type { + EmailPreview, + EventDataField, + NodeValidationResult, + PlaceholderMappingEntry, + RunSimulationBody, + SimulationNodeDto, + SimulationResult, + ValidationError, +} from "./automation-simulation.types"; + +const SYSTEM_TEMPLATE_PLACEHOLDERS: Record = { + user_invite: ["invitedByUserName", "createPasswordLink"], + welcome: ["coursesLink"], + user_first_login: ["name", "coursesUrl"], + user_assigned_to_course: ["courseName", "courseLink", "formatedCourseDueDate"], + user_short_inactivity: ["courseName", "courseLink"], + user_long_inactivity: ["courseName", "courseLink"], + user_finished_chapter: ["chapterName", "courseName", "courseLink"], + user_finished_course: ["courseName", "buttonLink", "hasCertificate"], + create_password_reminder: ["createPasswordLink"], + certificate_expiration_warning: ["courseName", "courseLink", "expiresAt"], + certificate_expired: ["courseName", "courseLink"], + announcement: ["title", "content", "buttonLink"], + course_due_date_reminder: ["courseName", "courseLink", "dueDate", "daysBeforeDueDate"], + new_user: ["userName", "profileLink"], + finished_course: ["userName", "courseName", "progressLink"], +}; + +@Injectable() +export class AutomationSimulationService { + private readonly logger = new Logger(AutomationSimulationService.name); + + constructor( + private readonly systemTemplatePreviewService: AutomationSystemTemplatePreviewService, + private readonly templateService: AutomationTemplateService, + ) {} + + async runSimulation(body: RunSimulationBody): Promise { + const { nodes, language } = body; + + const { nodeResults, overallStatus, eventData, placeholderMappings, sampleValues } = + this.validateNodes(nodes, language); + + const emailPreviews: EmailPreview[] = []; + + if (overallStatus === "success") { + const actionNodes = nodes.filter((n) => n.kind === "action"); + + for (const action of actionNodes) { + const preview = await this.buildEmailPreview(action, sampleValues, language); + emailPreviews.push(preview); + } + } + + return { + overallStatus, + nodeResults, + eventData, + placeholderMappings, + emailPreviews, + }; + } + + private validateNodes( + nodes: SimulationNodeDto[], + language: string, + ): { + nodeResults: NodeValidationResult[]; + overallStatus: "success" | "failed"; + eventData: EventDataField[]; + placeholderMappings: Record; + sampleValues: Record; + } { + const sampleValues = this.getSampleValues(language); + const triggerNode = nodes.find((n) => n.kind === "trigger"); + const actionNodes = nodes.filter((n) => n.kind === "action"); + + const nodeResults: NodeValidationResult[] = []; + + if (triggerNode) { + const triggerErrors: ValidationError[] = []; + + if (!triggerNode.type) { + triggerErrors.push({ + nodeId: triggerNode.id, + nodeName: triggerNode.label || "Trigger", + field: "type", + description: this.t("selectTriggerType", language), + }); + } + + nodeResults.push({ + nodeId: triggerNode.id, + nodeName: triggerNode.label || "Trigger", + kind: "trigger", + status: triggerErrors.length > 0 ? "invalid" : "valid", + errors: triggerErrors, + }); + } else { + nodeResults.push({ + nodeId: "missing-trigger", + nodeName: "Trigger", + kind: "trigger", + status: "invalid", + errors: [ + { + nodeId: "missing-trigger", + nodeName: "Trigger", + field: "trigger", + description: this.t("addTriggerNode", language), + }, + ], + }); + } + + for (const action of actionNodes) { + const actionErrors: ValidationError[] = []; + + if (!action.config.emailTemplate) { + actionErrors.push({ + nodeId: action.id, + nodeName: action.label || "Akcja", + field: "emailTemplate", + description: this.t("selectEmailTemplate", language), + }); + } + + if (!action.config.language) { + actionErrors.push({ + nodeId: action.id, + nodeName: action.label || "Akcja", + field: "language", + description: this.t("selectLanguage", language), + }); + } + + const selectedTemplate = action.config.emailTemplate as string | undefined; + if (selectedTemplate && !(selectedTemplate in SYSTEM_TEMPLATE_PLACEHOLDERS)) { + const placeholderValues = (action.config.placeholderValues as Record) ?? {}; + const unmappedPlaceholders = Object.keys(placeholderValues).filter( + (p) => !placeholderValues[p], + ); + + for (const placeholder of unmappedPlaceholders) { + actionErrors.push({ + nodeId: action.id, + nodeName: action.label || "Akcja", + field: `placeholderValues.${placeholder}`, + description: this.t("unmappedPlaceholder", language, { placeholder }), + }); + } + } + + nodeResults.push({ + nodeId: action.id, + nodeName: action.label || "Akcja", + kind: "action", + status: actionErrors.length > 0 ? "invalid" : "valid", + errors: actionErrors, + }); + } + + if (actionNodes.length === 0) { + nodeResults.push({ + nodeId: "missing-action", + nodeName: "Akcja", + kind: "action", + status: "invalid", + errors: [ + { + nodeId: "missing-action", + nodeName: "Akcja", + field: "action", + description: this.t("addActionNode", language), + }, + ], + }); + } + + const overallStatus = nodeResults.every((nr) => nr.status === "valid") ? "success" : "failed"; + + const triggerDef = triggerNode ? getStepDefinition(triggerNode.type as TriggerType) : undefined; + const eventData: EventDataField[] = (triggerDef?.providedVariables ?? []).map((v) => ({ + key: v.key, + label: v.labelKey, + dataType: v.dataType ?? "string", + })); + + const placeholderMappings: Record = {}; + for (const action of actionNodes) { + const values = (action.config.placeholderValues as Record) ?? {}; + const entries: PlaceholderMappingEntry[] = Object.entries(values).map( + ([placeholder, variable]) => ({ + placeholder, + mappedVariable: variable || null, + sampleValue: variable ? (sampleValues[variable] ?? null) : null, + }), + ); + if (entries.length > 0) { + placeholderMappings[action.id] = entries; + } + } + + return { nodeResults, overallStatus, eventData, placeholderMappings, sampleValues }; + } + + private async buildEmailPreview( + action: SimulationNodeDto, + sampleValues: Record, + language: string, + ): Promise { + const selectedTemplate = action.config.emailTemplate as string | undefined; + const selectedLanguage = (action.config.language as string) ?? language; + const previewLanguage = selectedLanguage === "user_default" ? "en" : selectedLanguage; + const placeholderValues = (action.config.placeholderValues as Record) ?? {}; + + const isCustomTemplate = selectedTemplate + ? !(selectedTemplate in SYSTEM_TEMPLATE_PLACEHOLDERS) && selectedTemplate !== "default_email" + : false; + const recipientEmail = isCustomTemplate + ? (sampleValues["user_email"] ?? sampleValues["userEmail"] ?? "jan.kowalski@example.com") + : (sampleValues["userEmail"] ?? "jan.kowalski@example.com"); + + let subject = `Preview: ${selectedTemplate ?? "default_email"}`; + let htmlBody = this.buildFallbackHtml(selectedTemplate ?? "default_email"); + + try { + if (isCustomTemplate && selectedTemplate) { + const preview = await this.renderCustomTemplatePreview( + selectedTemplate, + previewLanguage, + placeholderValues, + sampleValues, + ); + if (preview) { + subject = preview.subject; + htmlBody = preview.html; + } + } else { + const preview = await this.renderSystemTemplatePreview( + selectedTemplate ?? "default_email", + previewLanguage as SupportedLanguages, + ); + if (preview) { + subject = preview.subject; + htmlBody = preview.html; + } + } + } catch (error) { + this.logger.warn(`Failed to render email preview for node ${action.id}`, error); + } + + return { + nodeId: action.id, + nodeName: action.label || "Akcja", + subject, + senderAddress: "noreply@mentingo.com", + recipientAddress: recipientEmail, + htmlBody, + }; + } + + private async renderSystemTemplatePreview( + templateId: string, + language: SupportedLanguages, + ): Promise<{ subject: string; html: string } | null> { + return this.systemTemplatePreviewService.renderPreview(templateId, language); + } + + private async renderCustomTemplatePreview( + templateId: string, + language: string, + placeholderValues: Record, + sampleValues: Record, + ): Promise<{ subject: string; html: string } | null> { + const template = await this.templateService.getTemplate( + templateId, + language as SupportedLanguages, + ); + + if (!template) return null; + + let subject = template.subject; + let html = template.body; + + for (const [placeholder, variableKey] of Object.entries(placeholderValues)) { + const sampleValue = sampleValues[variableKey] ?? variableKey; + const regex = new RegExp(`\\{\\{\\s*${this.escapeRegex(placeholder)}\\s*\\}\\}`, "g"); + subject = subject.replace(regex, sampleValue); + html = html.replace(regex, sampleValue); + } + + return { subject, html: this.replaceCidReferences(html) }; + } + + private getSampleValues(language: string): Record { + const isPolish = language === "pl"; + + const urls: Record = { + course_url: "https://app.mentingo.com/courses/abc123", + invite_link: "https://app.mentingo.com/invite/xyz", + reset_password_link: "https://app.mentingo.com/reset/token123", + platform_url: "https://app.mentingo.com", + certificate_url: "https://app.mentingo.com/certificates/cert-001", + announcement_url: "https://app.mentingo.com/announcements/1", + chat_url: "https://app.mentingo.com/chat/msg-001", + courseUrl: "https://app.mentingo.com/courses/abc123", + inviteLink: "https://app.mentingo.com/invite/xyz", + resetPasswordLink: "https://app.mentingo.com/reset/token123", + platformUrl: "https://app.mentingo.com", + certificateUrl: "https://app.mentingo.com/certificates/cert-001", + announcementUrl: "https://app.mentingo.com/announcements/1", + chatUrl: "https://app.mentingo.com/chat/msg-001", + profileLink: "https://app.mentingo.com/profile/sample-user", + progressLink: "https://app.mentingo.com/progress/abc123", + }; + + const dates: Record = { + due_date: "2025-08-15", + login_date: "2025-07-22", + finished_at: "2025-07-20", + expiration_date: "2025-12-31", + registration_date: "2025-06-01", + created_at: "2025-06-01", + dueDate: "2025-08-15", + loginDate: "2025-07-22", + finishedAt: "2025-07-20", + expirationDate: "2025-12-31", + registrationDate: "2025-06-01", + archivedAt: "2025-07-01", + }; + + const textValues: Record = isPolish + ? { + user_first_name: "Jan", + user_last_name: "Kowalski", + user_email: "jan.kowalski@example.com", + course_name: "Szkolenie BHP 2025", + chapter_name: "Rozdział 1: Wprowadzenie", + certificate_name: "Certyfikat BHP", + announcement_title: "Nowe szkolenie dostępne", + announcement_content: "Zapraszamy na nowe szkolenie.", + author_full_name: "Anna Nowak", + message_content: "Cześć, sprawdź ten materiał!", + days_left: "7", + days_inactive: "14", + userFirstName: "Jan", + userLastName: "Kowalski", + userEmail: "jan.kowalski@example.com", + courseName: "Szkolenie BHP 2025", + chapterName: "Rozdział 1: Wprowadzenie", + certificateName: "Certyfikat BHP", + announcementTitle: "Nowe szkolenie dostępne", + announcementContent: "Zapraszamy na nowe szkolenie.", + authorFullName: "Anna Nowak", + messageContent: "Cześć, sprawdź ten materiał!", + daysLeft: "7", + daysInactive: "14", + userName: "Jan Kowalski", + invitedByUserName: "Anna Nowak", + hasCertificate: "true", + archiveReason: "expired", + } + : { + user_first_name: "John", + user_last_name: "Smith", + user_email: "john.smith@example.com", + course_name: "Health & Safety Training 2025", + chapter_name: "Chapter 1: Introduction", + certificate_name: "Safety Certificate", + announcement_title: "New training available", + announcement_content: "We invite you to a new training course.", + author_full_name: "Jane Doe", + message_content: "Hi, check out this material!", + days_left: "7", + days_inactive: "14", + userFirstName: "John", + userLastName: "Smith", + userEmail: "john.smith@example.com", + courseName: "Health & Safety Training 2025", + chapterName: "Chapter 1: Introduction", + certificateName: "Safety Certificate", + announcementTitle: "New training available", + announcementContent: "We invite you to a new training course.", + authorFullName: "Jane Doe", + messageContent: "Hi, check out this material!", + daysLeft: "7", + daysInactive: "14", + userName: "John Smith", + invitedByUserName: "Jane Doe", + hasCertificate: "true", + archiveReason: "expired", + }; + + return { ...urls, ...dates, ...textValues }; + } + + private t(key: string, language: string, params?: Record): string { + const translations: Record> = { + selectTriggerType: { + pl: "Wybierz typ triggera", + en: "Select trigger type", + }, + addTriggerNode: { + pl: "Dodaj węzeł triggera", + en: "Add a trigger node", + }, + selectEmailTemplate: { + pl: "Wybierz szablon e-mail", + en: "Select email template", + }, + selectLanguage: { + pl: "Wybierz język", + en: "Select language", + }, + unmappedPlaceholder: { + pl: `Niezmapowany placeholder: ${params?.placeholder ?? ""}`, + en: `Unmapped placeholder: ${params?.placeholder ?? ""}`, + }, + addActionNode: { + pl: "Dodaj węzeł akcji", + en: "Add an action node", + }, + }; + + const lang = language === "pl" ? "pl" : "en"; + return translations[key]?.[lang] ?? translations[key]?.["en"] ?? key; + } + + private buildFallbackHtml(templateId: string): string { + return `
+
+

+ Podgląd szablonu systemowego: ${templateId} +

+
+

+ Treść e-mail zostanie wygenerowana na podstawie wybranego szablonu. +

+

+ Mentingo Platform +

+
`; + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + private replaceCidReferences(html: string): string { + const logoUrl = `${CORS_ORIGIN}/app/assets/svgs/app-logo.svg`; + const borderCircleUrl = `${CORS_ORIGIN}/app/assets/svgs/app-email-border-circle.svg`; + + return html.replace(/cid:logo/g, logoUrl).replace(/cid:border-circle/g, borderCircleUrl); + } +} diff --git a/apps/api/src/automations/automation-runner/automation-simulation.types.ts b/apps/api/src/automations/automation-runner/automation-simulation.types.ts new file mode 100644 index 0000000000..a7284adcac --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-simulation.types.ts @@ -0,0 +1,60 @@ +import type { ActionType, NodeKind, TriggerType } from "@repo/shared"; + +export interface SimulationNodeDto { + id: string; + kind: NodeKind; + type: TriggerType | ActionType; + label: string; + parentId: string | null; + children: string[]; + config: Record; +} + +export interface RunSimulationBody { + nodes: SimulationNodeDto[]; + language: string; +} + +export interface ValidationError { + nodeId: string; + nodeName: string; + field: string; + description: string; +} + +export interface EventDataField { + key: string; + label: string; + dataType: "string" | "number" | "date" | "url"; +} + +export interface PlaceholderMappingEntry { + placeholder: string; + mappedVariable: string | null; + sampleValue: string | null; +} + +export interface EmailPreview { + nodeId: string; + nodeName: string; + subject: string; + senderAddress: string; + htmlBody: string; + recipientAddress: string; +} + +export interface NodeValidationResult { + nodeId: string; + nodeName: string; + kind: "trigger" | "action"; + status: "valid" | "invalid"; + errors: ValidationError[]; +} + +export interface SimulationResult { + overallStatus: "success" | "failed"; + nodeResults: NodeValidationResult[]; + eventData: EventDataField[]; + placeholderMappings: Record; + emailPreviews: EmailPreview[]; +} diff --git a/apps/api/src/automations/automation-runner/automation-system-template-preview.service.ts b/apps/api/src/automations/automation-runner/automation-system-template-preview.service.ts new file mode 100644 index 0000000000..d3db36dd56 --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-system-template-preview.service.ts @@ -0,0 +1,457 @@ +import { Injectable } from "@nestjs/common"; +import { + AnnouncementEmail, + CertificateExpirationWarningEmail, + CertificateExpiredEmail, + CourseDueDateReminderEmail, + CreatePasswordReminderEmail, + FinishedCourseEmail, + NewUserEmail, + UserAssignedToCourseEmail, + UserFinishedChapterEmail, + UserFinishedCourseEmail, + UserFirstLoginEmail, + UserInviteEmail, + UserLongInactivityEmail, + UserShortInactivityEmail, + WelcomeEmail, +} from "@repo/email-templates"; +import { SUPPORTED_LANGUAGES } from "@repo/shared"; + +import { CORS_ORIGIN } from "src/auth/consts"; + +import type { SupportedLanguages } from "@repo/shared"; + +interface PreviewSampleData { + userName: string; + userFullName: string; + invitedByName: string; + courseName: string; + chapterName: string; + announcementTitle: string; + announcementContent: string; + dueDate: string; + expiresAt: string; + subjects: { + userInvite: string; + welcome: string; + userFirstLogin: string; + userAssignedToCourse: string; + userShortInactivity: string; + userLongInactivity: string; + userFinishedChapter: string; + userFinishedCourse: string; + createPasswordReminder: string; + certificateExpirationWarning: string; + certificateExpired: string; + announcement: string; + courseDueDateReminder: string; + newUser: string; + finishedCourse: string; + }; +} + +const SAMPLE_DATA: Record = { + pl: { + userName: "Jan", + userFullName: "Jan Kowalski", + invitedByName: "Anna Nowak", + courseName: "Szkolenie BHP 2025", + chapterName: "Rozdział 1: Wprowadzenie", + announcementTitle: "Nowe szkolenie dostępne", + announcementContent: "Zapraszamy na nowe szkolenie z zakresu bezpieczeństwa pracy.", + dueDate: "15.08.2025", + expiresAt: "31.12.2025", + subjects: { + userInvite: "Zaproszenie do platformy", + welcome: "Witaj na platformie", + userFirstLogin: "Pierwsze logowanie", + userAssignedToCourse: "Przypisanie do kursu", + userShortInactivity: "Przypomnienie o kursie", + userLongInactivity: "Dawno Cię nie było", + userFinishedChapter: "Ukończono rozdział", + userFinishedCourse: "Gratulacje! Kurs ukończony", + createPasswordReminder: "Utwórz hasło", + certificateExpirationWarning: "Certyfikat wygasa wkrótce", + certificateExpired: "Certyfikat wygasł", + announcement: "Nowe szkolenie dostępne", + courseDueDateReminder: "Zbliża się termin kursu", + newUser: "Nowy użytkownik zarejestrowany", + finishedCourse: "Użytkownik ukończył kurs", + }, + }, + en: { + userName: "John", + userFullName: "John Smith", + invitedByName: "Jane Doe", + courseName: "Health & Safety Training 2025", + chapterName: "Chapter 1: Introduction", + announcementTitle: "New training available", + announcementContent: "We invite you to a new workplace safety training course.", + dueDate: "08/15/2025", + expiresAt: "12/31/2025", + subjects: { + userInvite: "Platform invitation", + welcome: "Welcome to the platform", + userFirstLogin: "First login", + userAssignedToCourse: "Course assignment", + userShortInactivity: "Course reminder", + userLongInactivity: "We miss you", + userFinishedChapter: "Chapter completed", + userFinishedCourse: "Congratulations! Course completed", + createPasswordReminder: "Create your password", + certificateExpirationWarning: "Certificate expiring soon", + certificateExpired: "Certificate expired", + announcement: "New training available", + courseDueDateReminder: "Course deadline approaching", + newUser: "New user registered", + finishedCourse: "User completed course", + }, + }, + de: { + userName: "Max", + userFullName: "Max Mustermann", + invitedByName: "Erika Musterfrau", + courseName: "Arbeitssicherheit Schulung 2025", + chapterName: "Kapitel 1: Einführung", + announcementTitle: "Neue Schulung verfügbar", + announcementContent: "Wir laden Sie zu einer neuen Schulung zur Arbeitssicherheit ein.", + dueDate: "15.08.2025", + expiresAt: "31.12.2025", + subjects: { + userInvite: "Plattform-Einladung", + welcome: "Willkommen auf der Plattform", + userFirstLogin: "Erste Anmeldung", + userAssignedToCourse: "Kurszuweisung", + userShortInactivity: "Kurs-Erinnerung", + userLongInactivity: "Wir vermissen Sie", + userFinishedChapter: "Kapitel abgeschlossen", + userFinishedCourse: "Herzlichen Glückwunsch! Kurs abgeschlossen", + createPasswordReminder: "Passwort erstellen", + certificateExpirationWarning: "Zertifikat läuft bald ab", + certificateExpired: "Zertifikat abgelaufen", + announcement: "Neue Schulung verfügbar", + courseDueDateReminder: "Kursfrist nähert sich", + newUser: "Neuer Benutzer registriert", + finishedCourse: "Benutzer hat Kurs abgeschlossen", + }, + }, + lt: { + userName: "Jonas", + userFullName: "Jonas Jonaitis", + invitedByName: "Ona Onaitė", + courseName: "Darbo saugos mokymai 2025", + chapterName: "1 skyrius: Įvadas", + announcementTitle: "Nauji mokymai prieinami", + announcementContent: "Kviečiame į naujus darbo saugos mokymus.", + dueDate: "2025-08-15", + expiresAt: "2025-12-31", + subjects: { + userInvite: "Kvietimas į platformą", + welcome: "Sveiki atvykę į platformą", + userFirstLogin: "Pirmas prisijungimas", + userAssignedToCourse: "Priskirtas kursas", + userShortInactivity: "Kurso priminimas", + userLongInactivity: "Seniai jūsų nematėme", + userFinishedChapter: "Skyrius baigtas", + userFinishedCourse: "Sveikiname! Kursas baigtas", + createPasswordReminder: "Sukurkite slaptažodį", + certificateExpirationWarning: "Sertifikatas netrukus baigsis", + certificateExpired: "Sertifikatas nebegalioja", + announcement: "Nauji mokymai prieinami", + courseDueDateReminder: "Artėja kurso terminas", + newUser: "Naujas vartotojas užsiregistravo", + finishedCourse: "Vartotojas baigė kursą", + }, + }, + cs: { + userName: "Jan", + userFullName: "Jan Novák", + invitedByName: "Eva Nováková", + courseName: "Školení BOZP 2025", + chapterName: "Kapitola 1: Úvod", + announcementTitle: "Nové školení k dispozici", + announcementContent: "Zveme vás na nové školení bezpečnosti práce.", + dueDate: "15.08.2025", + expiresAt: "31.12.2025", + subjects: { + userInvite: "Pozvánka na platformu", + welcome: "Vítejte na platformě", + userFirstLogin: "První přihlášení", + userAssignedToCourse: "Přiřazení ke kurzu", + userShortInactivity: "Připomínka kurzu", + userLongInactivity: "Dlouho jsme vás neviděli", + userFinishedChapter: "Kapitola dokončena", + userFinishedCourse: "Gratulujeme! Kurz dokončen", + createPasswordReminder: "Vytvořte si heslo", + certificateExpirationWarning: "Certifikát brzy vyprší", + certificateExpired: "Certifikát vypršel", + announcement: "Nové školení k dispozici", + courseDueDateReminder: "Blíží se termín kurzu", + newUser: "Nový uživatel se zaregistroval", + finishedCourse: "Uživatel dokončil kurz", + }, + }, + es: { + userName: "Juan", + userFullName: "Juan García", + invitedByName: "María López", + courseName: "Formación en Seguridad Laboral 2025", + chapterName: "Capítulo 1: Introducción", + announcementTitle: "Nueva formación disponible", + announcementContent: "Le invitamos a una nueva formación sobre seguridad laboral.", + dueDate: "15/08/2025", + expiresAt: "31/12/2025", + subjects: { + userInvite: "Invitación a la plataforma", + welcome: "Bienvenido a la plataforma", + userFirstLogin: "Primer inicio de sesión", + userAssignedToCourse: "Asignación de curso", + userShortInactivity: "Recordatorio de curso", + userLongInactivity: "Te echamos de menos", + userFinishedChapter: "Capítulo completado", + userFinishedCourse: "¡Felicidades! Curso completado", + createPasswordReminder: "Crea tu contraseña", + certificateExpirationWarning: "El certificado expira pronto", + certificateExpired: "Certificado expirado", + announcement: "Nueva formación disponible", + courseDueDateReminder: "Se acerca la fecha límite del curso", + newUser: "Nuevo usuario registrado", + finishedCourse: "El usuario completó el curso", + }, + }, +}; + +@Injectable() +export class AutomationSystemTemplatePreviewService { + async renderPreview( + templateId: string, + language: SupportedLanguages = SUPPORTED_LANGUAGES.PL, + ): Promise<{ subject: string; html: string } | null> { + const raw = await this.renderRawPreview(templateId, language); + if (!raw) return null; + + return { + subject: raw.subject, + html: this.replaceCidReferences(raw.html), + }; + } + + private getSampleData(language: SupportedLanguages): PreviewSampleData { + return SAMPLE_DATA[language] ?? SAMPLE_DATA.en; + } + + private async renderRawPreview( + templateId: string, + language: SupportedLanguages, + ): Promise<{ subject: string; html: string } | null> { + const sample = this.getSampleData(language); + const baseSettings = { + primaryColor: "#2563eb", + companyName: "Mentingo", + language, + }; + + const baseOrigin = "https://app.mentingo.com"; + + switch (templateId) { + case "user_invite": { + const email = new UserInviteEmail({ + invitedByUserName: sample.invitedByName, + createPasswordLink: `${baseOrigin}/auth/create-password?token=sample-token`, + ...baseSettings, + }); + return { + subject: sample.subjects.userInvite, + html: await email.html, + }; + } + + case "welcome": { + const email = new WelcomeEmail({ + coursesLink: `${baseOrigin}/courses`, + ...baseSettings, + }); + return { + subject: sample.subjects.welcome, + html: await email.html, + }; + } + + case "user_first_login": { + const email = new UserFirstLoginEmail({ + name: sample.userName, + coursesUrl: `${baseOrigin}/courses`, + ...baseSettings, + }); + return { + subject: sample.subjects.userFirstLogin, + html: await email.html, + }; + } + + case "user_assigned_to_course": { + const email = new UserAssignedToCourseEmail({ + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + formatedCourseDueDate: sample.dueDate, + ...baseSettings, + }); + return { + subject: sample.subjects.userAssignedToCourse, + html: await email.html, + }; + } + + case "user_short_inactivity": { + const email = new UserShortInactivityEmail({ + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + ...baseSettings, + }); + return { + subject: sample.subjects.userShortInactivity, + html: await email.html, + }; + } + + case "user_long_inactivity": { + const email = new UserLongInactivityEmail({ + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + ...baseSettings, + }); + return { + subject: sample.subjects.userLongInactivity, + html: await email.html, + }; + } + + case "user_finished_chapter": { + const email = new UserFinishedChapterEmail({ + chapterName: sample.chapterName, + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + ...baseSettings, + }); + return { + subject: sample.subjects.userFinishedChapter, + html: await email.html, + }; + } + + case "user_finished_course": { + const email = new UserFinishedCourseEmail({ + courseName: sample.courseName, + buttonLink: `${baseOrigin}/profile/sample-user`, + hasCertificate: true, + ...baseSettings, + }); + return { + subject: sample.subjects.userFinishedCourse, + html: await email.html, + }; + } + + case "create_password_reminder": { + const email = new CreatePasswordReminderEmail({ + createPasswordLink: `${baseOrigin}/auth/create-password?token=sample-token`, + ...baseSettings, + }); + return { + subject: sample.subjects.createPasswordReminder, + html: await email.html, + }; + } + + case "certificate_expiration_warning": { + const email = new CertificateExpirationWarningEmail({ + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + expiresAt: sample.expiresAt, + ...baseSettings, + }); + return { + subject: sample.subjects.certificateExpirationWarning, + html: await email.html, + }; + } + + case "certificate_expired": { + const email = new CertificateExpiredEmail({ + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + reason: "expired", + ...baseSettings, + }); + return { + subject: sample.subjects.certificateExpired, + html: await email.html, + }; + } + + case "announcement": { + const email = new AnnouncementEmail({ + title: sample.announcementTitle, + content: sample.announcementContent, + buttonLink: `${baseOrigin}/announcements/1`, + ...baseSettings, + }); + return { + subject: sample.subjects.announcement, + html: await email.html, + }; + } + + case "course_due_date_reminder": { + const email = new CourseDueDateReminderEmail({ + courseName: sample.courseName, + courseLink: `${baseOrigin}/course/sample-course-id`, + dueDate: sample.dueDate, + daysBeforeDueDate: 7, + ...baseSettings, + }); + return { + subject: sample.subjects.courseDueDateReminder, + html: await email.html, + }; + } + + case "new_user": { + const email = new NewUserEmail({ + userName: sample.userFullName, + profileLink: `${baseOrigin}/admin/users/sample-user`, + ...baseSettings, + }); + return { + subject: sample.subjects.newUser, + html: await email.html, + }; + } + + case "finished_course": { + const email = new FinishedCourseEmail({ + userName: sample.userFullName, + courseName: sample.courseName, + progressLink: `${baseOrigin}/admin/courses/sample-course/progress`, + ...baseSettings, + }); + return { + subject: sample.subjects.finishedCourse, + html: await email.html, + }; + } + + case "default_email": + default: + return null; + } + } + + private replaceCidReferences(html: string): string { + const logoUrl = `${CORS_ORIGIN}/app/assets/svgs/app-logo.svg`; + const borderCircleUrl = `${CORS_ORIGIN}/app/assets/svgs/app-email-border-circle.svg`; + + return html.replace(/cid:logo/g, logoUrl).replace(/cid:border-circle/g, borderCircleUrl); + } +} diff --git a/apps/api/src/automations/automation-runner/automation-system-template-renderer.service.ts b/apps/api/src/automations/automation-runner/automation-system-template-renderer.service.ts new file mode 100644 index 0000000000..d597489b47 --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-system-template-renderer.service.ts @@ -0,0 +1,306 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { + AnnouncementEmail, + CertificateExpirationWarningEmail, + CertificateExpiredEmail, + CourseDueDateReminderEmail, + CreatePasswordReminderEmail, + FinishedCourseEmail, + NewUserEmail, + UserAssignedToCourseEmail, + UserFinishedChapterEmail, + UserFinishedCourseEmail, + UserFirstLoginEmail, + UserInviteEmail, + UserLongInactivityEmail, + UserShortInactivityEmail, + WelcomeEmail, +} from "@repo/email-templates"; + +import { DatabasePg } from "src/common"; +import { EmailService } from "src/common/emails/emails.service"; +import { getEmailSubject } from "src/common/emails/translations"; +import { resolveTenantOrigin } from "src/common/helpers/resolveTenantOrigin"; +import { DB_ADMIN } from "src/storage/db/db.providers"; + +import type { SupportedLanguages } from "@repo/shared"; +import type { UUIDType } from "src/common"; +import type { EmailSubjectKey } from "src/common/emails/translations"; + +export const SYSTEM_TEMPLATE_IDS = new Set([ + "user_invite", + "welcome", + "user_first_login", + "user_assigned_to_course", + "user_short_inactivity", + "user_long_inactivity", + "user_finished_chapter", + "user_finished_course", + "create_password_reminder", + "certificate_expiration_warning", + "certificate_expired", + "announcement", + "course_due_date_reminder", + "new_user", + "finished_course", +]); + +export function isSystemTemplateId(templateId: string): boolean { + return SYSTEM_TEMPLATE_IDS.has(templateId); +} + +export interface RenderedSystemEmail { + subject: string; + text: string; + html: string; +} + +@Injectable() +export class AutomationSystemTemplateRendererService { + private readonly logger = new Logger(AutomationSystemTemplateRendererService.name); + + constructor( + private readonly emailService: EmailService, + @Inject(DB_ADMIN) private readonly dbAdmin: DatabasePg, + ) {} + + async render( + templateId: string, + variables: Record, + tenantId: UUIDType, + userId?: UUIDType, + language?: SupportedLanguages, + ): Promise { + const emailSettings = await this.emailService.getDefaultEmailProperties( + tenantId, + userId, + language, + ); + + const origin = await resolveTenantOrigin(this.dbAdmin, tenantId); + + const email = this.buildEmailInstance(templateId, variables, emailSettings, origin); + + if (!email) { + this.logger.warn(`No system template renderer for: ${templateId}`); + return null; + } + + const [text, html] = await Promise.all([email.text, email.html]); + + return { + subject: this.resolveSubject(templateId, variables, emailSettings.language), + text, + html, + }; + } + + private buildEmailInstance( + templateId: string, + vars: Record, + settings: { primaryColor: string; companyName: string; language: SupportedLanguages }, + origin: string, + ) { + const base = { + primaryColor: settings.primaryColor, + companyName: settings.companyName, + language: settings.language, + }; + + switch (templateId) { + case "user_invite": + return new UserInviteEmail({ + invitedByUserName: vars.invitedByUserName || vars.userFirstName || "Admin", + createPasswordLink: vars.inviteLink || `${origin}/create-password`, + ...base, + }); + + case "welcome": + return new WelcomeEmail({ + coursesLink: vars.platformUrl || `${origin}/courses`, + ...base, + }); + + case "user_first_login": + return new UserFirstLoginEmail({ + name: vars.userFirstName || "", + coursesUrl: vars.platformUrl || `${origin}/courses`, + ...base, + }); + + case "user_assigned_to_course": + return new UserAssignedToCourseEmail({ + courseName: vars.courseName || "", + courseLink: vars.courseUrl || `${origin}/courses`, + formatedCourseDueDate: vars.dueDate || null, + ...base, + }); + + case "user_short_inactivity": + return new UserShortInactivityEmail({ + courseName: vars.courseName || undefined, + courseLink: vars.courseUrl || `${origin}/courses`, + ...base, + }); + + case "user_long_inactivity": + return new UserLongInactivityEmail({ + courseName: vars.courseName || undefined, + courseLink: vars.courseUrl || `${origin}/courses`, + ...base, + }); + + case "user_finished_chapter": + return new UserFinishedChapterEmail({ + chapterName: vars.chapterName || "", + courseName: vars.courseName || "", + courseLink: vars.courseUrl || `${origin}/courses`, + ...base, + }); + + case "user_finished_course": + return new UserFinishedCourseEmail({ + courseName: vars.courseName || "", + buttonLink: vars.certificateUrl || vars.courseUrl || `${origin}/courses`, + hasCertificate: vars.hasCertificate === "true", + ...base, + }); + + case "create_password_reminder": + return new CreatePasswordReminderEmail({ + createPasswordLink: + vars.resetPasswordLink || vars.inviteLink || `${origin}/create-password`, + ...base, + }); + + case "certificate_expiration_warning": + return new CertificateExpirationWarningEmail({ + courseName: vars.certificateName || vars.courseName || "", + courseLink: vars.courseUrl || `${origin}/courses`, + expiresAt: vars.expirationDate || "", + ...base, + }); + + case "certificate_expired": + return new CertificateExpiredEmail({ + courseName: vars.certificateName || vars.courseName || "", + courseLink: vars.courseUrl || `${origin}/courses`, + reason: (vars.archiveReason as "expired" | "manual_reset") || "expired", + ...base, + }); + + case "announcement": + return new AnnouncementEmail({ + title: vars.announcementTitle || "", + content: vars.announcementContent || "", + buttonLink: vars.announcementUrl || `${origin}/announcements`, + ...base, + }); + + case "course_due_date_reminder": + return new CourseDueDateReminderEmail({ + courseName: vars.courseName || "", + courseLink: vars.courseUrl || `${origin}/courses`, + dueDate: vars.dueDate || "", + daysBeforeDueDate: parseInt(vars.daysLeft || "7", 10), + ...base, + }); + + case "new_user": + return new NewUserEmail({ + userName: + vars.userName || + [vars.userFirstName, vars.userLastName].filter(Boolean).join(" ") || + "", + profileLink: vars.profileLink || `${origin}/admin/users`, + ...base, + }); + + case "finished_course": + return new FinishedCourseEmail({ + userName: + vars.userName || + [vars.userFirstName, vars.userLastName].filter(Boolean).join(" ") || + "", + courseName: vars.courseName || "", + progressLink: vars.progressLink || `${origin}/admin/courses`, + ...base, + }); + + default: + return null; + } + } + + private resolveSubject( + templateId: string, + vars: Record, + language: SupportedLanguages, + ): string { + const mapping = this.getSubjectMapping(templateId, vars); + + if (!mapping) return vars.announcementTitle || "Notification"; + + return getEmailSubject(mapping.key, language, mapping.replacements); + } + + private getSubjectMapping( + templateId: string, + vars: Record, + ): { key: EmailSubjectKey; replacements: Record } | null { + switch (templateId) { + case "user_invite": + return { key: "userInviteEmail", replacements: {} }; + case "welcome": + return { key: "welcomeEmail", replacements: {} }; + case "user_first_login": + return { key: "userFirstLoginEmail", replacements: {} }; + case "user_assigned_to_course": + return { + key: "userCourseAssignmentEmail", + replacements: { courseName: vars.courseName || "" }, + }; + case "user_short_inactivity": + return vars.courseName + ? { key: "userShortInactivityEmail", replacements: { courseName: vars.courseName } } + : { key: "userShortInactivityPlatformEmail", replacements: {} }; + case "user_long_inactivity": + return { key: "userLongInactivityEmail", replacements: {} }; + case "user_finished_chapter": + return { + key: "userChapterFinishedEmail", + replacements: { chapterName: vars.chapterName || "" }, + }; + case "user_finished_course": + return { + key: "userCourseFinishedEmail", + replacements: { courseName: vars.courseName || "" }, + }; + case "create_password_reminder": + return { key: "passwordReminderEmail", replacements: {} }; + case "certificate_expiration_warning": + return { + key: "certificateExpirationWarningEmail", + replacements: { courseName: vars.certificateName || vars.courseName || "" }, + }; + case "certificate_expired": + return { + key: "certificateExpiredEmail", + replacements: { courseName: vars.certificateName || vars.courseName || "" }, + }; + case "announcement": + return null; // Announcement uses title directly as subject + case "course_due_date_reminder": + return { + key: "courseDueDateReminderEmail", + replacements: { courseName: vars.courseName || "" }, + }; + case "new_user": + return { key: "adminNewUserEmail", replacements: {} }; + case "finished_course": + return { key: "adminCourseFinishedEmail", replacements: {} }; + default: + return null; + } + } +} diff --git a/apps/api/src/automations/automation-runner/automation-template.service.ts b/apps/api/src/automations/automation-runner/automation-template.service.ts new file mode 100644 index 0000000000..94105a309e --- /dev/null +++ b/apps/api/src/automations/automation-runner/automation-template.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import { EmailNotificationTemplatesService } from "src/email-notification-templates/email-templates.service"; +import { renderTemplateContent } from "src/email-notification-templates/utils/renderTemplateContent"; + +import type { SupportedLanguages } from "@repo/shared"; +import type { UUIDType } from "src/common"; + +export type AutomationEmailTemplate = { + id: UUIDType; + subject: string; + body: string; +}; + +@Injectable() +export class AutomationTemplateService { + private readonly logger = new Logger(AutomationTemplateService.name); + + constructor(private readonly emailTemplatesService: EmailNotificationTemplatesService) {} + + async getTemplate( + templateId: UUIDType, + language?: SupportedLanguages, + ): Promise { + this.logger.debug(`Fetching email template: ${templateId}`); + + try { + const template = await this.emailTemplatesService.getTemplateById(templateId); + + if (!template) { + this.logger.warn(`Email template not found: ${templateId}`); + return null; + } + + const resolvedLanguage = language ?? template.baseLanguage; + + const rendered = await renderTemplateContent({ + blocks: template.blocks, + strings: template.strings, + subject: template.subject, + language: resolvedLanguage, + baseLanguage: template.baseLanguage, + primaryColor: "", + }); + + return { + id: templateId, + subject: rendered.subject, + body: rendered.html, + }; + } catch (error) { + this.logger.error(`Failed to fetch/render email template ${templateId}`, error); + return null; + } + } +} diff --git a/apps/api/src/automations/automations-seed-defaults.service.spec.ts b/apps/api/src/automations/automations-seed-defaults.service.spec.ts new file mode 100644 index 0000000000..54e6ab80f4 --- /dev/null +++ b/apps/api/src/automations/automations-seed-defaults.service.spec.ts @@ -0,0 +1,244 @@ +import { Test } from "@nestjs/testing"; + +import { AutomationStatus } from "src/announcements/types/automations.types"; + +import { AutomationsSeedDefaultsService } from "./automations-seed-defaults.service"; +import { AutomationStepsService } from "./automations-steps/automations-steps.service"; +import { AutomationsService } from "./automations.service"; + +import type { TestingModule } from "@nestjs/testing"; +import type { UUIDType } from "src/common"; + +describe("AutomationsSeedDefaultsService", () => { + let service: AutomationsSeedDefaultsService; + let automationsService: jest.Mocked; + let automationStepsService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationsSeedDefaultsService, + { + provide: AutomationsService, + useValue: { + getAllAutomations: jest.fn(), + createAutomation: jest.fn(), + }, + }, + { + provide: AutomationStepsService, + useValue: { + getAllAutomationSteps: jest.fn(), + createAutomationStep: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AutomationsSeedDefaultsService); + automationsService = module.get(AutomationsService); + automationStepsService = module.get(AutomationStepsService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("seedDefaults", () => { + const tenantId = "tenant-1" as UUIDType; + + it("creates all default automations when none exist", async () => { + automationsService.getAllAutomations.mockResolvedValue([]); + automationsService.createAutomation.mockResolvedValue({ + id: "new-automation-id" as UUIDType, + name: {}, + description: {}, + status: "enabled", + tenantId, + createdAt: new Date(), + updatedAt: new Date(), + lastRun: null, + } as any); + automationStepsService.createAutomationStep.mockResolvedValue( + "step-id" as unknown as UUIDType, + ); + + const result = await service.seedDefaults(tenantId, "en"); + + expect(result.created).toBe(18); + expect(result.skipped).toBe(0); + expect(result.total).toBe(18); + // Each automation creates 2 steps (trigger + action) + expect(automationStepsService.createAutomationStep).toHaveBeenCalledTimes(36); + }); + + it("skips automations that already have matching triggers", async () => { + automationsService.getAllAutomations.mockResolvedValue([ + { id: "existing-automation" as UUIDType } as any, + ]); + automationStepsService.getAllAutomationSteps.mockResolvedValue([ + { + id: "step-1", + automationId: "existing-automation", + parentId: null, + type: "trigger", + typeContext: { name: "user_invited", providedVariables: [] }, + } as any, + ]); + automationsService.createAutomation.mockResolvedValue({ + id: "new-id" as UUIDType, + name: {}, + description: {}, + status: "enabled", + tenantId, + createdAt: new Date(), + updatedAt: new Date(), + lastRun: null, + } as any); + automationStepsService.createAutomationStep.mockResolvedValue( + "step-id" as unknown as UUIDType, + ); + + const result = await service.seedDefaults(tenantId, "en"); + + expect(result.skipped).toBe(1); + expect(result.created).toBe(17); + expect(result.total).toBe(18); + }); + + it("skips all when all triggers already exist", async () => { + const allTriggerTypes = [ + "user_invited", + "users_imported_invite", + "user_password_reminder", + "user_welcome", + "user_first_login", + "users_assigned_to_course", + "users_short_inactivity", + "users_long_inactivity", + "user_chapter_finished", + "user_course_finished", + "user_registered", + "user_password_created", + "course_completed", + "certificate_expiration_warning", + "certificate_archived", + "announcement_published", + "course_chat_user_mentioned", + "course_due_date_reminder", + ]; + + const fakeAutomations = allTriggerTypes.map((_, i) => ({ + id: `automation-${i}` as UUIDType, + })); + + automationsService.getAllAutomations.mockResolvedValue(fakeAutomations as any); + + automationStepsService.getAllAutomationSteps.mockImplementation(async (automationId) => { + const index = parseInt((automationId as string).replace("automation-", "")); + return [ + { + id: `step-${index}`, + automationId, + parentId: null, + type: "trigger", + typeContext: { name: allTriggerTypes[index], providedVariables: [] }, + }, + ] as any; + }); + + const result = await service.seedDefaults(tenantId, "en"); + + expect(result.created).toBe(0); + expect(result.skipped).toBe(18); + expect(result.total).toBe(18); + expect(automationsService.createAutomation).not.toHaveBeenCalled(); + }); + + it("creates automation with enabled status and properly structured steps", async () => { + automationsService.getAllAutomations.mockResolvedValue([]); + automationsService.createAutomation.mockResolvedValue({ + id: "created-id" as UUIDType, + name: { pl: "test", en: "test" }, + description: {}, + status: "enabled", + tenantId, + createdAt: new Date(), + updatedAt: new Date(), + lastRun: null, + } as any); + automationStepsService.createAutomationStep.mockResolvedValue( + "trigger-step-id" as unknown as UUIDType, + ); + + await service.seedDefaults(tenantId, "en"); + + expect(automationsService.createAutomation).toHaveBeenCalledWith( + expect.objectContaining({ status: AutomationStatus.Enabled }), + ); + + expect(automationStepsService.createAutomationStep).toHaveBeenCalledWith( + expect.objectContaining({ + parentId: null, + type: "trigger", + typeContext: expect.objectContaining({ + name: expect.any(String), + label: "User Invitation", + config: {}, + position: { x: 0, y: 0 }, + }), + }), + ); + + expect(automationStepsService.createAutomationStep).toHaveBeenCalledWith( + expect.objectContaining({ + parentId: "trigger-step-id", + type: "action", + typeContext: expect.objectContaining({ + name: "send_email", + label: "Send email", // EN label + config: expect.objectContaining({ + emailTemplate: expect.any(String), + language: "user_default", + placeholderValues: expect.any(Object), + }), + position: { x: 0, y: 150 }, + }), + }), + ); + }); + + it("maps placeholderValues correctly for user_invited trigger", async () => { + automationsService.getAllAutomations.mockResolvedValue([]); + automationsService.createAutomation.mockResolvedValue({ + id: "created-id" as UUIDType, + name: {}, + description: {}, + status: "enabled", + tenantId, + createdAt: new Date(), + updatedAt: new Date(), + lastRun: null, + } as any); + automationStepsService.createAutomationStep.mockResolvedValue( + "trigger-step-id" as unknown as UUIDType, + ); + + await service.seedDefaults(tenantId, "en"); + + const actionCalls = automationStepsService.createAutomationStep.mock.calls.filter( + ([input]) => input.type === "action", + ); + + const firstAction = actionCalls[0][0]; + expect((firstAction.typeContext as Record).config).toEqual({ + emailTemplate: "user_invite", + language: "user_default", + placeholderValues: { + invitedByUserName: "invitedByUserName", + createPasswordLink: "inviteLink", + }, + }); + }); + }); +}); diff --git a/apps/api/src/automations/automations-seed-defaults.service.ts b/apps/api/src/automations/automations-seed-defaults.service.ts new file mode 100644 index 0000000000..a22fdea659 --- /dev/null +++ b/apps/api/src/automations/automations-seed-defaults.service.ts @@ -0,0 +1,398 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + AUTOMATION_TRIGGER_TYPES, + STEP_DEFINITIONS, + type SupportedLanguages, + type TriggerType, + type LocalizedText, +} from "@repo/shared"; + +import { AutomationStatus } from "src/announcements/types/automations.types"; + +import { AutomationStepsService } from "./automations-steps/automations-steps.service"; +import { AutomationsService } from "./automations.service"; + +import type { AutomationRecordInput } from "src/announcements/types/automations-source.types"; +import type { UUIDType } from "src/common"; + +const TRIGGER_TO_TEMPLATE: Record = { + user_invited: "user_invite", + users_imported_invite: "user_invite", + user_password_reminder: "create_password_reminder", + user_welcome: "welcome", + user_first_login: "user_first_login", + users_assigned_to_course: "user_assigned_to_course", + users_short_inactivity: "user_short_inactivity", + users_long_inactivity: "user_long_inactivity", + user_chapter_finished: "user_finished_chapter", + user_course_finished: "user_finished_course", + user_registered: "new_user", + user_password_created: "welcome", + course_completed: "finished_course", + certificate_expiration_warning: "certificate_expiration_warning", + certificate_archived: "certificate_expired", + announcement_published: "announcement", + course_chat_user_mentioned: "announcement", + course_due_date_reminder: "course_due_date_reminder", +}; + +const TEMPLATE_VARIABLE_MAPPINGS: Record> = { + user_invite: { + invitedByUserName: "invitedByUserName", + createPasswordLink: "inviteLink", + }, + welcome: { + coursesLink: "platformUrl", + }, + user_first_login: { + name: "userFirstName", + coursesUrl: "platformUrl", + }, + user_assigned_to_course: { + courseName: "courseName", + courseLink: "courseUrl", + formatedCourseDueDate: "dueDate", + }, + user_short_inactivity: { + courseName: "courseName", + courseLink: "courseUrl", + }, + user_long_inactivity: { + courseName: "courseName", + courseLink: "courseUrl", + }, + user_finished_chapter: { + chapterName: "chapterName", + courseName: "courseName", + courseLink: "courseUrl", + }, + user_finished_course: { + courseName: "courseName", + buttonLink: "courseUrl", + hasCertificate: "hasCertificate", + }, + create_password_reminder: { + createPasswordLink: "resetPasswordLink", + }, + certificate_expiration_warning: { + courseName: "certificateName", + courseLink: "courseUrl", + expiresAt: "expirationDate", + }, + certificate_expired: { + courseName: "certificateName", + courseLink: "courseUrl", + }, + announcement: { + title: "announcementTitle", + content: "announcementContent", + buttonLink: "announcementUrl", + }, + course_due_date_reminder: { + courseName: "courseName", + courseLink: "courseUrl", + dueDate: "dueDate", + daysBeforeDueDate: "daysLeft", + }, + new_user: { + userName: "userName", + profileLink: "profileLink", + }, + finished_course: { + userName: "userName", + courseName: "courseName", + progressLink: "progressLink", + }, +}; + +const TRIGGER_NAMES: Record> = { + user_invited: { + pl: "Zaproszenie użytkownika", + en: "User Invitation", + de: "Benutzereinladung", + lt: "Vartotojo pakvietimas", + cs: "Pozvánka uživatele", + es: "Invitación de usuario", + }, + users_imported_invite: { + pl: "Zaproszenie importowanych użytkowników", + en: "Imported Users Invitation", + de: "Einladung importierter Benutzer", + lt: "Importuotų vartotojų pakvietimas", + cs: "Pozvánka importovaných uživatelů", + es: "Invitación de usuarios importados", + }, + user_password_reminder: { + pl: "Przypomnienie hasła", + en: "Password Reminder", + de: "Passworterinnerung", + lt: "Slaptažodžio priminimas", + cs: "Připomínka hesla", + es: "Recordatorio de contraseña", + }, + user_welcome: { + pl: "Powitanie użytkownika", + en: "User Welcome", + de: "Benutzerbegrüßung", + lt: "Vartotojo pasisveikinimas", + cs: "Přivítání uživatele", + es: "Bienvenida del usuario", + }, + user_first_login: { + pl: "Pierwsze logowanie", + en: "First Login", + de: "Erste Anmeldung", + lt: "Pirmas prisijungimas", + cs: "První přihlášení", + es: "Primer inicio de sesión", + }, + users_assigned_to_course: { + pl: "Przypisanie do kursu", + en: "Course Assignment", + de: "Kurszuweisung", + lt: "Priskyrimas kursui", + cs: "Přiřazení ke kurzu", + es: "Asignación al curso", + }, + users_short_inactivity: { + pl: "Krótka nieaktywność", + en: "Short Inactivity", + de: "Kurze Inaktivität", + lt: "Trumpa neaktyvumo periodo", + cs: "Krátká neaktivita", + es: "Inactividad corta", + }, + users_long_inactivity: { + pl: "Długa nieaktywność", + en: "Long Inactivity", + de: "Lange Inaktivität", + lt: "Ilga neaktyvumo periodo", + cs: "Dlouhá neaktivita", + es: "Inactividad prolongada", + }, + user_chapter_finished: { + pl: "Ukończenie rozdziału", + en: "Chapter Completed", + de: "Kapitel abgeschlossen", + lt: "Skyrius baigtas", + cs: "Kapitola dokončena", + es: "Capítulo completado", + }, + user_course_finished: { + pl: "Ukończenie kursu (użytkownik)", + en: "Course Completed (User)", + de: "Kurs abgeschlossen (Benutzer)", + lt: "Kursas baigtas (vartotojas)", + cs: "Kurz dokončen (uživatel)", + es: "Curso completado (usuario)", + }, + user_registered: { + pl: "Rejestracja nowego użytkownika", + en: "New User Registered", + de: "Neuer Benutzer registriert", + lt: "Naujas vartotojas užregistruotas", + cs: "Nový uživatel zaregistrován", + es: "Nuevo usuario registrado", + }, + user_password_created: { + pl: "Utworzenie hasła", + en: "Password Created", + de: "Passwort erstellt", + lt: "Slaptažodis sukurtas", + cs: "Heslo vytvořeno", + es: "Contraseña creada", + }, + course_completed: { + pl: "Ukończenie kursu (admin)", + en: "Course Completed (Admin)", + de: "Kurs abgeschlossen (Admin)", + lt: "Kursas baigtas (admin)", + cs: "Kurz dokončen (admin)", + es: "Curso completado (admin)", + }, + certificate_expiration_warning: { + pl: "Ostrzeżenie o wygaśnięciu certyfikatu", + en: "Certificate Expiration Warning", + de: "Zertifikat-Ablaufwarnung", + lt: "Sertifikato galiojimo pabaigos įspėjimas", + cs: "Varování o vypršení certifikátu", + es: "Aviso de expiración del certificado", + }, + certificate_archived: { + pl: "Certyfikat zarchiwizowany", + en: "Certificate Archived", + de: "Zertifikat archiviert", + lt: "Sertifikatas archyvuotas", + cs: "Certifikát archivován", + es: "Certificado archivado", + }, + announcement_published: { + pl: "Opublikowanie ogłoszenia", + en: "Announcement Published", + de: "Ankündigung veröffentlicht", + lt: "Skelbimas paskelbtas", + cs: "Oznámení zveřejněno", + es: "Anuncio publicado", + }, + course_chat_user_mentioned: { + pl: "Wzmianka w czacie kursu", + en: "Course Chat Mention", + de: "Erwähnung im Kurschat", + lt: "Paminėjimas kurso pokalbyje", + cs: "Zmínka v chatu kurzu", + es: "Mención en el chat del curso", + }, + course_due_date_reminder: { + pl: "Przypomnienie o terminie kursu", + en: "Course Due Date Reminder", + de: "Kurs-Fälligkeitserinnerung", + lt: "Kurso termino priminimas", + cs: "Připomínka termínu kurzu", + es: "Recordatorio de fecha límite del curso", + }, +}; + +const DESCRIPTION_TEMPLATES: Record = { + pl: "Domyślna automatyzacja dla zdarzenia: ", + en: "Default automation for event: ", + de: "Standardautomatisierung für Ereignis: ", + lt: "Numatytasis automatizavimas įvykiui: ", + cs: "Výchozí automatizace pro událost: ", + es: "Automatización predeterminada para evento: ", +}; + +const SEND_EMAIL_LABELS: Record = { + pl: "Wyślij e-mail", + en: "Send email", + de: "E-Mail senden", + lt: "Siųsti el. laišką", + cs: "Odeslat e-mail", + es: "Enviar correo", +}; + +export interface SeedDefaultsResult { + created: number; + skipped: number; + total: number; +} + +@Injectable() +export class AutomationsSeedDefaultsService { + private readonly logger = new Logger(AutomationsSeedDefaultsService.name); + + constructor( + private readonly automationsService: AutomationsService, + private readonly automationStepsService: AutomationStepsService, + ) {} + + async seedDefaults( + tenantId: UUIDType, + language: SupportedLanguages, + ): Promise { + const existingAutomations = await this.automationsService.getAllAutomations(tenantId); + + const existingTriggerTypes = new Set(); + + for (const automation of existingAutomations) { + const steps = await this.automationStepsService.getAllAutomationSteps(automation.id); + for (const step of steps) { + if (step.type === "trigger" && step.typeContext?.name) { + existingTriggerTypes.add(step.typeContext.name); + } + } + } + + let created = 0; + let skipped = 0; + + for (const triggerType of AUTOMATION_TRIGGER_TYPES) { + if (existingTriggerTypes.has(triggerType)) { + skipped++; + continue; + } + + try { + // Przekazujemy tenantId do funkcji pomocniczej + await this.createDefaultAutomation(tenantId, triggerType, language); + created++; + } catch (error) { + this.logger.error(`Failed to create default automation for trigger: ${triggerType}`, error); + skipped++; + } + } + + return { + created, + skipped, + total: AUTOMATION_TRIGGER_TYPES.length, + }; + } + + private async createDefaultAutomation( + tenantId: UUIDType, + triggerType: TriggerType, + language: SupportedLanguages, + ): Promise { + const names = TRIGGER_NAMES[triggerType]; + const templateId = TRIGGER_TO_TEMPLATE[triggerType]; + + // Pobieramy etykietę dla wskazanego języka (z fallbackiem do EN) + const label = names[language] ?? names.en ?? Object.values(names)[0]; + const descTemplate = DESCRIPTION_TEMPLATES[language] ?? DESCRIPTION_TEMPLATES.en; + + // Tworzymy obiekty zlokalizowane spersonalizowane pod język użytkownika + const name: LocalizedText = { + [language]: label, + }; + const description: LocalizedText = { + [language]: `${descTemplate}${label}`, + }; + + const input: AutomationRecordInput = { + tenantId, // Pamiętaj o przekazaniu tenantId! + name, + description, + status: AutomationStatus.Enabled, + }; + + const automation = await this.automationsService.createAutomation(input); + + const triggerDef = STEP_DEFINITIONS.find((s) => s.kind === "trigger" && s.type === triggerType); + + const triggerLabel = label; + + const triggerStepId = await this.automationStepsService.createAutomationStep({ + parentId: null, + automationId: automation.id, + type: "trigger", + typeContext: { + name: triggerType, + label: triggerLabel, + config: {}, + position: { x: 0, y: 0 }, + providedVariables: triggerDef?.providedVariables ?? [], + } as any, + }); + + const placeholderValues = TEMPLATE_VARIABLE_MAPPINGS[templateId] ?? {}; + + const actionLabel = SEND_EMAIL_LABELS[language] ?? SEND_EMAIL_LABELS.en; + + await this.automationStepsService.createAutomationStep({ + parentId: triggerStepId, + automationId: automation.id, + type: "action", + typeContext: { + name: "send_email", + label: actionLabel, + config: { + emailTemplate: templateId, + language: "user_default", + placeholderValues, + }, + position: { x: 0, y: 150 }, + providedVariables: [], + } as any, + }); + } +} diff --git a/apps/api/src/automations/automations-steps/automations-steps.controller.spec.ts b/apps/api/src/automations/automations-steps/automations-steps.controller.spec.ts new file mode 100644 index 0000000000..6f10c0ba41 --- /dev/null +++ b/apps/api/src/automations/automations-steps/automations-steps.controller.spec.ts @@ -0,0 +1,143 @@ +import { Test } from "@nestjs/testing"; + +import { BaseResponse } from "src/common"; + +import { AutomationStepsController } from "./automations-steps.controller"; +import { AutomationStepsService } from "./automations-steps.service"; + +import type { TestingModule } from "@nestjs/testing"; +import type { UUIDType } from "src/common"; + +describe("AutomationStepsController", () => { + let controller: AutomationStepsController; + let service: jest.Mocked; + + const automationId = "auto-1" as UUIDType; + const stepId = "step-1" as UUIDType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AutomationStepsController], + providers: [ + { + provide: AutomationStepsService, + useValue: { + createAutomationStep: jest.fn(), + getAutomationStepById: jest.fn(), + getAllAutomationSteps: jest.fn(), + updateAutomationStep: jest.fn(), + deleteAutomationStep: jest.fn(), + ReplaceAutomationStepTree: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AutomationStepsController); + service = module.get(AutomationStepsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("create", () => { + it("creates step and returns BaseResponse with id", async () => { + service.createAutomationStep.mockResolvedValue(stepId); + + const input = { + parentId: null, + automationId, + type: "trigger" as const, + typeContext: { name: "user_invited", providedVariables: [] }, + }; + + const result = await controller.create(input); + + expect(service.createAutomationStep).toHaveBeenCalledWith(input); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual({ id: stepId }); + }); + }); + + describe("getById", () => { + it("returns step wrapped in BaseResponse", async () => { + const step = { id: stepId, type: "trigger", typeContext: { name: "user_invited" } }; + service.getAutomationStepById.mockResolvedValue(step as any); + + const result = await controller.getById(stepId); + + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(step); + }); + }); + + describe("getAll", () => { + it("returns all steps for automation wrapped in BaseResponse", async () => { + const steps = [ + { id: "s1", type: "trigger" }, + { id: "s2", type: "action" }, + ]; + service.getAllAutomationSteps.mockResolvedValue(steps as any); + + const result = await controller.getAll(automationId); + + expect(service.getAllAutomationSteps).toHaveBeenCalledWith(automationId); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toHaveLength(2); + }); + }); + + describe("update", () => { + it("updates step and returns BaseResponse with id", async () => { + service.updateAutomationStep.mockResolvedValue(stepId); + + const input = { + parentId: null, + automationId, + type: "trigger" as const, + typeContext: { name: "user_welcome", providedVariables: [] }, + }; + + const result = await controller.update(stepId, input); + + expect(service.updateAutomationStep).toHaveBeenCalledWith(stepId, input); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual({ id: stepId }); + }); + }); + + describe("replaceAutomationStepTree", () => { + it("replaces tree and returns success message", async () => { + service.ReplaceAutomationStepTree.mockResolvedValue(undefined); + + const steps = [ + { + id: "root" as UUIDType, + parentId: null, + automationId, + type: "trigger" as const, + typeContext: { name: "user_invited", providedVariables: [] }, + }, + ]; + + const result = await controller.replaceAutomationStepTree(automationId, steps); + + expect(service.ReplaceAutomationStepTree).toHaveBeenCalledWith(automationId, steps); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual({ message: "Step tree replaced successfully" }); + }); + }); + + describe("delete", () => { + it("deletes step and returns BaseResponse with id", async () => { + service.deleteAutomationStep.mockResolvedValue(stepId); + + const result = await controller.delete(stepId); + + expect(service.deleteAutomationStep).toHaveBeenCalledWith(stepId); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual({ id: stepId }); + }); + }); +}); diff --git a/apps/api/src/automations/automations-steps/automations-steps.controller.ts b/apps/api/src/automations/automations-steps/automations-steps.controller.ts new file mode 100644 index 0000000000..7eb79e12f1 --- /dev/null +++ b/apps/api/src/automations/automations-steps/automations-steps.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from "@nestjs/common"; +import { PERMISSIONS } from "@repo/shared"; + +import { AutomationStepRecordInput } from "src/announcements/types/automations-source.types"; +import { BaseResponse, UUIDType } from "src/common"; +import { RequirePermission } from "src/common/decorators/require-permission.decorator"; + +import { AutomationStepsService } from "./automations-steps.service"; + +import type { AutomationStepBulkUpdate } from "src/announcements/types/automations-source.types"; + +@RequirePermission(PERMISSIONS.AUTOMATION_MANAGE) +@Controller("automation-steps") +export class AutomationStepsController { + constructor(private readonly automationStepsService: AutomationStepsService) {} + + @Post() + async create(@Body() input: AutomationStepRecordInput) { + const stepId = await this.automationStepsService.createAutomationStep(input); + return new BaseResponse({ id: stepId }); + } + + @Get(":id") + async getById(@Param("id") stepId: UUIDType) { + const step = await this.automationStepsService.getAutomationStepById(stepId); + return new BaseResponse(step); + } + + @Get("automation/:automationId") + async getAll(@Param("automationId") automationId: UUIDType) { + const steps = await this.automationStepsService.getAllAutomationSteps(automationId); + return new BaseResponse(steps); + } + + @Patch(":id") + async update(@Param("id") stepId: UUIDType, @Body() input: AutomationStepRecordInput) { + const updatedId = await this.automationStepsService.updateAutomationStep(stepId, input); + return new BaseResponse({ id: updatedId }); + } + + @Put(":automationId/steps") + async replaceAutomationStepTree( + @Param("automationId") automationId: UUIDType, + @Body() steps: AutomationStepBulkUpdate[], + ) { + await this.automationStepsService.ReplaceAutomationStepTree(automationId, steps); + return new BaseResponse({ message: "Step tree replaced successfully" }); + } + + @Delete(":id") + async delete(@Param("id") stepId: UUIDType) { + const deletedId = await this.automationStepsService.deleteAutomationStep(stepId); + return new BaseResponse({ id: deletedId }); + } +} diff --git a/apps/api/src/automations/automations-steps/automations-steps.service.spec.ts b/apps/api/src/automations/automations-steps/automations-steps.service.spec.ts new file mode 100644 index 0000000000..33e69b85dd --- /dev/null +++ b/apps/api/src/automations/automations-steps/automations-steps.service.spec.ts @@ -0,0 +1,330 @@ +import { BadRequestException } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import { AutomationStepsRepository } from "../repositories/automation-steps/automation-steps.repository"; + +import { AutomationStepsService } from "./automations-steps.service"; + +import type { TestingModule } from "@nestjs/testing"; +import type { AutomationStepBulkUpdate } from "src/announcements/types/automations-source.types"; +import type { UUIDType } from "src/common"; + +describe("AutomationStepsService", () => { + let service: AutomationStepsService; + let repository: jest.Mocked; + + const automationId = "auto-1" as UUIDType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationStepsService, + { + provide: AutomationStepsRepository, + useValue: { + createAutomationStep: jest.fn(), + getAutomationStepById: jest.fn(), + getAllAutomationStepsByAutomationId: jest.fn(), + updateAutomationStep: jest.fn(), + deleteAutomationStep: jest.fn(), + replaceAutomationStepTree: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AutomationStepsService); + repository = module.get(AutomationStepsRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("createAutomationStep", () => { + it("creates a root step when automation has no steps", async () => { + repository.getAllAutomationStepsByAutomationId.mockResolvedValue([]); + repository.createAutomationStep.mockResolvedValue("step-1" as UUIDType); + + const input = { + parentId: null, + automationId, + type: "trigger" as const, + typeContext: { name: "user_invited", providedVariables: [] }, + }; + + const result = await service.createAutomationStep(input); + + expect(result).toBe("step-1"); + expect(repository.createAutomationStep).toHaveBeenCalledWith(input); + }); + + it("rejects root step when automation already has steps", async () => { + repository.getAllAutomationStepsByAutomationId.mockResolvedValue([ + { id: "existing-root", parentId: null } as any, + ]); + + const input = { + parentId: null, + automationId, + type: "trigger" as const, + typeContext: { name: "user_welcome", providedVariables: [] }, + }; + + await expect(service.createAutomationStep(input)).rejects.toThrow(BadRequestException); + }); + + it("rejects child step when automation has no steps (no root)", async () => { + repository.getAllAutomationStepsByAutomationId.mockResolvedValue([]); + + const input = { + parentId: "parent-1" as UUIDType, + automationId, + type: "action" as const, + typeContext: { name: "send_email", providedVariables: [] }, + }; + + await expect(service.createAutomationStep(input)).rejects.toThrow(BadRequestException); + }); + + it("creates child step when parent exists", async () => { + const existingRoot = { + id: "root-1", + parentId: null, + automationId, + type: "trigger", + typeContext: { name: "user_invited", providedVariables: [] }, + }; + + repository.getAllAutomationStepsByAutomationId.mockResolvedValue([existingRoot] as any); + repository.getAutomationStepById.mockResolvedValue(existingRoot as any); + repository.createAutomationStep.mockResolvedValue("step-2" as UUIDType); + + const input = { + parentId: "root-1" as UUIDType, + automationId, + type: "action" as const, + typeContext: { name: "send_email", providedVariables: [] }, + }; + + const result = await service.createAutomationStep(input); + expect(result).toBe("step-2"); + }); + }); + + describe("getAutomationStepById", () => { + it("returns step when found", async () => { + const step = { id: "step-1", type: "trigger" }; + repository.getAutomationStepById.mockResolvedValue(step as any); + + const result = await service.getAutomationStepById("step-1" as UUIDType); + expect(result).toEqual(step); + }); + + it("throws BadRequestException when step not found", async () => { + repository.getAutomationStepById.mockResolvedValue(undefined as any); + + await expect(service.getAutomationStepById("missing" as UUIDType)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe("updateAutomationStep", () => { + it("updates when ids match and step exists", async () => { + const rootStep = { + id: "root-1", + parentId: null, + automationId, + type: "trigger", + }; + const existingStep = { + id: "step-1", + parentId: "root-1" as UUIDType, + automationId, + type: "action", + }; + + repository.getAutomationStepById.mockResolvedValueOnce(existingStep as any); + repository.getAutomationStepById.mockResolvedValueOnce(rootStep as any); + repository.getAllAutomationStepsByAutomationId.mockResolvedValue([ + rootStep, + existingStep, + ] as any); + repository.updateAutomationStep.mockResolvedValue("step-1" as UUIDType); + + const input = { + parentId: "root-1" as UUIDType, + automationId, + type: "action" as const, + typeContext: { name: "send_email", providedVariables: [] }, + }; + + const result = await service.updateAutomationStep("step-1" as UUIDType, input); + expect(result).toBe("step-1"); + }); + + it("throws when parentId mismatches", async () => { + const existingStep = { + id: "step-1", + parentId: "parent-A" as UUIDType, + automationId, + }; + + repository.getAutomationStepById.mockResolvedValue(existingStep as any); + + const input = { + parentId: "parent-B" as UUIDType, + automationId, + type: "action" as const, + typeContext: { name: "send_email", providedVariables: [] }, + }; + + await expect(service.updateAutomationStep("step-1" as UUIDType, input)).rejects.toThrow( + BadRequestException, + ); + }); + + it("throws when automationId mismatches", async () => { + const existingStep = { + id: "step-1", + parentId: null, + automationId: "auto-A" as UUIDType, + }; + + repository.getAutomationStepById.mockResolvedValue(existingStep as any); + + const input = { + parentId: null, + automationId: "auto-B" as UUIDType, + type: "trigger" as const, + typeContext: { name: "user_invited", providedVariables: [] }, + }; + + await expect(service.updateAutomationStep("step-1" as UUIDType, input)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe("ReplaceAutomationStepTree", () => { + it("replaces step tree for valid connected acyclic input", async () => { + const steps: AutomationStepBulkUpdate[] = [ + { + id: "root" as UUIDType, + parentId: null, + automationId, + type: "trigger", + typeContext: { name: "user_invited", providedVariables: [] }, + }, + { + id: "action-1" as UUIDType, + parentId: "root" as UUIDType, + automationId, + type: "action", + typeContext: { name: "send_email", providedVariables: [] }, + }, + ]; + + repository.replaceAutomationStepTree.mockResolvedValue(true); + + await service.ReplaceAutomationStepTree(automationId, steps); + + expect(repository.replaceAutomationStepTree).toHaveBeenCalledWith(automationId, steps); + }); + + it("rejects step tree with zero roots", async () => { + const steps: AutomationStepBulkUpdate[] = [ + { + id: "a" as UUIDType, + parentId: "b" as UUIDType, + automationId, + type: "action", + typeContext: { name: "send_email", providedVariables: [] }, + }, + { + id: "b" as UUIDType, + parentId: "a" as UUIDType, + automationId, + type: "trigger", + typeContext: { name: "user_invited", providedVariables: [] }, + }, + ]; + + await expect(service.ReplaceAutomationStepTree(automationId, steps)).rejects.toThrow( + BadRequestException, + ); + }); + + it("rejects step tree with multiple roots", async () => { + const steps: AutomationStepBulkUpdate[] = [ + { + id: "root-1" as UUIDType, + parentId: null, + automationId, + type: "trigger", + typeContext: { name: "user_invited", providedVariables: [] }, + }, + { + id: "root-2" as UUIDType, + parentId: null, + automationId, + type: "trigger", + typeContext: { name: "user_welcome", providedVariables: [] }, + }, + ]; + + await expect(service.ReplaceAutomationStepTree(automationId, steps)).rejects.toThrow( + "automationSteps.toast.wrongNumberOfRoots", + ); + }); + + it("rejects disconnected step tree", async () => { + const steps: AutomationStepBulkUpdate[] = [ + { + id: "root" as UUIDType, + parentId: null, + automationId, + type: "trigger", + typeContext: { name: "user_invited", providedVariables: [] }, + }, + { + id: "action-1" as UUIDType, + parentId: "root" as UUIDType, + automationId, + type: "action", + typeContext: { name: "send_email", providedVariables: [] }, + }, + { + id: "action-2" as UUIDType, + parentId: "nonexistent" as UUIDType, + automationId, + type: "action", + typeContext: { name: "send_email", providedVariables: [] }, + }, + ]; + + await expect(service.ReplaceAutomationStepTree(automationId, steps)).rejects.toThrow( + "automationSteps.toast.treeNotConnected", + ); + }); + }); + + describe("deleteAutomationStep", () => { + it("deletes step and its children recursively", async () => { + const steps = [ + { id: "root", parentId: null, automationId, type: "trigger", typeContext: {} }, + { id: "child-1", parentId: "root", automationId, type: "action", typeContext: {} }, + { id: "grandchild", parentId: "child-1", automationId, type: "action", typeContext: {} }, + ]; + + repository.getAutomationStepById.mockResolvedValueOnce(steps[0] as any); + repository.getAllAutomationStepsByAutomationId.mockResolvedValue(steps as any); + repository.deleteAutomationStep.mockResolvedValue("id" as UUIDType); + + await service.deleteAutomationStep("root" as UUIDType); + + expect(repository.deleteAutomationStep).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/apps/api/src/automations/automations-steps/automations-steps.service.ts b/apps/api/src/automations/automations-steps/automations-steps.service.ts new file mode 100644 index 0000000000..917ef49539 --- /dev/null +++ b/apps/api/src/automations/automations-steps/automations-steps.service.ts @@ -0,0 +1,289 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; + +import { AutomationStepsRepository } from "../repositories/automation-steps/automation-steps.repository"; + +import type { + AutomationStep, + AutomationStepBulkUpdate, + AutomationStepRecordInput, +} from "src/announcements/types/automations-source.types"; +import type { UUIDType } from "src/common"; + +type StepNode = { + children: StepNode[]; + value: AutomationStep; +}; + +@Injectable() +export class AutomationStepsService { + constructor(private readonly automationStepsRepository: AutomationStepsRepository) {} + + async createAutomationStep(input: AutomationStepRecordInput) { + await this.validateStep(input); + return this.automationStepsRepository.createAutomationStep(input); + } + + async getAllAutomationSteps(automationId: UUIDType) { + return this.automationStepsRepository.getAllAutomationStepsByAutomationId(automationId); + } + + async getAutomationStepById(stepId: UUIDType) { + const step = await this.automationStepsRepository.getAutomationStepById(stepId); + + if (!step) { + throw new BadRequestException("automationSteps.toast.notFound"); + } + + return step; + } + + async updateAutomationStep(stepId: UUIDType, input: AutomationStepRecordInput) { + const stepToUpdate = await this.getAutomationStepById(stepId); + + const isIdMismatch = + stepToUpdate.parentId !== input.parentId || stepToUpdate.automationId !== input.automationId; + + if (isIdMismatch) { + throw new BadRequestException("automationSteps.toast.idMismatch"); + } + + await this.validateStep(input); + + const updatedId = await this.automationStepsRepository.updateAutomationStep(stepId, input); + + if (!updatedId) { + throw new BadRequestException("automationSteps.toast.updateFailed"); + } + + return updatedId; + } + + async ReplaceAutomationStepTree(automationId: UUIDType, input: AutomationStepBulkUpdate[]) { + const roots = input.filter((step) => step.parentId == null); + if (roots.length !== 1) { + throw new BadRequestException("automationSteps.toast.wrongNumberOfRoots"); + } + const root = this.buildStepGraph(input as AutomationStep[]); + const hasCycle = this.hasCycle(root); + const isConnected = this.isConnected(root, input.length); + + if (hasCycle) { + throw new BadRequestException("automationSteps.toast.cycleDetected"); + } + if (!isConnected) { + throw new BadRequestException("automationSteps.toast.treeNotConnected"); + } + + const res = await this.automationStepsRepository.replaceAutomationStepTree(automationId, input); + if (!res) { + throw new BadRequestException("automationSteps.toast.bulkInsertFailed"); + } + } + + async deleteAutomationStep(stepId: UUIDType) { + const childrenIdsToDelete = await this.getIdsToDeleteCascade(stepId); + const idsToDelete = [...childrenIdsToDelete, stepId]; + + for (const id of idsToDelete) { + const deletedId = await this.automationStepsRepository.deleteAutomationStep(id); + + if (!deletedId) { + throw new BadRequestException("automationSteps.toast.deleteFailed"); + } + } + + return stepId; + } + + private async deletePreviousTree(automationId: UUIDType) { + const allSteps = await this.getAllAutomationSteps(automationId); + if (allSteps.length > 0) { + const rootStep = allSteps.find((step) => step.parentId == null); + if (!rootStep) { + throw new BadRequestException("automationSteps.toast.updateFailed"); + } + const deletedId = await this.deleteAutomationStep(rootStep.id); + if (!deletedId) { + throw new BadRequestException("automationSteps.toast.deleteFailed"); + } + } + } + private async getIdsToDeleteCascade(stepId: UUIDType) { + const stepToDelete = await this.getAutomationStepById(stepId); + const allSteps = await this.getAllAutomationSteps(stepToDelete.automationId); + + const root = this.buildStepGraph(allSteps); + const nodeToDelete = this.findNode(root, stepId); + + return this.getChildrenIds(nodeToDelete); + } + + private getChildrenIds(root: StepNode) { + const childrenIds: UUIDType[] = []; + const toVisit: StepNode[] = [root]; + + while (toVisit.length > 0) { + const curr = toVisit.pop(); + + if (!curr) { + continue; + } + + for (const child of curr.children) { + childrenIds.push(child.value.id); + toVisit.push(child); + } + } + + return childrenIds; + } + + private findNode(root: StepNode, id: UUIDType) { + const stack: StepNode[] = [root]; + + while (stack.length > 0) { + const current = stack.pop(); + + if (!current) { + continue; + } + + if (current.value.id === id) { + return current; + } + + stack.push(...current.children); + } + + throw new BadRequestException("automationSteps.toast.nodeDeleteFailed"); + } + + private async hasNoSteps(automationId: UUIDType) { + const allSteps = await this.getAllAutomationSteps(automationId); + return allSteps.length === 0; + } + + private async validateStep(input: AutomationStepRecordInput) { + const hasNoSteps = await this.hasNoSteps(input.automationId); + + if (hasNoSteps && input.parentId != null) { + throw new BadRequestException("automationSteps.toast.noRootStep"); + } + + if (!hasNoSteps && input.parentId == null) { + throw new BadRequestException("automationSteps.toast.hasRootAlready"); + } + + if (input.parentId) { + await this.getAutomationStepById(input.parentId); + } + } + + private async validateTree(input: AutomationStepRecordInput) { + const fetchedSteps = (await this.getAllAutomationSteps(input.automationId)) as AutomationStep[]; + + if (fetchedSteps.length === 0) { + return; + } + + const stepToInsert: AutomationStep = { + id: "-1", + automationId: input.automationId, + parentId: input.parentId, + type: input.type, + typeContext: input.typeContext, + }; + + fetchedSteps.push(stepToInsert); + + const root = this.buildStepGraph(fetchedSteps); + + if (this.hasCycle(root)) { + throw new BadRequestException("automationSteps.toast.cycleDetected"); + } + } + + private buildStepGraph(steps: AutomationStep[]) { + const nodes = new Map(); + + for (const step of steps) { + nodes.set(step.id, { + value: step, + children: [], + }); + } + + let root: StepNode | null = null; + + for (const step of steps) { + const node = nodes.get(step.id)!; + + if (step.parentId === null) { + root = node; + continue; + } + + const parent = nodes.get(step.parentId); + + if (parent) { + parent.children.push(node); + } + } + + if (!root) { + throw new BadRequestException("automationSteps.toast.stepTreeBuildFailed"); + } + + return root; + } + + private hasCycle(root: StepNode) { + const visited = new Set(); + const visiting = new Set(); + + function dfs(node: StepNode): boolean { + const id = node.value.id; + + if (visiting.has(id)) { + return true; + } + + if (visited.has(id)) { + return false; + } + + visiting.add(id); + + for (const child of node.children) { + if (dfs(child)) { + return true; + } + } + + visiting.delete(id); + visited.add(id); + + return false; + } + + return dfs(root); + } + + private isConnected(root: StepNode, numberOfSteps: number) { + const visited = new Set(); + + const dfs = (node: StepNode) => { + if (visited.has(node.value.id)) { + return; + } + + visited.add(node.value.id); + for (const child of node.children) { + dfs(child); + } + }; + dfs(root); + + return visited.size === numberOfSteps; + } +} diff --git a/apps/api/src/automations/automations.controller.spec.ts b/apps/api/src/automations/automations.controller.spec.ts new file mode 100644 index 0000000000..cbf8138eda --- /dev/null +++ b/apps/api/src/automations/automations.controller.spec.ts @@ -0,0 +1,209 @@ +import { Test } from "@nestjs/testing"; + +import { AutomationStatus } from "src/announcements/types/automations.types"; +import { BaseResponse } from "src/common"; + +import { AutomationSimulationService } from "./automation-runner/automation-simulation.service"; +import { AutomationSystemTemplatePreviewService } from "./automation-runner/automation-system-template-preview.service"; +import { AutomationsSeedDefaultsService } from "./automations-seed-defaults.service"; +import { AutomationsController } from "./automations.controller"; +import { AutomationsService } from "./automations.service"; + +import type { TestingModule } from "@nestjs/testing"; +import type { UUIDType } from "src/common"; + +describe("AutomationsController", () => { + let controller: AutomationsController; + let service: jest.Mocked; + let simulationService: jest.Mocked; + let templatePreviewService: jest.Mocked; + + const tenantId = "tenant-1" as UUIDType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AutomationsController], + providers: [ + { + provide: AutomationsService, + useValue: { + createAutomation: jest.fn(), + getAllAutomations: jest.fn(), + getAutomationById: jest.fn(), + updateAutomation: jest.fn(), + updateStatus: jest.fn(), + deleteAutomation: jest.fn(), + }, + }, + { + provide: AutomationSystemTemplatePreviewService, + useValue: { + renderPreview: jest.fn(), + }, + }, + { + provide: AutomationSimulationService, + useValue: { + runSimulation: jest.fn(), + }, + }, + { + provide: AutomationsSeedDefaultsService, + useValue: { + seedDefaults: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AutomationsController); + service = module.get(AutomationsService); + simulationService = module.get(AutomationSimulationService); + templatePreviewService = module.get(AutomationSystemTemplatePreviewService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getAllAutomations", () => { + it("returns all automations wrapped in BaseResponse", async () => { + const automations = [ + { id: "auto-1", name: { en: "First" }, status: "draft" }, + { id: "auto-2", name: { en: "Second" }, status: "enabled" }, + ]; + service.getAllAutomations.mockResolvedValue(automations as any); + + const result = await controller.getAllAutomations(tenantId); + + expect(service.getAllAutomations).toHaveBeenCalledWith(tenantId); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(automations); + }); + + it("returns empty array when no automations exist", async () => { + service.getAllAutomations.mockResolvedValue([]); + + const result = await controller.getAllAutomations(tenantId); + + expect(result.data).toEqual([]); + }); + }); + + describe("getAutomationById", () => { + it("returns automation wrapped in BaseResponse", async () => { + const automation = { id: "auto-1", name: { en: "Test" }, status: "draft" }; + service.getAutomationById.mockResolvedValue(automation as any); + + const result = await controller.getAutomationById("auto-1" as UUIDType); + + expect(service.getAutomationById).toHaveBeenCalledWith("auto-1"); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(automation); + }); + }); + + describe("createAutomation", () => { + it("creates automation and returns BaseResponse", async () => { + const input = { + tenantId, + name: { en: "New Automation" }, + description: { en: "Description" }, + status: AutomationStatus.Draft, + }; + const createdAutomation = { id: "auto-new", ...input }; + service.createAutomation.mockResolvedValue(createdAutomation as any); + + const result = await controller.createAutomation(input); + + expect(service.createAutomation).toHaveBeenCalledWith(input); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(createdAutomation); + }); + }); + + describe("updateAutomation", () => { + it("updates automation and returns BaseResponse with id", async () => { + service.updateAutomation.mockResolvedValue("auto-1" as UUIDType); + + const input = { name: { en: "Updated" } }; + const result = await controller.updateAutomation("auto-1" as UUIDType, input); + + expect(service.updateAutomation).toHaveBeenCalledWith("auto-1", input); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual({ id: "auto-1" }); + }); + }); + + describe("updateStatus", () => { + it("updates status and returns BaseResponse with id", async () => { + service.updateStatus.mockResolvedValue("auto-1" as UUIDType); + + const result = await controller.updateStatus("auto-1" as UUIDType, { + status: AutomationStatus.Enabled, + }); + + expect(service.updateStatus).toHaveBeenCalledWith("auto-1", AutomationStatus.Enabled); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual({ id: "auto-1" }); + }); + }); + + describe("deleteAutomation", () => { + it("deletes automation and returns BaseResponse", async () => { + const deletedRecord = { id: "auto-1", name: { en: "Deleted" } }; + service.deleteAutomation.mockResolvedValue(deletedRecord as any); + + const result = await controller.deleteAutomation("auto-1" as UUIDType); + + expect(service.deleteAutomation).toHaveBeenCalledWith("auto-1"); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(deletedRecord); + }); + }); + + describe("previewSystemTemplate", () => { + it("returns rendered template preview", async () => { + const preview = { subject: "Welcome", html: "

Hello

" }; + templatePreviewService.renderPreview.mockResolvedValue(preview as any); + + const result = await controller.previewSystemTemplate("template-1", "en"); + + expect(templatePreviewService.renderPreview).toHaveBeenCalledWith("template-1", "en"); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(preview); + }); + + it("defaults to Polish language when no language provided", async () => { + const preview = { subject: "Witaj", html: "

Cześć

" }; + templatePreviewService.renderPreview.mockResolvedValue(preview as any); + + const result = await controller.previewSystemTemplate("template-1", undefined); + + expect(templatePreviewService.renderPreview).toHaveBeenCalledWith("template-1", "pl"); + expect(result.data).toEqual(preview); + }); + + it("returns empty subject and html when preview is null", async () => { + templatePreviewService.renderPreview.mockResolvedValue(null as any); + + const result = await controller.previewSystemTemplate("template-1", "en"); + + expect(result.data).toEqual({ subject: "", html: "" }); + }); + }); + + describe("runSimulation", () => { + it("runs simulation and returns BaseResponse with result", async () => { + const simulationResult = { success: true, steps: [] }; + simulationService.runSimulation.mockResolvedValue(simulationResult as any); + + const body = { automationId: "auto-1", triggerData: {} } as any; + const result = await controller.runSimulation(body); + + expect(simulationService.runSimulation).toHaveBeenCalledWith(body); + expect(result).toBeInstanceOf(BaseResponse); + expect(result.data).toEqual(simulationResult); + }); + }); +}); diff --git a/apps/api/src/automations/automations.controller.ts b/apps/api/src/automations/automations.controller.ts new file mode 100644 index 0000000000..302f4b154e --- /dev/null +++ b/apps/api/src/automations/automations.controller.ts @@ -0,0 +1,101 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { PERMISSIONS, SUPPORTED_LANGUAGES, SupportedLanguages } from "@repo/shared"; + +import { + AutomationRecordInput, + AutomationRecordUpdateInput, +} from "src/announcements/types/automations-source.types"; +import { BaseResponse, UUIDType } from "src/common"; +import { RequirePermission } from "src/common/decorators/require-permission.decorator"; +import { CurrentUser } from "src/common/decorators/user.decorator"; + +import { AutomationSimulationService } from "./automation-runner/automation-simulation.service"; +import { RunSimulationBody } from "./automation-runner/automation-simulation.types"; +import { AutomationSystemTemplatePreviewService } from "./automation-runner/automation-system-template-preview.service"; +import { AutomationsSeedDefaultsService } from "./automations-seed-defaults.service"; +import { AutomationsService } from "./automations.service"; + +import type { AutomationStatus } from "src/announcements/types/automations.types"; + +@RequirePermission(PERMISSIONS.AUTOMATION_MANAGE) +@Controller("automations") +export class AutomationsController { + constructor( + private readonly automationsService: AutomationsService, + private readonly systemTemplatePreviewService: AutomationSystemTemplatePreviewService, + private readonly simulationService: AutomationSimulationService, + private readonly seedDefaultsService: AutomationsSeedDefaultsService, + ) {} + + @Get() + async getAllAutomations(@CurrentUser("tenantId") tenantId: UUIDType) { + const automations = await this.automationsService.getAllAutomations(tenantId); + return new BaseResponse(automations); + } + + @Get("system-template-preview/:templateId") + async previewSystemTemplate( + @Param("templateId") templateId: string, + @Query("language") language?: SupportedLanguages, + ) { + const resolvedLanguage = language ?? SUPPORTED_LANGUAGES.PL; + const preview = await this.systemTemplatePreviewService.renderPreview( + templateId, + resolvedLanguage, + ); + + return new BaseResponse(preview ?? { subject: "", html: "" }); + } + + @Post("simulate") + async runSimulation(@Body() body: RunSimulationBody) { + const result = await this.simulationService.runSimulation(body); + return new BaseResponse(result); + } + + @Post("seed-defaults") + async seedDefaults( + @CurrentUser("tenantId") tenantId: UUIDType, + @Body() body?: { language?: SupportedLanguages }, + ) { + const language = body?.language ?? SUPPORTED_LANGUAGES.EN; + const result = await this.seedDefaultsService.seedDefaults(tenantId, language); + return new BaseResponse(result); + } + + @Get(":id") + async getAutomationById(@Param("id") automationId: UUIDType) { + const automation = await this.automationsService.getAutomationById(automationId); + return new BaseResponse(automation); + } + + @Post() + async createAutomation(@Body() input: AutomationRecordInput) { + const automation = await this.automationsService.createAutomation(input); + return new BaseResponse(automation); + } + + @Patch("status/:id") + async updateStatus( + @Param("id") automationId: UUIDType, + @Body() body: { status: AutomationStatus }, + ) { + const updatedId = await this.automationsService.updateStatus(automationId, body.status); + return new BaseResponse({ id: updatedId }); + } + + @Patch(":id") + async updateAutomation( + @Param("id") automationId: UUIDType, + @Body() input: AutomationRecordUpdateInput, + ) { + const updatedId = await this.automationsService.updateAutomation(automationId, input); + return new BaseResponse({ id: updatedId }); + } + + @Delete(":id") + async deleteAutomation(@Param("id") automationId: UUIDType) { + const deleted = await this.automationsService.deleteAutomation(automationId); + return new BaseResponse(deleted); + } +} diff --git a/apps/api/src/automations/automations.module.ts b/apps/api/src/automations/automations.module.ts new file mode 100644 index 0000000000..d1b67b3eba --- /dev/null +++ b/apps/api/src/automations/automations.module.ts @@ -0,0 +1,56 @@ +import { forwardRef, Module } from "@nestjs/common"; + +import { AnnouncementsModule } from "src/announcements/announcements.module"; +import { EmailModule } from "src/common/emails/emails.module"; +import { CourseChatModule } from "src/course-chat/course-chat.module"; +import { CourseModule } from "src/courses/course.module"; +import { EmailNotificationTemplatesModule } from "src/email-notification-templates/email-templates.module"; +import { SettingsModule } from "src/settings/settings.module"; +import { UserModule } from "src/user/user.module"; + +import { AutomationLogsController } from "./automation-logs/automation-logs.controller"; +import { AutomationDataResolverService } from "./automation-runner/automation-data-resolver.service"; +import { AutomationRunnerService } from "./automation-runner/automation-runner.service"; +import { AutomationSimulationService } from "./automation-runner/automation-simulation.service"; +import { AutomationSystemTemplatePreviewService } from "./automation-runner/automation-system-template-preview.service"; +import { AutomationSystemTemplateRendererService } from "./automation-runner/automation-system-template-renderer.service"; +import { AutomationTemplateService } from "./automation-runner/automation-template.service"; +import { AutomationsSeedDefaultsService } from "./automations-seed-defaults.service"; +import { AutomationStepsController } from "./automations-steps/automations-steps.controller"; +import { AutomationStepsService } from "./automations-steps/automations-steps.service"; +import { AutomationsController } from "./automations.controller"; +import { AutomationsService } from "./automations.service"; +import { AutomationsHandler } from "./handlers/automations-handler"; +import { AutomationLogsRepository } from "./repositories/automation-logs/automation-logs"; +import { AutomationStepsRepository } from "./repositories/automation-steps/automation-steps.repository"; +import { AutomationsRepository } from "./repositories/automations/automations.repository"; + +@Module({ + imports: [ + forwardRef(() => UserModule), + forwardRef(() => CourseModule), + forwardRef(() => AnnouncementsModule), + forwardRef(() => CourseChatModule), + EmailNotificationTemplatesModule, + EmailModule, + SettingsModule, + ], + providers: [ + AutomationsRepository, + AutomationsService, + AutomationStepsService, + AutomationStepsRepository, + AutomationsHandler, + AutomationRunnerService, + AutomationDataResolverService, + AutomationTemplateService, + AutomationSimulationService, + AutomationSystemTemplatePreviewService, + AutomationSystemTemplateRendererService, + AutomationLogsRepository, + AutomationsSeedDefaultsService, + ], + controllers: [AutomationsController, AutomationStepsController, AutomationLogsController], + exports: [AutomationsService, AutomationRunnerService, AutomationStepsService], +}) +export class AutomationsModule {} diff --git a/apps/api/src/automations/automations.service.spec.ts b/apps/api/src/automations/automations.service.spec.ts new file mode 100644 index 0000000000..503090b2ba --- /dev/null +++ b/apps/api/src/automations/automations.service.spec.ts @@ -0,0 +1,164 @@ +import { BadRequestException } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import { AutomationStatus } from "src/announcements/types/automations.types"; + +import { AutomationsService } from "./automations.service"; +import { AutomationsRepository } from "./repositories/automations/automations.repository"; + +import type { TestingModule } from "@nestjs/testing"; +import type { UUIDType } from "src/common"; + +describe("AutomationsService", () => { + let service: AutomationsService; + let repository: jest.Mocked; + + const tenantId = "tenant-1" as UUIDType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationsService, + { + provide: AutomationsRepository, + useValue: { + createAutomation: jest.fn(), + getAllAutomationsByTenantId: jest.fn(), + getAutomationById: jest.fn(), + updateAutomation: jest.fn(), + changeStatus: jest.fn(), + deleteAutomation: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AutomationsService); + repository = module.get(AutomationsRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("createAutomation", () => { + it("delegates creation to repository and returns result", async () => { + const input = { + tenantId, + name: { en: "Test Automation" }, + description: { en: "Test description" }, + status: AutomationStatus.Draft, + }; + const createdRecord = { id: "auto-1", ...input, createdAt: new Date() }; + repository.createAutomation.mockResolvedValue(createdRecord as any); + + const result = await service.createAutomation(input); + + expect(repository.createAutomation).toHaveBeenCalledWith(input); + expect(result).toEqual(createdRecord); + }); + }); + + describe("getAllAutomations", () => { + it("returns all automations for given tenant", async () => { + const automations = [ + { id: "auto-1", name: { en: "First" }, status: "draft" }, + { id: "auto-2", name: { en: "Second" }, status: "enabled" }, + ]; + repository.getAllAutomationsByTenantId.mockResolvedValue(automations as any); + + const result = await service.getAllAutomations(tenantId); + + expect(repository.getAllAutomationsByTenantId).toHaveBeenCalledWith(tenantId); + expect(result).toEqual(automations); + }); + + it("returns empty array when no automations exist", async () => { + repository.getAllAutomationsByTenantId.mockResolvedValue([]); + + const result = await service.getAllAutomations(tenantId); + + expect(result).toEqual([]); + }); + }); + + describe("getAutomationById", () => { + it("returns automation when found", async () => { + const automation = { id: "auto-1", name: { en: "Found" }, status: "draft" }; + repository.getAutomationById.mockResolvedValue(automation as any); + + const result = await service.getAutomationById("auto-1" as UUIDType); + + expect(result).toEqual(automation); + }); + + it("throws BadRequestException when automation not found", async () => { + repository.getAutomationById.mockResolvedValue(undefined as any); + + await expect(service.getAutomationById("missing" as UUIDType)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe("updateAutomation", () => { + it("returns updated id on success", async () => { + repository.updateAutomation.mockResolvedValue("auto-1" as UUIDType); + + const input = { name: { en: "Updated Name" } }; + const result = await service.updateAutomation("auto-1" as UUIDType, input); + + expect(repository.updateAutomation).toHaveBeenCalledWith("auto-1", input); + expect(result).toBe("auto-1"); + }); + + it("throws BadRequestException when update returns null", async () => { + repository.updateAutomation.mockResolvedValue(null as any); + + const input = { name: { en: "Updated" } }; + + await expect(service.updateAutomation("auto-1" as UUIDType, input)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe("updateStatus", () => { + it("returns updated id on success", async () => { + repository.changeStatus.mockResolvedValue("auto-1" as UUIDType); + + const result = await service.updateStatus("auto-1" as UUIDType, AutomationStatus.Enabled); + + expect(repository.changeStatus).toHaveBeenCalledWith("auto-1", AutomationStatus.Enabled); + expect(result).toBe("auto-1"); + }); + + it("throws BadRequestException when status change returns null", async () => { + repository.changeStatus.mockResolvedValue(null as any); + + await expect( + service.updateStatus("auto-1" as UUIDType, AutomationStatus.Enabled), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe("deleteAutomation", () => { + it("returns deleted record on success", async () => { + const deletedRecord = { id: "auto-1", name: { en: "Deleted" } }; + repository.deleteAutomation.mockResolvedValue(deletedRecord as any); + + const result = await service.deleteAutomation("auto-1" as UUIDType); + + expect(repository.deleteAutomation).toHaveBeenCalledWith("auto-1"); + expect(result).toEqual(deletedRecord); + }); + + it("throws BadRequestException when delete returns null", async () => { + repository.deleteAutomation.mockResolvedValue(null as any); + + await expect(service.deleteAutomation("auto-1" as UUIDType)).rejects.toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts new file mode 100644 index 0000000000..558432ea8f --- /dev/null +++ b/apps/api/src/automations/automations.service.ts @@ -0,0 +1,57 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; + +import { AutomationsRepository } from "./repositories/automations/automations.repository"; + +import type { + AutomationRecordInput, + AutomationRecordUpdateInput, +} from "src/announcements/types/automations-source.types"; +import type { AutomationStatus } from "src/announcements/types/automations.types"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class AutomationsService { + constructor(private readonly automationsRepository: AutomationsRepository) {} + + async createAutomation(input: AutomationRecordInput) { + return this.automationsRepository.createAutomation(input); + } + + async getAllAutomations(tenantId: UUIDType) { + return this.automationsRepository.getAllAutomationsByTenantId(tenantId); + } + + async getAutomationById(automationId: UUIDType) { + const automation = await this.automationsRepository.getAutomationById(automationId); + + if (!automation) { + throw new BadRequestException("Automation not found"); + } + + return automation; + } + + async updateAutomation(automationId: UUIDType, input: AutomationRecordUpdateInput) { + const updatedId = await this.automationsRepository.updateAutomation(automationId, input); + if (!updatedId) { + throw new BadRequestException("Couldn't update the automation"); + } + return updatedId; + } + + async updateStatus(automationId: UUIDType, status: AutomationStatus) { + const updatedId = await this.automationsRepository.changeStatus(automationId, status); + if (!updatedId) { + throw new BadRequestException("Couldn't change the status of automation"); + } + return updatedId; + } + + async deleteAutomation(automationId: UUIDType) { + const deletedId = await this.automationsRepository.deleteAutomation(automationId); + if (!deletedId) { + throw new BadRequestException("Error while deleting the automation"); + } + return deletedId; + } +} diff --git a/apps/api/src/automations/handlers/automations-handler.spec.ts b/apps/api/src/automations/handlers/automations-handler.spec.ts new file mode 100644 index 0000000000..6dc06ae011 --- /dev/null +++ b/apps/api/src/automations/handlers/automations-handler.spec.ts @@ -0,0 +1,125 @@ +import { Test } from "@nestjs/testing"; + +import { AutomationEventNames } from "src/announcements/types/automations.types"; +import { UserInviteEvent, UserWelcomeEvent } from "src/events"; + +import { AutomationRunnerService } from "../automation-runner/automation-runner.service"; +import { AutomationStepsRepository } from "../repositories/automation-steps/automation-steps.repository"; + +import { AutomationsHandler } from "./automations-handler"; + +import type { TestingModule } from "@nestjs/testing"; +import type { UUIDType } from "src/common"; + +describe("AutomationsHandler", () => { + let handler: AutomationsHandler; + let stepsRepository: jest.Mocked; + let runnerService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationsHandler, + { + provide: AutomationStepsRepository, + useValue: { + findAutomationTriggerToRun: jest.fn(), + }, + }, + { + provide: AutomationRunnerService, + useValue: { + startAutomation: jest.fn(), + }, + }, + ], + }).compile(); + + handler = module.get(AutomationsHandler); + stepsRepository = module.get(AutomationStepsRepository); + runnerService = module.get(AutomationRunnerService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(handler).toBeDefined(); + }); + + it("resolves event name from AutomationEventNames mapping", async () => { + stepsRepository.findAutomationTriggerToRun.mockResolvedValue([]); + + const event = new UserInviteEvent({ + email: "test@example.com", + userId: "user-1" as UUIDType, + tenantId: "tenant-1" as UUIDType, + token: "token-123", + invitedByUserName: "Admin", + } as any); + + await handler.handle(event); + + const expectedEventName = AutomationEventNames[UserInviteEvent.name]; + expect(stepsRepository.findAutomationTriggerToRun).toHaveBeenCalledWith(expectedEventName); + }); + + it("does nothing when no triggers are found", async () => { + stepsRepository.findAutomationTriggerToRun.mockResolvedValue([]); + + const event = new UserWelcomeEvent({ + email: "test@example.com", + userId: "user-1" as UUIDType, + tenantId: "tenant-1" as UUIDType, + origin: "https://app.mentingo.com", + } as any); + + await handler.handle(event); + + expect(runnerService.startAutomation).not.toHaveBeenCalled(); + }); + + it("starts automation for each unique automation id found", async () => { + const triggers = [ + { automationId: "auto-1" as UUIDType }, + { automationId: "auto-2" as UUIDType }, + ]; + stepsRepository.findAutomationTriggerToRun.mockResolvedValue(triggers as any); + + const event = new UserInviteEvent({ + email: "test@example.com", + userId: "user-1" as UUIDType, + tenantId: "tenant-1" as UUIDType, + token: "token-123", + invitedByUserName: "Admin", + } as any); + + await handler.handle(event); + + expect(runnerService.startAutomation).toHaveBeenCalledTimes(2); + expect(runnerService.startAutomation).toHaveBeenCalledWith("auto-1", event); + expect(runnerService.startAutomation).toHaveBeenCalledWith("auto-2", event); + }); + + it("deduplicates automation ids when multiple triggers belong to same automation", async () => { + const triggers = [ + { automationId: "auto-1" as UUIDType }, + { automationId: "auto-1" as UUIDType }, + { automationId: "auto-2" as UUIDType }, + ]; + stepsRepository.findAutomationTriggerToRun.mockResolvedValue(triggers as any); + + const event = new UserInviteEvent({ + email: "test@example.com", + userId: "user-1" as UUIDType, + tenantId: "tenant-1" as UUIDType, + token: "token-123", + invitedByUserName: "Admin", + } as any); + + await handler.handle(event); + + expect(runnerService.startAutomation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/automations/handlers/automations-handler.ts b/apps/api/src/automations/handlers/automations-handler.ts new file mode 100644 index 0000000000..765deaad3d --- /dev/null +++ b/apps/api/src/automations/handlers/automations-handler.ts @@ -0,0 +1,89 @@ +import { Injectable } from "@nestjs/common"; +import { EventsHandler } from "@nestjs/cqrs"; + +import { AutomationEventNames } from "src/announcements/types/automations.types"; +import { + AnnouncementPublishedEvent, + CertificateArchivedEmailEvent, + CertificateExpirationWarningEmailEvent, + CourseChatUserMentionedEvent, + CourseCompletedEvent, + CourseDueDateReminderEmailEvent, + UserChapterFinishedEvent, + UserCourseFinishedEvent, + UserFirstLoginEvent, + UserInviteEvent, + UserPasswordCreatedEvent, + UserPasswordReminderEvent, + UserRegisteredEvent, + UsersAssignedToCourseEvent, + UsersImportInviteEmailsEvent, + UsersLongInactivityEvent, + UsersShortInactivityEvent, + UserWelcomeEvent, +} from "src/events"; + +import { AutomationRunnerService } from "../automation-runner/automation-runner.service"; +import { AutomationStepsRepository } from "../repositories/automation-steps/automation-steps.repository"; + +import type { IEventHandler } from "@nestjs/cqrs"; + +export type AutomationEventTypes = + | UserInviteEvent + | UsersImportInviteEmailsEvent + | UserPasswordReminderEvent + | UserWelcomeEvent + | UserFirstLoginEvent + | UsersAssignedToCourseEvent + | UsersShortInactivityEvent + | UsersLongInactivityEvent + | UserChapterFinishedEvent + | UserCourseFinishedEvent + | UserRegisteredEvent + | UserPasswordCreatedEvent + | CourseCompletedEvent + | CertificateExpirationWarningEmailEvent + | CertificateArchivedEmailEvent + | AnnouncementPublishedEvent + | CourseChatUserMentionedEvent + | CourseDueDateReminderEmailEvent; + +export const AutomationEvents = [ + UserInviteEvent, + UsersImportInviteEmailsEvent, + UserPasswordReminderEvent, + UserWelcomeEvent, + UserFirstLoginEvent, + UsersAssignedToCourseEvent, + UsersShortInactivityEvent, + UsersLongInactivityEvent, + UserChapterFinishedEvent, + UserCourseFinishedEvent, + UserRegisteredEvent, + UserPasswordCreatedEvent, + CourseCompletedEvent, + CertificateExpirationWarningEmailEvent, + CertificateArchivedEmailEvent, + AnnouncementPublishedEvent, + CourseChatUserMentionedEvent, + CourseDueDateReminderEmailEvent, +] as const; + +@Injectable() +@EventsHandler(...AutomationEvents) +export class AutomationsHandler implements IEventHandler { + constructor( + private readonly automationStepsRepository: AutomationStepsRepository, + private readonly automationRunnerService: AutomationRunnerService, + ) {} + async handle(event: AutomationEventTypes) { + const eventName = AutomationEventNames[event.constructor.name]; + const triggers = await this.automationStepsRepository.findAutomationTriggerToRun(eventName); + const automationIds = triggers.map((step) => step.automationId); + const uniqueAutomationIds = [...new Set(automationIds)]; + + for (const automationId of uniqueAutomationIds) { + await this.automationRunnerService.startAutomation(automationId, event); + } + } +} diff --git a/apps/api/src/automations/repositories/automation-logs/automation-logs.spec.ts b/apps/api/src/automations/repositories/automation-logs/automation-logs.spec.ts new file mode 100644 index 0000000000..5c91146c3c --- /dev/null +++ b/apps/api/src/automations/repositories/automation-logs/automation-logs.spec.ts @@ -0,0 +1,40 @@ +import { Test } from "@nestjs/testing"; + +import { DB } from "src/storage/db/db.providers"; + +import { AutomationLogsRepository } from "./automation-logs"; + +import type { TestingModule } from "@nestjs/testing"; + +describe("AutomationLogsRepository", () => { + let repository: AutomationLogsRepository; + + const dbMock = { + insert: jest.fn(), + select: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationLogsRepository, + { + provide: DB, + useValue: dbMock, + }, + ], + }).compile(); + + repository = module.get(AutomationLogsRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(repository).toBeDefined(); + }); +}); diff --git a/apps/api/src/automations/repositories/automation-logs/automation-logs.ts b/apps/api/src/automations/repositories/automation-logs/automation-logs.ts new file mode 100644 index 0000000000..6d73b70900 --- /dev/null +++ b/apps/api/src/automations/repositories/automation-logs/automation-logs.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { eq } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { DB } from "src/storage/db/db.providers"; +import { automationLogs } from "src/storage/schema"; + +import type { AutomationLogRecordInput } from "src/announcements/types/automations-source.types"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class AutomationLogsRepository { + constructor(@Inject(DB) private readonly db: DatabasePg) {} + + async create(input: AutomationLogRecordInput) { + const [log] = await this.db + .insert(automationLogs) + .values({ + automationId: input.automationId, + automationName: input.automationName, + eventName: input.eventName, + status: input.status, + emailAddresses: input.emailAddresses, + errorName: input.errorName, + }) + .returning(); + + return log; + } + + async getById(id: UUIDType) { + const [log] = await this.db.select().from(automationLogs).where(eq(automationLogs.id, id)); + + return log; + } + + async getAll() { + return this.db.select().from(automationLogs); + } + + async GetByAutomationId(automationId: UUIDType) { + return this.db + .select() + .from(automationLogs) + .where(eq(automationLogs.automationId, automationId)); + } +} diff --git a/apps/api/src/automations/repositories/automation-steps/automation-steps.repository.ts b/apps/api/src/automations/repositories/automation-steps/automation-steps.repository.ts new file mode 100644 index 0000000000..985379dc74 --- /dev/null +++ b/apps/api/src/automations/repositories/automation-steps/automation-steps.repository.ts @@ -0,0 +1,103 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { and, eq, getTableColumns, sql } from "drizzle-orm"; + +import { AutomationStatus } from "src/announcements/types/automations.types"; +import { DatabasePg } from "src/common"; +import { DB } from "src/storage/db/db.providers"; +import { automations, automationSteps } from "src/storage/schema"; +import { toJsonbBuildObject } from "src/utils/jsonb"; + +import type { + AutomationStepBulkUpdate, + AutomationStepRecordInput, +} from "src/announcements/types/automations-source.types"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class AutomationStepsRepository { + constructor(@Inject(DB) private readonly db: DatabasePg) {} + + async createAutomationStep(input: AutomationStepRecordInput) { + const [createdStep] = await this.db + .insert(automationSteps) + .values({ + parentId: input.parentId, + automationId: input.automationId, + type: input.type, + typeContext: toJsonbBuildObject(input.typeContext), + }) + .returning(); + + return createdStep.id; + } + + async getAllAutomationStepsByAutomationId(automationId: UUIDType) { + return this.db + .select() + .from(automationSteps) + .where(eq(automationSteps.automationId, automationId)); + } + + async getAutomationStepById(stepId: UUIDType) { + const [step] = await this.db + .select() + .from(automationSteps) + .where(eq(automationSteps.id, stepId)); + + return step; + } + + async updateAutomationStep(stepId: UUIDType, input: AutomationStepRecordInput) { + const [updatedStep] = await this.db + .update(automationSteps) + .set({ + parentId: input.parentId, + automationId: input.automationId, + type: input.type, + typeContext: toJsonbBuildObject(input.typeContext), + }) + .where(eq(automationSteps.id, stepId)) + .returning(); + + return updatedStep?.id; + } + + async deleteAutomationStep(stepId: UUIDType) { + const [deletedStep] = await this.db + .delete(automationSteps) + .where(eq(automationSteps.id, stepId)) + .returning(); + + return deletedStep?.id; + } + + async replaceAutomationStepTree(automationId: UUIDType, steps: AutomationStepBulkUpdate[]) { + return this.db.transaction(async (tx) => { + await tx.delete(automationSteps).where(eq(automationSteps.automationId, automationId)); + + await tx.insert(automationSteps).values( + steps.map((step) => ({ + id: step.id, + parentId: step.parentId, + automationId, + type: step.type, + typeContext: toJsonbBuildObject(step.typeContext), + })), + ); + return true; + }); + } + + async findAutomationTriggerToRun(triggerName: string) { + return this.db + .select({ ...getTableColumns(automationSteps) }) + .from(automationSteps) + .innerJoin(automations, eq(automationSteps.automationId, automations.id)) + .where( + and( + sql`${automationSteps.typeContext} ->> 'name' LIKE ${triggerName}`, + eq(automations.status, AutomationStatus.Enabled), + ), + ); + } +} diff --git a/apps/api/src/automations/repositories/automations/automations.repository.ts b/apps/api/src/automations/repositories/automations/automations.repository.ts new file mode 100644 index 0000000000..6186dea460 --- /dev/null +++ b/apps/api/src/automations/repositories/automations/automations.repository.ts @@ -0,0 +1,98 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { eq, sql } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { DB } from "src/storage/db/db.providers"; +import { automationLogs, automations } from "src/storage/schema"; + +import type { + AutomationRecordInput, + AutomationRecordUpdateInput, +} from "src/announcements/types/automations-source.types"; +import type { AutomationStatus } from "src/announcements/types/automations.types"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class AutomationsRepository { + constructor(@Inject(DB) private readonly db: DatabasePg) {} + + async getAllAutomationsByTenantId(tenantId: UUIDType) { + return this.db + .select({ + id: automations.id, + name: automations.name, + description: automations.description, + status: automations.status, + lastRun: sql`max("automation_logs"."created_at")`.mapWith((value) => + value ? new Date(value).toISOString() : null, + ), + createdAt: automations.createdAt, + updatedAt: automations.updatedAt, + }) + .from(automations) + .leftJoin(automationLogs, eq(automationLogs.automationId, automations.id)) + .where(eq(automations.tenantId, tenantId)) + .groupBy( + automations.id, + automations.name, + automations.description, + automations.status, + automations.createdAt, + automations.updatedAt, + ); + } + + async getAutomationById(automationId: UUIDType) { + const [automation] = await this.db + .select() + .from(automations) + .where(eq(automations.id, automationId)); + return automation; + } + async createAutomation(input: AutomationRecordInput) { + const [automation] = await this.db + .insert(automations) + .values({ + name: input.name, + description: input.description, + status: input.status, + }) + .returning(); + + return automation; + } + async updateAutomation(automationId: UUIDType, input: AutomationRecordUpdateInput) { + const setFields: Record = {}; + if (input.name !== undefined) setFields.name = input.name; + if (input.description !== undefined) setFields.description = input.description; + if (input.status !== undefined) setFields.status = input.status; + + if (Object.keys(setFields).length === 0) { + return automationId; + } + + const [updated] = await this.db + .update(automations) + .set(setFields) + .where(eq(automations.id, automationId)) + .returning(); + return updated.id; + } + + async changeStatus(automationId: UUIDType, status: AutomationStatus) { + const [updated] = await this.db + .update(automations) + .set({ status: status }) + .where(eq(automations.id, automationId)) + .returning(); + return updated.id; + } + + async deleteAutomation(automationId: UUIDType) { + const [deleted] = await this.db + .delete(automations) + .where(eq(automations.id, automationId)) + .returning(); + return deleted; + } +} diff --git a/apps/api/src/certificates/handlers/certificate-email.handler.ts b/apps/api/src/certificates/handlers/certificate-email.handler.ts index 0f22a0fa93..6e448e8f05 100644 --- a/apps/api/src/certificates/handlers/certificate-email.handler.ts +++ b/apps/api/src/certificates/handlers/certificate-email.handler.ts @@ -56,12 +56,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..5ee9fdf315 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,7 +65,7 @@ export class EmailService { filename: "border-circle.png", content: borderCircleBuffer, contentType: "image/png", - ...(this.usingMailhogAdapter ? {} : { cid: "border-circle" }), + cid: "border-circle", }); } @@ -92,7 +87,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 +101,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..1498b185b5 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 @@ -108,7 +108,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 +120,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 2258c8062c..c740758d66 100644 --- a/apps/api/src/courses/course.service.ts +++ b/apps/api/src/courses/course.service.ts @@ -4725,11 +4725,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..134cf17ae4 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 @@ -60,13 +60,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..e7311e3d9d --- /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 service: 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.service.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..4dc0461c74 --- /dev/null +++ b/apps/api/src/courses/public-course-thumbnail.service.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { and, eq } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { FileService } from "src/file/file.service"; +import { DB_ADMIN } from "src/storage/db/db.providers"; +import { courses } from "src/storage/schema"; + +import type { UUIDType } from "src/common"; + +@Injectable() +export class PublicCourseThumbnailService { + constructor( + @Inject(DB_ADMIN) private readonly dbAdmin: DatabasePg, + private readonly fileService: FileService, + ) {} + + async resolveSignedUrl(courseId: UUIDType, tenantId: UUIDType): Promise { + const [course] = await this.dbAdmin + .select({ thumbnailS3Key: courses.thumbnailS3Key }) + .from(courses) + .where(and(eq(courses.id, courseId), eq(courses.tenantId, tenantId))) + .limit(1); + + 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..9cccc71ebb --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/email-notification-templates.service.spec.ts @@ -0,0 +1,924 @@ +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 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(), + findReferencedImageKeys: fn(), + findMaxAutoTemplateNumber: fn(), + }; + return r; +}; + +const makeImageService = () => ({ deleteByKey: 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 imageService = makeImageService(); + const emailService = makeEmailService(); + const settingsService = makeSettingsService(); + const cleanupQueue = makeCleanupQueue(); + cleanupQueue.enqueueImageCleanup.mockResolvedValue(undefined); + const service = new EmailNotificationTemplatesService( + repository as never, + imageService as never, + emailService as never, + settingsService as never, + cleanupQueue as never, + ); + return { service, repository, imageService, 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.findMaxAutoTemplateNumber.mockResolvedValue(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("recomputes max and retries on unique-violation", async () => { + const { service, repository } = createService(); + repository.findMaxAutoTemplateNumber.mockResolvedValueOnce(4).mockResolvedValueOnce(5); + repository.createTemplate + .mockRejectedValueOnce(uniqueViolation()) + .mockResolvedValueOnce(makeTemplate({ name: "Email template #6" })); + + const result = await service.createTemplate(autoNameInput); + + expect(repository.createTemplate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: "Email template #5" }), + ); + expect(repository.createTemplate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: "Email template #6" }), + ); + expect(result.name).toBe("Email template #6"); + }); + + it("rethrows unique-violation errors when no constraint field is present", async () => { + const { service, repository } = createService(); + repository.findMaxAutoTemplateNumber.mockResolvedValueOnce(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 after 5 unsuccessful attempts", async () => { + const { service, repository } = createService(); + repository.findMaxAutoTemplateNumber.mockResolvedValue(4); + repository.createTemplate.mockRejectedValue(uniqueViolation()); + + await expect(service.createTemplate(autoNameInput)).rejects.toThrow( + new ConflictException("emailTemplates.toast.nameAlreadyExists"), + ); + expect(repository.createTemplate).toHaveBeenCalledTimes(5); + }); + + it("rethrows non-unique errors without retrying", async () => { + const { service, repository } = createService(); + repository.findMaxAutoTemplateNumber.mockResolvedValue(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()); + repository.findReferencedImageKeys.mockResolvedValue(new Set()); + + 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, imageService } = 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.findReferencedImageKeys.mockResolvedValue(new Set([keptKey])); + imageService.deleteByKey.mockResolvedValue(undefined); + + await service.purgeOrphanedImages({ + tenantId: TENANT_ID, + srcs: [removedSrc, keptSrc, removedSrc], + excludeTemplateId: TEMPLATE_ID, + }); + + expect(repository.findReferencedImageKeys).toHaveBeenCalledWith( + [removedKey, keptKey], + TENANT_ID, + TEMPLATE_ID, + ); + expect(imageService.deleteByKey).toHaveBeenCalledWith(removedKey); + expect(imageService.deleteByKey).not.toHaveBeenCalledWith(keptKey); + }); + + it("does not delete extracted keys outside the current tenant email template image category", async () => { + const { service, repository, imageService } = 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.findReferencedImageKeys.mockResolvedValue(new Set()); + imageService.deleteByKey.mockResolvedValue(undefined); + + await service.purgeOrphanedImages({ + tenantId: TENANT_ID, + srcs: [safeSrc, differentTenantSrc, differentCategorySrc], + excludeTemplateId: TEMPLATE_ID, + }); + + expect(imageService.deleteByKey).toHaveBeenCalledTimes(1); + expect(imageService.deleteByKey).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, imageService } = createService(); + const key = `${TENANT_ID}/email_template_image/current.webp`; + const craftedSrc = `https://external.test/api/public/email-template-image/${encodeURIComponent( + key, + )}`; + repository.findReferencedImageKeys.mockResolvedValue(new Set([key])); + imageService.deleteByKey.mockResolvedValue(undefined); + + await service.purgeOrphanedImages({ + tenantId: TENANT_ID, + srcs: [craftedSrc], + excludeTemplateId: TEMPLATE_ID, + }); + + expect(repository.findReferencedImageKeys).toHaveBeenCalledWith([key], TENANT_ID, TEMPLATE_ID); + expect(imageService.deleteByKey).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..1fa8c69443 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/email-template-image.controller.e2e-spec.ts @@ -0,0 +1,138 @@ +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"; + +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__/email-template-image.service.spec.ts b/apps/api/src/email-notification-templates/__tests__/email-template-image.service.spec.ts new file mode 100644 index 0000000000..88f72eed03 --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/email-template-image.service.spec.ts @@ -0,0 +1,75 @@ +import { RESOURCE_CATEGORIES } from "src/file/file.constants"; + +import { EmailTemplateImageService } from "../email-template-image.service"; + +import type { CurrentUserType } from "src/common/types/current-user.type"; +import type { FileService } from "src/file/file.service"; + +const TENANT_ID = "11111111-1111-1111-1111-111111111111"; + +const makeCurrentUser = (): CurrentUserType => ({ + userId: "22222222-2222-2222-2222-222222222222", + email: "admin@example.com", + roleSlugs: ["admin"], + permissions: [], + tenantId: TENANT_ID, +}); + +const makeFile = (): Express.Multer.File => + ({ + fieldname: "file", + originalname: "photo.png", + encoding: "7bit", + mimetype: "image/png", + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + size: 4, + }) as Express.Multer.File; + +describe("EmailTemplateImageService", () => { + const createService = () => { + const uploadFile = jest.fn(); + const fileService = { uploadFile } as unknown as FileService; + const service = new EmailTemplateImageService(fileService); + return { service, uploadFile }; + }; + + it("delegates to FileService.uploadFile with EMAIL_TEMPLATE_IMAGE category", async () => { + const { service, uploadFile } = createService(); + const expectedFileKey = `${TENANT_ID}/email_template_image/variants/uuid.webp`; + uploadFile.mockResolvedValue({ fileKey: expectedFileKey, fileUrl: "https://s3.example.com/…" }); + + const result = await service.uploadForTenant(makeFile(), makeCurrentUser()); + + expect(uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ mimetype: "image/png" }), + RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE, + TENANT_ID, + { skipVariants: true }, + ); + expect(result.reference).toBe(expectedFileKey); + }); + + it("returns the fileKey from FileService as reference", async () => { + const { service, uploadFile } = createService(); + const fileKey = `${TENANT_ID}/email_template_image/variants/abc123.webp`; + uploadFile.mockResolvedValue({ fileKey, fileUrl: "https://s3.example.com/abc123.webp" }); + + const { reference } = await service.uploadForTenant(makeFile(), makeCurrentUser()); + + expect(reference).toBe(fileKey); + }); + + it("returns a reference prefixed with the tenant id", async () => { + const { service, uploadFile } = createService(); + uploadFile.mockImplementation((_file, _category, tenantId: string) => + Promise.resolve({ + fileKey: `${tenantId}/email_template_image/variants/uuid.webp`, + fileUrl: "https://s3.example.com/uuid.webp", + }), + ); + + const result = await service.uploadForTenant(makeFile(), makeCurrentUser()); + + expect(result.reference.startsWith(`${TENANT_ID}/email_template_image/`)).toBe(true); + }); +}); diff --git a/apps/api/src/email-notification-templates/__tests__/email-templates.repository.spec.ts b/apps/api/src/email-notification-templates/__tests__/email-templates.repository.spec.ts new file mode 100644 index 0000000000..2232c6dc3b --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/email-templates.repository.spec.ts @@ -0,0 +1,60 @@ +import { EMAIL_TEMPLATE_NODE_TYPES } from "@repo/shared"; + +import { EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH } from "../email-template-image.constants"; +import { EmailNotificationTemplatesRepository } from "../email-templates.repository"; + +import type { EmailTemplateBlocks } from "@repo/shared"; + +const TENANT_ID = "22222222-2222-2222-2222-222222222222"; +const TEMPLATE_ID = "11111111-1111-1111-1111-111111111111"; + +const imageBlocks = (src: string): EmailTemplateBlocks => ({ + type: EMAIL_TEMPLATE_NODE_TYPES.DOC, + content: [ + { + type: EMAIL_TEMPLATE_NODE_TYPES.IMAGE, + attrs: { src }, + }, + ], +}); + +const createRepository = (rows: Array<{ blocks: EmailTemplateBlocks }>) => { + const where = jest.fn().mockResolvedValue(rows); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const repository = new EmailNotificationTemplatesRepository({ select } as never); + + return { repository, select, from, where }; +}; + +describe("EmailNotificationTemplatesRepository — findReferencedImageKeys", () => { + it("returns requested keys found in stored block image URLs by canonical key", async () => { + const key = `${TENANT_ID}/email_template_image/current.webp`; + const storedSrc = `https://tenant.test${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${encodeURIComponent( + key, + )}`; + const { repository } = createRepository([{ blocks: imageBlocks(storedSrc) }]); + + const result = await repository.findReferencedImageKeys([key], TENANT_ID, TEMPLATE_ID); + + expect(result).toEqual(new Set([key])); + }); + + it("does not return keys from another tenant or category", async () => { + const key = `${TENANT_ID}/email_template_image/current.webp`; + const otherTenantSrc = `https://tenant.test${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${encodeURIComponent( + "99999999-9999-9999-9999-999999999999/email_template_image/current.webp", + )}`; + const otherCategorySrc = `https://tenant.test${EMAIL_TEMPLATE_IMAGE_PUBLIC_PATH}${encodeURIComponent( + `${TENANT_ID}/course/current.webp`, + )}`; + const { repository } = createRepository([ + { blocks: imageBlocks(otherTenantSrc) }, + { blocks: imageBlocks(otherCategorySrc) }, + ]); + + const result = await repository.findReferencedImageKeys([key], TENANT_ID, TEMPLATE_ID); + + expect(result).toEqual(new Set()); + }); +}); 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..5f2c561f0f --- /dev/null +++ b/apps/api/src/email-notification-templates/__tests__/emailTemplateImageUrl.spec.ts @@ -0,0 +1,135 @@ +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..8ae5287231 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-template-image.controller.ts @@ -0,0 +1,133 @@ +import { + BadRequestException, + Controller, + ForbiddenException, + 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 { TenantResolverService } from "src/storage/db/tenant-resolver.service"; + +import { EmailTemplateImageService } from "./email-template-image.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 service: EmailTemplateImageService, + 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() file: Express.Multer.File, + @CurrentUser() currentUser: CurrentUserType, + @Req() req: Request, + ): Promise> { + validateEmailTemplateImage(file); + + const tenantHost = await this.tenantResolver.resolveTenantHost(req); + if (!tenantHost) throw new ForbiddenException("tenant.error.unresolved"); + + const { reference } = await this.service.uploadForTenant(file, currentUser); + const url = buildEmailTemplateImageUrl({ tenantHost, reference }); + + return new BaseResponse({ url }); + } +} + +const validateEmailTemplateImage = (file?: Express.Multer.File): void => { + if (!file?.buffer?.length) throw new BadRequestException("files.toast.invalidData"); + + if (!file.size || file.size > FILE_SIZE_BASE) { + throw new BadRequestException( + `File size exceeds the maximum allowed size of ${FILE_SIZE_BASE} bytes`, + ); + } + + const resolvedMime = detectImageMimeType(file.buffer); + const providedMime = normalizeMime(file.mimetype); + + if (!resolvedMime || !ALLOWED_LESSON_IMAGE_FILE_TYPES.includes(resolvedMime)) { + throw new BadRequestException("files.toast.invalidFileType"); + } + + if (providedMime && resolvedMime !== providedMime) { + throw new BadRequestException("files.toast.contentTypeMismatch"); + } +}; + +const normalizeMime = (mime?: string): string | undefined => { + if (!mime) return undefined; + if (mime === "image/jpg") return "image/jpeg"; + return mime; +}; + +const detectImageMimeType = (buffer: Buffer): string | undefined => { + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return "image/png"; + } + + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + + const signature = buffer.subarray(0, 6).toString("ascii"); + if (signature === "GIF87a" || signature === "GIF89a") { + return "image/gif"; + } + + if ( + buffer.subarray(0, 4).toString("ascii") === "RIFF" && + buffer.subarray(8, 12).toString("ascii") === "WEBP" + ) { + return "image/webp"; + } + + if (buffer.subarray(0, 2).toString("ascii") === "BM") { + return "image/bmp"; + } + + if ( + buffer.subarray(0, 4).equals(Buffer.from([0x49, 0x49, 0x2a, 0x00])) || + buffer.subarray(0, 4).equals(Buffer.from([0x4d, 0x4d, 0x00, 0x2a])) + ) { + return "image/tiff"; + } + + return undefined; +}; diff --git a/apps/api/src/email-notification-templates/email-template-image.service.ts b/apps/api/src/email-notification-templates/email-template-image.service.ts new file mode 100644 index 0000000000..c8040f1ad1 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-template-image.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common"; + +import { RESOURCE_CATEGORIES } from "src/file/file.constants"; +import { FileService } from "src/file/file.service"; + +import type { CurrentUserType } from "src/common/types/current-user.type"; + +@Injectable() +export class EmailTemplateImageService { + constructor(private readonly fileService: FileService) {} + + async uploadForTenant(file: Express.Multer.File, currentUser: CurrentUserType) { + const result = await this.fileService.uploadFile( + file, + RESOURCE_CATEGORIES.EMAIL_TEMPLATE_IMAGE, + currentUser.tenantId, + { skipVariants: true }, + ); + return { reference: result.fileKey }; + } + + async deleteByKey(key: string): Promise { + await this.fileService.deleteFile(key); + } +} 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..ec47cd10a9 --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.module.ts @@ -0,0 +1,28 @@ +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 { EmailTemplateImageService } from "./email-template-image.service"; +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, + EmailTemplateImageService, + 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..d78cc8e13b --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.repository.ts @@ -0,0 +1,228 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { and, count, desc, eq, ilike, inArray, ne, sql, 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 { buildDefaultEmailTemplateBlocks } from "./utils/buildDefaultEmailTemplateBlocks"; +import { + collectImageSrcs, + extractTenantEmailTemplateImageFileKeyFromUrl, +} from "./utils/emailTemplateImageUrl"; + +import type { CreateEmailNotificationTemplate } from "./schemas/createEmailNotificationTemplate.schema"; +import type { UpdateEmailNotificationTemplate } from "./schemas/updateEmailNotificationTemplate.schema"; +import type { EmailTemplateBlocks, EmailTemplateStatus, EmailTemplateStrings } from "@repo/shared"; +import type { UUIDType } from "src/common"; + +@Injectable() +export class EmailNotificationTemplatesRepository { + constructor(@Inject(DB) private readonly db: DatabasePg) {} + + async listTemplates( + pagination: { page: number; perPage: number }, + filters: { status?: EmailTemplateStatus; name?: string }, + ) { + const conditions: SQL[] = []; + if (filters.status) conditions.push(eq(emailNotificationTemplates.status, filters.status)); + if (filters.name) conditions.push(ilike(emailNotificationTemplates.name, `%${filters.name}%`)); + + 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[]) { + const rows = await this.db + .delete(emailNotificationTemplates) + .where(inArray(emailNotificationTemplates.id, ids)) + .returning({ id: emailNotificationTemplates.id }); + + return rows; + } + + async createTemplate(input: CreateEmailNotificationTemplate & { name: string }) { + const [row] = await this.db + .insert(emailNotificationTemplates) + .values({ + name: input.name, + baseLanguage: input.baseLanguage, + availableLocales: input.availableLocales, + subject: input.subject ?? {}, + blocks: + (input.blocks as EmailTemplateBlocks | undefined) ?? + buildDefaultEmailTemplateBlocks(input.baseLanguage), + strings: (input.strings as EmailTemplateStrings | undefined) ?? {}, + }) + .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 { + if (ids.length === 0) return []; + + const rows = await this.db + .select({ blocks: emailNotificationTemplates.blocks }) + .from(emailNotificationTemplates) + .where(inArray(emailNotificationTemplates.id, ids)); + + return rows.map((r) => r.blocks as EmailTemplateBlocks); + } + + async findByName(name: string, excludeId?: UUIDType) { + const conditions: SQL[] = [eq(emailNotificationTemplates.name, name)]; + if (excludeId) conditions.push(ne(emailNotificationTemplates.id, excludeId)); + + 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: typeof emailNotificationTemplates.$inferSelect.baseLanguage; + availableLocales: typeof emailNotificationTemplates.$inferSelect.availableLocales; + subject: typeof emailNotificationTemplates.$inferSelect.subject; + 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, + input: UpdateEmailNotificationTemplate & { + blocks?: EmailTemplateBlocks; + strings?: EmailTemplateStrings; + }, + ) { + 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; + if (input.blocks !== undefined) updates.blocks = input.blocks as EmailTemplateBlocks; + if (input.strings !== undefined) updates.strings = input.strings as EmailTemplateStrings; + + const [row] = await this.db + .update(emailNotificationTemplates) + .set(updates) + .where(eq(emailNotificationTemplates.id, id)) + .returning(); + + return row; + } + + async findReferencedImageKeys( + keys: string[], + tenantId: UUIDType, + excludeId?: UUIDType, + ): Promise> { + const keySet = new Set(keys); + if (keySet.size === 0) return new Set(); + + 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); + + const out = new Set(); + for (const row of rows) { + for (const src of collectImageSrcs(row.blocks as EmailTemplateBlocks)) { + const key = extractTenantEmailTemplateImageFileKeyFromUrl(src, tenantId); + if (key && keySet.has(key)) out.add(key); + } + } + return out; + } + + async findMaxAutoTemplateNumber(): Promise { + const result = await this.db.execute( + sql`SELECT COALESCE(MAX((substring(name FROM '^Email template #([0-9]+)$'))::int), 0) AS max + FROM ${emailNotificationTemplates} + WHERE name ~ '^Email template #[0-9]+$'`, + ); + const rows = result as unknown as Array<{ max?: number | string }>; + const first = rows[0]; + return Number(first?.max ?? 0); + } +} 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..37dcaada0b --- /dev/null +++ b/apps/api/src/email-notification-templates/email-templates.service.ts @@ -0,0 +1,496 @@ +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 { EmailService } from "src/common/emails/emails.service"; +import { DEFAULT_PAGE_SIZE, parsePagination } from "src/common/pagination"; +import { isPostgresUniqueViolation } from "src/common/utils/postgresErrors"; +import { SettingsService } from "src/settings/settings.service"; + +import { EmailTemplateCleanupQueueService } from "./email-template-cleanup.queue.service"; +import { EmailTemplateImageService } from "./email-template-image.service"; +import { EmailNotificationTemplatesRepository } from "./email-templates.repository"; +import { assertSafeBlockUrls } from "./utils/assertSafeBlockUrls"; +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 TEMPLATE_NAME_UNIQUE_INDEX = "email_notification_templates_tenant_id_name_unique_idx"; + +@Injectable() +export class EmailNotificationTemplatesService { + private readonly logger = new Logger(EmailNotificationTemplatesService.name); + + constructor( + private readonly repository: EmailNotificationTemplatesRepository, + private readonly imageService: EmailTemplateImageService, + 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, + }); + + return this.repository.listTemplates({ page, perPage }, filters); + } + + async createTemplate(input: CreateEmailNotificationTemplate) { + this.validateLocales(input.baseLanguage, input.availableLocales); + const blocks = input.blocks as EmailTemplateBlocks | undefined; + const strings = (input.strings as EmailTemplateStrings | undefined) ?? {}; + if (blocks) { + 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, + }); + if (!template) throw new BadRequestException("emailTemplates.toast.createFailed"); + return template; + } + + return this.createWithAutoName(input); + } + + private async createWithAutoName(input: CreateEmailNotificationTemplate) { + const MAX_ATTEMPTS = 5; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const nextNumber = (await this.repository.findMaxAutoTemplateNumber()) + 1; + const candidate = `Email template #${nextNumber}`; + try { + return await this.repository.createTemplate({ ...input, name: candidate }); + } catch (err) { + if (!isPostgresUniqueViolation(err, TEMPLATE_NAME_UNIQUE_INDEX)) throw err; + } + } + throw new ConflictException("emailTemplates.toast.nameAlreadyExists"); + } + + 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 as EmailTemplateBlocks | undefined) ?? existing.blocks; + const nextStrings = (input.strings as EmailTemplateStrings | undefined) ?? 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, { + ...input, + blocks: nextBlocks, + strings: pruned, + }); + } catch (err) { + if (isPostgresUniqueViolation(err, 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.repository.findByName(name, excludeId); + if (existing) { + throw new ConflictException("emailTemplates.toast.nameAlreadyExists"); + } + } + + private async createTemplateOrThrowNameConflict( + input: CreateEmailNotificationTemplate & { name: string }, + ) { + try { + return await this.repository.createTemplate(input); + } catch (err) { + if (isPostgresUniqueViolation(err, 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.repository.findByName(`${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.repository.findReferencedImageKeys( + keyList, + tenantId, + excludeTemplateId, + ); + const orphaned = keyList.filter((key) => !stillReferenced.has(key)); + await Promise.all( + orphaned.map(async (key) => { + await this.imageService.deleteByKey(key); + }), + ); + } +} 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..483f50ec14 --- /dev/null +++ b/apps/api/src/email-notification-templates/schemas/emailNotificationTemplate.schema.ts @@ -0,0 +1,72 @@ +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"; + +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 = tiptapJsonNodeSchema; + +export const emailTemplateStringsSchema = Type.Partial( + Type.Record( + emailTemplateLanguageSchema, + Type.Record(Type.String(), Type.Array(tiptapJsonNodeSchema)), + ), +); + +export const localizedTextSchema = 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..46ea6f2d97 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.spec.ts @@ -0,0 +1,62 @@ +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?.level).toBe(2); + expect(content[1]?.content?.[0]?.text).toBe("Heading 2"); + expect(content[2]?.content?.[0]?.text).toBe("Paragraph text"); + expect(content[3]?.attrs).toMatchObject({ + text: "Button", + url: "", + alignment: "left", + variant: "filled", + borderRadius: "smooth", + }); + 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..e87a2f7f06 --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/buildDefaultEmailTemplateBlocks.ts @@ -0,0 +1,121 @@ +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_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", + }, +}; + +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: "center", + width: null, + height: DEFAULT_TENANT_LOGO_HEIGHT, + }), + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.HEADING, + attrs: withUuid({ level: 2 }), + content: [textNode(placeholders.heading)], + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.PARAGRAPH, + attrs: withUuid(), + content: [textNode(placeholders.paragraph)], + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.BUTTON, + attrs: withUuid({ + text: placeholders.button, + url: DEFAULT_BUTTON_URL, + alignment: "left", + variant: "filled", + borderRadius: "smooth", + }), + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.HORIZONTAL_RULE, + attrs: withUuid(), + }, + { + type: EMAIL_TEMPLATE_NODE_TYPES.FOOTER, + attrs: withUuid(), + 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..0e48e967fc --- /dev/null +++ b/apps/api/src/email-notification-templates/utils/renderTemplateContent.ts @@ -0,0 +1,179 @@ +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 cardTable = wrapperTd.children("table").first(); + if (cardTable.length === 0) return rawHtml; + + const cardInnerTd = cardTable + .children("tbody") + .first() + .children("tr") + .first() + .children("td") + .first(); + if (cardInnerTd.length === 0) return rawHtml; + + const nodes = cardInnerTd.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 6690e68f8e..12f41c6b81 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-course-thumbnail/public-course-thumbnail.controller.ts b/apps/api/src/public-course-thumbnail/public-course-thumbnail.controller.ts new file mode 100644 index 0000000000..e7311e3d9d --- /dev/null +++ b/apps/api/src/public-course-thumbnail/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 service: 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.service.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/public-course-thumbnail/public-course-thumbnail.module.ts b/apps/api/src/public-course-thumbnail/public-course-thumbnail.module.ts new file mode 100644 index 0000000000..34b9e6d0ba --- /dev/null +++ b/apps/api/src/public-course-thumbnail/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/public-course-thumbnail/public-course-thumbnail.service.ts b/apps/api/src/public-course-thumbnail/public-course-thumbnail.service.ts new file mode 100644 index 0000000000..4dc0461c74 --- /dev/null +++ b/apps/api/src/public-course-thumbnail/public-course-thumbnail.service.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { and, eq } from "drizzle-orm"; + +import { DatabasePg } from "src/common"; +import { FileService } from "src/file/file.service"; +import { DB_ADMIN } from "src/storage/db/db.providers"; +import { courses } from "src/storage/schema"; + +import type { UUIDType } from "src/common"; + +@Injectable() +export class PublicCourseThumbnailService { + constructor( + @Inject(DB_ADMIN) private readonly dbAdmin: DatabasePg, + private readonly fileService: FileService, + ) {} + + async resolveSignedUrl(courseId: UUIDType, tenantId: UUIDType): Promise { + const [course] = await this.dbAdmin + .select({ thumbnailS3Key: courses.thumbnailS3Key }) + .from(courses) + .where(and(eq(courses.id, courseId), eq(courses.tenantId, tenantId))) + .limit(1); + + if (!course) return null; + + if (!course.thumbnailS3Key) return null; + + return this.fileService.getFileUrl(course.thumbnailS3Key); + } +} 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/0162_add_adjusted_automation_tables.sql b/apps/api/src/storage/migrations/0162_add_adjusted_automation_tables.sql new file mode 100644 index 0000000000..fae70ebf5d --- /dev/null +++ b/apps/api/src/storage/migrations/0162_add_adjusted_automation_tables.sql @@ -0,0 +1,54 @@ +DO $$ BEGIN + CREATE TYPE "public"."automation_status" AS ENUM('enabled', 'disabled', 'archived', 'draft'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."automation_type" AS ENUM('action', 'condition', 'trigger'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "automation_steps" ( + "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, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL, + "automation_id" uuid NOT NULL, + "parent_id" uuid, + "type" "automation_type" NOT NULL, + "type_context" jsonb DEFAULT '{}'::jsonb +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "automations" ( + "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, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL, + "name" jsonb DEFAULT '{}'::jsonb NOT NULL, + "description" jsonb DEFAULT '{}'::jsonb, + "last_run" timestamp, + "status" "automation_status" NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "automation_steps" ADD CONSTRAINT "automation_steps_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 +DO $$ BEGIN + ALTER TABLE "automation_steps" ADD CONSTRAINT "automation_steps_automation_id_automations_id_fk" FOREIGN KEY ("automation_id") REFERENCES "public"."automations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "automations" ADD CONSTRAINT "automations_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 "automation_steps_index_tenant_id_idx" ON "automation_steps" USING btree ("tenant_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "automation_index_tenant_id_idx" ON "automations" USING btree ("tenant_id"); \ No newline at end of file diff --git a/apps/api/src/storage/migrations/0163_add_automation_log table.sql b/apps/api/src/storage/migrations/0163_add_automation_log table.sql new file mode 100644 index 0000000000..4083d001e6 --- /dev/null +++ b/apps/api/src/storage/migrations/0163_add_automation_log table.sql @@ -0,0 +1,32 @@ +DO $$ BEGIN + CREATE TYPE "public"."automation_log_status" AS ENUM('success', 'failed', 'skipped'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "automation_logs" ( + "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, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL, + "automation_id" uuid NOT NULL, + "automation_name" varchar NOT NULL, + "event_name" varchar NOT NULL, + "error_name" varchar, + "status" "automation_log_status" NOT NULL, + "email_addresses" jsonb DEFAULT '[]'::jsonb NOT NULL +); +--> statement-breakpoint +ALTER TABLE "automation_steps" ALTER COLUMN "type_context" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "automation_steps" ALTER COLUMN "type_context" SET NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "automation_logs" ADD CONSTRAINT "automation_logs_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 +DO $$ BEGIN + ALTER TABLE "automation_logs" ADD CONSTRAINT "automation_logs_automation_id_automations_id_fk" FOREIGN KEY ("automation_id") REFERENCES "public"."automations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/api/src/storage/migrations/0179_add_automation_tables.sql b/apps/api/src/storage/migrations/0179_add_automation_tables.sql new file mode 100644 index 0000000000..645c1671e1 --- /dev/null +++ b/apps/api/src/storage/migrations/0179_add_automation_tables.sql @@ -0,0 +1,109 @@ +DO $$ BEGIN + CREATE TYPE "public"."automation_log_status" AS ENUM('success', 'failed', 'skipped'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."automation_status" AS ENUM('enabled', 'disabled', 'archived', 'draft'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."automation_type" AS ENUM('action', 'condition', 'trigger'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "automation_logs" ( + "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, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL, + "automation_id" uuid NOT NULL, + "automation_name" varchar NOT NULL, + "event_name" varchar NOT NULL, + "error_name" varchar, + "status" "automation_log_status" NOT NULL, + "email_addresses" jsonb DEFAULT '[]'::jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "automation_steps" ( + "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, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL, + "automation_id" uuid NOT NULL, + "parent_id" uuid, + "type" "automation_type" NOT NULL, + "type_context" jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "automations" ( + "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, + "tenant_id" uuid DEFAULT current_setting('app.tenant_id', true)::uuid NOT NULL, + "name" jsonb DEFAULT '{}'::jsonb NOT NULL, + "description" jsonb DEFAULT '{}'::jsonb, + "last_run" timestamp, + "status" "automation_status" NOT NULL +); +--> statement-breakpoint +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 "automation_logs" ADD CONSTRAINT "automation_logs_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 +DO $$ BEGIN + ALTER TABLE "automation_logs" ADD CONSTRAINT "automation_logs_automation_id_automations_id_fk" FOREIGN KEY ("automation_id") REFERENCES "public"."automations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "automation_steps" ADD CONSTRAINT "automation_steps_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 +DO $$ BEGIN + ALTER TABLE "automation_steps" ADD CONSTRAINT "automation_steps_automation_id_automations_id_fk" FOREIGN KEY ("automation_id") REFERENCES "public"."automations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "automations" ADD CONSTRAINT "automations_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 +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 "automation_logs_index_tenant_id_idx" ON "automation_logs" USING btree ("tenant_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "automation_steps_index_tenant_id_idx" ON "automation_steps" USING btree ("tenant_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "automation_index_tenant_id_idx" ON "automations" USING btree ("tenant_id");--> 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/0179_add_email_notification_templates.sql b/apps/api/src/storage/migrations/0179_add_email_notification_templates.sql new file mode 100644 index 0000000000..c54fb66e85 --- /dev/null +++ b/apps/api/src/storage/migrations/0179_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/0180_enable_email_notification_templates_rls.sql b/apps/api/src/storage/migrations/0180_enable_email_notification_templates_rls.sql new file mode 100644 index 0000000000..716bf2d491 --- /dev/null +++ b/apps/api/src/storage/migrations/0180_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/0161_snapshot.json b/apps/api/src/storage/migrations/meta/0179_snapshot.json similarity index 85% rename from apps/api/src/storage/migrations/meta/0161_snapshot.json rename to apps/api/src/storage/migrations/meta/0179_snapshot.json index 9c6fe7d55f..c3455a2cea 100644 --- a/apps/api/src/storage/migrations/meta/0161_snapshot.json +++ b/apps/api/src/storage/migrations/meta/0179_snapshot.json @@ -1,6 +1,6 @@ { - "id": "51473765-3cec-469b-a0ac-a0ccb4880ca1", - "prevId": "3af3c3d2-9e7c-4ee9-9f32-049cf70906ac", + "id": "1245abc5-8976-421f-9498-403dbe9a7610", + "prevId": "60de642a-1902-46db-97ed-4758e7d35563", "version": "7", "dialect": "postgresql", "tables": { @@ -95,6 +95,27 @@ "method": "btree", "with": {} }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, "activity_logs_actor_idx": { "name": "activity_logs_actor_idx", "columns": [ @@ -205,8 +226,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.ai_mentor_lessons": { - "name": "ai_mentor_lessons", + "public.ai_judge_blocking_errors": { + "name": "ai_judge_blocking_errors", "schema": "", "columns": { "id": { @@ -230,66 +251,19 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "lesson_id": { - "name": "lesson_id", + "configuration_id": { + "name": "configuration_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "ai_mentor_instructions": { - "name": "ai_mentor_instructions", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "completion_conditions": { - "name": "completion_conditions", + "description": { + "name": "description", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'AI Mentor'" - }, - "avatar_reference": { - "name": "avatar_reference", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'mentor'" - }, - "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", @@ -299,8 +273,8 @@ } }, "indexes": { - "ai_mentor_lessons_tenant_id_idx": { - "name": "ai_mentor_lessons_tenant_id_idx", + "ai_judge_blocking_errors_tenant_id_idx": { + "name": "ai_judge_blocking_errors_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -313,15 +287,36 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "ai_mentor_lessons_lesson_id_lessons_id_fk": { - "name": "ai_mentor_lessons_lesson_id_lessons_id_fk", - "tableFrom": "ai_mentor_lessons", - "tableTo": "lessons", + "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", + "tableTo": "ai_judge_configurations", "columnsFrom": [ - "lesson_id" + "configuration_id" ], "columnsTo": [ "id" @@ -329,9 +324,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "ai_mentor_lessons_tenant_id_tenants_id_fk": { - "name": "ai_mentor_lessons_tenant_id_tenants_id_fk", - "tableFrom": "ai_mentor_lessons", + "ai_judge_blocking_errors_tenant_id_tenants_id_fk": { + "name": "ai_judge_blocking_errors_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_blocking_errors", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -346,8 +341,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.ai_mentor_student_lesson_progress": { - "name": "ai_mentor_student_lesson_progress", + "public.ai_judge_configurations": { + "name": "ai_judge_configurations", "schema": "", "columns": { "id": { @@ -371,48 +366,24 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "student_lesson_progress_id": { - "name": "student_lesson_progress_id", + "ai_mentor_lesson_id": { + "name": "ai_mentor_lesson_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "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", + "task_goal": { + "name": "task_goal", + "type": "jsonb", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'{}'::jsonb" }, - "percentage": { - "name": "percentage", + "passing_threshold_percent": { + "name": "passing_threshold_percent", "type": "integer", "primaryKey": false, - "notNull": false - }, - "passed": { - "name": "passed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false + "notNull": true }, "tenant_id": { "name": "tenant_id", @@ -423,8 +394,8 @@ } }, "indexes": { - "ai_mentor_student_lesson_progress_tenant_id_idx": { - "name": "ai_mentor_student_lesson_progress_tenant_id_idx", + "ai_judge_configurations_tenant_id_idx": { + "name": "ai_judge_configurations_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -440,12 +411,12 @@ } }, "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", - "tableTo": "student_lesson_progress", + "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", + "tableTo": "ai_mentor_lessons", "columnsFrom": [ - "student_lesson_progress_id" + "ai_mentor_lesson_id" ], "columnsTo": [ "id" @@ -453,9 +424,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "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", + "ai_judge_configurations_tenant_id_tenants_id_fk": { + "name": "ai_judge_configurations_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_configurations", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -468,10 +439,18 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {} + "uniqueConstraints": { + "ai_judge_configurations_ai_mentor_lesson_id_unique": { + "name": "ai_judge_configurations_ai_mentor_lesson_id_unique", + "nullsNotDistinct": false, + "columns": [ + "ai_mentor_lesson_id" + ] + } + } }, - "public.ai_mentor_thread_messages": { - "name": "ai_mentor_thread_messages", + "public.ai_judge_criteria": { + "name": "ai_judge_criteria", "schema": "", "columns": { "id": { @@ -495,37 +474,31 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "thread_id": { - "name": "thread_id", + "configuration_id": { + "name": "configuration_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "role": { - "name": "role", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", + "max_score": { + "name": "max_score", + "type": "integer", "primaryKey": false, "notNull": true }, - "token_count": { - "name": "token_count", - "type": "integer", + "title": { + "name": "title", + "type": "jsonb", "primaryKey": false, "notNull": true, - "default": 0 + "default": "'{}'::jsonb" }, - "archived": { - "name": "archived", - "type": "boolean", + "expected_behavior": { + "name": "expected_behavior", + "type": "jsonb", "primaryKey": false, - "notNull": false, - "default": false + "notNull": true, + "default": "'{}'::jsonb" }, "tenant_id": { "name": "tenant_id", @@ -536,8 +509,8 @@ } }, "indexes": { - "ai_mentor_thread_messages_tenant_id_idx": { - "name": "ai_mentor_thread_messages_tenant_id_idx", + "ai_judge_criteria_tenant_id_idx": { + "name": "ai_judge_criteria_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -550,15 +523,36 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "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", - "tableTo": "ai_mentor_threads", + "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", + "tableTo": "ai_judge_configurations", "columnsFrom": [ - "thread_id" + "configuration_id" ], "columnsTo": [ "id" @@ -566,9 +560,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "ai_mentor_thread_messages_tenant_id_tenants_id_fk": { - "name": "ai_mentor_thread_messages_tenant_id_tenants_id_fk", - "tableFrom": "ai_mentor_thread_messages", + "ai_judge_criteria_tenant_id_tenants_id_fk": { + "name": "ai_judge_criteria_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_criteria", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -583,8 +577,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.ai_mentor_threads": { - "name": "ai_mentor_threads", + "public.ai_judge_score_guidance": { + "name": "ai_judge_score_guidance", "schema": "", "columns": { "id": { @@ -608,31 +602,30 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "user_id": { - "name": "user_id", + "criterion_id": { + "name": "criterion_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "ai_mentor_lesson_id": { - "name": "ai_mentor_lesson_id", - "type": "uuid", + "score": { + "name": "score", + "type": "integer", "primaryKey": false, "notNull": true }, - "status": { - "name": "status", - "type": "varchar(20)", + "description": { + "name": "description", + "type": "jsonb", "primaryKey": false, "notNull": true, - "default": "'active'" + "default": "'{}'::jsonb" }, - "user_language": { - "name": "user_language", - "type": "varchar(20)", + "example": { + "name": "example", + "type": "jsonb", "primaryKey": false, - "notNull": true, - "default": "'en'" + "notNull": false }, "tenant_id": { "name": "tenant_id", @@ -643,8 +636,8 @@ } }, "indexes": { - "ai_mentor_threads_tenant_id_idx": { - "name": "ai_mentor_threads_tenant_id_idx", + "ai_judge_score_guidance_tenant_id_idx": { + "name": "ai_judge_score_guidance_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -657,28 +650,36 @@ "concurrently": false, "method": "btree", "with": {} - } - }, - "foreignKeys": { - "ai_mentor_threads_user_id_users_id_fk": { - "name": "ai_mentor_threads_user_id_users_id_fk", - "tableFrom": "ai_mentor_threads", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" }, - "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", - "tableTo": "ai_mentor_lessons", + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "ai_judge_criteria", "columnsFrom": [ - "ai_mentor_lesson_id" + "criterion_id" ], "columnsTo": [ "id" @@ -686,9 +687,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "ai_mentor_threads_tenant_id_tenants_id_fk": { - "name": "ai_mentor_threads_tenant_id_tenants_id_fk", - "tableFrom": "ai_mentor_threads", + "ai_judge_score_guidance_tenant_id_tenants_id_fk": { + "name": "ai_judge_score_guidance_tenant_id_tenants_id_fk", + "tableFrom": "ai_judge_score_guidance", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -703,8 +704,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.announcements": { - "name": "announcements", + "public.ai_mentor_judgement_blocking_errors": { + "name": "ai_mentor_judgement_blocking_errors", "schema": "", "columns": { "id": { @@ -728,98 +729,30 @@ "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", + "judgement_id": { + "name": "judgement_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "is_everyone": { - "name": "is_everyone", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "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", + "blocking_error_id": { + "name": "blocking_error_id", "type": "uuid", "primaryKey": false, "notNull": false }, - "base_language": { - "name": "base_language", + "blocking_error_description": { + "name": "blocking_error_description", "type": "text", "primaryKey": false, "notNull": true, - "default": "'en'" - }, - "available_locales": { - "name": "available_locales", - "type": "text[]", - "primaryKey": false, - "notNull": true, - "default": "ARRAY['en']::text[]" + "default": "''" }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp(3) with time zone", + "learner_safe_feedback": { + "name": "learner_safe_feedback", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "tenant_id": { "name": "tenant_id", @@ -830,8 +763,8 @@ } }, "indexes": { - "announcements_tenant_id_idx": { - "name": "announcements_tenant_id_idx", + "ai_mentor_judgement_blocking_errors_tenant_id_idx": { + "name": "ai_mentor_judgement_blocking_errors_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -844,15 +777,36 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "announcements_author_id_users_id_fk": { - "name": "announcements_author_id_users_id_fk", - "tableFrom": "announcements", - "tableTo": "users", + "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", + "tableTo": "ai_mentor_judgements", "columnsFrom": [ - "author_id" + "judgement_id" ], "columnsTo": [ "id" @@ -860,9 +814,22 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "announcements_tenant_id_tenants_id_fk": { - "name": "announcements_tenant_id_tenants_id_fk", - "tableFrom": "announcements", + "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", + "tableTo": "ai_judge_blocking_errors", + "columnsFrom": [ + "blocking_error_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "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", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -877,8 +844,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.article_sections": { - "name": "article_sections", + "public.ai_mentor_judgement_criteria": { + "name": "ai_mentor_judgement_criteria", "schema": "", "columns": { "id": { @@ -902,26 +869,48 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "title": { - "name": "title", - "type": "jsonb", + "judgement_id": { + "name": "judgement_id", + "type": "uuid", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": true }, - "base_language": { - "name": "base_language", + "criterion_id": { + "name": "criterion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "criterion_title": { + "name": "criterion_title", "type": "text", "primaryKey": false, "notNull": true, - "default": "'en'" + "default": "''" }, - "available_locales": { - "name": "available_locales", - "type": "text[]", + "awarded_points": { + "name": "awarded_points", + "type": "integer", "primaryKey": false, - "notNull": true, - "default": "ARRAY['en']::text[]" + "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", @@ -932,8 +921,8 @@ } }, "indexes": { - "article_sections_tenant_id_idx": { - "name": "article_sections_tenant_id_idx", + "ai_mentor_judgement_criteria_tenant_id_idx": { + "name": "ai_mentor_judgement_criteria_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -946,12 +935,59 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "article_sections_tenant_id_tenants_id_fk": { - "name": "article_sections_tenant_id_tenants_id_fk", - "tableFrom": "article_sections", + "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", + "tableTo": "ai_mentor_judgements", + "columnsFrom": [ + "judgement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "ai_judge_criteria", + "columnsFrom": [ + "criterion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_mentor_judgement_criteria_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgement_criteria_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgement_criteria", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -966,8 +1002,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.articles": { - "name": "articles", + "public.ai_mentor_judgements": { + "name": "ai_mentor_judgements", "schema": "", "columns": { "id": { @@ -991,48 +1027,1722 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "title": { - "name": "title", - "type": "jsonb", + "thread_id": { + "name": "thread_id", + "type": "uuid", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": true }, - "summary": { - "name": "summary", - "type": "jsonb", + "configuration_id": { + "name": "configuration_id", + "type": "uuid", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": true }, - "content": { - "name": "content", - "type": "jsonb", + "language": { + "name": "language", + "type": "varchar(20)", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": true }, - "status": { - "name": "status", - "type": "article_status", - "typeSchema": "public", + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "ai_mentor_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "ai_judge_configurations", + "columnsFrom": [ + "configuration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ai_mentor_judgements_tenant_id_tenants_id_fk": { + "name": "ai_mentor_judgements_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_judgements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_mentor_judgements_thread_id_unique": { + "name": "ai_mentor_judgements_thread_id_unique", + "nullsNotDistinct": false, + "columns": [ + "thread_id" + ] + } + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_mentor_lessons_lesson_id_lessons_id_fk": { + "name": "ai_mentor_lessons_lesson_id_lessons_id_fk", + "tableFrom": "ai_mentor_lessons", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_mentor_lessons_tenant_id_tenants_id_fk": { + "name": "ai_mentor_lessons_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_lessons", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "student_lesson_progress", + "columnsFrom": [ + "student_lesson_progress_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "ai_mentor_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_mentor_thread_messages_tenant_id_tenants_id_fk": { + "name": "ai_mentor_thread_messages_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_thread_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_mentor_threads_user_id_users_id_fk": { + "name": "ai_mentor_threads_user_id_users_id_fk", + "tableFrom": "ai_mentor_threads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "ai_mentor_lessons", + "columnsFrom": [ + "ai_mentor_lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_mentor_threads_tenant_id_tenants_id_fk": { + "name": "ai_mentor_threads_tenant_id_tenants_id_fk", + "tableFrom": "ai_mentor_threads", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcements_author_id_users_id_fk": { + "name": "announcements_author_id_users_id_fk", + "tableFrom": "announcements", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "announcements_tenant_id_tenants_id_fk": { + "name": "announcements_tenant_id_tenants_id_fk", + "tableFrom": "announcements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "article_sections_tenant_id_tenants_id_fk": { + "name": "article_sections_tenant_id_tenants_id_fk", + "tableFrom": "article_sections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_section_idx": { + "name": "article_section_idx", + "columns": [ + { + "expression": "article_section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "articles_article_section_id_article_sections_id_fk": { + "name": "articles_article_section_id_article_sections_id_fk", + "tableFrom": "articles", + "tableTo": "article_sections", + "columnsFrom": [ + "article_section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "articles_author_id_users_id_fk": { + "name": "articles_author_id_users_id_fk", + "tableFrom": "articles", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "articles_updated_by_id_users_id_fk": { + "name": "articles_updated_by_id_users_id_fk", + "tableFrom": "articles", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "articles_tenant_id_tenants_id_fk": { + "name": "articles_tenant_id_tenants_id_fk", + "tableFrom": "articles", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.automation_logs": { + "name": "automation_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" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "automation_name": { + "name": "automation_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "error_name": { + "name": "error_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_log_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "email_addresses": { + "name": "email_addresses", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "automation_logs_index_tenant_id_idx": { + "name": "automation_logs_index_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_logs_automation_id_automations_id_fk": { + "name": "automation_logs_automation_id_automations_id_fk", + "tableFrom": "automation_logs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.automation_steps": { + "name": "automation_steps", + "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" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "automation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type_context": { + "name": "type_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "automation_steps_index_tenant_id_idx": { + "name": "automation_steps_index_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_steps_tenant_id_tenants_id_fk": { + "name": "automation_steps_tenant_id_tenants_id_fk", + "tableFrom": "automation_steps", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_steps_automation_id_automations_id_fk": { + "name": "automation_steps_automation_id_automations_id_fk", + "tableFrom": "automation_steps", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.automations": { + "name": "automations", + "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" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "current_setting('app.tenant_id', true)::uuid" + }, + "name": { + "name": "name", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_run": { + "name": "last_run", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "automation_index_tenant_id_idx": { + "name": "automation_index_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_connections_subscription_idx": { + "name": "calendar_connections_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_connections_user_id_users_id_fk": { + "name": "calendar_connections_user_id_users_id_fk", + "tableFrom": "calendar_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_connections_tenant_id_tenants_id_fk": { + "name": "calendar_connections_tenant_id_tenants_id_fk", + "tableFrom": "calendar_connections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, "notNull": true, - "default": "'draft'" + "default": "gen_random_uuid()" }, - "is_public": { - "name": "is_public", - "type": "boolean", + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", "primaryKey": false, "notNull": true, - "default": true + "default": "CURRENT_TIMESTAMP" }, - "archived": { - "name": "archived", - "type": "boolean", + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", "primaryKey": false, "notNull": true, - "default": false + "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", @@ -1041,36 +2751,258 @@ "notNull": true, "default": "'en'" }, - "available_locales": { - "name": "available_locales", - "type": "text[]", + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_events_organizer_user_id_users_id_fk": { + "name": "calendar_events_organizer_user_id_users_id_fk", + "tableFrom": "calendar_events", + "tableTo": "users", + "columnsFrom": [ + "organizer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_tenant_id_tenants_id_fk": { + "name": "calendar_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, - "default": "ARRAY['en']::text[]" + "notNull": true }, - "published_at": { - "name": "published_at", - "type": "timestamp(3) with time zone", + "web_link": { + "name": "web_link", + "type": "text", "primaryKey": false, "notNull": false }, - "article_section_id": { - "name": "article_section_id", - "type": "uuid", + "sensitivity": { + "name": "sensitivity", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "author_id": { - "name": "author_id", - "type": "uuid", + "availability": { + "name": "availability", + "type": "text", "primaryKey": false, "notNull": true }, - "updated_by_id": { - "name": "updated_by_id", - "type": "uuid", + "is_cancelled": { + "name": "is_cancelled", + "type": "boolean", "primaryKey": false, - "notNull": false + "notNull": true, + "default": false }, "tenant_id": { "name": "tenant_id", @@ -1081,8 +3013,8 @@ } }, "indexes": { - "articles_tenant_id_idx": { - "name": "articles_tenant_id_idx", + "calendar_external_events_tenant_id_idx": { + "name": "calendar_external_events_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -1096,11 +3028,59 @@ "method": "btree", "with": {} }, - "article_section_idx": { - "name": "article_section_idx", + "calendar_external_events_calendar_event_unique_idx": { + "name": "calendar_external_events_calendar_event_unique_idx", "columns": [ { - "expression": "article_section_id", + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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" @@ -1113,12 +3093,12 @@ } }, "foreignKeys": { - "articles_article_section_id_article_sections_id_fk": { - "name": "articles_article_section_id_article_sections_id_fk", - "tableFrom": "articles", - "tableTo": "article_sections", + "calendar_external_events_connection_id_calendar_connections_id_fk": { + "name": "calendar_external_events_connection_id_calendar_connections_id_fk", + "tableFrom": "calendar_external_events", + "tableTo": "calendar_connections", "columnsFrom": [ - "article_section_id" + "connection_id" ], "columnsTo": [ "id" @@ -1126,12 +3106,12 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "articles_author_id_users_id_fk": { - "name": "articles_author_id_users_id_fk", - "tableFrom": "articles", - "tableTo": "users", + "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", + "tableTo": "calendar_events", "columnsFrom": [ - "author_id" + "calendar_event_id" ], "columnsTo": [ "id" @@ -1139,22 +3119,22 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "articles_updated_by_id_users_id_fk": { - "name": "articles_updated_by_id_users_id_fk", - "tableFrom": "articles", + "calendar_external_events_user_id_users_id_fk": { + "name": "calendar_external_events_user_id_users_id_fk", + "tableFrom": "calendar_external_events", "tableTo": "users", "columnsFrom": [ - "updated_by_id" + "user_id" ], "columnsTo": [ "id" ], - "onDelete": "set null", + "onDelete": "cascade", "onUpdate": "no action" }, - "articles_tenant_id_tenants_id_fk": { - "name": "articles_tenant_id_tenants_id_fk", - "tableFrom": "articles", + "calendar_external_events_tenant_id_tenants_id_fk": { + "name": "calendar_external_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_external_events", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -1169,8 +3149,8 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, - "public.calendar_events": { - "name": "calendar_events", + "public.calendar_outbound_events": { + "name": "calendar_outbound_events", "schema": "", "columns": { "id": { @@ -1194,108 +3174,30 @@ "notNull": true, "default": "CURRENT_TIMESTAMP" }, - "uid": { - "name": "uid", - "type": "text", + "connection_id": { + "name": "connection_id", + "type": "uuid", "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", + "calendar_event_id": { + "name": "calendar_event_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "ends_at": { - "name": "ends_at", - "type": "timestamp(3) with time zone", + "user_id": { + "name": "user_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "all_day": { - "name": "all_day", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "timezone": { - "name": "timezone", + "external_event_id": { + "name": "external_event_id", "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", @@ -1305,8 +3207,8 @@ } }, "indexes": { - "calendar_events_tenant_id_idx": { - "name": "calendar_events_tenant_id_idx", + "calendar_outbound_events_tenant_id_idx": { + "name": "calendar_outbound_events_tenant_id_idx", "columns": [ { "expression": "tenant_id", @@ -1320,8 +3222,8 @@ "method": "btree", "with": {} }, - "calendar_events_tenant_starts_ends_idx": { - "name": "calendar_events_tenant_starts_ends_idx", + "calendar_outbound_events_connection_event_user_unique_idx": { + "name": "calendar_outbound_events_connection_event_user_unique_idx", "columns": [ { "expression": "tenant_id", @@ -1330,25 +3232,31 @@ "nulls": "last" }, { - "expression": "starts_at", + "expression": "connection_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "ends_at", + "expression": "calendar_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} }, - "calendar_events_tenant_uid_unique_idx": { - "name": "calendar_events_tenant_uid_unique_idx", + "calendar_outbound_events_connection_external_event_unique_idx": { + "name": "calendar_outbound_events_connection_external_event_unique_idx", "columns": [ { "expression": "tenant_id", @@ -1357,7 +3265,13 @@ "nulls": "last" }, { - "expression": "uid", + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_event_id", "isExpression": false, "asc": true, "nulls": "last" @@ -1367,25 +3281,72 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { - "calendar_events_organizer_user_id_users_id_fk": { - "name": "calendar_events_organizer_user_id_users_id_fk", - "tableFrom": "calendar_events", + "calendar_outbound_events_connection_id_calendar_connections_id_fk": { + "name": "calendar_outbound_events_connection_id_calendar_connections_id_fk", + "tableFrom": "calendar_outbound_events", + "tableTo": "calendar_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "calendar_events", + "columnsFrom": [ + "calendar_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_outbound_events_user_id_users_id_fk": { + "name": "calendar_outbound_events_user_id_users_id_fk", + "tableFrom": "calendar_outbound_events", "tableTo": "users", "columnsFrom": [ - "organizer_user_id" + "user_id" ], "columnsTo": [ "id" ], - "onDelete": "set null", + "onDelete": "cascade", "onUpdate": "no action" }, - "calendar_events_tenant_id_tenants_id_fk": { - "name": "calendar_events_tenant_id_tenants_id_fk", - "tableFrom": "calendar_events", + "calendar_outbound_events_tenant_id_tenants_id_fk": { + "name": "calendar_outbound_events_tenant_id_tenants_id_fk", + "tableFrom": "calendar_outbound_events", "tableTo": "tenants", "columnsFrom": [ "tenant_id" @@ -1446,13 +3407,6 @@ "notNull": true, "default": "ARRAY['en']::text[]" }, - "archived": { - "name": "archived", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "tenant_id": { "name": "tenant_id", "type": "uuid", @@ -1790,6 +3744,27 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -3717,6 +5692,149 @@ } } }, + "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": "", @@ -6183,6 +8301,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -11683,7 +13828,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" }, "student_chapter_progress_tenant_id_tenants_id_fk": { @@ -11825,6 +13970,39 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -12317,6 +14495,34 @@ "concurrently": false, "method": "btree", "with": {} + }, + "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, + "where": "\"student_lesson_progress\".\"completed_at\" IS NOT NULL AND \"student_lesson_progress\".\"quiz_score\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -13456,6 +15662,34 @@ "published" ] }, + "public.automation_log_status": { + "name": "automation_log_status", + "schema": "public", + "values": [ + "success", + "failed", + "skipped" + ] + }, + "public.automation_status": { + "name": "automation_status", + "schema": "public", + "values": [ + "enabled", + "disabled", + "archived", + "draft" + ] + }, + "public.automation_type": { + "name": "automation_type", + "schema": "public", + "values": [ + "action", + "condition", + "trigger" + ] + }, "public.course_type": { "name": "course_type", "schema": "public", diff --git a/apps/api/src/storage/migrations/meta/0180_snapshot.json b/apps/api/src/storage/migrations/meta/0180_snapshot.json new file mode 100644 index 0000000000..78184213ee --- /dev/null +++ b/apps/api/src/storage/migrations/meta/0180_snapshot.json @@ -0,0 +1,15421 @@ +{ + "id": "c7610d0b-5919-4f9c-ba4b-8a12c77bf5ab", + "prevId": "e27ef116-efb6-4cf0-988c-3030cca3a5e0", + "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'" + }, + "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 + }, + "currency": { + "name": "currency", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "chapter_count": { + "name": "chapter_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "email_notification_templates_tenant_id_tenants_id_fk": { + "name": "email_notification_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_notification_templates", + "columnsFrom": [ + "tenant_id" + ], + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/apps/api/src/storage/migrations/meta/_journal.json b/apps/api/src/storage/migrations/meta/_journal.json index 48c03231df..e82dd48cf2 100644 --- a/apps/api/src/storage/migrations/meta/_journal.json +++ b/apps/api/src/storage/migrations/meta/_journal.json @@ -1254,6 +1254,13 @@ "when": 1785309941472, "tag": "0178_remove_category_archiving", "breakpoints": true + }, + { + "idx": 179, + "version": "7", + "when": 1785331794294, + "tag": "0179_add_automation_tables", + "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 e9b19e04d4..ef30356633 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, @@ -50,6 +51,7 @@ import { vector, } from "drizzle-orm/pg-core"; +import { AutomationStatus, automationTypes } from "src/announcements/types/automations.types"; import { coursesSettingsSchema } from "src/courses/types/settings"; import { DEFAULT_LEARNING_PATH_SETTINGS, @@ -100,6 +102,9 @@ import type { AnnouncementSourceType, AnnouncementStatus, CourseGenerationSyncStatus, + EmailTemplateBlocks, + EmailTemplateStatus, + EmailTemplateStrings, LiveTrainingDeliveryType, LiveTrainingLinkEntityType, LiveTrainingMemberRole, @@ -116,6 +121,7 @@ import type { } from "@repo/shared"; import type { ActivityLogActionType, ActivityLogMetadata } from "src/activity-logs/types"; import type { AiJudgeCriterionStatus } from "src/ai/judge-configuration/judge-configuration.types"; +import type { TypeContext } from "src/announcements/types/automations-source.types"; import type { MicrosoftCalendarOutboundErrorCode } from "src/calendar/calendar.constants"; import type { ActivityHistory, AllSettings } from "src/common/types"; import type { ResourceMetadata } from "src/file/types/resource-metadata.type"; @@ -1879,6 +1885,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", { @@ -2774,3 +2809,61 @@ export const learningPathEntityMap = pgTable( ), }), ); +export const automationStatus = pgEnum( + "automation_status", + Object.values(AutomationStatus) as [string, ...string[]], +); + +export const automations = pgTable( + "automations", + { + ...id, + ...timestamps, + tenantId, + name: jsonb("name").notNull().$type().default({}), + description: jsonb("description").$type().default({}), + lastRun: timestamp("last_run", { mode: "date" }), + status: automationStatus("status").notNull(), + }, + withTenantIdIndex("automation_index"), +); +export const automationTypeEnum = pgEnum("automation_type", automationTypes); + +export const automationSteps = pgTable( + "automation_steps", + { + ...id, + ...timestamps, + tenantId, + automationId: uuid("automation_id") + .references(() => automations.id, { onDelete: "cascade" }) + .notNull(), + parentId: uuid("parent_id"), + type: automationTypeEnum("type").notNull(), + typeContext: jsonb("type_context").$type().notNull(), + }, + withTenantIdIndex("automation_steps_index"), +); +export const automationLogStatusEnum = pgEnum("automation_log_status", [ + "success", + "failed", + "skipped", +]); + +export const automationLogs = pgTable( + "automation_logs", + { + ...id, + ...timestamps, + tenantId, + automationId: uuid("automation_id") + .references(() => automations.id, { onDelete: "cascade" }) + .notNull(), + automationName: varchar("automation_name").notNull(), + eventName: varchar("event_name").notNull(), + errorName: varchar("error_name"), + status: automationLogStatusEnum("status").notNull(), + emailAddresses: jsonb("email_addresses").$type().notNull().default([]), + }, + withTenantIdIndex("automation_logs_index"), +); diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index ddfe278cbe..83c7209ed5 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -12531,6 +12531,542 @@ } } }, + "/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" + } + ] + } + } + ], + "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" + } + ] + } + } + ], + "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", @@ -16263,6 +16799,303 @@ } } } + }, + "/api/automations": { + "get": { + "operationId": "AutomationsController_getAllAutomations", + "parameters": [], + "responses": { + "200": { + "description": "" + } + } + }, + "post": { + "operationId": "AutomationsController_createAutomation", + "parameters": [], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/api/automations/system-template-preview/{templateId}": { + "get": { + "operationId": "AutomationsController_previewSystemTemplate", + "parameters": [ + { + "name": "templateId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automations/simulate": { + "post": { + "operationId": "AutomationsController_runSimulation", + "parameters": [], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/api/automations/seed-defaults": { + "post": { + "operationId": "AutomationsController_seedDefaults", + "parameters": [], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/api/automations/{id}": { + "get": { + "operationId": "AutomationsController_getAutomationById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + }, + "patch": { + "operationId": "AutomationsController_updateAutomation", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + }, + "delete": { + "operationId": "AutomationsController_deleteAutomation", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automations/status/{id}": { + "patch": { + "operationId": "AutomationsController_updateStatus", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automation-steps": { + "post": { + "operationId": "AutomationStepsController_create", + "parameters": [], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/api/automation-steps/{id}": { + "get": { + "operationId": "AutomationStepsController_getById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + }, + "patch": { + "operationId": "AutomationStepsController_update", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + }, + "delete": { + "operationId": "AutomationStepsController_delete", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automation-steps/automation/{automationId}": { + "get": { + "operationId": "AutomationStepsController_getAll", + "parameters": [ + { + "name": "automationId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automation-steps/{automationId}/steps": { + "put": { + "operationId": "AutomationStepsController_replaceAutomationStepTree", + "parameters": [ + { + "name": "automationId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automation-logs": { + "get": { + "operationId": "AutomationLogsController_getAll", + "parameters": [], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/automation-logs/automation/{automationId}": { + "get": { + "operationId": "AutomationLogsController_getByAutomationId", + "parameters": [ + { + "name": "automationId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + } + } } }, "info": { @@ -17259,6 +18092,10 @@ "const": "announcement.delete", "type": "string" }, + { + "const": "email_template.manage", + "type": "string" + }, { "const": "news.read_public", "type": "string" @@ -17334,6 +18171,10 @@ { "const": "activity_log.read", "type": "string" + }, + { + "const": "automation.manage", + "type": "string" } ] } @@ -52846,6 +53687,5800 @@ "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "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" + } + } + } + } + } + } + } + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "subject": { + "type": "object", + "properties": { + "en": { + "type": "string" + }, + "pl": { + "type": "string" + }, + "de": { + "type": "string" + }, + "lt": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "es": { + "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" + } + } + } + } + } + } + } + } + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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" + } + ] + }, + "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" + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "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" + } + ] + } + }, + "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 434e1dbb99..ceb6e6fbc5 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -1,14 +1,14 @@ -/* eslint-disable */ -/* tslint:disable */ -/* - * --------------------------------------------------------------- - * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## - * ## ## - * ## AUTHOR: acacode ## - * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## - * --------------------------------------------------------------- - */ - +/* eslint-disable */ +/* tslint:disable */ +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + export interface FileUploadResponse { fileKey: string; fileUrl?: string; @@ -259,6 +259,7 @@ export interface CurrentUserResponse { | "announcement.read" | "announcement.create" | "announcement.delete" + | "email_template.manage" | "news.read_public" | "news.manage" | "news.manage_own" @@ -7649,6 +7650,494 @@ 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + 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"; + /** @minItems 1 */ + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + subject?: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: 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; + }; +} + +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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface UpdateTemplateBody { + /** + * @minLength 1 + * @maxLength 200 + */ + name?: string; + baseLanguage?: "en" | "pl" | "de" | "lt" | "cs" | "es"; + /** @minItems 1 */ + availableLocales?: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + subject?: { + en?: string; + pl?: string; + de?: string; + lt?: string; + cs?: string; + es?: 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; + }; +} + +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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface PreviewTemplateResponse { + data: { + language: "en" | "pl" | "de" | "lt" | "cs" | "es"; + 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; + }; + 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; + }; + baseLanguage: "en" | "pl" | "de" | "lt" | "cs" | "es"; + availableLocales: ("en" | "pl" | "de" | "lt" | "cs" | "es")[]; + status: "draft" | "published" | "archived"; + archivedAt: string | null; + }; +} + +export interface UploadResponse { + data: { + url: string; + }; +} + export interface GetTenantsResponse { data: { /** @format uuid */ @@ -15291,6 +15780,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"; + }, + 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"; + }, + 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. * @@ -16823,5 +17592,70 @@ export class API extends HttpClient + this.request({ + path: `/api/automations/tenant/${tenantId}`, + method: "GET", + ...params, + }), + + /** + * No description + * + * @name AutomationsControllerGetAutomationById + * @request GET:/api/automations/{id} + */ + automationsControllerGetAutomationById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/automations/${id}`, + method: "GET", + ...params, + }), + + /** + * No description + * + * @name AutomationsControllerUpdateAutomation + * @request PATCH:/api/automations/{id} + */ + automationsControllerUpdateAutomation: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/automations/${id}`, + method: "PATCH", + ...params, + }), + + /** + * No description + * + * @name AutomationsControllerDeleteAutomation + * @request DELETE:/api/automations/{id} + */ + automationsControllerDeleteAutomation: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/automations/${id}`, + method: "DELETE", + ...params, + }), + + /** + * No description + * + * @name AutomationsControllerCreateAutomation + * @request POST:/api/automations + */ + automationsControllerCreateAutomation: (params: RequestParams = {}) => + this.request({ + path: `/api/automations`, + method: "POST", + ...params, + }), }; } 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/useCreateAutomation.ts b/apps/web/app/api/mutations/admin/useCreateAutomation.ts new file mode 100644 index 0000000000..a1238a65f8 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useCreateAutomation.ts @@ -0,0 +1,43 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { AUTOMATIONS_QUERY_KEY } from "~/api/queries/admin/useAutomations"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +import type { CreateAutomationBody } from "~/api/queries/admin/automation.types"; + +/** + * Creates a new automation (initially in draft status). + * + * Backend endpoint: POST /api/automations + * Body: { name: LocalizedText, description?: LocalizedText, status: "draft" } + */ +export function useCreateAutomation() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (body: CreateAutomationBody) => { + const { data } = await ApiClient.instance.post<{ data: unknown }>("/api/automations", body); + await queryClient.invalidateQueries({ queryKey: [AUTOMATIONS_QUERY_KEY] }); + return data.data; + }, + + onSuccess: () => { + toast({ + variant: "default", + description: t("automationView.toasts.created"), + }); + }, + + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage(error, t, t("automationView.toasts.createError")), + }); + }, + }); +} 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/useDeleteAutomation.ts b/apps/web/app/api/mutations/admin/useDeleteAutomation.ts new file mode 100644 index 0000000000..5e7f715dc0 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useDeleteAutomation.ts @@ -0,0 +1,37 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { AUTOMATIONS_QUERY_KEY } from "~/api/queries/admin/useAutomations"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export function useDeleteAutomation() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (automationId: string) => { + const { data } = await ApiClient.instance.delete<{ data: unknown }>( + `/api/automations/${automationId}`, + ); + await queryClient.invalidateQueries({ queryKey: [AUTOMATIONS_QUERY_KEY] }); + return data.data; + }, + + onSuccess: () => { + toast({ + variant: "default", + description: t("automationView.toasts.deleted"), + }); + }, + + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage(error, t, t("automationView.toasts.deleteError")), + }); + }, + }); +} 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/useSeedDefaultAutomations.ts b/apps/web/app/api/mutations/admin/useSeedDefaultAutomations.ts new file mode 100644 index 0000000000..4bfda41d69 --- /dev/null +++ b/apps/web/app/api/mutations/admin/useSeedDefaultAutomations.ts @@ -0,0 +1,59 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { AUTOMATIONS_QUERY_KEY } from "~/api/queries/admin/useAutomations"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +export interface SeedDefaultsResponse { + created: number; + skipped: number; + total: number; +} + +/** + * Seeds default automations for the current tenant. + * Skips automations whose trigger type already exists. + * Sends the current UI language so labels are generated in the user's language. + * + * Backend endpoint: POST /api/automations/seed-defaults + */ +export function useSeedDefaultAutomations() { + const { toast } = useToast(); + const { t, i18n } = useTranslation(); + + return useMutation({ + mutationFn: async () => { + const language = i18n.language || "en"; + const { data } = await ApiClient.instance.post<{ data: SeedDefaultsResponse }>( + "/api/automations/seed-defaults", + { language }, + ); + await queryClient.invalidateQueries({ queryKey: [AUTOMATIONS_QUERY_KEY] }); + return data.data; + }, + + onSuccess: (result) => { + toast({ + variant: "default", + description: t("automationView.seedDefaults.toasts.success", { + created: result.created, + skipped: result.skipped, + }), + }); + }, + + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage( + error, + t, + t("automationView.seedDefaults.toasts.error"), + ), + }); + }, + }); +} 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/useUpdateAutomation.ts b/apps/web/app/api/mutations/admin/useUpdateAutomation.ts new file mode 100644 index 0000000000..7e0150bc8e --- /dev/null +++ b/apps/web/app/api/mutations/admin/useUpdateAutomation.ts @@ -0,0 +1,54 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import { ApiClient } from "~/api/api-client"; +import { AUTOMATIONS_QUERY_KEY } from "~/api/queries/admin/useAutomations"; +import { queryClient } from "~/api/queryClient"; +import { getTranslatedApiErrorMessage } from "~/api/utils/getTranslatedApiErrorMessage"; +import { useToast } from "~/components/ui/use-toast"; + +import type { + AutomationStepBulkItem, + UpdateAutomationBody, +} from "~/api/queries/admin/automation.types"; + +interface UpdateAutomationInput { + automationId: string; + body: UpdateAutomationBody; + steps?: AutomationStepBulkItem[]; +} + +export function useUpdateAutomation() { + const { toast } = useToast(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ automationId, body, steps }: UpdateAutomationInput) => { + const { data } = await ApiClient.instance.patch<{ data: unknown }>( + `/api/automations/${automationId}`, + body, + ); + + if (steps && steps.length > 0) { + await ApiClient.instance.put(`/api/automation-steps/${automationId}/steps`, steps); + } + + await queryClient.invalidateQueries({ queryKey: [AUTOMATIONS_QUERY_KEY] }); + return data.data; + }, + + onSuccess: () => { + toast({ + variant: "default", + description: t("automationView.toasts.updated"), + }); + }, + + onError: (error) => { + toast({ + variant: "destructive", + description: getTranslatedApiErrorMessage(error, t, t("automationView.toasts.updateError")), + }); + }, + }); +} 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/automation.types.ts b/apps/web/app/api/queries/admin/automation.types.ts new file mode 100644 index 0000000000..d6fe16588a --- /dev/null +++ b/apps/web/app/api/queries/admin/automation.types.ts @@ -0,0 +1,127 @@ +/** + * Automation data layer types. + * + * These define the shape of data expected from and sent to the backend. + * Aligned with backend enums and table structures. + */ + +// ─── Domain types ─────────────────────────────────────────────────────────────── + +/** Matches backend AutomationStatus enum (lowercase) */ +export type AutomationStatus = "draft" | "enabled" | "disabled" | "archived"; + +/** Matches backend AutomationType: "trigger" | "action" | "condition" */ +export type AutomationNodeKind = "trigger" | "action" | "condition"; + +/** + * Backend automation_steps row shape (from getAllAutomationSteps). + * `typeContext` holds kind, label, config, position and step-specific name. + */ +export interface AutomationStepRaw { + id: string; + automationId: string; + parentId: string | null; + type: AutomationNodeKind; + typeContext: { + name: string; + label?: string; + config?: Record; + position?: { x: number; y: number }; + [key: string]: unknown; + }; + createdAt?: string; + updatedAt?: string; +} + +/** Frontend node representation (derived from AutomationStepRaw) */ +export interface AutomationNode { + id: string; + kind: AutomationNodeKind; + type: string; + label: string; + parentId: string | null; + children: string[]; + config: Record; + position: { x: number; y: number }; +} + +export interface AutomationLastRun { + date: string | null; + status: "success" | "failed" | "never"; +} + +/** Raw automation record from backend (LocalizedText fields) */ +export interface AutomationRecord { + id: string; + name: Record; + description: Record; + status: AutomationStatus; + lastRun: string | null; + createdAt: string; + updatedAt: string; +} + +/** Lightweight item for the list view. */ +export interface AutomationListItem { + id: string; + name: string; + description: string; + status: AutomationStatus; + lastRun: string | null; + createdAt: string; + updatedAt: string; +} + +/** Full detail including the flow tree. Used in the builder page. */ +export interface AutomationDetail { + id: string; + name: string; + description: string; + status: AutomationStatus; + nodes: AutomationNode[]; + createdAt: string; + updatedAt: string; +} + +// ─── API response wrappers (matches BaseResponse from apps/api) ──────────────── + +export interface GetAllAutomationsResponse { + data: AutomationRecord[]; +} + +export interface GetAutomationByIdResponse { + data: AutomationRecord; +} + +export interface GetAutomationStepsResponse { + data: AutomationStepRaw[]; +} + +// ─── Mutation payloads (request bodies) ──────────────────────────────────────── + +export interface CreateAutomationBody { + name: Record; + description?: Record; + status: AutomationStatus; +} + +export interface UpdateAutomationBody { + name?: Record; + description?: Record; + status?: AutomationStatus; +} + +/** Step payload for bulk replace (PUT /automation-steps/:automationId/steps) */ +export interface AutomationStepBulkItem { + id: string; + parentId: string | null; + automationId: string; + type: AutomationNodeKind; + typeContext: { + name: string; + label?: string; + config?: Record; + position?: { x: number; y: number }; + [key: string]: unknown; + }; +} diff --git a/apps/web/app/api/queries/admin/automation.utils.ts b/apps/web/app/api/queries/admin/automation.utils.ts new file mode 100644 index 0000000000..27335bb973 --- /dev/null +++ b/apps/web/app/api/queries/admin/automation.utils.ts @@ -0,0 +1,85 @@ +import type { + AutomationNode, + AutomationNodeKind, + AutomationRecord, + AutomationListItem, + AutomationStepBulkItem, + AutomationStepRaw, +} from "./automation.types"; + +/** + * Converts raw backend steps (flat with parentId) into frontend BuilderNodes + * with computed `children[]` arrays. + */ +export function stepsToNodes(steps: AutomationStepRaw[]): AutomationNode[] { + const nodes: AutomationNode[] = steps.map((step) => ({ + id: step.id, + kind: step.type as AutomationNodeKind, + type: step.typeContext.name, + label: step.typeContext.label ?? step.typeContext.name, + parentId: step.parentId, + children: [], + config: step.typeContext.config ?? {}, + position: step.typeContext.position ?? { x: 0, y: 0 }, + })); + + // Compute children from parentId relationships + for (const node of nodes) { + if (node.parentId) { + const parent = nodes.find((n) => n.id === node.parentId); + if (parent) { + parent.children.push(node.id); + } + } + } + + return nodes; +} + +/** + * Converts frontend BuilderNodes into backend step bulk update payload. + */ +export function nodesToSteps( + nodes: AutomationNode[], + automationId: string, +): AutomationStepBulkItem[] { + return nodes.map((node) => ({ + id: node.id, + parentId: node.parentId, + automationId, + type: node.kind as AutomationNodeKind, + typeContext: { + name: node.type, + label: node.label, + config: node.config, + position: node.position, + }, + })); +} + +/** + * Extracts a localized string from a LocalizedText object. + * Falls back to first available value if the requested language is not present. + */ +export function getLocalizedValue( + text: Record | null | undefined, + language = "pl", +): string { + if (!text) return ""; + return text[language] ?? Object.values(text)[0] ?? ""; +} + +/** + * Converts a backend AutomationRecord to a frontend-friendly AutomationListItem. + */ +export function recordToListItem(record: AutomationRecord, language = "pl"): AutomationListItem { + return { + id: record.id, + name: getLocalizedValue(record.name, language), + description: getLocalizedValue(record.description, language), + status: record.status, + lastRun: record.lastRun, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} 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/useAutomationById.ts b/apps/web/app/api/queries/admin/useAutomationById.ts new file mode 100644 index 0000000000..005bce2b4c --- /dev/null +++ b/apps/web/app/api/queries/admin/useAutomationById.ts @@ -0,0 +1,42 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; + +import { ApiClient } from "~/api/api-client"; +import { AUTOMATIONS_QUERY_KEY } from "~/api/queries/admin/useAutomations"; + +import { getLocalizedValue, stepsToNodes } from "./automation.utils"; + +import type { AutomationDetail, AutomationRecord, AutomationStepRaw } from "./automation.types"; + +const useAutomationByIdQuery = (automationId: string) => ({ + queryKey: [AUTOMATIONS_QUERY_KEY, { automationId }], + queryFn: async (): Promise => { + const { data: automationRes } = await ApiClient.instance.get<{ data: AutomationRecord }>( + `/api/automations/${automationId}`, + ); + const record = automationRes.data; + + const { data: stepsRes } = await ApiClient.instance.get<{ data: AutomationStepRaw[] }>( + `/api/automation-steps/automation/${automationId}`, + ); + const nodes = stepsToNodes(stepsRes.data); + + return { + id: record.id, + name: getLocalizedValue(record.name), + description: getLocalizedValue(record.description), + status: record.status, + nodes, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; + }, + enabled: !!automationId && automationId !== "new", +}); + +export function useAutomationById(automationId: string) { + return useQuery(useAutomationByIdQuery(automationId)); +} + +export function useAutomationByIdSuspense(automationId: string) { + return useSuspenseQuery(useAutomationByIdQuery(automationId)); +} diff --git a/apps/web/app/api/queries/admin/useAutomationLogs.ts b/apps/web/app/api/queries/admin/useAutomationLogs.ts new file mode 100644 index 0000000000..a8573a58eb --- /dev/null +++ b/apps/web/app/api/queries/admin/useAutomationLogs.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ApiClient } from "~/api/api-client"; + +import type { AutomationLogRecord } from "~/modules/Admin/Automation/Logs/automationLogs.types"; + +export const AUTOMATION_LOGS_QUERY_KEY = "automationLogs"; + +export function useAutomationLogs() { + return useQuery({ + queryKey: [AUTOMATION_LOGS_QUERY_KEY], + queryFn: async (): Promise => { + const { data } = await ApiClient.instance.get<{ data: AutomationLogRecord[] }>( + "/api/automation-logs", + ); + return data.data; + }, + }); +} + +export function useAutomationLogsByAutomationId(automationId: string | undefined) { + return useQuery({ + queryKey: [AUTOMATION_LOGS_QUERY_KEY, { automationId }], + queryFn: async (): Promise => { + const { data } = await ApiClient.instance.get<{ data: AutomationLogRecord[] }>( + `/api/automation-logs/automation/${automationId}`, + ); + return data.data; + }, + enabled: !!automationId, + }); +} diff --git a/apps/web/app/api/queries/admin/useAutomations.ts b/apps/web/app/api/queries/admin/useAutomations.ts new file mode 100644 index 0000000000..eaa2ce0f87 --- /dev/null +++ b/apps/web/app/api/queries/admin/useAutomations.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ApiClient } from "~/api/api-client"; + +import { recordToListItem } from "./automation.utils"; + +import type { AutomationListItem, AutomationRecord } from "./automation.types"; + +export const AUTOMATIONS_QUERY_KEY = "automations"; + +export function useAutomations() { + return useQuery({ + queryKey: [AUTOMATIONS_QUERY_KEY], + queryFn: async (): Promise => { + const { data } = await ApiClient.instance.get<{ data: AutomationRecord[] }>( + "/api/automations", + ); + return data.data.map((record) => recordToListItem(record)); + }, + }); +} 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/config/navigationConfig.ts b/apps/web/app/config/navigationConfig.ts index edaf4a6635..27a036c3a0 100644 --- a/apps/web/app/config/navigationConfig.ts +++ b/apps/web/app/config/navigationConfig.ts @@ -161,6 +161,7 @@ export const getNavigationConfig = ( PERMISSIONS.GROUP_MANAGE, PERMISSIONS.CATEGORY_MANAGE, PERMISSIONS.BILLING_MANAGE, + PERMISSIONS.EMAIL_TEMPLATE_MANAGE, ], }, items: [ @@ -182,6 +183,16 @@ export const getNavigationConfig = ( iconName: "Categories", testId: NAVIGATION_HANDLES.CATEGORIES_LINK, }, + { + label: t("navigationSideBar.emailTemplates", "Email templates"), + path: "admin/email-templates", + iconName: "Email", + }, + { + label: t("navigationSideBar.automation"), + path: "admin/automation", + iconName: "WandSparkles", + }, ...(isStripeConfigured ? [ { diff --git a/apps/web/app/config/routeAccessConfig.ts b/apps/web/app/config/routeAccessConfig.ts index e23c88bdba..857d9b16ff 100644 --- a/apps/web/app/config/routeAccessConfig.ts +++ b/apps/web/app/config/routeAccessConfig.ts @@ -135,6 +135,15 @@ export const routeAccessConfig = createRouteConfig({ }, "admin/courses/:id": COURSE_EDIT_ACCESS, "admin/beta-courses/:id": COURSE_EDIT_ACCESS, + "admin/automation": { + anyOf: [PERMISSIONS.USER_MANAGE, PERMISSIONS.AUTOMATION_MANAGE], + }, + "admin/automation/logs": { + anyOf: [PERMISSIONS.USER_MANAGE, PERMISSIONS.AUTOMATION_MANAGE], + }, + "admin/automation/:id/builder": { + anyOf: [PERMISSIONS.USER_MANAGE, PERMISSIONS.AUTOMATION_MANAGE], + }, "admin/development-paths": LEARNING_PATH_ADMIN_ACCESS, "admin/development-paths/new": { allOf: [PERMISSIONS.LEARNING_PATH_CREATE], @@ -147,6 +156,9 @@ export const routeAccessConfig = createRouteConfig({ "admin/categories/*": { allOf: [PERMISSIONS.CATEGORY_MANAGE], }, + "admin/email-templates/*": { + allOf: [PERMISSIONS.EMAIL_TEMPLATE_MANAGE], + }, "admin/lessons/*": COURSE_EDIT_ACCESS, "admin/lesson-items/*": COURSE_EDIT_ACCESS, "provider-information": PUBLIC, diff --git a/apps/web/app/locales/cs/translation.json b/apps/web/app/locales/cs/translation.json index d9051e4f73..d64ffaa0d4 100644 --- a/apps/web/app/locales/cs/translation.json +++ b/apps/web/app/locales/cs/translation.json @@ -16,7 +16,6 @@ "clearAll": "Vymazat vše", "validate": "Ověřit", "edit": "Upravit", - "delete": "Smazat", "uploading": "Nahrávání...", "sending": "Odesílání..." }, @@ -329,6 +328,7 @@ } }, "navigationSideBar": { + "automation": "Automatizace", "settings": "Nastavení", "logout": "Odhlášení", "panel": "panel", @@ -353,6 +353,7 @@ "announcements": "Oznámení", "notifications": "Notifikace", "promotionCodes": "Propagační kódy", + "emailTemplates": "E-mailové šablony", "manage": "Spravovat", "ariaLabels": { "goToAvailableCourses": "Přejděte na dostupné kurzy" @@ -4050,7 +4051,9 @@ "activityLogs": "Log aktivit", "learningPaths": "Rozvojové cesty", "adminLearningPaths": "Rozvojové cesty", - "adminLearningPathEditor": "Editor rozvojové cesty" + "adminLearningPathEditor": "Editor rozvojové cesty", + "emailTemplates": "E-mailové šablony", + "editEmailTemplate": "Upravit e-mailovou šablonu" }, "masterCourse": { "error": { @@ -4861,5 +4864,775 @@ "maxParticipantsReached": "Bylo dosaženo maximálního počtu účastníků.", "maxParallelSessionsReached": "Bylo dosaženo maximálního počtu aktivních relací Živé školení." } + }, + "list": { + "title": "E‑mailové šablony", + "createButton": "Vytvořit novou", + "deleteSelected": "Smazat vybrané", + "searchPlaceholder": "Hledat podle názvu", + "statusFilter": "Stav", + "empty": "Zatím zde nejsou žádné šablony.", + "loading": "Načítání šablon...", + "loadFailed": "E‑mailové šablony se nepodařilo načíst.", + "status": { + "all": "Vše" + }, + "columns": { + "name": "Název", + "status": "Stav", + "languages": "Jazyky", + "updatedAt": "Aktualizováno", + "selectAll": "Vybrat vše", + "selectRow": "Vybrat řádek" + } + }, + "deleteModal": { + "titleSingle": "Smazat e‑mailovou šablonu", + "titleMultiple": "Smazat e‑mailové šablony", + "descriptionSingle": "Opravdu chcete smazat tuto e‑mailovou šablonu? Tuto akci nelze vrátit zpět.", + "descriptionMultiple": "Opravdu chcete smazat {{count}} e‑mailových šablon? Tuto akci nelze vrátit zpět." + }, + "status": { + "draft": "Koncept", + "published": "Publikováno", + "archived": "Archivováno" + }, + "edit": { + "loadFailed": "Tuto e‑mailovou šablonu se nepodařilo načíst." + }, + "form": { + "field": { + "name": "Název", + "subject": "Předmět", + "subjectHelp": "Doplňte předmět pro každý jazyk. Můžete použít proměnné jako {{user.first_name}}.", + "subjectPlaceholder": "např. Vítejte, {{user.first_name}}" + }, + "errors": { + "nameRequired": "Název je povinný.", + "nameTooLong": "Název je příliš dlouhý.", + "localesRequired": "Vyberte alespoň jeden jazyk.", + "baseLanguageMissing": "Základní jazyk musí patřit mezi dostupné jazyky." + } + }, + "toast": { + "createdSuccessfully": "E‑mailová šablona byla vytvořena.", + "createFailed": "E‑mailovou šablonu se nepodařilo vytvořit.", + "updatedSuccessfully": "E‑mailová šablona byla uložena.", + "updateFailed": "E‑mailovou šablonu se nepodařilo uložit.", + "publishedSuccessfully": "E‑mailová šablona byla publikována.", + "publishFailed": "E‑mailovou šablonu se nepodařilo publikovat.", + "publishBlocked": "Publikaci nelze provést — viz diagnostika níže.", + "archivedSuccessfully": "E‑mailová šablona byla archivována.", + "archiveFailed": "E‑mailovou šablonu se nepodařilo archivovat.", + "unarchivedSuccessfully": "E‑mailová šablona byla obnovena do stavu koncept.", + "unarchiveFailed": "E‑mailovou šablonu se nepodařilo obnovit z archivu.", + "previewFailed": "Náhled se nepodařilo vykreslit.", + "previewLanguageUnavailable": "Tento jazyk není pro tuto šablonu dostupný.", + "duplicatedSuccessfully": "E‑mailová šablona byla duplikována.", + "duplicateFailed": "E‑mailovou šablonu se nepodařilo duplikovat.", + "madeDraftSuccessfully": "E‑mailová šablona byla vrácena do konceptu.", + "makeDraftFailed": "E‑mailovou šablonu se nepodařilo vrátit do konceptu.", + "deletedSuccessfully": "E‑mailová šablona byla smazána.", + "deleteFailed": "E‑mailovou šablonu se nepodařilo smazat.", + "nameAlreadyExists": "E‑mailová šablona s tímto názvem již existuje.", + "testEmailSentSuccessfully": "Testovací e-mail odeslán.", + "testEmailSendFailed": "Nepodařilo se odeslat testovací e-mail." + }, + "actions": { + "preview": "Náhled", + "duplicate": "Duplikovat", + "archive": "Archivovat", + "unarchive": "Odarchivovat", + "publish": "Publikovat", + "makeDraft": "Označit jako koncept", + "edit": "Upravit", + "rename": "Přejmenovat", + "sendTest": "Odeslat test" + }, + "language": { + "label": "Jazyk", + "baseLanguage": "Výchozí", + "notAddedLanguages": "Jazyky k přidání", + "setBaseLanguage": "Nastavit jako výchozí", + "setBaseTitle": "Změnit výchozí jazyk", + "setBaseDescription": "Použít {{language}} jako výchozí jazyk pro tuto šablonu?", + "createTitle": "Přidat překlad", + "createDescription": "Přidat překlad této šablony v jazyce {{language}}?", + "deleteTitle": "Smazat překlad", + "deleteDescription": "Smazat překlad {{language}}? Jeho obsah bude odstraněn." + }, + "publishDiagnostics": { + "errorsTitle_one": "{{count}} chyba – před publikací je nutné opravit", + "errorsTitle_few": "{{count}} chyby – před publikací je nutné opravit", + "errorsTitle_many": "{{count}} chyb – před publikací je nutné opravit", + "errorsTitle_other": "{{count}} chyb – před publikací je nutné opravit", + "warningsTitle_one": "{{count}} upozornění – zkontrolujte před publikací", + "warningsTitle_few": "{{count}} upozornění – zkontrolujte před publikací", + "warningsTitle_many": "{{count}} upozornění – zkontrolujte před publikací", + "warningsTitle_other": "{{count}} upozornění – zkontrolujte před publikací", + "elementIndex": "Prvek {{index}}", + "reasons": { + "name_missing": "Název šablony je povinný", + "no_language_versions": "Přidejte alespoň jednu jazykovou verzi", + "subject_missing": "Předmět je pro výchozí jazyk povinný", + "body_missing": "Tělo e-mailu je prázdné", + "button_label_missing": "Popisek tlačítka je povinný", + "button_url_missing": "Cíl tlačítka je povinný", + "empty_translation": "Překlad je prázdný", + "invalid_url_protocol": "URL používá nepovolený protokol", + "unchanged_from_base": "Překlad je totožný se základním jazykem", + "footer_missing": "Chybí zápatí" + }, + "nodeTypes": { + "heading": "Nadpis", + "paragraph": "Text", + "button": "Tlačítko", + "footer": "Zápatí", + "image": "Obrázek" + }, + "blockedToast": { + "save": "Šablonu nelze uložit. Podívejte se do diagnostiky", + "publish": "Šablonu nelze publikovat. Podívejte se do diagnostiky" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Napište / pro otevření nabídky bloků", + "heading": "Nadpis {{level}}", + "htmlCode": "Kód HTML…" + }, + "blocks": { + "groups": { + "text": "Text", + "media": "Média", + "structure": "Struktura", + "interactive": "Interaktivní", + "footer": "Zápatí" + }, + "text": { + "title": "Text", + "description": "Odstavec prostého textu." + }, + "heading1": { + "title": "Nadpis 1", + "description": "Velký nadpis sekce (H1)." + }, + "heading2": { + "title": "Nadpis 2", + "description": "Střední nadpis sekce (H2)." + }, + "heading3": { + "title": "Nadpis 3", + "description": "Malý nadpis sekce (H3)." + }, + "image": { + "title": "Obrázek", + "description": "Obrázek na celou šířku." + }, + "logoHeader": { + "title": "Logo", + "description": "Celé logo." + }, + "section": { + "title": "Sekce", + "description": "Kontejner pro seskupení obsahu." + }, + "columns": { + "title": "Sloupce", + "description": "Vícesloupcové rozvržení." + }, + "divider": { + "title": "Oddělovač", + "description": "Vodorovná dělicí čára." + }, + "spacer": { + "title": "Mezera", + "description": "Svislý odstup mezi bloky." + }, + "button": { + "title": "Tlačítko", + "description": "Tlačítko výzvy k akci." + }, + "footer": { + "title": "Zápatí", + "description": "Textový blok zápatí." + } + } + }, + "image": { + "uploadFailed": "Nahrávání obrázku se nezdařilo. Zkuste to znovu.", + "tooLarge": "Obrázek je příliš velký. Maximální velikost je 10 MB.", + "invalidType": "Neplatný typ obrázku. Povoleno: JPEG, PNG, GIF, WebP, BMP, TIFF." + }, + "automationView": { + "title": "Automatizace", + "description": "Spravujte automatická e-mailová oznámení odesílaná v reakci na události na platformě.", + "createAutomation": "Vytvořit automatizaci", + "filters": { + "searchPlaceholder": "Hledat podle názvu nebo popisu...", + "all": "Vše", + "enabled": "Povolené", + "disabled": "Zakázané", + "drafts": "Koncepty", + "archived": "Archivované" + }, + "status": { + "enabled": "Povoleno", + "disabled": "Zakázáno", + "draft": "Koncept", + "archived": "Archivováno" + }, + "table": { + "name": "Název automatizace", + "status": "Stav", + "trigger": "Spouštěč", + "actions": "Akce", + "lastRun": "Poslední spuštění", + "updatedAt": "Aktualizováno", + "menu": "Menu", + "manage": "Spravovat", + "empty": "Nejsou definovány žádné automatizace. Klikněte na Vytvořit automatizaci a přidejte první.", + "emptyFiltered": "Žádné automatizace neodpovídají aktuálním filtrům. Zkuste upravit vyhledávání nebo filtr stavu.", + "emailCount_one": "{{count}} e-mail", + "emailCount_other": "{{count}} e-mailů", + "noRuns": "Zatím žádná spuštění", + "runSuccess": "Úspěšně dokončeno", + "runFailed": "Došlo k chybě" + }, + "drawer": { + "title": "Podrobnosti automatizace", + "description": "Upravte konfiguraci vybrané automatizace.", + "nameLabel": "Název automatizace", + "descriptionLabel": "Popis", + "statusLabel": "Stav", + "statusDraft": "Koncept", + "statusEnabled": "Aktivní (Povoleno)", + "statusDisabled": "Neaktivní (Zakázáno)", + "statusArchived": "Archivováno", + "flowManagement": "Správa toku", + "openBuilder": "Otevřít editor kroků", + "pause": "Pozastavit", + "activate": "Aktivovat", + "simulationRequiredTooltip": "Automatizace musí nejprve projít simulací", + "archive": "Archivovat", + "save": "Uložit změny", + "cancel": "Zrušit", + "delete": "Smazat" + }, + "deleteDialog": { + "title": "Smazat automatizaci?", + "description": "Tato akce je nevratná. Automatizace bude trvale smazána. Místo toho ji můžete archivovat.", + "confirm": "Smazat", + "cancel": "Zrušit" + }, + "actionMenu": { + "openMenu": "Otevřít menu", + "settingsAndEdit": "Nastavení a úpravy", + "disable": "Zakázat oznámení", + "enable": "Povolit oznámení", + "delete": "Smazat automatizaci" + }, + "newAutomation": { + "name": "Nová automatizace (Koncept)", + "description": "Definujte cíl a popis této automatizace a poté přejděte do editoru toku.", + "triggerPlaceholder": "Ke konfiguraci..." + }, + "toasts": { + "created": "Automatizace úspěšně vytvořena.", + "createError": "Nepodařilo se vytvořit automatizaci.", + "updated": "Automatizace úspěšně uložena.", + "updateError": "Nepodařilo se uložit automatizaci.", + "deleted": "Automatizace smazána.", + "deleteError": "Nepodařilo se smazat automatizaci." + }, + "seedDefaults": { + "button": "Generovat výchozí", + "dialog": { + "title": "Generovat výchozí automatizace?", + "description": "Budou vytvořeny všechny standardní automatizace (např. pozvánka uživatele, přivítání, připomínka hesla atd.) s výchozími e-mailovými šablonami. Stávající automatizace nebudou přepsány.", + "warning": "Upozornění: Nové automatizace budou vytvořeny v aktivním stavu a začnou okamžitě fungovat. Ujistěte se, že vaše e-mailové šablony jsou správně nakonfigurovány.", + "confirm": "Generovat", + "cancel": "Zrušit" + }, + "toasts": { + "success": "Vygenerováno {{created}} automatizací (přeskočeno {{skipped}} existujících).", + "error": "Nepodařilo se vygenerovat výchozí automatizace." + } + } + }, + "automationBuilder": { + "header": { + "back": "Zpět na automatizace", + "automations": "Automatizace", + "save": "Uložit", + "simulate": "Simulovat", + "active": "Aktivní", + "draft": "Koncept", + "duplicate": "Duplikovat", + "exportJson": "Exportovat jako JSON", + "delete": "Smazat automatizaci", + "savedJustNow": "Právě uloženo", + "savedMinutesAgo": "Uloženo před {{count}} min", + "simulationRequiredTooltip": "Spusťte simulaci a ujistěte se, že proběhne úspěšně, abyste mohli aktivovat automatizaci", + "invalidNodesTooltip": "Opravte všechny chybné uzly před aktivací automatizace", + "unsavedChangesTooltip": "Uložte změny před aktivací automatizace", + "leaveDialog": { + "title": "Neuložené změny", + "description": "Máte neuložené změny. Chcete uložit před odchodem?", + "saveAndLeave": "Uložit a odejít", + "leaveWithoutSaving": "Odejít bez uložení", + "cancel": "Zrušit" + }, + "deleteDialog": { + "title": "Smazat automatizaci", + "description": "Opravdu chcete smazat tuto automatizaci? Tuto akci nelze vrátit zpět.", + "confirm": "Smazat", + "cancel": "Zrušit" + } + }, + "sidebar": { + "title": "Kroky", + "description": "Přetáhněte bloky na plátno pro vytvoření workflow.", + "descriptionTrigger": "Začněte výběrem triggeru pro vaši automatizaci.", + "descriptionActions": "Přidejte akce, které se provedou po spuštění triggeru.", + "triggerInstruction": "Každá automatizace začíná triggerem. Přetáhněte jeden na plátno pro zahájení tvorby workflow.", + "triggers": "Triggery", + "actions": "Akce" + }, + "blocks": { + "courseDeadline": "Termín kurzu", + "overdue": "Po termínu", + "notCompleted": "Nedokončeno", + "userEnrolled": "Uživatel zapsán", + "certificateExpiringSoon": "Certifikát brzy vyprší", + "liveTransmissionStartingSoon": "Živý přenos brzy začne", + "sendEmail": "Odeslat e-mail", + "userInvited": "Uživatel pozván", + "usersImportedInvite": "Uživatelé importováni (pozvánka)", + "userPasswordReminder": "Připomínka hesla", + "userPasswordChanged": "Heslo změněno", + "userWelcome": "Uvítací zpráva", + "userFirstLogin": "První přihlášení", + "usersAssignedToCourse": "Uživatelé přiřazeni ke kurzu", + "usersShortInactivity": "Krátká neaktivita", + "usersLongInactivity": "Dlouhá neaktivita", + "userChapterFinished": "Kapitola dokončena", + "userCourseFinished": "Kurz dokončen", + "userRegistered": "Uživatel registrován", + "userPasswordCreated": "Heslo vytvořeno", + "courseCompleted": "Kurz splněn", + "certificateExpirationWarning": "Varování o vypršení certifikátu", + "certificateArchived": "Certifikát archivován", + "announcementPublished": "Oznámení zveřejněno", + "courseChatUserMentioned": "Uživatel zmíněn v chatu", + "courseDueDateReminder": "Připomínka termínu kurzu" + }, + "canvas": { + "emptyTitle": "Vaše plátno automatizace je prázdné", + "emptyDescription": "Přetáhněte trigger nebo akci z postranního panelu pro začátek.", + "emptyDescriptionTrigger": "Přetáhněte trigger z postranního panelu pro zahájení tvorby automatizace.", + "removeNode": "Odstranit uzel", + "simulationFailed": "Simulace zjistila chyby na tomto uzlu", + "deleteNodeDialogTitle": "Odstranit uzel", + "deleteNodeDialogDescription": "Opravdu chcete odstranit tento uzel? Tuto akci nelze vrátit zpět.", + "deleteNodeDialogCancel": "Zrušit", + "deleteNodeDialogConfirm": "Odstranit", + "addChild": "Přidat podřízený krok", + "zoomIn": "Přiblížit", + "zoomOut": "Oddálit", + "zoomReset": "Resetovat přiblížení" + }, + "editPanel": { + "editTrigger": "Upravit trigger", + "editAction": "Upravit akci", + "nodeType": "Typ uzlu", + "changeTriggerLabel": "Změnit trigger na", + "changeTriggerDialogTitle": "Změnit trigger", + "changeTriggerDialogDescription": "Změna typu triggeru odstraní všechny akce a podmínky z automatizace. Na plátně zůstane pouze trigger. Opravdu chcete pokračovat?", + "changeTriggerDialogCancel": "Zrušit", + "changeTriggerDialogConfirm": "Smazat vše a změnit trigger", + "triggerValue": "Hodnota", + "triggerValuePlaceholder": "Zadejte hodnotu...", + "operator": "Operátor", + "operatorEquals": "Rovná se", + "operatorGreaterThan": "Větší než", + "operatorLessThan": "Menší než", + "operatorContains": "Obsahuje", + "emailSubject": "Předmět e-mailu", + "emailSubjectPlaceholder": "Zadejte předmět...", + "emailTemplate": "Šablona e-mailu", + "emailTemplatePlaceholder": "Vyberte šablonu", + "emailBody": "Tělo e-mailu", + "emailBodyPlaceholder": "Napište obsah e-mailu...", + "emailRecipient": "Příjemce", + "recipientEnrolledUser": "Zapsaný uživatel", + "recipientAdmin": "Administrátor", + "recipientManager": "Manažer", + "removeNode": "Odstranit tento krok" + }, + "config": { + "daysBefore": "Dní předem", + "daysBeforePlaceholder": "např. 7", + "course": "Kurz", + "coursePlaceholder": "Vyberte kurz", + "daysOverdue": "Dní po termínu", + "daysOverduePlaceholder": "např. 3", + "daysEnrolled": "Dní od zápisu", + "daysEnrolledPlaceholder": "např. 30", + "minutesBefore": "Minut předem", + "minutesBeforePlaceholder": "např. 60", + "daysInactive": "Dní neaktivity", + "daysInactivePlaceholder": "např. 14" + }, + "editAction": { + "title": "Upravit akci", + "sendEmail": "Odeslat e-mail", + "emailTemplate": "Šablona e-mailu", + "selectTemplate": "Vyberte šablonu...", + "language": "Jazyk", + "userDefaultLanguage": "Výchozí jazyk uživatele", + "placeholders": "Proměnné šablony", + "selectTemplateFirst": "Vyberte šablonu e-mailu pro zobrazení dostupných proměnných.", + "noPlaceholders": "Tato šablona nemá proměnné k vyplnění.", + "placeholdersDescription": "Přiřaďte každou proměnnou šablony k datům poskytovaným triggerem.", + "selectVariable": "Vyberte proměnnou triggeru...", + "noTriggerVariables": "K této akci není připojen žádný trigger. Nejprve přidejte trigger pro mapování proměnných.", + "defaultEmailNoMapping": "Výchozí e-mailová šablona nevyžaduje žádné mapování proměnných.", + "systemTemplateNoMapping": "Tato systémová šablona automaticky používá data z triggeru. Ruční mapování není vyžadováno.", + "defaultTemplatesGroup": "Systémové šablony", + "customTemplatesGroup": "Vlastní šablony", + "noCustomTemplates": "Nejsou k dispozici žádné publikované vlastní šablony.", + "templates": { + "defaultEmail": "Výchozí e-mail", + "userInvite": "Pozvání uživatele", + "welcome": "Uvítání", + "userFirstLogin": "První přihlášení", + "userAssignedToCourse": "Uživatel přiřazen ke kurzu", + "userShortInactivity": "Krátká neaktivita", + "userLongInactivity": "Dlouhá neaktivita", + "userFinishedChapter": "Kapitola dokončena", + "userFinishedCourse": "Kurz dokončen", + "createPasswordReminder": "Připomenutí vytvoření hesla", + "certificateExpirationWarning": "Upozornění na vypršení certifikátu", + "certificateExpired": "Certifikát vypršel", + "announcement": "Oznámení", + "courseDueDateReminder": "Připomenutí termínu kurzu", + "newUser": "Nový uživatel", + "finishedCourse": "Dokončený kurz" + } + }, + "variables": { + "userFirstName": "Jméno uživatele", + "userLastName": "Příjmení uživatele", + "userEmail": "E-mailová adresa", + "inviteLink": "Aktivační odkaz", + "inviteLinkRegistration": "Registrační odkaz", + "resetPasswordLink": "Odkaz pro reset hesla", + "platformUrl": "Odkaz na platformu", + "loginDate": "Datum prvního přihlášení", + "courseName": "Název kurzu", + "courseUrl": "Odkaz na kurz", + "dueDate": "Termín dokončení", + "daysInactive": "Počet dní neaktivity", + "chapterName": "Název kapitoly", + "finishedAt": "Datum dokončení", + "certificateUrl": "Odkaz na certifikát", + "registrationDate": "Datum registrace", + "createdAt": "Datum vytvoření", + "daysLeft": "Zbývající dny", + "daysLeftExpiration": "Dní do vypršení", + "certificateName": "Název certifikátu", + "expirationDate": "Datum vypršení", + "archivedAt": "Datum archivace", + "recipientFirstName": "Jméno příjemce", + "recipientLastName": "Příjmení příjemce", + "announcementTitle": "Název oznámení", + "announcementContent": "Obsah oznámení", + "announcementUrl": "Odkaz na oznámení", + "mentionedFirstName": "Jméno zmíněného uživatele", + "mentionedLastName": "Příjmení zmíněného uživatele", + "authorFullName": "Celé jméno autora", + "messageContent": "Obsah zprávy", + "chatUrl": "Odkaz na zprávu", + "invitedByUserName": "Pozván/a (jméno)", + "hasCertificate": "Má certifikát", + "archiveReason": "Důvod archivace", + "profileLink": "Odkaz na profil uživatele", + "progressLink": "Odkaz na průběh kurzu", + "userName": "Celé jméno" + }, + "simulation": { + "title": "Výsledek simulace", + "statusSuccess": "Úspěch", + "statusFailed": "Neúspěch", + "errorTitle": "Chyba simulace", + "retry": "Opakovat simulaci", + "errorsTitle": "Zjištěné problémy", + "readyToActivate": "Automatizace je připravena k aktivaci.", + "tabPreview": "Náhled e-mailu", + "tabEventData": "Data události", + "tabMappings": "Mapování", + "from": "Od:", + "to": "Komu:", + "subject": "Předmět:", + "noEventData": "Žádná data události — vyberte typ triggeru.", + "availableVariables": "Dostupné proměnné události", + "variableName": "Proměnná", + "variableLabel": "Popis", + "variableType": "Typ", + "placeholder": "Placeholder", + "mappedTo": "Mapováno na", + "sampleValue": "Ukázková hodnota", + "unmapped": "Nemapováno", + "errors": { + "triggerNodeName": "Startovní událost", + "selectTriggerType": "Vyberte typ startovní události", + "addTriggerNode": "Přidejte alespoň jeden trigger uzel", + "actionNodeName": "Odeslat e-mail", + "actionLabel": "Akce", + "selectEmailTemplate": "Vyberte publikovanou šablonu e-mailu", + "selectLanguage": "Vyberte jazyk šablony e-mailu", + "unmappedPlaceholder": "Placeholder {{placeholder}} není namapován — přiřaďte proměnnou události", + "addActionNode": "Přidejte alespoň jeden uzel akce (např. Odeslat e-mail)" + }, + "preview": { + "subject": "Byli jste přiřazeni ke kurzu: {{courseName}}", + "greeting": "Ahoj {{name}}!", + "assignedToCourse": "Byli jste přiřazeni k novému kurzu: {{courseName}}.", + "goToCourse": "Přejít na kurz", + "unavailable": "Náhled není k dispozici", + "loadFailed": "Nepodařilo se načíst náhled šablony.", + "label": "Náhled", + "systemTemplateNote": "Systémová šablona {{templateLabel}} — výchozí obsah je generován automaticky při odeslání.", + "emailDescription": "Tento e-mail bude odeslán s obsahem odpovídajícím vybranému triggeru a namapovaným proměnným.", + "platformName": "Mentingo Learning Platform" + }, + "sampleData": { + "firstName": "Jan", + "lastName": "Novák", + "fullName": "Jan Novák", + "email": "jan.novak@example.com", + "courseName": "Školení BOZP 2025", + "chapterName": "Kapitola 1: Úvod", + "certificateName": "Certifikát BOZP", + "announcementTitle": "Nové školení k dispozici", + "announcementContent": "Zveme vás na nové školení...", + "authorFullName": "Eva Nováková", + "messageContent": "Hej, podívej se na to!", + "daysLeft": "30", + "daysInactive": "14", + "invitedByUserName": "Eva Nováková" + } + } + }, + "automationSteps": { + "toast": { + "notFound": "Krok automatizace nebyl nalezen", + "idMismatch": "Nemůžete změnit nadřazený krok ani automatizaci", + "updateFailed": "Nepodařilo se aktualizovat krok automatizace", + "deleteFailed": "Chyba při mazání kroku automatizace", + "nodeDeleteFailed": "Chyba při hledání uzlu ke smazání", + "noRootStep": "Prázdná automatizace musí nejprve obsahovat kořenový krok", + "hasRootAlready": "Automatizace již obsahuje kořenový krok", + "cycleDetected": "Strom kroků automatizace nesmí obsahovat cykly", + "stepTreeBuildFailed": "Chyba při vytváření stromu kroků", + "wrongNumberOfRoots": "Automatizace může mít pouze jeden kořenový krok", + "treeNotConnected": "Ne všechny zadané kroky jsou mezi sebou propojené", + "bulkInsertFailed": "Nepodařilo se aktualizovat kroky automatizace" + } + }, + "emailTemplates": { + "breadcrumbs": { + "list": "E‑mailové šablony", + "edit": "Upravit" + }, + "list": { + "title": "E‑mailové šablony", + "createButton": "Vytvořit novou", + "deleteSelected": "Smazat vybrané", + "searchPlaceholder": "Hledat podle názvu", + "statusFilter": "Stav", + "empty": "Zatím zde nejsou žádné šablony.", + "loading": "Načítání šablon...", + "loadFailed": "E‑mailové šablony se nepodařilo načíst.", + "status": { + "all": "Vše" + }, + "columns": { + "name": "Název", + "status": "Stav", + "languages": "Jazyky", + "updatedAt": "Aktualizováno", + "selectAll": "Vybrat vše", + "selectRow": "Vybrat řádek" + } + }, + "deleteModal": { + "titleSingle": "Smazat e‑mailovou šablonu", + "titleMultiple": "Smazat e‑mailové šablony", + "descriptionSingle": "Opravdu chcete smazat tuto e‑mailovou šablonu? Tuto akci nelze vrátit zpět.", + "descriptionMultiple": "Opravdu chcete smazat {{count}} e‑mailových šablon? Tuto akci nelze vrátit zpět." + }, + "status": { + "draft": "Koncept", + "published": "Publikováno", + "archived": "Archivováno" + }, + "edit": { + "loadFailed": "Tuto e‑mailovou šablonu se nepodařilo načíst." + }, + "form": { + "field": { + "name": "Název", + "subject": "Předmět", + "subjectHelp": "Doplňte předmět pro každý jazyk. Můžete použít proměnné jako {{user.first_name}}.", + "subjectPlaceholder": "např. Vítejte, {{user.first_name}}" + }, + "errors": { + "nameRequired": "Název je povinný.", + "nameTooLong": "Název je příliš dlouhý.", + "localesRequired": "Vyberte alespoň jeden jazyk.", + "baseLanguageMissing": "Základní jazyk musí patřit mezi dostupné jazyky." + } + }, + "toast": { + "createdSuccessfully": "E‑mailová šablona byla vytvořena.", + "createFailed": "E‑mailovou šablonu se nepodařilo vytvořit.", + "updatedSuccessfully": "E‑mailová šablona byla uložena.", + "updateFailed": "E‑mailovou šablonu se nepodařilo uložit.", + "publishedSuccessfully": "E‑mailová šablona byla publikována.", + "publishFailed": "E‑mailovou šablonu se nepodařilo publikovat.", + "publishBlocked": "Publikaci nelze provést — viz diagnostika níže.", + "archivedSuccessfully": "E‑mailová šablona byla archivována.", + "archiveFailed": "E‑mailovou šablonu se nepodařilo archivovat.", + "unarchivedSuccessfully": "E‑mailová šablona byla obnovena do stavu koncept.", + "unarchiveFailed": "E‑mailovou šablonu se nepodařilo obnovit z archivu.", + "previewFailed": "Náhled se nepodařilo vykreslit.", + "previewLanguageUnavailable": "Tento jazyk není pro tuto šablonu dostupný.", + "duplicatedSuccessfully": "E‑mailová šablona byla duplikována.", + "duplicateFailed": "E‑mailovou šablonu se nepodařilo duplikovat.", + "madeDraftSuccessfully": "E‑mailová šablona byla vrácena do konceptu.", + "makeDraftFailed": "E‑mailovou šablonu se nepodařilo vrátit do konceptu.", + "deletedSuccessfully": "E‑mailová šablona byla smazána.", + "deleteFailed": "E‑mailovou šablonu se nepodařilo smazat.", + "nameAlreadyExists": "E‑mailová šablona s tímto názvem již existuje.", + "testEmailSentSuccessfully": "Testovací e-mail odeslán.", + "testEmailSendFailed": "Nepodařilo se odeslat testovací e-mail." + }, + "actions": { + "preview": "Náhled", + "duplicate": "Duplikovat", + "archive": "Archivovat", + "unarchive": "Odarchivovat", + "publish": "Publikovat", + "makeDraft": "Označit jako koncept", + "edit": "Upravit", + "rename": "Přejmenovat", + "sendTest": "Odeslat test" + }, + "language": { + "label": "Jazyk", + "baseLanguage": "Výchozí", + "notAddedLanguages": "Jazyky k přidání", + "setBaseLanguage": "Nastavit jako výchozí", + "setBaseTitle": "Změnit výchozí jazyk", + "setBaseDescription": "Použít {{language}} jako výchozí jazyk pro tuto šablonu?", + "createTitle": "Přidat překlad", + "createDescription": "Přidat překlad této šablony v jazyce {{language}}?", + "deleteTitle": "Smazat překlad", + "deleteDescription": "Smazat překlad {{language}}? Jeho obsah bude odstraněn." + }, + "publishDiagnostics": { + "reasons": { + "name_missing": "Název šablony je povinný", + "no_language_versions": "Přidejte alespoň jednu jazykovou verzi", + "subject_missing": "Předmět je pro výchozí jazyk povinný", + "body_missing": "Tělo e-mailu je prázdné", + "button_label_missing": "Popisek tlačítka je povinný", + "button_url_missing": "Tlačítko nemá cíl", + "empty_translation": "Překlad je prázdný", + "invalid_url_protocol": "URL používá nepovolený protokol", + "unchanged_from_base": "Překlad je totožný se základním jazykem", + "footer_missing": "Chybí zápatí", + "logo_branding_missing": "Chybí logo značky" + }, + "blockedToast": { + "save": "Šablonu nelze uložit. Podívejte se do diagnostiky", + "publish": "Šablonu nelze publikovat. Podívejte se do diagnostiky" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Napište / pro otevření nabídky bloků", + "heading": "Nadpis {{level}}", + "htmlCode": "Kód HTML…" + }, + "blocks": { + "groups": { + "text": "Text", + "media": "Média", + "structure": "Struktura", + "interactive": "Interaktivní", + "footer": "Zápatí" + }, + "text": { + "title": "Text", + "description": "Odstavec prostého textu." + }, + "heading1": { + "title": "Nadpis 1", + "description": "Velký nadpis sekce (H1)." + }, + "heading2": { + "title": "Nadpis 2", + "description": "Střední nadpis sekce (H2)." + }, + "heading3": { + "title": "Nadpis 3", + "description": "Malý nadpis sekce (H3)." + }, + "image": { + "title": "Obrázek", + "description": "Obrázek na celou šířku." + }, + "logoHeader": { + "title": "Logo", + "description": "Celé logo." + }, + "variable": { + "title": "Proměnná", + "description": "Vloží {{proměnnou}}, které lze později v automatizačním enginu přiřadit hodnotu." + }, + "section": { + "title": "Sekce", + "description": "Kontejner pro seskupení obsahu." + }, + "columns": { + "title": "Sloupce", + "description": "Vícesloupcové rozvržení." + }, + "divider": { + "title": "Oddělovač", + "description": "Vodorovná dělicí čára." + }, + "spacer": { + "title": "Mezera", + "description": "Svislý odstup mezi bloky." + }, + "button": { + "title": "Tlačítko", + "description": "Tlačítko výzvy k akci." + }, + "footer": { + "title": "Zápatí", + "description": "Textový blok zápatí." + } + } + }, + "image": { + "uploadFailed": "Nahrávání obrázku se nezdařilo. Zkuste to znovu.", + "tooLarge": "Obrázek je příliš velký. Maximální velikost je 10 MB.", + "invalidType": "Neplatný typ obrázku. Povoleno: JPEG, PNG, GIF, WebP, BMP, TIFF." + } } } diff --git a/apps/web/app/locales/de/translation.json b/apps/web/app/locales/de/translation.json index c13e60c4ce..127ba38243 100644 --- a/apps/web/app/locales/de/translation.json +++ b/apps/web/app/locales/de/translation.json @@ -16,7 +16,6 @@ "clearAll": "Alles löschen", "validate": "Bestätigen", "edit": "Bearbeiten", - "delete": "Löschen", "uploading": "Hochladen...", "sending": "Senden..." }, @@ -329,6 +328,7 @@ } }, "navigationSideBar": { + "automation": "Automatisierungen", "settings": "Einstellungen", "logout": "Abmelden", "panel": "Panel", @@ -353,6 +353,7 @@ "announcements": "Ankündigungen", "notifications": "Benachrichtigungen", "promotionCodes": "Aktionscodes", + "emailTemplates": "E-Mail-Vorlagen", "manage": "Verwalten", "ariaLabels": { "goToAvailableCourses": "Gehen Sie zu den verfügbaren Kursen" @@ -4048,7 +4049,9 @@ "activityLogs": "Aktivitätsprotokoll", "learningPaths": "Entwicklungspfade", "adminLearningPaths": "Entwicklungspfade", - "adminLearningPathEditor": "Entwicklungspfad Editor" + "adminLearningPathEditor": "Entwicklungspfad Editor", + "emailTemplates": "E-Mail-Vorlagen", + "editEmailTemplate": "E-Mail-Vorlage bearbeiten" }, "masterCourse": { "error": { @@ -4859,5 +4862,578 @@ "maxParticipantsReached": "Die maximale Teilnehmerzahl wurde erreicht.", "maxParallelSessionsReached": "Die maximale Anzahl aktiver Live-Schulungssitzungen wurde erreicht." } + }, + "emailTemplates": { + "breadcrumbs": { + "list": "E‑Mail‑Vorlagen", + "edit": "Bearbeiten" + }, + "list": { + "title": "E‑Mail‑Vorlagen", + "createButton": "Neu erstellen", + "deleteSelected": "Ausgewählte löschen", + "searchPlaceholder": "Nach Name suchen", + "statusFilter": "Status", + "empty": "Noch keine E‑Mail‑Vorlagen vorhanden.", + "loading": "Vorlagen werden geladen...", + "loadFailed": "E‑Mail‑Vorlagen konnten nicht geladen werden.", + "status": { + "all": "Alle" + }, + "columns": { + "name": "Name", + "status": "Status", + "languages": "Sprachen", + "updatedAt": "Aktualisiert am", + "selectAll": "Alle auswählen", + "selectRow": "Zeile auswählen" + } + }, + "deleteModal": { + "titleSingle": "E‑Mail‑Vorlage löschen", + "titleMultiple": "E‑Mail‑Vorlagen löschen", + "descriptionSingle": "Möchten Sie diese E‑Mail‑Vorlage wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "descriptionMultiple": "Möchten Sie {{count}} E‑Mail‑Vorlagen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." + }, + "status": { + "draft": "Entwurf", + "published": "Veröffentlicht", + "archived": "Archiviert" + }, + "edit": { + "loadFailed": "Diese E‑Mail‑Vorlage konnte nicht geladen werden." + }, + "form": { + "field": { + "name": "Name", + "subject": "Betreff", + "subjectHelp": "Geben Sie den Betreff für jede Sprache ein. Variablen wie {{user.first_name}} sind erlaubt.", + "subjectPlaceholder": "z. B. Willkommen, {{user.first_name}}" + }, + "errors": { + "nameRequired": "Name ist erforderlich.", + "nameTooLong": "Name ist zu lang.", + "localesRequired": "Wählen Sie mindestens eine Sprache aus.", + "baseLanguageMissing": "Die Basissprache muss zu den verfügbaren Sprachen gehören." + } + }, + "toast": { + "createdSuccessfully": "E‑Mail‑Vorlage erstellt.", + "createFailed": "E‑Mail‑Vorlage konnte nicht erstellt werden.", + "updatedSuccessfully": "E‑Mail‑Vorlage gespeichert.", + "updateFailed": "E‑Mail‑Vorlage konnte nicht gespeichert werden.", + "publishedSuccessfully": "E‑Mail‑Vorlage veröffentlicht.", + "publishFailed": "E‑Mail‑Vorlage konnte nicht veröffentlicht werden.", + "publishBlocked": "Veröffentlichung nicht möglich – siehe Diagnose unten.", + "archivedSuccessfully": "E‑Mail‑Vorlage archiviert.", + "archiveFailed": "E‑Mail‑Vorlage konnte nicht archiviert werden.", + "unarchivedSuccessfully": "E‑Mail‑Vorlage als Entwurf wiederhergestellt.", + "unarchiveFailed": "E‑Mail‑Vorlage konnte nicht aus dem Archiv geholt werden.", + "previewFailed": "Vorschau konnte nicht erzeugt werden.", + "previewLanguageUnavailable": "Diese Sprache ist für diese Vorlage nicht verfügbar.", + "duplicatedSuccessfully": "E‑Mail‑Vorlage dupliziert.", + "duplicateFailed": "E‑Mail‑Vorlage konnte nicht dupliziert werden.", + "madeDraftSuccessfully": "E‑Mail‑Vorlage in Entwurf zurückgesetzt.", + "makeDraftFailed": "E‑Mail‑Vorlage konnte nicht in Entwurf zurückgesetzt werden.", + "deletedSuccessfully": "E‑Mail‑Vorlage gelöscht.", + "deleteFailed": "E‑Mail‑Vorlage konnte nicht gelöscht werden.", + "nameAlreadyExists": "Eine E‑Mail‑Vorlage mit diesem Namen existiert bereits.", + "testEmailSentSuccessfully": "Test-E-Mail gesendet.", + "testEmailSendFailed": "Test-E-Mail konnte nicht gesendet werden." + }, + "actions": { + "preview": "Vorschau", + "duplicate": "Duplizieren", + "archive": "Archivieren", + "unarchive": "Entarchivieren", + "publish": "Veröffentlichen", + "makeDraft": "Als Entwurf markieren", + "edit": "Bearbeiten", + "rename": "Umbenennen", + "sendTest": "Test senden" + }, + "language": { + "label": "Sprache", + "baseLanguage": "Standard", + "notAddedLanguages": "Sprachen zum Hinzufügen", + "setBaseLanguage": "Als Standard festlegen", + "setBaseTitle": "Standardsprache ändern", + "setBaseDescription": "{{language}} als Standardsprache für diese Vorlage verwenden?", + "createTitle": "Übersetzung hinzufügen", + "createDescription": "Übersetzung dieser Vorlage in {{language}} hinzufügen?", + "deleteTitle": "Übersetzung löschen", + "deleteDescription": "Die Übersetzung {{language}} löschen? Ihr Inhalt geht verloren." + }, + "publishDiagnostics": { + "reasons": { + "name_missing": "Vorlagenname ist erforderlich", + "no_language_versions": "Mindestens eine Sprachversion hinzufügen", + "subject_missing": "Betreff ist für die Standardsprache erforderlich", + "body_missing": "E-Mail-Text ist leer", + "button_label_missing": "Schaltflächenbeschriftung ist erforderlich", + "button_url_missing": "Schaltfläche hat kein Ziel", + "empty_translation": "Übersetzung ist leer", + "invalid_url_protocol": "URL verwendet ein unzulässiges Protokoll", + "unchanged_from_base": "Übersetzung ist identisch mit der Basissprache", + "footer_missing": "Footer fehlt", + "logo_branding_missing": "Logo-Branding fehlt" + }, + "blockedToast": { + "save": "Vorlage kann nicht gespeichert werden. Siehe Diagnose", + "publish": "Vorlage kann nicht veröffentlicht werden. Siehe Diagnose" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Geben Sie / ein, um das Blockmenü zu öffnen", + "heading": "Überschrift {{level}}", + "htmlCode": "HTML-Code…" + }, + "blocks": { + "groups": { + "text": "Text", + "media": "Medien", + "structure": "Struktur", + "interactive": "Interaktiv", + "footer": "Footer" + }, + "text": { + "title": "Text", + "description": "Absatz mit einfachem Text." + }, + "heading1": { + "title": "Überschrift 1", + "description": "Große Abschnittsüberschrift (H1)." + }, + "heading2": { + "title": "Überschrift 2", + "description": "Mittlere Abschnittsüberschrift (H2)." + }, + "heading3": { + "title": "Überschrift 3", + "description": "Kleine Abschnittsüberschrift (H3)." + }, + "image": { + "title": "Bild", + "description": "Bild in voller Breite." + }, + "logoHeader": { + "title": "Logo", + "description": "Vollständiges Logo." + }, + "variable": { + "title": "Variable", + "description": "Fügt eine {{Variable}} ein, der später in der Automatisierungs-Engine ein Wert zugewiesen werden kann." + }, + "section": { + "title": "Abschnitt", + "description": "Container zur Gruppierung von Inhalten." + }, + "columns": { + "title": "Spalten", + "description": "Mehrspaltiges Layout." + }, + "divider": { + "title": "Trennlinie", + "description": "Horizontale Trennlinie." + }, + "spacer": { + "title": "Abstand", + "description": "Vertikaler Abstand zwischen Blöcken." + }, + "button": { + "title": "Schaltfläche", + "description": "Call-to-Action-Schaltfläche." + }, + "footer": { + "title": "Footer", + "description": "Textblock für die Fußzeile." + } + } + }, + "image": { + "uploadFailed": "Bild-Upload fehlgeschlagen. Bitte erneut versuchen.", + "tooLarge": "Bild ist zu groß. Maximale Größe beträgt 10 MB.", + "invalidType": "Ungültiger Bildtyp. Erlaubt: JPEG, PNG, GIF, WebP, BMP, TIFF." + } + }, + "automationView": { + "title": "Automatisierungen", + "description": "Verwalten Sie automatische E-Mail-Benachrichtigungen, die als Reaktion auf Plattformereignisse gesendet werden.", + "createAutomation": "Automatisierung erstellen", + "filters": { + "searchPlaceholder": "Nach Name oder Beschreibung suchen...", + "all": "Alle", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "drafts": "Entwürfe", + "archived": "Archiviert" + }, + "status": { + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "draft": "Entwurf", + "archived": "Archiviert" + }, + "table": { + "name": "Name der Automatisierung", + "status": "Status", + "trigger": "Auslöser", + "actions": "Aktionen", + "lastRun": "Letzte Ausführung", + "updatedAt": "Aktualisiert", + "menu": "Menü", + "manage": "Verwalten", + "empty": "Keine Automatisierungen definiert. Klicken Sie auf Automatisierung erstellen, um die erste hinzuzufügen.", + "emptyFiltered": "Keine Automatisierungen entsprechen den aktuellen Filtern. Passen Sie die Suche oder den Statusfilter an.", + "emailCount_one": "{{count}} E-Mail", + "emailCount_other": "{{count}} E-Mails", + "noRuns": "Noch keine Ausführungen", + "runSuccess": "Erfolgreich abgeschlossen", + "runFailed": "Ein Fehler ist aufgetreten" + }, + "drawer": { + "title": "Automatisierungsdetails", + "description": "Bearbeiten Sie die Konfiguration der ausgewählten Automatisierung.", + "nameLabel": "Name der Automatisierung", + "descriptionLabel": "Beschreibung", + "statusLabel": "Status", + "statusDraft": "Entwurf", + "statusEnabled": "Aktiv (Aktiviert)", + "statusDisabled": "Inaktiv (Deaktiviert)", + "statusArchived": "Archiviert", + "flowManagement": "Ablaufverwaltung", + "openBuilder": "Schrittersteller öffnen", + "pause": "Pausieren", + "activate": "Aktivieren", + "simulationRequiredTooltip": "Die Automatisierung muss zuerst eine Simulation bestehen", + "archive": "Archivieren", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "delete": "Löschen" + }, + "deleteDialog": { + "title": "Automatisierung löschen?", + "description": "Diese Aktion ist unwiderruflich. Die Automatisierung wird dauerhaft gelöscht. Sie können sie stattdessen archivieren.", + "confirm": "Löschen", + "cancel": "Abbrechen" + }, + "actionMenu": { + "openMenu": "Menü öffnen", + "settingsAndEdit": "Einstellungen & Bearbeiten", + "disable": "Benachrichtigung deaktivieren", + "enable": "Benachrichtigung aktivieren", + "delete": "Automatisierung löschen" + }, + "newAutomation": { + "name": "Neue Automatisierung (Entwurf)", + "description": "Definieren Sie das Ziel und die Beschreibung dieser Automatisierung und gehen Sie dann zum Ablauf-Builder.", + "triggerPlaceholder": "Zu konfigurieren..." + }, + "toasts": { + "created": "Automatisierung erfolgreich erstellt.", + "createError": "Automatisierung konnte nicht erstellt werden.", + "updated": "Automatisierung erfolgreich gespeichert.", + "updateError": "Automatisierung konnte nicht gespeichert werden.", + "deleted": "Automatisierung gelöscht.", + "deleteError": "Automatisierung konnte nicht gelöscht werden." + }, + "seedDefaults": { + "button": "Standards generieren", + "dialog": { + "title": "Standard-Automatisierungen generieren?", + "description": "Alle Standard-Automatisierungen (z.B. Benutzereinladung, Willkommen, Passworterinnerung usw.) werden mit Standard-E-Mail-Vorlagen erstellt. Bestehende Automatisierungen werden nicht überschrieben.", + "warning": "Achtung: Neue Automatisierungen werden im aktiven Zustand erstellt und beginnen sofort zu arbeiten. Stellen Sie sicher, dass Ihre E-Mail-Vorlagen korrekt konfiguriert sind.", + "confirm": "Generieren", + "cancel": "Abbrechen" + }, + "toasts": { + "success": "{{created}} Automatisierungen generiert ({{skipped}} vorhandene übersprungen).", + "error": "Standard-Automatisierungen konnten nicht generiert werden." + } + } + }, + "automationBuilder": { + "header": { + "back": "Zurück zu Automatisierungen", + "automations": "Automatisierungen", + "save": "Speichern", + "simulate": "Simulieren", + "active": "Aktiv", + "draft": "Entwurf", + "duplicate": "Duplizieren", + "exportJson": "Als JSON exportieren", + "delete": "Automatisierung löschen", + "savedJustNow": "Gerade gespeichert", + "savedMinutesAgo": "Vor {{count}} Min. gespeichert", + "simulationRequiredTooltip": "Führen Sie eine Simulation durch und stellen Sie sicher, dass sie erfolgreich ist, um die Automatisierung zu aktivieren", + "invalidNodesTooltip": "Beheben Sie alle fehlerhaften Knoten, bevor Sie die Automatisierung aktivieren", + "unsavedChangesTooltip": "Speichern Sie Änderungen, bevor Sie die Automatisierung aktivieren", + "leaveDialog": { + "title": "Nicht gespeicherte Änderungen", + "description": "Sie haben nicht gespeicherte Änderungen. Möchten Sie vor dem Verlassen speichern?", + "saveAndLeave": "Speichern und verlassen", + "leaveWithoutSaving": "Ohne Speichern verlassen", + "cancel": "Abbrechen" + }, + "deleteDialog": { + "title": "Automatisierung löschen", + "description": "Sind Sie sicher, dass Sie diese Automatisierung löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm": "Löschen", + "cancel": "Abbrechen" + } + }, + "sidebar": { + "title": "Schritte", + "description": "Ziehen Sie Blöcke auf die Leinwand, um Ihren Workflow zu erstellen.", + "descriptionTrigger": "Wählen Sie zunächst einen Trigger für Ihre Automatisierung.", + "descriptionActions": "Fügen Sie Aktionen hinzu, die ausgeführt werden, wenn der Trigger ausgelöst wird.", + "triggerInstruction": "Jede Automatisierung beginnt mit einem Trigger. Ziehen Sie einen auf die Leinwand, um mit dem Aufbau Ihres Workflows zu beginnen.", + "triggers": "Trigger", + "conditions": "Bedingungen", + "actions": "Aktionen" + }, + "blocks": { + "courseDeadline": "Kursfrist", + "overdue": "Überfällig", + "notCompleted": "Nicht abgeschlossen", + "userEnrolled": "Benutzer eingeschrieben", + "certificateExpiringSoon": "Zertifikat läuft bald ab", + "liveTransmissionStartingSoon": "Live-Übertragung startet bald", + "sendEmail": "E-Mail senden", + "userInvited": "Benutzer eingeladen", + "usersImportedInvite": "Benutzer importiert (Einladung)", + "userPasswordReminder": "Passworterinnerung", + "userPasswordChanged": "Passwort geändert", + "userWelcome": "Willkommensnachricht", + "userFirstLogin": "Erste Anmeldung", + "usersAssignedToCourse": "Benutzer zum Kurs zugewiesen", + "usersShortInactivity": "Kurze Inaktivität", + "usersLongInactivity": "Lange Inaktivität", + "userChapterFinished": "Kapitel abgeschlossen", + "userCourseFinished": "Kurs abgeschlossen", + "userRegistered": "Benutzer registriert", + "userPasswordCreated": "Passwort erstellt", + "courseCompleted": "Kurs bestanden", + "certificateExpirationWarning": "Zertifikat-Ablaufwarnung", + "certificateArchived": "Zertifikat archiviert", + "announcementPublished": "Ankündigung veröffentlicht", + "courseChatUserMentioned": "Benutzer im Chat erwähnt", + "courseDueDateReminder": "Kurs-Fälligkeitserinnerung" + }, + "canvas": { + "emptyTitle": "Ihre Automatisierungsleinwand ist leer", + "emptyDescription": "Ziehen Sie eine Bedingung oder Aktion von der Seitenleiste, um zu beginnen.", + "emptyDescriptionTrigger": "Ziehen Sie einen Trigger von der Seitenleiste, um mit dem Aufbau Ihrer Automatisierung zu beginnen.", + "removeNode": "Knoten entfernen", + "simulationFailed": "Simulation hat Fehler an diesem Knoten erkannt", + "deleteNodeDialogTitle": "Knoten entfernen", + "deleteNodeDialogDescription": "Möchten Sie diesen Knoten wirklich entfernen? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteNodeDialogCancel": "Abbrechen", + "deleteNodeDialogConfirm": "Entfernen", + "addChild": "Unterschritt hinzufügen", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "zoomReset": "Zoom zurücksetzen" + }, + "editPanel": { + "editCondition": "Bedingung bearbeiten", + "editTrigger": "Trigger bearbeiten", + "editAction": "Aktion bearbeiten", + "nodeType": "Knotentyp", + "changeTriggerLabel": "Trigger ändern zu", + "changeTriggerDialogTitle": "Trigger ändern", + "changeTriggerDialogDescription": "Das Ändern des Trigger-Typs entfernt alle Aktionen und Bedingungen aus der Automatisierung. Nur der Trigger bleibt auf der Leinwand. Möchten Sie wirklich fortfahren?", + "changeTriggerDialogCancel": "Abbrechen", + "changeTriggerDialogConfirm": "Alles löschen und Trigger ändern", + "conditionValue": "Wert", + "conditionValuePlaceholder": "Wert eingeben...", + "operator": "Operator", + "operatorEquals": "Gleich", + "operatorGreaterThan": "Größer als", + "operatorLessThan": "Kleiner als", + "operatorContains": "Enthält", + "emailSubject": "E-Mail-Betreff", + "emailSubjectPlaceholder": "Betreff eingeben...", + "emailTemplate": "E-Mail-Vorlage", + "emailTemplatePlaceholder": "Vorlage auswählen", + "emailBody": "E-Mail-Inhalt", + "emailBodyPlaceholder": "Schreiben Sie Ihren E-Mail-Inhalt...", + "emailRecipient": "Empfänger", + "recipientEnrolledUser": "Eingeschriebener Benutzer", + "recipientAdmin": "Administrator", + "recipientManager": "Manager", + "removeNode": "Diesen Schritt entfernen" + }, + "config": { + "daysBefore": "Tage vorher", + "daysBeforePlaceholder": "z.B. 7", + "course": "Kurs", + "coursePlaceholder": "Kurs auswählen", + "daysOverdue": "Tage überfällig", + "daysOverduePlaceholder": "z.B. 3", + "daysEnrolled": "Tage seit Anmeldung", + "daysEnrolledPlaceholder": "z.B. 30", + "minutesBefore": "Minuten vorher", + "minutesBeforePlaceholder": "z.B. 60", + "daysInactive": "Tage Inaktivität", + "daysInactivePlaceholder": "z.B. 14" + }, + "editAction": { + "title": "Aktion bearbeiten", + "sendEmail": "E-Mail senden", + "emailTemplate": "E-Mail-Vorlage", + "selectTemplate": "Vorlage auswählen...", + "language": "Sprache", + "userDefaultLanguage": "Standardsprache des Benutzers", + "placeholders": "Vorlagenvariablen", + "selectTemplateFirst": "Wählen Sie eine E-Mail-Vorlage, um verfügbare Variablen anzuzeigen.", + "noPlaceholders": "Diese Vorlage hat keine Variablen zum Ausfüllen.", + "placeholdersDescription": "Ordnen Sie jede Vorlagenvariable einer vom Trigger bereitgestellten Variable zu.", + "selectVariable": "Trigger-Variable auswählen...", + "noTriggerVariables": "Kein Trigger mit dieser Aktion verbunden. Fügen Sie zuerst einen Trigger hinzu, um Variablen zuzuordnen.", + "defaultEmailNoMapping": "Die Standard-E-Mail-Vorlage erfordert keine Platzhalterzuordnung.", + "systemTemplateNoMapping": "Diese Systemvorlage verwendet automatisch Daten aus dem Trigger. Keine manuelle Zuordnung erforderlich.", + "defaultTemplatesGroup": "Systemvorlagen", + "customTemplatesGroup": "Benutzerdefinierte Vorlagen", + "noCustomTemplates": "Keine veröffentlichten benutzerdefinierten Vorlagen verfügbar.", + "templates": { + "defaultEmail": "Standard-E-Mail", + "userInvite": "Benutzereinladung", + "welcome": "Willkommen", + "userFirstLogin": "Erste Anmeldung", + "userAssignedToCourse": "Benutzer dem Kurs zugewiesen", + "userShortInactivity": "Kurze Inaktivität", + "userLongInactivity": "Lange Inaktivität", + "userFinishedChapter": "Kapitel abgeschlossen", + "userFinishedCourse": "Kurs abgeschlossen", + "createPasswordReminder": "Passwort-Erstellungserinnerung", + "certificateExpirationWarning": "Zertifikat-Ablaufwarnung", + "certificateExpired": "Zertifikat abgelaufen", + "announcement": "Ankündigung", + "courseDueDateReminder": "Kurs-Fälligkeitserinnerung", + "newUser": "Neuer Benutzer", + "finishedCourse": "Kurs abgeschlossen" + } + }, + "variables": { + "userFirstName": "Vorname des Benutzers", + "userLastName": "Nachname des Benutzers", + "userEmail": "E-Mail-Adresse", + "inviteLink": "Aktivierungslink", + "inviteLinkRegistration": "Registrierungslink", + "resetPasswordLink": "Link zum Passwort-Reset", + "platformUrl": "Plattform-Link", + "loginDate": "Datum der ersten Anmeldung", + "courseName": "Kursname", + "courseUrl": "Kurs-Link", + "dueDate": "Fälligkeitsdatum", + "daysInactive": "Tage der Inaktivität", + "chapterName": "Kapitelname", + "finishedAt": "Abschlussdatum", + "certificateUrl": "Zertifikat-Link", + "registrationDate": "Registrierungsdatum", + "createdAt": "Erstellungsdatum", + "daysLeft": "Verbleibende Tage", + "daysLeftExpiration": "Tage bis zum Ablauf", + "certificateName": "Zertifikatname", + "expirationDate": "Ablaufdatum", + "archivedAt": "Archivierungsdatum", + "recipientFirstName": "Vorname des Empfängers", + "recipientLastName": "Nachname des Empfängers", + "announcementTitle": "Ankündigungstitel", + "announcementContent": "Ankündigungsinhalt", + "announcementUrl": "Ankündigungs-Link", + "mentionedFirstName": "Vorname des erwähnten Benutzers", + "mentionedLastName": "Nachname des erwähnten Benutzers", + "authorFullName": "Vollständiger Name des Autors", + "messageContent": "Nachrichteninhalt", + "chatUrl": "Nachrichtenlink", + "invitedByUserName": "Eingeladen von (Name)", + "hasCertificate": "Hat Zertifikat", + "archiveReason": "Archivierungsgrund", + "profileLink": "Benutzerprofil-Link", + "progressLink": "Kursfortschritt-Link", + "userName": "Vollständiger Name" + }, + "simulation": { + "title": "Simulationsergebnis", + "statusSuccess": "Erfolg", + "statusFailed": "Fehlgeschlagen", + "errorTitle": "Simulationsfehler", + "retry": "Simulation wiederholen", + "errorsTitle": "Erkannte Probleme", + "readyToActivate": "Automatisierung ist bereit zur Aktivierung.", + "tabPreview": "E-Mail-Vorschau", + "tabEventData": "Ereignisdaten", + "tabMappings": "Zuordnungen", + "from": "Von:", + "to": "An:", + "subject": "Betreff:", + "noEventData": "Keine Ereignisdaten — wählen Sie einen Trigger-Typ.", + "availableVariables": "Verfügbare Ereignisvariablen", + "variableName": "Variable", + "variableLabel": "Beschreibung", + "variableType": "Typ", + "placeholder": "Platzhalter", + "mappedTo": "Zugeordnet zu", + "sampleValue": "Beispielwert", + "unmapped": "Nicht zugeordnet", + "errors": { + "triggerNodeName": "Startereignis", + "selectTriggerType": "Wählen Sie einen Startereignis-Typ", + "addTriggerNode": "Fügen Sie mindestens einen Trigger-Knoten hinzu", + "actionNodeName": "E-Mail senden", + "actionLabel": "Aktion", + "selectEmailTemplate": "Wählen Sie eine veröffentlichte E-Mail-Vorlage", + "selectLanguage": "Wählen Sie die Sprache der E-Mail-Vorlage", + "unmappedPlaceholder": "Platzhalter {{placeholder}} ist nicht zugeordnet — weisen Sie eine Ereignisvariable zu", + "addActionNode": "Fügen Sie mindestens einen Aktionsknoten hinzu (z.B. E-Mail senden)" + }, + "preview": { + "subject": "Sie wurden einem Kurs zugewiesen: {{courseName}}", + "greeting": "Hallo {{name}}!", + "assignedToCourse": "Sie wurden einem neuen Kurs zugewiesen: {{courseName}}.", + "goToCourse": "Zum Kurs", + "unavailable": "Vorschau nicht verfügbar", + "loadFailed": "Die Vorschau der Vorlage konnte nicht geladen werden.", + "label": "Vorschau", + "systemTemplateNote": "Systemvorlage {{templateLabel}} — der Standardinhalt wird beim Versand automatisch generiert.", + "emailDescription": "Diese E-Mail wird mit dem Inhalt gesendet, der zum ausgewählten Trigger und den zugeordneten Variablen passt.", + "platformName": "Mentingo Learning Platform" + }, + "sampleData": { + "firstName": "Max", + "lastName": "Mustermann", + "fullName": "Max Mustermann", + "email": "max.mustermann@example.com", + "courseName": "Arbeitssicherheit Schulung 2025", + "chapterName": "Kapitel 1: Einführung", + "certificateName": "Arbeitssicherheit Zertifikat", + "announcementTitle": "Neue Schulung verfügbar", + "announcementContent": "Wir laden Sie zu einer neuen Schulung ein...", + "authorFullName": "Erika Musterfrau", + "messageContent": "Hey, schau dir das an!", + "daysLeft": "30", + "daysInactive": "14", + "invitedByUserName": "Erika Musterfrau" + } + } + }, + "automationSteps": { + "toast": { + "notFound": "Automatisierungsschritt nicht gefunden", + "idMismatch": "Sie können den übergeordneten Schritt oder die Automatisierung nicht ändern", + "updateFailed": "Der Automatisierungsschritt konnte nicht aktualisiert werden", + "deleteFailed": "Fehler beim Löschen des Automatisierungsschritts", + "nodeDeleteFailed": "Fehler beim Finden des zu löschenden Knotens", + "noRootStep": "Eine leere Automatisierung muss zuerst einen Stammschritt besitzen", + "hasRootAlready": "Die Automatisierung besitzt bereits einen Stammschritt", + "cycleDetected": "Der Automatisierungsbaum darf keine Zyklen enthalten", + "stepTreeBuildFailed": "Fehler beim Erstellen des Schrittbaums", + "wrongNumberOfRoots": "Eine Automatisierung darf nur einen Stammschritt besitzen", + "treeNotConnected": "Nicht alle angegebenen Schritte sind miteinander verbunden", + "bulkInsertFailed": "Die Automatisierungsschritte konnten nicht aktualisiert werden" + } } } diff --git a/apps/web/app/locales/en/translation.json b/apps/web/app/locales/en/translation.json index 13e4c1f06a..4fef991220 100644 --- a/apps/web/app/locales/en/translation.json +++ b/apps/web/app/locales/en/translation.json @@ -16,7 +16,6 @@ "clearAll": "Clear All", "validate": "Validate", "edit": "Edit", - "delete": "Delete", "uploading": "Uploading...", "sending": "Sending..." }, @@ -329,6 +328,7 @@ } }, "navigationSideBar": { + "automation": "Automations", "settings": "Settings", "logout": "Logout", "panel": "panel", @@ -353,6 +353,7 @@ "announcements": "Announcements", "notifications": "Notifications", "promotionCodes": "Promotion Codes", + "emailTemplates": "Email templates", "manage": "Manage", "ariaLabels": { "goToAvailableCourses": "Go to available courses" @@ -4129,7 +4130,9 @@ "tenants": "Organizations", "learningPaths": "Development Paths", "adminLearningPaths": "Development Paths", - "adminLearningPathEditor": "Development Path Editor" + "adminLearningPathEditor": "Development Path Editor", + "emailTemplates": "Email templates", + "editEmailTemplate": "Edit email template" }, "learningPathsView": { "title": "Development Paths", @@ -4942,5 +4945,634 @@ "maxParticipantsReached": "The maximum number of participants has been reached.", "maxParallelSessionsReached": "The maximum number of active Live Training sessions has been reached." } + }, + "emailTemplates": { + "breadcrumbs": { + "list": "Email templates", + "edit": "Edit" + }, + "list": { + "title": "Email templates", + "createButton": "Create new", + "deleteSelected": "Delete selected", + "searchPlaceholder": "Search by name", + "statusFilter": "Status", + "empty": "No email templates yet.", + "loading": "Loading templates...", + "loadFailed": "Could not load email templates.", + "status": { + "all": "All" + }, + "columns": { + "name": "Name", + "status": "Status", + "languages": "Languages", + "updatedAt": "Updated at", + "selectAll": "Select all", + "selectRow": "Select row" + } + }, + "deleteModal": { + "titleSingle": "Delete email template", + "titleMultiple": "Delete email templates", + "descriptionSingle": "Are you sure you want to delete this email template? This action cannot be undone.", + "descriptionMultiple": "Are you sure you want to delete {{count}} email templates? This action cannot be undone." + }, + "status": { + "draft": "Draft", + "published": "Published", + "archived": "Archived" + }, + "edit": { + "loadFailed": "Could not load this email template." + }, + "form": { + "field": { + "name": "Name", + "subject": "Subject line", + "subjectHelp": "Fill in the subject for each language. Variables like {{user.first_name}} can be used.", + "subjectPlaceholder": "e.g. Welcome, {{user.first_name}}" + }, + "errors": { + "nameRequired": "Name is required.", + "nameTooLong": "Name is too long.", + "localesRequired": "Choose at least one language.", + "baseLanguageMissing": "Base language must be one of the available languages." + } + }, + "toast": { + "createdSuccessfully": "Email template created.", + "createFailed": "Failed to create email template.", + "updatedSuccessfully": "Email template saved.", + "updateFailed": "Failed to save email template.", + "publishedSuccessfully": "Email template published.", + "publishFailed": "Failed to publish email template.", + "publishBlocked": "Cannot publish — see the diagnostics below.", + "archivedSuccessfully": "Email template archived.", + "archiveFailed": "Failed to archive email template.", + "unarchivedSuccessfully": "Email template restored to draft.", + "unarchiveFailed": "Failed to unarchive email template.", + "previewFailed": "Failed to render preview.", + "previewLanguageUnavailable": "This language is not available for this template.", + "duplicatedSuccessfully": "Email template duplicated.", + "duplicateFailed": "Failed to duplicate email template.", + "madeDraftSuccessfully": "Email template reverted to draft.", + "makeDraftFailed": "Failed to revert email template to draft.", + "deletedSuccessfully": "Email template deleted.", + "deleteFailed": "Failed to delete email template.", + "nameAlreadyExists": "An email template with this name already exists.", + "testEmailSentSuccessfully": "Test email sent.", + "testEmailSendFailed": "Failed to send test email." + }, + "actions": { + "preview": "Preview", + "duplicate": "Duplicate", + "archive": "Archive", + "unarchive": "Unarchive", + "publish": "Publish", + "makeDraft": "Make draft", + "edit": "Edit", + "rename": "Rename", + "sendTest": "Send test" + }, + "language": { + "label": "Language", + "baseLanguage": "Default", + "notAddedLanguages": "Languages to add", + "setBaseLanguage": "Set as default", + "setBaseTitle": "Change default language", + "setBaseDescription": "Use {{language}} as the default language for this template?", + "createTitle": "Add translation", + "createDescription": "Add a translation for this template in {{language}}?", + "deleteTitle": "Delete translation", + "deleteDescription": "Delete the {{language}} translation? Its content will be removed." + }, + "publishDiagnostics": { + "errorsTitle_one": "{{count}} error — must be fixed before publishing", + "errorsTitle_other": "{{count}} errors — must be fixed before publishing", + "warningsTitle_one": "{{count}} warning — review before publishing", + "warningsTitle_other": "{{count}} warnings — review before publishing", + "elementIndex": "Element {{index}}", + "reasons": { + "name_missing": "Template name is required", + "no_language_versions": "Add at least one language version", + "subject_missing": "Subject line is required for the default language", + "body_missing": "Email body is empty", + "button_label_missing": "Button label is required", + "button_url_missing": "Button redirect target is required", + "empty_translation": "Translation is empty", + "invalid_url_protocol": "URL uses a disallowed protocol", + "unchanged_from_base": "Translation is identical to the base language", + "footer_missing": "Footer is missing" + }, + "nodeTypes": { + "heading": "Heading", + "paragraph": "Text", + "button": "Button", + "footer": "Footer", + "image": "Image" + }, + "blockedToast": { + "save": "Cannot save template. See diagnostics", + "publish": "Cannot publish template. See diagnostics" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Type / to open the block menu", + "heading": "Heading {{level}}", + "htmlCode": "HTML code…" + }, + "blocks": { + "groups": { + "text": "Text", + "media": "Media", + "structure": "Structure", + "interactive": "Interactive", + "footer": "Footer" + }, + "text": { + "title": "Text", + "description": "Plain text paragraph." + }, + "heading1": { + "title": "Heading 1", + "description": "Large section heading (H1)." + }, + "heading2": { + "title": "Heading 2", + "description": "Medium section heading (H2)." + }, + "heading3": { + "title": "Heading 3", + "description": "Small section heading (H3)." + }, + "image": { + "title": "Image", + "description": "Full-width image." + }, + "logoHeader": { + "title": "Logo", + "description": "Full logo." + }, + "section": { + "title": "Section", + "description": "Grouped content container." + }, + "columns": { + "title": "Columns", + "description": "Multi-column layout." + }, + "divider": { + "title": "Divider", + "description": "Horizontal divider line." + }, + "spacer": { + "title": "Spacer", + "description": "Vertical spacing between blocks." + }, + "button": { + "title": "Button", + "description": "Call-to-action button." + }, + "footer": { + "title": "Footer", + "description": "Footer text block." + } + } + }, + "image": { + "uploadFailed": "Image upload failed. Please try again.", + "tooLarge": "Image is too large. Maximum size is 10 MB.", + "invalidType": "Invalid image type. Allowed: JPEG, PNG, GIF, WebP, BMP, TIFF." + } + }, + "automationView": { + "title": "Automations", + "description": "Manage automatic email notifications sent in response to platform events.", + "createAutomation": "Create Automation", + "openLogs": "Open logs", + "filters": { + "searchPlaceholder": "Search by name or description...", + "all": "All", + "enabled": "Enabled", + "disabled": "Disabled", + "drafts": "Drafts", + "archived": "Archived" + }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "draft": "Draft", + "archived": "Archived" + }, + "table": { + "name": "Automation name", + "status": "Status", + "trigger": "Trigger", + "actions": "Actions", + "lastRun": "Last run", + "updatedAt": "Updated", + "menu": "Menu", + "manage": "Manage", + "empty": "No automations defined. Click Create Automation to add the first one.", + "emptyFiltered": "No automations match the current filters. Try adjusting your search or status filter.", + "emailCount_one": "{{count}} e-mail", + "emailCount_other": "{{count}} e-mails", + "noRuns": "No runs yet", + "runSuccess": "Completed successfully", + "runFailed": "An error occurred" + }, + "drawer": { + "title": "Automation details", + "description": "Edit the configuration of the selected automation.", + "nameLabel": "Automation name", + "descriptionLabel": "Description", + "statusLabel": "Status", + "statusDraft": "Draft", + "statusEnabled": "Active (Enabled)", + "statusDisabled": "Inactive (Disabled)", + "statusArchived": "Archived", + "flowManagement": "Flow management", + "openBuilder": "Open step builder", + "pause": "Pause", + "activate": "Activate", + "simulationRequiredTooltip": "The automation must pass a simulation first", + "archive": "Archive", + "save": "Save changes", + "cancel": "Cancel", + "delete": "Delete" + }, + "deleteDialog": { + "title": "Delete automation?", + "description": "This action is irreversible. The automation will be permanently deleted. You can archive it instead.", + "confirm": "Delete", + "cancel": "Cancel" + }, + "actionMenu": { + "openMenu": "Open menu", + "settingsAndEdit": "Settings & edit", + "disable": "Disable notification", + "enable": "Enable notification", + "delete": "Delete automation" + }, + "newAutomation": { + "name": "New automation (Draft)", + "description": "Define the goal and description of this automation, then proceed to the flow builder.", + "triggerPlaceholder": "To be configured..." + }, + "toasts": { + "created": "Automation created successfully.", + "createError": "Failed to create automation.", + "updated": "Automation saved successfully.", + "updateError": "Failed to save automation.", + "deleted": "Automation deleted.", + "deleteError": "Failed to delete automation." + }, + "seedDefaults": { + "button": "Generate defaults", + "dialog": { + "title": "Generate default automations?", + "description": "All standard automations (e.g. user invitation, welcome, password reminder, etc.) will be created with default email templates. Existing automations will not be overwritten.", + "warning": "Warning: New automations will be created in an active state and will start working immediately. Make sure your email templates are properly configured.", + "confirm": "Generate", + "cancel": "Cancel" + }, + "toasts": { + "success": "Generated {{created}} automations (skipped {{skipped}} existing).", + "error": "Failed to generate default automations." + } + } + }, + "automationLogs": { + "title": "Automation Logs", + "description": "View the execution history of all automations. Click on an entry to see full details.", + "backToAutomations": "Back to automations", + "status": { + "success": "Success", + "sent": "Sent", + "skipped": "Skipped", + "failed": "Failed" + }, + "filters": { + "searchPlaceholder": "Search by name, event, or recipient...", + "all": "All statuses", + "success": "Success", + "sent": "Sent", + "skipped": "Skipped", + "failed": "Failed" + }, + "table": { + "automation": "Automation", + "status": "Status", + "emails": "Emails", + "recipients": "recipients", + "ranAt": "Ran at", + "duration": "Duration", + "details": "Details", + "empty": "No log entries found." + }, + "detail": { + "title": "Log details", + "description": "Full information about this automation run.", + "ranAt": "Ran at", + "triggerEvent": "Trigger event", + "automation": "Automation", + "status": "Status", + "error": "Error", + "noEmails": "No email recipients.", + "duration": "Duration", + "recipient": "Recipient", + "template": "Template", + "language": "Language", + "skipReason": "Reason for skipping", + "failReason": "Reason for failure", + "emailsTitle": "Emails ({{count}})" + } + }, + "automationBuilder": { + "header": { + "back": "Back to automations", + "automations": "Automations", + "save": "Save", + "simulate": "Simulate", + "active": "Active", + "draft": "Draft", + "duplicate": "Duplicate", + "exportJson": "Export as JSON", + "delete": "Delete automation", + "savedJustNow": "Saved just now", + "savedMinutesAgo": "Saved {{count}} min ago", + "simulationRequiredTooltip": "Run a simulation and ensure it passes before activating the automation", + "invalidNodesTooltip": "Fix all invalid nodes before activating the automation", + "unsavedChangesTooltip": "Save changes before activating the automation", + "leaveDialog": { + "title": "Unsaved changes", + "description": "You have unsaved changes. Would you like to save before leaving?", + "saveAndLeave": "Save and leave", + "leaveWithoutSaving": "Leave without saving", + "cancel": "Cancel" + }, + "deleteDialog": { + "title": "Delete automation", + "description": "Are you sure you want to delete this automation? This action cannot be undone.", + "confirm": "Delete", + "cancel": "Cancel" + } + }, + "sidebar": { + "title": "Steps", + "description": "Drag blocks onto the canvas to build your workflow.", + "descriptionTrigger": "Start by choosing a trigger for your automation.", + "descriptionActions": "Add actions that will execute when the trigger fires.", + "triggerInstruction": "Every automation starts with a trigger. Drag one onto the canvas to begin building your workflow.", + "triggers": "Triggers", + "actions": "Actions" + }, + "blocks": { + "courseDeadline": "Course deadline", + "overdue": "Overdue", + "notCompleted": "Not completed", + "userEnrolled": "User enrolled", + "certificateExpiringSoon": "Certificate expiring soon", + "liveTransmissionStartingSoon": "Live transmission starting soon", + "sendEmail": "Send email", + "userInvited": "User invited", + "usersImportedInvite": "Users imported (invite)", + "userPasswordReminder": "Password reminder", + "userPasswordChanged": "Password changed", + "userWelcome": "Welcome message", + "userFirstLogin": "First login", + "usersAssignedToCourse": "Users assigned to course", + "usersShortInactivity": "Short inactivity", + "usersLongInactivity": "Long inactivity", + "userChapterFinished": "Chapter finished", + "userCourseFinished": "Course finished", + "userRegistered": "User registered", + "userPasswordCreated": "Password created", + "courseCompleted": "Course completed", + "certificateExpirationWarning": "Certificate expiration warning", + "certificateArchived": "Certificate archived", + "announcementPublished": "Announcement published", + "courseChatUserMentioned": "User mentioned in chat", + "courseDueDateReminder": "Course due date reminder" + }, + "canvas": { + "emptyTitle": "Your automation canvas is empty", + "emptyDescription": "Drag a trigger or action from the sidebar to get started.", + "emptyDescriptionTrigger": "Drag a trigger from the sidebar to start building your automation.", + "removeNode": "Remove node", + "simulationFailed": "Simulation detected errors on this node", + "deleteNodeDialogTitle": "Remove node", + "deleteNodeDialogDescription": "Are you sure you want to remove this node? This action cannot be undone.", + "deleteNodeDialogCancel": "Cancel", + "deleteNodeDialogConfirm": "Remove", + "addChild": "Add child step", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "zoomReset": "Reset zoom" + }, + "editPanel": { + "editTrigger": "Edit Trigger", + "editAction": "Edit Action", + "nodeType": "Node type", + "changeTriggerLabel": "Change trigger to", + "changeTriggerDialogTitle": "Change trigger", + "changeTriggerDialogDescription": "Changing the trigger type will remove all actions and conditions from the automation. Only the trigger will remain on the canvas. Are you sure you want to continue?", + "changeTriggerDialogCancel": "Cancel", + "changeTriggerDialogConfirm": "Delete all and change trigger", + "triggerValue": "Value", + "triggerValuePlaceholder": "Enter value...", + "operator": "Operator", + "operatorEquals": "Equals", + "operatorGreaterThan": "Greater than", + "operatorLessThan": "Less than", + "operatorContains": "Contains", + "emailSubject": "Email subject", + "emailSubjectPlaceholder": "Enter subject...", + "emailTemplate": "Email template", + "emailTemplatePlaceholder": "Select a template", + "emailBody": "Email body", + "emailBodyPlaceholder": "Write your email content...", + "emailRecipient": "Recipient", + "recipientEnrolledUser": "Enrolled user", + "recipientAdmin": "Administrator", + "recipientManager": "Manager", + "removeNode": "Remove this step", + "deleteNodeDialogTitle": "Remove node", + "deleteNodeDialogDescription": "Are you sure you want to remove this node? This action cannot be undone.", + "deleteNodeDialogCancel": "Cancel", + "deleteNodeDialogConfirm": "Remove" + }, + "config": { + "daysBefore": "Days before", + "daysBeforePlaceholder": "e.g. 7", + "course": "Course", + "coursePlaceholder": "Select a course", + "daysOverdue": "Days overdue", + "daysOverduePlaceholder": "e.g. 3", + "daysEnrolled": "Days since enrollment", + "daysEnrolledPlaceholder": "e.g. 30", + "minutesBefore": "Minutes before", + "minutesBeforePlaceholder": "e.g. 60", + "daysInactive": "Days of inactivity", + "daysInactivePlaceholder": "e.g. 14" + }, + "editAction": { + "title": "Edit Action", + "sendEmail": "Send Email", + "emailTemplate": "Email template", + "selectTemplate": "Select a template...", + "language": "Language", + "userDefaultLanguage": "User's default language", + "placeholders": "Template placeholders", + "selectTemplateFirst": "Select an email template to see available placeholders.", + "noPlaceholders": "This template has no placeholders to fill.", + "placeholdersDescription": "Map each template placeholder to a variable provided by the trigger.", + "selectVariable": "Select trigger variable...", + "noTriggerVariables": "No trigger is connected to this action. Add a trigger first to map variables.", + "defaultEmailNoMapping": "The default email template does not require any placeholder mapping.", + "systemTemplateNoMapping": "This system template automatically uses data from the trigger. No manual mapping is required.", + "defaultTemplatesGroup": "System templates", + "customTemplatesGroup": "Custom templates", + "noCustomTemplates": "No published custom templates available.", + "templates": { + "defaultEmail": "Default Email", + "userInvite": "User Invite", + "welcome": "Welcome", + "userFirstLogin": "First Login", + "userAssignedToCourse": "User Assigned to Course", + "userShortInactivity": "Short Inactivity", + "userLongInactivity": "Long Inactivity", + "userFinishedChapter": "Chapter Finished", + "userFinishedCourse": "Course Finished", + "createPasswordReminder": "Create Password Reminder", + "certificateExpirationWarning": "Certificate Expiration Warning", + "certificateExpired": "Certificate Expired", + "announcement": "Announcement", + "courseDueDateReminder": "Course Due Date Reminder", + "newUser": "New User", + "finishedCourse": "Finished Course" + } + }, + "variables": { + "userFirstName": "User first name", + "userLastName": "User last name", + "userEmail": "Email address", + "inviteLink": "Activation link", + "inviteLinkRegistration": "Registration link", + "resetPasswordLink": "Password reset link", + "platformUrl": "Platform link", + "loginDate": "First login date", + "courseName": "Course name", + "courseUrl": "Course link", + "dueDate": "Due date", + "daysInactive": "Days of inactivity", + "chapterName": "Chapter name", + "finishedAt": "Completion date", + "certificateUrl": "Certificate link", + "registrationDate": "Registration date", + "createdAt": "Creation date", + "daysLeft": "Days remaining", + "daysLeftExpiration": "Days until expiration", + "certificateName": "Certificate name", + "expirationDate": "Expiration date", + "archivedAt": "Archive date", + "recipientFirstName": "Recipient first name", + "recipientLastName": "Recipient last name", + "announcementTitle": "Announcement title", + "announcementContent": "Announcement content", + "announcementUrl": "Announcement link", + "mentionedFirstName": "Mentioned user first name", + "mentionedLastName": "Mentioned user last name", + "authorFullName": "Author full name", + "messageContent": "Message content", + "chatUrl": "Message link", + "invitedByUserName": "Invited by (name)", + "hasCertificate": "Has certificate", + "archiveReason": "Archive reason", + "profileLink": "User profile link", + "progressLink": "Course progress link", + "userName": "Full name" + }, + "simulation": { + "title": "Simulation Result", + "statusSuccess": "Success", + "statusFailed": "Failed", + "errorTitle": "Simulation Error", + "retry": "Retry Simulation", + "errorsTitle": "Detected Issues", + "readyToActivate": "Automation is ready to activate.", + "tabPreview": "Email Preview", + "tabEventData": "Event Data", + "tabMappings": "Mappings", + "from": "From:", + "to": "To:", + "subject": "Subject:", + "noEventData": "No event data — select a trigger type.", + "availableVariables": "Available Event Variables", + "variableName": "Variable", + "variableLabel": "Label", + "variableType": "Type", + "placeholder": "Placeholder", + "mappedTo": "Mapped to", + "sampleValue": "Sample Value", + "unmapped": "Unmapped", + "errors": { + "triggerNodeName": "Start event", + "selectTriggerType": "Select a start event type", + "addTriggerNode": "Add at least one trigger node", + "actionNodeName": "Send email", + "actionLabel": "Action", + "selectEmailTemplate": "Select a published email template", + "selectLanguage": "Select the email template language", + "unmappedPlaceholder": "Placeholder {{placeholder}} is not mapped — assign an event variable", + "addActionNode": "Add at least one action node (e.g. Send email)" + }, + "preview": { + "subject": "You have been assigned to course: {{courseName}}", + "greeting": "Hi {{name}}!", + "assignedToCourse": "You have been assigned to a new course: {{courseName}}.", + "goToCourse": "Go to course", + "unavailable": "Preview unavailable", + "loadFailed": "Could not load the template preview.", + "label": "Preview", + "systemTemplateNote": "System template {{templateLabel}} — default content is generated automatically during delivery.", + "emailDescription": "This email will be sent with content matching the selected trigger and mapped variables.", + "platformName": "Mentingo Learning Platform" + }, + "sampleData": { + "firstName": "John", + "lastName": "Smith", + "fullName": "John Smith", + "email": "john.smith@example.com", + "courseName": "Health & Safety Training 2025", + "chapterName": "Chapter 1: Introduction", + "certificateName": "H&S Certificate", + "announcementTitle": "New training available", + "announcementContent": "We invite you to a new training course...", + "authorFullName": "Jane Doe", + "messageContent": "Hey, check this out!", + "daysLeft": "30", + "daysInactive": "14", + "invitedByUserName": "Jane Doe" + } + } + }, + "automationSteps": { + "toast": { + "notFound": "Automation step not found", + "idMismatch": "You can't change step's parent or automation", + "updateFailed": "Couldn't update automation step", + "deleteFailed": "Error while deleting automation step", + "nodeDeleteFailed": "Error while finding node to delete", + "noRootStep": "Empty automation has to have root step first", + "hasRootAlready": "Automation already has a root step", + "cycleDetected": "Automation step tree can't have cycles", + "stepTreeBuildFailed": "Error while building step tree", + "wrongNumberOfRoots": "Automation can have only 1 root", + "treeNotConnected": "Not all provided steps are connected with each other", + "bulkInsertFailed": "Could not update automation steps" + } } } diff --git a/apps/web/app/locales/es/translation.json b/apps/web/app/locales/es/translation.json index 98abd98e4e..ecebb437b1 100644 --- a/apps/web/app/locales/es/translation.json +++ b/apps/web/app/locales/es/translation.json @@ -16,7 +16,6 @@ "clearAll": "Borrar todo", "validate": "Validar", "edit": "Editar", - "delete": "Eliminar", "uploading": "Subiendo...", "sending": "Enviando..." }, @@ -329,6 +328,7 @@ } }, "navigationSideBar": { + "automation": "Automatizaciones", "settings": "Configuración", "logout": "Cerrar sesión", "panel": "Panel", @@ -353,6 +353,7 @@ "announcements": "Anuncios", "notifications": "Notificaciones", "promotionCodes": "Códigos de promoción", + "emailTemplates": "Plantillas de correo electrónico", "manage": "Administrar", "ariaLabels": { "goToAvailableCourses": "Ir a cursos disponibles" @@ -4115,7 +4116,9 @@ "tenants": "Organizaciones", "learningPaths": "Rutas de desarrollo", "adminLearningPaths": "Rutas de desarrollo", - "adminLearningPathEditor": "Editor de ruta de desarrollo" + "adminLearningPathEditor": "Editor de ruta de desarrollo", + "emailTemplates": "Plantillas de correo electrónico", + "editEmailTemplate": "Editar plantilla de correo electrónico" }, "learningPathsView": { "title": "Rutas de desarrollo", @@ -4928,5 +4931,581 @@ "maxParticipantsReached": "Se ha alcanzado el número máximo de participantes.", "maxParallelSessionsReached": "Se alcanzó el número máximo de sesiones de formación en vivo activas." } + }, + "emailTemplates": { + "breadcrumbs": { + "list": "Plantillas de correo", + "edit": "Editar" + }, + "list": { + "title": "Plantillas de correo", + "createButton": "Crear nueva", + "deleteSelected": "Eliminar seleccionadas", + "searchPlaceholder": "Buscar por nombre", + "statusFilter": "Estado", + "empty": "Aún no hay plantillas de correo.", + "loading": "Cargando plantillas...", + "loadFailed": "No se pudieron cargar las plantillas de correo.", + "status": { + "all": "Todas" + }, + "columns": { + "name": "Nombre", + "status": "Estado", + "languages": "Idiomas", + "updatedAt": "Actualizada", + "selectAll": "Seleccionar todo", + "selectRow": "Seleccionar fila" + } + }, + "deleteModal": { + "titleSingle": "Eliminar plantilla de correo", + "titleMultiple": "Eliminar plantillas de correo", + "descriptionSingle": "¿Seguro que quieres eliminar esta plantilla de correo? Esta acción no se puede deshacer.", + "descriptionMultiple": "¿Seguro que quieres eliminar {{count}} plantillas de correo? Esta acción no se puede deshacer." + }, + "status": { + "draft": "Borrador", + "published": "Publicada", + "archived": "Archivada" + }, + "edit": { + "loadFailed": "No se pudo cargar esta plantilla de correo." + }, + "form": { + "field": { + "name": "Nombre", + "subject": "Asunto", + "subjectHelp": "Rellena el asunto para cada idioma. Puedes usar variables como {{user.first_name}}.", + "subjectPlaceholder": "p. ej. Bienvenido, {{user.first_name}}" + }, + "errors": { + "nameRequired": "El nombre es obligatorio.", + "nameTooLong": "El nombre es demasiado largo.", + "localesRequired": "Elige al menos un idioma.", + "baseLanguageMissing": "El idioma base debe estar entre los idiomas disponibles." + } + }, + "toast": { + "createdSuccessfully": "Plantilla de correo creada.", + "createFailed": "No se pudo crear la plantilla de correo.", + "updatedSuccessfully": "Plantilla de correo guardada.", + "updateFailed": "No se pudo guardar la plantilla de correo.", + "publishedSuccessfully": "Plantilla de correo publicada.", + "publishFailed": "No se pudo publicar la plantilla de correo.", + "publishBlocked": "No se puede publicar — revisa la diagnóstica de abajo.", + "archivedSuccessfully": "Plantilla de correo archivada.", + "archiveFailed": "No se pudo archivar la plantilla de correo.", + "unarchivedSuccessfully": "Plantilla de correo restaurada como borrador.", + "unarchiveFailed": "No se pudo desarchivar la plantilla de correo.", + "previewFailed": "No se pudo generar la vista previa.", + "previewLanguageUnavailable": "Este idioma no está disponible para esta plantilla.", + "duplicatedSuccessfully": "Plantilla de correo duplicada.", + "duplicateFailed": "No se pudo duplicar la plantilla de correo.", + "madeDraftSuccessfully": "Plantilla de correo revertida a borrador.", + "makeDraftFailed": "No se pudo revertir la plantilla de correo a borrador.", + "deletedSuccessfully": "Plantilla de correo eliminada.", + "deleteFailed": "No se pudo eliminar la plantilla de correo.", + "nameAlreadyExists": "Ya existe una plantilla de correo con este nombre.", + "testEmailSentSuccessfully": "Correo de prueba enviado.", + "testEmailSendFailed": "No se pudo enviar el correo de prueba." + }, + "actions": { + "preview": "Vista previa", + "duplicate": "Duplicar", + "archive": "Archivar", + "unarchive": "Desarchivar", + "publish": "Publicar", + "makeDraft": "Marcar como borrador", + "edit": "Editar", + "rename": "Renombrar", + "sendTest": "Enviar prueba" + }, + "language": { + "label": "Idioma", + "baseLanguage": "Predeterminado", + "notAddedLanguages": "Idiomas para añadir", + "setBaseLanguage": "Establecer como predeterminado", + "setBaseTitle": "Cambiar idioma predeterminado", + "setBaseDescription": "¿Usar {{language}} como idioma predeterminado para esta plantilla?", + "createTitle": "Añadir traducción", + "createDescription": "¿Añadir una traducción de esta plantilla en {{language}}?", + "deleteTitle": "Eliminar traducción", + "deleteDescription": "¿Eliminar la traducción {{language}}? Su contenido se perderá." + }, + "publishDiagnostics": { + "errorsTitle_one": "{{count}} error — debe corregirse antes de publicar", + "errorsTitle_other": "{{count}} errores — deben corregirse antes de publicar", + "warningsTitle_one": "{{count}} advertencia — revísala antes de publicar", + "warningsTitle_other": "{{count}} advertencias — revísalas antes de publicar", + "elementIndex": "Elemento {{index}}", + "reasons": { + "name_missing": "El nombre de la plantilla es obligatorio", + "no_language_versions": "Añade al menos una versión de idioma", + "subject_missing": "El asunto es obligatorio para el idioma predeterminado", + "body_missing": "El cuerpo del correo está vacío", + "button_label_missing": "El texto del botón es obligatorio", + "button_url_missing": "El destino del botón es obligatorio", + "empty_translation": "La traducción está vacía", + "invalid_url_protocol": "La URL usa un protocolo no permitido", + "unchanged_from_base": "La traducción es idéntica al idioma base", + "footer_missing": "Falta el pie de página" + }, + "nodeTypes": { + "heading": "Encabezado", + "paragraph": "Texto", + "button": "Botón", + "footer": "Pie de página", + "image": "Imagen" + }, + "blockedToast": { + "save": "No se puede guardar la plantilla. Consulta el diagnóstico", + "publish": "No se puede publicar la plantilla. Consulta el diagnóstico" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Escribe / para abrir el menú de bloques", + "heading": "Encabezado {{level}}", + "htmlCode": "Código HTML…" + }, + "blocks": { + "groups": { + "text": "Texto", + "media": "Multimedia", + "structure": "Estructura", + "interactive": "Interactivo", + "footer": "Pie de página" + }, + "text": { + "title": "Texto", + "description": "Párrafo de texto sin formato." + }, + "heading1": { + "title": "Encabezado 1", + "description": "Encabezado grande de sección (H1)." + }, + "heading2": { + "title": "Encabezado 2", + "description": "Encabezado mediano de sección (H2)." + }, + "heading3": { + "title": "Encabezado 3", + "description": "Encabezado pequeño de sección (H3)." + }, + "image": { + "title": "Imagen", + "description": "Imagen a ancho completo." + }, + "logoHeader": { + "title": "Logo", + "description": "Logo completo." + }, + "section": { + "title": "Sección", + "description": "Contenedor de contenido agrupado." + }, + "columns": { + "title": "Columnas", + "description": "Diseño de varias columnas." + }, + "divider": { + "title": "Divisor", + "description": "Línea divisoria horizontal." + }, + "spacer": { + "title": "Espaciador", + "description": "Espacio vertical entre bloques." + }, + "button": { + "title": "Botón", + "description": "Botón de llamada a la acción." + }, + "footer": { + "title": "Pie de página", + "description": "Bloque de texto del pie de página." + } + } + }, + "image": { + "uploadFailed": "Error al subir la imagen. Por favor, inténtelo de nuevo.", + "tooLarge": "La imagen es demasiado grande. El tamaño máximo es 10 MB.", + "invalidType": "Tipo de imagen no válido. Permitidos: JPEG, PNG, GIF, WebP, BMP, TIFF." + } + }, + "automationView": { + "title": "Automatizaciones", + "description": "Gestione las notificaciones automáticas por correo electrónico enviadas en respuesta a eventos de la plataforma.", + "createAutomation": "Crear automatización", + "filters": { + "searchPlaceholder": "Buscar por nombre o descripción...", + "all": "Todas", + "enabled": "Habilitadas", + "disabled": "Deshabilitadas", + "drafts": "Borradores", + "archived": "Archivadas" + }, + "status": { + "enabled": "Habilitada", + "disabled": "Deshabilitada", + "draft": "Borrador", + "archived": "Archivada" + }, + "table": { + "name": "Nombre de la automatización", + "status": "Estado", + "trigger": "Disparador", + "actions": "Acciones", + "lastRun": "Última ejecución", + "updatedAt": "Actualizado", + "menu": "Menú", + "manage": "Gestionar", + "empty": "No hay automatizaciones definidas. Haga clic en Crear automatización para agregar la primera.", + "emptyFiltered": "Ninguna automatización coincide con los filtros actuales. Intente ajustar la búsqueda o el filtro de estado.", + "emailCount_one": "{{count}} correo", + "emailCount_other": "{{count}} correos", + "noRuns": "Sin ejecuciones aún", + "runSuccess": "Completado con éxito", + "runFailed": "Ocurrió un error" + }, + "drawer": { + "title": "Detalles de la automatización", + "description": "Edite la configuración de la automatización seleccionada.", + "nameLabel": "Nombre de la automatización", + "descriptionLabel": "Descripción", + "statusLabel": "Estado", + "statusDraft": "Borrador", + "statusEnabled": "Activa (Habilitada)", + "statusDisabled": "Inactiva (Deshabilitada)", + "statusArchived": "Archivada", + "flowManagement": "Gestión del flujo", + "openBuilder": "Abrir editor de pasos", + "pause": "Pausar", + "activate": "Activar", + "simulationRequiredTooltip": "La automatización debe pasar primero una simulación", + "archive": "Archivar", + "save": "Guardar cambios", + "cancel": "Cancelar", + "delete": "Eliminar" + }, + "deleteDialog": { + "title": "¿Eliminar automatización?", + "description": "Esta acción es irreversible. La automatización se eliminará permanentemente. En su lugar, puede archivarla.", + "confirm": "Eliminar", + "cancel": "Cancelar" + }, + "actionMenu": { + "openMenu": "Abrir menú", + "settingsAndEdit": "Configuración y edición", + "disable": "Deshabilitar notificación", + "enable": "Habilitar notificación", + "delete": "Eliminar automatización" + }, + "newAutomation": { + "name": "Nueva automatización (Borrador)", + "description": "Defina el objetivo y la descripción de esta automatización y luego proceda al constructor de flujo.", + "triggerPlaceholder": "Por configurar..." + }, + "seedDefaults": { + "button": "Generar predeterminados", + "dialog": { + "title": "¿Generar automatizaciones predeterminadas?", + "description": "Se crearán todas las automatizaciones estándar (p. ej. invitación de usuario, bienvenida, recordatorio de contraseña, etc.) con plantillas de email predeterminadas. Las automatizaciones existentes no se sobrescribirán.", + "warning": "Advertencia: Las nuevas automatizaciones se crearán en estado activo y comenzarán a funcionar inmediatamente. Asegúrese de que sus plantillas de email estén configuradas correctamente.", + "confirm": "Generar", + "cancel": "Cancelar" + }, + "toasts": { + "success": "Se generaron {{created}} automatizaciones (se omitieron {{skipped}} existentes).", + "error": "No se pudieron generar las automatizaciones predeterminadas." + } + } + }, + + "automationBuilder": { + "header": { + "back": "Volver a automatizaciones", + "automations": "Automatizaciones", + "save": "Guardar", + "simulate": "Simular", + "active": "Activa", + "draft": "Borrador", + "duplicate": "Duplicar", + "exportJson": "Exportar como JSON", + "delete": "Eliminar automatización", + "savedJustNow": "Guardado justo ahora", + "savedMinutesAgo": "Guardado hace {{count}} min", + "simulationRequiredTooltip": "Ejecute una simulación y asegúrese de que pase correctamente para activar la automatización", + "invalidNodesTooltip": "Corrija todos los nodos inválidos antes de activar la automatización", + "unsavedChangesTooltip": "Guarde los cambios antes de activar la automatización", + "leaveDialog": { + "title": "Cambios sin guardar", + "description": "Tiene cambios sin guardar. ¿Desea guardar antes de salir?", + "saveAndLeave": "Guardar y salir", + "leaveWithoutSaving": "Salir sin guardar", + "cancel": "Cancelar" + }, + "deleteDialog": { + "title": "Eliminar automatización", + "description": "¿Está seguro de que desea eliminar esta automatización? Esta acción no se puede deshacer.", + "confirm": "Eliminar", + "cancel": "Cancelar" + } + }, + "sidebar": { + "title": "Pasos", + "description": "Arrastra bloques al lienzo para construir tu flujo de trabajo.", + "descriptionTrigger": "Comienza eligiendo un disparador para tu automatización.", + "descriptionActions": "Añade acciones que se ejecutarán cuando se active el disparador.", + "triggerInstruction": "Toda automatización comienza con un disparador. Arrastra uno al lienzo para empezar a construir tu flujo de trabajo.", + "triggers": "Disparadores", + "actions": "Acciones" + }, + "blocks": { + "courseDeadline": "Plazo del curso", + "overdue": "Vencido", + "notCompleted": "No completado", + "userEnrolled": "Usuario inscrito", + "certificateExpiringSoon": "Certificado por vencer", + "liveTransmissionStartingSoon": "Transmisión en vivo próxima", + "sendEmail": "Enviar correo electrónico", + "userInvited": "Usuario invitado", + "usersImportedInvite": "Usuarios importados (invitación)", + "userPasswordReminder": "Recordatorio de contraseña", + "userPasswordChanged": "Contraseña cambiada", + "userWelcome": "Mensaje de bienvenida", + "userFirstLogin": "Primer inicio de sesión", + "usersAssignedToCourse": "Usuarios asignados al curso", + "usersShortInactivity": "Inactividad corta", + "usersLongInactivity": "Inactividad prolongada", + "userChapterFinished": "Capítulo completado", + "userCourseFinished": "Curso completado", + "userRegistered": "Usuario registrado", + "userPasswordCreated": "Contraseña creada", + "courseCompleted": "Curso aprobado", + "certificateExpirationWarning": "Aviso de vencimiento de certificado", + "certificateArchived": "Certificado archivado", + "announcementPublished": "Anuncio publicado", + "courseChatUserMentioned": "Usuario mencionado en el chat", + "courseDueDateReminder": "Recordatorio de plazo del curso" + }, + "canvas": { + "emptyTitle": "El lienzo de automatización está vacío", + "emptyDescription": "Arrastra un disparador o acción desde el panel lateral para comenzar.", + "emptyDescriptionTrigger": "Arrastra un disparador desde el panel lateral para comenzar a construir tu automatización.", + "removeNode": "Eliminar nodo", + "simulationFailed": "La simulación detectó errores en este nodo", + "deleteNodeDialogTitle": "Eliminar nodo", + "deleteNodeDialogDescription": "¿Estás seguro de que deseas eliminar este nodo? Esta acción no se puede deshacer.", + "deleteNodeDialogCancel": "Cancelar", + "deleteNodeDialogConfirm": "Eliminar", + "addChild": "Añadir paso secundario", + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "zoomReset": "Restablecer zoom" + }, + "editPanel": { + "editTrigger": "Editar disparador", + "editAction": "Editar acción", + "nodeType": "Tipo de nodo", + "changeTriggerLabel": "Cambiar disparador a", + "changeTriggerDialogTitle": "Cambiar disparador", + "changeTriggerDialogDescription": "Cambiar el tipo de disparador eliminará todas las acciones y condiciones de la automatización. Solo quedará el disparador en el lienzo. ¿Deseas continuar?", + "changeTriggerDialogCancel": "Cancelar", + "changeTriggerDialogConfirm": "Eliminar todo y cambiar disparador", + "triggerValue": "Valor", + "triggerValuePlaceholder": "Introduce un valor...", + "operator": "Operador", + "operatorEquals": "Igual a", + "operatorGreaterThan": "Mayor que", + "operatorLessThan": "Menor que", + "operatorContains": "Contiene", + "emailSubject": "Asunto del correo", + "emailSubjectPlaceholder": "Introduce el asunto...", + "emailTemplate": "Plantilla de correo", + "emailTemplatePlaceholder": "Selecciona una plantilla", + "emailBody": "Cuerpo del correo", + "emailBodyPlaceholder": "Escribe el contenido del correo...", + "emailRecipient": "Destinatario", + "recipientEnrolledUser": "Usuario inscrito", + "recipientAdmin": "Administrador", + "recipientManager": "Gerente", + "removeNode": "Eliminar este paso", + "deleteNodeDialogTitle": "Eliminar nodo", + "deleteNodeDialogDescription": "¿Estás seguro de que deseas eliminar este nodo? Esta acción no se puede deshacer.", + "deleteNodeDialogCancel": "Cancelar", + "deleteNodeDialogConfirm": "Eliminar" + }, + "config": { + "daysBefore": "Días antes", + "daysBeforePlaceholder": "ej. 7", + "course": "Curso", + "coursePlaceholder": "Selecciona un curso", + "daysOverdue": "Días de retraso", + "daysOverduePlaceholder": "ej. 3", + "daysEnrolled": "Días desde la inscripción", + "daysEnrolledPlaceholder": "ej. 30", + "minutesBefore": "Minutos antes", + "minutesBeforePlaceholder": "ej. 60", + "daysInactive": "Días de inactividad", + "daysInactivePlaceholder": "ej. 14" + }, + "editAction": { + "title": "Editar acción", + "sendEmail": "Enviar correo electrónico", + "emailTemplate": "Plantilla de correo", + "selectTemplate": "Selecciona una plantilla...", + "language": "Idioma", + "userDefaultLanguage": "Idioma predeterminado del usuario", + "placeholders": "Variables de la plantilla", + "selectTemplateFirst": "Selecciona una plantilla de correo para ver las variables disponibles.", + "noPlaceholders": "Esta plantilla no tiene variables para completar.", + "placeholdersDescription": "Asigna cada variable de la plantilla a los datos proporcionados por el disparador.", + "selectVariable": "Selecciona variable del disparador...", + "noTriggerVariables": "No hay disparador conectado a esta acción. Añade un disparador primero para mapear variables.", + "defaultEmailNoMapping": "La plantilla de correo predeterminada no requiere mapeo de variables.", + "systemTemplateNoMapping": "Esta plantilla del sistema utiliza automáticamente los datos del disparador. No se requiere mapeo manual.", + "defaultTemplatesGroup": "Plantillas del sistema", + "customTemplatesGroup": "Plantillas personalizadas", + "noCustomTemplates": "No hay plantillas personalizadas publicadas disponibles.", + "templates": { + "defaultEmail": "Correo predeterminado", + "userInvite": "Invitación de usuario", + "welcome": "Bienvenida", + "userFirstLogin": "Primer inicio de sesión", + "userAssignedToCourse": "Usuario asignado al curso", + "userShortInactivity": "Inactividad corta", + "userLongInactivity": "Inactividad prolongada", + "userFinishedChapter": "Capítulo completado", + "userFinishedCourse": "Curso completado", + "createPasswordReminder": "Recordatorio de creación de contraseña", + "certificateExpirationWarning": "Advertencia de vencimiento de certificado", + "certificateExpired": "Certificado vencido", + "announcement": "Anuncio", + "courseDueDateReminder": "Recordatorio de fecha límite del curso", + "newUser": "Nuevo usuario", + "finishedCourse": "Curso finalizado" + } + }, + "variables": { + "userFirstName": "Nombre del usuario", + "userLastName": "Apellido del usuario", + "userEmail": "Dirección de correo electrónico", + "inviteLink": "Enlace de activación", + "inviteLinkRegistration": "Enlace de registro", + "resetPasswordLink": "Enlace para restablecer contraseña", + "platformUrl": "Enlace a la plataforma", + "loginDate": "Fecha del primer inicio de sesión", + "courseName": "Nombre del curso", + "courseUrl": "Enlace al curso", + "dueDate": "Fecha límite", + "daysInactive": "Días de inactividad", + "chapterName": "Nombre del capítulo", + "finishedAt": "Fecha de finalización", + "certificateUrl": "Enlace al certificado", + "registrationDate": "Fecha de registro", + "createdAt": "Fecha de creación", + "daysLeft": "Días restantes", + "daysLeftExpiration": "Días hasta el vencimiento", + "certificateName": "Nombre del certificado", + "expirationDate": "Fecha de vencimiento", + "archivedAt": "Fecha de archivado", + "recipientFirstName": "Nombre del destinatario", + "recipientLastName": "Apellido del destinatario", + "announcementTitle": "Título del anuncio", + "announcementContent": "Contenido del anuncio", + "announcementUrl": "Enlace al anuncio", + "mentionedFirstName": "Nombre del usuario mencionado", + "mentionedLastName": "Apellido del usuario mencionado", + "authorFullName": "Nombre completo del autor", + "messageContent": "Contenido del mensaje", + "chatUrl": "Enlace al mensaje", + "invitedByUserName": "Invitado por (nombre)", + "hasCertificate": "Tiene certificado", + "archiveReason": "Motivo de archivo", + "profileLink": "Enlace al perfil del usuario", + "progressLink": "Enlace al progreso del curso", + "userName": "Nombre completo" + }, + "simulation": { + "title": "Resultado de la simulación", + "statusSuccess": "Éxito", + "statusFailed": "Fallido", + "errorTitle": "Error de simulación", + "retry": "Reintentar simulación", + "errorsTitle": "Problemas detectados", + "readyToActivate": "La automatización está lista para activarse.", + "tabPreview": "Vista previa del correo", + "tabEventData": "Datos del evento", + "tabMappings": "Mapeos", + "from": "De:", + "to": "Para:", + "subject": "Asunto:", + "noEventData": "Sin datos del evento — selecciona un tipo de disparador.", + "availableVariables": "Variables de evento disponibles", + "variableName": "Variable", + "variableLabel": "Descripción", + "variableType": "Tipo", + "placeholder": "Marcador", + "mappedTo": "Mapeado a", + "sampleValue": "Valor de ejemplo", + "unmapped": "Sin mapear", + "errors": { + "triggerNodeName": "Evento inicial", + "selectTriggerType": "Seleccione un tipo de evento inicial", + "addTriggerNode": "Agregue al menos un nodo de trigger", + "actionNodeName": "Enviar correo", + "actionLabel": "Acción", + "selectEmailTemplate": "Seleccione una plantilla de correo publicada", + "selectLanguage": "Seleccione el idioma de la plantilla de correo", + "unmappedPlaceholder": "El placeholder {{placeholder}} no está mapeado — asigne una variable de evento", + "addActionNode": "Agregue al menos un nodo de acción (ej. Enviar correo)" + }, + "preview": { + "subject": "Has sido asignado al curso: {{courseName}}", + "greeting": "¡Hola {{name}}!", + "assignedToCourse": "Has sido asignado a un nuevo curso: {{courseName}}.", + "goToCourse": "Ir al curso", + "unavailable": "Vista previa no disponible", + "loadFailed": "No se pudo cargar la vista previa de la plantilla.", + "label": "Vista previa", + "systemTemplateNote": "Plantilla del sistema {{templateLabel}} — el contenido predeterminado se genera automáticamente durante el envío.", + "emailDescription": "Este correo electrónico se enviará con el contenido correspondiente al trigger seleccionado y las variables mapeadas.", + "platformName": "Mentingo Learning Platform" + }, + "sampleData": { + "firstName": "Juan", + "lastName": "García", + "fullName": "Juan García", + "email": "juan.garcia@example.com", + "courseName": "Formación en Seguridad Laboral 2025", + "chapterName": "Capítulo 1: Introducción", + "certificateName": "Certificado de Seguridad", + "announcementTitle": "Nueva formación disponible", + "announcementContent": "Le invitamos a una nueva formación...", + "authorFullName": "María López", + "messageContent": "¡Oye, mira esto!", + "daysLeft": "30", + "daysInactive": "14", + "invitedByUserName": "María López" + } + } + }, + + "automationSteps": { + "toast": { + "notFound": "Paso de automatización no encontrado", + "idMismatch": "No puedes cambiar el paso padre ni la automatización", + "updateFailed": "No se pudo actualizar el paso de automatización", + "deleteFailed": "Error al eliminar el paso de automatización", + "nodeDeleteFailed": "Error al encontrar el nodo para eliminar", + "noRootStep": "Una automatización vacía debe tener primero un paso raíz", + "hasRootAlready": "La automatización ya tiene un paso raíz", + "cycleDetected": "El árbol de pasos de automatización no puede contener ciclos", + "stepTreeBuildFailed": "Error al construir el árbol de pasos", + "wrongNumberOfRoots": "Una automatización solo puede tener un paso raíz", + "treeNotConnected": "No todos los pasos proporcionados están conectados entre sí", + "bulkInsertFailed": "No se pudieron actualizar los pasos de automatización" + } } } diff --git a/apps/web/app/locales/lt/translation.json b/apps/web/app/locales/lt/translation.json index b41fc0c13b..f2980ff78e 100644 --- a/apps/web/app/locales/lt/translation.json +++ b/apps/web/app/locales/lt/translation.json @@ -16,7 +16,6 @@ "clearAll": "Išvalyti viską", "validate": "Patvirtinti", "edit": "Redaguoti", - "delete": "Ištrinti", "uploading": "Įkeliama...", "sending": "Siunčiama..." }, @@ -329,6 +328,7 @@ } }, "navigationSideBar": { + "automation": "Automazioni", "settings": "Nustatymai", "logout": "Atsijungti", "panel": "skydelis", @@ -353,6 +353,7 @@ "announcements": "Skelbimai", "notifications": "Pranešimai", "promotionCodes": "Reklamos kodai", + "emailTemplates": "El. pašto šablonai", "manage": "Tvarkyti", "ariaLabels": { "goToAvailableCourses": "Eikite į galimus kursus" @@ -4052,7 +4053,9 @@ "activityLogs": "Veiklos žurnalas", "learningPaths": "Tobulėjimo keliai", "adminLearningPaths": "Tobulėjimo keliai", - "adminLearningPathEditor": "Tobulėjimo kelio redaktorius" + "adminLearningPathEditor": "Tobulėjimo kelio redaktorius", + "emailTemplates": "El. pašto šablonai", + "editEmailTemplate": "Redaguoti el. pašto šabloną" }, "masterCourse": { "error": { @@ -4863,5 +4866,589 @@ "maxParticipantsReached": "Pasiektas maksimalus dalyvių skaičius.", "maxParallelSessionsReached": "Pasiektas maksimalus aktyvių Tiesioginiai mokymai sesijų skaičius." } + }, + "emailTemplates": { + "breadcrumbs": { + "list": "El. laiškų šablonai", + "edit": "Redaguoti" + }, + "list": { + "title": "El. laiškų šablonai", + "createButton": "Sukurti naują", + "deleteSelected": "Ištrinti pasirinktus", + "searchPlaceholder": "Ieškoti pagal pavadinimą", + "statusFilter": "Būsena", + "empty": "Šablonų dar nėra.", + "loading": "Kraunami šablonai...", + "loadFailed": "Nepavyko įkelti el. laiškų šablonų.", + "status": { + "all": "Visi" + }, + "columns": { + "name": "Pavadinimas", + "status": "Būsena", + "languages": "Kalbos", + "updatedAt": "Atnaujinta", + "selectAll": "Pažymėti visus", + "selectRow": "Pažymėti eilutę" + } + }, + "deleteModal": { + "titleSingle": "Ištrinti el. laiško šabloną", + "titleMultiple": "Ištrinti el. laiškų šablonus", + "descriptionSingle": "Ar tikrai norite ištrinti šį el. laiško šabloną? Šio veiksmo atšaukti nebus galima.", + "descriptionMultiple": "Ar tikrai norite ištrinti {{count}} el. laiškų šablonus? Šio veiksmo atšaukti nebus galima." + }, + "status": { + "draft": "Juodraštis", + "published": "Paskelbta", + "archived": "Suarchyvuota" + }, + "edit": { + "loadFailed": "Nepavyko įkelti šio el. laiško šablono." + }, + "form": { + "field": { + "name": "Pavadinimas", + "subject": "Temos eilutė", + "subjectHelp": "Užpildykite temą kiekvienai kalbai. Galite naudoti kintamuosius, pvz., {{user.first_name}}.", + "subjectPlaceholder": "pvz., Sveiki, {{user.first_name}}" + }, + "errors": { + "nameRequired": "Pavadinimas privalomas.", + "nameTooLong": "Pavadinimas per ilgas.", + "localesRequired": "Pasirinkite bent vieną kalbą.", + "baseLanguageMissing": "Bazinė kalba turi būti tarp galimų kalbų." + } + }, + "toast": { + "createdSuccessfully": "El. laiško šablonas sukurtas.", + "createFailed": "Nepavyko sukurti el. laiško šablono.", + "updatedSuccessfully": "El. laiško šablonas išsaugotas.", + "updateFailed": "Nepavyko išsaugoti el. laiško šablono.", + "publishedSuccessfully": "El. laiško šablonas paskelbtas.", + "publishFailed": "Nepavyko paskelbti el. laiško šablono.", + "publishBlocked": "Skelbti negalima — žr. diagnostiką žemiau.", + "archivedSuccessfully": "El. laiško šablonas suarchyvuotas.", + "archiveFailed": "Nepavyko suarchyvuoti el. laiško šablono.", + "unarchivedSuccessfully": "El. laiško šablonas grąžintas į juodraštį.", + "unarchiveFailed": "Nepavyko atkurti šablono iš archyvo.", + "previewFailed": "Nepavyko sugeneruoti peržiūros.", + "previewLanguageUnavailable": "Ši kalba šiam šablonui nepasiekiama.", + "duplicatedSuccessfully": "El. laiško šablonas nukopijuotas.", + "duplicateFailed": "Nepavyko nukopijuoti el. laiško šablono.", + "madeDraftSuccessfully": "El. laiško šablonas grąžintas į juodraštį.", + "makeDraftFailed": "Nepavyko grąžinti el. laiško šablono į juodraštį.", + "deletedSuccessfully": "El. laiško šablonas ištrintas.", + "deleteFailed": "Nepavyko ištrinti el. laiško šablono.", + "nameAlreadyExists": "El. laiško šablonas tokiu pavadinimu jau egzistuoja.", + "testEmailSentSuccessfully": "Bandomasis el. laiškas išsiųstas.", + "testEmailSendFailed": "Nepavyko išsiųsti bandomojo el. laiško." + }, + "actions": { + "preview": "Peržiūra", + "duplicate": "Kopijuoti", + "archive": "Archyvuoti", + "unarchive": "Išarchyvuoti", + "publish": "Skelbti", + "makeDraft": "Pažymėti kaip juodraštį", + "edit": "Redaguoti", + "rename": "Pervadinti", + "sendTest": "Siųsti testą" + }, + "language": { + "label": "Kalba", + "baseLanguage": "Numatytoji", + "notAddedLanguages": "Kalbos, kurias galima pridėti", + "setBaseLanguage": "Nustatyti kaip numatytąją", + "setBaseTitle": "Pakeisti numatytąją kalbą", + "setBaseDescription": "Naudoti {{language}} kaip numatytąją šio šablono kalbą?", + "createTitle": "Pridėti vertimą", + "createDescription": "Pridėti šio šablono vertimą į {{language}}?", + "deleteTitle": "Ištrinti vertimą", + "deleteDescription": "Ištrinti vertimą {{language}}? Jo turinys bus pašalintas." + }, + "publishDiagnostics": { + "errorsTitle_one": "{{count}} klaida — būtina ištaisyti prieš skelbiant", + "errorsTitle_few": "{{count}} klaidos — būtina ištaisyti prieš skelbiant", + "errorsTitle_many": "{{count}} klaidų — būtina ištaisyti prieš skelbiant", + "errorsTitle_other": "{{count}} klaidų — būtina ištaisyti prieš skelbiant", + "warningsTitle_one": "{{count}} įspėjimas — peržiūrėkite prieš skelbdami", + "warningsTitle_few": "{{count}} įspėjimai — peržiūrėkite prieš skelbdami", + "warningsTitle_many": "{{count}} įspėjimų — peržiūrėkite prieš skelbdami", + "warningsTitle_other": "{{count}} įspėjimų — peržiūrėkite prieš skelbdami", + "elementIndex": "Elementas {{index}}", + "reasons": { + "name_missing": "Šablono pavadinimas privalomas", + "no_language_versions": "Pridėkite bent vieną kalbos versiją", + "subject_missing": "Numatytosios kalbos temai reikia turinio", + "body_missing": "El. laiško turinys tuščias", + "button_label_missing": "Mygtuko etiketė privaloma", + "button_url_missing": "Mygtuko paskirties adresas privalomas", + "empty_translation": "Vertimas tuščias", + "invalid_url_protocol": "URL naudoja neleistiną protokolą", + "unchanged_from_base": "Vertimas identiškas bazinei kalbai", + "footer_missing": "Trūksta poraštės" + }, + "nodeTypes": { + "heading": "Antraštė", + "paragraph": "Tekstas", + "button": "Mygtukas", + "footer": "Poraštė", + "image": "Vaizdas" + }, + "blockedToast": { + "save": "Šablono išsaugoti nepavyko. Žiūrėkite diagnostiką", + "publish": "Šablono paskelbti nepavyko. Žiūrėkite diagnostiką" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Įveskite /, kad atidarytumėte blokų meniu", + "heading": "Antraštė {{level}}", + "htmlCode": "HTML kodas…" + }, + "blocks": { + "groups": { + "text": "Tekstas", + "media": "Medija", + "structure": "Struktūra", + "interactive": "Interaktyvūs", + "footer": "Poraštė" + }, + "text": { + "title": "Tekstas", + "description": "Paprastojo teksto pastraipa." + }, + "heading1": { + "title": "Antraštė 1", + "description": "Didelė sekcijos antraštė (H1)." + }, + "heading2": { + "title": "Antraštė 2", + "description": "Vidutinė sekcijos antraštė (H2)." + }, + "heading3": { + "title": "Antraštė 3", + "description": "Maža sekcijos antraštė (H3)." + }, + "image": { + "title": "Vaizdas", + "description": "Viso pločio vaizdas." + }, + "logoHeader": { + "title": "Logo", + "description": "Visas logotipas." + }, + "section": { + "title": "Sekcija", + "description": "Turinio grupavimo konteineris." + }, + "columns": { + "title": "Stulpeliai", + "description": "Kelių stulpelių išdėstymas." + }, + "divider": { + "title": "Skirtukas", + "description": "Horizontali skiriamoji linija." + }, + "spacer": { + "title": "Tarpas", + "description": "Vertikalus tarpas tarp blokų." + }, + "button": { + "title": "Mygtukas", + "description": "Raginimo veikti mygtukas." + }, + "footer": { + "title": "Poraštė", + "description": "Poraštės teksto blokas." + } + } + }, + "image": { + "uploadFailed": "Paveikslo įkėlimas nepavyko. Bandykite dar kartą.", + "tooLarge": "Paveikslas per didelis. Maksimalus dydis yra 10 MB.", + "invalidType": "Netinkamas paveikslo tipas. Leidžiami: JPEG, PNG, GIF, WebP, BMP, TIFF." + } + }, + "automationView": { + "title": "Automatizavimai", + "description": "Valdykite automatinius el. pašto pranešimus, siunčiamus reaguojant į platformos įvykius.", + "createAutomation": "Sukurti automatizavimą", + "filters": { + "searchPlaceholder": "Ieškoti pagal pavadinimą arba aprašymą...", + "all": "Visi", + "enabled": "Įjungti", + "disabled": "Išjungti", + "drafts": "Juodraščiai", + "archived": "Archyvuoti" + }, + "status": { + "enabled": "Įjungtas", + "disabled": "Išjungtas", + "draft": "Juodraštis", + "archived": "Archyvuotas" + }, + "table": { + "name": "Automatizavimo pavadinimas", + "status": "Būsena", + "trigger": "Aktyviklis", + "actions": "Veiksmai", + "lastRun": "Paskutinis paleidimas", + "updatedAt": "Atnaujinta", + "menu": "Meniu", + "manage": "Valdyti", + "empty": "Nėra apibrėžtų automatizavimų. Spustelėkite Sukurti automatizavimą, kad pridėtumėte pirmąjį.", + "emptyFiltered": "Jokios automatizacijos neatitinka pasirinktų filtrų. Pabandykite pakeisti paiešką arba būsenos filtrą.", + "emailCount_one": "{{count}} el. laiškas", + "emailCount_other": "{{count}} el. laiškai", + "noRuns": "Dar nebuvo paleista", + "runSuccess": "Sėkmingai baigta", + "runFailed": "Įvyko klaida" + }, + "drawer": { + "title": "Automatizavimo informacija", + "description": "Redaguokite pasirinkto automatizavimo konfigūraciją.", + "nameLabel": "Automatizavimo pavadinimas", + "descriptionLabel": "Aprašymas", + "statusLabel": "Būsena", + "statusDraft": "Juodraštis", + "statusEnabled": "Aktyvus (Įjungtas)", + "statusDisabled": "Neaktyvus (Išjungtas)", + "statusArchived": "Archyvuotas", + "flowManagement": "Srauto valdymas", + "openBuilder": "Atidaryti žingsnių kūrimo priemonę", + "pause": "Pristabdyti", + "activate": "Aktyvuoti", + "simulationRequiredTooltip": "Automatizavimas pirmiausia turi praeiti simuliaciją", + "archive": "Archyvuoti", + "save": "Išsaugoti pakeitimus", + "cancel": "Atšaukti", + "delete": "Ištrinti" + }, + "deleteDialog": { + "title": "Ištrinti automatizavimą?", + "description": "Šis veiksmas yra negrįžtamas. Automatizavimas bus visam laikui ištrintas. Vietoj to galite jį archyvuoti.", + "confirm": "Ištrinti", + "cancel": "Atšaukti" + }, + "actionMenu": { + "openMenu": "Atidaryti meniu", + "settingsAndEdit": "Nustatymai ir redagavimas", + "disable": "Išjungti pranešimą", + "enable": "Įjungti pranešimą", + "delete": "Ištrinti automatizavimą" + }, + "newAutomation": { + "name": "Naujas automatizavimas (Juodraštis)", + "description": "Apibrėžkite šio automatizavimo tikslą ir aprašymą, tada pereikite prie srauto kūrimo priemonės.", + "triggerPlaceholder": "Reikia sukonfigūruoti..." + }, + "toasts": { + "created": "Automatizavimas sėkmingai sukurtas.", + "createError": "Nepavyko sukurti automatizavimo.", + "updated": "Automatizavimas sėkmingai išsaugotas.", + "updateError": "Nepavyko išsaugoti automatizavimo.", + "deleted": "Automatizavimas ištrintas.", + "deleteError": "Nepavyko ištrinti automatizavimo." + }, + "seedDefaults": { + "button": "Generuoti numatytuosius", + "dialog": { + "title": "Generuoti numatytąsias automatizacijas?", + "description": "Bus sukurti visi standartiniai automatizavimai (pvz., vartotojo pakvietimas, pasisveikinimas, slaptažodžio priminimas ir kt.) su numatytaisiais el. pašto šablonais. Esami automatizavimai nebus perrašyti.", + "warning": "Dėmesio: Nauji automatizavimai bus sukurti aktyvios būsenos ir iš karto pradės veikti. Įsitikinkite, kad jūsų el. pašto šablonai yra tinkamai sukonfigūruoti.", + "confirm": "Generuoti", + "cancel": "Atšaukti" + }, + "toasts": { + "success": "Sugeneruota {{created}} automatizavimų (praleista {{skipped}} esamų).", + "error": "Nepavyko sugeneruoti numatytųjų automatizavimų." + } + } + }, + "automationBuilder": { + "header": { + "back": "Grįžti į automatizavimus", + "automations": "Automatizavimai", + "save": "Išsaugoti", + "simulate": "Simuliuoti", + "active": "Aktyvus", + "draft": "Juodraštis", + "duplicate": "Dubliuoti", + "exportJson": "Eksportuoti kaip JSON", + "delete": "Ištrinti automatizavimą", + "savedJustNow": "Ką tik išsaugota", + "savedMinutesAgo": "Išsaugota prieš {{count}} min.", + "simulationRequiredTooltip": "Paleiskite simuliaciją ir įsitikinkite, kad ji sėkminga, kad galėtumėte aktyvuoti automatizavimą", + "invalidNodesTooltip": "Ištaisykite visus klaidingus mazgus prieš aktyvuodami automatizavimą", + "unsavedChangesTooltip": "Išsaugokite pakeitimus prieš aktyvuodami automatizavimą", + "leaveDialog": { + "title": "Neišsaugoti pakeitimai", + "description": "Turite neišsaugotų pakeitimų. Ar norite išsaugoti prieš išeidami?", + "saveAndLeave": "Išsaugoti ir išeiti", + "leaveWithoutSaving": "Išeiti be išsaugojimo", + "cancel": "Atšaukti" + }, + "deleteDialog": { + "title": "Ištrinti automatizavimą", + "description": "Ar tikrai norite ištrinti šį automatizavimą? Šio veiksmo negalima atšaukti.", + "confirm": "Ištrinti", + "cancel": "Atšaukti" + } + }, + "sidebar": { + "title": "Žingsniai", + "description": "Vilkite blokus ant drobės, kad sukurtumėte darbo eigą.", + "descriptionTrigger": "Pradėkite pasirinkdami trigerį savo automatizavimui.", + "descriptionActions": "Pridėkite veiksmus, kurie bus vykdomi suveikus trigeriui.", + "triggerInstruction": "Kiekvienas automatizavimas prasideda trigeriu. Vilkite jį ant drobės, kad pradėtumėte kurti darbo eigą.", + "triggers": "Trigeriai", + "conditions": "Sąlygos", + "actions": "Veiksmai" + }, + "blocks": { + "courseDeadline": "Kurso terminas", + "overdue": "Vėluojantis", + "notCompleted": "Nebaigtas", + "userEnrolled": "Vartotojas užregistruotas", + "certificateExpiringSoon": "Sertifikatas netrukus baigsis", + "liveTransmissionStartingSoon": "Tiesioginė transliacija netrukus prasidės", + "sendEmail": "Siųsti el. laišką", + "userInvited": "Vartotojas pakviestas", + "usersImportedInvite": "Vartotojai importuoti (kvietimas)", + "userPasswordReminder": "Slaptažodžio priminimas", + "userPasswordChanged": "Slaptažodis pakeistas", + "userWelcome": "Pasisveikinimo žinutė", + "userFirstLogin": "Pirmas prisijungimas", + "usersAssignedToCourse": "Vartotojai priskirti kursui", + "usersShortInactivity": "Trumpas neaktyvumas", + "usersLongInactivity": "Ilgas neaktyvumas", + "userChapterFinished": "Skyrius baigtas", + "userCourseFinished": "Kursas baigtas", + "userRegistered": "Vartotojas užsiregistravo", + "userPasswordCreated": "Slaptažodis sukurtas", + "courseCompleted": "Kursas užbaigtas", + "certificateExpirationWarning": "Sertifikato galiojimo pabaigos įspėjimas", + "certificateArchived": "Sertifikatas archyvuotas", + "announcementPublished": "Skelbimas paskelbtas", + "courseChatUserMentioned": "Vartotojas paminėtas pokalbyje", + "courseDueDateReminder": "Kurso termino priminimas" + }, + "canvas": { + "emptyTitle": "Jūsų automatizavimo drobė tuščia", + "emptyDescription": "Vilkite sąlygą arba veiksmą iš šoninės juostos, kad pradėtumėte.", + "emptyDescriptionTrigger": "Vilkite trigerį iš šoninės juostos, kad pradėtumėte kurti automatizavimą.", + "removeNode": "Pašalinti mazgą", + "simulationFailed": "Simuliacija aptiko klaidų šiame mazge", + "deleteNodeDialogTitle": "Pašalinti mazgą", + "deleteNodeDialogDescription": "Ar tikrai norite pašalinti šį mazgą? Šio veiksmo negalima atšaukti.", + "deleteNodeDialogCancel": "Atšaukti", + "deleteNodeDialogConfirm": "Pašalinti", + "addChild": "Pridėti antrinį žingsnį", + "zoomIn": "Priartinti", + "zoomOut": "Atitolinti", + "zoomReset": "Atstatyti mastelį" + }, + "editPanel": { + "editCondition": "Redaguoti sąlygą", + "editTrigger": "Redaguoti trigerį", + "editAction": "Redaguoti veiksmą", + "nodeType": "Mazgo tipas", + "changeTriggerLabel": "Pakeisti trigerį į", + "changeTriggerDialogTitle": "Pakeisti trigerį", + "changeTriggerDialogDescription": "Pakeitus trigerio tipą, visi veiksmai ir sąlygos bus pašalinti iš automatizacijos. Drobėje liks tik trigeris. Ar tikrai norite tęsti?", + "changeTriggerDialogCancel": "Atšaukti", + "changeTriggerDialogConfirm": "Ištrinti viską ir pakeisti trigerį", + "conditionValue": "Reikšmė", + "conditionValuePlaceholder": "Įveskite reikšmę...", + "operator": "Operatorius", + "operatorEquals": "Lygu", + "operatorGreaterThan": "Daugiau nei", + "operatorLessThan": "Mažiau nei", + "operatorContains": "Apima", + "emailSubject": "El. laiško tema", + "emailSubjectPlaceholder": "Įveskite temą...", + "emailTemplate": "El. laiško šablonas", + "emailTemplatePlaceholder": "Pasirinkite šabloną", + "emailBody": "El. laiško turinys", + "emailBodyPlaceholder": "Parašykite el. laiško turinį...", + "emailRecipient": "Gavėjas", + "recipientEnrolledUser": "Užregistruotas vartotojas", + "recipientAdmin": "Administratorius", + "recipientManager": "Vadovas", + "removeNode": "Pašalinti šį žingsnį" + }, + "config": { + "daysBefore": "Dienų prieš", + "daysBeforePlaceholder": "pvz. 7", + "course": "Kursas", + "coursePlaceholder": "Pasirinkite kursą", + "daysOverdue": "Dienų vėlavimo", + "daysOverduePlaceholder": "pvz. 3", + "daysEnrolled": "Dienų nuo registracijos", + "daysEnrolledPlaceholder": "pvz. 30", + "minutesBefore": "Minučių prieš", + "minutesBeforePlaceholder": "pvz. 60", + "daysInactive": "Neaktyvumo dienų", + "daysInactivePlaceholder": "pvz. 14" + }, + "editAction": { + "title": "Redaguoti veiksmą", + "sendEmail": "Siųsti el. laišką", + "emailTemplate": "El. laiško šablonas", + "selectTemplate": "Pasirinkite šabloną...", + "language": "Kalba", + "userDefaultLanguage": "Numatytoji vartotojo kalba", + "placeholders": "Šablono kintamieji", + "selectTemplateFirst": "Pasirinkite el. laiško šabloną, kad pamatytumėte galimus kintamuosius.", + "noPlaceholders": "Šis šablonas neturi kintamųjų, kuriuos reikėtų užpildyti.", + "placeholdersDescription": "Priskirkite kiekvieną šablono kintamąjį trigerio teikiamiems duomenims.", + "selectVariable": "Pasirinkite trigerio kintamąjį...", + "noTriggerVariables": "Prie šio veiksmo neprijungtas joks trigeris. Pirmiausia pridėkite trigerį, kad galėtumėte susieti kintamuosius.", + "defaultEmailNoMapping": "Numatytasis el. pašto šablonas nereikalauja kintamųjų susiejimo.", + "systemTemplateNoMapping": "Šis sistemos šablonas automatiškai naudoja trigerio duomenis. Rankinis susiejimas nereikalingas.", + "defaultTemplatesGroup": "Sistemos šablonai", + "customTemplatesGroup": "Pasirinktiniai šablonai", + "noCustomTemplates": "Nėra paskelbtų pasirinktinių šablonų.", + "templates": { + "defaultEmail": "Numatytasis el. laiškas", + "userInvite": "Vartotojo pakvietimas", + "welcome": "Pasisveikinimas", + "userFirstLogin": "Pirmas prisijungimas", + "userAssignedToCourse": "Vartotojas priskirtas kursui", + "userShortInactivity": "Trumpas neaktyvumas", + "userLongInactivity": "Ilgas neaktyvumas", + "userFinishedChapter": "Skyrius baigtas", + "userFinishedCourse": "Kursas baigtas", + "createPasswordReminder": "Slaptažodžio sukūrimo priminimas", + "certificateExpirationWarning": "Sertifikato galiojimo pabaigos įspėjimas", + "certificateExpired": "Sertifikatas nebegalioja", + "announcement": "Pranešimas", + "courseDueDateReminder": "Kurso termino priminimas", + "newUser": "Naujas vartotojas", + "finishedCourse": "Baigtas kursas" + } + }, + "variables": { + "userFirstName": "Vartotojo vardas", + "userLastName": "Vartotojo pavardė", + "userEmail": "El. pašto adresas", + "inviteLink": "Aktyvavimo nuoroda", + "inviteLinkRegistration": "Registracijos nuoroda", + "resetPasswordLink": "Slaptažodžio atkūrimo nuoroda", + "platformUrl": "Platformos nuoroda", + "loginDate": "Pirmo prisijungimo data", + "courseName": "Kurso pavadinimas", + "courseUrl": "Kurso nuoroda", + "dueDate": "Terminas", + "daysInactive": "Neaktyvumo dienų skaičius", + "chapterName": "Skyriaus pavadinimas", + "finishedAt": "Baigimo data", + "certificateUrl": "Sertifikato nuoroda", + "registrationDate": "Registracijos data", + "createdAt": "Sukūrimo data", + "daysLeft": "Likusios dienos", + "daysLeftExpiration": "Dienų iki galiojimo pabaigos", + "certificateName": "Sertifikato pavadinimas", + "expirationDate": "Galiojimo pabaigos data", + "archivedAt": "Archyvavimo data", + "recipientFirstName": "Gavėjo vardas", + "recipientLastName": "Gavėjo pavardė", + "announcementTitle": "Skelbimo pavadinimas", + "announcementContent": "Skelbimo turinys", + "announcementUrl": "Skelbimo nuoroda", + "mentionedFirstName": "Paminėto vartotojo vardas", + "mentionedLastName": "Paminėto vartotojo pavardė", + "authorFullName": "Autoriaus vardas ir pavardė", + "messageContent": "Žinutės turinys", + "chatUrl": "Žinutės nuoroda", + "invitedByUserName": "Pakvietė (vardas)", + "hasCertificate": "Turi sertifikatą", + "archiveReason": "Archyvavimo priežastis", + "profileLink": "Vartotojo profilio nuoroda", + "progressLink": "Kurso pažangos nuoroda", + "userName": "Vardas ir pavardė" + }, + "simulation": { + "title": "Simuliacijos rezultatas", + "statusSuccess": "Sėkmė", + "statusFailed": "Nepavyko", + "errorTitle": "Simuliacijos klaida", + "retry": "Pakartoti simuliaciją", + "errorsTitle": "Aptiktos problemos", + "readyToActivate": "Automatizavimas paruoštas aktyvavimui.", + "tabPreview": "El. laiško peržiūra", + "tabEventData": "Įvykio duomenys", + "tabMappings": "Susiejimai", + "from": "Nuo:", + "to": "Kam:", + "subject": "Tema:", + "noEventData": "Nėra įvykio duomenų — pasirinkite trigerio tipą.", + "availableVariables": "Galimi įvykio kintamieji", + "variableName": "Kintamasis", + "variableLabel": "Aprašymas", + "variableType": "Tipas", + "placeholder": "Placeholder", + "mappedTo": "Susieta su", + "sampleValue": "Pavyzdinė reikšmė", + "unmapped": "Nesusieta", + "errors": { + "triggerNodeName": "Pradinis įvykis", + "selectTriggerType": "Pasirinkite pradinio įvykio tipą", + "addTriggerNode": "Pridėkite bent vieną trigerio mazgą", + "actionNodeName": "Siųsti el. laišką", + "actionLabel": "Veiksmas", + "selectEmailTemplate": "Pasirinkite paskelbtą el. laiško šabloną", + "selectLanguage": "Pasirinkite el. laiško šablono kalbą", + "unmappedPlaceholder": "Placeholder {{placeholder}} nesusietas — priskirkite įvykio kintamąjį", + "addActionNode": "Pridėkite bent vieną veiksmo mazgą (pvz. Siųsti el. laišką)" + }, + "preview": { + "subject": "Jums priskirtas kursas: {{courseName}}", + "greeting": "Sveiki {{name}}!", + "assignedToCourse": "Jums priskirtas naujas kursas: {{courseName}}.", + "goToCourse": "Eiti į kursą", + "unavailable": "Peržiūra nepasiekiama", + "loadFailed": "Nepavyko įkelti šablono peržiūros.", + "label": "Peržiūra", + "systemTemplateNote": "Sistemos šablonas {{templateLabel}} — numatytasis turinys generuojamas automatiškai siuntimo metu.", + "emailDescription": "Šis el. laiškas bus išsiųstas su turiniu, atitinkančiu pasirinktą trigerį ir susietas kintamuosius.", + "platformName": "Mentingo Learning Platform" + }, + "sampleData": { + "firstName": "Jonas", + "lastName": "Jonaitis", + "fullName": "Jonas Jonaitis", + "email": "jonas.jonaitis@example.com", + "courseName": "Darbo saugos mokymai 2025", + "chapterName": "1 skyrius: Įvadas", + "certificateName": "Darbo saugos sertifikatas", + "announcementTitle": "Nauji mokymai prieinami", + "announcementContent": "Kviečiame į naujus mokymus...", + "authorFullName": "Ona Onaitė", + "messageContent": "Ei, pažiūrėk!", + "daysLeft": "30", + "daysInactive": "14", + "invitedByUserName": "Ona Onaitė" + } + } + }, + "automationSteps": { + "toast": { + "notFound": "Passaggio di automazione non trovato", + "idMismatch": "Non puoi modificare il passaggio padre o l'automazione", + "updateFailed": "Impossibile aggiornare il passaggio di automazione", + "deleteFailed": "Errore durante l'eliminazione del passaggio di automazione", + "nodeDeleteFailed": "Errore durante la ricerca del nodo da eliminare", + "noRootStep": "Un'automazione vuota deve avere prima un passaggio radice", + "hasRootAlready": "L'automazione ha già un passaggio radice", + "cycleDetected": "L'albero dei passaggi di automazione non può contenere cicli", + "stepTreeBuildFailed": "Errore durante la creazione dell'albero dei passaggi", + "wrongNumberOfRoots": "Un'automazione può avere un solo passaggio radice", + "treeNotConnected": "Non tutti i passaggi forniti sono collegati tra loro", + "bulkInsertFailed": "Impossibile aggiornare i passaggi di automazione" + } } } diff --git a/apps/web/app/locales/pl/translation.json b/apps/web/app/locales/pl/translation.json index 109ad0e624..71bd3932b8 100644 --- a/apps/web/app/locales/pl/translation.json +++ b/apps/web/app/locales/pl/translation.json @@ -16,7 +16,6 @@ "ignore": "Ignoruj", "validate": "Popraw błędy", "edit": "Edytuj", - "delete": "Usuń", "continue": "Kontynuuj", "uploading": "Wgrywanie...", "sending": "Wysyłanie..." @@ -330,6 +329,7 @@ } }, "navigationSideBar": { + "automation": "Automatyzacja", "settings": "Ustawienia", "logout": "Wyloguj", "panel": "Panel", @@ -352,6 +352,7 @@ "announcements": "Ogłoszenia", "notifications": "Powiadomienia", "promotionCodes": "Kody promocyjne", + "emailTemplates": "Szablony e-maili", "manage": "Zarządzaj", "ariaLabels": { "goToAvailableCourses": "Przejdź do dostępnych kursów" @@ -362,7 +363,8 @@ "activityLogs": "Dziennik zdarzeń", "learningPaths": "Ścieżki Rozwoju", "adminLearningPaths": "Ścieżki Rozwoju", - "adminLearningPathEditor": "Edytor ścieżki rozwoju" + "adminLearningPathEditor": "Edytor ścieżki rozwoju", + "editEmailTemplate": "Edytuj szablon e-mail" }, "microsoftCalendar": { "title": "Kalendarz Microsoft", @@ -4981,5 +4983,638 @@ "maxParticipantsReached": "Osiągnięto maksymalną liczbę uczestników.", "maxParallelSessionsReached": "Osiągnięto maksymalną liczbę aktywnych sesji szkoleń na żywo." } + }, + "emailTemplates": { + "breadcrumbs": { + "list": "Szablony e‑maili", + "edit": "Edytuj" + }, + "list": { + "title": "Szablony e‑maili", + "createButton": "Utwórz nowy", + "deleteSelected": "Usuń zaznaczone", + "searchPlaceholder": "Szukaj po nazwie", + "statusFilter": "Status", + "empty": "Nie ma jeszcze żadnych szablonów e‑maili.", + "loading": "Ładowanie szablonów...", + "loadFailed": "Nie udało się załadować szablonów e‑maili.", + "status": { + "all": "Wszystkie" + }, + "columns": { + "name": "Nazwa", + "status": "Status", + "languages": "Języki", + "updatedAt": "Zaktualizowano", + "selectAll": "Zaznacz wszystkie", + "selectRow": "Zaznacz wiersz" + } + }, + "deleteModal": { + "titleSingle": "Usuń szablon e‑maila", + "titleMultiple": "Usuń szablony e‑maili", + "descriptionSingle": "Czy na pewno chcesz usunąć ten szablon e‑maila? Tej operacji nie można cofnąć.", + "descriptionMultiple": "Czy na pewno chcesz usunąć {{count}} szablonów e‑maili? Tej operacji nie można cofnąć." + }, + "status": { + "draft": "Wersja robocza", + "published": "Opublikowany", + "archived": "Zarchiwizowany" + }, + "edit": { + "loadFailed": "Nie udało się załadować tego szablonu e‑maila." + }, + "form": { + "field": { + "name": "Nazwa", + "subject": "Temat", + "subjectHelp": "Uzupełnij temat dla każdego języka. Możesz używać zmiennych, np. {{user.first_name}}.", + "subjectPlaceholder": "np. Witaj, {{user.first_name}}" + }, + "errors": { + "nameRequired": "Nazwa jest wymagana.", + "nameTooLong": "Nazwa jest zbyt długa.", + "localesRequired": "Wybierz co najmniej jeden język.", + "baseLanguageMissing": "Język bazowy musi znajdować się wśród dostępnych języków." + } + }, + "toast": { + "createdSuccessfully": "Szablon e‑maila utworzony.", + "createFailed": "Nie udało się utworzyć szablonu e‑maila.", + "updatedSuccessfully": "Szablon e‑maila zapisany.", + "updateFailed": "Nie udało się zapisać szablonu e‑maila.", + "publishedSuccessfully": "Szablon e‑maila opublikowany.", + "publishFailed": "Nie udało się opublikować szablonu e‑maila.", + "publishBlocked": "Nie można opublikować — sprawdź diagnostykę poniżej.", + "archivedSuccessfully": "Szablon e‑maila zarchiwizowany.", + "archiveFailed": "Nie udało się zarchiwizować szablonu e‑maila.", + "unarchivedSuccessfully": "Szablon e‑maila przywrócony do wersji roboczej.", + "unarchiveFailed": "Nie udało się przywrócić szablonu z archiwum.", + "previewFailed": "Nie udało się wygenerować podglądu.", + "previewLanguageUnavailable": "Ten język nie jest dostępny dla tego szablonu.", + "duplicatedSuccessfully": "Szablon e‑maila zduplikowany.", + "duplicateFailed": "Nie udało się zduplikować szablonu e‑maila.", + "madeDraftSuccessfully": "Szablon e‑maila przywrócony do wersji roboczej.", + "makeDraftFailed": "Nie udało się przywrócić szablonu do wersji roboczej.", + "deletedSuccessfully": "Szablon e‑maila usunięty.", + "deleteFailed": "Nie udało się usunąć szablonu e‑maila.", + "nameAlreadyExists": "Szablon e‑maila o tej nazwie już istnieje.", + "testEmailSentSuccessfully": "Testowy e-mail wysłany.", + "testEmailSendFailed": "Nie udało się wysłać testowego e-maila." + }, + "actions": { + "preview": "Podgląd", + "duplicate": "Duplikuj", + "archive": "Archiwizuj", + "unarchive": "Odarchiwizuj", + "publish": "Opublikuj", + "makeDraft": "Oznacz jako szkic", + "edit": "Edytuj", + "rename": "Zmień nazwę", + "sendTest": "Wyślij test" + }, + "language": { + "label": "Język", + "baseLanguage": "Domyślny", + "notAddedLanguages": "Języki do dodania", + "setBaseLanguage": "Ustaw jako domyślny", + "setBaseTitle": "Zmień domyślny język", + "setBaseDescription": "Użyć języka {{language}} jako domyślnego dla tego szablonu?", + "createTitle": "Dodaj tłumaczenie", + "createDescription": "Dodać tłumaczenie tego szablonu w języku {{language}}?", + "deleteTitle": "Usuń tłumaczenie", + "deleteDescription": "Usunąć tłumaczenie {{language}}? Jego treść zostanie usunięta." + }, + "publishDiagnostics": { + "errorsTitle_one": "{{count}} błąd — musi zostać poprawiony przed publikacją", + "errorsTitle_few": "{{count}} błędy — muszą zostać poprawione przed publikacją", + "errorsTitle_many": "{{count}} błędów — musi zostać poprawionych przed publikacją", + "errorsTitle_other": "{{count}} błędów — musi zostać poprawionych przed publikacją", + "warningsTitle_one": "{{count}} ostrzeżenie — sprawdź przed publikacją", + "warningsTitle_few": "{{count}} ostrzeżenia — sprawdź przed publikacją", + "warningsTitle_many": "{{count}} ostrzeżeń — sprawdź przed publikacją", + "warningsTitle_other": "{{count}} ostrzeżeń — sprawdź przed publikacją", + "elementIndex": "Element {{index}}", + "reasons": { + "name_missing": "Nazwa szablonu jest wymagana", + "no_language_versions": "Dodaj co najmniej jedną wersję językową", + "subject_missing": "Temat jest wymagany dla języka domyślnego", + "body_missing": "Treść wiadomości jest pusta", + "button_label_missing": "Etykieta przycisku jest wymagana", + "button_url_missing": "Adres docelowy przycisku jest wymagany", + "empty_translation": "Tłumaczenie jest puste", + "invalid_url_protocol": "URL używa niedozwolonego protokołu", + "unchanged_from_base": "Tłumaczenie jest identyczne z językiem bazowym", + "footer_missing": "Brakuje stopki" + }, + "nodeTypes": { + "heading": "Nagłówek", + "paragraph": "Tekst", + "button": "Przycisk", + "footer": "Stopka", + "image": "Obraz" + }, + "blockedToast": { + "save": "Nie można zapisać szablonu. Zobacz diagnostykę", + "publish": "Nie można opublikować szablonu. Zobacz diagnostykę" + } + }, + "builder": { + "placeholder": { + "writeSomethingOrSlash": "Wpisz /, aby otworzyć menu bloków", + "heading": "Nagłówek {{level}}", + "htmlCode": "Kod HTML…" + }, + "blocks": { + "groups": { + "text": "Tekst", + "media": "Multimedia", + "structure": "Struktura", + "interactive": "Interaktywne", + "footer": "Stopka" + }, + "text": { + "title": "Tekst", + "description": "Akapit zwykłego tekstu." + }, + "heading1": { + "title": "Nagłówek 1", + "description": "Duży nagłówek sekcji (H1)." + }, + "heading2": { + "title": "Nagłówek 2", + "description": "Średni nagłówek sekcji (H2)." + }, + "heading3": { + "title": "Nagłówek 3", + "description": "Mały nagłówek sekcji (H3)." + }, + "image": { + "title": "Obraz", + "description": "Obraz na pełną szerokość." + }, + "logoHeader": { + "title": "Logo", + "description": "Pełne logo." + }, + "section": { + "title": "Sekcja", + "description": "Kontener grupujący treść." + }, + "columns": { + "title": "Kolumny", + "description": "Układ wielokolumnowy." + }, + "divider": { + "title": "Separator", + "description": "Pozioma linia oddzielająca." + }, + "spacer": { + "title": "Odstęp", + "description": "Pionowy odstęp między blokami." + }, + "button": { + "title": "Przycisk", + "description": "Przycisk wezwania do działania." + }, + "footer": { + "title": "Stopka", + "description": "Blok tekstu stopki." + } + } + }, + "image": { + "uploadFailed": "Przesyłanie obrazu nie powiodło się. Spróbuj ponownie.", + "tooLarge": "Obraz jest za duży. Maksymalny rozmiar to 10 MB.", + "invalidType": "Nieprawidłowy format obrazu. Dozwolone: JPEG, PNG, GIF, WebP, BMP, TIFF." + } + }, + "automationView": { + "title": "Automatyzacje", + "description": "Zarządzaj automatycznymi powiadomieniami email wysyłanymi w odpowiedzi na zdarzenia w platformie.", + "createAutomation": "Utwórz automatyzację", + "openLogs": "Otwórz logi", + "filters": { + "searchPlaceholder": "Szukaj po nazwie lub opisie...", + "all": "Wszystkie", + "enabled": "Włączone", + "disabled": "Wyłączone", + "drafts": "Szkice", + "archived": "Zarchiwizowane" + }, + "status": { + "enabled": "Włączona", + "disabled": "Wyłączona", + "draft": "Szkic", + "archived": "Zarchiwizowana" + }, + "table": { + "name": "Nazwa automatyzacji", + "status": "Status", + "trigger": "Wyzwalacz", + "actions": "Akcje", + "lastRun": "Ostatnie uruchomienie", + "updatedAt": "Aktualizacja", + "menu": "Menu", + "manage": "Zarządzaj", + "empty": "Brak zdefiniowanych automatyzacji. Kliknij Utwórz automatyzację, aby dodać pierwszą.", + "emptyFiltered": "Brak automatyzacji pasujących do wybranych filtrów. Spróbuj zmienić wyszukiwanie lub filtr statusu.", + "emailCount_one": "{{count}} e-mail", + "emailCount_other": "{{count}} e-maile", + "noRuns": "Brak uruchomień", + "runSuccess": "Zakończone sukcesem", + "runFailed": "Wystąpił błąd" + }, + "drawer": { + "title": "Szczegóły automatyzacji", + "description": "Edytuj konfigurację wybranej automatyzacji.", + "nameLabel": "Nazwa automatyzacji", + "descriptionLabel": "Opis działania", + "statusLabel": "Status", + "statusDraft": "Szkic (Draft)", + "statusEnabled": "Aktywna (Enabled)", + "statusDisabled": "Nieaktywna (Disabled)", + "statusArchived": "Zarchiwizowana (Archived)", + "flowManagement": "Zarządzanie przepływem", + "openBuilder": "Otwórz kreator kroków", + "pause": "Wstrzymaj", + "activate": "Uruchom", + "simulationRequiredTooltip": "Automatyzacja musi najpierw przejść symulację", + "archive": "Archiwizuj", + "save": "Zapisz zmiany", + "cancel": "Anuluj", + "delete": "Usuń" + }, + "deleteDialog": { + "title": "Usunąć automatyzację?", + "description": "Ta operacja jest nieodwracalna. Automatyzacja zostanie trwale usunięta. Zamiast tego możesz ją zarchiwizować.", + "confirm": "Usuń", + "cancel": "Anuluj" + }, + "actionMenu": { + "openMenu": "Otwórz menu", + "settingsAndEdit": "Ustawienia i edycja", + "disable": "Wyłącz powiadomienie", + "enable": "Włącz powiadomienie", + "delete": "Usuń automatyzację" + }, + "newAutomation": { + "name": "Nowa automatyzacja (Szkic)", + "description": "Zdefiniuj cel i opis tej automatyzacji, a następnie przejdź do kreatora przepływu.", + "triggerPlaceholder": "Do skonfigurowania..." + }, + "toasts": { + "created": "Automatyzacja utworzona pomyślnie.", + "createError": "Nie udało się utworzyć automatyzacji.", + "updated": "Automatyzacja zapisana pomyślnie.", + "updateError": "Nie udało się zapisać automatyzacji.", + "deleted": "Automatyzacja usunięta.", + "deleteError": "Nie udało się usunąć automatyzacji." + }, + "seedDefaults": { + "button": "Generuj domyślne", + "dialog": { + "title": "Wygenerować domyślne automatyzacje?", + "description": "Zostaną utworzone wszystkie podstawowe automatyzacje (np. zaproszenie użytkownika, powitanie, przypomnienie hasła itp.) ze standardowymi szablonami email. Już istniejące automatyzacje nie zostaną nadpisane.", + "warning": "Uwaga: Nowe automatyzacje zostaną utworzone w stanie aktywnym i będą od razu działać. Upewnij się, że szablony email są poprawnie skonfigurowane.", + "confirm": "Generuj", + "cancel": "Anuluj" + }, + "toasts": { + "success": "Wygenerowano {{created}} automatyzacji (pominięto {{skipped}} istniejących).", + "error": "Nie udało się wygenerować domyślnych automatyzacji." + } + } + }, + "automationLogs": { + "title": "Logi automatyzacji", + "description": "Podgląd historii wykonań wszystkich automatyzacji. Kliknij wpis, aby zobaczyć szczegóły.", + "backToAutomations": "Powrót do automatyzacji", + "status": { + "success": "Sukces", + "sent": "Wysłano", + "skipped": "Pominięto", + "failed": "Błąd" + }, + "filters": { + "searchPlaceholder": "Szukaj po nazwie, zdarzeniu lub odbiorcy...", + "all": "Wszystkie statusy", + "success": "Sukces", + "sent": "Wysłane", + "skipped": "Pominięte", + "failed": "Błędy" + }, + "table": { + "automation": "Automatyzacja", + "status": "Status", + "emails": "E-maile", + "recipients": "odbiorców", + "ranAt": "Uruchomiono", + "duration": "Czas trwania", + "details": "Szczegóły", + "empty": "Brak wpisów w logach." + }, + "detail": { + "title": "Szczegóły logu", + "description": "Pełna informacja o tym uruchomieniu automatyzacji.", + "ranAt": "Uruchomiono", + "triggerEvent": "Zdarzenie wyzwalające", + "automation": "Automatyzacja", + "status": "Status", + "error": "Błąd", + "noEmails": "Brak odbiorców e-mail.", + "duration": "Czas trwania", + "recipient": "Odbiorca", + "template": "Szablon", + "language": "Język", + "skipReason": "Powód pominięcia", + "failReason": "Powód błędu", + "emailsTitle": "E-maile ({{count}})" + } + }, + "automationBuilder": { + "header": { + "back": "Powrót do automatyzacji", + "automations": "Automatyzacje", + "save": "Zapisz", + "simulate": "Symuluj", + "active": "Aktywna", + "draft": "Szkic", + "duplicate": "Duplikuj", + "exportJson": "Eksportuj jako JSON", + "delete": "Usuń automatyzację", + "savedJustNow": "Zapisano właśnie", + "savedMinutesAgo": "Zapisano {{count}} min temu", + "simulationRequiredTooltip": "Uruchom symulację i upewnij się, że przejdzie pomyślnie, aby aktywować automatyzację", + "invalidNodesTooltip": "Napraw wszystkie błędne węzły przed aktywacją automatyzacji", + "unsavedChangesTooltip": "Zapisz zmiany przed aktywacją automatyzacji", + "leaveDialog": { + "title": "Niezapisane zmiany", + "description": "Masz niezapisane zmiany. Czy chcesz zapisać przed wyjściem?", + "saveAndLeave": "Zapisz i wyjdź", + "leaveWithoutSaving": "Wyjdź bez zapisywania", + "cancel": "Anuluj" + }, + "deleteDialog": { + "title": "Usuń automatyzację", + "description": "Czy na pewno chcesz usunąć tę automatyzację? Tej operacji nie można cofnąć.", + "confirm": "Usuń", + "cancel": "Anuluj" + } + }, + "sidebar": { + "title": "Kroki", + "description": "Przeciągnij bloki na płótno, aby zbudować przepływ.", + "descriptionTrigger": "Zacznij od wybrania triggera dla automatyzacji.", + "descriptionActions": "Dodaj akcje, które wykonają się po wyzwoleniu triggera.", + "triggerInstruction": "Każda automatyzacja zaczyna się od triggera. Przeciągnij jeden na płótno, aby rozpocząć budowanie przepływu.", + "triggers": "Triggery", + "actions": "Akcje" + }, + "blocks": { + "courseDeadline": "Termin kursu", + "overdue": "Zaległy", + "notCompleted": "Nieukończony", + "userEnrolled": "Użytkownik zapisany", + "certificateExpiringSoon": "Certyfikat wygasa wkrótce", + "liveTransmissionStartingSoon": "Transmisja na żywo wkrótce", + "sendEmail": "Wyślij e-mail", + "userInvited": "Użytkownik zaproszony", + "usersImportedInvite": "Użytkownicy zaimportowani (zaproszenie)", + "userPasswordReminder": "Przypomnienie hasła", + "userPasswordChanged": "Hasło zmienione", + "userWelcome": "Wiadomość powitalna", + "userFirstLogin": "Pierwsze logowanie", + "usersAssignedToCourse": "Użytkownicy przypisani do kursu", + "usersShortInactivity": "Krótka nieaktywność", + "usersLongInactivity": "Długa nieaktywność", + "userChapterFinished": "Rozdział ukończony", + "userCourseFinished": "Kurs ukończony", + "userRegistered": "Użytkownik zarejestrowany", + "userPasswordCreated": "Hasło utworzone", + "courseCompleted": "Kurs zaliczony", + "certificateExpirationWarning": "Ostrzeżenie o wygaśnięciu certyfikatu", + "certificateArchived": "Certyfikat zarchiwizowany", + "announcementPublished": "Ogłoszenie opublikowane", + "courseChatUserMentioned": "Użytkownik wspomniany na czacie", + "courseDueDateReminder": "Przypomnienie o terminie kursu" + }, + "canvas": { + "emptyTitle": "Płótno automatyzacji jest puste", + "emptyDescription": "Przeciągnij trigger lub akcję z panelu bocznego, aby rozpocząć.", + "emptyDescriptionTrigger": "Przeciągnij trigger z panelu bocznego, aby rozpocząć budowanie automatyzacji.", + "removeNode": "Usuń węzeł", + "simulationFailed": "Symulacja wykryła błędy w tym węźle", + "deleteNodeDialogTitle": "Usuń węzeł", + "deleteNodeDialogDescription": "Czy na pewno chcesz usunąć ten węzeł? Tej operacji nie można cofnąć.", + "deleteNodeDialogCancel": "Anuluj", + "deleteNodeDialogConfirm": "Usuń", + "addChild": "Dodaj krok podrzędny", + "zoomIn": "Przybliż", + "zoomOut": "Oddal", + "zoomReset": "Resetuj przybliżenie" + }, + "editPanel": { + "editTrigger": "Edytuj trigger", + "editAction": "Edytuj akcję", + "nodeType": "Typ węzła", + "changeTriggerLabel": "Zmień trigger na", + "changeTriggerDialogTitle": "Zmiana triggera", + "changeTriggerDialogDescription": "Zmiana typu triggera usunie wszystkie akcje i warunki z automatyzacji. Na canvas pozostanie tylko trigger. Czy na pewno chcesz kontynuować?", + "changeTriggerDialogCancel": "Anuluj", + "changeTriggerDialogConfirm": "Usuń wszystko i zmień trigger", + "triggerValue": "Wartość", + "triggerValuePlaceholder": "Wprowadź wartość...", + "operator": "Operator", + "operatorEquals": "Równa się", + "operatorGreaterThan": "Większy niż", + "operatorLessThan": "Mniejszy niż", + "operatorContains": "Zawiera", + "emailSubject": "Temat e-maila", + "emailSubjectPlaceholder": "Wprowadź temat...", + "emailTemplate": "Szablon e-maila", + "emailTemplatePlaceholder": "Wybierz szablon", + "emailBody": "Treść e-maila", + "emailBodyPlaceholder": "Napisz treść wiadomości...", + "emailRecipient": "Odbiorca", + "recipientEnrolledUser": "Zapisany użytkownik", + "recipientAdmin": "Administrator", + "recipientManager": "Menedżer", + "removeNode": "Usuń ten krok", + "deleteNodeDialogTitle": "Usuń węzeł", + "deleteNodeDialogDescription": "Czy na pewno chcesz usunąć ten węzeł? Tej operacji nie można cofnąć.", + "deleteNodeDialogCancel": "Anuluj", + "deleteNodeDialogConfirm": "Usuń" + }, + "config": { + "daysBefore": "Dni przed", + "daysBeforePlaceholder": "np. 7", + "course": "Kurs", + "coursePlaceholder": "Wybierz kurs", + "daysOverdue": "Dni zaległości", + "daysOverduePlaceholder": "np. 3", + "daysEnrolled": "Dni od zapisu", + "daysEnrolledPlaceholder": "np. 30", + "minutesBefore": "Minut przed", + "minutesBeforePlaceholder": "np. 60", + "daysInactive": "Dni nieaktywności", + "daysInactivePlaceholder": "np. 14" + }, + "editAction": { + "title": "Edytuj akcję", + "sendEmail": "Wyślij email", + "emailTemplate": "Szablon email", + "selectTemplate": "Wybierz szablon...", + "language": "Język", + "userDefaultLanguage": "Domyślny język użytkownika", + "placeholders": "Zmienne szablonu", + "selectTemplateFirst": "Wybierz szablon email, aby zobaczyć dostępne zmienne.", + "noPlaceholders": "Ten szablon nie posiada zmiennych do uzupełnienia.", + "placeholdersDescription": "Przypisz każdą zmienną szablonu do danych dostarczanych przez trigger.", + "selectVariable": "Wybierz zmienną triggera...", + "noTriggerVariables": "Brak triggera podłączonego do tej akcji. Najpierw dodaj trigger, aby mapować zmienne.", + "defaultEmailNoMapping": "Domyślny szablon email nie wymaga mapowania zmiennych.", + "systemTemplateNoMapping": "Ten szablon systemowy automatycznie używa danych z triggera. Ręczne mapowanie nie jest wymagane.", + "defaultTemplatesGroup": "Szablony systemowe", + "customTemplatesGroup": "Szablony niestandardowe", + "noCustomTemplates": "Brak opublikowanych szablonów niestandardowych.", + "templates": { + "defaultEmail": "Domyślny email", + "userInvite": "Zaproszenie użytkownika", + "welcome": "Powitanie", + "userFirstLogin": "Pierwsze logowanie", + "userAssignedToCourse": "Użytkownik przypisany do kursu", + "userShortInactivity": "Krótka nieaktywność", + "userLongInactivity": "Długa nieaktywność", + "userFinishedChapter": "Ukończony rozdział", + "userFinishedCourse": "Ukończony kurs", + "createPasswordReminder": "Przypomnienie o utworzeniu hasła", + "certificateExpirationWarning": "Ostrzeżenie o wygaśnięciu certyfikatu", + "certificateExpired": "Certyfikat wygasł", + "announcement": "Ogłoszenie", + "courseDueDateReminder": "Przypomnienie o terminie kursu", + "newUser": "Nowy użytkownik", + "finishedCourse": "Ukończony kurs" + } + }, + "variables": { + "userFirstName": "Imię użytkownika", + "userLastName": "Nazwisko użytkownika", + "userEmail": "Adres e-mail", + "inviteLink": "Link aktywacyjny", + "inviteLinkRegistration": "Link do rejestracji", + "resetPasswordLink": "Link do resetu hasła", + "platformUrl": "Link do platformy", + "loginDate": "Data pierwszego zalogowania", + "courseName": "Nazwa kursu", + "courseUrl": "Link do kursu", + "dueDate": "Termin ukończenia", + "daysInactive": "Liczba dni nieaktywności", + "chapterName": "Nazwa rozdziału", + "finishedAt": "Data ukończenia", + "certificateUrl": "Link do certyfikatu", + "registrationDate": "Data rejestracji", + "createdAt": "Data utworzenia", + "daysLeft": "Liczba dni do końca", + "daysLeftExpiration": "Liczba dni do wygaśnięcia", + "certificateName": "Nazwa certyfikatu", + "expirationDate": "Data wygaśnięcia", + "archivedAt": "Data archiwizacji", + "recipientFirstName": "Imię odbiorcy", + "recipientLastName": "Nazwisko odbiorcy", + "announcementTitle": "Tytuł ogłoszenia", + "announcementContent": "Treść ogłoszenia", + "announcementUrl": "Link do ogłoszenia", + "mentionedFirstName": "Imię oznaczonego", + "mentionedLastName": "Nazwisko oznaczonego", + "authorFullName": "Imię i nazwisko autora", + "messageContent": "Treść wiadomości", + "chatUrl": "Link do wiadomości", + "invitedByUserName": "Zaproszony przez (imię)", + "hasCertificate": "Posiada certyfikat", + "archiveReason": "Powód archiwizacji", + "profileLink": "Link do profilu użytkownika", + "progressLink": "Link do postępu kursu", + "userName": "Imię i nazwisko" + }, + "simulation": { + "title": "Wynik symulacji", + "statusSuccess": "Sukces", + "statusFailed": "Niepowodzenie", + "errorTitle": "Błąd symulacji", + "retry": "Ponów symulację", + "errorsTitle": "Wykryte problemy", + "readyToActivate": "Automatyzacja jest gotowa do aktywacji.", + "tabPreview": "Podgląd e-maila", + "tabEventData": "Dane zdarzenia", + "tabMappings": "Mapowania", + "from": "Od:", + "to": "Do:", + "subject": "Temat:", + "noEventData": "Brak danych zdarzenia — wybierz typ triggera.", + "availableVariables": "Dostępne zmienne zdarzenia", + "variableName": "Zmienna", + "variableLabel": "Opis", + "variableType": "Typ", + "placeholder": "Placeholder", + "mappedTo": "Zmapowane do", + "sampleValue": "Wartość próbna", + "unmapped": "Niezmapowane", + "errors": { + "triggerNodeName": "Zdarzenie startowe", + "selectTriggerType": "Wybierz typ zdarzenia startowego", + "addTriggerNode": "Dodaj co najmniej jeden węzeł triggera", + "actionNodeName": "Wyślij e-mail", + "actionLabel": "Akcja", + "selectEmailTemplate": "Wybierz opublikowany szablon e-mail", + "selectLanguage": "Wybierz język szablonu e-mail", + "unmappedPlaceholder": "Placeholder {{placeholder}} nie jest zmapowany — przypisz zmienną zdarzenia", + "addActionNode": "Dodaj co najmniej jeden węzeł akcji (np. Wyślij e-mail)" + }, + "preview": { + "subject": "Zostałeś przypisany do kursu: {{courseName}}", + "greeting": "Cześć {{name}}!", + "assignedToCourse": "Zostałeś przypisany do nowego kursu: {{courseName}}.", + "goToCourse": "Przejdź do kursu", + "unavailable": "Podgląd niedostępny", + "loadFailed": "Nie udało się załadować podglądu szablonu.", + "label": "Podgląd", + "systemTemplateNote": "Szablon systemowy {{templateLabel}} — domyślna treść generowana automatycznie podczas wysyłki.", + "emailDescription": "Ten e-mail zostanie wysłany z treścią odpowiednią dla wybranego triggera i zmapowanych zmiennych.", + "platformName": "Mentingo Learning Platform" + }, + "sampleData": { + "firstName": "Jan", + "lastName": "Kowalski", + "fullName": "Jan Kowalski", + "email": "jan.kowalski@example.com", + "courseName": "Szkolenie BHP 2025", + "chapterName": "Rozdział 1: Wprowadzenie", + "certificateName": "Certyfikat BHP", + "announcementTitle": "Nowe szkolenie dostępne", + "announcementContent": "Zapraszamy na nowe szkolenie...", + "authorFullName": "Anna Nowak", + "messageContent": "Hej, sprawdź to!", + "daysLeft": "30", + "daysInactive": "14", + "invitedByUserName": "Anna Nowak" + } + } + }, + "automationSteps": { + "toast": { + "notFound": "Nie znaleziono kroku automatyzacji", + "idMismatch": "Nie możesz zmienić rodzica ani automatyzacji kroku", + "updateFailed": "Nie udało się zaktualizować kroku automatyzacji", + "deleteFailed": "Błąd podczas usuwania kroku automatyzacji", + "nodeDeleteFailed": "Błąd podczas wyszukiwania węzła do usunięcia", + "noRootStep": "Pusta automatyzacja musi najpierw posiadać krok główny", + "hasRootAlready": "Automatyzacja posiada już krok główny", + "cycleDetected": "Drzewo kroków automatyzacji nie może zawierać cykli", + "stepTreeBuildFailed": "Błąd podczas budowania drzewa kroków", + "wrongNumberOfRoots": "Automatyzacja może posiadać tylko jeden krok główny", + "treeNotConnected": "Nie wszystkie podane kroki są ze sobą połączone", + "bulkInsertFailed": "Nie udało się zaktualizować kroków automatyzacji" + } } } diff --git a/apps/web/app/modules/Admin/Admin.layout.tsx b/apps/web/app/modules/Admin/Admin.layout.tsx index 7cb2f8e2fc..e8e3b1bb17 100644 --- a/apps/web/app/modules/Admin/Admin.layout.tsx +++ b/apps/web/app/modules/Admin/Admin.layout.tsx @@ -50,9 +50,13 @@ const AdminGuard = ({ children }: PropsWithChildren) => { PERMISSIONS.LEARNING_PATH_EXPORT, ], }); + const { hasAccess: canManageAutomation } = usePermissions({ + required: PERMISSIONS.AUTOMATION_MANAGE || PERMISSIONS.USER_MANAGE, + }); const navigate = useNavigate(); - const isAllowed = canManageUsers || canManageOwnCourses || canAccessLearningPathAdmin; + const isAllowed = + canManageUsers || canManageOwnCourses || canAccessLearningPathAdmin || canManageAutomation; useLayoutEffect(() => { if (!isAllowed) { @@ -70,6 +74,10 @@ export const shouldHideTopbarAndSidebar = (pathname: string) => .with("/admin/beta-courses/new", () => true) .with("/admin/beta-courses/new/standard", () => true) .with("/admin/courses/new-scorm", () => true) + .when( + (p) => /^\/admin\/automation\/[^/]+\/builder$/.test(p), + () => true, + ) .otherwise(() => false); const AdminLayout = () => { diff --git a/apps/web/app/modules/Admin/Automation/Automation.page.tsx b/apps/web/app/modules/Admin/Automation/Automation.page.tsx new file mode 100644 index 0000000000..4f1d5d5dac --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Automation.page.tsx @@ -0,0 +1,206 @@ +import { useNavigate } from "@remix-run/react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useCreateAutomation } from "~/api/mutations/admin/useCreateAutomation"; +import { useDeleteAutomation } from "~/api/mutations/admin/useDeleteAutomation"; +import { useSeedDefaultAutomations } from "~/api/mutations/admin/useSeedDefaultAutomations"; +import { useAutomations } from "~/api/queries/admin/useAutomations"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { Button } from "~/components/ui/button"; + +import { AutomationDrawer } from "./components/AutomationDrawer"; +import { AutomationFilters, type StatusFilter } from "./components/AutomationFilters"; +import { AutomationHeader } from "./components/AutomationHeader"; +import { AutomationTable } from "./components/AutomationTable"; + +import type { AutomationListItem } from "~/api/queries/admin/automation.types"; + +export default function AutomationPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + + const { data: automations = [], isLoading } = useAutomations(); + const createAutomation = useCreateAutomation(); + const deleteAutomation = useDeleteAutomation(); + const seedDefaultAutomations = useSeedDefaultAutomations(); + + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [selectedAutomation, setSelectedAutomation] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("All"); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [showSeedDefaultsDialog, setShowSeedDefaultsDialog] = useState(false); + + const handleCreate = () => { + const lang = i18n.language || "pl"; + createAutomation.mutate({ + name: { [lang]: t("automationView.newAutomation.name") }, + description: { [lang]: t("automationView.newAutomation.description") }, + status: "draft", + }); + }; + + const handleOpenDrawer = (automation: AutomationListItem) => { + setSelectedAutomation(automation); + setIsDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setIsDrawerOpen(false); + setSelectedAutomation(null); + }; + + const handleRequestDelete = (id: string) => { + setDeleteTargetId(id); + }; + + const handleConfirmDelete = () => { + if (deleteTargetId) { + deleteAutomation.mutate(deleteTargetId); + if (selectedAutomation?.id === deleteTargetId) { + handleCloseDrawer(); + } + setDeleteTargetId(null); + } + }; + + const handleCancelDelete = () => { + setDeleteTargetId(null); + }; + + const handleEdit = (id: string) => { + navigate(`/admin/automation/${id}/builder`); + }; + + const filteredAutomations = automations.filter((item) => { + const matchesSearch = + item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = statusFilter === "All" || item.status === statusFilter; + + return matchesSearch && matchesStatus; + }); + + return ( +
+ + +
+ +
+ +
+ +
+ + + +
+ + +
+ + !open && handleCancelDelete()}> + + + {t("automationView.deleteDialog.title")} + + {t("automationView.deleteDialog.description")} + + + + + {t("automationView.deleteDialog.cancel")} + + + {t("automationView.deleteDialog.confirm")} + + + + + + !open && setShowSeedDefaultsDialog(false)} + > + + + {t("automationView.seedDefaults.dialog.title")} + + {t("automationView.seedDefaults.dialog.description")} + + +
+ {t("automationView.seedDefaults.dialog.warning")} +
+ + setShowSeedDefaultsDialog(false)} + data-testid="automation-page-seed-defaults-dialog-cancel" + > + {t("automationView.seedDefaults.dialog.cancel")} + + { + seedDefaultAutomations.mutate(); + setShowSeedDefaultsDialog(false); + }} + data-testid="automation-page-seed-defaults-dialog-confirm" + > + {t("automationView.seedDefaults.dialog.confirm")} + + +
+
+
+ ); +} diff --git a/apps/web/app/modules/Admin/Automation/Builder/AutomationBuilder.page.tsx b/apps/web/app/modules/Admin/Automation/Builder/AutomationBuilder.page.tsx new file mode 100644 index 0000000000..6b327bf110 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/AutomationBuilder.page.tsx @@ -0,0 +1,169 @@ +import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { useParams } from "@remix-run/react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useAutomationById } from "~/api/queries/admin/useAutomationById"; +import { cn } from "~/lib/utils"; + +import { useBuilderStore } from "./automationBuilderStore"; +import { BlocksSidebar } from "./components/BlocksSidebar"; +import { BuilderCanvas } from "./components/BuilderCanvas"; +import { BuilderHeader } from "./components/BuilderHeader"; +import { EditNodePanel } from "./components/EditNodePanel"; + +import type { + AutomationStepDefinition, + BuilderNode, + SidebarBlock, +} from "./automationBuilder.types"; +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; + +function generateNodeId(): string { + return crypto.randomUUID(); +} + +function createNode( + kind: BuilderNode["kind"], + type: BuilderNode["type"], + label: string, + parentId: string | null, +): BuilderNode { + return { + id: generateNodeId(), + kind, + type, + label, + parentId, + children: [], + position: { x: 0, y: 0 }, + config: {}, + }; +} + +export default function AutomationBuilderPage() { + const { t } = useTranslation(); + const { id: automationId = "new" } = useParams<{ id: string }>(); + const [activeDragBlock, setActiveDragBlock] = useState(null); + + const addNode = useBuilderStore((s) => s.addNode); + const addChildNode = useBuilderStore((s) => s.addChildNode); + const loadNodes = useBuilderStore((s) => s.loadNodes); + const reset = useBuilderStore((s) => s.reset); + const setAutomationName = useBuilderStore((s) => s.setAutomationName); + const setActive = useBuilderStore((s) => s.setActive); + const setSimulationPassed = useBuilderStore((s) => s.setSimulationPassed); + + const { data: automation } = useAutomationById(automationId); + const hasLoadedRef = useRef(false); + + useEffect(() => { + hasLoadedRef.current = false; + }, [automationId]); + + useEffect(() => { + if (automation && !hasLoadedRef.current) { + hasLoadedRef.current = true; + reset(); + setAutomationName(automation.name); + + const builderNodes: BuilderNode[] = automation.nodes.map((node) => ({ + id: node.id, + kind: node.kind as BuilderNode["kind"], + type: node.type as BuilderNode["type"], + label: node.label, + parentId: node.parentId, + children: node.children, + position: node.position, + config: node.config, + })); + loadNodes(builderNodes); + + const triggerNode = automation.nodes.find((n) => n.kind === "trigger"); + const savedSimulationPassed = triggerNode?.config?.simulationPassed === true; + + const hasInvalidNodes = builderNodes.some((n) => n.config?.simulationStatus === "invalid"); + + if (hasInvalidNodes) { + setSimulationPassed(false); + setActive(false); + } else { + setSimulationPassed(savedSimulationPassed); + setActive(automation.status === "enabled"); + } + } + }, [automation]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + + const handleDragStart = (event: DragStartEvent) => { + const block = event.active.data.current?.block as SidebarBlock | undefined; + setActiveDragBlock(block ?? null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragBlock(null); + const { active, over } = event; + + if (!over) return; + + const block = active.data.current?.block as SidebarBlock | undefined; + if (!block) return; + + const targetNodeId = over.data.current?.targetNodeId as string | null | undefined; + const nodes = useBuilderStore.getState().nodes; + const hasTrigger = nodes.some((n) => n.kind === "trigger"); + + if (over.id === "canvas-root") { + if (block.kind !== "trigger" || hasTrigger) return; + addNode(createNode(block.kind, block.type, t(block.labelKey), null)); + } else if (targetNodeId) { + if (block.kind !== "action") return; + addChildNode( + targetNodeId, + createNode(block.kind, block.type, t(block.labelKey), targetNodeId), + ); + } + }; + + const handleAddChild = (parentId: string, definition: AutomationStepDefinition) => { + if (definition.kind !== "action") return; + addChildNode( + parentId, + createNode(definition.kind, definition.type, t(definition.labelKey), parentId), + ); + }; + + return ( +
+ + + +
+ + + +
+ + + {activeDragBlock && ( +
+ {t(activeDragBlock.labelKey)} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/modules/Admin/Automation/Builder/__tests__/emailTemplates.constants.test.ts b/apps/web/app/modules/Admin/Automation/Builder/__tests__/emailTemplates.constants.test.ts new file mode 100644 index 0000000000..b28da9b890 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/__tests__/emailTemplates.constants.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_EMAIL_TEMPLATE_ID, EMAIL_TEMPLATES } from "../emailTemplates.constants"; + +describe("emailTemplates.constants", () => { + it("exports a non-empty array of templates", () => { + expect(EMAIL_TEMPLATES.length).toBeGreaterThan(0); + }); + + it("every template has a unique id", () => { + const ids = EMAIL_TEMPLATES.map((t) => t.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + + it("every template has a labelKey string", () => { + for (const template of EMAIL_TEMPLATES) { + expect(template.labelKey).toBeTruthy(); + expect(typeof template.labelKey).toBe("string"); + } + }); + + it("every template has a placeholders array", () => { + for (const template of EMAIL_TEMPLATES) { + expect(Array.isArray(template.placeholders)).toBe(true); + } + }); + + it("DEFAULT_EMAIL_TEMPLATE_ID references an existing template", () => { + const found = EMAIL_TEMPLATES.find((t) => t.id === DEFAULT_EMAIL_TEMPLATE_ID); + expect(found).toBeDefined(); + }); + + it("default email template has no required placeholders", () => { + const defaultTemplate = EMAIL_TEMPLATES.find((t) => t.id === DEFAULT_EMAIL_TEMPLATE_ID)!; + expect(defaultTemplate.placeholders).toEqual([]); + }); + + it("user_invite template requires invitedByUserName and createPasswordLink", () => { + const template = EMAIL_TEMPLATES.find((t) => t.id === "user_invite")!; + expect(template.placeholders).toContain("invitedByUserName"); + expect(template.placeholders).toContain("createPasswordLink"); + }); +}); diff --git a/apps/web/app/modules/Admin/Automation/Builder/automationBuilder.types.ts b/apps/web/app/modules/Admin/Automation/Builder/automationBuilder.types.ts new file mode 100644 index 0000000000..05a7ffe9b4 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/automationBuilder.types.ts @@ -0,0 +1,73 @@ +export type { + TriggerType, + ActionType, + NodeKind, + PayloadVariable, + AutomationStepDefinition, +} from "@repo/shared"; + +export { + AUTOMATION_TRIGGER_TYPES, + AUTOMATION_ACTION_TYPES, + AUTOMATION_NODE_KINDS, + AUTOMATION_STATUSES, + STEP_DEFINITIONS, + TRIGGER_DEFINITIONS, + ACTION_DEFINITIONS, + getStepDefinition, +} from "@repo/shared"; + +import { ACTION_DEFINITIONS, TRIGGER_DEFINITIONS } from "@repo/shared"; + +import type { ActionType, NodeKind, TriggerType } from "@repo/shared"; + +export interface StepConfigField { + key: string; + labelKey: string; + type: "text" | "number" | "select" | "multiselect" | "textarea" | "emailTemplateSelect"; + placeholderKey?: string; + options?: { value: string; labelKey: string; label?: string; imageUrl?: string }[]; + dataSource?: "courses" | "users" | "announcements"; +} + +export interface SidebarBlock { + kind: NodeKind; + type: TriggerType | ActionType; + labelKey: string; + icon: string; +} + +export const TRIGGER_BLOCKS: SidebarBlock[] = TRIGGER_DEFINITIONS.map((d) => ({ + kind: d.kind, + type: d.type, + labelKey: d.labelKey, + icon: d.icon, +})); + +export const ACTION_BLOCKS: SidebarBlock[] = ACTION_DEFINITIONS.map((d) => ({ + kind: d.kind, + type: d.type, + labelKey: d.labelKey, + icon: d.icon, +})); + +export interface BuilderNode { + id: string; + kind: NodeKind; + type: TriggerType | ActionType; + label: string; + parentId: string | null; + children: string[]; + position: { x: number; y: number }; + config: Record; +} + +export interface BuilderState { + nodes: BuilderNode[]; + selectedNodeId: string | null; + automationName: string; + isActive: boolean; + simulationPassed: boolean; + lastSavedAt: string | null; + isDirty: boolean; +} diff --git a/apps/web/app/modules/Admin/Automation/Builder/automationBuilderStore.ts b/apps/web/app/modules/Admin/Automation/Builder/automationBuilderStore.ts new file mode 100644 index 0000000000..57253bfc46 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/automationBuilderStore.ts @@ -0,0 +1,124 @@ +import { create } from "zustand"; + +import type { ActionType, BuilderNode, BuilderState, TriggerType } from "./automationBuilder.types"; + +interface BuilderActions { + addNode: (node: BuilderNode) => void; + addChildNode: (parentId: string, node: BuilderNode) => void; + removeNode: (nodeId: string) => void; + selectNode: (nodeId: string | null) => void; + updateNodeConfig: (nodeId: string, config: Record) => void; + updateNodeConfigSilent: (nodeId: string, config: Record) => void; + updateNodeType: (nodeId: string, type: TriggerType | ActionType, label: string) => void; + updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void; + setAutomationName: (name: string) => void; + setActive: (active: boolean) => void; + setSimulationPassed: (passed: boolean) => void; + loadNodes: (nodes: BuilderNode[]) => void; + markSaved: () => void; + markDirty: () => void; + reset: () => void; +} + +const initialState: BuilderState = { + nodes: [], + selectedNodeId: null, + automationName: "New Automation", + isActive: false, + simulationPassed: false, + lastSavedAt: null, + isDirty: false, +}; + +export const useBuilderStore = create((set) => ({ + ...initialState, + + addNode: (node) => + set((state) => ({ + nodes: [...state.nodes, node], + isDirty: true, + isActive: false, + simulationPassed: false, + })), + + addChildNode: (parentId, node) => + set((state) => ({ + nodes: state.nodes + .map((n) => (n.id === parentId ? { ...n, children: [...n.children, node.id] } : n)) + .concat({ ...node, parentId }), + isDirty: true, + isActive: false, + simulationPassed: false, + })), + + removeNode: (nodeId) => + set((state) => { + const removeRecursive = (id: string, nodes: BuilderNode[]): BuilderNode[] => { + const target = nodes.find((n) => n.id === id); + if (!target) return nodes; + + let remaining = nodes.filter((n) => n.id !== id); + for (const childId of target.children) { + remaining = removeRecursive(childId, remaining); + } + + return remaining.map((n) => + n.children.includes(id) ? { ...n, children: n.children.filter((c) => c !== id) } : n, + ); + }; + + return { + nodes: removeRecursive(nodeId, state.nodes), + selectedNodeId: state.selectedNodeId === nodeId ? null : state.selectedNodeId, + isDirty: true, + isActive: false, + simulationPassed: false, + }; + }), + + selectNode: (nodeId) => set({ selectedNodeId: nodeId }), + + updateNodeConfig: (nodeId, config) => + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === nodeId ? { ...n, config: { ...n.config, ...config } } : n, + ), + isDirty: true, + isActive: false, + simulationPassed: false, + })), + + updateNodeConfigSilent: (nodeId, config) => + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === nodeId ? { ...n, config: { ...n.config, ...config } } : n, + ), + })), + + updateNodeType: (nodeId, type, label) => + set((state) => ({ + nodes: state.nodes.map((n) => (n.id === nodeId ? { ...n, type, label, config: {} } : n)), + isDirty: true, + isActive: false, + simulationPassed: false, + })), + + updateNodePosition: (nodeId, position) => + set((state) => ({ + nodes: state.nodes.map((n) => (n.id === nodeId ? { ...n, position } : n)), + })), + + setAutomationName: (name) => set({ automationName: name }), + + setActive: (active) => set({ isActive: active }), + + setSimulationPassed: (passed) => set({ simulationPassed: passed }), + + loadNodes: (nodes) => set({ nodes, isDirty: false }), + + markSaved: () => set({ lastSavedAt: new Date().toISOString(), isDirty: false }), + + markDirty: () => set({ isDirty: true }), + + reset: () => set(initialState), +})); diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/AddNodePicker.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/AddNodePicker.tsx new file mode 100644 index 0000000000..243aa426f6 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/AddNodePicker.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; + +import { Badge } from "~/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; + +import { ACTION_DEFINITIONS } from "../automationBuilder.types"; + +import { BLOCK_ICON_MAP } from "./automationIcons"; + +import type { AutomationStepDefinition } from "../automationBuilder.types"; +import type { FC, ReactNode } from "react"; + +interface AddNodePickerProps { + onSelect: (definition: AutomationStepDefinition) => void; + trigger: ReactNode; +} + +export const AddNodePicker: FC = ({ onSelect, trigger }) => { + const { t } = useTranslation(); + + return ( + + {trigger} + +
+ + {t("automationBuilder.sidebar.actions")} + +
+ {ACTION_DEFINITIONS.map((def) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/BlocksSidebar.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/BlocksSidebar.tsx new file mode 100644 index 0000000000..0a49b27939 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/BlocksSidebar.tsx @@ -0,0 +1,107 @@ +import { useDraggable } from "@dnd-kit/core"; +import { Info } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "~/lib/utils"; + +import { ACTION_BLOCKS, TRIGGER_BLOCKS } from "../automationBuilder.types"; +import { useBuilderStore } from "../automationBuilderStore"; + +import { BLOCK_ICON_MAP } from "./automationIcons"; + +import type { SidebarBlock } from "../automationBuilder.types"; +import type { FC } from "react"; + +interface DraggableBlockProps { + block: SidebarBlock; +} + +const DraggableBlock: FC = ({ block }) => { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `sidebar-${block.kind}-${block.type}`, + data: { block }, + }); + + return ( +
+ + {BLOCK_ICON_MAP[block.icon]} + + {t(block.labelKey)} +
+ ); +}; + +export const BlocksSidebar: FC = () => { + const { t } = useTranslation(); + const nodes = useBuilderStore((s) => s.nodes); + + const hasTrigger = nodes.some((n) => n.kind === "trigger"); + + return ( + + ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/BuilderCanvas.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/BuilderCanvas.tsx new file mode 100644 index 0000000000..fb7b693340 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/BuilderCanvas.tsx @@ -0,0 +1,103 @@ +import { useDroppable } from "@dnd-kit/core"; +import { Workflow } from "lucide-react"; +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "~/lib/utils"; + +import { useBuilderStore } from "../automationBuilderStore"; +import { useCanvasControls } from "../hooks/useCanvasControls"; + +import { CanvasNode } from "./CanvasNode"; +import { CanvasZoomControls } from "./CanvasZoomControls"; + +import type { AutomationStepDefinition } from "../automationBuilder.types"; +import type { FC, MutableRefObject } from "react"; + +interface BuilderCanvasProps { + onAddChild: (parentId: string, definition: AutomationStepDefinition) => void; +} + +export const BuilderCanvas: FC = ({ onAddChild }) => { + const { t } = useTranslation(); + const nodes = useBuilderStore((s) => s.nodes); + const scrollContainerRef = useRef(null); + + const { + zoom, + pan, + isPanning, + handleZoomIn, + handleZoomOut, + handleZoomReset, + handleWheel, + handlePointerDown, + handlePointerMove, + handlePointerUp, + } = useCanvasControls(); + + const rootNodes = nodes.filter((n) => n.parentId === null); + + const { setNodeRef, isOver } = useDroppable({ + id: "canvas-root", + data: { targetNodeId: null }, + }); + + return ( +
+
{ + setNodeRef(node); + (scrollContainerRef as MutableRefObject).current = node; + }} + className={cn( + "flex-1 overflow-auto bg-muted/30", + isOver && "bg-primary/5", + isPanning && "cursor-grabbing", + !isPanning && "cursor-grab", + )} + onWheel={handleWheel} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + data-canvas-bg="true" + > +
+ {rootNodes.length === 0 ? ( +
+
+ +
+
+

{t("automationBuilder.canvas.emptyTitle")}

+

+ {t("automationBuilder.canvas.emptyDescriptionTrigger")} +

+
+
+ ) : ( +
+ {rootNodes.map((node) => ( + + ))} +
+ )} +
+
+ + +
+ ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/BuilderHeader.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/BuilderHeader.tsx new file mode 100644 index 0000000000..3a32dc0138 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/BuilderHeader.tsx @@ -0,0 +1,237 @@ +import { ArrowLeft, Loader2, Play, Save, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb"; +import { Button } from "~/components/ui/button"; +import { Separator } from "~/components/ui/separator"; +import { Switch } from "~/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; + +import { useBuilderHeaderActions } from "../hooks/useBuilderHeaderActions"; + +import { SimulationPanel } from "./SimulationPanel"; + +import type { FC } from "react"; + +interface BuilderHeaderProps { + automationId: string; +} + +export const BuilderHeader: FC = ({ automationId }) => { + const { t } = useTranslation(); + + const { + automationName, + isActive, + lastSavedAt, + toggleDisabled, + showLeaveDialog, + showDeleteDialog, + simulationState, + isSimulating, + panelOpen, + setShowLeaveDialog, + setShowDeleteDialog, + handleBack, + handleSave, + handleSaveAndLeave, + handleLeaveWithoutSaving, + handleDeleteRequest, + handleDeleteConfirm, + handleSimulate, + handleRetrySimulation, + handleToggleActive, + closePanel, + getToggleTooltip, + formatSavedTime, + isSavePending, + isDeletePending, + } = useBuilderHeaderActions({ automationId }); + + return ( + +
+
+ + + + + {t("automationBuilder.header.back")} + + + + + + + + + + + + + {automationName} + + + + +
+ +
+ {lastSavedAt && ( + {formatSavedTime()} + )} + + + + + + + +
+ + {isActive + ? t("automationBuilder.header.active") + : t("automationBuilder.header.draft")} + + + + + + + + {getToggleTooltip() && ( + {getToggleTooltip()} + )} + +
+ + + + + + + + {t("automationBuilder.header.delete")} + +
+
+ + + + {/* Leave confirmation dialog */} + + + + {t("automationBuilder.header.leaveDialog.title")} + + {t("automationBuilder.header.leaveDialog.description")} + + + + + {t("automationBuilder.header.leaveDialog.cancel")} + + + + {t("automationBuilder.header.leaveDialog.saveAndLeave")} + + + + + + {/* Delete confirmation dialog */} + + + + {t("automationBuilder.header.deleteDialog.title")} + + {t("automationBuilder.header.deleteDialog.description")} + + + + + {t("automationBuilder.header.deleteDialog.cancel")} + + + {t("automationBuilder.header.deleteDialog.confirm")} + + + + +
+ ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/CanvasNode.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/CanvasNode.tsx new file mode 100644 index 0000000000..67e37cd636 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/CanvasNode.tsx @@ -0,0 +1,230 @@ +import { useDroppable } from "@dnd-kit/core"; +import { AlertCircle, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; + +import { useBuilderStore } from "../automationBuilderStore"; + +import { AddNodePicker } from "./AddNodePicker"; +import { BLOCK_ICON_MAP } from "./automationIcons"; + +import type { AutomationStepDefinition, BuilderNode } from "../automationBuilder.types"; +import type { FC } from "react"; + +const VLine: FC<{ height: number }> = ({ height }) => ( +
+); + +const DownArrow: FC = () => ( +
+); + +const RailSegment: FC<{ isFirst: boolean; isLast: boolean }> = ({ isFirst, isLast }) => ( +
+ {!isFirst && ( +
+ )} + {!isLast && ( +
+ )} +
+); + +interface CanvasNodeProps { + node: BuilderNode; + onAddChild: (parentId: string, definition: AutomationStepDefinition) => void; +} + +export const CanvasNode: FC = ({ node, onAddChild }) => { + const { t } = useTranslation(); + const selectNode = useBuilderStore((s) => s.selectNode); + const removeNode = useBuilderStore((s) => s.removeNode); + const selectedNodeId = useBuilderStore((s) => s.selectedNodeId); + const nodes = useBuilderStore((s) => s.nodes); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { setNodeRef, isOver } = useDroppable({ + id: `canvas-node-${node.id}`, + data: { targetNodeId: node.id }, + }); + + const isSelected = selectedNodeId === node.id; + const childNodes = nodes.filter((n) => node.children.includes(n.id)); + + return ( + +
+ {/* Node card */} + selectNode(node.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectNode(node.id); + } + }} + > + + {BLOCK_ICON_MAP[node.type]} + + {node.label} + {node.config.simulationStatus === "invalid" && ( + + + + + + + + {t("automationBuilder.canvas.simulationFailed")} + + + )} + + + + + {t("automationBuilder.canvas.removeNode")} + + + + {/* Connector: card → add button */} + + + {/* Add child picker */} + onAddChild(node.id, definition)} + trigger={ + + } + /> + + {/* Children */} + {childNodes.length > 0 && ( + <> + + + {childNodes.length === 1 ? ( +
+ + +
+ ) : ( +
+
+ {childNodes.map((child, index) => ( +
+ +
+ + + +
+
+ ))} +
+
+ )} + + )} +
+ + + + + + {t("automationBuilder.canvas.deleteNodeDialogTitle")} + + + {t("automationBuilder.canvas.deleteNodeDialogDescription")} + + + + setDeleteDialogOpen(false)}> + {t("automationBuilder.canvas.deleteNodeDialogCancel")} + + { + removeNode(node.id); + setDeleteDialogOpen(false); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("automationBuilder.canvas.deleteNodeDialogConfirm")} + + + + +
+ ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/CanvasZoomControls.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/CanvasZoomControls.tsx new file mode 100644 index 0000000000..160541a4f1 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/CanvasZoomControls.tsx @@ -0,0 +1,91 @@ +import { Minus, Plus, RotateCcw } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "~/components/ui/button"; +import { Separator } from "~/components/ui/separator"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; + +import type { FC } from "react"; + +interface CanvasZoomControlsProps { + zoom: number; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomReset: () => void; +} + +export const CanvasZoomControls: FC = ({ + zoom, + onZoomIn, + onZoomOut, + onZoomReset, +}) => { + const { t } = useTranslation(); + + return ( + +
+
+ + + + + {t("automationBuilder.canvas.zoomOut")} + + + + + + + + + {t("automationBuilder.canvas.zoomIn")} + + + + + + + + + {t("automationBuilder.canvas.zoomReset")} + +
+
+
+ ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/EditActionModal.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/EditActionModal.tsx new file mode 100644 index 0000000000..72b46493b0 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/EditActionModal.tsx @@ -0,0 +1,309 @@ +import { Loader2, X } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "~/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/ui/dialog"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Separator } from "~/components/ui/separator"; + +import { getStepDefinition } from "../automationBuilder.types"; +import { useBuilderStore } from "../automationBuilderStore"; +import { DEFAULT_EMAIL_TEMPLATE_ID, EMAIL_TEMPLATES } from "../emailTemplates.constants"; +import { useSaveAutomationSteps } from "../hooks/useSaveAutomationSteps"; + +import type { BuilderNode, TriggerType, PayloadVariable } from "../automationBuilder.types"; +import type { AutomationEmailTemplateOption } from "../hooks/useEmailTemplatesForAutomation"; +import type { FC } from "react"; + +const USER_DEFAULT_LANGUAGE = "user_default"; + +type LanguageOption = + | { value: string; labelKey: string; label?: undefined } + | { value: string; label: string; labelKey?: undefined }; + +const LANGUAGE_OPTIONS: LanguageOption[] = [ + { value: USER_DEFAULT_LANGUAGE, labelKey: "automationBuilder.editAction.userDefaultLanguage" }, + { value: "en", label: "English" }, + { value: "pl", label: "Polski" }, + { value: "de", label: "Deutsch" }, + { value: "lt", label: "Lietuvių" }, + { value: "cs", label: "Čeština" }, + { value: "es", label: "Español" }, +]; + +interface EditActionModalProps { + open: boolean; + onClose: () => void; + node: BuilderNode; + customTemplates: AutomationEmailTemplateOption[]; + isLoadingCustomTemplates: boolean; +} + +export const EditActionModal: FC = ({ + open, + onClose, + node, + customTemplates, + isLoadingCustomTemplates, +}) => { + const { t } = useTranslation(); + const updateNodeConfig = useBuilderStore((s) => s.updateNodeConfig); + const nodes = useBuilderStore((s) => s.nodes); + const { saveSteps } = useSaveAutomationSteps(); + + const triggerNode = nodes.find((n) => n.kind === "trigger"); + const triggerType = triggerNode?.type as TriggerType | undefined; + + const [selectedTemplate, setSelectedTemplate] = useState( + (node.config.emailTemplate as string) ?? "", + ); + const [selectedLanguage, setSelectedLanguage] = useState( + (node.config.language as string) ?? "user_default", + ); + const [placeholderValues, setPlaceholderValues] = useState>( + (node.config.placeholderValues as Record) ?? {}, + ); + + const triggerVariables: PayloadVariable[] = useMemo(() => { + if (!triggerType) return []; + const def = getStepDefinition(triggerType); + return def?.providedVariables ?? []; + }, [triggerType]); + + const isDefaultTemplate = selectedTemplate === DEFAULT_EMAIL_TEMPLATE_ID; + const isSystemTemplate = + !isDefaultTemplate && EMAIL_TEMPLATES.some((t) => t.id === selectedTemplate); + const isCustomTemplate = !isDefaultTemplate && !isSystemTemplate && !!selectedTemplate; + + const templatePlaceholders = useMemo(() => { + if (isCustomTemplate) { + const custom = customTemplates.find((t) => t.id === selectedTemplate); + return custom?.placeholders ?? []; + } + const template = EMAIL_TEMPLATES.find((t) => t.id === selectedTemplate); + return template?.placeholders ?? []; + }, [selectedTemplate, isCustomTemplate, customTemplates]); + + const handleTemplateChange = useCallback((value: string) => { + setSelectedTemplate(value); + setPlaceholderValues({}); + }, []); + + const handleLanguageChange = useCallback((value: string) => { + setSelectedLanguage(value); + }, []); + + const handlePlaceholderChange = useCallback((placeholder: string, value: string) => { + setPlaceholderValues((prev) => ({ ...prev, [placeholder]: value })); + }, []); + + const handleSave = useCallback(() => { + updateNodeConfig(node.id, { + emailTemplate: selectedTemplate, + language: selectedLanguage, + placeholderValues, + }); + + setTimeout(() => saveSteps(), 0); + onClose(); + }, [ + node.id, + selectedTemplate, + selectedLanguage, + placeholderValues, + updateNodeConfig, + onClose, + saveSteps, + ]); + + return ( + !isOpen && onClose()}> + + +
+ + {t("automationBuilder.editAction.title")} + +

{node.label}

+
+ +
+ + + +
+
+
+

+ {t("automationBuilder.editAction.sendEmail")} +

+ +
+ + +
+ +
+ + +
+
+
+ +
+

+ {t("automationBuilder.editAction.placeholders")} +

+ + {isDefaultTemplate && ( +

+ {t("automationBuilder.editAction.defaultEmailNoMapping")} +

+ )} + + {isSystemTemplate && ( +

+ {t("automationBuilder.editAction.systemTemplateNoMapping")} +

+ )} + + {!isDefaultTemplate && !isSystemTemplate && !selectedTemplate && ( +

+ {t("automationBuilder.editAction.selectTemplateFirst")} +

+ )} + + {!isDefaultTemplate && + !isSystemTemplate && + selectedTemplate && + templatePlaceholders.length === 0 && ( +

+ {t("automationBuilder.editAction.noPlaceholders")} +

+ )} + + {!isDefaultTemplate && + !isSystemTemplate && + selectedTemplate && + templatePlaceholders.length > 0 && ( +
+

+ {t("automationBuilder.editAction.placeholdersDescription")} +

+ + {templatePlaceholders.map((placeholder) => ( +
+ + +
+ ))} +
+ )} + + {!isDefaultTemplate && + !isSystemTemplate && + selectedTemplate && + triggerVariables.length === 0 && ( +
+

+ {t("automationBuilder.editAction.noTriggerVariables")} +

+
+ )} +
+
+ + +
+ + +
+
+
+ ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/EditNodePanel.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/EditNodePanel.tsx new file mode 100644 index 0000000000..879d715f51 --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/EditNodePanel.tsx @@ -0,0 +1,252 @@ +import { X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { Button } from "~/components/ui/button"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Separator } from "~/components/ui/separator"; +import { cn } from "~/lib/utils"; + +import { TRIGGER_DEFINITIONS, getStepDefinition } from "../automationBuilder.types"; +import { useBuilderStore } from "../automationBuilderStore"; +import { useEmailTemplatesForAutomation } from "../hooks/useEmailTemplatesForAutomation"; + +import { EditActionModal } from "./EditActionModal"; + +import type { + AutomationStepDefinition, + BuilderNode, + TriggerType, +} from "../automationBuilder.types"; +import type { FC } from "react"; + +export const EditNodePanel: FC = () => { + const { t } = useTranslation(); + const selectedNodeId = useBuilderStore((s) => s.selectedNodeId); + const nodes = useBuilderStore((s) => s.nodes); + const selectNode = useBuilderStore((s) => s.selectNode); + const updateNodeType = useBuilderStore((s) => s.updateNodeType); + const removeNode = useBuilderStore((s) => s.removeNode); + + const [editActionNode, setEditActionNode] = useState(null); + + const { templates: customTemplates, isLoading: isLoadingCustomTemplates } = + useEmailTemplatesForAutomation(); + + const selectedNode = nodes.find((n) => n.id === selectedNodeId); + const stepDefinition = selectedNode ? getStepDefinition(selectedNode.type) : undefined; + + const [deleteNodeDialogOpen, setDeleteNodeDialogOpen] = useState(false); + const [changeTriggerDialogOpen, setChangeTriggerDialogOpen] = useState(false); + const [pendingTriggerDef, setPendingTriggerDef] = useState(null); + + const handleRequestChangeTrigger = (def: AutomationStepDefinition) => { + setPendingTriggerDef(def); + setChangeTriggerDialogOpen(true); + }; + + const handleConfirmChangeTrigger = () => { + if (selectedNode && pendingTriggerDef) { + const nonTriggerNodes = nodes.filter((n) => n.id !== selectedNode.id); + for (const node of nonTriggerNodes) { + removeNode(node.id); + } + + updateNodeType( + selectedNode.id, + pendingTriggerDef.type as TriggerType, + t(pendingTriggerDef.labelKey), + ); + } + setChangeTriggerDialogOpen(false); + setPendingTriggerDef(null); + }; + + const handleCancelChangeTrigger = () => { + setChangeTriggerDialogOpen(false); + setPendingTriggerDef(null); + }; + + return ( + <> + + + + + + + {t("automationBuilder.editPanel.deleteNodeDialogTitle")} + + + {t("automationBuilder.editPanel.deleteNodeDialogDescription")} + + + + setDeleteNodeDialogOpen(false)}> + {t("automationBuilder.editPanel.deleteNodeDialogCancel")} + + { + if (selectedNode) { + removeNode(selectedNode.id); + } + setDeleteNodeDialogOpen(false); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("automationBuilder.editPanel.deleteNodeDialogConfirm")} + + + + + + + + + + {t("automationBuilder.editPanel.changeTriggerDialogTitle")} + + + {t("automationBuilder.editPanel.changeTriggerDialogDescription")} + + + + + {t("automationBuilder.editPanel.changeTriggerDialogCancel")} + + + {t("automationBuilder.editPanel.changeTriggerDialogConfirm")} + + + + + + {editActionNode && ( + setEditActionNode(null)} + node={editActionNode} + customTemplates={customTemplates} + isLoadingCustomTemplates={isLoadingCustomTemplates} + /> + )} + + ); +}; diff --git a/apps/web/app/modules/Admin/Automation/Builder/components/SimulationPanel.tsx b/apps/web/app/modules/Admin/Automation/Builder/components/SimulationPanel.tsx new file mode 100644 index 0000000000..90718291dd --- /dev/null +++ b/apps/web/app/modules/Admin/Automation/Builder/components/SimulationPanel.tsx @@ -0,0 +1,409 @@ +import { AlertCircle, CheckCircle2, Loader2, Mail, Variable, X } from "lucide-react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { cn } from "~/lib/utils"; + +import type { + EmailPreview, + EventDataField, + PlaceholderMappingEntry, + SimulationPanelState, + SimulationResult, +} from "../simulation.types"; +import type { FC } from "react"; + +export type { SimulationPanelState, SimulationResult }; + +interface SimulationPanelProps { + open: boolean; + onClose: () => void; + state: SimulationPanelState; + onRetry?: () => void; +} + +export const SimulationPanel: FC = ({ open, onClose, state, onRetry }) => { + const { t } = useTranslation(); + + if (state.type === "idle") { + return null; + } + + return ( + !isOpen && onClose()}> + + +
+ + {t("automationBuilder.simulation.title", "Wynik symulacji")} + + {state.type === "success" && } +
+ +
+ +
+ {state.type === "loading" && } + + {state.type === "error" && } + + {state.type === "success" && state.result.overallStatus === "failed" && ( + + )} + + {state.type === "success" && state.result.overallStatus === "success" && ( + + )} +
+ + + + +
+
+ ); +}; + +const StatusBadge: FC<{ status: "success" | "failed" }> = ({ status }) => { + const { t } = useTranslation(); + + return ( + + {status === "success" ? ( + <> + + {t("automationBuilder.simulation.statusSuccess", "Sukces")} + + ) : ( + <> + + {t("automationBuilder.simulation.statusFailed", "Niepowodzenie")} + + )} + + ); +}; + +const LoadingView: FC = () => { + const { t } = useTranslation(); + + return ( +
+ +

+ {t("automationBuilder.simulation.loading", "Generowanie podglądu...")} +

+
+ ); +}; + +const ErrorView: FC<{ message: string; onRetry?: () => void }> = ({ message, onRetry }) => { + const { t } = useTranslation(); + + return ( +
+ + + {t("automationBuilder.simulation.errorTitle", "Błąd symulacji")} + {message} + + {onRetry && ( + + )} +
+ ); +}; + +const FailedResultView: FC<{ result: SimulationResult }> = ({ result }) => { + const { t } = useTranslation(); + + const allErrors = useMemo( + () => result.nodeResults.flatMap((nr) => nr.errors), + [result.nodeResults], + ); + + return ( + +
+ + + + + {t("automationBuilder.simulation.errorsTitle", "Wykryte problemy")} + + {allErrors.length} + + + + + {allErrors.map((error, idx) => ( + + + + {error.nodeName} + + {error.field} + + + {error.description} + + ))} + + +
+
+ ); +}; + +const SuccessResultView: FC<{ result: SimulationResult }> = ({ result }) => { + const { t } = useTranslation(); + + return ( + + + + + {t("automationBuilder.simulation.tabPreview", "Podgląd e-maila")} + + + + {t("automationBuilder.simulation.tabEventData", "Dane zdarzenia")} + + + {t("automationBuilder.simulation.tabMappings", "Mapowania")} + + + + + +
+ + + + {t( + "automationBuilder.simulation.readyToActivate", + "Automatyzacja jest gotowa do aktywacji.", + )} + + + + {result.emailPreviews.map((preview) => ( + + ))} +
+
+
+ + + +
+ +
+
+
+ + + +
+ {Object.entries(result.placeholderMappings).map(([nodeId, mappings]) => { + const nodeResult = result.nodeResults.find((nr) => nr.nodeId === nodeId); + return ( + + ); + })} +
+
+
+
+ ); +}; + +const EmailPreviewCard: FC<{ preview: EmailPreview }> = ({ preview }) => { + const { t } = useTranslation(); + + return ( + + + {preview.nodeName} + + +
+
+ + {t("automationBuilder.simulation.from", "Od:")} + + {preview.senderAddress} +
+
+ + {t("automationBuilder.simulation.to", "Do:")} + + {preview.recipientAddress} +
+
+ + {t("automationBuilder.simulation.subject", "Temat:")} + + {preview.subject} +
+
+ +