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:
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user