diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index ab293bbb..b927727d 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -766,6 +766,131 @@ router.post("/notes/:id/read", requireAuth, async (req, res): Promise => { 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 => { + 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([ + 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 => { const userId = req.session.userId!; const id = parseIdParam(req.params.id); diff --git a/artifacts/api-server/tests/notes-checklist-toggle.test.mjs b/artifacts/api-server/tests/notes-checklist-toggle.test.mjs new file mode 100644 index 00000000..92e9f81e --- /dev/null +++ b/artifacts/api-server/tests/notes-checklist-toggle.test.mjs @@ -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); +}); diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx index ea1ef87f..b8e993c8 100644 --- a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -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", }} >
-
)} {isChecklistNote ? ( - + ) : ( bodyContent && (
diff --git a/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx b/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx index a47de66a..7193fd54 100644 --- a/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx +++ b/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx @@ -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( @@ -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 ( diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 8975f3b1..1f38020a 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -34,9 +34,12 @@ export function useNotificationsSocket() { const { user } = useAuth(); const queryClient = useQueryClient(); const connectedAtRef = useRef(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(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()); diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 9a55460b..5d37aa8a 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -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({ + queryKey: ["notes"], + })) { + if (!Array.isArray(data)) continue; + snapshots.push([key, data]); + qc.setQueryData( + 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({ + queryKey: sentNotesKey, + })) { + if (!Array.isArray(data)) continue; + snapshots.push([key, data]); + qc.setQueryData( + 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({ + queryKey: ["notes", "received"], + })) { + if (!Array.isArray(data)) continue; + snapshots.push([key, data]); + qc.setQueryData( + 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(threadKey); + if (threadData) { + snapshots.push([threadKey, threadData]); + qc.setQueryData(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({ diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index 93d058aa..8bb19df6 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -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(null); const markedRef = useRef(false); @@ -1881,6 +1884,24 @@ function ThreadDialog({ + toggleChecklistItem.mutate({ + id: thread.id, + itemId, + done, + }) + : undefined + } /> ) : ( thread.content && ( diff --git a/artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs b/artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs new file mode 100644 index 00000000..60eebaaa --- /dev/null +++ b/artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs @@ -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-`. + 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(); + } +});