All files / routes/homeowner profile.routes.ts

100% Statements 42/42
80% Branches 16/20
100% Functions 6/6
100% Lines 40/40

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 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158                            1x         25x                     1x   1x       6x                 8x 8x 7x 6x                                           1x 3x 3x 3x           3x             3x                             1x     1x     1x 19x 19x 19x 19x 19x             19x 3x 3x         2x     2x 2x         1x       19x       1x 3x 3x 3x 3x   3x   3x 3x 3x        
import { zValidator } from "@hono/zod-validator";
import { freeTextSchema } from "@interioring/utils/validation/free-text";
import { Hono } from "hono";
import { z } from "zod";
import { createDal } from "../../dal";
import { getDb } from "../../db";
import { logger } from "../../lib/logger";
import { success } from "../../lib/response";
import avatarRoutes from "./profile/avatar.routes";
import credentialChangeRoutes from "./profile/credential-change.routes";
 
type HoUser = { id: string; name: string; email: string };
type Variables = { hoUser: HoUser | null };
 
const app = new Hono<{ Bindings: CloudflareBindings; Variables: Variables }>();
 
// Middleware guarantees hoUser is set; helper avoids noNonNullAssertion lint
// (same pattern as favorites.routes.ts).
function getUser(c: { get(key: "hoUser"): HoUser | null }): HoUser {
	return c.get("hoUser") as HoUser;
}
 
// 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 = getUser(c);
	const db = getDb(c.env.DB);
	const dal = createDal(db);
	// The session context carries only {id,name,email}; the verified phone lives
	// on the ho_users row. Phone-signup homeowners confirm a number over
	// WhatsApp before they ever open this page, but the response used to omit
	// it — so the Phone field rendered empty and asked them to retype the
	// number they had just verified.
	const [profile, account] = await Promise.all([
		dal.hoProfiles.findByUserId(user.id),
		dal.hoUsers.findById(user.id),
	]);
	// email/emailVerified read from the fresh `account` row, not the session
	// (`user`) — the session can be stale immediately after a change-email
	// verify, which would otherwise show the old placeholder here.
	return success(c, {
		user: {
			id: user.id,
			name: user.name,
			email: account?.email ?? user.email,
			emailVerified: account?.emailVerified ?? false,
			phoneNumber: account?.phoneNumber ?? null,
			phoneNumberVerified: account?.phoneNumberVerified ?? false,
			image: account?.image ?? null,
		},
		profile: profile ?? null,
	});
});
 
// OTP-based email change — see ./profile/credential-change.routes.ts
app.route("/", credentialChangeRoutes);
 
// Profile picture upload/remove — see ./profile/avatar.routes.ts
app.route("/", avatarRoutes);
 
// PUT /api/homeowner/profile
app.put("/", zValidator("json", profileUpdateSchema), async (c) => {
	const user = getUser(c);
	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);
 
	// Mirror displayName onto the auth user. The header avatar and the
	// "Welcome back, {name}" greeting read ho_users.name from the session, so
	// without this a renamed homeowner kept seeing the signup placeholder.
	// Best-effort: the profile row is the source of truth and is already saved,
	// so a failure here must not fail the request.
	if (data.displayName) {
		try {
			await dal.hoUsers.updateName(user.id, data.displayName);
			// Drop the cached session so the next request rebuilds it with the
			// new name instead of serving the stale one. refreshHoSessionCache
			// (cache-only) — NOT invalidateHoSession, which also blocklists the
			// token and would sign the user out on every rename.
			const { refreshHoSessionCache } = await import(
				"../../lib/ho-session-cache"
			);
			const { createDualCache } = await import("../../lib/cache");
			await refreshHoSessionCache(
				createDualCache(c.env.KV_CACHE),
				c.req.raw.headers,
			);
		} catch (err) {
			logger.error("[HO-PROFILE] name sync failed:", err);
		}
	}
 
	return success(c, profile);
});
 
// DELETE /api/homeowner/profile (account deletion)
app.delete("/", async (c) => {
	const user = getUser(c);
	const dal = createDal(getDb(c.env.DB));
	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 dal.hoUsers.deleteById(user.id);
	// 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;