diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 6efbc71..3bae7e8 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -15,8 +15,11 @@ privateCleartext?: PrivatePayload | null; onDeleted: (id: string) => void; onEdit?: (a: Activity) => void; + /** Called when the row updates its own activity (e.g. after a heart + * toggle). The parent should patch its list so the change persists. */ + onChanged?: (a: Activity) => void; } - let { activity, privateCleartext = null, onDeleted, onEdit }: Props = $props(); + let { activity, privateCleartext = null, onDeleted, onEdit, onChanged }: Props = $props(); // Fallback decrypt for the case where Home hasn't pre-computed yet (e.g. // immediately after a create) — we tolerate redundant work to keep the @@ -104,6 +107,39 @@ onDeleted(activity.id); } + // --- Hearts ---------------------------------------------------------------- + // Optimistic toggle via a local override that wins over the prop. On + // success we both update the local override AND let the parent know, so + // a re-render (parent's list refreshing) still shows the right state. + let localOverride: Activity | null = $state(null); + const view = $derived(localOverride ?? activity); + let heartBusy = $state(false); + + async function toggleHeart() { + if (!session.user || heartBusy) return; + if (view.visibility === 'private') return; + heartBusy = true; + const wasHearted = view.viewer_hearted; + const optimistic: Activity = { + ...view, + viewer_hearted: !wasHearted, + heart_count: view.heart_count + (wasHearted ? -1 : 1), + }; + localOverride = optimistic; + try { + const updated = wasHearted + ? await api.unheartActivity(view.id) + : await api.heartActivity(view.id); + localOverride = updated; + onChanged?.(updated); + } catch { + // Snap back. + localOverride = null; + } finally { + heartBusy = false; + } + } + /** * Share-link: copy /a/ (absolute URL) to the clipboard. Private rows * are shareable in principle but only the owner can decrypt — the receiver @@ -140,7 +176,9 @@ {#if activity.visibility === 'private'} {#if decrypted}

- {decrypted.title} + + {decrypted.title} + Privat

