import { randomBytes } from "node:crypto"; import type { PrismaClient } from "@/generated/prisma/client"; import { NotificationType } from "@/generated/prisma/enums"; import { notifySafely } from "@/services/notifications/notification.service"; import { getOwnerUserIds } from "@/services/notifications/recipients"; /** * The "invite a new staff member" flow, deliberately split out of * StaffService (see docs/phase-6-summary.md, "Domain services" — the * brief's own instruction not to mix invitation delivery into StaffService). * StaffService.createStaffProfile creates the User/StaffProfile * immediately, but requestOtp's `onlyIfUserExists` check (src/lib/auth/ * otp.service.ts) also requires `emailVerified` — so a newly-invited * account genuinely cannot sign in until this file's acceptInvitation sets * that, which itself only happens after the invitee verifies an OTP sent * to this exact email (see src/features/auth/actions/accept-invitation.ts). * This file tracks that "has this person verified their email yet" signal * via StaffInvitation.acceptedAt. * * `token` is a plain opaque value (crypto-random, not HMAC-signed like the * booking-draft cookie, because it's validated against a database row * rather than needing to be self-contained) — it's still also surfaced to * the owner as a copy-link (`/invite/accept?token=...`) as a fallback in * case email delivery is unavailable/unconfigured, but as of Phase 7 a * real STAFF_INVITATION_RECEIVED email is queued too (see * NotificationService/templates/staff-emails.ts). */ const INVITATION_TTL_DAYS = 7; export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED"; function generateToken(): string { return randomBytes(32).toString("base64url"); } export async function createInvitation( prisma: PrismaClient, params: { businessId: string; staffId: string; email: string; invitedByUserId?: string }, ) { const invitation = await prisma.staffInvitation.create({ data: { businessId: params.businessId, staffId: params.staffId, email: params.email, token: generateToken(), invitedByUserId: params.invitedByUserId, expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), }, }); const staff = await prisma.staffProfile.findUnique({ where: { id: params.staffId }, include: { user: { select: { id: true, name: true } }, role: { select: { name: true } }, business: { select: { name: true } } }, }); const invitedBy = params.invitedByUserId ? await prisma.user.findUnique({ where: { id: params.invitedByUserId }, select: { name: true } }) : null; if (staff) { await notifySafely(prisma, { businessId: params.businessId, type: NotificationType.STAFF_INVITATION_RECEIVED, recipientUserId: staff.user.id, subject: `You've been invited to join ${staff.business.name}`, bodyText: `${invitedBy?.name ?? "The business's administrator"} has added you as ${staff.role.name} at ${staff.business.name}. Verify your email to get started — no password needed.`, deepLinkPath: `/invite/accept?token=${invitation.token}`, idempotencyKey: `notify:staff-invitation-received:${invitation.id}`, }); } return invitation; } /** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */ export async function regenerateInvitation( prisma: PrismaClient, params: { staffId: string; businessId: string }, ) { const existing = await prisma.staffInvitation.findFirst({ where: { staffId: params.staffId, businessId: params.businessId }, }); if (!existing) return null; return prisma.staffInvitation.update({ where: { id: existing.id }, data: { token: generateToken(), expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000), acceptedAt: null }, }); } export async function getInvitationByToken(prisma: PrismaClient, token: string) { return prisma.staffInvitation.findUnique({ where: { token }, include: { staff: { include: { user: { select: { id: true, name: true, email: true } }, business: { select: { name: true } } } }, }, }); } export type InvitationCheck = | { valid: true; invitation: NonNullable>> } | { valid: false; reason: StaffInvitationErrorCode }; export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise { const invitation = await getInvitationByToken(prisma, token); if (!invitation) return { valid: false, reason: "INVALID_TOKEN" }; if (invitation.acceptedAt) return { valid: false, reason: "ALREADY_ACCEPTED" }; if (invitation.expiresAt < new Date()) return { valid: false, reason: "EXPIRED" }; return { valid: true, invitation }; } /** * Activates the invited staff member's account — no password to set. The * caller (verifyInvitationOtpAction, src/features/auth/actions/accept- * invitation.ts) has already required the invitee to request and verify a * one-time code sent to this exact email (via requestOtp/verifyOtp, * purpose "staff-invite") before calling this — the token alone is no * longer treated as sufficient proof of email ownership on its own. This * function itself just does the actual account-activation bookkeeping * (emailVerified + acceptedAt) once that verification has already * succeeded, still re-validating the token/expiry/already-accepted state * one more time for safety. */ export async function acceptInvitation( prisma: PrismaClient, params: { token: string }, ): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> { const check = await validateInvitationToken(prisma, params.token); if (!check.valid) return { success: false, reason: check.reason }; await prisma.$transaction([ prisma.user.update({ where: { id: check.invitation.staff.userId }, data: { emailVerified: new Date() }, }), prisma.staffInvitation.update({ where: { id: check.invitation.id }, data: { acceptedAt: new Date() }, }), ]); const ownerUserIds = await getOwnerUserIds(prisma, check.invitation.businessId); for (const ownerUserId of ownerUserIds) { await notifySafely(prisma, { businessId: check.invitation.businessId, type: NotificationType.OWNER_STAFF_INVITATION_ACCEPTED, recipientUserId: ownerUserId, subject: "Invitation accepted", bodyText: `${check.invitation.staff.user.name ?? "A new team member"} has verified their email and joined ${check.invitation.staff.business.name}.`, deepLinkPath: `/owner/team/${check.invitation.staff.id}`, idempotencyKey: `notify:owner-staff-invitation-accepted:${check.invitation.id}:${ownerUserId}`, }); } return { success: true }; }