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:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

@@ -0,0 +1,47 @@
"use server";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { acceptInvitationSchema } from "@/features/auth/schemas";
export interface AcceptInvitationResult {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[] | undefined>;
}
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
const parsed = acceptInvitationSchema.safeParse({
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
});
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const check = await validateInvitationToken(prisma, token);
if (!check.valid) {
const message =
check.reason === "EXPIRED"
? "This invitation link has expired. Ask the business owner to send you a new one."
: check.reason === "ALREADY_ACCEPTED"
? "This invitation has already been used — try signing in instead."
: "This invitation link isn't valid.";
return { success: false, error: message };
}
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
if (!result.success) {
return { success: false, error: "Couldn't accept this invitation. Please try again." };
}
await signIn("credentials", {
email: check.invitation.staff.user.email,
password: parsed.data.password,
redirectTo: "/staff",
});
return { success: true };
}
@@ -0,0 +1,69 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
import { acceptInvitationSchema } from "@/features/auth/schemas";
import type { AcceptInvitationInput } from "@/features/auth/schemas";
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
const onSubmit = async (data: AcceptInvitationInput) => {
setFormError(null);
const formData = new FormData();
formData.set("password", data.password);
formData.set("confirmPassword", data.confirmPassword);
const result = await acceptInvitationAction(token, formData);
if (!result.success) {
setFormError(result.error ?? "Something went wrong. Please try again.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) setError(field as keyof AcceptInvitationInput, { message: messages[0] });
}
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<p className="text-muted text-sm">
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
a password to finish setting up your account.
</p>
<Input
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{formError && (
<p role="alert" className="text-danger text-sm font-medium">
{formError}
</p>
)}
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Set Password &amp; Sign In
</Button>
</form>
);
}
+11
View File
@@ -26,3 +26,14 @@ export const registerSchema = z
path: ["confirmPassword"],
});
export type RegisterInput = z.infer<typeof registerSchema>;
export const acceptInvitationSchema = z
.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;