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:
2026-09-10 08:17:53 +02:00
co-authored by Claude Sonnet 5
parent 24f8070b8a
commit 320a41142b
13 changed files with 250 additions and 18 deletions
+22 -6
View File
@@ -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 () => {
+20 -1
View File
@@ -9,6 +9,7 @@ import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { MailService } from '../mail/mail.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
@@ -30,6 +31,7 @@ export class TeamerService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly mail: MailService,
) {}
async createTeamer(
@@ -118,7 +120,24 @@ export class TeamerService {
},
});
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
// Personal invites go out by email (best-effort); group links are shared
// by the Verantwortliche/r directly.
let emailSent = false;
if (email) {
const kc = await this.prisma.kc.findUnique({
where: { id: gemeinde.kcId },
select: { name: true },
});
emailSent = await this.mail.sendTeamerInvite({
to: email,
kcName: kc?.name ?? '',
gemeindeName: gemeinde.name,
token: invite.token,
expiresAt: invite.expiresAt,
});
}
return { ...invite, emailSent };
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {