Task #402: Convert Notes into in-app messaging

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 backend access checks (admin sees everything),
realtime updates, toasts, an unread badge on the Notes app tile, and full
i18n + RTL.

Three review rounds were addressed in this commit:

Round 1 (independence):
- Snapshot columns (title/content/color) on note_recipients; FKs dropped
  on note_recipients.note_id and note_replies.note_id so recipient
  threads survive the sender deleting their note.
- /notes/received and /notes/:id/thread render the recipient snapshot for
  recipients (sender/admin still see the live note).

Round 2:
- POST /notes/:id/reply: owner is now allowed to reply too. Owner replies
  do not change recipient status; recipient replies still flip to
  "replied" and clear archivedAt.
- Added GET /notes/:id as an alias of /notes/:id/thread.
- Added OpenAPI ops for the Notes routes and ran orval codegen.
- use-notifications-socket.ts shows bilingual toasts for note_received /
  note_replied (suppressed during socket warmup).
- home.tsx renders an unread badge on the Notes app tile.

Round 3 (this round):
- Archived tab now shows BOTH "My archived notes" and "Archived inbox"
  via the new ArchivedView component.
- Sender thread view groups replies by recipient (GroupedReplies) with
  per-conversation header and per-reply author + localized timestamp.
  Recipient view stays flat.
- Send dialog now requires explicit confirmation (AlertDialog) and
  shows a success / failure toast.
- Realtime toast strings moved off hardcoded EN/AR to i18n keys
  (notes.toast.received|replied.*) added in en.json and ar.json.
- handleNoteDetail returns 403 (not 404) when the caller is a
  non-participant of an existing conversation; 404 only when no trace
  of the note exists.
- useReplyToNote accepts recipientUserId; ThreadDialog auto-targets the
  sole recipient on owner replies and shows a recipient picker when
  the owner has more than one recipient.

Tests: 7 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox.spec.mjs)
all pass. tx-os typecheck is clean. Architect re-review: PASS.

