From 37407cb0af58db25479a7e7bb914edfb0b65f2bb Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:33:12 +0530 Subject: [PATCH] feat(templates): T5 boot seeder + default seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateSeeder (OnModuleInit) seeds repo file defaults as global (scopeId NULL) templates if absent. Idempotent per (key,channel,locale,version): re-boot inserts nothing; a bumped version adds a new row and keeps the old for audit. Seeds: welcome (email), payment.receipt (email+sms), onboarding.reminder (email) — placeholder copy for marketing to replace. 3 tests. Co-Authored-By: Claude Opus 4.8 --- .../iios-service/src/templates/seeds/index.ts | 79 +++++++++++++++++++ .../src/templates/template.seeder.spec.ts | 42 ++++++++++ .../src/templates/template.seeder.ts | 49 ++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 packages/iios-service/src/templates/seeds/index.ts create mode 100644 packages/iios-service/src/templates/template.seeder.spec.ts create mode 100644 packages/iios-service/src/templates/template.seeder.ts diff --git a/packages/iios-service/src/templates/seeds/index.ts b/packages/iios-service/src/templates/seeds/index.ts new file mode 100644 index 0000000..848b0f9 --- /dev/null +++ b/packages/iios-service/src/templates/seeds/index.ts @@ -0,0 +1,79 @@ +import type { IiosTemplateChannel } from '@prisma/client'; + +/** A platform-default template shipped in the repo and seeded at boot (scopeId NULL). */ +export interface TemplateSeed { + key: string; + channel: IiosTemplateChannel; + locale: string; + version: number; + subject?: string; + bodyHtml?: string; + bodyText?: string; + variables: string[]; +} + +// Placeholder copy — marketing (Nikita/Himanshu) owns the words, Gautam the HTML. These are +// functional defaults so the pipeline works end-to-end before final copy lands; bump `version` +// when the content changes (the old version stays for audit/replay). + +const WELCOME_EMAIL: TemplateSeed = { + key: 'welcome', + channel: 'EMAIL', + locale: 'en', + version: 1, + subject: 'Welcome to the Founders Club, {{firstName}}', + bodyHtml: + '

Hi {{firstName}},

' + + '

Thanks for joining the Founders Club. Your account is ready to set up.

' + + '

Create your account

' + + '

We are launching soon — we will keep you posted.

', + bodyText: + 'Hi {{firstName}},\n\nThanks for joining the Founders Club. Create your account: {{signupLink}}\n\nWe are launching soon.', + variables: ['firstName', 'signupLink'], +}; + +const PAYMENT_RECEIPT_EMAIL: TemplateSeed = { + key: 'payment.receipt', + channel: 'EMAIL', + locale: 'en', + version: 1, + subject: 'Thanks for your payment, {{firstName}}', + bodyHtml: + '

Hi {{firstName}},

' + + '

We have received your payment of {{amount}} for {{licenseCount}} license(s).

' + + '

A separate tax receipt from our payment processor will follow.

', + bodyText: + 'Hi {{firstName}},\n\nWe have received your payment of {{amount}} for {{licenseCount}} license(s).\nA separate tax receipt will follow.', + variables: ['firstName', 'amount', 'licenseCount'], +}; + +const PAYMENT_RECEIPT_SMS: TemplateSeed = { + key: 'payment.receipt', + channel: 'SMS', + locale: 'en', + version: 1, + bodyText: 'Thanks {{firstName}}! We received your payment of {{amount}}. A receipt is on its way to your email.', + variables: ['firstName', 'amount'], +}; + +const ONBOARDING_REMINDER_EMAIL: TemplateSeed = { + key: 'onboarding.reminder', + channel: 'EMAIL', + locale: 'en', + version: 1, + subject: 'Looks like you have not finished setting up', + bodyHtml: + '

Hi {{firstName}},

' + + '

You paid but have not created your account yet. It only takes a minute.

' + + '

Finish creating your account

', + bodyText: + 'Hi {{firstName}},\n\nYou paid but have not created your account yet. Finish here: {{signupLink}}', + variables: ['firstName', 'signupLink'], +}; + +export const TEMPLATE_SEEDS: TemplateSeed[] = [ + WELCOME_EMAIL, + PAYMENT_RECEIPT_EMAIL, + PAYMENT_RECEIPT_SMS, + ONBOARDING_REMINDER_EMAIL, +]; diff --git a/packages/iios-service/src/templates/template.seeder.spec.ts b/packages/iios-service/src/templates/template.seeder.spec.ts new file mode 100644 index 0000000..3459a5c --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateSeeder } from './template.seeder'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const seeder = new TemplateSeeder(prisma as unknown as PrismaService); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateSeeder', () => { + it('seeds the file defaults as global (scopeId NULL) templates', async () => { + const n = await seeder.seed(TEMPLATE_SEEDS); + expect(n).toBe(TEMPLATE_SEEDS.length); + const rows = await prisma.iiosMessageTemplate.findMany(); + expect(rows).toHaveLength(TEMPLATE_SEEDS.length); + expect(rows.every((r) => r.scopeId === null)).toBe(true); + const welcome = rows.find((r) => r.key === 'welcome' && r.channel === 'EMAIL'); + expect(welcome?.variables).toEqual(['firstName', 'signupLink']); + }); + + it('is idempotent — re-seeding inserts nothing and creates no duplicates', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const second = await seeder.seed(TEMPLATE_SEEDS); + expect(second).toBe(0); + expect(await prisma.iiosMessageTemplate.count()).toBe(TEMPLATE_SEEDS.length); + }); + + it('a bumped version inserts a new row and leaves the old one', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const bumped: TemplateSeed = { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'v2', bodyText: 'v2', variables: [] }; + const n = await seeder.seed([bumped]); + expect(n).toBe(1); + const welcomes = await prisma.iiosMessageTemplate.findMany({ where: { key: 'welcome', channel: 'EMAIL' }, orderBy: { version: 'asc' } }); + expect(welcomes.map((w) => w.version)).toEqual([1, 2]); + }); +}); diff --git a/packages/iios-service/src/templates/template.seeder.ts b/packages/iios-service/src/templates/template.seeder.ts new file mode 100644 index 0000000..8412edf --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; + +/** + * Seeds platform-default templates (scopeId NULL) from the repo files on boot — DB is runtime truth, + * files are the version-controlled source. Idempotent: a seed is inserted only if its exact + * (key, channel, locale, version) default is absent, so re-boots never duplicate and a bumped + * version adds a new row while leaving the old one for audit/replay. + */ +@Injectable() +export class TemplateSeeder implements OnModuleInit { + private readonly log = new Logger(TemplateSeeder.name); + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + const inserted = await this.seed(TEMPLATE_SEEDS); + if (inserted > 0) this.log.log(`seeded ${inserted} default template(s)`); + } + + /** Returns how many rows were newly inserted. */ + async seed(seeds: TemplateSeed[]): Promise { + let inserted = 0; + for (const s of seeds) { + const exists = await this.prisma.iiosMessageTemplate.findFirst({ + where: { scopeId: null, key: s.key, channel: s.channel, locale: s.locale, version: s.version }, + select: { id: true }, + }); + if (exists) continue; + await this.prisma.iiosMessageTemplate.create({ + data: { + scopeId: null, + key: s.key, + channel: s.channel, + locale: s.locale, + version: s.version, + subject: s.subject, + bodyHtml: s.bodyHtml, + bodyText: s.bodyText, + variables: s.variables as Prisma.InputJsonValue, + }, + }); + inserted++; + } + return inserted; + } +}