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>
202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
import {
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { randomBytes } from 'crypto';
|
|
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';
|
|
|
|
const BCRYPT_ROUNDS = 10;
|
|
|
|
type PublicUser = {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
createdAt: Date;
|
|
};
|
|
|
|
/// Management of local Gemeinde Teamer accounts and their invites. Callable by
|
|
/// the Leitungsteam (any Gemeinde) or by a Gemeinde Verantwortliche/r for
|
|
/// their own Gemeinde only.
|
|
@Injectable()
|
|
export class TeamerService {
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly sync: SyncService,
|
|
private readonly mail: MailService,
|
|
) {}
|
|
|
|
async createTeamer(
|
|
caller: AuthenticatedUser,
|
|
gemeindeId: string,
|
|
input: { firstName: string; lastName: string; email: string; password: string },
|
|
): Promise<PublicUser> {
|
|
const gemeinde = await this.assertCanManage(caller, gemeindeId);
|
|
const email = input.email.toLowerCase();
|
|
if (await this.prisma.user.findUnique({ where: { email } })) {
|
|
throw new ConflictException('An account with this email already exists');
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
|
|
const user = await this.prisma.user.create({
|
|
data: {
|
|
email,
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
passwordHash,
|
|
kcId: gemeinde.kcId,
|
|
},
|
|
});
|
|
const membership = await this.prisma.membership.create({
|
|
data: {
|
|
userId: user.id,
|
|
kcId: gemeinde.kcId,
|
|
gemeindeId,
|
|
role: Role.GEMEINDE_TEAMER,
|
|
},
|
|
});
|
|
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
|
|
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
|
|
return toPublicUser(user);
|
|
}
|
|
|
|
async listTeamer(caller: AuthenticatedUser, gemeindeId: string): Promise<PublicUser[]> {
|
|
await this.assertCanManage(caller, gemeindeId);
|
|
const memberships = await this.prisma.membership.findMany({
|
|
where: { gemeindeId, role: Role.GEMEINDE_TEAMER },
|
|
include: { user: true },
|
|
orderBy: { user: { lastName: 'asc' } },
|
|
});
|
|
return memberships.map((m) => toPublicUser(m.user));
|
|
}
|
|
|
|
async removeTeamer(
|
|
caller: AuthenticatedUser,
|
|
gemeindeId: string,
|
|
userId: string,
|
|
): Promise<{ id: string }> {
|
|
await this.assertCanManage(caller, gemeindeId);
|
|
const membership = await this.prisma.membership.findFirst({
|
|
where: { userId, gemeindeId, role: Role.GEMEINDE_TEAMER },
|
|
include: { user: true },
|
|
});
|
|
if (!membership || !membership.user.passwordHash) {
|
|
throw new NotFoundException('No local Teamer account for this Gemeinde');
|
|
}
|
|
await this.prisma.user.delete({ where: { id: userId } });
|
|
await this.sync.capture('User', SyncOperation.DELETE, userId, { id: userId });
|
|
return { id: userId };
|
|
}
|
|
|
|
async createInvite(
|
|
caller: AuthenticatedUser,
|
|
gemeindeId: string,
|
|
dto: CreateTeamerInviteDto,
|
|
) {
|
|
const gemeinde = await this.assertCanManage(caller, gemeindeId);
|
|
const email = dto.email?.toLowerCase() ?? null;
|
|
const maxUses = dto.maxUses ?? (email ? 1 : null);
|
|
const expiresAt = dto.expiresInHours
|
|
? new Date(Date.now() + dto.expiresInHours * 3600_000)
|
|
: null;
|
|
|
|
const invite = await this.prisma.teamerInvite.create({
|
|
data: {
|
|
kcId: gemeinde.kcId,
|
|
gemeindeId,
|
|
token: randomBytes(24).toString('base64url'),
|
|
email,
|
|
maxUses,
|
|
expiresAt,
|
|
createdByUserId: caller.userId,
|
|
},
|
|
});
|
|
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, 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) {
|
|
await this.assertCanManage(caller, gemeindeId);
|
|
return this.prisma.teamerInvite.findMany({
|
|
where: { gemeindeId },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
|
|
await this.assertCanManage(caller, gemeindeId);
|
|
const invite = await this.prisma.teamerInvite.findFirst({
|
|
where: { id: inviteId, gemeindeId },
|
|
});
|
|
if (!invite) {
|
|
throw new NotFoundException('Invite not found');
|
|
}
|
|
const updated = await this.prisma.teamerInvite.update({
|
|
where: { id: inviteId },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updated.id, updated);
|
|
return updated;
|
|
}
|
|
|
|
/// LT may manage every Gemeinde; a Verantwortliche/r only the one they hold
|
|
/// that role for. Returns the Gemeinde (for its kcId) on success.
|
|
private async assertCanManage(caller: AuthenticatedUser, gemeindeId: string) {
|
|
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
|
|
if (!gemeinde) {
|
|
throw new NotFoundException('Gemeinde not found');
|
|
}
|
|
const isLeitungsteam = caller.memberships.some(
|
|
(m) => m.role === Role.LEITUNGSTEAM,
|
|
);
|
|
const isVerantwortlich = caller.memberships.some(
|
|
(m) => m.role === Role.GEMEINDE_VERANTWORTLICHER && m.gemeindeId === gemeindeId,
|
|
);
|
|
if (!isLeitungsteam && !isVerantwortlich) {
|
|
throw new ForbiddenException('Not responsible for this Gemeinde');
|
|
}
|
|
return gemeinde;
|
|
}
|
|
}
|
|
|
|
function toPublicUser(user: {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
createdAt: Date;
|
|
}): PublicUser {
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
createdAt: user.createdAt,
|
|
};
|
|
}
|