feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s

Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:49:17 +02:00
parent 31d514b7ca
commit e10da76259
27 changed files with 2471 additions and 82 deletions
@@ -0,0 +1,125 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
*
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
* `tenantId`, Zuruecksetzen loescht die Vorlage.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
}));
type Row = {
tenantId: string;
subject: string;
heading: string;
intro: string;
closing: string;
updatedBy: string | null;
updatedAt: Date;
};
function makePrisma(rows: Row[] = []) {
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
return {
__log: log,
__rows: rows,
__bound(tenantId: string) {
return {
welcomeMailTemplate: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
return (
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
null
);
}),
upsert: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
else rows.push({ ...args.create, updatedAt: new Date() });
return {};
}),
deleteMany: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
const before = rows.length;
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
}
return { count: before - rows.length };
}),
},
tenant: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
}),
},
};
},
};
}
const texts = { subject: 'S {{name}}', heading: 'H', intro: 'I', closing: 'C' };
let prisma: ReturnType<typeof makePrisma>;
let service: WelcomeMailTemplateService;
beforeEach(() => {
prisma = makePrisma();
service = new WelcomeMailTemplateService(prisma as any);
});
describe('WelcomeMailTemplateService', () => {
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
const state = await service.getState('t1');
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(await service.getCustomTexts('t1')).toBeNull();
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
});
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
const state = await service.save('t1', texts, 'ada');
const upsert = prisma.__log.find((c) => c.method === 'upsert');
expect(upsert?.tenantId).toBe('t1');
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
expect(state.custom).toBe(true);
expect(state.texts).toEqual(texts);
expect(state.updatedBy).toBe('ada');
expect(await service.getCustomTexts('t1')).toEqual(texts);
});
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
await service.save('t1', texts, 'ada');
expect(await service.getCustomTexts('t2')).toBeNull();
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
});
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
await service.save('t1', texts, 'ada');
const state = await service.reset('t1');
const del = prisma.__log.find((c) => c.method === 'deleteMany');
expect(del?.tenantId).toBe('t1');
expect(del?.args.where).toEqual({ tenantId: 't1' });
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
});
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
expect(await service.getTenantName('tx')).toBe('');
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
't1',
'tx',
]);
});
});