import type { Prisma } from "@prisma/client"; import type { DomainEvent, EventType } from "@/lib/events"; import { normalizeLocale, type Locale } from "@/server/mail/templates"; import type { ServiceCtx } from "@/server/services/context"; /** * Recipient rules per domain event (spec §19.4, §20, §33.1; ARCHITEKTUR §4.1). * * Tenant separation: every lookup runs through `ctx.db` (tenant guard + RLS). User ids coming * from entity rows are re-resolved via `ctx.db.user` with status ACTIVE, so ids of other tenants * (or deactivated users) can never become recipients. */ /** Events whose e-mail cannot be turned off by the user (spec §19.4). */ export const MANDATORY_EMAIL_EVENTS: ReadonlySet = new Set(["emergency.created", "emergency.completed"]); /** * System-result events: the affected user is informed even if the event was emitted in their own * context (a worker/sync run on their behalf) — the "actor excluded" rule does not apply to them. */ const SYSTEM_RESULT_EVENTS: ReadonlySet = new Set(["import.ready_for_review", "import.failed", "sync.failed"]); export type Target = { userId: string; email: string; name: string; locale: Locale; permissions: ReadonlySet; }; /** Facts about the entity, used for texts, templates and links. */ export type EventFacts = { workOrderId?: string; number?: string; title?: string; customer?: string; site?: string; team?: string; plannedStart?: Date | null; isEmergency?: boolean; reportId?: string; reportType?: "daily" | "completion"; importJobId?: string; fileName?: string; errorMessage?: string; syncErrorCode?: string; actorName?: string; emergencyStart?: Date | null; emergencyEnd?: Date | null; /** L12: reason of a rejected time entry */ rejectionReason?: string; }; export type RecipientPlan = { /** Users receiving an in-app notification (actor already removed where applicable). */ users: Target[]; /** Users who additionally get an e-mail (subject to preferences unless mandatory). */ mailUserIds: ReadonlySet; /** Configured external addresses (tenant mail settings) — e-mail only. */ externalEmails: string[]; mandatory: boolean; facts: EventFacts; }; const USER_SELECT = { id: true, email: true, name: true, identity: { select: { uiLocale: true } }, userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } }, } satisfies Prisma.UserSelect; function hasPermissionWhere(key: string): Prisma.UserWhereInput { return { userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } }; } /** Load active users of the current tenant matching `where` (always via the tenant client). */ export async function loadTargets(ctx: ServiceCtx, where: Prisma.UserWhereInput, fallbackLocale: string | null | undefined): Promise { const rows = await ctx.db.user.findMany({ where: { AND: [where, { status: "ACTIVE" }] }, select: USER_SELECT }); return rows.map((u) => ({ userId: u.id, email: u.email, name: u.name, locale: normalizeLocale(u.identity?.uiLocale ?? fallbackLocale), permissions: new Set(u.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))), })); } /** Back office responsible for reviews: users with work_order:read_all AND report:approve. */ export function backofficeWhere(): Prisma.UserWhereInput { return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] }; } /** L12: back office approving time entries (all entries): work_order:read_all AND time:approve. */ export function timeApproverBackofficeWhere(): Prisma.UserWhereInput { return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("time:approve")] }; } export function billingWhere(): Prisma.UserWhereInput { return hasPermissionWhere("work_order:release_billing"); } type LoadedWorkOrder = { id: string; number: string; title: string; isEmergency: boolean; plannedStart: Date | null; createdAt: Date; teamLeadUserId: string | null; customer: { companyName: string | null; firstName: string | null; lastName: string | null } | null; site: { name: string | null } | null; team: { id: string; name: string; leaderUserId: string | null } | null; assignees: { userId: string }[]; }; async function loadWorkOrder(ctx: ServiceCtx, id: string): Promise { return ctx.db.workOrder.findFirst({ where: { id, deletedAt: null }, select: { id: true, number: true, title: true, isEmergency: true, plannedStart: true, createdAt: true, teamLeadUserId: true, customer: { select: { companyName: true, firstName: true, lastName: true } }, site: { select: { name: true } }, team: { select: { id: true, name: true, leaderUserId: true } }, assignees: { select: { userId: true } }, }, }) as Promise; } export function customerLabel(c: LoadedWorkOrder["customer"]): string | undefined { if (!c) return undefined; return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || undefined; } /** Active team members + team leader + order team lead + individual assignees. */ async function participantIds(ctx: ServiceCtx, wo: LoadedWorkOrder): Promise { const ids = new Set(); if (wo.team) { const now = new Date(); const members = await ctx.db.teamMember.findMany({ where: { teamId: wo.team.id, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] }, select: { userId: true }, }); members.forEach((m) => ids.add(m.userId)); if (wo.team.leaderUserId) ids.add(wo.team.leaderUserId); } if (wo.teamLeadUserId) ids.add(wo.teamLeadUserId); wo.assignees.forEach((a) => ids.add(a.userId)); return [...ids]; } function teamLeadIds(wo: LoadedWorkOrder): string[] { return [...new Set([wo.teamLeadUserId, wo.team?.leaderUserId].filter((x): x is string => !!x))]; } function workOrderFacts(wo: LoadedWorkOrder): EventFacts { return { workOrderId: wo.id, number: wo.number, title: wo.title, customer: customerLabel(wo.customer), site: wo.site?.name ?? undefined, team: wo.team?.name, plannedStart: wo.plannedStart, isEmergency: wo.isEmergency, }; } function dateFromData(v: unknown): Date | null { if (typeof v !== "string" && typeof v !== "number") return null; const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d; } type Rule = { users: Prisma.UserWhereInput[]; /** ids from entity rows — re-resolved through ctx.db.user */ userIds: string[]; /** false = in-app only for the users (e-mail goes to `external`); default true */ mailToUsers?: boolean; external?: string[]; }; /** * Resolve recipients for an event. Returns null when the entity is not visible in this tenant * (unknown id, other tenant, soft-deleted) — then nothing is sent. */ export async function resolveRecipients( ctx: ServiceCtx, event: DomainEvent, settings: { locale?: string | null; emergencyRecipients?: string[]; billingRecipients?: string[] } | null, ): Promise { const facts: EventFacts = {}; let rule: Rule; switch (event.type) { case "work_order.assigned": case "work_order.changed": case "work_order.cancelled": { const wo = await loadWorkOrder(ctx, event.entityId); if (!wo) return null; Object.assign(facts, workOrderFacts(wo)); rule = { users: [], userIds: await participantIds(ctx, wo) }; break; } case "work_order.started": case "work_order.daily_report_created": case "work_order.technically_completed": case "work_order.signature_missing": case "work_order.missing_required": { const wo = await loadWorkOrder(ctx, event.entityId); if (!wo) return null; Object.assign(facts, workOrderFacts(wo)); rule = { users: [backofficeWhere()], userIds: [] }; break; } case "work_order.released_for_billing": { const wo = await loadWorkOrder(ctx, event.entityId); if (!wo) return null; Object.assign(facts, workOrderFacts(wo)); const configured = settings?.billingRecipients ?? []; // In-app for billing staff; e-mail to the configured billing recipients, or — if none are // configured — to the billing staff themselves. rule = { users: [billingWhere()], userIds: [], mailToUsers: configured.length === 0, external: configured, }; break; } case "emergency.created": case "emergency.completed": { const wo = await loadWorkOrder(ctx, event.entityId); if (!wo) return null; Object.assign(facts, workOrderFacts(wo)); facts.emergencyStart = dateFromData(event.data?.startedAt) ?? wo.plannedStart ?? wo.createdAt; facts.emergencyEnd = event.type === "emergency.completed" ? (dateFromData(event.data?.endedAt) ?? new Date()) : null; rule = { users: [backofficeWhere()], userIds: [], external: settings?.emergencyRecipients ?? [] }; break; } case "report.submitted": case "report.approved": case "report.rejected": { const report = await ctx.db.report.findFirst({ where: { id: event.entityId }, select: { id: true, type: true, workOrderId: true, createdById: true }, }); if (!report) return null; const wo = await loadWorkOrder(ctx, report.workOrderId); if (!wo) return null; Object.assign(facts, workOrderFacts(wo), { reportId: report.id, reportType: report.type }); if (event.type === "report.submitted") { // data.approvalStage (set by the reports lane): "team" → team leads only, // "backoffice" → back office only, unset → both. const stage = event.data?.approvalStage; const leadsWhere: Prisma.UserWhereInput = { AND: [hasPermissionWhere("report:approve_team"), { id: { in: teamLeadIds(wo) } }], }; if (stage === "team") rule = { users: [leadsWhere], userIds: [] }; else if (stage === "backoffice") rule = { users: [backofficeWhere()], userIds: [] }; else rule = { users: [backofficeWhere(), leadsWhere], userIds: [] }; } else { const ids = await participantIds(ctx, wo); if (report.createdById) ids.push(report.createdById); rule = { users: [], userIds: ids }; } break; } case "import.ready_for_review": case "import.failed": { const job = await ctx.db.importJob.findFirst({ where: { id: event.entityId }, select: { id: true, importedById: true, errorMessage: true, documentId: true }, }); if (!job) return null; const doc = await ctx.db.document.findFirst({ where: { id: job.documentId }, select: { fileName: true } }); Object.assign(facts, { importJobId: job.id, fileName: doc?.fileName, errorMessage: job.errorMessage ?? undefined }); rule = { users: [], userIds: job.importedById ? [job.importedById] : [] }; break; } case "time.approval_requested": case "time.approved": case "time.rejected": { const entry = await ctx.db.timeEntry.findFirst({ where: { id: event.entityId }, select: { id: true, userId: true, workSession: { select: { workOrderId: true } } }, }); if (!entry) return null; const wo = await loadWorkOrder(ctx, entry.workSession.workOrderId); if (!wo) return null; Object.assign(facts, workOrderFacts(wo)); if (typeof event.data?.reason === "string") facts.rejectionReason = event.data.reason; if (event.type === "time.approval_requested") { // team leads of the order's team / the technician's teams (with time:approve), otherwise back office const now = new Date(); const memberships = await ctx.db.teamMember.findMany({ where: { userId: entry.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } }, select: { team: { select: { leaderUserId: true } } }, }); const candidateIds = [...teamLeadIds(wo), ...memberships.map((m) => m.team.leaderUserId)].filter( (id): id is string => !!id && id !== entry.userId && id !== ctx.userId, ); const leads = candidateIds.length ? await ctx.db.user.findMany({ where: { AND: [{ id: { in: [...new Set(candidateIds)] } }, { status: "ACTIVE" }, hasPermissionWhere("time:approve")] }, select: { id: true } }) : []; rule = leads.length ? { users: [], userIds: leads.map((l) => l.id) } : { users: [timeApproverBackofficeWhere()], userIds: [] }; } else { // decision → the technician, in-app only rule = { users: [], userIds: [entry.userId], mailToUsers: false }; } break; } case "sync.failed": { const op = await ctx.db.syncOperation.findFirst({ where: { id: event.entityId }, select: { id: true, userId: true, errorCode: true, entityType: true, entityId: true }, }); if (!op) return null; facts.syncErrorCode = op.errorCode ?? (typeof event.data?.reason === "string" ? event.data.reason : undefined); if (op.entityType === "work_order" && op.entityId) { const wo = await loadWorkOrder(ctx, op.entityId); if (wo) Object.assign(facts, workOrderFacts(wo)); } rule = { users: [backofficeWhere()], userIds: [op.userId] }; break; } default: return null; } const or: Prisma.UserWhereInput[] = [...rule.users]; if (rule.userIds.length) or.push({ id: { in: [...new Set(rule.userIds)] } }); let users = or.length ? await loadTargets(ctx, { OR: or }, settings?.locale) : []; const keepActor = SYSTEM_RESULT_EVENTS.has(event.type); if (!keepActor) users = users.filter((u) => u.userId !== ctx.userId); const mailUserIds = new Set(rule.mailToUsers === false ? [] : users.map((u) => u.userId)); // Configured external addresses: skip those already covered by a user mail (no double mail). const userMails = new Set(users.filter((u) => mailUserIds.has(u.userId)).map((u) => u.email.toLowerCase())); const externalEmails = [...new Set((rule.external ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean))].filter( (e) => !userMails.has(e), ); // Actor display name (for "{actor} hat …" texts) — tenant-bound lookup. const actor = await ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } }); facts.actorName = typeof event.data?.technician === "string" ? event.data.technician : actor?.name; return { users, mailUserIds, externalEmails, mandatory: MANDATORY_EMAIL_EVENTS.has(event.type), facts }; }