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.
Two prior code-review rounds were addressed in this commit:
Round 1 (independence):
- Added immutable snapshot columns (title/content/color) on note_recipients.
- Dropped the FK from 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 (validation REJECT fixes):
- 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 (shared handler).
- Added OpenAPI ops for /notes/sent, /notes/received, /notes/{id},
/notes/{id}/send, /notes/{id}/read, /notes/{id}/archive,
/notes/{id}/reply, and ran orval codegen.
- use-notifications-socket.ts now shows bilingual toasts for note_received
and note_replied (suppressed during the socket warmup window).
- home.tsx renders an unread badge on the Notes app tile, fed by
useReceivedNotes(false) filtered to status === "unread"; refreshes
automatically on socket invalidation.
Tests: 7 backend tests in notes-share.test.mjs (independence,
archived-reply, owner-reply preserves recipient status, GET /notes/:id
alias, etc.) + the notes-inbox e2e all pass. tx-os typecheck is clean.
Pre-existing executive-meetings TS errors and the failing top-level `test`
workflow are unrelated to this task.
This commit is contained in:
@@ -318,6 +318,64 @@ test("reply on an archived recipient row clears archivedAt and bumps to replied"
|
||||
assert.equal(rows[0].archived_at, null, "archivedAt must be cleared on reply");
|
||||
});
|
||||
|
||||
test("owner can reply on their own note; recipient status is preserved", async () => {
|
||||
const sender = await createUser("notes_oreply_sender");
|
||||
const recipient = await createUser("notes_oreply_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] }),
|
||||
});
|
||||
|
||||
// Recipient leaves the note unread, then the owner replies. Owner reply
|
||||
// must succeed (not 403) and must NOT flip the recipient's status.
|
||||
const ownerReply = await api(`/notes/${note.id}/reply`, sCookie, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "owner says hi" }),
|
||||
});
|
||||
assert.equal(
|
||||
ownerReply.status,
|
||||
201,
|
||||
`owner reply expected 201, got ${ownerReply.status}`,
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT status FROM note_recipients
|
||||
WHERE note_id = $1 AND recipient_user_id = $2`,
|
||||
[note.id, recipient.id],
|
||||
);
|
||||
assert.equal(rows[0].status, "unread", "owner reply must not flip recipient status");
|
||||
|
||||
// Recipient sees the owner reply in their thread.
|
||||
const thread = await (await api(`/notes/${note.id}/thread`, rCookie)).json();
|
||||
assert.ok(
|
||||
thread.replies.some((r) => r.content === "owner says hi"),
|
||||
"recipient must see the owner's reply in the thread",
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /notes/:id is the same payload as /notes/:id/thread", async () => {
|
||||
const sender = await createUser("notes_alias_sender");
|
||||
const recipient = await createUser("notes_alias_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 a = await (await api(`/notes/${note.id}`, rCookie)).json();
|
||||
const b = await (await api(`/notes/${note.id}/thread`, rCookie)).json();
|
||||
assert.equal(a.id, b.id);
|
||||
assert.equal(a.title, b.title);
|
||||
assert.equal(a.content, b.content);
|
||||
assert.equal(a.color, b.color);
|
||||
assert.equal(a.myStatus, b.myStatus);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user