All files / routes/homeowner profile.routes.ts

100% Statements 34/34
100% Branches 8/8
100% Functions 5/5
100% Lines 32/32

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96                      1x                   1x   1x       4x                 8x 8x 7x 6x                                   1x 3x 3x 3x 3x 3x             1x 16x 16x 16x 16x 16x 16x       1x 3x 3x 3x 3x 3x 3x   3x   3x 3x 3x        
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { freeTextSchema } from "@interioring/utils/validation/free-text";
import { createDal } from "../../dal";
import { getDb } from "../../db";
import { success } from "../../lib/response";
 
type HoUser = { id: string; name: string; email: string };
type Variables = { hoUser: HoUser | null; };
 
const app = new Hono<{ Bindings: CloudflareBindings; Variables: Variables }>();
 
// Empty string is a valid "clear this field" signal from the form; any non-empty
// value must satisfy the format constraint. #382: API previously accepted
// "46564655" (name) and "+5ytuygvuvbhi" (phone) because there was no format check.
//
// The marketplace client runs the same rules client-side at
// `apps/marketplace/src/lib/profile-validation.ts` (cross-app imports would
// break the monorepo boundary). If you change the regex, digit minimum, or the
// empty-string-accepted semantics here, update that file too.
const PHONE_FORMAT = /^\+?[\d\s()-]+$/;
 
const profileUpdateSchema = z.object({
	displayName: z
		.string()
		.max(100)
		.refine((v) => v.trim() === "" || /\p{L}/u.test(v), {
			message: "Display name must contain at least one letter",
		})
		.optional(),
	phone: z
		.string()
		.max(15)
		.refine(
			(v) => {
				const trimmed = v.trim();
				if (trimmed === "") return true;
				if (!PHONE_FORMAT.test(trimmed)) return false;
				return trimmed.replace(/\D/g, "").length >= 7;
			},
			{
				message:
					"Phone must contain only digits, spaces, hyphens, parentheses, and an optional leading +",
			},
		)
		.optional(),
	// Free-text city/locality must contain ≥1 letter when present (rejects
	// "12345"). Allows digits like "Sector 12" or "HSR Layout 5th Block".
	city: freeTextSchema({ minLen: 2, requireLetter: true, maxLen: 100 }),
	locality: freeTextSchema({ minLen: 2, requireLetter: true, maxLen: 100 }),
	propertyType: z.enum(["apartment", "villa", "independent_house", "penthouse"]).optional(),
	budgetRange: freeTextSchema({ minLen: 1, requireLetter: false, maxLen: 50 }),
	timeline: z.enum(["immediate", "1_3_months", "3_6_months", "exploring"]).optional(),
});
 
// GET /api/homeowner/profile
app.get("/", async (c) => {
	const user = c.get("hoUser")!;
	const db = getDb(c.env.DB);
	const dal = createDal(db);
	const profile = await dal.hoProfiles.findByUserId(user.id);
	return success(c, {
		user: { id: user.id, name: user.name, email: user.email },
		profile: profile ?? null,
	});
});
 
// PUT /api/homeowner/profile
app.put("/", zValidator("json", profileUpdateSchema), async (c) => {
	const user = c.get("hoUser")!;
	const data = c.req.valid("json");
	const db = getDb(c.env.DB);
	const dal = createDal(db);
	const profile = await dal.hoProfiles.upsert(user.id, data);
	return success(c, profile);
});
 
// DELETE /api/homeowner/profile (account deletion)
app.delete("/", async (c) => {
	const user = c.get("hoUser")!;
	const db = getDb(c.env.DB);
	const { hoUsers } = await import("../../db/schema/homeowner");
	const { eq } = await import("drizzle-orm");
	const { invalidateHoSession } = await import("../../lib/ho-session-cache");
	const { createDualCache } = await import("../../lib/cache");
	// Cascade deletes handle favorites, mood boards, profile via FK constraints
	await db.delete(hoUsers).where(eq(hoUsers.id, user.id)).run();
	// Invalidate session cache so stale tokens cannot access deleted account
	const cache = createDualCache(c.env.KV_CACHE);
	await invalidateHoSession(cache, c.req.raw.headers);
	return success(c, { deleted: true });
});
 
export default app;