feat(backend): mail module + send personal Gemeinde-Teamer invites
New global mail/ module mirroring the files/storage/ provider pattern: - MailProvider abstraction; default LogMailProvider only logs (no delivery), MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*, MAIL_FROM). - MailService.sendTeamerInvite() composes the invite email with a link built from APP_BASE_URL. TeamerService.createInvite() now mails personal invites (those with an email) best-effort and returns `emailSent`; group links are unchanged. Delivery failures are logged and swallowed, never blocking invite creation. New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/ USER/PASS. Tests: teamer spec covers mail-on-personal-invite, no-mail-on-group-link, and transport-drop; npm test green at 56. Docs updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?:
|
||||
|
||||
const prisma = {
|
||||
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
|
||||
kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) },
|
||||
user: {
|
||||
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
|
||||
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
|
||||
@@ -56,8 +57,9 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?:
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new TeamerService(prisma as never, sync as never);
|
||||
return { service, prisma, sync, created };
|
||||
const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) };
|
||||
const service = new TeamerService(prisma as never, sync as never, mail as never);
|
||||
return { service, prisma, sync, mail, created };
|
||||
}
|
||||
|
||||
describe('TeamerService scope check', () => {
|
||||
@@ -120,20 +122,34 @@ describe('TeamerService.createTeamer', () => {
|
||||
});
|
||||
|
||||
describe('TeamerService.createInvite', () => {
|
||||
it('defaults a group link to unlimited uses and no expiry', async () => {
|
||||
const { service } = makeService();
|
||||
it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => {
|
||||
const { service, mail } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', {});
|
||||
expect(inv.email).toBeNull();
|
||||
expect(inv.maxUses).toBeNull();
|
||||
expect(inv.expiresAt).toBeNull();
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
expect(inv.emailSent).toBe(false);
|
||||
expect(mail.sendTeamerInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults a personal invite to a single use and lowercases the email', async () => {
|
||||
const { service } = makeService();
|
||||
it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => {
|
||||
const { service, mail } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
|
||||
expect(inv.email).toBe('new@example.org');
|
||||
expect(inv.maxUses).toBe(1);
|
||||
expect(inv.emailSent).toBe(true);
|
||||
expect(mail.sendTeamerInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still returns the invite when the mail transport drops it', async () => {
|
||||
const { service, mail } = makeService();
|
||||
mail.sendTeamerInvite.mockResolvedValueOnce(false);
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' });
|
||||
expect(inv.emailSent).toBe(false);
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('turns expiresInHours into a concrete expiry', async () => {
|
||||
|
||||
Reference in New Issue
Block a user