All files / routes/homeowner pro-views.routes.ts

100% Statements 21/21
100% Branches 4/4
100% Functions 2/2
100% Lines 21/21

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                      1x       1x                               1x 2x 2x 2x 2x 2x 2x       1x   2x                             1x 5x 5x 5x   5x       5x         5x 5x 5x       5x        
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { createDal } from "../../dal";
import { getDb } from "../../db";
import { success } from "../../lib/response";
import { logger } from "../../lib/logger";
 
type HoUser = { id: string; name: string; email: string };
type Variables = { hoUser: HoUser };
 
const app = new Hono<{ Bindings: CloudflareBindings; Variables: Variables }>();
 
// Pro entity ids in this codebase are cuid2 (24 chars). Cap defensively to
// avoid an attacker stuffing a megabyte of garbage into a single insert.
const recordViewSchema = z.object({
	proId: z.string().min(1).max(128),
});
 
/**
 * POST /api/homeowner/pro-views
 *
 * Records that the authenticated homeowner viewed a pro page. Powers the
 * /account hub's activity bar ("you viewed N pros in {city}"). Fire-and-
 * forget at the call site (the marketplace pro page wraps this in
 * `ctx.waitUntil` so a slow D1 insert never blocks the page response).
 *
 * Always returns 202 Accepted — even if the pro doesn't exist or the
 * insert fails internally. The caller doesn't care; this is a write-only
 * analytics ping.
 */
app.post("/", zValidator("json", recordViewSchema), async (c) => {
	const user = c.get("hoUser");
	const { proId } = c.req.valid("json");
	const db = getDb(c.env.DB);
	const dal = createDal(db);
	try {
		await dal.hoProViews.recordView(user.id, proId);
	} catch (err) {
		// Never surface to the caller. The pro page rendered fine; we just
		// missed an analytics row, which is recoverable on the next view.
		logger.error("[ho-pro-views] recordView failed:", err);
	}
	return success(c, { recorded: true }, 202);
});
 
/**
 * GET /api/homeowner/pro-views/recent
 *
 * Returns the top city by distinct-pro view count in the trailing N days
 * (default 7). Powers the /account hub activity bar's rule 1. Returns
 * `null` (not 404) when the user hasn't viewed enough pros to fire the
 * rule — the hub treats null as "fall through to rule 2".
 *
 * Query params:
 *   days        — window length in days. Default 7. Capped at 90.
 *   minDistinct — minimum distinct pros required. Default 2.
 */
app.get("/recent", async (c) => {
	const user = c.get("hoUser");
	const daysParam = c.req.query("days");
	const minDistinctParam = c.req.query("minDistinct");
 
	const days = Math.min(
		Math.max(daysParam ? parseInt(daysParam, 10) : 7, 1),
		90,
	);
	const minDistinct = Math.max(
		minDistinctParam ? parseInt(minDistinctParam, 10) : 2,
		1,
	);
 
	const db = getDb(c.env.DB);
	const dal = createDal(db);
	const result = await dal.hoProViews.recentByUserCity(user.id, {
		days,
		minDistinct,
	});
	return success(c, result);
});
 
export default app;