feat(activity): "Gjort" mark with statistics
Per-user "I've done this" toggle alongside hearts and bookmarks.
Hearts express approval; gjort expresses completion. Both contribute
to public statistics so readers can see what people LIKE versus what
people actually DO.
Backend:
- New activity_done table (composite PK on activity_id + user_id,
CASCADE on both refs, mirrors activity_hearts).
- POST/DELETE /api/activities/:id/done. Unlike heart/bookmark, "gjort"
works on every visibility the viewer can see — private (owner-only,
acts as a personal todo checkbox), friends-only (mutual-friend +
no-block check, mirrors GET /:id), public, semi. Non-viewers get
404 to avoid leaking existence.
- buildBulkLookups + serialize extended with done_count + viewer_done
so the list endpoint stays at constant queries per render.
- Public-list endpoint (server/users.ts) bulk-fetches done counts
alongside heart counts; viewer_done is always false (unauth view).
Types: Activity{Public,Semi,Private,Friends} all gain done_count +
viewer_done. Private's count is at most 1 (only the owner can write).
UI: new "✓ Gjort" / "☐ Gjort" button in the action row with the same
optimistic-toggle + localOverride pattern as hearts. Anonymous viewers
on public activities see a muted "✓ N" stat. Title hint clarifies
the intent: "Dette har jeg gjort" vs "Du har gjort dette."
Tests: 2 new in engagement.test.ts — toggle + idempotency on public,
owner-only access on private (non-owner gets 404).
This commit is contained in:
parent
38db772b4f
commit
bbb5ad2bdd
7 changed files with 263 additions and 17 deletions
|
|
@ -124,6 +124,70 @@ describe('hearts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('"gjort" (done) marks', () => {
|
||||
ttest('toggle on a public activity, idempotent both ways', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'done-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'done-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'Gjort-test', tags: [],
|
||||
});
|
||||
|
||||
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(after.done_count).toBe(1);
|
||||
expect(after.viewer_done).toBe(true);
|
||||
|
||||
// Re-mark is idempotent (INSERT OR IGNORE).
|
||||
const again = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(again.done_count).toBe(1);
|
||||
|
||||
// Owner's own view shows the count too but their viewer_done is still false.
|
||||
const list = await listActivities(ctx, owner.cookie);
|
||||
const ownerView = list.find((a) => a.id === pub.id);
|
||||
expect(ownerView?.done_count).toBe(1);
|
||||
expect(ownerView?.viewer_done).toBe(false);
|
||||
|
||||
// Undo.
|
||||
const undone = await reqJson<Activity>(ctx, 'DELETE', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(undone.done_count).toBe(0);
|
||||
expect(undone.viewer_done).toBe(false);
|
||||
});
|
||||
|
||||
ttest('owner can mark their own private activity done; non-owners get 404', async () => {
|
||||
const [owner, other] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'priv-done-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'priv-done-other@test.invalid'),
|
||||
]);
|
||||
// Private rows need a ciphertext/nonce payload — we don't actually
|
||||
// decrypt in this test, just need the row to exist.
|
||||
const priv = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'private',
|
||||
ciphertext: 'AAAA',
|
||||
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 24 bytes base64
|
||||
} as never);
|
||||
|
||||
// Owner can mark it done.
|
||||
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${priv.id}/done`, {
|
||||
cookie: owner.cookie,
|
||||
});
|
||||
expect(after.done_count).toBe(1);
|
||||
expect(after.viewer_done).toBe(true);
|
||||
|
||||
// Non-owner gets 404 (same as GET /:id behaviour — doesn't leak existence).
|
||||
const denied = await req(ctx, 'POST', `/api/activities/${priv.id}/done`, {
|
||||
cookie: other.cookie,
|
||||
});
|
||||
expect(denied.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
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