Phase 6
Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
73 files changed
+8400
-53
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 },
|
||||
) {
|
||||
return 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 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() },
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
Reference in new issue
Block a user