feat: public item detail view for shared and public setups
Items in shared/public setups are now viewable without auth. Clicking an item in a shared setup navigates to /items/:id?setup=:setupId&share=token which fetches the item via a public endpoint authorized by the setup's visibility or share token. Read-only mode hides all owner controls. - Added getSetupItemById service function - Added GET /api/shared/:token/items/:itemId endpoint - Added GET /api/setups/:setupId/items/:itemId/public endpoint - Added usePublicSetupItem and useSharedSetupItem hooks - Item detail page detects setup context and switches to public fetch - Back link returns to setup instead of collection in setup context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,9 +29,12 @@ import { setupRoutes } from "./routes/setups.ts";
|
||||
import { tagRoutes } from "./routes/tags.ts";
|
||||
import { threadRoutes } from "./routes/threads.ts";
|
||||
import { totalRoutes } from "./routes/totals.ts";
|
||||
import { getSetupWithItemsById } from "./services/setup.service.ts";
|
||||
import {
|
||||
getSetupItemById,
|
||||
getSetupWithItemsById,
|
||||
} from "./services/setup.service.ts";
|
||||
import { validateShareToken } from "./services/share.service.ts";
|
||||
import { withImageUrls } from "./services/storage.service.ts";
|
||||
import { withImageUrl, withImageUrls } from "./services/storage.service.ts";
|
||||
|
||||
// Seed default data on startup
|
||||
await seedDefaults();
|
||||
@@ -185,6 +188,43 @@ app.get("/api/shared/:token", async (c) => {
|
||||
return c.json({ ...setup, items: enrichedItems });
|
||||
});
|
||||
|
||||
// Shared setup item detail via token (no auth required)
|
||||
app.get("/api/shared/:token/items/:itemId", async (c) => {
|
||||
const db = c.get("db");
|
||||
const token = c.req.param("token");
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
if (!itemId || Number.isNaN(itemId))
|
||||
return c.json({ error: "Invalid item ID" }, 400);
|
||||
const result = await validateShareToken(db, token);
|
||||
if (!result) return c.json({ error: "Not found" }, 404);
|
||||
const item = await getSetupItemById(db, result.setupId, itemId);
|
||||
if (!item) return c.json({ error: "Not found" }, 404);
|
||||
const enriched = await withImageUrl(item);
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// Public setup item detail (no auth required — setup must be public)
|
||||
app.get("/api/setups/:setupId/items/:itemId/public", async (c) => {
|
||||
const db = c.get("db");
|
||||
const setupId = Number(c.req.param("setupId"));
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
if (!setupId || !itemId || Number.isNaN(setupId) || Number.isNaN(itemId))
|
||||
return c.json({ error: "Invalid ID" }, 400);
|
||||
// Verify setup is public
|
||||
const { setups } = await import("../db/schema.ts");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const [setup] = await db
|
||||
.select({ visibility: setups.visibility })
|
||||
.from(setups)
|
||||
.where(eq(setups.id, setupId));
|
||||
if (!setup || setup.visibility !== "public")
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
const item = await getSetupItemById(db, setupId, itemId);
|
||||
if (!item) return c.json({ error: "Not found" }, 404);
|
||||
const enriched = await withImageUrl(item);
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// Short share URL redirect (no auth required — before SPA catch-all)
|
||||
app.get("/s/:token", async (c) => {
|
||||
const db = c.get("db");
|
||||
@@ -206,6 +246,12 @@ app.use("/api/*", async (c, next) => {
|
||||
// Skip public setup view (GET /api/setups/:id/public)
|
||||
if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET")
|
||||
return next();
|
||||
// Skip public setup item view (GET /api/setups/:id/items/:id/public)
|
||||
if (
|
||||
/^\/api\/setups\/\d+\/items\/\d+\/public$/.test(c.req.path) &&
|
||||
c.req.method === "GET"
|
||||
)
|
||||
return next();
|
||||
// Skip public tags endpoint (GET /api/tags)
|
||||
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
|
||||
return next();
|
||||
|
||||
@@ -170,6 +170,62 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
|
||||
return { ...setup, items: itemList };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single item from a setup by item ID (no auth check).
|
||||
* Used for public/shared setup item viewing.
|
||||
* Returns null if the item is not in the setup.
|
||||
*/
|
||||
export async function getSetupItemById(
|
||||
db: Db,
|
||||
setupId: number,
|
||||
itemId: number,
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: sql<string>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
||||
ELSE ${items.name}
|
||||
END,
|
||||
${items.name}
|
||||
)`.as("name"),
|
||||
brand: sql<
|
||||
string | null
|
||||
>`CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.brand} ELSE ${items.brand} END`.as(
|
||||
"brand",
|
||||
),
|
||||
weightGrams: sql<number | null>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
||||
${items.weightGrams}
|
||||
)`.as("weight_grams"),
|
||||
priceCents: sql<number | null>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
||||
${items.priceCents}
|
||||
)`.as("price_cents"),
|
||||
quantity: items.quantity,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: sql<string | null>`COALESCE(
|
||||
${items.imageFilename},
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
||||
)`.as("image_filename"),
|
||||
globalItemId: items.globalItemId,
|
||||
createdAt: items.createdAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
classification: setupItems.classification,
|
||||
})
|
||||
.from(setupItems)
|
||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||
.where(and(eq(setupItems.setupId, setupId), eq(items.id, itemId)));
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function updateSetup(
|
||||
db: Db,
|
||||
userId: number,
|
||||
|
||||
Reference in New Issue
Block a user