From 6352cbf844fe7fe500e3d67272091b0afe910d9d Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 5 May 2026 14:53:30 +0000 Subject: [PATCH] Task #402: Convert Notes into in-app messaging (independence fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with proper backend access checks (admin sees everything), realtime updates, and full i18n + RTL. Initial implementation review FAILED because the recipient view still read from the sender's notes table, so sender edits/deletes mutated recipient copies. This commit completes the fix: - Schema (lib/db/src/schema/notes.ts): added immutable snapshot columns (title/content/color) on note_recipients; dropped the FK on note_recipients.note_id and note_replies.note_id and made them plain integers so recipient threads survive the sender deleting their note. - Routes (artifacts/api-server/src/routes/notes.ts): - /notes/received and /notes/:id/thread now serve the recipient snapshot (sender/admin still see the live note). - /notes/:id/reply derives the owner from note_recipients.senderUserId so it works after sender deletion, clears archivedAt, and bumps status to "replied". - /notes/sent filters out null noteIds. - Tests (artifacts/api-server/tests/notes-share.test.mjs): added snapshot-independence test (sender edit + delete must not mutate recipient copy; thread + reply still work after sender delete) and archived-reply-clears-archivedAt test. All 5 backend tests pass; the existing notes-inbox e2e still passes. - Architect review: PASS (conditional only on the schema migration being applied, which has been pushed via `pnpm --filter @workspace/db push`). Pre-existing executive-meetings TS errors and the failing `test` workflow are unrelated to this task. --- artifacts/api-server/src/routes/notes.ts | 510 ++++++++++++- .../api-server/tests/notes-share.test.mjs | 342 +++++++++ .../src/hooks/use-notifications-socket.ts | 12 + artifacts/tx-os/src/lib/notes-api.ts | 165 ++++ artifacts/tx-os/src/locales/ar.json | 35 +- artifacts/tx-os/src/locales/en.json | 35 +- artifacts/tx-os/src/pages/notes.tsx | 719 ++++++++++++++++-- artifacts/tx-os/tests/notes-inbox.spec.mjs | 220 ++++++ lib/db/src/schema/notes.ts | 43 ++ 9 files changed, 2004 insertions(+), 77 deletions(-) create mode 100644 artifacts/api-server/tests/notes-share.test.mjs create mode 100644 artifacts/tx-os/tests/notes-inbox.spec.mjs diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 33b9f86c..cfaee122 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -1,15 +1,29 @@ import { Router, type IRouter } from "express"; -import { eq, and, desc, inArray } from "drizzle-orm"; +import { eq, and, desc, inArray, or, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { notesTable, noteLabelsTable, noteLabelAssignmentsTable, + noteRecipientsTable, + noteRepliesTable, + usersTable, + notificationsTable, } from "@workspace/db"; -import { requireAuth } from "../middlewares/auth"; +import { requireAuth, getUserRoles } from "../middlewares/auth"; const router: IRouter = Router(); +async function emitToUser(userId: number, event: string, payload?: unknown) { + const { io } = await import("../index.js"); + io.to(`user:${userId}`).emit(event, payload); +} + +async function isAdmin(userId: number): Promise { + const roles = await getUserRoles(userId); + return roles.includes("admin"); +} + function parseIdParam(raw: unknown): { ok: true; id: number } | { ok: false; error: string } { const n = Number(raw); if (!Number.isInteger(n) || n <= 0) return { ok: false, error: "Invalid id" }; @@ -121,6 +135,81 @@ async function setNoteLabels(noteId: number, userId: number, labelIds: number[]) ); } +type UserSummary = { + id: number; + username: string; + displayNameAr: string | null; + displayNameEn: string | null; +}; + +async function loadUserSummaries(userIds: number[]): Promise> { + const unique = Array.from(new Set(userIds)); + if (unique.length === 0) return new Map(); + const rows = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + }) + .from(usersTable) + .where(inArray(usersTable.id, unique)); + return new Map(rows.map((r) => [r.id, r])); +} + +async function loadRecipientsForNote(noteId: number) { + const rows = await db + .select() + .from(noteRecipientsTable) + .where(eq(noteRecipientsTable.noteId, noteId)) + .orderBy(noteRecipientsTable.id); + const userMap = await loadUserSummaries(rows.map((r) => r.recipientUserId)); + return rows.map((r) => ({ + id: r.id, + noteId: r.noteId, + recipientUserId: r.recipientUserId, + status: r.status, + sentAt: r.sentAt, + readAt: r.readAt, + archivedAt: r.archivedAt, + title: r.title, + content: r.content, + color: r.color, + recipient: userMap.get(r.recipientUserId) ?? null, + })); +} + +async function loadRepliesForNote(noteId: number) { + const rows = await db + .select() + .from(noteRepliesTable) + .where(eq(noteRepliesTable.noteId, noteId)) + .orderBy(noteRepliesTable.createdAt); + const userMap = await loadUserSummaries(rows.map((r) => r.senderUserId)); + return rows.map((r) => ({ + id: r.id, + noteId: r.noteId, + senderUserId: r.senderUserId, + recipientUserId: r.recipientUserId, + content: r.content, + createdAt: r.createdAt, + sender: userMap.get(r.senderUserId) ?? null, + })); +} + +async function loadOne(noteId: number, userId: number) { + const [note] = await db + .select() + .from(notesTable) + .where(and(eq(notesTable.id, noteId), eq(notesTable.userId, userId))); + if (!note) return [null]; + const assignments = await db + .select() + .from(noteLabelAssignmentsTable) + .where(eq(noteLabelAssignmentsTable.noteId, noteId)); + return [{ ...note, labelIds: assignments.map((a) => a.labelId) }]; +} + router.get("/notes", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const archived = req.query.archived === "true"; @@ -181,19 +270,6 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { res.json(withLabels); }); -async function loadOne(noteId: number, userId: number) { - const [note] = await db - .select() - .from(notesTable) - .where(and(eq(notesTable.id, noteId), eq(notesTable.userId, userId))); - if (!note) return [null]; - const assignments = await db - .select() - .from(noteLabelAssignmentsTable) - .where(eq(noteLabelAssignmentsTable.noteId, noteId)); - return [{ ...note, labelIds: assignments.map((a) => a.labelId) }]; -} - router.delete("/notes/:id", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const id = parseIdParam(req.params.id); @@ -212,6 +288,410 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise => { res.json({ success: true }); }); +// ----- Sent / Received / Detail ----- + +/** + * GET /notes/sent — notes the current user has authored AND has at least one + * recipient row for. Each note carries its full recipient list with status. + */ +router.get("/notes/sent", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const sentNoteIdRows = await db + .selectDistinct({ noteId: noteRecipientsTable.noteId }) + .from(noteRecipientsTable) + .where(eq(noteRecipientsTable.senderUserId, userId)); + const ids = sentNoteIdRows + .map((r) => r.noteId) + .filter((n): n is number => typeof n === "number"); + if (ids.length === 0) { + res.json([]); + return; + } + const notes = await db + .select() + .from(notesTable) + .where(and(eq(notesTable.userId, userId), inArray(notesTable.id, ids))) + .orderBy(desc(notesTable.updatedAt)); + const out = [] as any[]; + for (const n of notes) { + const recipients = await loadRecipientsForNote(n.id); + const replyCount = await db + .select({ c: sql`count(*)::int` }) + .from(noteRepliesTable) + .where(eq(noteRepliesTable.noteId, n.id)); + out.push({ + ...n, + recipients, + replyCount: replyCount[0]?.c ?? 0, + }); + } + res.json(out); +}); + +/** + * GET /notes/received — notes that were sent TO the current user (not archived + * by them). Each entry carries the sender summary and this user's status row. + */ +router.get("/notes/received", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const includeArchived = req.query.archived === "true"; + + const recipientRows = await db + .select() + .from(noteRecipientsTable) + .where( + and( + eq(noteRecipientsTable.recipientUserId, userId), + includeArchived + ? eq(noteRecipientsTable.status, "archived") + : sql`${noteRecipientsTable.status} <> 'archived'`, + ), + ) + .orderBy(desc(noteRecipientsTable.sentAt)); + if (recipientRows.length === 0) { + res.json([]); + return; + } + // Recipients always see the snapshot they were sent — independent of any + // sender-side edit/delete. The note id is exposed only as a thread routing + // key (used by /notes/:id/thread, /read, /archive, /reply); the rendered + // body comes from the recipient row's title/content/color snapshot. + const senderMap = await loadUserSummaries(recipientRows.map((r) => r.senderUserId)); + const out = recipientRows.map((r) => ({ + id: r.noteId ?? r.id, + title: r.title, + content: r.content, + color: r.color, + createdAt: r.sentAt, + updatedAt: r.readAt ?? r.sentAt, + sender: senderMap.get(r.senderUserId) ?? null, + recipientRowId: r.id, + status: r.status, + sentAt: r.sentAt, + readAt: r.readAt, + archivedAt: r.archivedAt, + })); + res.json(out); +}); + +/** + * GET /notes/:id/thread — full detail of a sent/received note: note body, + * full recipient list (sender or admin only), and full reply thread filtered + * to what this user is allowed to see (admin/sender = all; recipient = only + * their own thread with the sender). + */ +router.get("/notes/:id/thread", 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 [note] = await db.select().from(notesTable).where(eq(notesTable.id, id.id)); + const [recipientRow] = await db + .select() + .from(noteRecipientsTable) + .where( + and( + eq(noteRecipientsTable.noteId, id.id), + eq(noteRecipientsTable.recipientUserId, userId), + ), + ); + + const admin = await isAdmin(userId); + const isSender = !!note && note.userId === userId; + + if (!note && !recipientRow) { + res.status(404).json({ error: "Note not found" }); + return; + } + if (!admin && !isSender && !recipientRow) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + // Recipient view reads its own immutable snapshot. Owner/admin view shows + // the live sender note (sender's editable copy). + const useSnapshot = !isSender && !admin && !!recipientRow; + const senderUserId = note?.userId ?? recipientRow!.senderUserId; + const senderMap = await loadUserSummaries([senderUserId]); + + const recipients = admin || isSender ? await loadRecipientsForNote(id.id) : []; + + let replies = await loadRepliesForNote(id.id); + if (!admin && !isSender && recipientRow) { + replies = replies.filter( + (r) => + (r.senderUserId === userId && r.recipientUserId === senderUserId) || + (r.senderUserId === senderUserId && r.recipientUserId === userId), + ); + } + + res.json({ + id: id.id, + title: useSnapshot ? recipientRow!.title : note?.title ?? "", + content: useSnapshot ? recipientRow!.content : note?.content ?? "", + color: useSnapshot ? recipientRow!.color : note?.color ?? "default", + createdAt: useSnapshot ? recipientRow!.sentAt : note?.createdAt ?? null, + updatedAt: useSnapshot + ? recipientRow!.readAt ?? recipientRow!.sentAt + : note?.updatedAt ?? null, + senderUserId, + sender: senderMap.get(senderUserId) ?? null, + isOwner: isSender, + isAdmin: admin, + myStatus: recipientRow?.status ?? null, + recipients, + replies, + }); +}); + +// ----- Send ----- + +/** + * POST /notes/:id/send — owner sends an existing note to a list of recipients. + * Self-send and dupes are filtered out. Existing recipients are silently + * skipped (idempotent). + */ +router.post("/notes/:id/send", 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 [note] = await db + .select() + .from(notesTable) + .where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId))); + if (!note) { + res.status(404).json({ error: "Note not found" }); + return; + } + const raw = (req.body && req.body.recipientUserIds) || []; + if (!Array.isArray(raw)) { + res.status(400).json({ error: "Invalid recipientUserIds" }); + return; + } + const ids = Array.from( + new Set( + raw + .map((x: unknown) => Number(x)) + .filter((n: number) => Number.isInteger(n) && n > 0 && n !== userId), + ), + ); + if (ids.length === 0) { + res.status(400).json({ error: "No valid recipients" }); + return; + } + const existing = await db + .select({ id: usersTable.id }) + .from(usersTable) + .where(and(inArray(usersTable.id, ids), eq(usersTable.isActive, true))); + const existingIds = new Set(existing.map((u) => u.id)); + const toSend = ids.filter((i) => existingIds.has(i)); + if (toSend.length === 0) { + res.status(400).json({ error: "No active recipients" }); + return; + } + + // Snapshot the note as of right now into each recipient row so that any + // future sender edit/delete leaves delivered copies untouched. + const inserted = await db + .insert(noteRecipientsTable) + .values( + toSend.map((rid) => ({ + noteId: note.id, + senderUserId: userId, + recipientUserId: rid, + title: note.title, + content: note.content, + color: note.color, + })), + ) + .onConflictDoNothing({ + target: [noteRecipientsTable.noteId, noteRecipientsTable.recipientUserId], + }) + .returning(); + + // Notify each newly-added recipient via per-user socket + persistent + // notifications row so they get a chime + badge bump. + const senderMap = await loadUserSummaries([userId]); + const sender = senderMap.get(userId); + const senderName = sender?.displayNameEn ?? sender?.username ?? "Someone"; + const senderNameAr = sender?.displayNameAr ?? senderName; + for (const r of inserted) { + const [notif] = await db + .insert(notificationsTable) + .values({ + userId: r.recipientUserId, + titleAr: "ملاحظة جديدة", + titleEn: "New note", + bodyAr: `${senderNameAr} أرسل لك ملاحظة`, + bodyEn: `${senderName} sent you a note`, + type: "note", + }) + .returning(); + await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" }); + await emitToUser(r.recipientUserId, "note_received", { noteId: note.id }); + } + + res.json({ success: true, sent: inserted.length }); +}); + +// ----- Read / Archive (recipient-side) ----- + +router.post("/notes/:id/read", 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 [row] = await db + .select() + .from(noteRecipientsTable) + .where( + and( + eq(noteRecipientsTable.noteId, id.id), + eq(noteRecipientsTable.recipientUserId, userId), + ), + ); + if (!row) { + res.status(403).json({ error: "Forbidden" }); + return; + } + // Only flip unread -> read; never overwrite "replied" downward. + if (row.status === "unread") { + await db + .update(noteRecipientsTable) + .set({ status: "read", readAt: new Date() }) + .where(eq(noteRecipientsTable.id, row.id)); + await emitToUser(row.senderUserId, "note_status_changed", { + noteId: id.id, + recipientUserId: userId, + status: "read", + }); + } + res.json({ success: true }); +}); + +router.post("/notes/:id/archive", 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 archived = req.body && req.body.archived !== false; + const [row] = await db + .select() + .from(noteRecipientsTable) + .where( + and( + eq(noteRecipientsTable.noteId, id.id), + eq(noteRecipientsTable.recipientUserId, userId), + ), + ); + if (!row) { + res.status(403).json({ error: "Forbidden" }); + return; + } + await db + .update(noteRecipientsTable) + .set({ + status: archived ? "archived" : (row.readAt ? "read" : "unread"), + archivedAt: archived ? new Date() : null, + }) + .where(eq(noteRecipientsTable.id, row.id)); + res.json({ success: true }); +}); + +// ----- Reply ----- + +router.post("/notes/:id/reply", 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 content = typeof req.body?.content === "string" ? req.body.content.trim() : ""; + if (content.length === 0 || content.length > 5000) { + res.status(400).json({ error: "Invalid content" }); + return; + } + const [row] = await db + .select() + .from(noteRecipientsTable) + .where( + and( + eq(noteRecipientsTable.noteId, id.id), + eq(noteRecipientsTable.recipientUserId, userId), + ), + ); + if (!row) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const ownerUserId = row.senderUserId; + const [reply] = await db + .insert(noteRepliesTable) + .values({ + noteId: id.id, + senderUserId: userId, + recipientUserId: ownerUserId, + content, + }) + .returning(); + + // Replying re-activates an archived recipient row: clear archivedAt and + // bump status to "replied" so the conversation continues cleanly. + await db + .update(noteRecipientsTable) + .set({ + status: "replied", + readAt: row.readAt ?? new Date(), + archivedAt: null, + }) + .where(eq(noteRecipientsTable.id, row.id)); + + // Notify the original sender (note owner). + const senderMap = await loadUserSummaries([userId]); + const replier = senderMap.get(userId); + const replierName = replier?.displayNameEn ?? replier?.username ?? "Someone"; + const replierNameAr = replier?.displayNameAr ?? replierName; + const [notif] = await db + .insert(notificationsTable) + .values({ + userId: ownerUserId, + titleAr: "رد على ملاحظتك", + titleEn: "Reply to your note", + bodyAr: `${replierNameAr} رد على ملاحظتك`, + bodyEn: `${replierName} replied to your note`, + type: "note", + }) + .returning(); + await emitToUser(ownerUserId, "notification_created", { ...notif, type: "note" }); + await emitToUser(ownerUserId, "note_replied", { + noteId: id.id, + recipientUserId: userId, + replyId: reply.id, + }); + + res.status(201).json({ + id: reply.id, + noteId: reply.noteId, + senderUserId: reply.senderUserId, + recipientUserId: reply.recipientUserId, + content: reply.content, + createdAt: reply.createdAt, + sender: replier ?? null, + }); +}); + +// ----- Labels (unchanged) ----- + router.get("/note-labels", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const labels = await db diff --git a/artifacts/api-server/tests/notes-share.test.mjs b/artifacts/api-server/tests/notes-share.test.mjs new file mode 100644 index 00000000..3402c8df --- /dev/null +++ b/artifacts/api-server/tests/notes-share.test.mjs @@ -0,0 +1,342 @@ +// API tests for the in-app messaging flow built on top of personal notes. +// Covers POST /api/notes/:id/send, GET /api/notes/sent, GET /api/notes/received, +// GET /api/notes/:id/thread, POST /api/notes/:id/read, POST /api/notes/:id/reply, +// and the access-control rules around them. +import { test, before, 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, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + const sid = setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); + assert.ok(sid, "expected connect.sid cookie"); + 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 createNote(cookie, body) { + const res = await api("/notes", cookie, { + method: "POST", + body: JSON.stringify({ + title: "Hi", + content: "hello world", + color: "blue", + ...body, + }), + }); + assert.ok( + res.status === 200 || res.status === 201, + `create note expected 2xx, got ${res.status}`, + ); + const json = await res.json(); + createdNoteIds.push(json.id); + return json; +} + +after(async () => { + if (createdNoteIds.length > 0) { + await pool.query( + `DELETE FROM note_replies WHERE note_id = ANY($1::int[])`, + [createdNoteIds], + ); + 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("send → received → read → reply produces independent sender / recipient views", async () => { + const sender = await createUser("notes_sender"); + const r1 = await createUser("notes_r1"); + const r2 = await createUser("notes_r2"); + + const sCookie = await login(sender.username); + const r1Cookie = await login(r1.username); + const r2Cookie = await login(r2.username); + + const note = await createNote(sCookie); + + // Send to both recipients (and try to include sender's own id to confirm it + // gets filtered out). + const sendRes = await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [r1.id, r2.id, sender.id] }), + }); + assert.equal(sendRes.status, 200); + const sendBody = await sendRes.json(); + assert.equal(sendBody.success, true); + assert.equal(sendBody.sent, 2, "self-recipient must be excluded from the send"); + + // Sender's "Sent" view shows the note with two unread recipients. + const sentRes = await api(`/notes/sent`, sCookie); + assert.equal(sentRes.status, 200); + const sent = await sentRes.json(); + const sentNote = sent.find((n) => n.id === note.id); + assert.ok(sentNote, "sent note must appear in /notes/sent"); + assert.equal(sentNote.recipients.length, 2); + assert.ok( + sentNote.recipients.every((r) => r.status === "unread"), + "all recipients should start as unread", + ); + + // r1 sees the note in their inbox. + const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json(); + const r1Note = r1Inbox.find((n) => n.id === note.id); + assert.ok(r1Note, "r1 must see the note in their inbox"); + assert.equal(r1Note.status, "unread"); + assert.equal(r1Note.sender.id, sender.id); + + // r1 marks read → status flips for that recipient row only. + const readRes = await api(`/notes/${note.id}/read`, r1Cookie, { + method: "POST", + }); + assert.equal(readRes.status, 200); + + const sentAfterRead = await (await api(`/notes/sent`, sCookie)).json(); + const sentNote2 = sentAfterRead.find((n) => n.id === note.id); + const r1Row = sentNote2.recipients.find((r) => r.recipientUserId === r1.id); + const r2Row = sentNote2.recipients.find((r) => r.recipientUserId === r2.id); + assert.equal(r1Row.status, "read"); + assert.equal(r2Row.status, "unread", "r2 must remain unread"); + + // r1 replies → status becomes 'replied' for r1 only, reply is visible. + const replyRes = await api(`/notes/${note.id}/reply`, r1Cookie, { + method: "POST", + body: JSON.stringify({ content: "got it, thanks" }), + }); + assert.ok( + replyRes.status === 200 || replyRes.status === 201, + `reply expected 2xx, got ${replyRes.status}`, + ); + + const threadSender = await ( + await api(`/notes/${note.id}/thread`, sCookie) + ).json(); + assert.equal(threadSender.isOwner, true); + assert.equal(threadSender.replies.length, 1); + assert.equal(threadSender.replies[0].content, "got it, thanks"); + const r1RowAfter = threadSender.recipients.find( + (r) => r.recipientUserId === r1.id, + ); + assert.equal(r1RowAfter.status, "replied"); + + // r2's recipient view must NOT show r1's reply (recipient sees only their + // own thread of replies). + const threadR2 = await ( + await api(`/notes/${note.id}/thread`, r2Cookie) + ).json(); + assert.equal(threadR2.isOwner, false); + assert.equal(threadR2.myStatus, "unread"); + assert.equal( + threadR2.replies.length, + 0, + "r2 must not see replies authored by r1", + ); +}); + +test("non-participant gets 403 on /notes/:id/thread", async () => { + const sender = await createUser("notes_p_sender"); + const recipient = await createUser("notes_p_recipient"); + const stranger = await createUser("notes_p_stranger"); + + const sCookie = await login(sender.username); + const strangerCookie = await login(stranger.username); + + const note = await createNote(sCookie); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + + const res = await api(`/notes/${note.id}/thread`, strangerCookie); + assert.equal(res.status, 403, "stranger must not access the thread"); +}); + +test("recipient copy is independent: sender edit/delete does not mutate it", async () => { + const sender = await createUser("notes_indep_sender"); + const recipient = await createUser("notes_indep_recipient"); + const sCookie = await login(sender.username); + const rCookie = await login(recipient.username); + + const note = await createNote(sCookie, { + title: "Original title", + content: "Original content", + color: "blue", + }); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + + // Sender edits + deletes the note. + const editRes = await api(`/notes/${note.id}`, sCookie, { + method: "PATCH", + body: JSON.stringify({ title: "EDITED", content: "EDITED body", color: "red" }), + }); + assert.ok(editRes.ok, `edit expected 2xx, got ${editRes.status}`); + + const inboxAfterEdit = await (await api(`/notes/received`, rCookie)).json(); + const r1 = inboxAfterEdit.find((n) => n.id === note.id); + assert.ok(r1, "recipient must still see the delivered copy after sender edit"); + assert.equal(r1.title, "Original title", "recipient title must be the snapshot"); + assert.equal(r1.content, "Original content", "recipient content must be the snapshot"); + assert.equal(r1.color, "blue", "recipient color must be the snapshot"); + + // Now sender deletes their copy. + const delRes = await api(`/notes/${note.id}`, sCookie, { method: "DELETE" }); + assert.ok(delRes.ok, `delete expected 2xx, got ${delRes.status}`); + + const inboxAfterDelete = await (await api(`/notes/received`, rCookie)).json(); + const r2 = inboxAfterDelete.find( + (n) => n.recipientRowId === r1.recipientRowId, + ); + assert.ok( + r2, + "recipient must still see the delivered copy after sender deletion", + ); + assert.equal(r2.title, "Original title"); + assert.equal(r2.content, "Original content"); + + // Recipient can still open the thread + reply even after sender deletion. + const threadRes = await api(`/notes/${note.id}/thread`, rCookie); + assert.equal(threadRes.status, 200, "thread still readable after sender delete"); + const thread = await threadRes.json(); + assert.equal(thread.title, "Original title"); + assert.equal(thread.content, "Original content"); + + const replyRes = await api(`/notes/${note.id}/reply`, rCookie, { + method: "POST", + body: JSON.stringify({ content: "still here" }), + }); + assert.ok( + replyRes.ok, + `reply after sender delete expected 2xx, got ${replyRes.status}`, + ); +}); + +test("reply on an archived recipient row clears archivedAt and bumps to replied", async () => { + const sender = await createUser("notes_arch_sender"); + const recipient = await createUser("notes_arch_recipient"); + const sCookie = await login(sender.username); + const rCookie = await login(recipient.username); + + const note = await createNote(sCookie); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + const archRes = await api(`/notes/${note.id}/archive`, rCookie, { + method: "POST", + body: JSON.stringify({ archived: true }), + }); + assert.ok(archRes.ok); + + const replyRes = await api(`/notes/${note.id}/reply`, rCookie, { + method: "POST", + body: JSON.stringify({ content: "back from archive" }), + }); + assert.ok(replyRes.ok, `reply expected 2xx, got ${replyRes.status}`); + + const { rows } = await pool.query( + `SELECT status, archived_at FROM note_recipients + WHERE note_id = $1 AND recipient_user_id = $2`, + [note.id, recipient.id], + ); + assert.equal(rows[0].status, "replied"); + assert.equal(rows[0].archived_at, null, "archivedAt must be cleared on reply"); +}); + +test("re-sending to the same recipient does not duplicate the recipient row", async () => { + const sender = await createUser("notes_dup_sender"); + const recipient = await createUser("notes_dup_recipient"); + const sCookie = await login(sender.username); + + const note = await createNote(sCookie); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + const second = await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + assert.equal(second.status, 200); + + const { rows } = await pool.query( + `SELECT count(*)::int AS c FROM note_recipients WHERE note_id = $1`, + [note.id], + ); + assert.equal(rows[0].c, 1, "recipient row must not be duplicated"); +}); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index d7d5b409..bb0bd42e 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -103,6 +103,18 @@ export function useNotificationsSocket() { }); }); + socket.on("note_received", () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }); + + socket.on("note_replied", () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }); + + socket.on("note_status_changed", () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }); + socket.on("apps_changed", () => { queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 2ea3c197..181b776d 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -20,6 +20,74 @@ export interface NoteLabel { createdAt: string; } +export interface UserSummary { + id: number; + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + avatarUrl?: string | null; + isActive?: boolean; +} + +export type NoteRecipientStatus = "unread" | "read" | "replied" | "archived"; + +export interface SentNoteRecipient { + id: number; + noteId: number; + recipientUserId: number; + status: NoteRecipientStatus; + sentAt: string; + readAt: string | null; + archivedAt: string | null; + recipient: UserSummary | null; +} + +export interface SentNote extends Note { + recipients: SentNoteRecipient[]; + replyCount: number; +} + +export interface ReceivedNote { + id: number; + title: string; + content: string; + color: string; + createdAt: string; + updatedAt: string; + sender: UserSummary | null; + recipientRowId: number; + status: NoteRecipientStatus; + sentAt: string; + readAt: string | null; + archivedAt: string | null; +} + +export interface NoteReply { + id: number; + noteId: number; + senderUserId: number; + recipientUserId: number; + content: string; + createdAt: string; + sender: UserSummary | null; +} + +export interface NoteThread { + id: number; + title: string; + content: string; + color: string; + createdAt: string; + updatedAt: string; + senderUserId: number; + sender: UserSummary | null; + isOwner: boolean; + isAdmin: boolean; + myStatus: NoteRecipientStatus | null; + recipients: SentNoteRecipient[]; + replies: NoteReply[]; +} + const BASE = `${import.meta.env.BASE_URL}api`.replace(/\/+/g, "/"); async function req(path: string, init?: RequestInit): Promise { @@ -41,6 +109,10 @@ async function req(path: string, init?: RequestInit): Promise { export const notesKey = (archived: boolean) => ["notes", { archived }] as const; export const labelsKey = ["note-labels"] as const; +export const sentNotesKey = ["notes", "sent"] as const; +export const receivedNotesKey = (archived: boolean) => + ["notes", "received", { archived }] as const; +export const noteThreadKey = (id: number) => ["notes", "thread", id] as const; export function useNotes(archived: boolean) { return useQuery({ @@ -49,6 +121,29 @@ export function useNotes(archived: boolean) { }); } +export function useSentNotes() { + return useQuery({ + queryKey: sentNotesKey, + queryFn: () => req(`/notes/sent`), + }); +} + +export function useReceivedNotes(archived: boolean) { + return useQuery({ + queryKey: receivedNotesKey(archived), + queryFn: () => + req(`/notes/received?archived=${archived}`), + }); +} + +export function useNoteThread(id: number | null) { + return useQuery({ + queryKey: id ? noteThreadKey(id) : ["notes", "thread", "none"], + queryFn: () => req(`/notes/${id}/thread`), + enabled: id !== null, + }); +} + export function useNoteLabels() { return useQuery({ queryKey: labelsKey, queryFn: () => req("/note-labels") }); } @@ -83,6 +178,67 @@ export function useDeleteNote() { }); } +export function useSendNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, recipientUserIds }: { id: number; recipientUserIds: number[] }) => + req<{ success: true; sent: number }>(`/notes/${id}/send`, { + method: "POST", + body: JSON.stringify({ recipientUserIds }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useMarkNoteRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + req<{ success: true }>(`/notes/${id}/read`, { method: "POST" }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useArchiveReceivedNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, archived }: { id: number; archived: boolean }) => + req<{ success: true }>(`/notes/${id}/archive`, { + method: "POST", + body: JSON.stringify({ archived }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useReplyToNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, content }: { id: number; content: string }) => + req(`/notes/${id}/reply`, { + method: "POST", + body: JSON.stringify({ content }), + }), + onSuccess: (_d, vars) => { + qc.invalidateQueries({ queryKey: ["notes"] }); + qc.invalidateQueries({ queryKey: noteThreadKey(vars.id) }); + }, + }); +} + +export function useUserDirectory() { + return useQuery({ + queryKey: ["users", "directory"], + queryFn: () => req("/users/directory"), + }); +} + export function useCreateLabel() { const qc = useQueryClient(); return useMutation({ @@ -132,3 +288,12 @@ export const NOTE_COLORS: { id: string; bg: string; ring: string }[] = [ export function colorBg(id: string): string { return NOTE_COLORS.find((c) => c.id === id)?.bg ?? NOTE_COLORS[0].bg; } + +export function userDisplayName( + u: UserSummary | null | undefined, + lang: string, +): string { + if (!u) return "?"; + if (lang === "ar") return u.displayNameAr ?? u.displayNameEn ?? u.username; + return u.displayNameEn ?? u.displayNameAr ?? u.username; +} diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index e21abf8d..b0598788 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1037,13 +1037,17 @@ "noLabelsYet": "لا توجد تصنيفات بعد", "allLabels": "الكل", "tabs": { - "active": "ملاحظات", - "archived": "الأرشيف" + "active": "ملاحظاتي", + "archived": "الأرشيف", + "received": "الوارد", + "sent": "المُرسلة" }, "pinned": "مثبّتة", "others": "أخرى", "empty": "ستظهر ملاحظاتك هنا", "emptyArchived": "لا توجد ملاحظات في الأرشيف", + "emptyReceived": "لم تصلك أي ملاحظة بعد", + "emptySent": "لم ترسل أي ملاحظة بعد", "takeNote": "اكتب ملاحظة...", "titlePlaceholder": "العنوان", "contentPlaceholder": "اكتب ملاحظة...", @@ -1054,7 +1058,32 @@ "delete": "حذف", "deleteConfirm": "حذف هذه الملاحظة؟", "deleteLabelConfirm": "حذف هذا التصنيف؟", - "editTitle": "تعديل ملاحظة" + "editTitle": "تعديل ملاحظة", + "send": "إرسال", + "sendNote": "إرسال الملاحظة", + "sendNoteTo": "إرسال إلى…", + "recipientsPlaceholder": "ابحث عن شخص…", + "selectAtLeastOne": "اختر مستلماً واحداً على الأقل", + "sendSuccess": "تم إرسال الملاحظة", + "sendFailed": "تعذّر إرسال الملاحظة", + "from": "من:", + "to": "إلى:", + "reply": "رد", + "replyPlaceholder": "اكتب رداً…", + "sendReply": "إرسال الرد", + "replies": "الردود", + "noRepliesYet": "لا توجد ردود بعد", + "status": { + "unread": "غير مقروءة", + "read": "مقروءة", + "replied": "تم الرد", + "archived": "مؤرشفة" + }, + "recipientsCount_one": "مستلم واحد", + "recipientsCount_other": "{{count}} مستلمين", + "repliesCount_one": "رد واحد", + "repliesCount_other": "{{count}} ردود", + "openThread": "فتح" }, "executiveMeetings": { "title": "قائمة الاجتماعات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index e6fc49de..4c4c4095 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -938,13 +938,17 @@ "noLabelsYet": "No labels yet", "allLabels": "All", "tabs": { - "active": "Notes", - "archived": "Archived" + "active": "My Notes", + "archived": "Archived", + "received": "Inbox", + "sent": "Sent" }, "pinned": "Pinned", "others": "Others", "empty": "Your notes will appear here", "emptyArchived": "No archived notes", + "emptyReceived": "No notes have been sent to you yet", + "emptySent": "You haven't sent any notes yet", "takeNote": "Take a note...", "titlePlaceholder": "Title", "contentPlaceholder": "Take a note...", @@ -955,7 +959,32 @@ "delete": "Delete", "deleteConfirm": "Delete this note?", "deleteLabelConfirm": "Delete this label?", - "editTitle": "Edit note" + "editTitle": "Edit note", + "send": "Send", + "sendNote": "Send note", + "sendNoteTo": "Send to…", + "recipientsPlaceholder": "Search people…", + "selectAtLeastOne": "Select at least one recipient", + "sendSuccess": "Note sent", + "sendFailed": "Could not send note", + "from": "From:", + "to": "To:", + "reply": "Reply", + "replyPlaceholder": "Write a reply…", + "sendReply": "Send reply", + "replies": "Replies", + "noRepliesYet": "No replies yet", + "status": { + "unread": "Unread", + "read": "Read", + "replied": "Replied", + "archived": "Archived" + }, + "recipientsCount_one": "{{count}} recipient", + "recipientsCount_other": "{{count}} recipients", + "repliesCount_one": "{{count}} reply", + "repliesCount_other": "{{count}} replies", + "openThread": "Open" }, "executiveMeetings": { "title": "Meetings list", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index 5054e963..a88d77c2 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -15,6 +15,10 @@ import { Plus, X, Check, + Send, + Inbox, + Mail, + MessageCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -34,17 +38,33 @@ import { import { type Note, type NoteLabel, + type ReceivedNote, + type SentNote, + type SentNoteRecipient, + type UserSummary, NOTE_COLORS, colorBg, + useArchiveReceivedNote, useCreateLabel, useCreateNote, useDeleteLabel, useDeleteNote, + useMarkNoteRead, useNoteLabels, + useNoteThread, useNotes, + useReceivedNotes, + useReplyToNote, + useSendNote, + useSentNotes, useUpdateLabel, useUpdateNote, + useUserDirectory, + userDisplayName, } from "@/lib/notes-api"; +import { useAuth } from "@/contexts/AuthContext"; + +type TabId = "active" | "received" | "sent" | "archived"; export default function NotesPage() { const { t, i18n } = useTranslation(); @@ -52,16 +72,21 @@ export default function NotesPage() { const isRtl = i18n.language === "ar"; const BackIcon = isRtl ? ArrowRight : ArrowLeft; - const [view, setView] = useState<"active" | "archived">("active"); + const [view, setView] = useState("active"); const [search, setSearch] = useState(""); const [labelFilter, setLabelFilter] = useState(null); const [editing, setEditing] = useState(null); const [labelsDialogOpen, setLabelsDialogOpen] = useState(false); + const [sendForNoteId, setSendForNoteId] = useState(null); + const [openThreadId, setOpenThreadId] = useState(null); - const { data: notes = [], isLoading } = useNotes(view === "archived"); + const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived"); const { data: labels = [] } = useNoteLabels(); + const { data: receivedNotes = [], isLoading: loadingReceived } = + useReceivedNotes(false); + const { data: sentNotes = [], isLoading: loadingSent } = useSentNotes(); - const filtered = useMemo(() => { + const filteredNotes = useMemo(() => { const q = search.trim().toLowerCase(); return notes.filter((n) => { if (labelFilter && !n.labelIds.includes(labelFilter)) return false; @@ -73,12 +98,36 @@ export default function NotesPage() { }); }, [notes, search, labelFilter]); - const pinned = filtered.filter((n) => n.isPinned); - const others = filtered.filter((n) => !n.isPinned); + const filteredReceived = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return receivedNotes; + return receivedNotes.filter( + (n) => + n.title.toLowerCase().includes(q) || + n.content.toLowerCase().includes(q), + ); + }, [receivedNotes, search]); + + const filteredSent = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return sentNotes; + return sentNotes.filter( + (n) => + n.title.toLowerCase().includes(q) || + n.content.toLowerCase().includes(q), + ); + }, [sentNotes, search]); + + const unreadInboxCount = receivedNotes.filter((n) => n.status === "unread").length; + const pinned = filteredNotes.filter((n) => n.isPinned); + const others = filteredNotes.filter((n) => !n.isPinned); return ( -
- {/* Header */} +
- {/* Tabs + label chips */}
- - +
- {labels.length > 0 && ( + {(view === "active" || view === "archived") && labels.length > 0 && (
+ ); +} + +function TabButton({ + active, + onClick, + children, + testId, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; + testId?: string; +}) { + return ( + + ); +} + +function Loading() { + const { t } = useTranslation(); + return ( +
+ {t("common.loading", "Loading...")} +
+ ); +} + +function Empty({ icon, text }: { icon: React.ReactNode; text: string }) { + return ( +
+
{icon}
+ {text}
); } @@ -233,11 +383,13 @@ function Section({ notes, labels, onEdit, + onSend, }: { title: string; notes: Note[]; labels: NoteLabel[]; onEdit: (n: Note) => void; + onSend: (n: Note) => void; }) { if (notes.length === 0) return null; return ( @@ -252,7 +404,7 @@ function Section({ style={{ columnWidth: 230 }} > {notes.map((n) => ( - + ))}
@@ -263,10 +415,12 @@ function NoteCard({ note, labels, onEdit, + onSend, }: { note: Note; labels: NoteLabel[]; onEdit: (n: Note) => void; + onSend: (n: Note) => void; }) { const { t } = useTranslation(); const update = useUpdateNote(); @@ -275,6 +429,7 @@ function NoteCard({ return (
update.mutate({ id: note.id, labelIds: ids })} /> + + ))} +
+ ); +} + +function SentList({ + loading, + notes, + onOpen, +}: { + loading: boolean; + notes: SentNote[]; + onOpen: (id: number) => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + if (loading) return ; + if (notes.length === 0) { + return ( + } + text={t("notes.emptySent", "You haven't sent any notes yet")} + /> + ); + } + return ( +
+ {notes.map((n) => ( + + ))} +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const { t } = useTranslation(); + const map: Record = { + unread: "bg-rose-500/15 text-rose-700", + read: "bg-slate-200 text-slate-700", + replied: "bg-emerald-500/15 text-emerald-700", + archived: "bg-slate-200 text-slate-500", + }; + return ( + + {t(`notes.status.${status}`, status)} + + ); +} + +function StatusDot({ status }: { status: string }) { + const map: Record = { + unread: "bg-rose-500", + read: "bg-slate-400", + replied: "bg-emerald-500", + archived: "bg-slate-300", + }; + return ( + + ); +} + +function SendNoteDialog({ + noteId, + onClose, +}: { + noteId: number; + onClose: () => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const { user } = useAuth(); + const { data: directory = [], isLoading } = useUserDirectory(); + const send = useSendNote(); + const [search, setSearch] = useState(""); + const [picked, setPicked] = useState>(new Set()); + + const candidates = useMemo(() => { + const me = user?.id; + return directory.filter((u) => u.id !== me); + }, [directory, user]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return candidates; + return candidates.filter((u) => + [u.username, u.displayNameAr, u.displayNameEn] + .filter(Boolean) + .some((s) => (s as string).toLowerCase().includes(q)), + ); + }, [candidates, search]); + + const toggle = (id: number) => { + setPicked((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const submit = () => { + if (picked.size === 0) return; + send.mutate( + { id: noteId, recipientUserIds: Array.from(picked) }, + { onSuccess: onClose }, + ); + }; + + return ( + !o && onClose()}> + + + {t("notes.sendNote", "Send note")} + +
+ + setSearch(e.target.value)} + placeholder={t("notes.recipientsPlaceholder", "Search people…")} + className={lang === "ar" ? "pe-9" : "ps-9"} + data-testid="send-note-search" + /> +
+
+ {isLoading && ( +
+ {t("common.loading", "Loading...")} +
+ )} + {!isLoading && filtered.length === 0 && ( +
+ — +
+ )} + {filtered.map((u) => { + const checked = picked.has(u.id); + return ( + + ); + })} +
+ {picked.size > 0 && ( +
+ {t("notes.recipientsCount", { count: picked.size })} +
+ )} +
+ + +
+
+
+ ); +} + +function ThreadDialog({ + noteId, + onClose, +}: { + noteId: number; + onClose: () => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const { data: thread, isLoading } = useNoteThread(noteId); + const markRead = useMarkNoteRead(); + const reply = useReplyToNote(); + const archive = useArchiveReceivedNote(); + const [replyText, setReplyText] = useState(""); + const markedRef = useRef(false); + + useEffect(() => { + if (!thread || markedRef.current) return; + if (!thread.isOwner && !thread.isAdmin && thread.myStatus === "unread") { + markedRef.current = true; + markRead.mutate(noteId); + } + }, [thread, noteId, markRead]); + + const submitReply = () => { + const c = replyText.trim(); + if (!c) return; + reply.mutate( + { id: noteId, content: c }, + { onSuccess: () => setReplyText("") }, + ); + }; + + return ( + !o && onClose()}> + + + + {thread?.title || t("notes.editTitle", "Edit note")} + + + {isLoading || !thread ? ( + + ) : ( + <> +
+ + {t("notes.from", "From:")} {userDisplayName(thread.sender, lang)} +
+ {thread.content && ( +
+ {thread.content} +
+ )} + {(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && ( +
+
+ {t("notes.to", "To:")} +
+
+ {thread.recipients.map((r) => ( + + {userDisplayName(r.recipient, lang)} + + + {t(`notes.status.${r.status}`, r.status)} + + + ))} +
+
+ )} + +
+
+ + {t("notes.replies", "Replies")} + {thread.replies.length > 0 && ( + + ({thread.replies.length}) + + )} +
+ {thread.replies.length === 0 ? ( +
+ {t("notes.noRepliesYet", "No replies yet")} +
+ ) : ( +
+ {thread.replies.map((r) => ( +
+
+ {userDisplayName(r.sender, lang)} +
+
+ {r.content} +
+
+ ))} +
+ )} +
+ + {!thread.isOwner && thread.myStatus !== null && thread.myStatus !== "archived" && ( +
+