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
|
|
@ -179,6 +179,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- Archive / Hide -----------------------------------------------------
|
||||
// Two per-viewer flags. Archive applies to anyone (including the owner);
|
||||
// hide applies only to non-owners. Both opt the row out of the default
|
||||
// listing — the user has to toggle "Vis arkiv" / "Vis skjulte" to see
|
||||
// them again.
|
||||
let archiveBusy = $state(false);
|
||||
let hideBusy = $state(false);
|
||||
|
||||
async function toggleArchive() {
|
||||
if (!session.user || archiveBusy) return;
|
||||
archiveBusy = true;
|
||||
const wasArchived = view.viewer_archived;
|
||||
localOverride = { ...view, viewer_archived: !wasArchived };
|
||||
try {
|
||||
const updated = wasArchived
|
||||
? await api.unarchiveActivity(view.id)
|
||||
: await api.archiveActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
archiveBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHide() {
|
||||
if (!session.user || hideBusy) return;
|
||||
hideBusy = true;
|
||||
const wasHidden = view.viewer_hidden;
|
||||
localOverride = { ...view, viewer_hidden: !wasHidden };
|
||||
try {
|
||||
const updated = wasHidden
|
||||
? await api.unhideActivity(view.id)
|
||||
: await api.hideActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
hideBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bookmarks -----------------------------------------------------------
|
||||
let bookmarkBusy = $state(false);
|
||||
async function toggleBookmark() {
|
||||
|
|
@ -374,6 +418,29 @@
|
|||
{#if canEdit && onEdit}
|
||||
<button type="button" onclick={startEdit}>Rediger</button>
|
||||
{/if}
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleArchive}
|
||||
disabled={archiveBusy}
|
||||
aria-pressed={view.viewer_archived}
|
||||
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
|
||||
>
|
||||
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if session.user && !isOwner}
|
||||
<!-- Hide is non-owner only — owners can delete or archive instead. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleHide}
|
||||
disabled={hideBusy}
|
||||
aria-pressed={view.viewer_hidden}
|
||||
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
|
||||
>
|
||||
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,28 @@
|
|||
let error: string | null = $state(null);
|
||||
let query = $state('');
|
||||
|
||||
onMount(load);
|
||||
// Two toggles control what shape of list we ask the server for:
|
||||
// - default: active rows only (excludes archived AND hidden)
|
||||
// - showArchived: include archived rows mixed in with active
|
||||
// - showHidden: include hidden rows mixed in
|
||||
// The user can flip these independently. Anonymous (publicOnly) viewers
|
||||
// never see the toggles since they have no archive/hide state.
|
||||
let showArchived = $state(false);
|
||||
let showHidden = $state(false);
|
||||
|
||||
onMount(() => load());
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
activities = await api.listActivities();
|
||||
activities = await api.listActivities(
|
||||
publicOnly
|
||||
? undefined
|
||||
: {
|
||||
...(showArchived ? { archived: '1' as const } : {}),
|
||||
...(showHidden ? { hidden: '1' as const } : {}),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
error = 'Kunne ikke laste oppføringer.';
|
||||
} finally {
|
||||
|
|
@ -35,6 +51,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Re-fetch from the server when the toggles flip — the WHERE clause is
|
||||
// server-side so we can't filter the existing array client-side.
|
||||
$effect(() => {
|
||||
void showArchived;
|
||||
void showHidden;
|
||||
if (!loading) load();
|
||||
});
|
||||
|
||||
function onCreated(a: Activity) {
|
||||
activities = [a, ...activities];
|
||||
showForm = false;
|
||||
|
|
@ -46,7 +70,13 @@
|
|||
}
|
||||
|
||||
function onChanged(a: Activity) {
|
||||
// When a row's archived/hidden state flips, it may no longer belong in
|
||||
// the current view (toggles are off → archived/hidden rows disappear).
|
||||
// Reload to get the authoritative list from the server.
|
||||
const needsRefetch =
|
||||
(a.viewer_archived && !showArchived) || (a.viewer_hidden && !showHidden);
|
||||
activities = activities.map((x) => (x.id === a.id ? a : x));
|
||||
if (needsRefetch) load();
|
||||
}
|
||||
|
||||
function onDeleted(id: string) {
|
||||
|
|
@ -201,6 +231,19 @@
|
|||
aria-label="Søk i aktiviteter"
|
||||
/>
|
||||
|
||||
{#if !publicOnly && session.user}
|
||||
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
|
||||
<label class="row" style="gap: 0.35rem;">
|
||||
<input type="checkbox" bind:checked={showArchived} />
|
||||
<span class="muted">📦 Vis arkivert</span>
|
||||
</label>
|
||||
<label class="row" style="gap: 0.35rem;">
|
||||
<input type="checkbox" bind:checked={showHidden} />
|
||||
<span class="muted">🙈 Vis skjult</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div style="margin-top: 1rem;">
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
|
|
|
|||
|
|
@ -64,7 +64,13 @@ export const api = {
|
|||
}),
|
||||
|
||||
// --- activities -----------------------------------------------------------
|
||||
listActivities: () => http<Activity[]>('/activities'),
|
||||
listActivities: (opts?: { archived?: '1' | 'only'; hidden?: '1' | 'only' }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.archived) qs.set('archived', opts.archived);
|
||||
if (opts?.hidden) qs.set('hidden', opts.hidden);
|
||||
const q = qs.toString();
|
||||
return http<Activity[]>(`/activities${q ? `?${q}` : ''}`);
|
||||
},
|
||||
getActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}`),
|
||||
createActivity: (body: CreateActivityRequest) =>
|
||||
|
|
@ -83,6 +89,14 @@ export const api = {
|
|||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
|
||||
undoneActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
|
||||
archiveActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
|
||||
unarchiveActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
|
||||
hideActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
|
||||
unhideActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
|
||||
bookmarkActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
|
||||
unbookmarkActivity: (id: string) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -223,6 +223,13 @@ export interface ActivityPublic {
|
|||
done_count: number;
|
||||
/** True when the authenticated viewer has marked this activity done. */
|
||||
viewer_done: boolean;
|
||||
/** True when the authenticated viewer has archived this activity for
|
||||
* themselves. Default-filtered from the main and public lists; the
|
||||
* client opts in via ?archived=1 to see them. Owner-archiving works too. */
|
||||
viewer_archived: boolean;
|
||||
/** True when the authenticated viewer has hidden this activity. Only
|
||||
* non-owners can hide. Default-filtered from lists; opt-in via ?hidden=1. */
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -250,6 +257,8 @@ export interface ActivitySemi {
|
|||
viewer_bookmarked: boolean;
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
viewer_archived: boolean;
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -275,6 +284,11 @@ export interface ActivityPrivate {
|
|||
// for private rows. viewer_done reflects the owner's own state.
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
// Private rows are owner-only, so viewer_hidden is always false (the
|
||||
// hide endpoint refuses on your own activities). viewer_archived is the
|
||||
// owner archiving their own private todo.
|
||||
viewer_archived: boolean;
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -307,6 +321,8 @@ export interface ActivityFriends {
|
|||
viewer_bookmarked: boolean;
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
viewer_archived: boolean;
|
||||
viewer_hidden: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
|
|||
|
|
@ -188,6 +188,99 @@ describe('"gjort" (done) marks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('archive + hide (per-viewer)', () => {
|
||||
ttest('viewer can archive any visibility they see; default list excludes archived', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'arch-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'arch-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'arch-pub', tags: [],
|
||||
});
|
||||
|
||||
// Default list includes it.
|
||||
let list = await listActivities(ctx, viewer.cookie);
|
||||
expect(list.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Archive it.
|
||||
const archived = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/archive`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(archived.viewer_archived).toBe(true);
|
||||
|
||||
// Disappears from the default list.
|
||||
list = await listActivities(ctx, viewer.cookie);
|
||||
expect(list.find((a) => a.id === pub.id)).toBeUndefined();
|
||||
|
||||
// ?archived=1 brings it back.
|
||||
const withArch = await reqJson<Activity[]>(ctx, 'GET', '/api/activities?archived=1', {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(withArch.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Other viewer is unaffected — archive is per-viewer.
|
||||
const otherList = await listActivities(ctx, owner.cookie);
|
||||
expect(otherList.find((a) => a.id === pub.id)?.viewer_archived).toBe(false);
|
||||
});
|
||||
|
||||
ttest('owner can archive their own row (own public list filters it too)', async () => {
|
||||
const owner = await signupAndGetCookie(ctx, 'arch-self@test.invalid');
|
||||
// Owner needs a username + public_list_enabled to test the public-list filter.
|
||||
await reqJson(ctx, 'PATCH', '/api/auth/profile', {
|
||||
cookie: owner.cookie,
|
||||
body: { username: 'archself', public_list_enabled: true, display_name: 'Arch Self' },
|
||||
});
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'arch-own-pub', tags: [],
|
||||
});
|
||||
|
||||
// Visible on the owner's public list initially.
|
||||
const before = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list');
|
||||
expect(before.activities.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Owner archives → drops off their public list.
|
||||
await req(ctx, 'POST', `/api/activities/${pub.id}/archive`, { cookie: owner.cookie });
|
||||
const after = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list');
|
||||
expect(after.activities.find((a) => a.id === pub.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
ttest('hide refuses on owner\'s own row; works on others\'; per-viewer', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'hide-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'hide-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'hide-test', tags: [],
|
||||
});
|
||||
|
||||
// Owner trying to hide their own row → 400.
|
||||
const ownerHide = await req(ctx, 'POST', `/api/activities/${pub.id}/hide`, {
|
||||
cookie: owner.cookie,
|
||||
});
|
||||
expect(ownerHide.status).toBe(400);
|
||||
|
||||
// Non-owner viewer can hide it.
|
||||
const hidden = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/hide`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(hidden.viewer_hidden).toBe(true);
|
||||
|
||||
// Disappears from the viewer's default list…
|
||||
const list = await listActivities(ctx, viewer.cookie);
|
||||
expect(list.find((a) => a.id === pub.id)).toBeUndefined();
|
||||
|
||||
// …but ?hidden=only surfaces just the hidden ones.
|
||||
const only = await reqJson<Activity[]>(ctx, 'GET', '/api/activities?hidden=only', {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(only.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
|
||||
// Owner's view is unaffected.
|
||||
const ownerList = await listActivities(ctx, owner.cookie);
|
||||
expect(ownerList.find((a) => a.id === pub.id)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookmarks', () => {
|
||||
ttest('toggle, idempotent, refused on private', async () => {
|
||||
const [owner, viewer, otherViewer] = await Promise.all([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue