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

@@ -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 };
}