diff --git a/apps/server/src/routes/attachments.ts b/apps/server/src/routes/attachments.ts index cf6ad43..894cce3 100644 --- a/apps/server/src/routes/attachments.ts +++ b/apps/server/src/routes/attachments.ts @@ -1,4 +1,4 @@ -import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments"; +import { listAttachmentLinks, listAttachments, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments"; import { NotFoundError } from "@sttracker/core/errors"; import type { AttachmentEntity } from "@sttracker/db/schema"; import { z } from "zod"; @@ -18,6 +18,18 @@ export function safeFilename(name: string): string { } export const attachmentRoutes = [ + route({ + method: "GET", + path: "attachments", + summary: "Attachments linked to one of your records (oldest first), each with its links.", + auth: "any:read", + query: z.object({ entity: fields.shape.entity, entityId: z.string().min(1) }), + handler: async ({ deps, user, query }) => { + await requireLinkTarget(deps.db, user.id, query.entity as AttachmentEntity, query.entityId); + const atts = await listAttachments(deps.db, user.id, query.entity as AttachmentEntity, query.entityId); + return Promise.all(atts.map(async (a) => ({ ...a, links: await listAttachmentLinks(deps.db, user.id, a.id) }))); + }, + }), route({ method: "POST", path: "attachments", diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 3032b22..bf1776c 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,7 +1,9 @@ -import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth"; +import { createAppToken, createInvite, hasUsers, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth"; +import { getPreferences, RATING_SCALES, updatePreferences } from "@sttracker/core/preferences"; import { NotFoundError } from "@sttracker/core/errors"; import { z } from "zod"; import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts"; +import { visibility } from "./schemas.ts"; const credentials = z.object({ username: z.string().min(1).max(64), @@ -9,6 +11,13 @@ const credentials = z.object({ }); export const authRoutes = [ + route({ + method: "GET", + path: "auth/status", + summary: "Whether any account exists yet (the web app shows first-run registration when not).", + auth: "none", + handler: async ({ deps }) => ({ hasUsers: await hasUsers(deps.db) }), + }), route({ method: "POST", path: "auth/register", @@ -41,6 +50,21 @@ export const authRoutes = [ }, }), route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }), + route({ + method: "GET", + path: "me/preferences", + summary: "Your preferences: rating scale and default visibility.", + auth: "any:read", + handler: async ({ deps, user }) => getPreferences(deps.db, user.id), + }), + route({ + method: "PATCH", + path: "me/preferences", + summary: "Update your preferences (partial).", + auth: "any:write", + body: z.object({ ratingScale: z.enum(RATING_SCALES), defaultVisibility: visibility }).partial().strict(), + handler: async ({ deps, user, body }) => updatePreferences(deps.db, user.id, body as Parameters[2]), + }), route({ method: "GET", path: "me/tokens", diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts index e32299d..ac34635 100644 --- a/apps/server/test/api.test.ts +++ b/apps/server/test/api.test.ts @@ -286,6 +286,49 @@ forEachDialect("api", (getDb) => { expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404); }); + test("auth/status says whether any user exists, without auth", async () => { + expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: false }); + await signUp(app, "ana"); + expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: true }); + }); + + test("attachments list: owner-scoped per entity; a foreign or unknown entity is 404", async () => { + const ana = await signUp(app, "ana"); + const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string }; + const q = `attachments?entity=entry&entityId=${add.id}`; + expect(await (await call(app, "GET", q, { cookie: ana })).json()).toEqual([]); + const form = new FormData(); + form.set("file", new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" })); + form.set("entity", "entry"); + form.set("entityId", add.id); + const up = (await (await call(app, "POST", "attachments", { cookie: ana, form })).json()) as { id: string }; + const list = (await (await call(app, "GET", q, { cookie: ana })).json()) as { id: string; mime: string; filename: string; links: { id: string }[] }[]; + expect(list.map((a) => [a.id, a.mime, a.filename])).toEqual([[up.id, "image/png", "shot.png"]]); + expect(list[0]!.links.length).toBe(1); + + const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string }; + const bob = await signUp(app, "bob", code); + expect((await call(app, "GET", q, { cookie: bob })).status).toBe(404); + expect((await call(app, "GET", "attachments?entity=entry&entityId=nope", { cookie: ana })).status).toBe(404); + expect((await call(app, "GET", `attachments?entity=bogus&entityId=${add.id}`, { cookie: ana })).status).toBe(400); + expect((await call(app, "GET", q)).status).toBe(401); + }); + + test("me/preferences: defaults, validated PATCH, and quick add uses defaultVisibility", async () => { + const ana = await signUp(app, "ana"); + expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "5half", defaultVisibility: "private" }); + const bad = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "7" } }); + expect(bad.status).toBe(400); + expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { extra: 1 } })).status).toBe(400); + const ok = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "10" } }); + expect(await ok.json()).toEqual({ ratingScale: "10", defaultVisibility: "private" }); + await call(app, "PATCH", "me/preferences", { cookie: ana, body: { defaultVisibility: "friends" } }); + expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "10", defaultVisibility: "friends" }); + const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { visibility: string }; + expect(e.visibility).toBe("friends"); + expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403); + }); + test("JSON bodies need Content-Type application/json (415)", async () => { const res = await app.fetch( new Request(`${BASE}/auth/register`, { diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 6cefcdc..6cfa9b5 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -111,6 +111,11 @@ export async function login( return { user: toUser(row), sessionToken }; } +/** True once any account exists (the first one becomes admin). */ +export async function hasUsers(db: Db): Promise { + return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined; +} + export async function getSessionUser(db: Db, token: string): Promise { const row = await db.k .selectFrom("sessions") diff --git a/packages/core/src/preferences.ts b/packages/core/src/preferences.ts new file mode 100644 index 0000000..8a75767 --- /dev/null +++ b/packages/core/src/preferences.ts @@ -0,0 +1,44 @@ +import type { Db } from "@sttracker/db/db"; +import { fromJson, toJson } from "@sttracker/db/json"; +import { nowIso } from "@sttracker/db/ids"; +import type { Visibility } from "@sttracker/db/schema"; +import { VISIBILITIES } from "./entries.ts"; +import { NotFoundError, ValidationError } from "./errors.ts"; + +export const RATING_SCALES = ["5half", "5", "10", "100"] as const; +export type RatingScale = (typeof RATING_SCALES)[number]; + +/** Per-user settings, stored as JSON in `users.preferences`. Keys match what `defaultVisibility()` reads. */ +export interface Preferences { + ratingScale: RatingScale; + defaultVisibility: Visibility; +} + +export const DEFAULT_PREFERENCES: Preferences = { ratingScale: "5half", defaultVisibility: "private" }; + +async function readRaw(db: Db, userId: string): Promise> { + const u = await db.k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst(); + if (!u) throw new NotFoundError("user"); + return fromJson>(u.preferences, {}); +} + +/** The user's preferences; missing or invalid stored values fall back to the defaults. */ +export async function getPreferences(db: Db, userId: string): Promise { + const raw = await readRaw(db, userId); + return { + ratingScale: (RATING_SCALES as readonly unknown[]).includes(raw.ratingScale) ? (raw.ratingScale as RatingScale) : DEFAULT_PREFERENCES.ratingScale, + defaultVisibility: (VISIBILITIES as readonly unknown[]).includes(raw.defaultVisibility) + ? (raw.defaultVisibility as Visibility) + : DEFAULT_PREFERENCES.defaultVisibility, + }; +} + +/** Merge a partial update into the stored preferences (unknown stored keys are kept). */ +export async function updatePreferences(db: Db, userId: string, patch: Partial): Promise { + if (patch.ratingScale !== undefined && !RATING_SCALES.includes(patch.ratingScale)) throw new ValidationError("ratingScale", "invalid"); + if (patch.defaultVisibility !== undefined && !VISIBILITIES.includes(patch.defaultVisibility)) throw new ValidationError("defaultVisibility", "invalid"); + const raw = await readRaw(db, userId); + const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) }; + await db.k.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute(); + return getPreferences(db, userId); +}