f48ed13b00
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).
1620 lines
53 KiB
TypeScript
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;
|