{#if decrypted.tags.length} @@ -157,7 +195,9 @@ {/if} {:else}

- {activity.title} + + {activity.title} + {activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'} @@ -184,6 +224,22 @@ {/if}
+ {#if view.visibility !== 'private'} + {#if session.user} + + {:else if view.heart_count > 0} + ♡ {view.heart_count} + {/if} + {/if} {#if canEdit && onEdit} {/if} diff --git a/frontend/src/components/Feedback.svelte b/frontend/src/components/Feedback.svelte index 69dbce8..60f315b 100644 --- a/frontend/src/components/Feedback.svelte +++ b/frontend/src/components/Feedback.svelte @@ -57,11 +57,34 @@ } } - // Auto-load for moderators when component mounts. + // Auto-load for moderators (and admins, who imply moderator). $effect(() => { - if (session.user?.is_moderator) refreshList(); + if (session.user?.is_moderator || session.user?.is_admin) refreshList(); }); + async function toggleDone(entry: FeedbackEntry, done: boolean) { + try { + const updated = await api.updateFeedback(entry.id, { done }); + entries = entries.map((e) => (e.id === updated.id ? updated : e)); + } catch (err) { + listError = err instanceof ApiError && err.status === 403 + ? 'Bare administratorer kan markere som ferdig.' + : 'Endring feilet.'; + } + } + + async function removeEntry(entry: FeedbackEntry) { + if (!confirm('Slett denne tilbakemeldingen?')) return; + try { + await api.deleteFeedback(entry.id); + entries = entries.filter((e) => e.id !== entry.id); + } catch (err) { + listError = err instanceof ApiError && err.status === 403 + ? 'Bare administratorer kan slette.' + : 'Sletting feilet.'; + } + } + function formatDate(epochMs: number): string { return new Date(epochMs).toLocaleString('nb-NO', { year: 'numeric', month: '2-digit', day: '2-digit', @@ -114,15 +137,32 @@

Ingen tilbakemeldinger ennå.

{/if} {#each entries as e (e.id)} -
+
- {e.kind === 'feature' ? 'Forslag' : 'Feilrapport'} +
+ {e.kind === 'feature' ? 'Forslag' : 'Feilrapport'} + {#if e.done_at} + Ferdig + {:else} + Åpen + {/if} +
{formatDate(e.created_at)}

{e.body}

Fra {e.user_display?.trim() || e.user_email}

+ {#if session.user?.is_admin} +
+ {#if e.done_at} + + {:else} + + {/if} + +
+ {/if}
{/each} diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index c44493f..fe75953 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -46,6 +46,12 @@ editing = null; } + function onChanged(a: Activity) { + // In-place patch (no editing context change). Used by row-level actions + // like hearts. + activities = activities.map((x) => (x.id === a.id ? a : x)); + } + function onDeleted(id: string) { activities = activities.filter((a) => a.id !== id); } @@ -158,6 +164,7 @@ privateCleartext={privateCleartext.get(a.id) ?? null} onDeleted={onDeleted} onEdit={(act) => (editing = act)} + onChanged={onChanged} /> {/each} {/if} @@ -170,6 +177,7 @@ privateCleartext={null} onDeleted={onDeleted} onEdit={(act) => (editing = act)} + onChanged={onChanged} /> {/each} {/if} @@ -182,6 +190,7 @@ privateCleartext={null} onDeleted={onDeleted} onEdit={(act) => (editing = act)} + onChanged={onChanged} /> {/each} {/if} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 177868a..ff372a6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,7 +3,7 @@ import type { RecoveryChallengeResponse, RecoveryCompleteRequest, PasswordChangeRequest, MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest, TagSuggestion, ProfileUpdateRequest, - PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, + PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest, AdminUser, AdminRoleUpdate, } from '../../../shared/types'; @@ -73,6 +73,10 @@ export const api = { }), deleteActivity: (id: string) => http<{ ok: true }>(`/activities/${encodeURIComponent(id)}`, { method: 'DELETE' }), + heartActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }), + unheartActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }), // --- tags ----------------------------------------------------------------- tagSuggestions: (q: string, limit = 20) => @@ -86,6 +90,12 @@ export const api = { submitFeedback: (body: FeedbackSubmitRequest) => http('/feedback', { method: 'POST', body: JSON.stringify(body) }), listFeedback: () => http('/feedback'), + updateFeedback: (id: string, body: FeedbackUpdateRequest) => + http(`/feedback/${encodeURIComponent(id)}`, { + method: 'PATCH', body: JSON.stringify(body), + }), + deleteFeedback: (id: string) => + http<{ ok: true }>(`/feedback/${encodeURIComponent(id)}`, { method: 'DELETE' }), // --- admin (admin role only) ---------------------------------------------- adminListUsers: () => http('/admin/users'), diff --git a/server/activities.ts b/server/activities.ts index c51386e..a9bac9b 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -48,6 +48,24 @@ interface ActivityRow { * for the slug whenever the user hasn't opted in, so the link decision is * purely server-side. */ +/** + * Heart-count + viewer-hearted lookup for a single activity. Two prepared + * statements; the result is cached at the call site so a list serialisation + * can run them per-row without re-preparing. + */ +function heartsFor(activityId: string, viewerId: string | null): { count: number; hearted: boolean } { + const db = getDb(); + const count = (db + .prepare('SELECT COUNT(*) AS n FROM activity_hearts WHERE activity_id = ?') + .get(activityId) as { n: number }).n; + const hearted = viewerId + ? !!db + .prepare('SELECT 1 FROM activity_hearts WHERE activity_id = ? AND user_id = ?') + .get(activityId, viewerId) + : false; + return { count, hearted }; +} + function ownerAttribution(ownerId: string): { display: string; username: string | null } { const row = getDb() .prepare('SELECT display_name, email, username, public_list_enabled FROM users WHERE id = ?') @@ -79,12 +97,16 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { owner_id: row.owner_id, ciphertext: b64(row.ciphertext) ?? '', nonce: b64(row.nonce) ?? '', + // Private rows don't surface hearts — nobody else sees them. + heart_count: 0, + viewer_hearted: false, created_at: row.created_at, updated_at: row.updated_at, }; return a; } const tags = tagsFor(row.id); + const hearts = heartsFor(row.id, viewerId); if (row.visibility === 'semi') { // owner_id is included ONLY when the viewer IS the owner — that lets the // client render Edit/Delete on the user's own semi rows without leaking @@ -98,6 +120,8 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { loc_lat: row.loc_lat, loc_lng: row.loc_lng, scheduled_at: row.scheduled_at, + heart_count: hearts.count, + viewer_hearted: hearts.hearted, created_at: row.created_at, updated_at: row.updated_at, }; @@ -117,6 +141,8 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { loc_lat: row.loc_lat, loc_lng: row.loc_lng, scheduled_at: row.scheduled_at, + heart_count: hearts.count, + viewer_hearted: hearts.hearted, created_at: row.created_at, updated_at: row.updated_at, }; @@ -281,6 +307,45 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => { return c.json(serialize(row, userId)); }); +// --- POST /api/activities/:id/heart ---------------------------------------- +// Idempotent heart: if the viewer already hearted it, this is a no-op rather +// than a 409. The client posts the same shape regardless of state. +activitiesRoutes.post('/:id/heart', requireAuth, (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = getDb(); + + const row = db + .prepare('SELECT visibility, owner_id FROM activities WHERE id = ?') + .get(id) as { visibility: Visibility; owner_id: string } | null; + if (!row) return c.json({ error: 'not_found' }, 404); + + // Hearts only make sense on what other people can see. + if (row.visibility === 'private') return c.json({ error: 'cannot_heart_private' }, 400); + + db.prepare( + 'INSERT OR IGNORE INTO activity_hearts (activity_id, user_id, created_at) VALUES (?, ?, ?)', + ).run(id, userId, Date.now()); + + const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow; + return c.json(serialize(refreshed, userId)); +}); + +// --- DELETE /api/activities/:id/heart -------------------------------------- +activitiesRoutes.delete('/:id/heart', requireAuth, (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = getDb(); + + const row = db.prepare('SELECT 1 FROM activities WHERE id = ?').get(id); + if (!row) return c.json({ error: 'not_found' }, 404); + + db.prepare('DELETE FROM activity_hearts WHERE activity_id = ? AND user_id = ?').run(id, userId); + + const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow; + return c.json(serialize(refreshed, userId)); +}); + // --- DELETE /api/activities/:id --------------------------------------------- // Authz: // - private: owner only. Other users can't even see private rows, so diff --git a/server/db.ts b/server/db.ts index 1604e54..5d20744 100644 --- a/server/db.ts +++ b/server/db.ts @@ -83,15 +83,28 @@ const SCHEMA_STATEMENTS: readonly string[] = [ )`, `CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id)`, `CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)`, - // Feedback: any logged-in user can submit; moderators read. + // Feedback: any logged-in user can submit; moderators read; admins triage. `CREATE TABLE IF NOT EXISTS feedback ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('feature','bug')), body TEXT NOT NULL, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + -- NULL while the request is open; epoch ms once an admin marks it done. + done_at INTEGER, + -- Admin who marked it done (for audit). NULL when done_at is NULL. + done_by TEXT REFERENCES users(id) )`, `CREATE INDEX IF NOT EXISTS feedback_created_idx ON feedback(created_at DESC)`, + // Hearts: logged-in users can heart any non-private activity they can see. + // Composite PK makes (activity, user) unique without an extra index. + `CREATE TABLE IF NOT EXISTS activity_hearts ( + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (activity_id, user_id) + )`, + `CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`, ]; const PRAGMAS: readonly string[] = [ @@ -146,6 +159,9 @@ export function getDb(): Database { ensureColumn(db, 'users', 'display_name', 'TEXT'); ensureColumn(db, 'users', 'is_moderator', 'INTEGER NOT NULL DEFAULT 0'); ensureColumn(db, 'users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); + // Feedback triage columns (added after the feedback feature shipped). + ensureColumn(db, 'feedback', 'done_at', 'INTEGER'); + ensureColumn(db, 'feedback', 'done_by', 'TEXT'); ensureColumn(db, 'users', 'username', 'TEXT'); ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0'); // UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE diff --git a/server/feedback.ts b/server/feedback.ts index 5e51cd7..c00393d 100644 --- a/server/feedback.ts +++ b/server/feedback.ts @@ -1,8 +1,10 @@ import { Hono } from 'hono'; import { getDb } from './db'; import { requireAuth, type AppVariables } from './session'; -import { isModerator } from './roles'; -import type { FeedbackSubmitRequest, FeedbackEntry } from '../shared/types'; +import { isModerator, isAdmin } from './roles'; +import type { + FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest, +} from '../shared/types'; const MAX_BODY = 4000; @@ -38,6 +40,7 @@ feedbackRoutes.post('/', requireAuth, async (c) => { const entry: FeedbackEntry = { id, kind: body.kind, body: trimmed, created_at: Date.now(), + done_at: null, done_by: null, }; return c.json(entry, 201); }); @@ -48,12 +51,59 @@ feedbackRoutes.get('/', requireAuth, (c) => { const rows = getDb() .prepare(` - SELECT f.id, f.kind, f.body, f.created_at, + SELECT f.id, f.kind, f.body, f.created_at, f.done_at, f.done_by, f.user_id, u.email AS user_email, u.display_name AS user_display FROM feedback f JOIN users u ON u.id = f.user_id - ORDER BY f.created_at DESC + ORDER BY (f.done_at IS NOT NULL) ASC, f.created_at DESC `) .all() as FeedbackEntry[]; return c.json(rows); }); + +// --- PATCH /api/feedback/:id ------------------------------------------------ +// Admin-only: mark a request as done / reopen it. +feedbackRoutes.patch('/:id', requireAuth, async (c) => { + const userId = c.get('userId'); + if (!isAdmin(userId)) return c.json({ error: 'forbidden' }, 403); + + const id = c.req.param('id'); + const body = (await c.req.json().catch(() => null)) as FeedbackUpdateRequest | null; + if (!body || typeof body.done !== 'boolean') { + return c.json({ error: 'missing:done' }, 400); + } + + const db = getDb(); + const exists = db.prepare('SELECT 1 FROM feedback WHERE id = ?').get(id); + if (!exists) return c.json({ error: 'not_found' }, 404); + + if (body.done) { + db.prepare('UPDATE feedback SET done_at = ?, done_by = ? WHERE id = ?') + .run(Date.now(), userId, id); + } else { + db.prepare('UPDATE feedback SET done_at = NULL, done_by = NULL WHERE id = ?').run(id); + } + + const row = db.prepare(` + SELECT f.id, f.kind, f.body, f.created_at, f.done_at, f.done_by, + f.user_id, u.email AS user_email, u.display_name AS user_display + FROM feedback f + JOIN users u ON u.id = f.user_id + WHERE f.id = ? + `).get(id) as FeedbackEntry; + return c.json(row); +}); + +// --- DELETE /api/feedback/:id ----------------------------------------------- +// Admin-only: drop a feedback entry. No soft-delete — once an admin removes +// it, it's gone. +feedbackRoutes.delete('/:id', requireAuth, (c) => { + const userId = c.get('userId'); + if (!isAdmin(userId)) return c.json({ error: 'forbidden' }, 403); + + const id = c.req.param('id'); + const db = getDb(); + const res = db.prepare('DELETE FROM feedback WHERE id = ?').run(id); + if (res.changes === 0) return c.json({ error: 'not_found' }, 404); + return c.json({ ok: true }); +}); diff --git a/server/users.ts b/server/users.ts index 57a8814..d2f8e2b 100644 --- a/server/users.ts +++ b/server/users.ts @@ -49,23 +49,32 @@ usersRoutes.get('/:username/list', (c) => { `) .all(user.id) as ActivityRow[]; - const activities: ActivityPublic[] = rows.map((r) => ({ - id: r.id, - visibility: 'public', - owner_id: r.owner_id, - owner_display: user.display_name?.trim() || username, - // The list itself is at //list, so we already know the slug. - // Surfacing it on each row keeps ActivityRow's rendering uniform. - owner_username: username, - title: r.title ?? '', - tags: tagsFor(r.id), - loc_label: r.loc_label, - loc_lat: r.loc_lat, - loc_lng: r.loc_lng, - scheduled_at: r.scheduled_at, - created_at: r.created_at, - updated_at: r.updated_at, - })); + const activities: ActivityPublic[] = rows.map((r) => { + const count = (db + .prepare('SELECT COUNT(*) AS n FROM activity_hearts WHERE activity_id = ?') + .get(r.id) as { n: number }).n; + return { + id: r.id, + visibility: 'public', + owner_id: r.owner_id, + owner_display: user.display_name?.trim() || username, + // The list itself is at //list, so we already know the slug. + // Surfacing it on each row keeps ActivityRow's rendering uniform. + owner_username: username, + title: r.title ?? '', + tags: tagsFor(r.id), + loc_label: r.loc_label, + loc_lat: r.loc_lat, + loc_lng: r.loc_lng, + scheduled_at: r.scheduled_at, + heart_count: count, + // The public-list endpoint is unauthenticated; we don't know who the + // viewer is to fill viewer_hearted truthfully. Always false here. + viewer_hearted: false, + created_at: r.created_at, + updated_at: r.updated_at, + }; + }); const resp: PublicListResponse = { username, diff --git a/shared/types.ts b/shared/types.ts index b5f2e87..1262435 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -123,6 +123,10 @@ export interface FeedbackEntry { kind: FeedbackKind; body: string; created_at: number; + /** Set when an admin has marked the entry as done. */ + done_at: number | null; + /** User id of the admin who marked it done; null when done_at is null. */ + done_by: string | null; // Moderator-only fields; included when the caller is a moderator viewing // the list. (The submit endpoint doesn't return these — a submitter doesn't // need to see them.) @@ -131,6 +135,10 @@ export interface FeedbackEntry { user_display?: string | null; } +export interface FeedbackUpdateRequest { + done: boolean; +} + // --- Activities -------------------------------------------------------------- export interface ActivityPublic { id: string; @@ -146,6 +154,10 @@ export interface ActivityPublic { loc_lat: number | null; loc_lng: number | null; scheduled_at: number | null; + /** Total hearts on this activity. */ + heart_count: number; + /** True when the authenticated viewer has hearted this activity. */ + viewer_hearted: boolean; created_at: number; updated_at: number; } @@ -163,6 +175,8 @@ export interface ActivitySemi { loc_lat: number | null; loc_lng: number | null; scheduled_at: number | null; + heart_count: number; + viewer_hearted: boolean; created_at: number; updated_at: number; } @@ -173,6 +187,11 @@ export interface ActivityPrivate { owner_id: string; // always you — server only returns your private rows ciphertext: string; // base64 nonce: string; // base64 + // Always 0 / false for private rows — hearts don't apply (nobody else sees + // them). Kept in the type so the client doesn't need a discriminator check + // before reading the field. + heart_count: number; + viewer_hearted: boolean; created_at: number; updated_at: number; }