feat(templates): T5 boot seeder + default seeds
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
'<p>Hi {{firstName}},</p>' +
|
||||
'<p>Thanks for joining the Founders Club. Your account is ready to set up.</p>' +
|
||||
'<p><a href="{{signupLink}}">Create your account</a></p>' +
|
||||
'<p>We are launching soon — we will keep you posted.</p>',
|
||||
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:
|
||||
'<p>Hi {{firstName}},</p>' +
|
||||
'<p>We have received your payment of <strong>{{amount}}</strong> for {{licenseCount}} license(s).</p>' +
|
||||
'<p>A separate tax receipt from our payment processor will follow.</p>',
|
||||
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:
|
||||
'<p>Hi {{firstName}},</p>' +
|
||||
'<p>You paid but have not created your account yet. It only takes a minute.</p>' +
|
||||
'<p><a href="{{signupLink}}">Finish creating your account</a></p>',
|
||||
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,
|
||||
];
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user