Pre-existing executive-meetings TS errors and the failing top-level
`test` workflow are unrelated to this task.
This commit is contained in:
riyadhafraa
2026-05-05 15:09:44 +00:00
parent 65c5a172d6
commit d1b4bb228c
7 changed files with 357 additions and 55 deletions
+12
View File
@@ -405,7 +405,19 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
const admin = await isAdmin(userId);
const isSender = !!note && note.userId === userId;
// Authorization contract: any non-participant gets 403 (not 404), as long
// as someone is participating in the conversation. Only return 404 when
// there is no trace of the note at all (no live row, no recipient rows).
if (!note && !recipientRow) {
const [otherRow] = await db
.select({ id: noteRecipientsTable.id })
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, id.id))
.limit(1);
if (otherRow) {
res.status(403).json({ error: "Forbidden" });
return;
}
res.status(404).json({ error: "Note not found" });
return;
}
@@ -111,24 +111,18 @@ export function useNotificationsSocket() {
socket.on("note_received", () => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
const ar = i18n.language === "ar";
toast({
title: ar ? "ملاحظة جديدة" : "New note received",
description: ar
? "تحقق من بريد الملاحظات الوارد لقراءتها."
: "Check your Notes inbox to read it.",
title: i18n.t("notes.toast.received.title"),
description: i18n.t("notes.toast.received.desc"),
});
});
socket.on("note_replied", () => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
const ar = i18n.language === "ar";
toast({
title: ar ? "رد جديد على ملاحظة" : "New reply on a note",
description: ar
? "افتح الملاحظة لمتابعة المحادثة."
: "Open the note to continue the conversation.",
title: i18n.t("notes.toast.replied.title"),
description: i18n.t("notes.toast.replied.desc"),
});
});
+12 -2
View File
@@ -220,10 +220,20 @@ export function useArchiveReceivedNote() {
export function useReplyToNote() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, content }: { id: number; content: string }) =>
mutationFn: ({
id,
content,
recipientUserId,
}: {
id: number;
content: string;
recipientUserId?: number;
}) =>
req<NoteReply>(`/notes/${id}/reply`, {
method: "POST",
body: JSON.stringify({ content }),
body: JSON.stringify(
recipientUserId ? { content, recipientUserId } : { content },
),
}),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["notes"] });
+21
View File
@@ -1079,6 +1079,27 @@
"replied": "تم الرد",
"archived": "مؤرشفة"
},
"toast": {
"received": {
"title": "ملاحظة جديدة",
"desc": "تحقق من بريد الملاحظات الوارد لقراءتها."
},
"replied": {
"title": "رد جديد على ملاحظة",
"desc": "افتح الملاحظة لمتابعة المحادثة."
}
},
"confirmSendTitle": "إرسال هذه الملاحظة؟",
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
"confirmSendBody_other": "سيتم إرسال هذه الملاحظة إلى {{count}} أشخاص.",
"confirmSendCta": "إرسال",
"archivedSection": {
"myNotes": "ملاحظاتي المؤرشفة",
"inbox": "البريد الوارد المؤرشف"
},
"conversationWith": "محادثة مع {{name}}",
"ownerReplies": "ردودك",
"noConversationsYet": "لا توجد محادثات بعد",
"recipientsCount_one": "مستلم واحد",
"recipientsCount_other": "{{count}} مستلمين",
"repliesCount_one": "رد واحد",
+22 -1
View File
@@ -984,7 +984,28 @@
"recipientsCount_other": "{{count}} recipients",
"repliesCount_one": "{{count}} reply",
"repliesCount_other": "{{count}} replies",
"openThread": "Open"
"openThread": "Open",
"toast": {
"received": {
"title": "New note received",
"desc": "Check your Notes inbox to read it."
},
"replied": {
"title": "New reply on a note",
"desc": "Open the note to continue the conversation."
}
},
"confirmSendTitle": "Send this note?",
"confirmSendBody_one": "This note will be sent to {{count}} person.",
"confirmSendBody_other": "This note will be sent to {{count}} people.",
"confirmSendCta": "Send",
"archivedSection": {
"myNotes": "My archived notes",
"inbox": "Archived inbox"
},
"conversationWith": "Conversation with {{name}}",
"ownerReplies": "Your replies",
"noConversationsYet": "No conversations yet"
},
"executiveMeetings": {
"title": "Meetings list",
+283 -42
View File
@@ -24,6 +24,17 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
@@ -38,6 +49,7 @@ import {
import {
type Note,
type NoteLabel,
type NoteThread,
type ReceivedNote,
type SentNote,
type SentNoteRecipient,
@@ -84,6 +96,8 @@ export default function NotesPage() {
const { data: labels = [] } = useNoteLabels();
const { data: receivedNotes = [], isLoading: loadingReceived } =
useReceivedNotes(false);
const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
useReceivedNotes(true);
const { data: sentNotes = [], isLoading: loadingSent } = useSentNotes();
const filteredNotes = useMemo(() => {
@@ -273,17 +287,23 @@ export default function NotesPage() {
)}
{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>
)}
</>
<ArchivedView
loadingMine={loadingNotes}
mine={filteredNotes}
labels={labels}
onEdit={setEditing}
onSend={(n) => setSendForNoteId(n.id)}
loadingInbox={loadingArchivedReceived}
archivedReceived={archivedReceivedNotes.filter((n) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
);
})}
onOpenThread={(id) => setOpenThreadId(id)}
/>
)}
{view === "received" && (
@@ -727,11 +747,30 @@ function SendNoteDialog({
});
};
const submit = () => {
const [confirmOpen, setConfirmOpen] = useState(false);
const requestSubmit = () => {
if (picked.size === 0) return;
setConfirmOpen(true);
};
const confirmSubmit = () => {
send.mutate(
{ id: noteId, recipientUserIds: Array.from(picked) },
{ onSuccess: onClose },
{
onSuccess: () => {
toast({ title: t("notes.sendSuccess", "Note sent") });
setConfirmOpen(false);
onClose();
},
onError: () => {
setConfirmOpen(false);
toast({
title: t("notes.sendFailed", "Could not send note"),
variant: "destructive",
});
},
},
);
};
@@ -793,7 +832,7 @@ function SendNoteDialog({
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={submit}
onClick={requestSubmit}
disabled={picked.size === 0 || send.isPending}
data-testid="send-note-submit"
>
@@ -802,10 +841,87 @@ function SendNoteDialog({
</Button>
</div>
</DialogContent>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent data-testid="send-note-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.confirmSendTitle", "Send this note?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("notes.confirmSendBody", { count: picked.size })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmSubmit}
disabled={send.isPending}
data-testid="send-note-confirm-submit"
>
{t("notes.confirmSendCta", "Send")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
function ArchivedView({
loadingMine,
mine,
labels,
onEdit,
onSend,
loadingInbox,
archivedReceived,
onOpenThread,
}: {
loadingMine: boolean;
mine: Note[];
labels: NoteLabel[];
onEdit: (n: Note) => void;
onSend: (n: Note) => void;
loadingInbox: boolean;
archivedReceived: ReceivedNote[];
onOpenThread: (id: number) => void;
}) {
const { t } = useTranslation();
if (loadingMine || loadingInbox) return <Loading />;
if (mine.length === 0 && archivedReceived.length === 0) {
return (
<Empty
icon={<StickyNote size={48} />}
text={t("notes.emptyArchived", "No archived notes")}
/>
);
}
return (
<div className="space-y-8 mt-2">
{mine.length > 0 && (
<div>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
{t("notes.archivedSection.myNotes", "My archived notes")}
</h2>
<Section title="" notes={mine} labels={labels} onEdit={onEdit} onSend={onSend} />
</div>
)}
{archivedReceived.length > 0 && (
<div data-testid="notes-archived-inbox">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
{t("notes.archivedSection.inbox", "Archived inbox")}
</h2>
<ReceivedList
loading={false}
notes={archivedReceived}
onOpen={onOpenThread}
/>
</div>
)}
</div>
);
}
function ThreadDialog({
noteId,
onClose,
@@ -820,6 +936,7 @@ function ThreadDialog({
const reply = useReplyToNote();
const archive = useArchiveReceivedNote();
const [replyText, setReplyText] = useState("");
const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
const markedRef = useRef(false);
useEffect(() => {
@@ -833,8 +950,21 @@ function ThreadDialog({
const submitReply = () => {
const c = replyText.trim();
if (!c) return;
if (!thread) return;
const isOwner = thread.isOwner;
const recipients = thread.recipients;
let recipientUserId: number | undefined;
if (isOwner) {
if (recipients.length === 0) return;
if (recipients.length === 1) {
recipientUserId = recipients[0].recipientUserId;
} else {
if (ownerReplyTarget == null) return;
recipientUserId = ownerReplyTarget;
}
}
reply.mutate(
{ id: noteId, content: c },
{ id: noteId, content: c, recipientUserId },
{ onSuccess: () => setReplyText("") },
);
};
@@ -900,28 +1030,41 @@ function ThreadDialog({
<div className="text-xs text-muted-foreground italic">
{t("notes.noRepliesYet", "No replies yet")}
</div>
) : thread.isOwner || thread.isAdmin ? (
<GroupedReplies thread={thread} lang={lang} />
) : (
<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>
<ReplyBubble key={r.id} reply={r} lang={lang} />
))}
</div>
)}
</div>
{!thread.isOwner && thread.myStatus !== null && thread.myStatus !== "archived" && (
{(thread.isOwner ||
(thread.myStatus !== null && thread.myStatus !== "archived")) && (
<div className="border-t pt-3 flex flex-col gap-2">
{thread.isOwner && thread.recipients.length > 1 && (
<select
value={ownerReplyTarget ?? ""}
onChange={(e) =>
setOwnerReplyTarget(
e.target.value === "" ? null : Number(e.target.value),
)
}
data-testid="thread-owner-reply-target"
className="text-sm rounded-md border border-black/10 bg-white/80 px-2 py-1.5"
>
<option value="">
{t("notes.to", "To:")}
</option>
{thread.recipients.map((r) => (
<option key={r.recipientUserId} value={r.recipientUserId}>
{userDisplayName(r.recipient, lang)}
</option>
))}
</select>
)}
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
@@ -930,23 +1073,33 @@ function ThreadDialog({
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>
{!thread.isOwner ? (
<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>
) : (
<span />
)}
<Button
size="sm"
onClick={submitReply}
disabled={!replyText.trim() || reply.isPending}
disabled={
!replyText.trim() ||
reply.isPending ||
(thread.isOwner &&
thread.recipients.length > 1 &&
ownerReplyTarget == null)
}
data-testid="thread-reply-submit"
>
<Send size={14} className="me-1" />
@@ -962,6 +1115,94 @@ function ThreadDialog({
);
}
function ReplyBubble({
reply,
lang,
}: {
reply: NoteThread["replies"][number];
lang: string;
}) {
return (
<div
data-testid={`thread-reply-${reply.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 flex items-center justify-between gap-2">
<span className="truncate">{userDisplayName(reply.sender, lang)}</span>
<span className="shrink-0">{formatReplyTime(reply.createdAt, lang)}</span>
</div>
<div className="whitespace-pre-wrap break-words">{reply.content}</div>
</div>
);
}
function formatReplyTime(iso: string, lang: string) {
try {
return new Date(iso).toLocaleString(lang === "ar" ? "ar" : "en", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function GroupedReplies({
thread,
lang,
}: {
thread: NoteThread;
lang: string;
}) {
const { t } = useTranslation();
const recipientNames = new Map<number, string>();
for (const r of thread.recipients) {
recipientNames.set(r.recipientUserId, userDisplayName(r.recipient, lang));
}
// Group replies by the recipient that the reply is "with" — i.e. the
// non-owner participant. For replies the owner sent, that is the
// `recipientUserId` field on the reply; for replies sent by a recipient
// it is the reply's `senderUserId`.
const groups = new Map<number, NoteThread["replies"]>();
const ownerId = thread.senderUserId;
for (const r of thread.replies) {
const otherId = r.senderUserId === ownerId ? r.recipientUserId : r.senderUserId;
if (!groups.has(otherId)) groups.set(otherId, []);
groups.get(otherId)!.push(r);
}
if (groups.size === 0) {
return (
<div className="text-xs text-muted-foreground italic">
{t("notes.noConversationsYet", "No conversations yet")}
</div>
);
}
return (
<div className="space-y-3 max-h-72 overflow-y-auto">
{Array.from(groups.entries()).map(([otherId, replies]) => (
<div
key={otherId}
data-testid={`thread-conversation-${otherId}`}
className="rounded-lg bg-white/40 border border-black/5 p-2"
>
<div className="text-[11px] font-semibold text-foreground/80 mb-1.5">
{t("notes.conversationWith", {
name: recipientNames.get(otherId) ?? `#${otherId}`,
})}
</div>
<div className="space-y-2">
{replies.map((r) => (
<ReplyBubble key={r.id} reply={r} lang={lang} />
))}
</div>
</div>
))}
</div>
);
}
function ColorPicker({
value,
onChange,
@@ -147,6 +147,9 @@ test("sender → recipient → reply flow updates Inbox + Sent tabs", async ({
{ timeout: 15_000 },
);
await page.getByTestId("send-note-submit").click();
// Confirm-before-send dialog: explicit confirmation step.
await expect(page.getByTestId("send-note-confirm")).toBeVisible();
await page.getByTestId("send-note-confirm-submit").click();
await sendPromise;
await expect(sendDialog).toBeHidden();