Customer Profile

Customer profile update with image upload.
This commit is contained in:
Stefan Lazic committed 2026-08-06 12:11:36 +01:00
1 parent f07c30da0d
commit 973f18f64c
11 files changed
+761 -25

No files matched your search

+22 -17
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Bell, Gift, LogIn } from "lucide-react";
import { Bell, ChevronRight, Gift, LogIn, User } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
@@ -13,6 +13,12 @@ import { InstallAppButton } from "@/features/pwa/components/install-app-button";
export const metadata: Metadata = { title: "Settings" };
export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/settings/preferred-barber", label: "Preferred Barber", icon: User },
{ href: "/loyalty", label: "Loyalty Card", icon: Gift },
{ href: "/settings/notifications", label: "Notifications", icon: Bell },
] as const;
export default async function CustomerSettingsPage() {
const session = await auth();
@@ -34,8 +40,9 @@ export default async function CustomerSettingsPage() {
);
}
const [customerProfile, branding] = await Promise.all([
const [customerProfile, user, branding] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
getBranding(),
]);
@@ -49,22 +56,20 @@ export default async function CustomerSettingsPage() {
: (session.user.name ?? "Signed-in customer")
}
meta={customerProfile?.email ?? session.user.email}
avatarUrl={session.user.image ?? undefined}
avatarUrl={user?.image ?? undefined}
editHref="/settings/profile"
/>
<div className="space-y-2">
<Link
href="/loyalty"
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
>
<Gift className="h-4 w-4" aria-hidden="true" /> Loyalty card
</Link>
<Link
href="/settings/notifications"
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
>
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
</Link>
</div>
<ul className="divide-line divide-y rounded-md border border-line bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
<span className="flex-1">{label}</span>
<ChevronRight className="text-muted h-4 w-4" aria-hidden="true" />
</Link>
</li>
))}
</ul>
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { PreferredBarberPicker } from "@/features/customer/components/preferred-barber-picker";
export const metadata: Metadata = { title: "Preferred Barber" };
export const dynamic = "force-dynamic";
export default async function PreferredBarberPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/preferred-barber");
const [business, customerProfile] = await Promise.all([
getPrimaryBusiness(),
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
]);
const staff = await prisma.staffProfile.findMany({
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
include: { user: { select: { name: true, image: true } } },
});
return (
<div className="pb-12">
<TopNav title="Preferred Barber" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<p className="text-muted text-sm">Chosen for you by default when booking — you can still pick anyone else.</p>
<PreferredBarberPicker
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Barber", avatarUrl: s.user.image ?? undefined }))}
initialPreferredId={customerProfile?.preferredBarberId ?? null}
/>
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { CustomerProfileForm } from "@/features/customer/components/customer-profile-form";
export const metadata: Metadata = { title: "Edit Profile" };
export const dynamic = "force-dynamic";
export default async function CustomerEditProfilePage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/profile");
const [customerProfile, user] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
]);
const [sessionFirstName, ...sessionLastNameParts] = (session.user.name ?? "").trim().split(" ");
return (
<div className="pb-12">
<TopNav title="Edit Profile" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<CustomerProfileForm
initial={{
firstName: customerProfile?.firstName ?? sessionFirstName ?? "",
lastName: customerProfile?.lastName ?? sessionLastNameParts.join(" "),
email: customerProfile?.email ?? session.user.email ?? "",
phone: customerProfile?.phone ?? "",
image: user?.image ?? "",
}}
/>
</main>
</div>
);
}
+14 -8
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import Link from "next/link";
import { Pencil } from "lucide-react";
import type { ReactNode } from "react";
import { Card } from "@/components/ui/card";
@@ -8,10 +9,14 @@ export interface ProfileCardProps {
meta?: ReactNode;
avatarUrl?: string;
onEdit?: () => void;
/** Renders the edit-pencil as a `<Link>` instead of a button — lets a Server Component page use the edit affordance without becoming a Client Component. Ignored if `onEdit` is also passed. */
editHref?: string;
}
/** Avatar + name + meta + edit-pencil pattern reused across nearly every settings/profile screen. */
export function ProfileCard({ name, meta, avatarUrl, onEdit }: ProfileCardProps) {
export function ProfileCard({ name, meta, avatarUrl, onEdit, editHref }: ProfileCardProps) {
const editButtonClassName = "flex h-10 w-10 items-center justify-center rounded-full bg-stone text-ink";
return (
<Card className="flex items-center gap-4 p-5">
{avatarUrl ? (
@@ -31,15 +36,16 @@ export function ProfileCard({ name, meta, avatarUrl, onEdit }: ProfileCardProps)
<h2 className="text-lg font-bold">{name}</h2>
{meta && <div className="mt-0.5 text-xs text-muted">{meta}</div>}
</div>
{onEdit && (
<button
type="button"
onClick={onEdit}
aria-label={`Edit ${name}`}
className="flex h-10 w-10 items-center justify-center rounded-full bg-stone text-ink"
>
{onEdit ? (
<button type="button" onClick={onEdit} aria-label={`Edit ${name}`} className={editButtonClassName}>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : (
editHref && (
<Link href={editHref} aria-label={`Edit ${name}`} className={editButtonClassName}>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
)
)}
</Card>
);
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { customerProfileSchema } from "@/features/customer/schemas";
import {
CUSTOMER_AVATAR_CATEGORY,
setCustomerAvatar,
updateCustomerProfile,
updatePreferredBarber,
} from "@/services/customer/customer.service";
import { deleteMediaFile, isAllowedMediaMimeType, MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
export interface CustomerProfileActionResult {
success: boolean;
error?: string;
/** Per-field errors, keyed by field name, for the form to surface inline. */
fieldErrors?: Record<string, string[] | undefined>;
}
export async function updateCustomerProfileAction(input: {
firstName: string;
lastName: string;
email: string;
phone: string;
}): Promise<CustomerProfileActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
const parsed = customerProfileSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const result = await updateCustomerProfile(prisma, { userId: session.user.id, ...parsed.data, phone: parsed.data.phone ?? "" });
if (!result.success) {
return {
success: false,
error: result.error,
fieldErrors: result.field ? { [result.field]: [result.error ?? ""] } : undefined,
};
}
return { success: true };
}
export async function updatePreferredBarberAction(staffId: string | null): Promise<CustomerProfileActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
return updatePreferredBarber(prisma, { userId: session.user.id, staffId });
}
export interface CustomerAvatarActionResult {
success: boolean;
url?: string | null;
error?: string;
}
/**
* Uploads a customer's own profile photo straight from the device file
* picker — no Media Manager/library involved, unlike staff/service/branding
* images (see MediaPicker). Stored under its own category so it's never
* inserted as a MediaAsset row and therefore never browsable in that
* library. Takes effect immediately (writes User.image right away) rather
* than waiting for the rest of the profile form's Save, and deletes the
* previous photo file once the new one is safely persisted — but only if
* that previous file was one we stored (an OAuth-provided avatar URL is
* left alone).
*/
export async function uploadCustomerAvatarAction(formData: FormData): Promise<CustomerAvatarActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
const file = formData.get("file");
if (!(file instanceof File)) return { success: false, error: "No file provided." };
if (!isAllowedMediaMimeType(file.type)) {
return { success: false, error: "Unsupported image type — use PNG, JPEG, WEBP, or GIF." };
}
if (file.size > MEDIA_MAX_UPLOAD_BYTES) {
return { success: false, error: `File is too large — max ${Math.floor(MEDIA_MAX_UPLOAD_BYTES / 1_000_000)}MB.` };
}
const buffer = Buffer.from(await file.arrayBuffer());
const stored = await storeMediaFile({ buffer, mimeType: file.type, category: CUSTOMER_AVATAR_CATEGORY });
const { previousUrl } = await setCustomerAvatar(prisma, { userId: session.user.id, url: stored.url });
if (previousUrl?.includes(`/${CUSTOMER_AVATAR_CATEGORY}/`)) {
await deleteMediaFile(previousUrl);
}
return { success: true, url: stored.url };
}
export async function removeCustomerAvatarAction(): Promise<CustomerAvatarActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
const { previousUrl } = await setCustomerAvatar(prisma, { userId: session.user.id, url: null });
if (previousUrl?.includes(`/${CUSTOMER_AVATAR_CATEGORY}/`)) {
await deleteMediaFile(previousUrl);
}
return { success: true, url: null };
}
@@ -0,0 +1,92 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { ImageOff, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { removeCustomerAvatarAction, uploadCustomerAvatarAction } from "@/features/customer/actions/profile";
export interface CustomerAvatarPickerProps {
value: string | null;
onChange: (url: string | null) => void;
}
/**
* The customer's own profile photo — opens the device's native file picker
* directly and uploads immediately, unlike MediaPicker (staff/service/
* branding images), which opens the shared Media Manager library. Kept
* deliberately separate: a customer's photo is personal, not something an
* owner should be able to browse/reuse/reassign to something else.
*/
export function CustomerAvatarPicker({ value, onChange }: CustomerAvatarPickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
const formData = new FormData();
formData.set("file", file);
startTransition(async () => {
const result = await uploadCustomerAvatarAction(formData);
if (!result.success) {
setError(result.error ?? "Couldn't upload your photo.");
return;
}
onChange(result.url ?? null);
});
}
function handleRemove() {
setError(null);
startTransition(async () => {
const result = await removeCustomerAvatarAction();
if (!result.success) {
setError(result.error ?? "Couldn't remove your photo.");
return;
}
onChange(null);
});
}
return (
<div className="w-full">
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Photo</p>
<div className="border-line flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="bg-stone flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{value ? (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide
<img src={value} alt="" className="h-full w-full object-cover" />
) : (
<ImageOff className="text-muted h-5 w-5" aria-hidden="true" />
)}
</div>
<div className="flex flex-1 flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" isLoading={isPending} onClick={() => inputRef.current?.click()}>
{value ? "Change" : "Choose image"}
</Button>
{value && (
<Button type="button" size="sm" variant="ghost" disabled={isPending} onClick={handleRemove}>
<X className="h-3.5 w-3.5" aria-hidden="true" /> Remove
</Button>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleFileChange}
/>
</div>
{error && (
<p role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { CustomerAvatarPicker } from "@/features/customer/components/customer-avatar-picker";
import { updateCustomerProfileAction } from "@/features/customer/actions/profile";
export interface CustomerProfileFormInitial {
firstName: string;
lastName: string;
email: string;
phone: string;
image: string;
}
export function CustomerProfileForm({ initial }: { initial: CustomerProfileFormInitial }) {
const router = useRouter();
const [firstName, setFirstName] = useState(initial.firstName);
const [lastName, setLastName] = useState(initial.lastName);
const [email, setEmail] = useState(initial.email);
const [phone, setPhone] = useState(initial.phone);
const [image, setImage] = useState(initial.image);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string | undefined>>({});
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const handleSave = () => {
setError(null);
setFieldErrors({});
setSaved(false);
startTransition(async () => {
const result = await updateCustomerProfileAction({ firstName, lastName, email, phone });
if (!result.success) {
setError(result.error ?? "Couldn't save your profile.");
if (result.fieldErrors) {
setFieldErrors(Object.fromEntries(Object.entries(result.fieldErrors).map(([key, value]) => [key, value?.[0]])));
}
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<CustomerAvatarPicker value={image || null} onChange={(url) => setImage(url ?? "")} />
<div className="grid grid-cols-2 gap-4">
<Input
label="First Name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
error={fieldErrors.firstName}
/>
<Input label="Last Name" value={lastName} onChange={(e) => setLastName(e.target.value)} error={fieldErrors.lastName} />
</div>
<Input
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={fieldErrors.email}
/>
<Input
label="Phone Number"
type="tel"
placeholder="+44 7700 900000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
error={fieldErrors.phone}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
import Image from "next/image";
import { Check } from "lucide-react";
import { useState, useTransition } from "react";
import { updatePreferredBarberAction } from "@/features/customer/actions/profile";
export interface PreferredBarberOption {
id: string;
name: string;
avatarUrl?: string;
}
/** Tap-to-select, saves immediately (no separate Save step) — same "pick one" pattern as the Reviews manager's hide/unhide toggle: one action per row, optimistic update, revert on failure. */
export function PreferredBarberPicker({
staff,
initialPreferredId,
}: {
staff: PreferredBarberOption[];
initialPreferredId: string | null;
}) {
const [preferredId, setPreferredId] = useState(initialPreferredId);
const [pendingId, setPendingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const select = (id: string | null) => {
if (id === preferredId) return;
const previous = preferredId;
setError(null);
setPreferredId(id);
setPendingId(id ?? "none");
startTransition(async () => {
const result = await updatePreferredBarberAction(id);
if (!result.success) {
setPreferredId(previous);
setError(result.error ?? "Couldn't save your preference.");
}
setPendingId(null);
});
};
return (
<div className="space-y-3">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<ul className="divide-line divide-y rounded-md border border-line bg-white">
<li>
<button
type="button"
onClick={() => select(null)}
disabled={pendingId !== null}
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-bold disabled:opacity-60"
>
<div className="bg-stone flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-muted">—</div>
<span className="flex-1">No preference</span>
{preferredId === null && <Check className="text-accent h-4 w-4" aria-hidden="true" />}
</button>
</li>
{staff.map((barber) => (
<li key={barber.id}>
<button
type="button"
onClick={() => select(barber.id)}
disabled={pendingId !== null}
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-bold disabled:opacity-60"
>
{barber.avatarUrl ? (
<Image
src={barber.avatarUrl}
alt=""
width={44}
height={44}
className="h-11 w-11 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{barber.name.charAt(0)}
</div>
)}
<span className="flex-1">{barber.name}</span>
{preferredId === barber.id && <Check className="text-accent h-4 w-4" aria-hidden="true" />}
</button>
</li>
))}
</ul>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
/** Shared between the customer profile-edit form and its server action, same rationale as auth/schemas.ts. */
export const customerProfileSchema = z.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().optional().or(z.literal("")),
});
export type CustomerProfileInput = z.infer<typeof customerProfileSchema>;
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("customer.service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("updateCustomerProfile", () => {
it("updates both User and CustomerProfile", async () => {
const { userId } = await createTestCustomer(business);
// Distinct from the phone number, not just the email — User.phone has
// its own unique constraint, so a hardcoded literal here would
// collide with any other row (seed data, another test run) that
// happens to use the same number.
const phone = `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`;
const result = await updateCustomerProfile(prisma, {
userId,
firstName: "Jamie",
lastName: "Fraser",
email: `jamie-${userId}@test.local`,
phone,
});
expect(result.success).toBe(true);
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.name).toBe("Jamie Fraser");
expect(user.email).toBe(`jamie-${userId}@test.local`);
expect(user.phone).toBe(phone);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
expect(profile.firstName).toBe("Jamie");
expect(profile.lastName).toBe("Fraser");
expect(profile.phone).toBe(phone);
});
it("creates a CustomerProfile via upsert for a user who has never booked", async () => {
const user = await prisma.user.create({ data: { email: `never-booked-${Date.now()}@test.local`, name: "New User" } });
business.createdUserIds.push(user.id);
const result = await updateCustomerProfile(prisma, {
userId: user.id,
firstName: "New",
lastName: "User",
email: user.email!,
phone: "",
});
expect(result.success).toBe(true);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
expect(profile.firstName).toBe("New");
expect(profile.isGuest).toBe(false);
});
it("rejects an email already used by another user", async () => {
const { userId } = await createTestCustomer(business);
const other = await createTestCustomer(business);
const otherUser = await prisma.user.findUniqueOrThrow({ where: { id: other.userId } });
const result = await updateCustomerProfile(prisma, {
userId,
firstName: "Jamie",
lastName: "Fraser",
email: otherUser.email!,
phone: "",
});
expect(result.success).toBe(false);
expect(result.field).toBe("email");
});
});
describe("setCustomerAvatar", () => {
it("sets User.image and returns the previous value", async () => {
const { userId } = await createTestCustomer(business);
const first = await setCustomerAvatar(prisma, { userId, url: "/api/media/customer-avatars/a.jpg" });
expect(first.previousUrl).toBeNull();
let user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.image).toBe("/api/media/customer-avatars/a.jpg");
const second = await setCustomerAvatar(prisma, { userId, url: "/api/media/customer-avatars/b.jpg" });
expect(second.previousUrl).toBe("/api/media/customer-avatars/a.jpg");
user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.image).toBe("/api/media/customer-avatars/b.jpg");
const cleared = await setCustomerAvatar(prisma, { userId, url: null });
expect(cleared.previousUrl).toBe("/api/media/customer-avatars/b.jpg");
user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
expect(user.image).toBeNull();
});
});
describe("updatePreferredBarber", () => {
it("sets and clears a preferred barber on an existing CustomerProfile", async () => {
const { userId } = await createTestCustomer(business);
const { staffId } = await createTestStaff(business);
const set = await updatePreferredBarber(prisma, { userId, staffId });
expect(set.success).toBe(true);
let profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
expect(profile.preferredBarberId).toBe(staffId);
const cleared = await updatePreferredBarber(prisma, { userId, staffId: null });
expect(cleared.success).toBe(true);
profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
expect(profile.preferredBarberId).toBeNull();
});
it("creates a CustomerProfile from session User data when none exists yet", async () => {
const user = await prisma.user.create({ data: { email: `no-profile-${Date.now()}@test.local`, name: "Casey Rivers" } });
business.createdUserIds.push(user.id);
const { staffId } = await createTestStaff(business);
const result = await updatePreferredBarber(prisma, { userId: user.id, staffId });
expect(result.success).toBe(true);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
expect(profile.firstName).toBe("Casey");
expect(profile.lastName).toBe("Rivers");
expect(profile.preferredBarberId).toBe(staffId);
});
it("rejects a staffId that doesn't exist", async () => {
const { userId } = await createTestCustomer(business);
const result = await updatePreferredBarber(prisma, { userId, staffId: "not-a-real-staff-id" });
expect(result.success).toBe(false);
});
});
});
+117
View File
@@ -149,3 +149,120 @@ export function matchesCustomerSearch(
(customer.phone ?? "").toLowerCase().includes(normalized)
);
}
export interface UpdateCustomerProfileParams {
userId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
}
export interface UpdateCustomerProfileResult {
success: boolean;
error?: string;
/** Set only for a validation failure the caller should surface on a specific field (currently just a taken email). */
field?: "email";
}
/**
* A signed-in customer editing their own name/email/phone — see
* features/customer/actions/profile.ts, the only caller. Writes both User
* (the actual auth/session record, same split staff profile editing uses
* in staff.service.ts's updateStaffProfile) and CustomerProfile (the
* denormalized firstName/lastName/email/phone copy read everywhere else —
* see this file's own header on why the two can drift). A customer who has
* never booked yet has no CustomerProfile row at all, so this upserts
* rather than assuming one exists. Photo is handled separately by
* setCustomerAvatar below — it uploads/deletes immediately rather than
* waiting for this form's Save.
*/
export async function updateCustomerProfile(
prisma: PrismaClient,
params: UpdateCustomerProfileParams,
): Promise<UpdateCustomerProfileResult> {
const emailTaken = await prisma.user.findUnique({ where: { email: params.email }, select: { id: true } });
if (emailTaken && emailTaken.id !== params.userId) {
return { success: false, error: "That email is already in use.", field: "email" };
}
try {
await prisma.$transaction([
prisma.user.update({
where: { id: params.userId },
data: {
name: `${params.firstName} ${params.lastName}`.trim(),
email: params.email,
phone: params.phone || null,
},
}),
prisma.customerProfile.upsert({
where: { userId: params.userId },
create: {
userId: params.userId,
firstName: params.firstName,
lastName: params.lastName,
email: params.email,
phone: params.phone || null,
isGuest: false,
},
update: { firstName: params.firstName, lastName: params.lastName, email: params.email, phone: params.phone || null },
}),
]);
} catch {
return { success: false, error: "Couldn't save your profile." };
}
return { success: true };
}
/**
* Directory customer avatar uploads are stored under — deliberately never
* inserted as a MediaAsset row (see features/customer/actions/profile.ts's
* uploadCustomerAvatarAction), so these files never show up in the staff/
* owner Media Manager library. Exported so the "is this old image one of
* ours to delete" check in that action uses the exact same value.
*/
export const CUSTOMER_AVATAR_CATEGORY = "customer-avatars";
/** Swaps User.image, returning the previous value so the caller (uploadCustomerAvatarAction/removeCustomerAvatarAction) can delete the old file from disk after the DB write succeeds. */
export async function setCustomerAvatar(
prisma: PrismaClient,
params: { userId: string; url: string | null },
): Promise<{ previousUrl: string | null }> {
const previous = await prisma.user.findUniqueOrThrow({ where: { id: params.userId }, select: { image: true } });
await prisma.user.update({ where: { id: params.userId }, data: { image: params.url } });
return { previousUrl: previous.image };
}
/** Sets or clears (`staffId: null`) the customer's preferred barber — see Home's "Preferred Barber" display, the only other place this is read. */
export async function updatePreferredBarber(
prisma: PrismaClient,
params: { userId: string; staffId: string | null },
): Promise<{ success: boolean; error?: string }> {
if (params.staffId) {
const staff = await prisma.staffProfile.findUnique({ where: { id: params.staffId }, select: { id: true } });
if (!staff) return { success: false, error: "That barber couldn't be found." };
}
const existing = await prisma.customerProfile.findUnique({ where: { userId: params.userId } });
if (existing) {
await prisma.customerProfile.update({ where: { userId: params.userId }, data: { preferredBarberId: params.staffId } });
return { success: true };
}
const user = await prisma.user.findUniqueOrThrow({ where: { id: params.userId } });
const [firstName, ...rest] = (user.name ?? "").trim().split(/\s+/).filter(Boolean);
await prisma.customerProfile.create({
data: {
userId: params.userId,
firstName: firstName || "Customer",
lastName: rest.join(" "),
email: user.email ?? "",
phone: user.phone ?? undefined,
preferredBarberId: params.staffId,
isGuest: false,
},
});
return { success: true };
}