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:
parent
bd82f71a01
commit
5c9455c3f3
9 changed files with 305 additions and 31 deletions
|
|
@ -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/<id> (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}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
{decrypted.title}
|
||||
<a href={`/a/${activity.id}`} style="color: inherit; text-decoration: none;">
|
||||
{decrypted.title}
|
||||
</a>
|
||||
<span class="vis-badge private">Privat</span>
|
||||
</h3>
|
||||
{#if decrypted.tags.length}
|
||||
|
|
@ -157,7 +195,9 @@
|
|||
{/if}
|
||||
{:else}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
{activity.title}
|
||||
<a href={`/a/${activity.id}`} style="color: inherit; text-decoration: none;">
|
||||
{activity.title}
|
||||
</a>
|
||||
<span class="vis-badge {activity.visibility}">
|
||||
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
|
||||
</span>
|
||||
|
|
@ -184,6 +224,22 @@
|
|||
{/if}
|
||||
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
{#if view.visibility !== 'private'}
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleHeart}
|
||||
disabled={heartBusy}
|
||||
aria-pressed={view.viewer_hearted}
|
||||
aria-label={view.viewer_hearted ? 'Fjern hjerte' : 'Sett hjerte'}
|
||||
class={view.viewer_hearted ? 'primary' : ''}
|
||||
>
|
||||
{view.viewer_hearted ? '♥' : '♡'} {view.heart_count}
|
||||
</button>
|
||||
{:else if view.heart_count > 0}
|
||||
<span class="muted" aria-label="Antall hjerter">♡ {view.heart_count}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if canEdit && onEdit}
|
||||
<button type="button" onclick={startEdit}>Rediger</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<p class="muted">Ingen tilbakemeldinger ennå.</p>
|
||||
{/if}
|
||||
{#each entries as e (e.id)}
|
||||
<article class="card" style="margin-top: 0.5rem;">
|
||||
<article class="card" style="margin-top: 0.5rem; {e.done_at ? 'opacity: 0.7;' : ''}">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<strong>{e.kind === 'feature' ? 'Forslag' : 'Feilrapport'}</strong>
|
||||
<div class="row" style="gap: 0.5rem;">
|
||||
<strong>{e.kind === 'feature' ? 'Forslag' : 'Feilrapport'}</strong>
|
||||
{#if e.done_at}
|
||||
<span class="vis-badge semi" style="background: rgba(46,160,67,0.18); color: #2ea043;">Ferdig</span>
|
||||
{:else}
|
||||
<span class="vis-badge semi">Åpen</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="muted">{formatDate(e.created_at)}</span>
|
||||
</div>
|
||||
<p style="white-space: pre-wrap; margin: 0.5rem 0;">{e.body}</p>
|
||||
<p class="muted" style="font-size: 0.85rem;">
|
||||
Fra {e.user_display?.trim() || e.user_email}
|
||||
</p>
|
||||
{#if session.user?.is_admin}
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
{#if e.done_at}
|
||||
<button type="button" onclick={() => toggleDone(e, false)}>Marker som åpen</button>
|
||||
{:else}
|
||||
<button class="primary" type="button" onclick={() => toggleDone(e, true)}>Marker som ferdig</button>
|
||||
{/if}
|
||||
<button class="danger" type="button" onclick={() => removeEntry(e)}>Slett</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
|
||||
unheartActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
|
||||
|
||||
// --- tags -----------------------------------------------------------------
|
||||
tagSuggestions: (q: string, limit = 20) =>
|
||||
|
|
@ -86,6 +90,12 @@ export const api = {
|
|||
submitFeedback: (body: FeedbackSubmitRequest) =>
|
||||
http<FeedbackEntry>('/feedback', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listFeedback: () => http<FeedbackEntry[]>('/feedback'),
|
||||
updateFeedback: (id: string, body: FeedbackUpdateRequest) =>
|
||||
http<FeedbackEntry>(`/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<AdminUser[]>('/admin/users'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
server/db.ts
20
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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue