feat(activity): per-viewer archive and hide
Two new per-viewer flags on activities, mirroring the heart/done shape: - ARCHIVE: any viewer (incl. the owner) can archive a row. Out of sight by default but the permalink still resolves. For owners, archive also filters the row out of their /<bruker>/liste public page — "I'm done sharing this." - HIDE: only non-owners. "This doesn't appeal to me." Endpoint returns 400 cannot_hide_own when the owner tries it (they should delete or archive instead). Both default-filtered from GET /api/activities. Opt-in via query params with three modes each: exclude (default), include (1), only. ?archived=1&hidden=1 includes both; ?archived=only shows just the archive. The list endpoint composes the SQL filters per-viewer (anonymous viewers have no rows in either table → no-ops). Schema: two new tables user_archived_activities and user_hidden_activities, both composite-PK on (user_id, activity_id), cascade on both refs. Same shape as the existing engagement tables. Types: viewer_archived + viewer_hidden on every Activity variant. viewer_hidden on private is always false (the endpoint refuses); docstring on the type explains why. Bulk lookups extended so the list endpoint stays at constant queries. Single-row paths get per-row viewerArchived/viewerHidden helpers. fetchRowForViewer keeps preserving the viewer's custom sort_position on toggles. UI: two new buttons in ActivityRow's action row — "📦 Arkiver" / "📦 Arkivert" (everyone) and "🙈 Gjem" / "🙈 Skjult" (non-owners only). Two new checkboxes near the search field on /hjem: "📦 Vis arkivert" and "🙈 Vis skjult". Toggling either re-fetches from the server because the filtering is server-side. When an archive/hide flips the row out of the current view, Home.svelte triggers a refetch so the row disappears in real time. Public-list endpoint (/api/users/:bruker/list) also filters out the owner's own archived rows — consistent with "archive means filed away from active view." Tests: 3 new in engagement.test.ts — viewer archives + per-viewer isolation, owner's archive filters their public list, hide refuses on own row + ?hidden=only path. Suite goes 102 → 105.
This commit is contained in:
parent
ef02b3f585
commit
6e005fc2d7
8 changed files with 418 additions and 7 deletions
|
|
@ -79,6 +79,22 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
|
|||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Does the viewer have an archive entry on this activity? */
|
||||
function viewerArchived(activityId: string, viewerId: string | null): boolean {
|
||||
if (!viewerId) return false;
|
||||
return !!getDb()
|
||||
.prepare('SELECT 1 FROM user_archived_activities WHERE activity_id = ? AND user_id = ?')
|
||||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Does the viewer have a hide entry on this activity? */
|
||||
function viewerHidden(activityId: string, viewerId: string | null): boolean {
|
||||
if (!viewerId) return false;
|
||||
return !!getDb()
|
||||
.prepare('SELECT 1 FROM user_hidden_activities WHERE activity_id = ? AND user_id = ?')
|
||||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Done-count + viewer-done lookup for a single activity. */
|
||||
function doneFor(activityId: string, viewerId: string | null): { count: number; done: boolean } {
|
||||
const db = getDb();
|
||||
|
|
@ -166,6 +182,8 @@ interface BulkLookups {
|
|||
hearts: Map<string, { count: number; hearted: boolean }>;
|
||||
done: Map<string, { count: number; done: boolean }>;
|
||||
bookmarked: Set<string>;
|
||||
archived: Set<string>;
|
||||
hidden: Set<string>;
|
||||
attribution: Map<string, { display: string | null; username: string | null }>;
|
||||
}
|
||||
|
||||
|
|
@ -176,9 +194,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
const hearts = new Map<string, { count: number; hearted: boolean }>();
|
||||
const done = new Map<string, { count: number; done: boolean }>();
|
||||
const bookmarked = new Set<string>();
|
||||
const archived = new Set<string>();
|
||||
const hidden = new Set<string>();
|
||||
const attribution = new Map<string, { display: string | null; username: string | null }>();
|
||||
|
||||
if (ids.length === 0) return { tags, hearts, done, bookmarked, attribution };
|
||||
if (ids.length === 0) return { tags, hearts, done, bookmarked, archived, hidden, attribution };
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
|
||||
const heartCounts = db
|
||||
|
|
@ -231,6 +251,22 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of bm) bookmarked.add(r.activity_id);
|
||||
|
||||
const ar = db
|
||||
.prepare(`
|
||||
SELECT activity_id FROM user_archived_activities
|
||||
WHERE activity_id IN (${ph}) AND user_id = ?
|
||||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of ar) archived.add(r.activity_id);
|
||||
|
||||
const hd = db
|
||||
.prepare(`
|
||||
SELECT activity_id FROM user_hidden_activities
|
||||
WHERE activity_id IN (${ph}) AND user_id = ?
|
||||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of hd) hidden.add(r.activity_id);
|
||||
}
|
||||
|
||||
const ownerIds = [...new Set(rows.map((r) => r.owner_id))];
|
||||
|
|
@ -256,7 +292,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
}
|
||||
}
|
||||
|
||||
return { tags, hearts, done, bookmarked, attribution };
|
||||
return { tags, hearts, done, bookmarked, archived, hidden, attribution };
|
||||
}
|
||||
|
||||
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
|
||||
|
|
@ -269,6 +305,11 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
const done = bulk
|
||||
? (bulk.done.get(row.id) ?? { count: 0, done: false })
|
||||
: doneFor(row.id, viewerId);
|
||||
// Archive applies to every viewer; hide applies only to non-owners. For
|
||||
// a private row that's only ever visible to its owner, hide is always
|
||||
// false (the endpoint refuses it anyway).
|
||||
const archived = bulk ? bulk.archived.has(row.id) : viewerArchived(row.id, viewerId);
|
||||
const hidden = bulk ? bulk.hidden.has(row.id) : viewerHidden(row.id, viewerId);
|
||||
if (row.visibility === 'private') {
|
||||
const a: ActivityPrivate = {
|
||||
id: row.id,
|
||||
|
|
@ -282,6 +323,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
viewer_bookmarked: false,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: false,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -314,6 +357,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: hidden,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -347,6 +392,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: hidden,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -370,6 +417,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
viewer_archived: archived,
|
||||
viewer_hidden: hidden,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -407,8 +456,20 @@ activitiesRoutes.get('/', (c) => {
|
|||
const viewerId = currentUserId(c);
|
||||
const db = getDb();
|
||||
|
||||
const params: string[] = [];
|
||||
let where = `visibility IN ('public','semi')`;
|
||||
// Inclusion flags for archived / hidden rows. Modes:
|
||||
// ?archived=0 (default) -- exclude archived
|
||||
// ?archived=1 -- include archived (still mixed with the rest)
|
||||
// ?archived=only -- ONLY archived
|
||||
// Same scheme for ?hidden=...
|
||||
// Anonymous viewers don't have either flag stored so these are no-ops for them.
|
||||
type Mode = 'exclude' | 'include' | 'only';
|
||||
const parseMode = (q?: string): Mode =>
|
||||
q === '1' ? 'include' : q === 'only' ? 'only' : 'exclude';
|
||||
const archivedMode = parseMode(c.req.query('archived'));
|
||||
const hiddenMode = parseMode(c.req.query('hidden'));
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
let where = `(visibility IN ('public','semi')`;
|
||||
if (viewerId) {
|
||||
// Own private:
|
||||
where += ` OR (visibility = 'private' AND owner_id = ?)`;
|
||||
|
|
@ -433,6 +494,28 @@ activitiesRoutes.get('/', (c) => {
|
|||
`;
|
||||
params.push(viewerId, viewerId, viewerId);
|
||||
}
|
||||
where += `)`;
|
||||
|
||||
// Per-viewer archive / hide filters. Only meaningful for an authenticated
|
||||
// viewer (anonymous viewers have no rows in either table).
|
||||
if (viewerId) {
|
||||
const archivedExists = `EXISTS (SELECT 1 FROM user_archived_activities WHERE activity_id = activities.id AND user_id = ?)`;
|
||||
const hiddenExists = `EXISTS (SELECT 1 FROM user_hidden_activities WHERE activity_id = activities.id AND user_id = ?)`;
|
||||
if (archivedMode === 'exclude') {
|
||||
where += ` AND NOT ${archivedExists}`;
|
||||
params.push(viewerId);
|
||||
} else if (archivedMode === 'only') {
|
||||
where += ` AND ${archivedExists}`;
|
||||
params.push(viewerId);
|
||||
}
|
||||
if (hiddenMode === 'exclude') {
|
||||
where += ` AND NOT ${hiddenExists}`;
|
||||
params.push(viewerId);
|
||||
} else if (hiddenMode === 'only') {
|
||||
where += ` AND ${hiddenExists}`;
|
||||
params.push(viewerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Effective ordering: if the viewer has a per-row sort position, use it;
|
||||
// otherwise fall back to -created_at so new activities (no sort row yet)
|
||||
|
|
@ -735,6 +818,72 @@ function toggleDone(c: AppContext, op: 'add' | 'remove') {
|
|||
activitiesRoutes.post('/:id/done', requireAuth, (c) => toggleDone(c, 'add'));
|
||||
activitiesRoutes.delete('/:id/done', requireAuth, (c) => toggleDone(c, 'remove'));
|
||||
|
||||
/**
|
||||
* Archive (anyone) / hide (non-owner only). Same skeleton as toggleDone:
|
||||
* - Visibility-aware existence check (404 for rows the viewer can't see).
|
||||
* - For hide, the owner gets 400 cannot_hide_own — they should delete or
|
||||
* archive instead.
|
||||
* - INSERT OR IGNORE / DELETE for idempotency.
|
||||
*/
|
||||
type Filing = 'archive' | 'hide';
|
||||
const FILING_TABLES: Record<Filing, string> = {
|
||||
archive: 'user_archived_activities',
|
||||
hide: 'user_hidden_activities',
|
||||
};
|
||||
|
||||
function toggleFiling(c: AppContext, kind: Filing, op: 'add' | 'remove') {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
if (!id) return c.json({ error: 'not_found' }, 404);
|
||||
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);
|
||||
|
||||
// Same visibility check as toggleDone — hidden rows return 404, not 403.
|
||||
if (row.visibility === 'private' && row.owner_id !== userId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
if (row.visibility === 'friends' && row.owner_id !== userId) {
|
||||
const isFriend = !!db
|
||||
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(row.owner_id, userId);
|
||||
if (!isFriend) return c.json({ error: 'not_found' }, 404);
|
||||
const blocked = !!db
|
||||
.prepare(`
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = ?)
|
||||
`)
|
||||
.get(row.owner_id, userId, userId, row.owner_id);
|
||||
if (blocked) return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
// Hiding your own row makes no sense — you can delete or archive instead.
|
||||
if (kind === 'hide' && row.owner_id === userId) {
|
||||
return c.json({ error: 'cannot_hide_own' }, 400);
|
||||
}
|
||||
|
||||
const table = FILING_TABLES[kind];
|
||||
if (op === 'add') {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO ${table} (user_id, activity_id, created_at) VALUES (?, ?, ?)`,
|
||||
).run(userId, id, Date.now());
|
||||
} else {
|
||||
db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id);
|
||||
}
|
||||
|
||||
const refreshed = fetchRowForViewer(id, userId) as ActivityRow;
|
||||
return c.json(serialize(refreshed, userId));
|
||||
}
|
||||
|
||||
activitiesRoutes.post('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'add'));
|
||||
activitiesRoutes.delete('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'remove'));
|
||||
activitiesRoutes.post('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'add'));
|
||||
activitiesRoutes.delete('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'remove'));
|
||||
|
||||
// --- DELETE /api/activities/:id ---------------------------------------------
|
||||
// Authz:
|
||||
// - private: owner only. Other users can't even see private rows, so
|
||||
|
|
|
|||
18
server/db.ts
18
server/db.ts
|
|
@ -131,6 +131,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
PRIMARY KEY (activity_id, user_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activity_done_user_idx ON activity_done(user_id)`,
|
||||
// Per-viewer "archived" flag. Any viewer (incl. the owner) can archive an
|
||||
// activity to remove it from their default list while keeping the row
|
||||
// intact for history. Archived rows are still permalinked.
|
||||
`CREATE TABLE IF NOT EXISTS user_archived_activities (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, activity_id)
|
||||
)`,
|
||||
// Per-viewer "hidden" flag. Only non-owners can hide a row — the owner
|
||||
// already has delete. Semantics: "this doesn't appeal to me, get it out
|
||||
// of my feed." Default-filtered like archived.
|
||||
`CREATE TABLE IF NOT EXISTS user_hidden_activities (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, activity_id)
|
||||
)`,
|
||||
// Bookmarks: logged-in users can save public/semi activities to their own
|
||||
// dashboard. Same shape as hearts: composite PK on (user, activity), one
|
||||
// row per bookmark. CASCADE on the activity so deletes clean up.
|
||||
|
|
|
|||
|
|
@ -41,12 +41,20 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
// The owner's archive intent extends to their public list — archived
|
||||
// rows disappear from "their published winter list" too. There's no
|
||||
// logged-in viewer here (this endpoint is for the anonymous public),
|
||||
// so we only need to filter rows the OWNER has archived.
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT id, owner_id, title, description, scheduled_at, loc_label,
|
||||
loc_lat, loc_lng, created_at, updated_at
|
||||
FROM activities
|
||||
WHERE owner_id = ? AND visibility = 'public'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_archived_activities
|
||||
WHERE activity_id = activities.id AND user_id = activities.owner_id
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
.all(user.id) as ActivityRow[];
|
||||
|
|
@ -94,6 +102,9 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
viewer_bookmarked: false,
|
||||
done_count: doneCounts.get(r.id) ?? 0,
|
||||
viewer_done: false,
|
||||
// No logged-in viewer → can't have personal archive/hide state.
|
||||
viewer_archived: false,
|
||||
viewer_hidden: false,
|
||||
// No personal sort here — anonymous view always sorts by recency.
|
||||
sort_position: -r.created_at,
|
||||
created_at: r.created_at,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue