Hearts on activities, feedback triage by admins, click-to-permalink

Three small features and one UX bug.

1. **Hearts.** New `activity_hearts(activity_id, user_id, created_at)`
   table with composite PK. POST/DELETE /api/activities/:id/heart for
   logged-in users on any non-private activity. Idempotent — re-posting
   is a no-op rather than a 409.

   Activity serialisation now includes `heart_count` and
   `viewer_hearted` on all three visibility variants (private is always
   0/false; hearts make no sense there). ActivityRow renders a toggle
   button with optimistic updates and a local override that snaps back
   on network error, then propagates the server's authoritative state
   to the parent list via a new `onChanged` callback.

2. **Admin can triage feedback.** Added `done_at` + `done_by` columns
   to feedback (migrated via ensureColumn for older scaffold DBs). New
   admin-only endpoints:
     PATCH  /api/feedback/:id    — { done: boolean }; sets/clears done_at
     DELETE /api/feedback/:id    — drops the entry
   The Feedback list view sorts open requests above done ones, and
   admins see "Marker som ferdig" / "Marker som åpen" / "Slett" buttons
   per entry. Open/done badges visible to everyone with read access
   (i.e., moderators).

3. **Clicking an activity opens its permalink.** Activity titles in
   ActivityRow are now anchor links to /a/:id, so clicking the title
   navigates to the permalink view. Action buttons (heart, edit,
   delete, del/copy-link) stay inside the card; the anchor only wraps
   the title text, so taps elsewhere don't navigate.

Bundle gained 1 KB gzipped (30.2 → 31.2 KB). 26 tests still pass,
typecheck clean.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:33:51 +02:00
commit 5c9455c3f3
9 changed files with 305 additions and 31 deletions

View file

@ -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

View file

@ -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

View file

@ -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 });
});

View file

@ -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 /<username>/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 /<username>/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,