Customer Profile
Customer profile update with image upload.
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user