notes(#454): per-recipient view/edit folder sharing

- Schema: noteFolderShares.permission ('view'|'edit', default 'view').
- Server: resolveFolderEditAccess + emitFolderChanged helpers.
  - POST/PATCH/DELETE/checklist now allow folder editors.
  - Editors stamp notes with folder owner's userId; labels validated
    against owner; PATCH editors blocked from unfile (folderId=null)
    and cross-folder moves to non-matching owners.
  - emitFolderChanged fires for owner AND editor mutations across
    create / patch (old + new folder) / delete / checklist toggle.
  - PUT /shares accepts both legacy recipientUserIds and new
    recipients:[{userId,permission}]; diffs add/update/remove/perm-flip.
  - GET /shares, /shared-with-me, /shared-notes return permission /
    myPermission / readOnly.
- Client:
  - notes-api types: FolderSharePermission, myPermission on
    SharedFolder/SharedFolderView, permission on FolderShareRecipient,
    readOnly:boolean on SharedFolderNote.
  - useUpdateFolderShares takes recipients[].
  - useCreate/Update/DeleteNote also invalidate ['note-folders'] so
    actor's shared-folder rail badge stays fresh.
  - FolderShareDialog: per-row checkbox + segmented View/Edit toggle.
  - SharedFolderView: editor mode mounts Composer + NoteCard with
    full edit/delete/archive affordances; Send hidden in editor
    context (Composer + NoteCard hideSend prop) since /send is
    owner-only.
  - folders-rail: per-folder permission badge.
  - socket: note-folder-shared also invalidates ['notes'] +
    ['note-folders'] for fanout to owner / other editors.
  - Locales: shareDescriptionPerm, permissionView/Edit, canEdit
    (en + ar).
- Pre-existing executive-meetings.ts type errors are out of scope.
This commit is contained in:
riyadhafraa
2026-05-10 10:16:05 +00:00
parent e52c27f377
commit 1aad708cb7
8 changed files with 528 additions and 105 deletions
+289 -53
View File
@@ -117,6 +117,58 @@ async function userOwnsFolder(userId: number, folderId: number): Promise<boolean
return !!row; return !!row;
} }
// Folder-level access helpers (Task #454).
//
// `userCanEditFolder` returns true if the caller is the owner OR has a share
// row with permission="edit" for the folder. Used by every write path that
// must accept folder editors as well as the folder owner (POST/PATCH/DELETE
// /notes inside the folder, checklist toggle, etc.). Returns the resolved
// owner id so callers can stamp newly-created notes with the folder owner's
// userId — an editor's note still belongs to the folder owner so the folder
// remains a single user_id namespace and shows up to every other recipient.
async function resolveFolderEditAccess(
userId: number,
folderId: number,
): Promise<{ ok: true; ownerId: number } | { ok: false }> {
const [folder] = await db
.select({ ownerId: noteFoldersTable.userId })
.from(noteFoldersTable)
.where(eq(noteFoldersTable.id, folderId));
if (!folder) return { ok: false };
if (folder.ownerId === userId) return { ok: true, ownerId: folder.ownerId };
const [share] = await db
.select({ permission: noteFolderSharesTable.permission })
.from(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, folderId),
eq(noteFolderSharesTable.recipientUserId, userId),
),
);
if (share && share.permission === "edit") return { ok: true, ownerId: folder.ownerId };
return { ok: false };
}
// Notify the entire folder audience (owner + all current recipients except
// the actor) that a folder's note set changed. Drives live refresh of the
// shared-folder view AND the rail "Shared with me" note count.
async function emitFolderChanged(folderId: number, actorUserId: number) {
const [folder] = await db
.select({ ownerId: noteFoldersTable.userId })
.from(noteFoldersTable)
.where(eq(noteFoldersTable.id, folderId));
if (!folder) return;
const recipients = await db
.select({ uid: noteFolderSharesTable.recipientUserId })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, folderId));
const audience = new Set<number>([folder.ownerId, ...recipients.map((r) => r.uid)]);
audience.delete(actorUserId);
for (const uid of audience) {
void emitToUser(uid, "note-folder-shared", { folderId });
}
}
function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInput } | { ok: false; error: string } { 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" }; if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
const data: NoteInput = {}; const data: NoteInput = {};
@@ -315,7 +367,10 @@ async function handleListMyNotes(
return; return;
} }
const [share] = await db const [share] = await db
.select({ folderId: noteFolderSharesTable.folderId }) .select({
folderId: noteFolderSharesTable.folderId,
permission: noteFolderSharesTable.permission,
})
.from(noteFolderSharesTable) .from(noteFolderSharesTable)
.where( .where(
and( and(
@@ -335,10 +390,11 @@ async function handleListMyNotes(
res.status(404).json({ error: "Folder not found" }); res.status(404).json({ error: "Folder not found" });
return; return;
} }
const readOnly = share.permission !== "edit";
const ownerNotes = await loadNotesWithLabels(folder.ownerId, false); const ownerNotes = await loadNotesWithLabels(folder.ownerId, false);
const filtered = ownerNotes const filtered = ownerNotes
.filter((n) => n.folderId === parsed) .filter((n) => n.folderId === parsed)
.map((n) => ({ ...n, readOnly: true as const })); .map((n) => ({ ...n, readOnly }));
res.json(filtered); res.json(filtered);
return; return;
} }
@@ -360,14 +416,24 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
return; return;
} }
const { labelIds = [], folderId, ...data } = parsed.data; const { labelIds = [], folderId, ...data } = parsed.data;
if (folderId != null && !(await userOwnsFolder(userId, folderId))) { // Resolve who the new note's owner should be. The caller is allowed to
res.status(400).json({ error: "Invalid folderId" }); // create notes either in their own folders OR in a folder that's been
return; // shared with them with permission="edit"; in the editor case the note
// is stamped with the folder owner's userId so the folder remains a
// single user_id namespace and shows up to every other recipient.
let stampedUserId = userId;
if (folderId != null) {
const access = await resolveFolderEditAccess(userId, folderId);
if (!access.ok) {
res.status(400).json({ error: "Invalid folderId" });
return;
}
stampedUserId = access.ownerId;
} }
const [note] = await db const [note] = await db
.insert(notesTable) .insert(notesTable)
.values({ .values({
userId, userId: stampedUserId,
title: data.title ?? "", title: data.title ?? "",
content: data.content ?? "", content: data.content ?? "",
color: data.color ?? "default", color: data.color ?? "default",
@@ -378,8 +444,20 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
folderId: folderId ?? null, folderId: folderId ?? null,
}) })
.returning(); .returning();
if (labelIds.length > 0) await setNoteLabels(note.id, userId, labelIds); // Labels are scoped to the note OWNER (stampedUserId), not necessarily
const [withLabels] = await loadOne(note.id, userId); // the caller — when an editor creates a note in someone else's folder
// the note belongs to the folder owner, so labelIds are validated
// against that owner's label set.
if (labelIds.length > 0) await setNoteLabels(note.id, stampedUserId, labelIds);
const [withLabels] = await loadOne(note.id, stampedUserId);
// Notify every viewer of the shared folder (owner + recipients) so the
// shared-folder view and rail counts refresh live. Fires for both the
// editor branch and the owner-creating-in-their-own-shared-folder
// branch — `emitFolderChanged` is a no-op when the folder isn't
// actually shared with anyone.
if (folderId != null) {
void emitFolderChanged(folderId, userId);
}
res.status(201).json(withLabels); res.status(201).json(withLabels);
}); });
@@ -397,8 +475,9 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
} }
const { labelIds, ...data } = parsed.data; const { labelIds, ...data } = parsed.data;
// Two-step lookup so non-owners get a precise 403 (instead of an // 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 // ambiguous 404). Recipients of a shared folder may now also mutate
// mutate the owner's notes — see Task #445 acceptance criteria. // the owner's notes when their share grants permission="edit"
// (Task #454).
const [existing] = await db const [existing] = await db
.select() .select()
.from(notesTable) .from(notesTable)
@@ -407,13 +486,38 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
res.status(404).json({ error: "Note not found" }); res.status(404).json({ error: "Note not found" });
return; return;
} }
let actingAsEditor = false;
if (existing.userId !== userId) { if (existing.userId !== userId) {
res.status(403).json({ error: "Forbidden" }); if (
return; existing.folderId == null ||
!(await resolveFolderEditAccess(userId, existing.folderId)).ok
) {
res.status(403).json({ error: "Forbidden" });
return;
}
actingAsEditor = true;
} }
if (data.folderId != null && !(await userOwnsFolder(userId, data.folderId))) { // Folder-change validation. We must guard BOTH branches:
res.status(400).json({ error: "Invalid folderId" }); // - data.folderId === null → caller is unfiling the note. Editors
return; // don't own the note, so unfiling would yank it out of the shared
// folder and effectively orphan it from the owner's organization
// scheme; only the owner may unfile.
// - data.folderId !== null → caller is moving it into a folder.
// Owners can pick any of their own folders; editors are limited to
// a folder they have edit permission on AND that's owned by the
// same owner (i.e. effectively the same shared folder).
if (data.folderId !== undefined) {
if (actingAsEditor && data.folderId === null) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (data.folderId !== null) {
const access = await resolveFolderEditAccess(userId, data.folderId);
if (!access.ok || access.ownerId !== existing.userId) {
res.status(400).json({ error: "Invalid folderId" });
return;
}
}
} }
// If the caller patched `items` without specifying `kind`, normalize // If the caller patched `items` without specifying `kind`, normalize
// against the note's current kind so a text note can never end up // against the note's current kind so a text note can never end up
@@ -429,8 +533,18 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
if (Object.keys(data).length > 0) { if (Object.keys(data).length > 0) {
await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id)); await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
} }
if (labelIds) await setNoteLabels(existing.id, userId, labelIds); if (labelIds) await setNoteLabels(existing.id, existing.userId, labelIds);
const [withLabels] = await loadOne(existing.id, userId); const [withLabels] = await loadOne(existing.id, existing.userId);
// Live-refresh shared-folder viewers regardless of whether the caller
// was the owner or an editor. If the move crossed folders, both the
// source AND destination need a ping so each side's note list updates.
const oldFolderId = existing.folderId;
const newFolderId =
data.folderId !== undefined ? data.folderId : existing.folderId;
if (oldFolderId != null) void emitFolderChanged(oldFolderId, userId);
if (newFolderId != null && newFolderId !== oldFolderId) {
void emitFolderChanged(newFolderId, userId);
}
res.json(withLabels); res.json(withLabels);
}); });
@@ -441,21 +555,36 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
res.status(400).json({ error: id.error }); res.status(400).json({ error: id.error });
return; return;
} }
// Two-step lookup so non-owners (e.g. shared-folder recipients) get // Two-step lookup so non-owners (e.g. shared-folder view-only recipients)
// a precise 403 instead of a 404 — see Task #445 acceptance criteria. // get a precise 403 instead of a 404 — see Task #445 acceptance criteria.
// Editors with permission="edit" on the note's folder are allowed
// (Task #454).
const [existing] = await db const [existing] = await db
.select({ userId: notesTable.userId }) .select({ userId: notesTable.userId, folderId: notesTable.folderId })
.from(notesTable) .from(notesTable)
.where(eq(notesTable.id, id.id)); .where(eq(notesTable.id, id.id));
if (!existing) { if (!existing) {
res.status(404).json({ error: "Note not found" }); res.status(404).json({ error: "Note not found" });
return; return;
} }
let actingAsEditor = false;
if (existing.userId !== userId) { if (existing.userId !== userId) {
res.status(403).json({ error: "Forbidden" }); if (
return; existing.folderId == null ||
!(await resolveFolderEditAccess(userId, existing.folderId)).ok
) {
res.status(403).json({ error: "Forbidden" });
return;
}
actingAsEditor = true;
} }
await db.delete(notesTable).where(eq(notesTable.id, id.id)); await db.delete(notesTable).where(eq(notesTable.id, id.id));
// Owner OR editor delete — every shared-folder viewer needs a refresh
// so the deleted note disappears from their cached note list and the
// rail count drops.
if (existing.folderId != null) {
void emitFolderChanged(existing.folderId, userId);
}
res.json({ success: true }); res.json({ success: true });
}); });
@@ -854,7 +983,13 @@ router.post(
// the same `items` array, modify their copy, and have the slower // the same `items` array, modify their copy, and have the slower
// writer overwrite the faster one. // writer overwrite the faster one.
type ToggleOutcome = type ToggleOutcome =
| { kind: "ok"; updated: ChecklistItem[]; ownerUserId: number; recipientIds: number[] } | {
kind: "ok";
updated: ChecklistItem[];
ownerUserId: number;
recipientIds: number[];
folderId: number | null;
}
| { kind: "noop"; items: ChecklistItem[] } | { kind: "noop"; items: ChecklistItem[] }
| { kind: "err"; status: number; error: string }; | { kind: "err"; status: number; error: string };
const outcome: ToggleOutcome = await db.transaction(async (tx) => { const outcome: ToggleOutcome = await db.transaction(async (tx) => {
@@ -863,6 +998,7 @@ router.post(
); );
const lockedRows = (locked as unknown as { rows: Array<{ const lockedRows = (locked as unknown as { rows: Array<{
id: number; user_id: number; kind: string; items: unknown; id: number; user_id: number; kind: string; items: unknown;
folder_id: number | null;
}> }).rows; }> }).rows;
const note = lockedRows[0]; const note = lockedRows[0];
if (!note) return { kind: "err", status: 404, error: "Note not found" }; if (!note) return { kind: "err", status: 404, error: "Note not found" };
@@ -880,7 +1016,26 @@ router.post(
eq(noteRecipientsTable.recipientUserId, userId), eq(noteRecipientsTable.recipientUserId, userId),
), ),
); );
if (!recipientRow || recipientRow.status === "archived") const sentToMe = !!recipientRow && recipientRow.status !== "archived";
// Task #454: also accept folder editors on a shared folder. We
// can't call resolveFolderEditAccess here (different db handle),
// so re-implement the lookup against `tx` for transactional read
// consistency.
let folderEditor = false;
const folderId = note.folder_id;
if (!sentToMe && folderId != null) {
const [share] = await tx
.select({ permission: noteFolderSharesTable.permission })
.from(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, folderId),
eq(noteFolderSharesTable.recipientUserId, userId),
),
);
if (share && share.permission === "edit") folderEditor = true;
}
if (!sentToMe && !folderEditor)
return { kind: "err", status: 403, error: "Forbidden" }; return { kind: "err", status: 403, error: "Forbidden" };
} }
@@ -911,6 +1066,7 @@ router.post(
updated, updated,
ownerUserId: note.user_id, ownerUserId: note.user_id,
recipientIds: recipientRows.map((r) => r.recipientUserId), recipientIds: recipientRows.map((r) => r.recipientUserId),
folderId: note.folder_id,
}; };
}); });
@@ -940,6 +1096,15 @@ router.post(
actorUserId: userId, actorUserId: userId,
}); });
} }
// Task #454: also notify shared-folder viewers (owner + every other
// editor / viewer of the folder) so the shared-folder note list
// refetches and shows the toggled item — the per-recipient
// `note_checklist_changed` audience above only covers users in the
// note's send-recipients table, which is disjoint from folder-share
// recipients.
if (outcome.folderId != null) {
void emitFolderChanged(outcome.folderId, userId);
}
res.json({ success: true, items: updated }); res.json({ success: true, items: updated });
}, },
@@ -1348,6 +1513,7 @@ router.get(
name: noteFoldersTable.name, name: noteFoldersTable.name,
ownerId: noteFoldersTable.userId, ownerId: noteFoldersTable.userId,
sharedAt: noteFolderSharesTable.sharedAt, sharedAt: noteFolderSharesTable.sharedAt,
myPermission: noteFolderSharesTable.permission,
ownerUsername: usersTable.username, ownerUsername: usersTable.username,
ownerDisplayNameAr: usersTable.displayNameAr, ownerDisplayNameAr: usersTable.displayNameAr,
ownerDisplayNameEn: usersTable.displayNameEn, ownerDisplayNameEn: usersTable.displayNameEn,
@@ -1394,6 +1560,7 @@ router.get(
displayNameAr: usersTable.displayNameAr, displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn, displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt, sharedAt: noteFolderSharesTable.sharedAt,
permission: noteFolderSharesTable.permission,
}) })
.from(noteFolderSharesTable) .from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id)) .innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
@@ -1418,54 +1585,98 @@ router.put(
return; return;
} }
const body = req.body; const body = req.body;
if (!body || typeof body !== "object" || !Array.isArray(body.recipientUserIds)) { // Task #454: accept BOTH the legacy `recipientUserIds: number[]`
res.status(400).json({ error: "Invalid recipientUserIds" }); // shape (treated as permission="view") AND the new
// `recipients: [{ userId, permission }]` shape so older clients keep
// working. Internally we collapse to a single Map<userId, permission>.
const desired = new Map<number, "view" | "edit">();
const ingest = (rawId: unknown, rawPerm: unknown): string | null => {
const n = Number(rawId);
if (!Number.isInteger(n) || n <= 0) return "Invalid recipient id";
if (n === userId) return "Cannot share folder with yourself";
let perm: "view" | "edit" = "view";
if (rawPerm !== undefined) {
if (rawPerm !== "view" && rawPerm !== "edit") return "Invalid permission";
perm = rawPerm;
}
desired.set(n, perm);
return null;
};
if (!body || typeof body !== "object") {
res.status(400).json({ error: "Invalid body" });
return; return;
} }
const desired = new Set<number>(); if (Array.isArray(body.recipients)) {
for (const x of body.recipientUserIds) { for (const r of body.recipients) {
const n = Number(x); if (!r || typeof r !== "object") {
if (!Number.isInteger(n) || n <= 0) { res.status(400).json({ error: "Invalid recipients" });
res.status(400).json({ error: "Invalid recipientUserIds" }); return;
return; }
const err = ingest((r as any).userId, (r as any).permission);
if (err) {
res.status(400).json({ error: err });
return;
}
} }
// Self-share is meaningless and the spec calls for an explicit } else if (Array.isArray(body.recipientUserIds)) {
// 400 rather than silently dropping the id. for (const x of body.recipientUserIds) {
if (n === userId) { const err = ingest(x, undefined);
res.status(400).json({ error: "Cannot share folder with yourself" }); if (err) {
return; res.status(400).json({ error: err });
return;
}
} }
desired.add(n); } else {
res.status(400).json({ error: "Invalid recipients" });
return;
} }
// Validate that every desired recipient actually exists. // Drop any desired ids that don't actually exist as users (silently
// skip — same behaviour as before for unknown ids in the legacy path).
if (desired.size > 0) { if (desired.size > 0) {
const valid = await db const valid = await db
.select({ id: usersTable.id }) .select({ id: usersTable.id })
.from(usersTable) .from(usersTable)
.where(inArray(usersTable.id, Array.from(desired))); .where(inArray(usersTable.id, Array.from(desired.keys())));
const validIds = new Set(valid.map((r) => r.id)); const validIds = new Set(valid.map((r) => r.id));
for (const id of Array.from(desired)) { for (const uid of Array.from(desired.keys())) {
if (!validIds.has(id)) desired.delete(id); if (!validIds.has(uid)) desired.delete(uid);
} }
} }
// Diff current vs desired and apply incrementally so repeated PUTs are // Diff current vs desired and apply incrementally so repeated PUTs are
// idempotent and we only emit notifications for genuinely new recipients. // idempotent and we only emit notifications for genuinely new
// recipients OR permission changes.
const current = await db const current = await db
.select({ recipientUserId: noteFolderSharesTable.recipientUserId }) .select({
recipientUserId: noteFolderSharesTable.recipientUserId,
permission: noteFolderSharesTable.permission,
})
.from(noteFolderSharesTable) .from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, id.id)); .where(eq(noteFolderSharesTable.folderId, id.id));
const currentSet = new Set(current.map((r) => r.recipientUserId)); const currentMap = new Map(
current.map((r) => [r.recipientUserId, r.permission as "view" | "edit"]),
);
const toAdd: number[] = []; const toAdd: Array<{ userId: number; permission: "view" | "edit" }> = [];
const toRemove: number[] = []; const toRemove: number[] = [];
for (const r of desired) if (!currentSet.has(r)) toAdd.push(r); const toUpdate: Array<{ userId: number; permission: "view" | "edit" }> = [];
for (const r of currentSet) if (!desired.has(r)) toRemove.push(r); for (const [uid, perm] of desired) {
const cur = currentMap.get(uid);
if (cur === undefined) toAdd.push({ userId: uid, permission: perm });
else if (cur !== perm) toUpdate.push({ userId: uid, permission: perm });
}
for (const uid of currentMap.keys()) if (!desired.has(uid)) toRemove.push(uid);
if (toAdd.length > 0) { if (toAdd.length > 0) {
await db await db
.insert(noteFolderSharesTable) .insert(noteFolderSharesTable)
.values(toAdd.map((rid) => ({ folderId: id.id, recipientUserId: rid }))) .values(
toAdd.map((a) => ({
folderId: id.id,
recipientUserId: a.userId,
permission: a.permission,
})),
)
.onConflictDoNothing(); .onConflictDoNothing();
} }
if (toRemove.length > 0) { if (toRemove.length > 0) {
@@ -1478,10 +1689,28 @@ router.put(
), ),
); );
} }
for (const u of toUpdate) {
await db
.update(noteFolderSharesTable)
.set({ permission: u.permission })
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, u.userId),
),
);
}
// Emit live socket events so recipients' rails refresh without polling. // Emit live socket events so recipients' rails refresh without polling.
for (const rid of toAdd) { // Permission flips reuse the `note-folder-shared` event — recipients
void emitToUser(rid, "note-folder-shared", { folderId: id.id }); // simply refetch shared-with-me / shared-notes and pick up the new
// myPermission, which is enough to flip their UI between view / edit
// affordances without a dedicated event type.
for (const a of toAdd) {
void emitToUser(a.userId, "note-folder-shared", { folderId: id.id });
}
for (const u of toUpdate) {
void emitToUser(u.userId, "note-folder-shared", { folderId: id.id });
} }
for (const rid of toRemove) { for (const rid of toRemove) {
void emitToUser(rid, "note-folder-unshared", { folderId: id.id }); void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
@@ -1494,6 +1723,7 @@ router.put(
displayNameAr: usersTable.displayNameAr, displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn, displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt, sharedAt: noteFolderSharesTable.sharedAt,
permission: noteFolderSharesTable.permission,
}) })
.from(noteFolderSharesTable) .from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id)) .innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
@@ -1550,7 +1780,10 @@ router.get(
return; return;
} }
const [share] = await db const [share] = await db
.select({ folderId: noteFolderSharesTable.folderId }) .select({
folderId: noteFolderSharesTable.folderId,
permission: noteFolderSharesTable.permission,
})
.from(noteFolderSharesTable) .from(noteFolderSharesTable)
.where( .where(
and( and(
@@ -1562,6 +1795,8 @@ router.get(
res.status(403).json({ error: "Forbidden" }); res.status(403).json({ error: "Forbidden" });
return; return;
} }
const myPermission = share.permission === "edit" ? "edit" : "view";
const readOnly = myPermission !== "edit";
const [folder] = await db const [folder] = await db
.select() .select()
.from(noteFoldersTable) .from(noteFoldersTable)
@@ -1611,7 +1846,8 @@ router.get(
res.json({ res.json({
folder: { id: folder.id, name: folder.name, ownerId: folder.userId }, folder: { id: folder.id, name: folder.name, ownerId: folder.userId },
owner: owner ?? null, owner: owner ?? null,
notes: withLabels.map((n) => ({ ...n, readOnly: true as const })), myPermission,
notes: withLabels.map((n) => ({ ...n, readOnly })),
}); });
}, },
); );
@@ -588,6 +588,30 @@ export function FoldersRail({
})} })}
</span> </span>
</span> </span>
{/* Permission badge so the recipient can tell at a
glance whether they can edit this folder's notes
(Task #454). */}
<span
className={`shrink-0 inline-flex items-center justify-center rounded px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide ${
sf.myPermission === "edit"
? isSel
? "bg-background/20 text-background"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: isSel
? "bg-background/10 text-background/80"
: "bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300"
}`}
data-testid={`shared-folder-perm-${sf.id}`}
title={
sf.myPermission === "edit"
? t("notes.folders.canEdit", "You can edit")
: t("notes.folders.readOnly", "Read-only")
}
>
{sf.myPermission === "edit"
? t("notes.folders.permissionEdit", "Edit")
: t("notes.folders.permissionView", "View")}
</span>
<span <span
className={`shrink-0 text-[10px] tabular-nums ${ className={`shrink-0 text-[10px] tabular-nums ${
isSel ? "text-background/80" : "text-muted-foreground" isSel ? "text-background/80" : "text-muted-foreground"
@@ -335,6 +335,12 @@ export function useNotificationsSocket() {
if (typeof payload?.folderId === "number") { if (typeof payload?.folderId === "number") {
invalidate(["notes", "shared-folder", payload.folderId]); invalidate(["notes", "shared-folder", payload.folderId]);
} }
// Task #454: this event also fanouts to the OWNER + every other
// recipient when an editor mutates the folder's notes (create /
// patch / delete / checklist toggle), so refresh the owner's
// personal notes list and the folders rail badge counts too.
invalidate(["notes"]);
invalidate(["note-folders"]);
}, },
); );
socket.on( socket.on(
+33 -12
View File
@@ -50,11 +50,20 @@ export interface NoteFolder {
// caller is not the owner: there are no per-tab note counts (notes change // caller is not the owner: there are no per-tab note counts (notes change
// live as the owner edits) and the owner's display info travels with the row // live as the owner edits) and the owner's display info travels with the row
// so the rail can render "by <name>" without an extra fetch. // so the rail can render "by <name>" without an extra fetch.
// Permission a recipient has on a shared folder. "view" is read-only
// (default); "edit" lets them create / update / delete notes inside the
// folder owner's namespace. Added in Task #454.
export type FolderSharePermission = "view" | "edit";
export interface SharedFolder { export interface SharedFolder {
id: number; id: number;
name: string; name: string;
ownerId: number; ownerId: number;
sharedAt: string; sharedAt: string;
// Permission this user has on the folder. The server stamps this from
// the per-share row so the rail can render an "edit"/"view" badge
// without an extra fetch.
myPermission: FolderSharePermission;
ownerUsername: string; ownerUsername: string;
ownerDisplayNameAr: string | null; ownerDisplayNameAr: string | null;
ownerDisplayNameEn: string | null; ownerDisplayNameEn: string | null;
@@ -70,6 +79,7 @@ export interface FolderShareRecipient {
displayNameAr: string | null; displayNameAr: string | null;
displayNameEn: string | null; displayNameEn: string | null;
sharedAt: string; sharedAt: string;
permission: FolderSharePermission;
} }
export interface UserSummary { export interface UserSummary {
@@ -213,14 +223,23 @@ export function useNoteLabels() {
return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/note-labels") }); return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/note-labels") });
} }
// Task #454: shared-folder editors mutate notes that live in someone
// else's namespace. The server emits `note-folder-shared` to every
// other viewer, but the actor is excluded from that fanout, so we
// invalidate the shared-with-me list and folders rail locally too —
// otherwise the editor's own rail badge can stay stale after a
// create/delete/move they performed themselves.
function invalidateNotesAndFolders(qc: ReturnType<typeof useQueryClient>) {
qc.invalidateQueries({ queryKey: ["notes"] });
qc.invalidateQueries({ queryKey: ["note-folders"] });
}
export function useCreateNote() { export function useCreateNote() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (body: Partial<Note>) => mutationFn: (body: Partial<Note>) =>
req<Note>("/notes", { method: "POST", body: JSON.stringify(body) }), req<Note>("/notes", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => { onSuccess: () => invalidateNotesAndFolders(qc),
qc.invalidateQueries({ queryKey: ["notes"] });
},
}); });
} }
@@ -229,9 +248,7 @@ export function useUpdateNote() {
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Note> & { id: number }) => mutationFn: ({ id, ...body }: Partial<Note> & { id: number }) =>
req<Note>(`/notes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), req<Note>(`/notes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
onSuccess: () => { onSuccess: () => invalidateNotesAndFolders(qc),
qc.invalidateQueries({ queryKey: ["notes"] });
},
}); });
} }
@@ -239,7 +256,7 @@ export function useDeleteNote() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => req<{ success: true }>(`/notes/${id}`, { method: "DELETE" }), mutationFn: (id: number) => req<{ success: true }>(`/notes/${id}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["notes"] }), onSuccess: () => invalidateNotesAndFolders(qc),
}); });
} }
@@ -569,20 +586,21 @@ export function useFolderShares(folderId: number | null) {
} }
// Replace the recipient set on a folder. Server diffs idempotently so this // Replace the recipient set on a folder. Server diffs idempotently so this
// can be called repeatedly with the desired final set. // can be called repeatedly with the desired final set. Each recipient
// carries an explicit "view" | "edit" permission (Task #454).
export function useUpdateFolderShares() { export function useUpdateFolderShares() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
folderId, folderId,
recipientUserIds, recipients,
}: { }: {
folderId: number; folderId: number;
recipientUserIds: number[]; recipients: { userId: number; permission: FolderSharePermission }[];
}) => }) =>
req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`, { req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ recipientUserIds }), body: JSON.stringify({ recipients }),
}), }),
onSuccess: (_data, vars) => { onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: folderSharesKey(vars.folderId) }); qc.invalidateQueries({ queryKey: folderSharesKey(vars.folderId) });
@@ -602,13 +620,16 @@ export function useSharedWithMeFolders() {
// Note returned inside a shared-folder view. Carries the readOnly flag the // Note returned inside a shared-folder view. Carries the readOnly flag the
// server stamps on every record so the UI doesn't have to recompute access. // server stamps on every record so the UI doesn't have to recompute access.
// `readOnly` is `false` for notes inside an editor-permission folder
// (Task #454).
export interface SharedFolderNote extends Note { export interface SharedFolderNote extends Note {
readOnly: true; readOnly: boolean;
} }
export interface SharedFolderView { export interface SharedFolderView {
folder: { id: number; name: string; ownerId: number }; folder: { id: number; name: string; ownerId: number };
owner: UserSummary | null; owner: UserSummary | null;
myPermission: FolderSharePermission;
notes: SharedFolderNote[]; notes: SharedFolderNote[];
} }
+4
View File
@@ -1067,6 +1067,10 @@
"share": "مشاركة المجلد", "share": "مشاركة المجلد",
"shareTitle": "مشاركة المجلد", "shareTitle": "مشاركة المجلد",
"shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.", "shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.",
"shareDescriptionPerm": "اختر عرض أو تعديل لكل مستلم.",
"permissionView": "عرض",
"permissionEdit": "تعديل",
"canEdit": "يمكنك التعديل",
"shareSaved": "تم تحديث المشاركة", "shareSaved": "تم تحديث المشاركة",
"shareFailed": "تعذّر تحديث المشاركة", "shareFailed": "تعذّر تحديث المشاركة",
"shareRevoked": "لم يعد هذا المجلد مشاركاً معك", "shareRevoked": "لم يعد هذا المجلد مشاركاً معك",
+4
View File
@@ -968,6 +968,10 @@
"share": "Share folder", "share": "Share folder",
"shareTitle": "Share folder", "shareTitle": "Share folder",
"shareDescription": "Recipients can view but not edit notes in this folder.", "shareDescription": "Recipients can view but not edit notes in this folder.",
"shareDescriptionPerm": "Pick view or edit for each recipient.",
"permissionView": "View",
"permissionEdit": "Edit",
"canEdit": "You can edit",
"shareSaved": "Sharing updated", "shareSaved": "Sharing updated",
"shareFailed": "Could not update sharing", "shareFailed": "Could not update sharing",
"shareRevoked": "This folder is no longer shared with you", "shareRevoked": "This folder is no longer shared with you",
+163 -38
View File
@@ -501,6 +501,8 @@ export default function NotesPage() {
labels={labels} labels={labels}
search={search} search={search}
onBack={() => setFolderSelection({ kind: "all" })} onBack={() => setFolderSelection({ kind: "all" })}
onEdit={setEditing}
onSend={(n) => setSendForNoteId(n.id)}
/> />
)} )}
{view === "active" && folderSelection.kind !== "shared-folder" && ( {view === "active" && folderSelection.kind !== "shared-folder" && (
@@ -725,11 +727,16 @@ function NoteCard({
labels, labels,
onEdit, onEdit,
onSend, onSend,
hideSend = false,
}: { }: {
note: Note; note: Note;
labels: NoteLabel[]; labels: NoteLabel[];
onEdit: (n: Note) => void; onEdit: (n: Note) => void;
onSend: (n: Note) => void; onSend: (n: Note) => void;
// Task #454: shared-folder editors don't own the note, and the
// server-side /send route is owner-only, so the Send button is
// suppressed for them to avoid a UI affordance that would 403.
hideSend?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const update = useUpdateNote(); const update = useUpdateNote();
@@ -902,14 +909,16 @@ function NoteCard({
selected={note.labelIds} selected={note.labelIds}
onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })} onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })}
/> />
<button {!hideSend && (
onClick={() => onSend(note)} <button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary" onClick={() => onSend(note)}
aria-label={t("notes.send", "Send")} className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary"
data-testid={`note-card-send-${note.id}`} aria-label={t("notes.send", "Send")}
> data-testid={`note-card-send-${note.id}`}
<Send size={15} /> >
</button> <Send size={15} />
</button>
)}
<button <button
onClick={() => onClick={() =>
update.mutate({ id: note.id, isArchived: !note.isArchived }) update.mutate({ id: note.id, isArchived: !note.isArchived })
@@ -2515,10 +2524,15 @@ function Composer({
labels, labels,
folderSelection, folderSelection,
onSend, onSend,
hideSend = false,
}: { }: {
labels: NoteLabel[]; labels: NoteLabel[];
folderSelection: FolderSelection; folderSelection: FolderSelection;
onSend: (id: number) => void; onSend: (id: number) => void;
// Task #454: shared-folder editors create notes that get stamped to
// the folder owner; the owner-only `/notes/:id/send` route would 403
// for them, so the composer hides its Send button in that context.
hideSend?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -2691,17 +2705,19 @@ function Composer({
then opens <SendNoteDialog> for the new note. Disabled then opens <SendNoteDialog> for the new note. Disabled
while empty or while the create request is in flight to while empty or while the create request is in flight to
prevent duplicate notes from rapid clicks. */} prevent duplicate notes from rapid clicks. */}
<Button {!hideSend && (
size="sm" <Button
variant="ghost" size="sm"
onClick={sendNew} variant="ghost"
disabled={sendDisabled} onClick={sendNew}
aria-label={t("notes.send", "Send")} disabled={sendDisabled}
data-testid="notes-composer-send" aria-label={t("notes.send", "Send")}
className="px-2" data-testid="notes-composer-send"
> className="px-2"
<Send size={16} /> >
</Button> <Send size={16} />
</Button>
)}
</div> </div>
</div> </div>
</> </>
@@ -2984,14 +3000,18 @@ function LabelsDialog({
// the moment the owner revokes access (parent effect falls back to "all"). // the moment the owner revokes access (parent effect falls back to "all").
function SharedFolderView({ function SharedFolderView({
folderId, folderId,
labels: _labels, labels,
search, search,
onBack, onBack,
onEdit,
onSend,
}: { }: {
folderId: number; folderId: number;
labels: NoteLabel[]; labels: NoteLabel[];
search: string; search: string;
onBack: () => void; onBack: () => void;
onEdit: (n: Note) => void;
onSend: (n: Note) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
@@ -3067,9 +3087,25 @@ function SharedFolderView({
{userDisplayName(data.owner, lang)} {userDisplayName(data.owner, lang)}
</span> </span>
{" · "} {" · "}
{t("notes.folders.readOnly", "Read-only")} {data.myPermission === "edit"
? t("notes.folders.canEdit", "You can edit")
: t("notes.folders.readOnly", "Read-only")}
</span> </span>
</div> </div>
{/* Editors get the same composer as the owner so they can add notes
directly into this shared folder. The composer's targetFolderId
derives from folderSelection.kind === "folder", so we pass a
synthetic folder selection scoped to this folder id. */}
{data.myPermission === "edit" && (
<div className="mb-3" data-testid="shared-folder-composer">
<Composer
labels={labels}
folderSelection={{ kind: "folder", id: folderId }}
onSend={(id) => onSend({ id } as Note)}
hideSend
/>
</div>
)}
{visibleNotes.length === 0 ? ( {visibleNotes.length === 0 ? (
<Empty <Empty
icon={<StickyNote size={48} />} icon={<StickyNote size={48} />}
@@ -3079,6 +3115,28 @@ function SharedFolderView({
: t("notes.folders.sharedEmpty", "This folder has no notes yet") : t("notes.folders.sharedEmpty", "This folder has no notes yet")
} }
/> />
) : data.myPermission === "edit" ? (
// Editors see full NoteCard affordances (edit, delete, send,
// checklist toggle, color/labels). Each NoteCard's mutations
// call PATCH /notes/:id which the server now allows for editors;
// the server emits `note-folder-shared` on success and the socket
// hook invalidates the shared-folder query so all viewers refresh.
<div
className="gap-3 mt-2 [column-fill:_balance]"
style={{ columnWidth: 230 }}
data-testid="shared-folder-notes"
>
{visibleNotes.map((n) => (
<NoteCard
key={n.id}
note={n}
labels={labels}
onEdit={onEdit}
onSend={onSend}
hideSend
/>
))}
</div>
) : ( ) : (
<div <div
className="gap-3 mt-2 [column-fill:_balance]" className="gap-3 mt-2 [column-fill:_balance]"
@@ -3142,15 +3200,21 @@ function FolderShareDialog({
); );
const update = useUpdateFolderShares(); const update = useUpdateFolderShares();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<number> | null>(null); // Map<userId, "view" | "edit">: presence = picked, value = permission.
// null until the existing-shares list has loaded so we can seed it
// from the current server state without clobbering user toggles.
const [picked, setPicked] = useState<Map<number, "view" | "edit"> | null>(
null,
);
// Seed the picked set from the existing shares once both sources have // Seed the picked map from the existing shares (incl. their saved
// landed. We only do this on the first non-loading tick so user toggles // permission) once both sources have landed.
// don't get clobbered by a refetch.
useEffect(() => { useEffect(() => {
if (picked !== null) return; if (picked !== null) return;
if (loadingShares) return; if (loadingShares) return;
setPicked(new Set(existing.map((r) => r.userId))); setPicked(
new Map(existing.map((r) => [r.userId, r.permission])),
);
}, [existing, loadingShares, picked]); }, [existing, loadingShares, picked]);
const candidates = useMemo(() => { const candidates = useMemo(() => {
@@ -3168,19 +3232,32 @@ function FolderShareDialog({
); );
}, [candidates, search]); }, [candidates, search]);
const toggle = (id: number) => { const togglePicked = (id: number) => {
setPicked((prev) => { setPicked((prev) => {
const next = new Set(prev ?? []); const next = new Map(prev ?? []);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.set(id, "view");
return next;
});
};
const setPermission = (id: number, perm: "view" | "edit") => {
setPicked((prev) => {
const next = new Map(prev ?? []);
// Picking a permission also implies adding the recipient — saves
// one click for "share with edit" in a single gesture.
next.set(id, perm);
return next; return next;
}); });
}; };
const submit = () => { const submit = () => {
if (picked === null) return; if (picked === null) return;
const recipients = Array.from(picked.entries()).map(
([userId, permission]) => ({ userId, permission }),
);
update.mutate( update.mutate(
{ folderId: folder.id, recipientUserIds: Array.from(picked) }, { folderId: folder.id, recipients },
{ {
onSuccess: () => { onSuccess: () => {
toast({ title: t("notes.folders.shareSaved", "Sharing updated") }); toast({ title: t("notes.folders.shareSaved", "Sharing updated") });
@@ -3210,7 +3287,10 @@ function FolderShareDialog({
<div className="text-xs text-muted-foreground -mt-1"> <div className="text-xs text-muted-foreground -mt-1">
<span className="text-foreground/80 font-medium">{folder.name}</span> <span className="text-foreground/80 font-medium">{folder.name}</span>
{" · "} {" · "}
{t("notes.folders.shareDescription", "Recipients can view but not edit notes in this folder.")} {t(
"notes.folders.shareDescriptionPerm",
"Pick view or edit for each recipient.",
)}
</div> </div>
<div className="relative"> <div className="relative">
<Search <Search
@@ -3236,19 +3316,64 @@ function FolderShareDialog({
)} )}
{!isLoading && {!isLoading &&
filtered.map((u) => { filtered.map((u) => {
const checked = picked?.has(u.id) ?? false; const perm = picked?.get(u.id);
const checked = perm !== undefined;
return ( return (
<button <div
key={u.id} key={u.id}
onClick={() => toggle(u.id)}
data-testid={`folder-share-option-${u.id}`} data-testid={`folder-share-option-${u.id}`}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${ className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm ${
checked ? "bg-primary/5" : "" checked ? "bg-primary/5" : ""
}`} }`}
> >
<span className="truncate">{userDisplayName(u, lang)}</span> <button
{checked && <Check size={14} className="text-primary" />} type="button"
</button> onClick={() => togglePicked(u.id)}
className="flex items-center gap-2 min-w-0 flex-1 text-start hover:opacity-80"
>
<span
className={`inline-flex items-center justify-center h-4 w-4 rounded border ${
checked
? "bg-primary border-primary text-primary-foreground"
: "border-slate-300"
}`}
>
{checked && <Check size={12} />}
</span>
<span className="truncate">{userDisplayName(u, lang)}</span>
</button>
<div
className="inline-flex rounded-md border overflow-hidden text-xs"
aria-disabled={!checked}
>
<button
type="button"
disabled={!checked}
onClick={() => setPermission(u.id, "view")}
data-testid={`folder-share-perm-view-${u.id}`}
className={`px-2 py-1 ${
perm === "view"
? "bg-primary text-primary-foreground"
: "bg-background hover:bg-slate-100 disabled:opacity-50"
}`}
>
{t("notes.folders.permissionView", "View")}
</button>
<button
type="button"
disabled={!checked}
onClick={() => setPermission(u.id, "edit")}
data-testid={`folder-share-perm-edit-${u.id}`}
className={`px-2 py-1 border-s ${
perm === "edit"
? "bg-primary text-primary-foreground"
: "bg-background hover:bg-slate-100 disabled:opacity-50"
}`}
>
{t("notes.folders.permissionEdit", "Edit")}
</button>
</div>
</div>
); );
})} })}
</div> </div>
+5 -2
View File
@@ -114,8 +114,10 @@ export type NoteReply = typeof noteRepliesTable.$inferSelect;
* always see the owner's current notes via a join, so adds/edits/removes by the * always see the owner's current notes via a join, so adds/edits/removes by the
* owner are reflected live in the recipient's "Shared with me" view. * owner are reflected live in the recipient's "Shared with me" view.
* *
* Read-only by design: there is no role column. Recipients cannot edit, add, * Permission per recipient (Task #454): "view" (default, read-only) or
* move, or delete notes inside a shared folder; they can only read. * "edit" (full create/update/delete on the owner's notes inside this folder).
* Notes mutated by an editor still belong to the folder owner — the folder
* stays a single namespace under `noteFoldersTable.userId`.
*/ */
export const noteFolderSharesTable = pgTable("note_folder_shares", { export const noteFolderSharesTable = pgTable("note_folder_shares", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
@@ -125,6 +127,7 @@ export const noteFolderSharesTable = pgTable("note_folder_shares", {
recipientUserId: integer("recipient_user_id") recipientUserId: integer("recipient_user_id")
.notNull() .notNull()
.references(() => usersTable.id, { onDelete: "cascade" }), .references(() => usersTable.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 8 }).notNull().default("view"),
sharedAt: timestamp("shared_at", { withTimezone: true }).notNull().defaultNow(), sharedAt: timestamp("shared_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ }, (t) => ({
folderRecipientUnique: uniqueIndex("note_folder_shares_folder_recipient_unique").on( folderRecipientUnique: uniqueIndex("note_folder_shares_folder_recipient_unique").on(