diff --git a/apps/web/app/api/albums/[id]/assets/route.ts b/apps/web/app/api/albums/[id]/assets/route.ts new file mode 100644 index 0000000..5d4b0c6 --- /dev/null +++ b/apps/web/app/api/albums/[id]/assets/route.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import { + getAdminOk, + handleAddAlbumAsset, + handleRemoveAlbumAsset, +} from "../../handlers"; + +export const runtime = "nodejs"; + +const paramsSchema = z.object({ + id: z.string().uuid(), +}); + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const rawParams = await context.params; + const paramsParsed = paramsSchema.safeParse(rawParams); + if (!paramsParsed.success) { + return Response.json( + { error: "invalid_params", issues: paramsParsed.error.issues }, + { status: 400 }, + ); + } + + const bodyJson = await request.json().catch(() => ({})); + const res = await handleAddAlbumAsset({ + adminOk: getAdminOk(request.headers), + params: paramsParsed.data, + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} + +export async function DELETE( + request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const rawParams = await context.params; + const paramsParsed = paramsSchema.safeParse(rawParams); + if (!paramsParsed.success) { + return Response.json( + { error: "invalid_params", issues: paramsParsed.error.issues }, + { status: 400 }, + ); + } + + const bodyJson = await request.json().catch(() => ({})); + const res = await handleRemoveAlbumAsset({ + adminOk: getAdminOk(request.headers), + params: paramsParsed.data, + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/apps/web/app/api/albums/handlers.ts b/apps/web/app/api/albums/handlers.ts new file mode 100644 index 0000000..441be3c --- /dev/null +++ b/apps/web/app/api/albums/handlers.ts @@ -0,0 +1,176 @@ +import { getAdminToken, isAdminRequest } from "@tline/config"; +import { getDb } from "@tline/db"; +import { z } from "zod"; + +const ADMIN_HEADER = "X-Porthole-Admin-Token"; + +const createAlbumBodySchema = z + .object({ + name: z.string().min(1), + }) + .strict(); + +const albumParamsSchema = z.object({ + id: z.string().uuid(), +}); + +const albumAssetBodySchema = z + .object({ + assetId: z.string().uuid(), + ord: z.coerce.number().int().optional(), + }) + .strict(); + +type DbLike = ReturnType; + +export function getAdminOk(headers: Headers) { + const headerToken = headers.get(ADMIN_HEADER); + return isAdminRequest({ adminToken: getAdminToken() }, { headerToken }); +} + +export async function handleListAlbums(input: { + adminOk: boolean; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const db = (input.db ?? getDb()) as DbLike; + const rows = await db< + { + id: string; + name: string; + created_at: string; + }[] + >` + select id, name, created_at + from albums + order by created_at desc + `; + + return { status: 200, body: rows }; +} + +export async function handleCreateAlbum(input: { + adminOk: boolean; + body: unknown; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const body = createAlbumBodySchema.parse(input.body ?? {}); + const db = (input.db ?? getDb()) as DbLike; + const rows = await db< + { + id: string; + name: string; + created_at: string; + }[] + >` + insert into albums (name) + values (${body.name}) + returning id, name, created_at + `; + + const created = rows[0]; + if (!created) { + return { status: 500, body: { error: "insert_failed" } }; + } + + const payload = JSON.stringify({ name: created.name }); + await db` + insert into audit_log (actor, action, entity_type, entity_id, payload) + values ('admin', 'create', 'album', ${created.id}, ${payload}::jsonb) + `; + + return { status: 200, body: created }; +} + +export async function handleAddAlbumAsset(input: { + adminOk: boolean; + params: { id: string }; + body: unknown; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const paramsParsed = albumParamsSchema.safeParse(input.params); + if (!paramsParsed.success) { + return { + status: 400, + body: { error: "invalid_params", issues: paramsParsed.error.issues }, + }; + } + + const body = albumAssetBodySchema.parse(input.body ?? {}); + const db = (input.db ?? getDb()) as DbLike; + const rows = await db< + { + album_id: string; + asset_id: string; + ord: number | null; + }[] + >` + insert into album_assets (album_id, asset_id, ord) + values (${paramsParsed.data.id}, ${body.assetId}, ${body.ord ?? null}) + on conflict (album_id, asset_id) + do update set ord = excluded.ord + returning album_id, asset_id, ord + `; + + const created = rows[0]; + if (!created) { + return { status: 500, body: { error: "insert_failed" } }; + } + + const payload = JSON.stringify({ + asset_id: created.asset_id, + ord: created.ord, + }); + await db` + insert into audit_log (actor, action, entity_type, entity_id, payload) + values ('admin', 'add_asset', 'album', ${created.album_id}, ${payload}::jsonb) + `; + + return { status: 200, body: created }; +} + +export async function handleRemoveAlbumAsset(input: { + adminOk: boolean; + params: { id: string }; + body: unknown; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const paramsParsed = albumParamsSchema.safeParse(input.params); + if (!paramsParsed.success) { + return { + status: 400, + body: { error: "invalid_params", issues: paramsParsed.error.issues }, + }; + } + + const body = albumAssetBodySchema.parse(input.body ?? {}); + const db = (input.db ?? getDb()) as DbLike; + await db` + delete from album_assets + where album_id = ${paramsParsed.data.id} + and asset_id = ${body.assetId} + `; + + const payload = JSON.stringify({ asset_id: body.assetId }); + await db` + insert into audit_log (actor, action, entity_type, entity_id, payload) + values ('admin', 'remove_asset', 'album', ${paramsParsed.data.id}, ${payload}::jsonb) + `; + + return { status: 200, body: { ok: true } }; +} diff --git a/apps/web/app/api/albums/route.ts b/apps/web/app/api/albums/route.ts new file mode 100644 index 0000000..32ca86e --- /dev/null +++ b/apps/web/app/api/albums/route.ts @@ -0,0 +1,17 @@ +import { getAdminOk, handleCreateAlbum, handleListAlbums } from "./handlers"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const res = await handleListAlbums({ adminOk: getAdminOk(request.headers) }); + return Response.json(res.body, { status: res.status }); +} + +export async function POST(request: Request): Promise { + const bodyJson = await request.json().catch(() => ({})); + const res = await handleCreateAlbum({ + adminOk: getAdminOk(request.headers), + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/apps/web/app/api/tags/handlers.ts b/apps/web/app/api/tags/handlers.ts new file mode 100644 index 0000000..4c4c2d0 --- /dev/null +++ b/apps/web/app/api/tags/handlers.ts @@ -0,0 +1,79 @@ +import { getAdminToken, isAdminRequest } from "@tline/config"; +import { getDb } from "@tline/db"; +import { z } from "zod"; + +const ADMIN_HEADER = "X-Porthole-Admin-Token"; + +const createTagBodySchema = z + .object({ + name: z.string().min(1), + }) + .strict(); + +type DbLike = ReturnType; + +export function getAdminOk(headers: Headers) { + const headerToken = headers.get(ADMIN_HEADER); + return isAdminRequest({ adminToken: getAdminToken() }, { headerToken }); +} + +export async function handleListTags(input: { + adminOk: boolean; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const db = (input.db ?? getDb()) as DbLike; + const rows = await db< + { + id: string; + name: string; + created_at: string; + }[] + >` + select id, name, created_at + from tags + order by created_at desc + `; + + return { status: 200, body: rows }; +} + +export async function handleCreateTag(input: { + adminOk: boolean; + body: unknown; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + if (!input.adminOk) { + return { status: 401, body: { error: "admin_required" } }; + } + + const body = createTagBodySchema.parse(input.body ?? {}); + const db = (input.db ?? getDb()) as DbLike; + const rows = await db< + { + id: string; + name: string; + created_at: string; + }[] + >` + insert into tags (name) + values (${body.name}) + returning id, name, created_at + `; + + const created = rows[0]; + if (!created) { + return { status: 500, body: { error: "insert_failed" } }; + } + + const payload = JSON.stringify({ name: created.name }); + await db` + insert into audit_log (actor, action, entity_type, entity_id, payload) + values ('admin', 'create', 'tag', ${created.id}, ${payload}::jsonb) + `; + + return { status: 200, body: created }; +} diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts new file mode 100644 index 0000000..b65275c --- /dev/null +++ b/apps/web/app/api/tags/route.ts @@ -0,0 +1,17 @@ +import { getAdminOk, handleCreateTag, handleListTags } from "./handlers"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const res = await handleListTags({ adminOk: getAdminOk(request.headers) }); + return Response.json(res.body, { status: res.status }); +} + +export async function POST(request: Request): Promise { + const bodyJson = await request.json().catch(() => ({})); + const res = await handleCreateTag({ + adminOk: getAdminOk(request.headers), + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/apps/web/src/__tests__/albums-admin-auth.test.ts b/apps/web/src/__tests__/albums-admin-auth.test.ts new file mode 100644 index 0000000..3f78be9 --- /dev/null +++ b/apps/web/src/__tests__/albums-admin-auth.test.ts @@ -0,0 +1,153 @@ +import { test, expect } from "bun:test"; + +function createMockDb(responses: Array) { + const calls: Array<{ sql: string; values: unknown[] }> = []; + const db = async (strings: TemplateStringsArray, ...values: unknown[]) => { + calls.push({ sql: strings.join(""), values }); + const next = responses.shift(); + return next as T; + }; + return { db, calls }; +} + +test("albums POST rejects when missing admin token", async () => { + const { handleCreateAlbum } = await import("../../app/api/albums/handlers"); + const res = await handleCreateAlbum({ adminOk: false, body: {} }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("albums GET rejects when missing admin token", async () => { + const { handleListAlbums } = await import("../../app/api/albums/handlers"); + const res = await handleListAlbums({ adminOk: false }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("album add asset rejects when missing admin token", async () => { + const { handleAddAlbumAsset } = await import( + "../../app/api/albums/handlers" + ); + const res = await handleAddAlbumAsset({ + adminOk: false, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: { assetId: "00000000-0000-4000-8000-000000000000" }, + }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("album remove asset rejects when missing admin token", async () => { + const { handleRemoveAlbumAsset } = await import( + "../../app/api/albums/handlers" + ); + const res = await handleRemoveAlbumAsset({ + adminOk: false, + params: { id: "00000000-0000-4000-8000-000000000000" }, + body: { assetId: "00000000-0000-4000-8000-000000000000" }, + }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("albums GET returns rows", async () => { + const { handleListAlbums } = await import("../../app/api/albums/handlers"); + const { db } = createMockDb([ + [ + { + id: "00000000-0000-4000-8000-000000000010", + name: "Summer", + created_at: "2026-02-01T00:00:00.000Z", + }, + ], + ]); + const res = await handleListAlbums({ adminOk: true, db: db as never }); + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + id: "00000000-0000-4000-8000-000000000010", + name: "Summer", + created_at: "2026-02-01T00:00:00.000Z", + }, + ]); +}); + +test("albums POST inserts and writes audit log", async () => { + const { handleCreateAlbum } = await import("../../app/api/albums/handlers"); + const { db, calls } = createMockDb([ + [ + { + id: "00000000-0000-4000-8000-000000000020", + name: "Trips", + created_at: "2026-02-01T00:00:00.000Z", + }, + ], + [], + ]); + const res = await handleCreateAlbum({ + adminOk: true, + body: { name: "Trips" }, + db: db as never, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + id: "00000000-0000-4000-8000-000000000020", + name: "Trips", + created_at: "2026-02-01T00:00:00.000Z", + }); + expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe( + true, + ); +}); + +test("album add asset inserts and writes audit log", async () => { + const { handleAddAlbumAsset } = await import( + "../../app/api/albums/handlers" + ); + const { db, calls } = createMockDb([ + [ + { + album_id: "00000000-0000-4000-8000-000000000030", + asset_id: "00000000-0000-4000-8000-000000000031", + ord: 2, + }, + ], + [], + ]); + const res = await handleAddAlbumAsset({ + adminOk: true, + params: { id: "00000000-0000-4000-8000-000000000030" }, + body: { assetId: "00000000-0000-4000-8000-000000000031", ord: 2 }, + db: db as never, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + album_id: "00000000-0000-4000-8000-000000000030", + asset_id: "00000000-0000-4000-8000-000000000031", + ord: 2, + }); + expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe( + true, + ); +}); + +test("album remove asset deletes and writes audit log", async () => { + const { handleRemoveAlbumAsset } = await import( + "../../app/api/albums/handlers" + ); + const { db, calls } = createMockDb([[], [], []]); + const res = await handleRemoveAlbumAsset({ + adminOk: true, + params: { id: "00000000-0000-4000-8000-000000000040" }, + body: { assetId: "00000000-0000-4000-8000-000000000041" }, + db: db as never, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(calls.some((call) => call.sql.includes("delete from album_assets"))).toBe( + true, + ); + expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe( + true, + ); +}); diff --git a/apps/web/src/__tests__/tags-admin-auth.test.ts b/apps/web/src/__tests__/tags-admin-auth.test.ts new file mode 100644 index 0000000..c88bd48 --- /dev/null +++ b/apps/web/src/__tests__/tags-admin-auth.test.ts @@ -0,0 +1,75 @@ +import { test, expect } from "bun:test"; + +function createMockDb(responses: Array) { + const calls: Array<{ sql: string; values: unknown[] }> = []; + const db = async (strings: TemplateStringsArray, ...values: unknown[]) => { + calls.push({ sql: strings.join(""), values }); + const next = responses.shift(); + return next as T; + }; + return { db, calls }; +} + +test("tags POST rejects when missing admin token", async () => { + const { handleCreateTag } = await import("../../app/api/tags/handlers"); + const res = await handleCreateTag({ adminOk: false, body: {} }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("tags GET rejects when missing admin token", async () => { + const { handleListTags } = await import("../../app/api/tags/handlers"); + const res = await handleListTags({ adminOk: false }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "admin_required" }); +}); + +test("tags GET returns rows", async () => { + const { handleListTags } = await import("../../app/api/tags/handlers"); + const { db } = createMockDb([ + [ + { + id: "00000000-0000-4000-8000-000000000001", + name: "Pets", + created_at: "2026-02-01T00:00:00.000Z", + }, + ], + ]); + const res = await handleListTags({ adminOk: true, db: db as never }); + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + id: "00000000-0000-4000-8000-000000000001", + name: "Pets", + created_at: "2026-02-01T00:00:00.000Z", + }, + ]); +}); + +test("tags POST inserts and writes audit log", async () => { + const { handleCreateTag } = await import("../../app/api/tags/handlers"); + const { db, calls } = createMockDb([ + [ + { + id: "00000000-0000-4000-8000-000000000002", + name: "Trips", + created_at: "2026-02-01T00:00:00.000Z", + }, + ], + [], + ]); + const res = await handleCreateTag({ + adminOk: true, + body: { name: "Trips" }, + db: db as never, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + id: "00000000-0000-4000-8000-000000000002", + name: "Trips", + created_at: "2026-02-01T00:00:00.000Z", + }); + expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe( + true, + ); +});