Task #402: Convert Notes into in-app messaging (independence fix)
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.
This commit is contained in:
@@ -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<boolean> {
|
||||
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<Map<number, UserSummary>> {
|
||||
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<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const archived = req.query.archived === "true";
|
||||
@@ -181,19 +270,6 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
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<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const id = parseIdParam(req.params.id);
|
||||
@@ -212,6 +288,410 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
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<void> => {
|
||||
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<number>`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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const labels = await db
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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() });
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -41,6 +109,10 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
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<SentNote[]>(`/notes/sent`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReceivedNotes(archived: boolean) {
|
||||
return useQuery({
|
||||
queryKey: receivedNotesKey(archived),
|
||||
queryFn: () =>
|
||||
req<ReceivedNote[]>(`/notes/received?archived=${archived}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNoteThread(id: number | null) {
|
||||
return useQuery({
|
||||
queryKey: id ? noteThreadKey(id) : ["notes", "thread", "none"],
|
||||
queryFn: () => req<NoteThread>(`/notes/${id}/thread`),
|
||||
enabled: id !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNoteLabels() {
|
||||
return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/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<NoteReply>(`/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<UserSummary[]>("/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;
|
||||
}
|
||||
|
||||
@@ -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": "قائمة الاجتماعات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<TabId>("active");
|
||||
const [search, setSearch] = useState("");
|
||||
const [labelFilter, setLabelFilter] = useState<number | null>(null);
|
||||
const [editing, setEditing] = useState<Note | null>(null);
|
||||
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false);
|
||||
const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
|
||||
const [openThreadId, setOpenThreadId] = useState<number | null>(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 (
|
||||
<div className="min-h-screen os-bg flex flex-col" dir={isRtl ? "rtl" : "ltr"}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="min-h-screen os-bg flex flex-col"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
data-testid="notes-page"
|
||||
>
|
||||
<div className="glass-panel border-b border-slate-200/70 px-4 py-3 sticky top-0 z-20">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
@@ -119,31 +168,50 @@ export default function NotesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs + label chips */}
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
<div className="inline-flex rounded-xl bg-slate-100/70 p-1">
|
||||
<button
|
||||
<TabButton
|
||||
active={view === "active"}
|
||||
onClick={() => setView("active")}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition ${
|
||||
view === "active"
|
||||
? "bg-white shadow-sm text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
testId="notes-tab-active"
|
||||
>
|
||||
{t("notes.tabs.active", "Notes")}
|
||||
</button>
|
||||
<button
|
||||
<StickyNote size={14} className="me-1 inline-block" />
|
||||
{t("notes.tabs.active", "My Notes")}
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={view === "received"}
|
||||
onClick={() => setView("received")}
|
||||
testId="notes-tab-received"
|
||||
>
|
||||
<Inbox size={14} className="me-1 inline-block" />
|
||||
{t("notes.tabs.received", "Inbox")}
|
||||
{unreadInboxCount > 0 && (
|
||||
<span
|
||||
className="ms-1 inline-flex items-center justify-center rounded-full bg-rose-500 text-white text-[10px] min-w-[18px] h-[18px] px-1"
|
||||
data-testid="notes-inbox-unread-badge"
|
||||
>
|
||||
{unreadInboxCount}
|
||||
</span>
|
||||
)}
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={view === "sent"}
|
||||
onClick={() => setView("sent")}
|
||||
testId="notes-tab-sent"
|
||||
>
|
||||
<Send size={14} className="me-1 inline-block" />
|
||||
{t("notes.tabs.sent", "Sent")}
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={view === "archived"}
|
||||
onClick={() => setView("archived")}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition ${
|
||||
view === "archived"
|
||||
? "bg-white shadow-sm text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
testId="notes-tab-archived"
|
||||
>
|
||||
<Archive size={14} className="me-1 inline-block" />
|
||||
{t("notes.tabs.archived", "Archived")}
|
||||
</button>
|
||||
</TabButton>
|
||||
</div>
|
||||
{labels.length > 0 && (
|
||||
{(view === "active" || view === "archived") && labels.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setLabelFilter(null)}
|
||||
@@ -175,39 +243,63 @@ export default function NotesPage() {
|
||||
|
||||
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto">
|
||||
{view === "active" && (
|
||||
<Composer labels={labels} />
|
||||
<>
|
||||
<Composer labels={labels} />
|
||||
{loadingNotes ? (
|
||||
<Loading />
|
||||
) : filteredNotes.length === 0 ? (
|
||||
<Empty icon={<StickyNote size={48} />} text={t("notes.empty", "Your notes will appear here")} />
|
||||
) : (
|
||||
<div className="space-y-6 mt-6">
|
||||
{pinned.length > 0 && (
|
||||
<Section
|
||||
title={t("notes.pinned", "Pinned")}
|
||||
notes={pinned}
|
||||
labels={labels}
|
||||
onEdit={setEditing}
|
||||
onSend={(n) => setSendForNoteId(n.id)}
|
||||
/>
|
||||
)}
|
||||
<Section
|
||||
title={pinned.length > 0 ? t("notes.others", "Others") : ""}
|
||||
notes={others}
|
||||
labels={labels}
|
||||
onEdit={setEditing}
|
||||
onSend={(n) => setSendForNoteId(n.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-muted-foreground">
|
||||
<StickyNote size={48} className="opacity-30" />
|
||||
<span>
|
||||
{view === "archived"
|
||||
? t("notes.emptyArchived", "No archived notes")
|
||||
: t("notes.empty", "Your notes will appear here")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 mt-6">
|
||||
{pinned.length > 0 && (
|
||||
<Section
|
||||
title={t("notes.pinned", "Pinned")}
|
||||
notes={pinned}
|
||||
labels={labels}
|
||||
onEdit={setEditing}
|
||||
/>
|
||||
{view === "archived" && (
|
||||
<>
|
||||
{loadingNotes ? (
|
||||
<Loading />
|
||||
) : filteredNotes.length === 0 ? (
|
||||
<Empty icon={<StickyNote size={48} />} text={t("notes.emptyArchived", "No archived notes")} />
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<Section title="" notes={filteredNotes} labels={labels} onEdit={setEditing} onSend={(n) => setSendForNoteId(n.id)} />
|
||||
</div>
|
||||
)}
|
||||
<Section
|
||||
title={pinned.length > 0 ? t("notes.others", "Others") : ""}
|
||||
notes={others}
|
||||
labels={labels}
|
||||
onEdit={setEditing}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "received" && (
|
||||
<ReceivedList
|
||||
loading={loadingReceived}
|
||||
notes={filteredReceived}
|
||||
onOpen={(id) => setOpenThreadId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "sent" && (
|
||||
<SentList
|
||||
loading={loadingSent}
|
||||
notes={filteredSent}
|
||||
onOpen={(id) => setOpenThreadId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -224,6 +316,64 @@ export default function NotesPage() {
|
||||
onClose={() => setLabelsDialogOpen(false)}
|
||||
labels={labels}
|
||||
/>
|
||||
|
||||
{sendForNoteId !== null && (
|
||||
<SendNoteDialog
|
||||
noteId={sendForNoteId}
|
||||
onClose={() => setSendForNoteId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openThreadId !== null && (
|
||||
<ThreadDialog
|
||||
noteId={openThreadId}
|
||||
onClose={() => setOpenThreadId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-testid={testId}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition inline-flex items-center ${
|
||||
active
|
||||
? "bg-white shadow-sm text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-muted-foreground">
|
||||
<div className="opacity-30">{icon}</div>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} />
|
||||
<NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} onSend={onSend} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid={`note-card-${note.id}`}
|
||||
className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group ${colorBg(
|
||||
note.color,
|
||||
)}`}
|
||||
@@ -330,6 +485,14 @@ function NoteCard({
|
||||
selected={note.labelIds}
|
||||
onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onSend(note)}
|
||||
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary"
|
||||
aria-label={t("notes.send", "Send")}
|
||||
data-testid={`note-card-send-${note.id}`}
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
update.mutate({ id: note.id, isArchived: !note.isArchived })
|
||||
@@ -359,6 +522,446 @@ function NoteCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ReceivedList({
|
||||
loading,
|
||||
notes,
|
||||
onOpen,
|
||||
}: {
|
||||
loading: boolean;
|
||||
notes: ReceivedNote[];
|
||||
onOpen: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
if (loading) return <Loading />;
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Inbox size={48} />}
|
||||
text={t("notes.emptyReceived", "No notes have been sent to you yet")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="gap-3 mt-2 [column-fill:_balance]"
|
||||
style={{ columnWidth: 260 }}
|
||||
data-testid="notes-received-list"
|
||||
>
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => onOpen(n.id)}
|
||||
data-testid={`received-note-card-${n.id}`}
|
||||
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md ${colorBg(
|
||||
n.color,
|
||||
)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
|
||||
<Mail size={12} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={n.status} />
|
||||
</div>
|
||||
{n.title && (
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{n.title}
|
||||
</div>
|
||||
)}
|
||||
{n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
||||
{n.content}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SentList({
|
||||
loading,
|
||||
notes,
|
||||
onOpen,
|
||||
}: {
|
||||
loading: boolean;
|
||||
notes: SentNote[];
|
||||
onOpen: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
if (loading) return <Loading />;
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Send size={48} />}
|
||||
text={t("notes.emptySent", "You haven't sent any notes yet")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="gap-3 mt-2 [column-fill:_balance]"
|
||||
style={{ columnWidth: 260 }}
|
||||
data-testid="notes-sent-list"
|
||||
>
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => onOpen(n.id)}
|
||||
data-testid={`sent-note-card-${n.id}`}
|
||||
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md ${colorBg(
|
||||
n.color,
|
||||
)}`}
|
||||
>
|
||||
{n.title && (
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{n.title}
|
||||
</div>
|
||||
)}
|
||||
{n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
|
||||
{n.content}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11px] text-muted-foreground mt-2">
|
||||
{t("notes.to", "To:")}{" "}
|
||||
<span className="text-foreground/80">
|
||||
{n.recipients
|
||||
.map((r) => userDisplayName(r.recipient, lang))
|
||||
.join("، ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{n.recipients.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
data-testid={`sent-recipient-status-${n.id}-${r.recipientUserId}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-white/60 border border-black/5"
|
||||
>
|
||||
<span className="truncate max-w-[80px]">
|
||||
{userDisplayName(r.recipient, lang)}
|
||||
</span>
|
||||
<StatusDot status={r.status} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
const map: Record<string, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full ${map[status] ?? map.read}`}
|
||||
data-testid={`status-badge-${status}`}
|
||||
>
|
||||
{t(`notes.status.${status}`, status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
unread: "bg-rose-500",
|
||||
read: "bg-slate-400",
|
||||
replied: "bg-emerald-500",
|
||||
archived: "bg-slate-300",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full ${map[status] ?? map.read}`}
|
||||
aria-label={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<Set<number>>(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 (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md" data-testid="send-note-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
style={lang === "ar" ? { right: 10 } : { left: 10 }}
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
|
||||
className={lang === "ar" ? "pe-9" : "ps-9"}
|
||||
data-testid="send-note-search"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
|
||||
{isLoading && (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((u) => {
|
||||
const checked = picked.has(u.id);
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => toggle(u.id)}
|
||||
data-testid={`send-recipient-option-${u.id}`}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
|
||||
checked ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{userDisplayName(u, lang)}</span>
|
||||
{checked && <Check size={14} className="text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{picked.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("notes.recipientsCount", { count: picked.size })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={picked.size === 0 || send.isPending}
|
||||
data-testid="send-note-submit"
|
||||
>
|
||||
<Send size={14} className="me-1" />
|
||||
{t("notes.send", "Send")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent
|
||||
className={`max-w-lg ${thread ? colorBg(thread.color) : ""}`}
|
||||
data-testid="note-thread-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">
|
||||
{thread?.title || t("notes.editTitle", "Edit note")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading || !thread ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<Mail size={12} />
|
||||
{t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
|
||||
</div>
|
||||
{thread.content && (
|
||||
<div className="text-sm text-foreground/85 whitespace-pre-wrap mt-1">
|
||||
{thread.content}
|
||||
</div>
|
||||
)}
|
||||
{(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{t("notes.to", "To:")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{thread.recipients.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-white/70 border border-black/5"
|
||||
data-testid={`thread-recipient-${r.recipientUserId}`}
|
||||
>
|
||||
<span>{userDisplayName(r.recipient, lang)}</span>
|
||||
<StatusDot status={r.status} />
|
||||
<span className="text-muted-foreground">
|
||||
{t(`notes.status.${r.status}`, r.status)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-xs text-muted-foreground mb-1.5 flex items-center gap-1">
|
||||
<MessageCircle size={12} />
|
||||
{t("notes.replies", "Replies")}
|
||||
{thread.replies.length > 0 && (
|
||||
<span className="text-foreground/70">
|
||||
({thread.replies.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{thread.replies.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
{t("notes.noRepliesYet", "No replies yet")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{thread.replies.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid={`thread-reply-${r.id}`}
|
||||
className="text-sm bg-white/70 rounded-lg p-2 border border-black/5"
|
||||
>
|
||||
<div className="text-[11px] text-muted-foreground mb-0.5">
|
||||
{userDisplayName(r.sender, lang)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!thread.isOwner && thread.myStatus !== null && thread.myStatus !== "archived" && (
|
||||
<div className="border-t pt-3 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder={t("notes.replyPlaceholder", "Write a reply…")}
|
||||
className="min-h-[70px] resize-none"
|
||||
data-testid="thread-reply-input"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
archive.mutate(
|
||||
{ id: noteId, archived: true },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Archive size={14} className="me-1" />
|
||||
{t("notes.archive", "Archive")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submitReply}
|
||||
disabled={!replyText.trim() || reply.isPending}
|
||||
data-testid="thread-reply-submit"
|
||||
>
|
||||
<Send size={14} className="me-1" />
|
||||
{t("notes.sendReply", "Send reply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -514,6 +1117,7 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="notes-composer"
|
||||
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
|
||||
color,
|
||||
)}`}
|
||||
@@ -526,12 +1130,14 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("notes.titlePlaceholder", "Title")}
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
|
||||
data-testid="notes-composer-title"
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
|
||||
data-testid="notes-composer-content"
|
||||
/>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
@@ -541,7 +1147,7 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
onChange={setLabelIds}
|
||||
/>
|
||||
<div className="ms-auto">
|
||||
<Button size="sm" variant="ghost" onClick={save}>
|
||||
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
|
||||
{t("notes.save", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -551,6 +1157,7 @@ function Composer({ labels }: { labels: NoteLabel[] }) {
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full text-start text-muted-foreground py-1 px-1"
|
||||
data-testid="notes-composer-open"
|
||||
>
|
||||
{t("notes.takeNote", "Take a note...")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// E2E test for the in-app messaging flow built on the Notes page:
|
||||
// - Sender (user A) creates a note via the composer at /notes
|
||||
// - Sender clicks "Send" on the card and picks user B as recipient
|
||||
// - Recipient (user B) signs in, opens /notes → "Inbox" tab
|
||||
// - Sees the note from A with an "Unread" badge → opens it (which marks read)
|
||||
// - Replies inline; reply persists in the thread dialog
|
||||
// - Sender re-opens /notes → "Sent" tab and sees the recipient with the
|
||||
// "Replied" status pill.
|
||||
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 to run this UI test");
|
||||
}
|
||||
|
||||
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_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 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function logout(page) {
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
|
||||
test("sender → recipient → reply flow updates Inbox + Sent tabs", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sender = await createUser("notes_e2e_sender", "Sender Person");
|
||||
const recipient = await createUser("notes_e2e_recipient", "Recipient Person");
|
||||
|
||||
// ---- Sender composes + sends a note ----
|
||||
await loginViaUi(page, sender.username);
|
||||
await page.goto("/notes");
|
||||
await expect(page.getByTestId("notes-page")).toBeVisible();
|
||||
|
||||
const noteTitle = `E2E Note ${uniq()}`;
|
||||
const noteContent = "Please review attached.";
|
||||
|
||||
await page.getByTestId("notes-composer-open").click();
|
||||
await page.getByTestId("notes-composer-title").fill(noteTitle);
|
||||
await page.getByTestId("notes-composer-content").fill(noteContent);
|
||||
|
||||
const createPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === "/api/notes" &&
|
||||
r.request().method() === "POST" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("notes-composer-save").click();
|
||||
const createResp = await createPromise;
|
||||
const createdNote = await createResp.json();
|
||||
createdNoteIds.push(createdNote.id);
|
||||
|
||||
// The new card should appear with our content; click its Send button.
|
||||
const card = page.getByTestId(`note-card-${createdNote.id}`);
|
||||
await expect(card).toBeVisible();
|
||||
await card.hover();
|
||||
await page.getByTestId(`note-card-send-${createdNote.id}`).click();
|
||||
|
||||
const sendDialog = page.getByTestId("send-note-dialog");
|
||||
await expect(sendDialog).toBeVisible();
|
||||
await page.getByTestId("send-note-search").fill(recipient.displayEn);
|
||||
await page
|
||||
.getByTestId(`send-recipient-option-${recipient.id}`)
|
||||
.click();
|
||||
|
||||
const sendPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` &&
|
||||
r.request().method() === "POST" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("send-note-submit").click();
|
||||
await sendPromise;
|
||||
await expect(sendDialog).toBeHidden();
|
||||
|
||||
// ---- Switch to recipient session ----
|
||||
await logout(page);
|
||||
await loginViaUi(page, recipient.username);
|
||||
await page.goto("/notes");
|
||||
|
||||
await page.getByTestId("notes-tab-received").click();
|
||||
const inboxCard = page.getByTestId(`received-note-card-${createdNote.id}`);
|
||||
await expect(inboxCard).toBeVisible();
|
||||
await expect(inboxCard).toContainText(noteTitle);
|
||||
await expect(inboxCard).toContainText(sender.displayEn);
|
||||
await expect(
|
||||
inboxCard.getByTestId("status-badge-unread"),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the thread (this auto-marks read).
|
||||
const readPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === `/api/notes/${createdNote.id}/read` &&
|
||||
r.request().method() === "POST" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await inboxCard.click();
|
||||
const threadDialog = page.getByTestId("note-thread-dialog");
|
||||
await expect(threadDialog).toBeVisible();
|
||||
await readPromise;
|
||||
|
||||
// Reply inline.
|
||||
const replyText = "Acknowledged, thanks.";
|
||||
await page.getByTestId("thread-reply-input").fill(replyText);
|
||||
const replyPromise = page.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === `/api/notes/${createdNote.id}/reply` &&
|
||||
r.request().method() === "POST" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("thread-reply-submit").click();
|
||||
await replyPromise;
|
||||
|
||||
// Reply now appears in the thread.
|
||||
await expect(threadDialog).toContainText(replyText);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// ---- Back to sender → Sent tab shows replied status ----
|
||||
await logout(page);
|
||||
await loginViaUi(page, sender.username);
|
||||
await page.goto("/notes");
|
||||
await page.getByTestId("notes-tab-sent").click();
|
||||
|
||||
const sentCard = page.getByTestId(`sent-note-card-${createdNote.id}`);
|
||||
await expect(sentCard).toBeVisible();
|
||||
await expect(sentCard).toContainText(noteTitle);
|
||||
await expect(sentCard).toContainText(recipient.displayEn);
|
||||
// Per-recipient status pill flipped to "replied".
|
||||
const recipientPill = page.getByTestId(
|
||||
`sent-recipient-status-${createdNote.id}-${recipient.id}`,
|
||||
);
|
||||
await expect(recipientPill).toBeVisible();
|
||||
|
||||
// Open the thread from the sender side and confirm the reply is visible.
|
||||
await sentCard.click();
|
||||
const senderThread = page.getByTestId("note-thread-dialog");
|
||||
await expect(senderThread).toBeVisible();
|
||||
await expect(senderThread).toContainText(replyText);
|
||||
});
|
||||
@@ -31,6 +31,46 @@ export const noteLabelAssignmentsTable = pgTable("note_label_assignments", {
|
||||
pk: primaryKey({ columns: [t.noteId, t.labelId] }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Per-recipient delivery row for a note that was sent from one user
|
||||
* (`senderUserId` / the owning `notes.user_id`) to another
|
||||
* (`recipientUserId`). One row is created per recipient at send time.
|
||||
*
|
||||
* Status lifecycle: `unread` -> `read` (recipient opens) -> `replied`
|
||||
* (recipient sends a reply). `archived` is recipient-side only and does
|
||||
* not affect the sender's view.
|
||||
*/
|
||||
export const noteRecipientsTable = pgTable("note_recipients", {
|
||||
id: serial("id").primaryKey(),
|
||||
// The original sender-side note id at send time. Stored as a plain integer
|
||||
// (no FK) so the recipient row — and the thread it anchors — survives the
|
||||
// sender deleting their own note. Together with the snapshot fields below
|
||||
// this guarantees recipient copies are fully independent of sender edits.
|
||||
noteId: integer("note_id").notNull(),
|
||||
senderUserId: integer("sender_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
recipientUserId: integer("recipient_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
// Immutable snapshot of the note as it was at send time. Sender edits to the
|
||||
// original note do NOT propagate to delivered copies.
|
||||
title: varchar("title", { length: 300 }).notNull().default(""),
|
||||
content: text("content").notNull().default(""),
|
||||
color: varchar("color", { length: 32 }).notNull().default("default"),
|
||||
status: varchar("status", { length: 16 }).notNull().default("unread"),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
}, (t) => ({
|
||||
noteRecipientUnique: uniqueIndex("note_recipients_note_recipient_unique").on(t.noteId, t.recipientUserId),
|
||||
}));
|
||||
|
||||
export const noteRepliesTable = pgTable("note_replies", {
|
||||
id: serial("id").primaryKey(),
|
||||
noteId: integer("note_id").notNull(),
|
||||
senderUserId: integer("sender_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
recipientUserId: integer("recipient_user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
content: text("content").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const insertNoteSchema = createInsertSchema(notesTable).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertNote = z.infer<typeof insertNoteSchema>;
|
||||
export type Note = typeof notesTable.$inferSelect;
|
||||
@@ -38,3 +78,6 @@ export type Note = typeof notesTable.$inferSelect;
|
||||
export const insertNoteLabelSchema = createInsertSchema(noteLabelsTable).omit({ id: true, createdAt: true });
|
||||
export type InsertNoteLabel = z.infer<typeof insertNoteLabelSchema>;
|
||||
export type NoteLabel = typeof noteLabelsTable.$inferSelect;
|
||||
|
||||
export type NoteRecipient = typeof noteRecipientsTable.$inferSelect;
|
||||
export type NoteReply = typeof noteRepliesTable.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user