Files
TX/artifacts/api-server/src/routes/notes.ts
T
Riyadh f48ed13b00 notes: address review feedback for folder sharing (Task #445)
Re-applied review fixes on top of the live folder-sharing implementation:

- PATCH/DELETE /notes/:id now do an id-only lookup and return an explicit
  403 when the caller is not the owner (previously returned an ambiguous
  404). This makes the read-only contract for shared-folder recipients
  precise instead of silently masquerading as "not found".
- PUT /note-folders/:id/shares rejects self-share with 400
  "Cannot share folder with yourself" instead of silently filtering the
  caller's id out of the recipient set.
- GET /note-folders/shared-with-me now includes a `noteCount` (live count
  of the owner's non-archived notes in the folder) computed via SQL
  subquery; SharedFolder TS type updated; folders rail renders the count
  next to each shared folder.
- SharedFolderView accepts the page's `search` value and filters the
  read-only list client-side over title + content + checklist item text,
  matching the owner's search experience. Empty state distinguishes
  "no notes" from "no matches".
- Added GET /notes?sharedFolderId=:id as a spec-aligned alias that
  returns the owner's live notes inside a folder shared with the caller
  (gated by an active row in note_folder_shares).
- Updated the stale comment block above the share routes that claimed
  recipient writes "just 404"; documents the new 403 contract.

Deviations / out of scope:
- Project uses `drizzle-kit push` (no migrations directory); no SQL
  migration file was added.
- Pre-existing TS errors in artifacts/api-server/src/routes/executive-meetings.ts
  remain; unrelated to Task #445.
- "Strict reject for nonexistent recipient ids in share PUT" left as
  optional follow-up (currently silently dropped per existing behavior).
2026-05-09 11:13:56 +00:00

1620 lines
53 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,
noteFolderSharesTable,
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!;
// ?sharedFolderId=:id — read-only access to the live notes inside a
// folder shared WITH the caller. Spec alias for the same data the
// dedicated `/note-folders/:id/shared-notes` endpoint returns; kept
// here so clients that prefer the unified `/notes?…` shape work too.
const sharedFolderIdRaw = req.query.sharedFolderId;
if (typeof sharedFolderIdRaw === "string" && sharedFolderIdRaw.length > 0) {
const parsed = Number(sharedFolderIdRaw);
if (!Number.isInteger(parsed) || parsed <= 0) {
res.status(400).json({ error: "Invalid sharedFolderId" });
return;
}
const [share] = await db
.select({ folderId: noteFolderSharesTable.folderId })
.from(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, parsed),
eq(noteFolderSharesTable.recipientUserId, userId),
),
);
if (!share) {
res.status(403).json({ error: "Folder is not shared with you" });
return;
}
const [folder] = await db
.select({ ownerId: noteFoldersTable.userId })
.from(noteFoldersTable)
.where(eq(noteFoldersTable.id, parsed));
if (!folder) {
res.status(404).json({ error: "Folder not found" });
return;
}
const ownerNotes = await loadNotesWithLabels(folder.ownerId, false);
const filtered = ownerNotes
.filter((n) => n.folderId === parsed)
.map((n) => ({ ...n, readOnly: true as const }));
res.json(filtered);
return;
}
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;
// Two-step lookup so non-owners get a precise 403 (instead of an
// ambiguous 404). Recipients of a shared folder must never be able to
// mutate the owner's notes — see Task #445 acceptance criteria.
const [existing] = await db
.select()
.from(notesTable)
.where(eq(notesTable.id, id.id));
if (!existing) {
res.status(404).json({ error: "Note not found" });
return;
}
if (existing.userId !== userId) {
res.status(403).json({ error: "Forbidden" });
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;
}
// Two-step lookup so non-owners (e.g. shared-folder recipients) get
// a precise 403 instead of a 404 — see Task #445 acceptance criteria.
const [existing] = await db
.select({ userId: notesTable.userId })
.from(notesTable)
.where(eq(notesTable.id, id.id));
if (!existing) {
res.status(404).json({ error: "Note not found" });
return;
}
if (existing.userId !== userId) {
res.status(403).json({ error: "Forbidden" });
return;
}
await db.delete(notesTable).where(eq(notesTable.id, id.id));
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 });
});
// Task #438: collaborative checklist toggle. The owner OR any active
// (non-archived) recipient can flip an item's `done` flag; the change
// is persisted to the live `notes.items` AND mirrored to every
// `note_recipients.items` snapshot so all collaborators see the same
// shared state. Other audience members get a `note_checklist_changed`
// socket push so their open popups / threads refetch.
router.post(
"/notes/:id/checklist/:itemId/toggle",
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 itemId = req.params.itemId;
if (typeof itemId !== "string" || itemId.length === 0 || itemId.length > 64) {
res.status(400).json({ error: "Invalid itemId" });
return;
}
if (!req.body || typeof req.body.done !== "boolean") {
res.status(400).json({ error: "Invalid done" });
return;
}
const done: boolean = req.body.done;
// Wrap the read-modify-write + mirror in a single transaction with
// a row lock on the live note so concurrent toggles from multiple
// collaborators can't lose each other's updates. Without this, two
// people ticking different items at the same time could each read
// the same `items` array, modify their copy, and have the slower
// writer overwrite the faster one.
type ToggleOutcome =
| { kind: "ok"; updated: ChecklistItem[]; ownerUserId: number; recipientIds: number[] }
| { kind: "noop"; items: ChecklistItem[] }
| { kind: "err"; status: number; error: string };
const outcome: ToggleOutcome = await db.transaction(async (tx) => {
const locked = await tx.execute(
sql`SELECT * FROM ${notesTable} WHERE ${notesTable.id} = ${id.id} FOR UPDATE`,
);
const lockedRows = (locked as unknown as { rows: Array<{
id: number; user_id: number; kind: string; items: unknown;
}> }).rows;
const note = lockedRows[0];
if (!note) return { kind: "err", status: 404, error: "Note not found" };
if (note.kind !== "checklist")
return { kind: "err", status: 400, error: "Not a checklist note" };
const isOwner = note.user_id === userId;
if (!isOwner) {
const [recipientRow] = await tx
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
if (!recipientRow || recipientRow.status === "archived")
return { kind: "err", status: 403, error: "Forbidden" };
}
const items = (note.items ?? []) as ChecklistItem[];
const idx = items.findIndex((it) => it.id === itemId);
if (idx === -1) return { kind: "err", status: 404, error: "Item not found" };
if (items[idx].done === done) return { kind: "noop", items };
const updated = items.map((it, i) => (i === idx ? { ...it, done } : it));
await tx
.update(notesTable)
.set({ items: updated })
.where(eq(notesTable.id, id.id));
// Mirror to every recipient snapshot inside the same transaction
// so collaborative state stays consistent (recipients render from
// their snapshot row, not the live note).
await tx
.update(noteRecipientsTable)
.set({ items: updated })
.where(eq(noteRecipientsTable.noteId, id.id));
const recipientRows = await tx
.select({ recipientUserId: noteRecipientsTable.recipientUserId })
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, id.id));
return {
kind: "ok",
updated,
ownerUserId: note.user_id,
recipientIds: recipientRows.map((r) => r.recipientUserId),
};
});
if (outcome.kind === "err") {
res.status(outcome.status).json({ error: outcome.error });
return;
}
if (outcome.kind === "noop") {
// Idempotent no-op — still return current items so the client can
// reconcile if its optimistic state was stale.
res.json({ success: true, items: outcome.items });
return;
}
const updated = outcome.updated;
const audience = new Set<number>([
outcome.ownerUserId,
...outcome.recipientIds,
]);
audience.delete(userId);
for (const uid of audience) {
await emitToUser(uid, "note_checklist_changed", {
noteId: id.id,
itemId,
done,
items: updated,
actorUserId: userId,
});
}
res.json({ success: true, items: updated });
},
);
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, shareCounts] = 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),
db
.select({
folderId: noteFolderSharesTable.folderId,
count: sql<number>`count(*)::int`,
})
.from(noteFolderSharesTable)
.innerJoin(
noteFoldersTable,
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
)
.where(eq(noteFoldersTable.userId, userId))
.groupBy(noteFolderSharesTable.folderId),
]);
const countMap = new Map<number, number>();
for (const row of counts) {
if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
}
const shareCountMap = new Map<number, number>();
for (const row of shareCounts) {
shareCountMap.set(row.folderId, Number(row.count));
}
res.json(
folders.map((f) => ({
...f,
noteCount: countMap.get(f.id) ?? 0,
sharedWithCount: shareCountMap.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, sharedWithCount: 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),
),
);
const [shareRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, folder.id));
res.json({
...folder,
noteCount: Number(countRow?.count ?? 0),
sharedWithCount: Number(shareRow?.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 });
});
// ---------- Folder-level live sharing ----------
//
// A folder owner can share a folder read-only with other users. Recipients see
// the owner's CURRENT notes inside the folder (live, not snapshot). They cannot
// edit, add, move, or delete anything; the per-note write routes
// (`PATCH/DELETE /notes/:id`, archive, checklist toggle for non-recipients)
// look up the row by id and explicitly return 403 when the caller is not
// the owner, so a shared-folder recipient that probes a write endpoint
// gets a clear authorization error instead of an ambiguous 404.
router.get(
"/note-folders/shared-with-me",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const rows = await db
.select({
id: noteFoldersTable.id,
name: noteFoldersTable.name,
ownerId: noteFoldersTable.userId,
sharedAt: noteFolderSharesTable.sharedAt,
ownerUsername: usersTable.username,
ownerDisplayNameAr: usersTable.displayNameAr,
ownerDisplayNameEn: usersTable.displayNameEn,
// Live count of the owner's non-archived notes in this folder so
// the recipient's rail can show "Folder name (12)" without a
// second roundtrip per row.
noteCount: sql<number>`(
SELECT COUNT(*)::int FROM ${notesTable}
WHERE ${notesTable.folderId} = ${noteFoldersTable.id}
AND ${notesTable.userId} = ${noteFoldersTable.userId}
AND ${notesTable.isArchived} = false
)`.as("note_count"),
})
.from(noteFolderSharesTable)
.innerJoin(
noteFoldersTable,
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
)
.innerJoin(usersTable, eq(noteFoldersTable.userId, usersTable.id))
.where(eq(noteFolderSharesTable.recipientUserId, userId))
.orderBy(noteFoldersTable.name);
res.json(rows);
},
);
router.get(
"/note-folders/:id/shares",
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;
}
if (!(await userOwnsFolder(userId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
const rows = await db
.select({
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt,
})
.from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
.where(eq(noteFolderSharesTable.folderId, id.id))
.orderBy(noteFolderSharesTable.sharedAt);
res.json(rows);
},
);
router.put(
"/note-folders/:id/shares",
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;
}
if (!(await userOwnsFolder(userId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
const body = req.body;
if (!body || typeof body !== "object" || !Array.isArray(body.recipientUserIds)) {
res.status(400).json({ error: "Invalid recipientUserIds" });
return;
}
const desired = new Set<number>();
for (const x of body.recipientUserIds) {
const n = Number(x);
if (!Number.isInteger(n) || n <= 0) {
res.status(400).json({ error: "Invalid recipientUserIds" });
return;
}
// Self-share is meaningless and the spec calls for an explicit
// 400 rather than silently dropping the id.
if (n === userId) {
res.status(400).json({ error: "Cannot share folder with yourself" });
return;
}
desired.add(n);
}
// Validate that every desired recipient actually exists.
if (desired.size > 0) {
const valid = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(usersTable.id, Array.from(desired)));
const validIds = new Set(valid.map((r) => r.id));
for (const id of Array.from(desired)) {
if (!validIds.has(id)) desired.delete(id);
}
}
// Diff current vs desired and apply incrementally so repeated PUTs are
// idempotent and we only emit notifications for genuinely new recipients.
const current = await db
.select({ recipientUserId: noteFolderSharesTable.recipientUserId })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, id.id));
const currentSet = new Set(current.map((r) => r.recipientUserId));
const toAdd: number[] = [];
const toRemove: number[] = [];
for (const r of desired) if (!currentSet.has(r)) toAdd.push(r);
for (const r of currentSet) if (!desired.has(r)) toRemove.push(r);
if (toAdd.length > 0) {
await db
.insert(noteFolderSharesTable)
.values(toAdd.map((rid) => ({ folderId: id.id, recipientUserId: rid })))
.onConflictDoNothing();
}
if (toRemove.length > 0) {
await db
.delete(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
inArray(noteFolderSharesTable.recipientUserId, toRemove),
),
);
}
// Emit live socket events so recipients' rails refresh without polling.
for (const rid of toAdd) {
void emitToUser(rid, "note-folder-shared", { folderId: id.id });
}
for (const rid of toRemove) {
void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
}
const rows = await db
.select({
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt,
})
.from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
.where(eq(noteFolderSharesTable.folderId, id.id))
.orderBy(noteFolderSharesTable.sharedAt);
res.json(rows);
},
);
router.delete(
"/note-folders/:id/shares/:userId",
requireAuth,
async (req, res): Promise<void> => {
const ownerId = req.session.userId!;
const id = parseIdParam(req.params.id);
const target = parseIdParam(req.params.userId);
if (!id.ok || !target.ok) {
res.status(400).json({ error: "Invalid id" });
return;
}
if (!(await userOwnsFolder(ownerId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
await db
.delete(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, target.id),
),
);
void emitToUser(target.id, "note-folder-unshared", { folderId: id.id });
res.json({ success: true });
},
);
/**
* GET /note-folders/:id/shared-notes
*
* Recipient view of a shared folder. Returns the OWNER's current notes inside
* the folder (live join — not a snapshot), with `readOnly: true` so the client
* knows to disable every write affordance. 403 if the caller is not a current
* recipient of the share.
*/
router.get(
"/note-folders/:id/shared-notes",
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 [share] = await db
.select({ folderId: noteFolderSharesTable.folderId })
.from(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, userId),
),
);
if (!share) {
res.status(403).json({ error: "Forbidden" });
return;
}
const [folder] = await db
.select()
.from(noteFoldersTable)
.where(eq(noteFoldersTable.id, id.id));
if (!folder) {
res.status(404).json({ error: "Folder not found" });
return;
}
const notes = await db
.select()
.from(notesTable)
.where(
and(
eq(notesTable.userId, folder.userId),
eq(notesTable.folderId, folder.id),
eq(notesTable.isArchived, false),
),
)
.orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt));
let withLabels: Array<(typeof notes)[number] & { labelIds: number[] }> = [];
if (notes.length > 0) {
const ids = notes.map((n) => n.id);
const assignments = await db
.select()
.from(noteLabelAssignmentsTable)
.where(inArray(noteLabelAssignmentsTable.noteId, ids));
const map = new Map<number, number[]>();
for (const a of assignments) {
const arr = map.get(a.noteId) ?? [];
arr.push(a.labelId);
map.set(a.noteId, arr);
}
withLabels = notes.map((n) => ({ ...n, labelIds: map.get(n.id) ?? [] }));
}
const [owner] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, folder.userId));
res.json({
folder: { id: folder.id, name: folder.name, ownerId: folder.userId },
owner: owner ?? null,
notes: withLabels.map((n) => ({ ...n, readOnly: true as const })),
});
},
);
export default router;