Files
TX/artifacts/api-server/src/routes/notes.ts
T
riyadhafraa dcaebb38f5 notes: re-send fires popup + notification again
Previously POST /api/notes/:id/send used onConflictDoNothing on
(noteId, recipientUserId) and only iterated `inserted` rows for
side-effects, so a re-send to the same recipient was silent — no
note_received socket event, no notification — even though the API
returned 200. Reported by the user: "I sent again to rh, why
didn't it reach them?"

Fix: after the idempotent insert, load the existing recipient
rows for any recipients that were skipped, and run the
notification + socket emit loop over BOTH inserted and existing
rows. The DB row is still not duplicated (snapshot semantics
preserved), but the recipient now sees the popup and gets a fresh
notification on every re-send.

Verified:
- @workspace/api-server tests/notes-share.test.mjs — 12/12 pass
  (including "re-sending to the same recipient does not duplicate
  the recipient row").
- tx-os e2e notes-popup-on-receive — 3/3 pass.
2026-05-07 10:32:25 +00:00

1122 lines
36 KiB
TypeScript

import { Router, type IRouter } from "express";
import { eq, and, desc, inArray, or, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
notesTable,
noteFoldersTable,
noteLabelsTable,
noteLabelAssignmentsTable,
noteRecipientsTable,
noteRepliesTable,
usersTable,
notificationsTable,
} from "@workspace/db";
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" };
return { ok: true, id: n };
}
// Postgres unique-constraint violation (SQLSTATE 23505). Used to distinguish
// duplicate-name errors (→ 409) from unrelated DB failures (→ 500).
function isUniqueViolation(e: unknown): boolean {
return (
typeof e === "object" && e !== null && "code" in e &&
(e as { code?: unknown }).code === "23505"
);
}
function parseStringMax(v: unknown, max: number): string | undefined {
if (v === undefined || v === null) return undefined;
if (typeof v !== "string") return undefined;
return v.slice(0, max);
}
function parseLabelIds(v: unknown): number[] | undefined {
if (v === undefined) return undefined;
if (!Array.isArray(v)) return undefined;
const out: number[] = [];
for (const x of v) {
const n = Number(x);
if (Number.isInteger(n) && n > 0) out.push(n);
}
return out;
}
type ChecklistItem = { id: string; text: string; done: boolean };
interface NoteInput {
title?: string;
content?: string;
color?: string;
kind?: "text" | "checklist";
items?: ChecklistItem[] | null;
isPinned?: boolean;
isArchived?: boolean;
labelIds?: number[];
folderId?: number | null;
}
function parseKind(v: unknown): "text" | "checklist" | undefined {
return v === "text" || v === "checklist" ? v : undefined;
}
function parseItems(v: unknown): ChecklistItem[] | null | undefined {
if (v === null) return null;
if (!Array.isArray(v)) return undefined;
if (v.length > 200) return undefined;
const out: ChecklistItem[] = [];
for (const it of v) {
if (!it || typeof it !== "object") return undefined;
const obj = it as Record<string, unknown>;
const id = typeof obj.id === "string" && obj.id.length > 0 && obj.id.length <= 64
? obj.id : null;
const text = typeof obj.text === "string" ? obj.text.slice(0, 500) : null;
const done = typeof obj.done === "boolean" ? obj.done : false;
if (id === null || text === null) return undefined;
out.push({ id, text, done });
}
return out;
}
function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } {
if (v === null) return { ok: true, value: null };
const n = Number(v);
if (!Number.isInteger(n) || n <= 0) return { ok: false };
return { ok: true, value: n };
}
function parseFolderName(body: any): { ok: true; name: string } | { ok: false; error: string } {
if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
if (typeof body.name !== "string") return { ok: false, error: "Invalid name" };
const name = body.name.trim();
if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" };
return { ok: true, name };
}
async function userOwnsFolder(userId: number, folderId: number): Promise<boolean> {
const [row] = await db
.select({ id: noteFoldersTable.id })
.from(noteFoldersTable)
.where(and(eq(noteFoldersTable.id, folderId), eq(noteFoldersTable.userId, userId)));
return !!row;
}
function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInput } | { ok: false; error: string } {
if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
const data: NoteInput = {};
if (body.title !== undefined) {
const t = parseStringMax(body.title, 300);
if (t === undefined) return { ok: false, error: "Invalid title" };
data.title = t;
} else if (isCreate) data.title = "";
if (body.content !== undefined) {
if (typeof body.content !== "string") return { ok: false, error: "Invalid content" };
data.content = body.content;
} else if (isCreate) data.content = "";
if (body.color !== undefined) {
const c = parseStringMax(body.color, 32);
if (c === undefined) return { ok: false, error: "Invalid color" };
data.color = c;
} else if (isCreate) data.color = "default";
if (body.kind !== undefined) {
const k = parseKind(body.kind);
if (!k) return { ok: false, error: "Invalid kind" };
data.kind = k;
} else if (isCreate) data.kind = "text";
if (body.items !== undefined) {
const its = parseItems(body.items);
if (its === undefined) return { ok: false, error: "Invalid items" };
data.items = its;
}
// Normalize items to match kind whenever kind is being set explicitly so
// the column is never out of sync (e.g. checklist-mode without items, or
// text-mode that still carries stale items from the previous shape).
if (data.kind !== undefined) {
if (data.kind === "checklist") {
data.items = data.items ?? [];
} else {
data.items = null;
}
}
if (body.isPinned !== undefined) {
if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" };
data.isPinned = body.isPinned;
} else if (isCreate) data.isPinned = false;
if (body.isArchived !== undefined) {
if (typeof body.isArchived !== "boolean") return { ok: false, error: "Invalid isArchived" };
data.isArchived = body.isArchived;
}
if (body.labelIds !== undefined) {
const ids = parseLabelIds(body.labelIds);
if (ids === undefined) return { ok: false, error: "Invalid labelIds" };
data.labelIds = ids;
} else if (isCreate) data.labelIds = [];
if (body.folderId !== undefined) {
const fid = parseFolderIdInput(body.folderId);
if (!fid.ok) return { ok: false, error: "Invalid folderId" };
data.folderId = fid.value;
}
return { ok: true, data };
}
function parseLabelName(body: any): { ok: true; name: string } | { ok: false; error: string } {
if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
if (typeof body.name !== "string") return { ok: false, error: "Invalid name" };
const name = body.name.trim();
if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" };
return { ok: true, name };
}
async function loadNotesWithLabels(userId: number, archived: boolean) {
const notes = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.userId, userId), eq(notesTable.isArchived, archived)))
.orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt));
if (notes.length === 0) return [];
const noteIds = notes.map((n) => n.id);
const assignments = await db
.select()
.from(noteLabelAssignmentsTable)
.where(inArray(noteLabelAssignmentsTable.noteId, noteIds));
const labelsByNote = new Map<number, number[]>();
for (const a of assignments) {
const arr = labelsByNote.get(a.noteId) ?? [];
arr.push(a.labelId);
labelsByNote.set(a.noteId, arr);
}
return notes.map((n) => ({ ...n, labelIds: labelsByNote.get(n.id) ?? [] }));
}
async function setNoteLabels(noteId: number, userId: number, labelIds: number[]) {
await db.delete(noteLabelAssignmentsTable).where(eq(noteLabelAssignmentsTable.noteId, noteId));
if (labelIds.length === 0) return;
const valid = await db
.select({ id: noteLabelsTable.id })
.from(noteLabelsTable)
.where(and(eq(noteLabelsTable.userId, userId), inArray(noteLabelsTable.id, labelIds)));
if (valid.length === 0) return;
await db.insert(noteLabelAssignmentsTable).values(
valid.map((l) => ({ noteId, labelId: l.id })),
);
}
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,
kind: r.kind,
items: r.items,
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) }];
}
async function handleListMyNotes(
req: import("express").Request,
res: import("express").Response,
): Promise<void> {
const userId = req.session.userId!;
const archived = req.query.archived === "true";
const notes = await loadNotesWithLabels(userId, archived);
res.json(notes);
}
// Backward-compatible: GET /notes lists the caller's personal notes.
router.get("/notes", requireAuth, handleListMyNotes);
// Explicit alias requested by the messaging spec.
router.get("/notes/my", requireAuth, handleListMyNotes);
router.post("/notes", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = parseNoteInput(req.body, true);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
const { labelIds = [], folderId, ...data } = parsed.data;
if (folderId != null && !(await userOwnsFolder(userId, folderId))) {
res.status(400).json({ error: "Invalid folderId" });
return;
}
const [note] = await db
.insert(notesTable)
.values({
userId,
title: data.title ?? "",
content: data.content ?? "",
color: data.color ?? "default",
kind: data.kind ?? "text",
items: data.items ?? null,
isPinned: data.isPinned ?? false,
isArchived: data.isArchived ?? false,
folderId: folderId ?? null,
})
.returning();
if (labelIds.length > 0) await setNoteLabels(note.id, userId, labelIds);
const [withLabels] = await loadOne(note.id, userId);
res.status(201).json(withLabels);
});
router.patch("/notes/:id", 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 parsed = parseNoteInput(req.body, false);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
const { labelIds, ...data } = parsed.data;
const [existing] = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId)));
if (!existing) {
res.status(404).json({ error: "Note not found" });
return;
}
if (data.folderId != null && !(await userOwnsFolder(userId, data.folderId))) {
res.status(400).json({ error: "Invalid folderId" });
return;
}
// If the caller patched `items` without specifying `kind`, normalize
// against the note's current kind so a text note can never end up
// carrying checklist items (and vice versa). parseNoteInput handles the
// case where `kind` is provided; this covers the items-only PATCH.
if (data.kind === undefined && data.items !== undefined) {
if (existing.kind === "checklist") {
data.items = data.items ?? [];
} else {
data.items = null;
}
}
if (Object.keys(data).length > 0) {
await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
}
if (labelIds) await setNoteLabels(existing.id, userId, labelIds);
const [withLabels] = await loadOne(existing.id, userId);
res.json(withLabels);
});
router.delete("/notes/:id", 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 result = await db
.delete(notesTable)
.where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId)))
.returning();
if (result.length === 0) {
res.status(404).json({ error: "Note not found" });
return;
}
res.json({ success: true });
});
// ----- Sent / Received / Detail -----
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));
type SentNoteOut = (typeof notes)[number] & {
recipients: Awaited<ReturnType<typeof loadRecipientsForNote>>;
replyCount: number;
};
const out: SentNoteOut[] = [];
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);
});
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 render their immutable snapshot, not the live note row.
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,
kind: r.kind,
items: r.items,
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).
*/
// GET /notes/:id — alias of /notes/:id/thread.
async function handleNoteDetail(req: import("express").Request, res: import("express").Response): 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;
// Admin: 200 if any trace exists. Non-participant: 403. No trace: 404.
const [anyRecipientRow] =
!note && !recipientRow
? await db
.select({ id: noteRecipientsTable.id })
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, id.id))
.limit(1)
: [null];
if (!note && !recipientRow) {
if (admin && anyRecipientRow) {
// fall through — admin reads from recipient snapshots below
} else if (anyRecipientRow) {
res.status(403).json({ error: "Forbidden" });
return;
} else {
res.status(404).json({ error: "Note not found" });
return;
}
} else if (!admin && !isSender && !recipientRow) {
res.status(403).json({ error: "Forbidden" });
return;
}
// Admin reading a deleted note: fall back to any recipient snapshot.
let adminFallbackRow: typeof recipientRow | null = null;
if (admin && !note && !recipientRow) {
const [r] = await db
.select()
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, id.id))
.orderBy(noteRecipientsTable.id)
.limit(1);
adminFallbackRow = r ?? null;
}
const snapshotRow = recipientRow ?? adminFallbackRow;
// Recipients render their snapshot; sender/admin render the live note.
const useSnapshot = !isSender && !admin && !!recipientRow;
const senderUserId =
note?.userId ?? recipientRow?.senderUserId ?? snapshotRow?.senderUserId ?? 0;
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),
);
}
const titleFallback = snapshotRow?.title ?? note?.title ?? "";
const contentFallback = snapshotRow?.content ?? note?.content ?? "";
const colorFallback = snapshotRow?.color ?? note?.color ?? "default";
const kindFallback = snapshotRow?.kind ?? note?.kind ?? "text";
const itemsFallback = snapshotRow?.items ?? note?.items ?? null;
res.json({
id: id.id,
title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback,
content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback,
color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback,
kind: useSnapshot ? recipientRow!.kind : note?.kind ?? kindFallback,
items: useSnapshot ? recipientRow!.items : note?.items ?? itemsFallback,
createdAt: useSnapshot
? recipientRow!.sentAt
: note?.createdAt ?? snapshotRow?.sentAt ?? null,
updatedAt: useSnapshot
? recipientRow!.readAt ?? recipientRow!.sentAt
: note?.updatedAt ?? snapshotRow?.sentAt ?? null,
senderUserId,
sender: senderMap.get(senderUserId) ?? null,
isOwner: isSender,
isAdmin: admin,
myStatus: recipientRow?.status ?? null,
recipients,
replies,
});
}
router.get("/notes/:id/thread", requireAuth, handleNoteDetail);
router.get("/notes/:id", requireAuth, handleNoteDetail);
// ----- Send -----
/**
* POST /notes/:id/send — owner sends note to recipients.
* Self-send and dupes are filtered out. The (noteId, recipientUserId)
* unique constraint keeps a single recipient row per (note, user), but
* a re-send still re-emits the note_received socket event and creates
* a fresh notification, so the recipient sees the popup again.
*/
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. The
// unique (noteId, recipientUserId) means a re-send to the same person
// does NOT duplicate the row — but we still want the popup +
// notification to fire again so the recipient sees the re-send. So:
// 1) insert with onConflictDoNothing (gives us new rows only)
// 2) load the existing rows for any recipients that were skipped
// 3) emit note_received + create a fresh notification for ALL of them
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,
kind: note.kind,
items: note.items,
})),
)
.onConflictDoNothing({
target: [noteRecipientsTable.noteId, noteRecipientsTable.recipientUserId],
})
.returning();
const insertedRecipientIds = new Set(inserted.map((r) => r.recipientUserId));
const resendRecipientIds = toSend.filter((rid) => !insertedRecipientIds.has(rid));
const existingRows =
resendRecipientIds.length > 0
? await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, note.id),
inArray(noteRecipientsTable.recipientUserId, resendRecipientIds),
),
)
: [];
const allRows = [...inserted, ...existingRows];
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 allRows) {
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,
recipientRowId: r.id,
title: r.title,
content: r.content,
color: r.color,
kind: r.kind,
items: r.items,
sentAt: r.sentAt,
senderUserId: userId,
sender: sender ?? null,
});
}
res.json({ success: true, sent: allRows.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;
}
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 [liveNote] = await db
.select()
.from(notesTable)
.where(eq(notesTable.id, id.id));
const [callerRecipientRow] = await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
const isOwner = !!liveNote && liveNote.userId === userId;
if (!isOwner && !callerRecipientRow) {
res.status(403).json({ error: "Forbidden" });
return;
}
let ownerUserId: number;
let otherPartyUserId: number;
let targetRow: typeof callerRecipientRow | null = null;
if (isOwner) {
ownerUserId = userId;
const requestedTarget =
typeof req.body?.recipientUserId === "number"
? req.body.recipientUserId
: null;
const allRows = await db
.select()
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, id.id));
if (requestedTarget !== null) {
targetRow = allRows.find((r) => r.recipientUserId === requestedTarget) ?? null;
} else if (allRows.length === 1) {
targetRow = allRows[0];
}
if (!targetRow) {
res.status(400).json({ error: "recipientUserId required" });
return;
}
otherPartyUserId = targetRow.recipientUserId;
} else {
targetRow = callerRecipientRow!;
ownerUserId = callerRecipientRow!.senderUserId;
otherPartyUserId = ownerUserId;
}
const [reply] = await db
.insert(noteRepliesTable)
.values({
noteId: id.id,
senderUserId: userId,
recipientUserId: otherPartyUserId,
content,
})
.returning();
// Recipient replies bump status to "replied" + un-archive. Owner replies
// leave recipient status untouched.
if (!isOwner) {
await db
.update(noteRecipientsTable)
.set({
status: "replied",
readAt: targetRow.readAt ?? new Date(),
archivedAt: null,
})
.where(eq(noteRecipientsTable.id, targetRow.id));
}
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: otherPartyUserId,
titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
titleEn: isOwner ? "New reply on a note" : "Reply to your note",
bodyAr: `${replierNameAr} رد على ملاحظة`,
bodyEn: `${replierName} replied on a note`,
type: "note",
})
.returning();
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
// Enrich the realtime payload so the recipient's client can render the
// floating reply card without an extra round-trip. We truncate the body
// to keep the socket frame small; the full reply is still in /notes.
const replyContentSnippet = (reply.content ?? "").slice(0, 280);
await emitToUser(otherPartyUserId, "note_replied", {
noteId: id.id,
recipientUserId: isOwner ? otherPartyUserId : userId,
replyId: reply.id,
replyContent: replyContentSnippet,
replier: replier ?? null,
noteTitle: liveNote?.title ?? null,
color: liveNote?.color ?? "default",
});
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
.select()
.from(noteLabelsTable)
.where(eq(noteLabelsTable.userId, userId))
.orderBy(noteLabelsTable.name);
res.json(labels);
});
router.post("/note-labels", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = parseLabelName(req.body);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
try {
const [label] = await db
.insert(noteLabelsTable)
.values({ userId, name: parsed.name })
.returning();
res.status(201).json(label);
} catch (e) {
res.status(409).json({ error: "Label already exists" });
}
});
router.patch("/note-labels/:id", 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 parsed = parseLabelName(req.body);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
try {
const [label] = await db
.update(noteLabelsTable)
.set({ name: parsed.name })
.where(and(eq(noteLabelsTable.id, id.id), eq(noteLabelsTable.userId, userId)))
.returning();
if (!label) {
res.status(404).json({ error: "Label not found" });
return;
}
res.json(label);
} catch (e) {
res.status(409).json({ error: "Label already exists" });
}
});
router.delete("/note-labels/:id", 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 result = await db
.delete(noteLabelsTable)
.where(and(eq(noteLabelsTable.id, id.id), eq(noteLabelsTable.userId, userId)))
.returning();
if (result.length === 0) {
res.status(404).json({ error: "Label not found" });
return;
}
res.json({ success: true });
});
// ----- Folders -----
router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
// Counts are scoped to the current tab (active vs archived) so the rail
// matches the visible note set. `archived` query param: "true" → archived
// notes only, otherwise active notes only.
const archived = req.query.archived === "true";
const noteScope = and(
eq(notesTable.userId, userId),
eq(notesTable.isArchived, archived),
);
const [folders, counts] = await Promise.all([
db
.select()
.from(noteFoldersTable)
.where(eq(noteFoldersTable.userId, userId))
.orderBy(noteFoldersTable.name),
db
.select({
folderId: notesTable.folderId,
count: sql<number>`count(*)::int`,
})
.from(notesTable)
.where(noteScope)
.groupBy(notesTable.folderId),
]);
const countMap = new Map<number, number>();
for (const row of counts) {
if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
}
res.json(folders.map((f) => ({ ...f, noteCount: countMap.get(f.id) ?? 0 })));
});
router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = parseFolderName(req.body);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
try {
const [folder] = await db
.insert(noteFoldersTable)
.values({ userId, name: parsed.name })
.returning();
res.status(201).json({ ...folder, noteCount: 0 });
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
return;
}
throw e;
}
});
router.patch("/note-folders/:id", 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 parsed = parseFolderName(req.body);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
// Mirror GET /note-folders: count is scoped to the current tab the caller is
// viewing so the rail badge stays in sync after a rename.
const archived = req.query.archived === "true";
try {
const [folder] = await db
.update(noteFoldersTable)
.set({ name: parsed.name })
.where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId)))
.returning();
if (!folder) {
res.status(404).json({ error: "Folder not found" });
return;
}
const [countRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(notesTable)
.where(
and(
eq(notesTable.userId, userId),
eq(notesTable.isArchived, archived),
eq(notesTable.folderId, folder.id),
),
);
res.json({ ...folder, noteCount: Number(countRow?.count ?? 0) });
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
return;
}
throw e;
}
});
router.delete("/note-folders/:id", 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 result = await db
.delete(noteFoldersTable)
.where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId)))
.returning();
if (result.length === 0) {
res.status(404).json({ error: "Folder not found" });
return;
}
res.json({ success: true });
});
export default router;