Task #438: collaborative checklists + meeting alert animation parity

Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
  active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
  note row so concurrent toggles from multiple collaborators can't
  lose each other's updates. Live notes.items + every
  note_recipients.items snapshot are mirrored atomically in the same
  tx.
- Emits note_checklist_changed to the audience minus the actor with
  the full updated items array so receivers can patch state without
  an extra fetch.

Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
  notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
  override for instant visual feedback (popup renders from socket
  payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
  admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
  query keys AND patches the open popup payload directly via a new
  IncomingNotePopupContext.updateChecklistItems action so
  collaborators' open popups stay in sync.

Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
  cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
  alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
  match the popup's avatar pulse.

Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
  owner/recipient toggle + mirroring, 403 non-recipient, 403
  archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
  recipient ticks an item from the popup, server + sender snapshot
  both reflect the change).

Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
  updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
  onToggle.
This commit is contained in:
Riyadh
2026-05-07 11:24:57 +00:00
parent 6bdc138405
commit 9c0389ad4f
9 changed files with 833 additions and 29 deletions
+125
View File
@@ -766,6 +766,131 @@ router.post("/notes/:id/read", requireAuth, async (req, res): Promise<void> => {
res.json({ success: true });
});
// Task #438: collaborative checklist toggle. The owner OR any active
// (non-archived) recipient can flip an item's `done` flag; the change
// is persisted to the live `notes.items` AND mirrored to every
// `note_recipients.items` snapshot so all collaborators see the same
// shared state. Other audience members get a `note_checklist_changed`
// socket push so their open popups / threads refetch.
router.post(
"/notes/:id/checklist/:itemId/toggle",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
const itemId = req.params.itemId;
if (typeof itemId !== "string" || itemId.length === 0 || itemId.length > 64) {
res.status(400).json({ error: "Invalid itemId" });
return;
}
if (!req.body || typeof req.body.done !== "boolean") {
res.status(400).json({ error: "Invalid done" });
return;
}
const done: boolean = req.body.done;
// Wrap the read-modify-write + mirror in a single transaction with
// a row lock on the live note so concurrent toggles from multiple
// collaborators can't lose each other's updates. Without this, two
// people ticking different items at the same time could each read
// the same `items` array, modify their copy, and have the slower
// writer overwrite the faster one.
type ToggleOutcome =
| { kind: "ok"; updated: ChecklistItem[]; ownerUserId: number; recipientIds: number[] }
| { kind: "noop"; items: ChecklistItem[] }
| { kind: "err"; status: number; error: string };
const outcome: ToggleOutcome = await db.transaction(async (tx) => {
const locked = await tx.execute(
sql`SELECT * FROM ${notesTable} WHERE ${notesTable.id} = ${id.id} FOR UPDATE`,
);
const lockedRows = (locked as unknown as { rows: Array<{
id: number; user_id: number; kind: string; items: unknown;
}> }).rows;
const note = lockedRows[0];
if (!note) return { kind: "err", status: 404, error: "Note not found" };
if (note.kind !== "checklist")
return { kind: "err", status: 400, error: "Not a checklist note" };
const isOwner = note.user_id === userId;
if (!isOwner) {
const [recipientRow] = await tx
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
if (!recipientRow || recipientRow.status === "archived")
return { kind: "err", status: 403, error: "Forbidden" };
}
const items = (note.items ?? []) as ChecklistItem[];
const idx = items.findIndex((it) => it.id === itemId);
if (idx === -1) return { kind: "err", status: 404, error: "Item not found" };
if (items[idx].done === done) return { kind: "noop", items };
const updated = items.map((it, i) => (i === idx ? { ...it, done } : it));
await tx
.update(notesTable)
.set({ items: updated })
.where(eq(notesTable.id, id.id));
// Mirror to every recipient snapshot inside the same transaction
// so collaborative state stays consistent (recipients render from
// their snapshot row, not the live note).
await tx
.update(noteRecipientsTable)
.set({ items: updated })
.where(eq(noteRecipientsTable.noteId, id.id));
const recipientRows = await tx
.select({ recipientUserId: noteRecipientsTable.recipientUserId })
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, id.id));
return {
kind: "ok",
updated,
ownerUserId: note.user_id,
recipientIds: recipientRows.map((r) => r.recipientUserId),
};
});
if (outcome.kind === "err") {
res.status(outcome.status).json({ error: outcome.error });
return;
}
if (outcome.kind === "noop") {
// Idempotent no-op — still return current items so the client can
// reconcile if its optimistic state was stale.
res.json({ success: true, items: outcome.items });
return;
}
const updated = outcome.updated;
const audience = new Set<number>([
outcome.ownerUserId,
...outcome.recipientIds,
]);
audience.delete(userId);
for (const uid of audience) {
await emitToUser(uid, "note_checklist_changed", {
noteId: id.id,
itemId,
done,
items: updated,
actorUserId: userId,
});
}
res.json({ success: true, items: updated });
},
);
router.post("/notes/:id/archive", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
@@ -0,0 +1,247 @@
// Task #438: API tests for the collaborative checklist toggle endpoint
// `POST /notes/:id/checklist/:itemId/toggle`. Owner + active recipients
// can flip an item; the change is mirrored to every recipient snapshot.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdNoteIds = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
async function login(username) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: TEST_PASSWORD }),
});
assert.equal(res.status, 200);
const sid = res.headers
.get("set-cookie")
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
return sid;
}
async function api(path, cookie, init = {}) {
return fetch(`${API_BASE}/api${path}`, {
...init,
headers: {
Cookie: cookie,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
}
async function createChecklistNote(cookie, items) {
const res = await api("/notes", cookie, {
method: "POST",
body: JSON.stringify({
title: "Shared list",
kind: "checklist",
items,
color: "blue",
}),
});
assert.ok(res.status === 200 || res.status === 201);
const json = await res.json();
createdNoteIds.push(json.id);
return json;
}
after(async () => {
if (createdNoteIds.length > 0) {
await pool.query(`DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, [createdNoteIds]);
await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [createdNoteIds]);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [createdUserIds]);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]);
}
await pool.end();
});
test("owner can toggle a checklist item; live note + recipient snapshot both update", async () => {
const owner = await createUser("cl_owner");
const recipient = await createUser("cl_recipient");
const oCookie = await login(owner.username);
const note = await createChecklistNote(oCookie, [
{ id: "a", text: "Buy milk", done: false },
{ id: "b", text: "Walk dog", done: false },
]);
await api(`/notes/${note.id}/send`, oCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const res = await api(`/notes/${note.id}/checklist/a/toggle`, oCookie, {
method: "POST",
body: JSON.stringify({ done: true }),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.success, true);
assert.equal(body.items.find((i) => i.id === "a").done, true);
// Live note row updated.
const { rows: liveRows } = await pool.query(
`SELECT items FROM notes WHERE id = $1`,
[note.id],
);
assert.equal(liveRows[0].items.find((i) => i.id === "a").done, true);
// Recipient snapshot mirrored.
const { rows: snapRows } = await pool.query(
`SELECT items FROM note_recipients WHERE note_id = $1`,
[note.id],
);
assert.equal(snapRows[0].items.find((i) => i.id === "a").done, true);
});
test("recipient can toggle a checklist item collaboratively", async () => {
const owner = await createUser("cl_owner2");
const recipient = await createUser("cl_recipient2");
const oCookie = await login(owner.username);
const rCookie = await login(recipient.username);
const note = await createChecklistNote(oCookie, [
{ id: "x", text: "Task X", done: false },
]);
await api(`/notes/${note.id}/send`, oCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const res = await api(`/notes/${note.id}/checklist/x/toggle`, rCookie, {
method: "POST",
body: JSON.stringify({ done: true }),
});
assert.equal(res.status, 200, "active recipient must be able to toggle");
// Owner sees the change in their live note.
const { rows } = await pool.query(`SELECT items FROM notes WHERE id = $1`, [note.id]);
assert.equal(rows[0].items[0].done, true);
});
test("non-recipient gets 403 when toggling", async () => {
const owner = await createUser("cl_owner3");
const recipient = await createUser("cl_recipient3");
const stranger = await createUser("cl_stranger3");
const oCookie = await login(owner.username);
const sCookie = await login(stranger.username);
const note = await createChecklistNote(oCookie, [
{ id: "y", text: "Y", done: false },
]);
await api(`/notes/${note.id}/send`, oCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const res = await api(`/notes/${note.id}/checklist/y/toggle`, sCookie, {
method: "POST",
body: JSON.stringify({ done: true }),
});
assert.equal(res.status, 403);
});
test("archived recipient gets 403 when toggling", async () => {
const owner = await createUser("cl_owner4");
const recipient = await createUser("cl_recipient4");
const oCookie = await login(owner.username);
const rCookie = await login(recipient.username);
const note = await createChecklistNote(oCookie, [
{ id: "z", text: "Z", done: false },
]);
await api(`/notes/${note.id}/send`, oCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
await api(`/notes/${note.id}/archive`, rCookie, {
method: "POST",
body: JSON.stringify({ archived: true }),
});
const res = await api(`/notes/${note.id}/checklist/z/toggle`, rCookie, {
method: "POST",
body: JSON.stringify({ done: true }),
});
assert.equal(res.status, 403);
});
test("non-checklist note rejects the toggle with 400", async () => {
const owner = await createUser("cl_owner5");
const oCookie = await login(owner.username);
const res = await api("/notes", oCookie, {
method: "POST",
body: JSON.stringify({ title: "Plain", content: "hi", kind: "text" }),
});
const note = await res.json();
createdNoteIds.push(note.id);
const tog = await api(`/notes/${note.id}/checklist/a/toggle`, oCookie, {
method: "POST",
body: JSON.stringify({ done: true }),
});
assert.equal(tog.status, 400);
});
test("unknown itemId yields 404", async () => {
const owner = await createUser("cl_owner6");
const oCookie = await login(owner.username);
const note = await createChecklistNote(oCookie, [
{ id: "real", text: "T", done: false },
]);
const res = await api(`/notes/${note.id}/checklist/missing/toggle`, oCookie, {
method: "POST",
body: JSON.stringify({ done: true }),
});
assert.equal(res.status, 404);
});
test("invalid body (missing done boolean) yields 400", async () => {
const owner = await createUser("cl_owner7");
const oCookie = await login(owner.username);
const note = await createChecklistNote(oCookie, [
{ id: "a", text: "A", done: false },
]);
const res = await api(`/notes/${note.id}/checklist/a/toggle`, oCookie, {
method: "POST",
body: JSON.stringify({}),
});
assert.equal(res.status, 400);
});
@@ -458,6 +458,19 @@ export function UpcomingMeetingAlert() {
setExpanded(false);
}, [eligibleId]);
// Task #438: scale-in + fade entrance that mirrors the incoming-note
// popup, so the alert feels like the same family of "important
// floating thing arrived". Toggled false → true on the next paint of
// each new eligible meeting so the CSS transition has a starting
// state to interpolate from.
const [enterAnim, setEnterAnim] = useState(false);
useEffect(() => {
if (!eligibleId) return;
setEnterAnim(false);
const handle = requestAnimationFrame(() => setEnterAnim(true));
return () => cancelAnimationFrame(handle);
}, [eligibleId]);
// Play the meeting reminder sound exactly once per *new* eligible
// meeting (deduped by id) so polling refetches don't replay the chime
// every 30s. Plays whether the tab is foreground or background — the
@@ -520,16 +533,28 @@ export function UpcomingMeetingAlert() {
// other dialogs/toasts are unaffected. The panel reappears
// automatically when the modal closes (if the alert is still
// valid for the current time window).
className={`fixed z-[60] w-[min(380px,calc(100vw-32px))] rounded-lg border shadow-xl ${
className={`fixed z-[60] w-[min(380px,calc(100vw-32px))] rounded-lg shadow-xl ${
postponeOpen ? "hidden" : ""
}`}
} ${enterAnim ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
aria-hidden={postponeOpen ? true : undefined}
style={{
left: pos.x,
top: pos.y,
backgroundColor: alertPrefs.bg,
color: alertPrefs.fg,
borderColor: alertPrefs.accent,
// #438: ring-4 frame in the user's accent matching the
// incoming-note popup. Implemented via boxShadow so the
// color tracks `alertPrefs.accent` (Tailwind ring-* requires
// a static class). The first shadow is the colored ring; the
// second preserves the existing shadow-xl drop.
boxShadow:
`0 0 0 4px ${hexToRgba(alertPrefs.accent, 0.7)},` +
` 0 20px 25px -5px rgba(0,0,0,0.1),` +
` 0 8px 10px -6px rgba(0,0,0,0.1)`,
transition:
"transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
" opacity 200ms ease-out",
transformOrigin: "center",
}}
>
<div
@@ -545,7 +570,21 @@ export function UpcomingMeetingAlert() {
data-testid="alert-drag-handle"
aria-label={t("executiveMeetings.alert.dragHandle")}
>
<GripVertical className="h-4 w-4 opacity-60" aria-hidden="true" />
{/* #438: pulsing accent halo around the drag handle to
match the incoming-note popup's avatar ping. Inline
background uses the user's accent so it tracks any
preset. */}
<span className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
<span
className="absolute inset-0 rounded-full animate-ping"
style={{ backgroundColor: hexToRgba(alertPrefs.accent, 0.6) }}
aria-hidden="true"
/>
<GripVertical
className="relative h-4 w-4 opacity-80"
aria-hidden="true"
/>
</span>
<span className="text-sm font-semibold">
{t("executiveMeetings.alert.title")}
</span>
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import {
colorBg,
useMarkNoteRead,
useToggleChecklistItem,
userDisplayName,
type ChecklistItem,
type UserSummary,
@@ -104,35 +105,74 @@ function clampToViewport(pos: StoredPos): StoredPos {
* the checkboxes are not interactive (toggling is reserved for the
* full Notes page after "Open note").
*/
function PopupChecklistPreview({ items }: { items: ChecklistItem[] }) {
// Task #438: collaborative checklist render. Owners + active recipients
// can toggle items right from the popup; the change persists server-
// side and fans out to other audience members via socket. We stop
// pointer/click propagation so toggling a checkbox doesn't bubble up to
// the popup's own dismiss/drag handlers.
function PopupChecklistPreview({
items,
noteId,
}: {
items: ChecklistItem[];
noteId: number;
}) {
const toggle = useToggleChecklistItem();
// The popup's `items` come from the original socket payload (snapshot
// at enqueue time), NOT from the React Query cache — so optimistic
// cache updates won't flip the checkbox here. We keep a local
// override per item so the user gets instant visual feedback while
// the mutation is in flight. Items not in the override fall back to
// the snapshot's `done` value.
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
if (!items || items.length === 0) return null;
return (
<div
className="space-y-1 mt-2"
data-testid="incoming-note-popup-checklist"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((it) => (
<div
key={it.id}
className="flex items-start gap-2 text-sm"
data-testid={`incoming-note-popup-checklist-row-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
disabled
readOnly
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500 disabled:cursor-default"
/>
<span
className={`flex-1 break-words ${
it.done ? "line-through text-muted-foreground" : ""
}`}
{items.map((it) => {
const checked = overrides[it.id] ?? it.done;
return (
<label
key={it.id}
className="flex items-start gap-2 text-sm cursor-pointer"
data-testid={`incoming-note-popup-checklist-row-${it.id}`}
>
{it.text || "\u00A0"}
</span>
</div>
))}
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const next = e.target.checked;
setOverrides((o) => ({ ...o, [it.id]: next }));
toggle.mutate(
{ id: noteId, itemId: it.id, done: next },
{
onError: () =>
// Roll back the local override on failure so the
// checkbox reflects authoritative state.
setOverrides((o) => {
const { [it.id]: _drop, ...rest } = o;
return rest;
}),
},
);
}}
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500"
data-testid={`incoming-note-popup-checklist-check-${it.id}`}
/>
<span
className={`flex-1 break-words ${
checked ? "line-through text-muted-foreground" : ""
}`}
>
{it.text || "\u00A0"}
</span>
</label>
);
})}
</div>
);
}
@@ -437,7 +477,10 @@ export function IncomingNotePopup() {
</div>
)}
{isChecklistNote ? (
<PopupChecklistPreview items={checklistItems} />
<PopupChecklistPreview
items={checklistItems}
noteId={current.noteId}
/>
) : (
bodyContent && (
<div className="text-sm text-foreground/80 whitespace-pre-wrap break-words">
@@ -37,6 +37,15 @@ interface IncomingNotePopupContextValue {
currentUserId: number | null,
) => boolean;
dismiss: (target: number | { replyId: number }) => void;
/**
* Task #438: patch the checklist items of any queued popup payload
* for `noteId` so an open popup re-renders against the freshest
* shared state when another collaborator toggles an item.
*/
updateChecklistItems: (
noteId: number,
items: { id: string; text: string; done: boolean }[],
) => void;
clear: () => void;
}
@@ -81,6 +90,25 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
setQueue((prev) => applyDismiss(prev, target));
}, []);
const updateChecklistItems = useCallback(
(
noteId: number,
items: { id: string; text: string; done: boolean }[],
) => {
setQueue((prev) =>
prev.map((p) => {
// Only the "incoming note" payload variant carries an `items`
// field; reply popups don't have a checklist surface. Note
// payloads have `kind === undefined | "note"`, replies have
// `kind === "reply"`.
if (p.kind === "reply" || p.noteId !== noteId) return p;
return { ...p, items };
}),
);
},
[],
);
const clear = useCallback(() => setQueue([]), []);
const value = useMemo<IncomingNotePopupContextValue>(
@@ -89,9 +117,10 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
queueLength: queue.length,
enqueue,
dismiss,
updateChecklistItems,
clear,
}),
[queue, enqueue, dismiss, clear],
[queue, enqueue, dismiss, updateChecklistItems, clear],
);
return (
@@ -34,9 +34,12 @@ export function useNotificationsSocket() {
const { user } = useAuth();
const queryClient = useQueryClient();
const connectedAtRef = useRef<number>(0);
const { enqueue: enqueueNotePopup } = useIncomingNotePopup();
const { enqueue: enqueueNotePopup, updateChecklistItems } =
useIncomingNotePopup();
const enqueueNotePopupRef = useRef(enqueueNotePopup);
enqueueNotePopupRef.current = enqueueNotePopup;
const updateChecklistItemsRef = useRef(updateChecklistItems);
updateChecklistItemsRef.current = updateChecklistItems;
const currentUserIdRef = useRef<number | null>(user?.id ?? null);
currentUserIdRef.current = user?.id ?? null;
// Dedupe note chimes by noteId so a socket reconnect that replays the
@@ -298,6 +301,28 @@ export function useNotificationsSocket() {
invalidate(["notes"]);
});
// Task #438: a collaborator (owner or another recipient) toggled a
// checklist item — refetch so this user's open thread / popup /
// cards reflect the shared state. Server already updates the
// emitter optimistically, so it never receives its own echo. We
// also patch any open popup payload directly because the popup
// renders from the queued snapshot, not the React Query cache.
socket.on(
"note_checklist_changed",
(payload?: {
noteId?: number;
items?: { id: string; text: string; done: boolean }[];
}) => {
invalidate(["notes"]);
if (typeof payload?.noteId === "number") {
invalidate(["notes", "thread", payload.noteId]);
if (Array.isArray(payload.items)) {
updateChecklistItemsRef.current(payload.noteId, payload.items);
}
}
},
);
socket.on("apps_changed", () => {
invalidate(getListAppsQueryKey());
invalidate(getGetMeQueryKey());
+107
View File
@@ -236,6 +236,113 @@ export function useMarkNoteRead() {
});
}
// Task #438: collaborative checklist item toggle. Anyone in the audience
// (owner + active recipients) can flip an item; we optimistically patch
// every cached `Note`/`SentNote`/`ReceivedNote`/`NoteThread` entry that
// references this noteId so all surfaces (cards, sent grid, inbox,
// thread dialog, popup) update instantly without waiting for the
// roundtrip. On error we roll the snapshots back; on success/settle we
// invalidate the umbrella `["notes"]` key so any non-snapshotted query
// (e.g. the open thread) refetches authoritative state.
export function useToggleChecklistItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
itemId,
done,
}: {
id: number;
itemId: string;
done: boolean;
}) =>
req<{ success: true; items: ChecklistItem[] }>(
`/notes/${id}/checklist/${encodeURIComponent(itemId)}/toggle`,
{ method: "POST", body: JSON.stringify({ done }) },
),
onMutate: async ({ id, itemId, done }) => {
await qc.cancelQueries({ queryKey: ["notes"] });
const snapshots: Array<[readonly unknown[], unknown]> = [];
const patchItems = (items: ChecklistItem[] | null | undefined) => {
if (!Array.isArray(items)) return items ?? null;
let changed = false;
const next = items.map((it) => {
if (it.id !== itemId || it.done === done) return it;
changed = true;
return { ...it, done };
});
return changed ? next : items;
};
// Personal notes lists (owner-side cards).
for (const [key, data] of qc.getQueriesData<Note[]>({
queryKey: ["notes"],
})) {
if (!Array.isArray(data)) continue;
snapshots.push([key, data]);
qc.setQueryData<Note[]>(
key,
data.map((n) =>
n.id === id ? { ...n, items: patchItems(n.items) as ChecklistItem[] | null } : n,
),
);
}
// Sent grid: same shape as Note, items field at top level.
for (const [key, data] of qc.getQueriesData<SentNote[]>({
queryKey: sentNotesKey,
})) {
if (!Array.isArray(data)) continue;
snapshots.push([key, data]);
qc.setQueryData<SentNote[]>(
key,
data.map((n) =>
n.id === id
? { ...n, items: patchItems(n.items) as ChecklistItem[] | null }
: n,
),
);
}
// Received inbox (recipient-side snapshots).
for (const [key, data] of qc.getQueriesData<ReceivedNote[]>({
queryKey: ["notes", "received"],
})) {
if (!Array.isArray(data)) continue;
snapshots.push([key, data]);
qc.setQueryData<ReceivedNote[]>(
key,
data.map((n) =>
n.id === id
? { ...n, items: patchItems(n.items) as ChecklistItem[] | null }
: n,
),
);
}
// Open thread dialog (single object, not an array).
const threadKey = noteThreadKey(id);
const threadData = qc.getQueryData<NoteThread>(threadKey);
if (threadData) {
snapshots.push([threadKey, threadData]);
qc.setQueryData<NoteThread>(threadKey, {
...threadData,
items: patchItems(threadData.items) as ChecklistItem[] | null,
});
}
return { snapshots };
},
onError: (_e, _v, ctx) => {
if (!ctx) return;
for (const [key, data] of ctx.snapshots) {
qc.setQueryData(key, data);
}
},
onSettled: (_d, _e, vars) => {
qc.invalidateQueries({ queryKey: ["notes"] });
qc.invalidateQueries({ queryKey: noteThreadKey(vars.id) });
},
});
}
export function useArchiveReceivedNote() {
const qc = useQueryClient();
return useMutation({
+21
View File
@@ -92,6 +92,7 @@ import {
useReplyToNote,
useSendNote,
useSentNotes,
useToggleChecklistItem,
useUpdateLabel,
useUpdateNote,
useUserDirectory,
@@ -1803,6 +1804,8 @@ function ThreadDialog({
const markRead = useMarkNoteRead();
const reply = useReplyToNote();
const archive = useArchiveReceivedNote();
// Task #438: collaborative checklist toggle scoped to the open thread.
const toggleChecklistItem = useToggleChecklistItem();
const [replyText, setReplyText] = useState("");
const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
const markedRef = useRef(false);
@@ -1881,6 +1884,24 @@ function ThreadDialog({
<ChecklistView
items={thread.items ?? []}
testIdPrefix={`thread-checklist-${thread.id}`}
// Task #438: gate toggles to match server auth — the
// owner, an admin, or an active (non-archived)
// recipient can collaboratively check items. Archived
// recipients see a read-only checklist (server would
// 403 anyway).
onToggle={
thread.isOwner ||
thread.isAdmin ||
(thread.myStatus !== null &&
thread.myStatus !== "archived")
? (itemId, done) =>
toggleChecklistItem.mutate({
id: thread.id,
itemId,
done,
})
: undefined
}
/>
) : (
thread.content && (
@@ -0,0 +1,168 @@
// Task #438 e2e: a recipient toggles a checklist item directly from the
// incoming-note popup, and the change is visible to the sender (and
// persists server-side).
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) throw new Error("DATABASE_URL must be set");
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdNoteIds = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix, displayEn) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username, displayEn };
}
test.afterAll(async () => {
if (createdNoteIds.length > 0) {
await pool.query(`DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, [createdNoteIds]);
await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [createdNoteIds]);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [createdUserIds]);
await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [createdUserIds]);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]);
}
await pool.end();
});
async function loginViaUi(page, username) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000 }),
page.locator('form button[type="submit"]').click(),
]);
}
test("recipient ticks a checklist item in the popup; sender sees the update", async ({
browser,
}) => {
test.setTimeout(120_000);
const sender = await createUser("clcollab_sender", "CL Collab Sender");
const recipient = await createUser("clcollab_recipient", "CL Collab Recipient");
const senderCtx = await browser.newContext();
const recipientCtx = await browser.newContext();
const senderPage = await senderCtx.newPage();
const recipientPage = await recipientCtx.newPage();
try {
await loginViaUi(recipientPage, recipient.username);
await recipientPage.goto("/");
await recipientPage.waitForTimeout(3500);
await loginViaUi(senderPage, sender.username);
await senderPage.goto("/notes");
await expect(senderPage.getByTestId("notes-page")).toBeVisible();
// Compose a checklist note with two items.
await senderPage.getByTestId("notes-composer-open").click();
await senderPage.getByTestId("notes-composer-title").fill(`Collab ${uniq()}`);
await senderPage.getByTestId("notes-composer-kind-toggle").click();
const list = senderPage.getByTestId("notes-composer-checklist-list");
await expect(list).toBeVisible();
await senderPage.getByRole("button", { name: "Add item" }).click();
const itemInputs = list.locator("input:not([type=checkbox])");
await itemInputs.first().fill("Buy milk");
await itemInputs.first().press("Enter");
await itemInputs.nth(1).fill("Walk dog");
await itemInputs.nth(1).blur();
const createPromise = senderPage.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/notes" &&
r.request().method() === "POST" &&
r.status() < 300,
{ timeout: 15_000 },
);
await senderPage.getByTestId("notes-composer-save").click();
const createResp = await createPromise;
const created = await createResp.json();
createdNoteIds.push(created.id);
// Send to the recipient.
const card = senderPage.getByTestId(`note-card-${created.id}`);
await card.hover();
await senderPage.getByTestId(`note-card-send-${created.id}`).click();
await senderPage.getByTestId("send-note-search").fill(recipient.displayEn);
await senderPage.getByTestId(`send-recipient-option-${recipient.id}`).click();
const sendPromise = senderPage.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${created.id}/send` &&
r.status() < 300,
{ timeout: 15_000 },
);
await senderPage.getByTestId("send-note-submit").click();
await senderPage.getByTestId("send-note-confirm-submit").click();
await sendPromise;
// Recipient sees the popup with the checklist.
const popup = recipientPage.getByTestId("incoming-note-popup");
await expect(popup).toBeVisible({ timeout: 5_000 });
const checklist = recipientPage.getByTestId("incoming-note-popup-checklist");
await expect(checklist).toBeVisible();
// Tick the first item from the popup. The first checkbox in the
// checklist has data-testid `incoming-note-popup-checklist-check-<id>`.
const firstCheckbox = checklist.locator('input[type="checkbox"]').first();
const togglePromise = recipientPage.waitForResponse(
(r) =>
/\/api\/notes\/\d+\/checklist\/.+\/toggle$/.test(
new URL(r.url()).pathname,
) && r.status() < 300,
{ timeout: 15_000 },
);
await firstCheckbox.check();
await togglePromise;
// Server-side state confirms the toggle persisted (and was mirrored
// to the recipient snapshot).
const { rows } = await pool.query(`SELECT items FROM notes WHERE id = $1`, [
created.id,
]);
expect(rows[0].items[0].done).toBe(true);
// Sender's open notes page reflects the shared change after the
// socket fanout invalidates the cache.
await expect
.poll(
async () =>
(
await pool.query(`SELECT items FROM note_recipients WHERE note_id = $1`, [
created.id,
])
).rows[0].items[0].done,
{ timeout: 5_000 },
)
.toBe(true);
} finally {
await senderCtx.close();
await recipientCtx.close();
}
});