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 | 1x 1x 12x 12x 1x 11x 11x 11x 11x 2x 9x 9x 1x 8x 8x 8x 12x 8x 8x 8x 2x 6x 3x 3x 2x 3x 3x 3x 2x 2x 1x 1x 1x 1x 8x 8x 8x | // Public Board Share Route — read-only view of a shared mood board by token.
// Mounted at /api/marketplace/boards/share → full path /:token.
// API key protected (inherited from the marketplace mount).
import { Hono } from "hono";
import { eq } from "drizzle-orm";
import { hoUsers } from "../../db/schema/homeowner";
import { SHARE_TOKEN_REGEX } from "../../lib/share-token";
import type { ContextVariables } from "../../middleware";
type Env = {
Bindings: CloudflareBindings;
Variables: ContextVariables;
};
const app = new Hono<Env>();
// GET /:token — public read of a shared board.
// Status codes:
// 400 — malformed token (fails regex)
// 410 — token not found OR revoked (same message, no leakage)
// 200 — board payload with enriched items, Cache-Control public, max-age=60
app.get("/:token", async (c) => {
const token = c.req.param("token");
// Shape check BEFORE any DB work — cheap guard against crawlers/junk.
if (!SHARE_TOKEN_REGEX.test(token)) {
return c.json({ error: "Malformed token" }, 400);
}
const dal = c.get("dal");
const db = c.get("db");
const share = await dal.hoMoodBoardShares.findByToken(token);
// Non-existent OR revoked — identical response, no information leak.
if (!share || share.revokedAt) {
return c.json({ error: "This board is no longer shared" }, 410);
}
const board = await dal.hoMoodBoards.findByIdPublic(share.boardId);
if (!board) {
return c.json({ error: "This board is no longer shared" }, 410);
}
const items = await dal.hoMoodBoards.listItems(share.boardId);
// Owner first name — falls back to "Someone" so the public page always
// has something to render in "{owner} shared this board".
const ownerRow = await db
.select({ name: hoUsers.name })
.from(hoUsers)
.where(eq(hoUsers.id, share.createdByUser))
.limit(1);
const ownerFirstName =
(ownerRow[0]?.name ?? "").trim().split(/\s+/)[0] || "Someone";
// Enrich each item with its underlying entity. Fail soft per item —
// deleted/unpublished entities return null, never 500 the whole board.
const enrichedItems = await Promise.all(
items.map(async (it) => {
let entity: unknown = null;
try {
if (it.entityType === "project") {
entity = (await dal.projects.findById(it.entityId)) ?? null;
} else if (it.entityType === "room") {
// rooms.id is integer; entityId is stored as text.
const roomId = Number.parseInt(it.entityId, 10);
if (Number.isFinite(roomId) && roomId > 0) {
entity = (await dal.rooms.findById(roomId)) ?? null;
}
E} else if (it.entityType === "photo") {
// Photo items don't have a dedicated card variant. Walk
// media → room so the frontend can render the parent
// room with the photo surfaced as its cover image.
const mediaId = Number.parseInt(it.entityId, 10);
if (Number.isFinite(mediaId) && mediaId > 0) {
const media = await dal.media.findById(mediaId);
if (media) {
const room = await dal.rooms.findById(media.roomId);
Eif (room) {
entity = {
...room,
// Override the room's cover with the
// specific photo that was pinned.
coverImage: media.storageKey,
};
}
}
}
}
} catch {
entity = null;
}
return { ...it, entity };
}),
);
c.header("Cache-Control", "public, max-age=60");
return c.json({
board,
items: enrichedItems,
ownerFirstName,
itemCount: items.length,
});
});
export default app;
|