From 1aad708cb7ea81497ca5b3809948912833b9bd1c Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 10 May 2026 10:16:05 +0000 Subject: [PATCH] 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. --- artifacts/api-server/src/routes/notes.ts | 342 +++++++++++++++--- .../src/components/notes/folders-rail.tsx | 24 ++ .../src/hooks/use-notifications-socket.ts | 6 + artifacts/tx-os/src/lib/notes-api.ts | 45 ++- artifacts/tx-os/src/locales/ar.json | 4 + artifacts/tx-os/src/locales/en.json | 4 + artifacts/tx-os/src/pages/notes.tsx | 201 ++++++++-- lib/db/src/schema/notes.ts | 7 +- 8 files changed, 528 insertions(+), 105 deletions(-) diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 2d9f99e4..63e770be 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -117,6 +117,58 @@ async function userOwnsFolder(userId: number, folderId: number): Promise { + 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([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 } { if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" }; const data: NoteInput = {}; @@ -315,7 +367,10 @@ async function handleListMyNotes( return; } const [share] = await db - .select({ folderId: noteFolderSharesTable.folderId }) + .select({ + folderId: noteFolderSharesTable.folderId, + permission: noteFolderSharesTable.permission, + }) .from(noteFolderSharesTable) .where( and( @@ -335,10 +390,11 @@ async function handleListMyNotes( res.status(404).json({ error: "Folder not found" }); return; } + const readOnly = share.permission !== "edit"; const ownerNotes = await loadNotesWithLabels(folder.ownerId, false); const filtered = ownerNotes .filter((n) => n.folderId === parsed) - .map((n) => ({ ...n, readOnly: true as const })); + .map((n) => ({ ...n, readOnly })); res.json(filtered); return; } @@ -360,14 +416,24 @@ router.post("/notes", requireAuth, async (req, res): Promise => { return; } const { labelIds = [], folderId, ...data } = parsed.data; - if (folderId != null && !(await userOwnsFolder(userId, folderId))) { - res.status(400).json({ error: "Invalid folderId" }); - return; + // Resolve who the new note's owner should be. The caller is allowed to + // create notes either in their own folders OR in a folder that's been + // 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 .insert(notesTable) .values({ - userId, + userId: stampedUserId, title: data.title ?? "", content: data.content ?? "", color: data.color ?? "default", @@ -378,8 +444,20 @@ router.post("/notes", requireAuth, async (req, res): Promise => { folderId: folderId ?? null, }) .returning(); - if (labelIds.length > 0) await setNoteLabels(note.id, userId, labelIds); - const [withLabels] = await loadOne(note.id, userId); + // Labels are scoped to the note OWNER (stampedUserId), not necessarily + // 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); }); @@ -397,8 +475,9 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { } 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. + // ambiguous 404). Recipients of a shared folder may now also mutate + // the owner's notes when their share grants permission="edit" + // (Task #454). const [existing] = await db .select() .from(notesTable) @@ -407,13 +486,38 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { res.status(404).json({ error: "Note not found" }); return; } + let actingAsEditor = false; if (existing.userId !== userId) { - res.status(403).json({ error: "Forbidden" }); - return; + if ( + 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))) { - res.status(400).json({ error: "Invalid folderId" }); - return; + // Folder-change validation. We must guard BOTH branches: + // - data.folderId === null → caller is unfiling the note. Editors + // 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 // 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 => { 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); + if (labelIds) await setNoteLabels(existing.id, existing.userId, labelIds); + 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); }); @@ -441,21 +555,36 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise => { 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. + // Two-step lookup so non-owners (e.g. shared-folder view-only recipients) + // 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 - .select({ userId: notesTable.userId }) + .select({ userId: notesTable.userId, folderId: notesTable.folderId }) .from(notesTable) .where(eq(notesTable.id, id.id)); if (!existing) { res.status(404).json({ error: "Note not found" }); return; } + let actingAsEditor = false; if (existing.userId !== userId) { - res.status(403).json({ error: "Forbidden" }); - return; + if ( + 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)); + // 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 }); }); @@ -854,7 +983,13 @@ router.post( // 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: "ok"; + updated: ChecklistItem[]; + ownerUserId: number; + recipientIds: number[]; + folderId: number | null; + } | { kind: "noop"; items: ChecklistItem[] } | { kind: "err"; status: number; error: string }; const outcome: ToggleOutcome = await db.transaction(async (tx) => { @@ -863,6 +998,7 @@ router.post( ); const lockedRows = (locked as unknown as { rows: Array<{ id: number; user_id: number; kind: string; items: unknown; + folder_id: number | null; }> }).rows; const note = lockedRows[0]; if (!note) return { kind: "err", status: 404, error: "Note not found" }; @@ -880,7 +1016,26 @@ router.post( 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" }; } @@ -911,6 +1066,7 @@ router.post( updated, ownerUserId: note.user_id, recipientIds: recipientRows.map((r) => r.recipientUserId), + folderId: note.folder_id, }; }); @@ -940,6 +1096,15 @@ router.post( 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 }); }, @@ -1348,6 +1513,7 @@ router.get( name: noteFoldersTable.name, ownerId: noteFoldersTable.userId, sharedAt: noteFolderSharesTable.sharedAt, + myPermission: noteFolderSharesTable.permission, ownerUsername: usersTable.username, ownerDisplayNameAr: usersTable.displayNameAr, ownerDisplayNameEn: usersTable.displayNameEn, @@ -1394,6 +1560,7 @@ router.get( displayNameAr: usersTable.displayNameAr, displayNameEn: usersTable.displayNameEn, sharedAt: noteFolderSharesTable.sharedAt, + permission: noteFolderSharesTable.permission, }) .from(noteFolderSharesTable) .innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id)) @@ -1418,54 +1585,98 @@ router.put( return; } const body = req.body; - if (!body || typeof body !== "object" || !Array.isArray(body.recipientUserIds)) { - res.status(400).json({ error: "Invalid recipientUserIds" }); + // Task #454: accept BOTH the legacy `recipientUserIds: number[]` + // shape (treated as permission="view") AND the new + // `recipients: [{ userId, permission }]` shape so older clients keep + // working. Internally we collapse to a single Map. + const desired = new Map(); + 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; } - const desired = new Set(); - for (const x of body.recipientUserIds) { - const n = Number(x); - if (!Number.isInteger(n) || n <= 0) { - res.status(400).json({ error: "Invalid recipientUserIds" }); - return; + if (Array.isArray(body.recipients)) { + for (const r of body.recipients) { + if (!r || typeof r !== "object") { + res.status(400).json({ error: "Invalid recipients" }); + 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 - // 400 rather than silently dropping the id. - if (n === userId) { - res.status(400).json({ error: "Cannot share folder with yourself" }); - return; + } else if (Array.isArray(body.recipientUserIds)) { + for (const x of body.recipientUserIds) { + const err = ingest(x, undefined); + if (err) { + 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) { const valid = await db .select({ id: usersTable.id }) .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)); - for (const id of Array.from(desired)) { - if (!validIds.has(id)) desired.delete(id); + for (const uid of Array.from(desired.keys())) { + if (!validIds.has(uid)) desired.delete(uid); } } // 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 - .select({ recipientUserId: noteFolderSharesTable.recipientUserId }) + .select({ + recipientUserId: noteFolderSharesTable.recipientUserId, + permission: noteFolderSharesTable.permission, + }) .from(noteFolderSharesTable) .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[] = []; - for (const r of desired) if (!currentSet.has(r)) toAdd.push(r); - for (const r of currentSet) if (!desired.has(r)) toRemove.push(r); + const toUpdate: Array<{ userId: number; permission: "view" | "edit" }> = []; + 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) { await db .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(); } 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. - for (const rid of toAdd) { - void emitToUser(rid, "note-folder-shared", { folderId: id.id }); + // Permission flips reuse the `note-folder-shared` event — recipients + // 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) { void emitToUser(rid, "note-folder-unshared", { folderId: id.id }); @@ -1494,6 +1723,7 @@ router.put( displayNameAr: usersTable.displayNameAr, displayNameEn: usersTable.displayNameEn, sharedAt: noteFolderSharesTable.sharedAt, + permission: noteFolderSharesTable.permission, }) .from(noteFolderSharesTable) .innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id)) @@ -1550,7 +1780,10 @@ router.get( return; } const [share] = await db - .select({ folderId: noteFolderSharesTable.folderId }) + .select({ + folderId: noteFolderSharesTable.folderId, + permission: noteFolderSharesTable.permission, + }) .from(noteFolderSharesTable) .where( and( @@ -1562,6 +1795,8 @@ router.get( res.status(403).json({ error: "Forbidden" }); return; } + const myPermission = share.permission === "edit" ? "edit" : "view"; + const readOnly = myPermission !== "edit"; const [folder] = await db .select() .from(noteFoldersTable) @@ -1611,7 +1846,8 @@ router.get( res.json({ folder: { id: folder.id, name: folder.name, ownerId: folder.userId }, owner: owner ?? null, - notes: withLabels.map((n) => ({ ...n, readOnly: true as const })), + myPermission, + notes: withLabels.map((n) => ({ ...n, readOnly })), }); }, ); diff --git a/artifacts/tx-os/src/components/notes/folders-rail.tsx b/artifacts/tx-os/src/components/notes/folders-rail.tsx index 4f8d8487..2ee32f14 100644 --- a/artifacts/tx-os/src/components/notes/folders-rail.tsx +++ b/artifacts/tx-os/src/components/notes/folders-rail.tsx @@ -588,6 +588,30 @@ export function FoldersRail({ })} + {/* Permission badge so the recipient can tell at a + glance whether they can edit this folder's notes + (Task #454). */} + + {sf.myPermission === "edit" + ? t("notes.folders.permissionEdit", "Edit") + : t("notes.folders.permissionView", "View")} + " 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 { id: number; name: string; ownerId: number; 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; ownerDisplayNameAr: string | null; ownerDisplayNameEn: string | null; @@ -70,6 +79,7 @@ export interface FolderShareRecipient { displayNameAr: string | null; displayNameEn: string | null; sharedAt: string; + permission: FolderSharePermission; } export interface UserSummary { @@ -213,14 +223,23 @@ export function useNoteLabels() { return useQuery({ queryKey: labelsKey, queryFn: () => req("/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) { + qc.invalidateQueries({ queryKey: ["notes"] }); + qc.invalidateQueries({ queryKey: ["note-folders"] }); +} + export function useCreateNote() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: Partial) => req("/notes", { method: "POST", body: JSON.stringify(body) }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["notes"] }); - }, + onSuccess: () => invalidateNotesAndFolders(qc), }); } @@ -229,9 +248,7 @@ export function useUpdateNote() { return useMutation({ mutationFn: ({ id, ...body }: Partial & { id: number }) => req(`/notes/${id}`, { method: "PATCH", body: JSON.stringify(body) }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["notes"] }); - }, + onSuccess: () => invalidateNotesAndFolders(qc), }); } @@ -239,7 +256,7 @@ export function useDeleteNote() { const qc = useQueryClient(); return useMutation({ 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 -// 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() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ folderId, - recipientUserIds, + recipients, }: { folderId: number; - recipientUserIds: number[]; + recipients: { userId: number; permission: FolderSharePermission }[]; }) => req(`/note-folders/${folderId}/shares`, { method: "PUT", - body: JSON.stringify({ recipientUserIds }), + body: JSON.stringify({ recipients }), }), onSuccess: (_data, vars) => { 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 // 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 { - readOnly: true; + readOnly: boolean; } export interface SharedFolderView { folder: { id: number; name: string; ownerId: number }; owner: UserSummary | null; + myPermission: FolderSharePermission; notes: SharedFolderNote[]; } diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index c5ff539e..e66aeded 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1067,6 +1067,10 @@ "share": "مشاركة المجلد", "shareTitle": "مشاركة المجلد", "shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.", + "shareDescriptionPerm": "اختر عرض أو تعديل لكل مستلم.", + "permissionView": "عرض", + "permissionEdit": "تعديل", + "canEdit": "يمكنك التعديل", "shareSaved": "تم تحديث المشاركة", "shareFailed": "تعذّر تحديث المشاركة", "shareRevoked": "لم يعد هذا المجلد مشاركاً معك", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 2e11011e..8b121795 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -968,6 +968,10 @@ "share": "Share folder", "shareTitle": "Share 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", "shareFailed": "Could not update sharing", "shareRevoked": "This folder is no longer shared with you", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index c4142238..08cdea1b 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -501,6 +501,8 @@ export default function NotesPage() { labels={labels} search={search} onBack={() => setFolderSelection({ kind: "all" })} + onEdit={setEditing} + onSend={(n) => setSendForNoteId(n.id)} /> )} {view === "active" && folderSelection.kind !== "shared-folder" && ( @@ -725,11 +727,16 @@ function NoteCard({ labels, onEdit, onSend, + hideSend = false, }: { note: Note; labels: NoteLabel[]; onEdit: (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 update = useUpdateNote(); @@ -902,14 +909,16 @@ function NoteCard({ selected={note.labelIds} onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })} /> - + {!hideSend && ( + + )} + {!hideSend && ( + + )} @@ -2984,14 +3000,18 @@ function LabelsDialog({ // the moment the owner revokes access (parent effect falls back to "all"). function SharedFolderView({ folderId, - labels: _labels, + labels, search, onBack, + onEdit, + onSend, }: { folderId: number; labels: NoteLabel[]; search: string; onBack: () => void; + onEdit: (n: Note) => void; + onSend: (n: Note) => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -3067,9 +3087,25 @@ function SharedFolderView({ {userDisplayName(data.owner, lang)} {" · "} - {t("notes.folders.readOnly", "Read-only")} + {data.myPermission === "edit" + ? t("notes.folders.canEdit", "You can edit") + : t("notes.folders.readOnly", "Read-only")} + {/* 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" && ( +
+ onSend({ id } as Note)} + hideSend + /> +
+ )} {visibleNotes.length === 0 ? ( } @@ -3079,6 +3115,28 @@ function SharedFolderView({ : 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. +
+ {visibleNotes.map((n) => ( + + ))} +
) : (
| null>(null); + // Map: 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 | null>( + null, + ); - // Seed the picked set from the existing shares once both sources have - // landed. We only do this on the first non-loading tick so user toggles - // don't get clobbered by a refetch. + // Seed the picked map from the existing shares (incl. their saved + // permission) once both sources have landed. useEffect(() => { if (picked !== null) 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]); const candidates = useMemo(() => { @@ -3168,19 +3232,32 @@ function FolderShareDialog({ ); }, [candidates, search]); - const toggle = (id: number) => { + const togglePicked = (id: number) => { setPicked((prev) => { - const next = new Set(prev ?? []); + const next = new Map(prev ?? []); 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; }); }; const submit = () => { if (picked === null) return; + const recipients = Array.from(picked.entries()).map( + ([userId, permission]) => ({ userId, permission }), + ); update.mutate( - { folderId: folder.id, recipientUserIds: Array.from(picked) }, + { folderId: folder.id, recipients }, { onSuccess: () => { toast({ title: t("notes.folders.shareSaved", "Sharing updated") }); @@ -3210,7 +3287,10 @@ function FolderShareDialog({
{folder.name} {" · "} - {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.", + )}
{ - const checked = picked?.has(u.id) ?? false; + const perm = picked?.get(u.id); + const checked = perm !== undefined; return ( - + +
+ + +
+
); })}
diff --git a/lib/db/src/schema/notes.ts b/lib/db/src/schema/notes.ts index b87734f6..f4852749 100644 --- a/lib/db/src/schema/notes.ts +++ b/lib/db/src/schema/notes.ts @@ -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 * 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, - * move, or delete notes inside a shared folder; they can only read. + * Permission per recipient (Task #454): "view" (default, read-only) or + * "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", { id: serial("id").primaryKey(), @@ -125,6 +127,7 @@ export const noteFolderSharesTable = pgTable("note_folder_shares", { recipientUserId: integer("recipient_user_id") .notNull() .references(() => usersTable.id, { onDelete: "cascade" }), + permission: varchar("permission", { length: 8 }).notNull().default("view"), sharedAt: timestamp("shared_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ folderRecipientUnique: uniqueIndex("note_folder_shares_folder_recipient_unique").on(