PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

@@ -1,143 +0,0 @@
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
* StaffService (see docs/phase-6-summary.md, "Domain services" — the
* brief's own instruction not to mix invitation delivery into StaffService).
* StaffService.createStaffProfile makes the User/StaffProfile usable
* immediately; this file only tracks the separate "has this person set
* their own password 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" | "INVALID_INPUT";
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 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`). */
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<Awaited<ReturnType<typeof getInvitationByToken>>> }
| { valid: false; reason: StaffInvitationErrorCode };
export async function validateInvitationToken(prisma: PrismaClient, token: string): Promise<InvitationCheck> {
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 };
}
export async function acceptInvitation(
prisma: PrismaClient,
params: { token: string; password: string },
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
const check = await validateInvitationToken(prisma, params.token);
if (!check.valid) return { success: false, reason: check.reason };
const passwordHash = await bcrypt.hash(params.password, 12);
await prisma.$transaction([
prisma.user.update({
where: { id: check.invitation.staff.userId },
data: { passwordHash, 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 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 };
}