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