dcaebb38f5
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.
1122 lines
36 KiB
TypeScript
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;
|