Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
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
|
||||
@@ -10,12 +13,13 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
||||
* immediately; this file only tracks the separate "has this person set
|
||||
* their own password yet" signal via StaffInvitation.acceptedAt.
|
||||
*
|
||||
* No real email delivery this phase — `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) surfaced to the owner as a copy-link
|
||||
* (`/invite/accept?token=...`) to send however they like. This is a
|
||||
* documented development-safe shortcut, not a production email system.
|
||||
* `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;
|
||||
@@ -30,7 +34,7 @@ export async function createInvitation(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
|
||||
) {
|
||||
return prisma.staffInvitation.create({
|
||||
const invitation = await prisma.staffInvitation.create({
|
||||
data: {
|
||||
businessId: params.businessId,
|
||||
staffId: params.staffId,
|
||||
@@ -40,6 +44,28 @@ export async function createInvitation(
|
||||
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 owner"} has added you as ${staff.role.name} at ${staff.business.name}. Set your password to get started.`,
|
||||
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`). */
|
||||
@@ -99,5 +125,19 @@ export async function acceptInvitation(
|
||||
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 set their password 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 };
|
||||
}
|
||||
Reference in new issue
Block a user