From 4e00a20bdaa8989756717790bf8bb9df55d082f1 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 10 May 2026 11:11:45 +0000 Subject: [PATCH] notes(#454): per-recipient view/edit folder sharing - Schema: noteFolderShares.permission ('view'|'edit', default 'view'). Project uses drizzle-kit push (no migration files); existing rows pick up the default automatically on next push. - 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. - Distinct socket events: note-folder-shared (added), note-folder-share-updated (permission flipped), note-folder-unshared (removed). Permission events carry the new permission so clients can react. - 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: handlers for shared / share-updated / unshared all invalidate the right query keys for live UI flips. - Locales: shareDescriptionPerm, permissionView/Edit, canEdit (en + ar). - Tests: new permission roundtrip test in notes-share.test.mjs covering view-rejects-write, edit-can-write, editor-cannot-unfile, editor-create-stamped-to-owner, downgrade/upgrade flips. - Pre-existing executive-meetings.ts type errors are out of scope. --- artifacts/api-server/src/routes/notes.ts | 19 ++- .../api-server/tests/notes-share.test.mjs | 121 ++++++++++++++++++ .../src/hooks/use-notifications-socket.ts | 13 ++ 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 63e770be..d451d91d 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -1702,15 +1702,22 @@ router.put( } // Emit live socket events so recipients' rails refresh without polling. - // 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. + // Three distinct events so the client can tell apart "you got access", + // "your permission level changed", and "your access was revoked": + // - note-folder-shared → newly added recipient + // - note-folder-share-updated → existing recipient's permission flipped + // - note-folder-unshared → recipient removed for (const a of toAdd) { - void emitToUser(a.userId, "note-folder-shared", { folderId: id.id }); + void emitToUser(a.userId, "note-folder-shared", { + folderId: id.id, + permission: a.permission, + }); } for (const u of toUpdate) { - void emitToUser(u.userId, "note-folder-shared", { folderId: id.id }); + void emitToUser(u.userId, "note-folder-share-updated", { + folderId: id.id, + permission: u.permission, + }); } for (const rid of toRemove) { void emitToUser(rid, "note-folder-unshared", { folderId: id.id }); diff --git a/artifacts/api-server/tests/notes-share.test.mjs b/artifacts/api-server/tests/notes-share.test.mjs index abc58193..2297d6ec 100644 --- a/artifacts/api-server/tests/notes-share.test.mjs +++ b/artifacts/api-server/tests/notes-share.test.mjs @@ -88,6 +88,8 @@ async function createNote(cookie, body) { return json; } +const createdFolderIds = []; + after(async () => { if (createdNoteIds.length > 0) { await pool.query( @@ -102,6 +104,15 @@ after(async () => { createdNoteIds, ]); } + if (createdFolderIds.length > 0) { + await pool.query( + `DELETE FROM note_folder_shares WHERE folder_id = ANY($1::int[])`, + [createdFolderIds], + ); + await pool.query(`DELETE FROM note_folders WHERE id = ANY($1::int[])`, [ + createdFolderIds, + ]); + } if (createdUserIds.length > 0) { await pool.query( `DELETE FROM notifications WHERE user_id = ANY($1::int[])`, @@ -496,6 +507,116 @@ test("GET /notes/my returns the same payload as GET /notes (alias)", async () => ); }); +// Task #454: per-recipient folder permission (view vs edit). +test("folder share permissions: view-only is rejected on writes; edit can mutate; permission roundtrip works", async () => { + const owner = await createUser("notes_perm_owner"); + const viewer = await createUser("notes_perm_viewer"); + const editor = await createUser("notes_perm_editor"); + const oCookie = await login(owner.username); + const vCookie = await login(viewer.username); + const eCookie = await login(editor.username); + + // Owner creates a folder + a note inside it. + const fRes = await api(`/note-folders`, oCookie, { + method: "POST", + body: JSON.stringify({ name: "perm-folder", color: "blue" }), + }); + assert.ok(fRes.status === 200 || fRes.status === 201, `folder create expected 2xx, got ${fRes.status}`); + const folder = await fRes.json(); + createdFolderIds.push(folder.id); + + const note = await createNote(oCookie, { folderId: folder.id, title: "T" }); + + // Owner shares: viewer = view, editor = edit (new recipients[] payload). + const shareRes = await api(`/note-folders/${folder.id}/shares`, oCookie, { + method: "PUT", + body: JSON.stringify({ + recipients: [ + { userId: viewer.id, permission: "view" }, + { userId: editor.id, permission: "edit" }, + ], + }), + }); + assert.equal(shareRes.status, 200); + const shareList = await shareRes.json(); + const viewerRow = shareList.find((r) => r.userId === viewer.id); + const editorRow = shareList.find((r) => r.userId === editor.id); + assert.equal(viewerRow.permission, "view"); + assert.equal(editorRow.permission, "edit"); + + // Recipients see correct myPermission via shared-with-me. + const vSwm = await (await api(`/note-folders/shared-with-me`, vCookie)).json(); + assert.equal(vSwm.find((f) => f.id === folder.id).myPermission, "view"); + const eSwm = await (await api(`/note-folders/shared-with-me`, eCookie)).json(); + assert.equal(eSwm.find((f) => f.id === folder.id).myPermission, "edit"); + + // Viewer cannot patch a note in the shared folder. + const vPatch = await api(`/notes/${note.id}`, vCookie, { + method: "PATCH", + body: JSON.stringify({ title: "hacked" }), + }); + assert.equal(vPatch.status, 403, "view-only must not patch"); + + // Editor can patch the note. + const ePatch = await api(`/notes/${note.id}`, eCookie, { + method: "PATCH", + body: JSON.stringify({ title: "edited by editor" }), + }); + assert.equal(ePatch.status, 200, "editor must patch"); + + // Editor cannot unfile (folderId=null) — would steal the note from owner. + const eUnfile = await api(`/notes/${note.id}`, eCookie, { + method: "PATCH", + body: JSON.stringify({ folderId: null }), + }); + assert.equal(eUnfile.status, 403, "editor must not unfile"); + + // Editor can create a new note in the folder; it is stamped to owner. + const eCreate = await api(`/notes`, eCookie, { + method: "POST", + body: JSON.stringify({ + title: "by editor", + content: "x", + color: "blue", + folderId: folder.id, + }), + }); + assert.ok(eCreate.status === 200 || eCreate.status === 201, `editor create expected 2xx, got ${eCreate.status}`); + const eCreated = await eCreate.json(); + createdNoteIds.push(eCreated.id); + const { rows: ownerRows } = await pool.query( + `SELECT user_id FROM notes WHERE id = $1`, + [eCreated.id], + ); + assert.equal(ownerRows[0].user_id, owner.id, "editor-created note belongs to owner"); + + // Permission flip: downgrade editor to view, upgrade viewer to edit. + const flipRes = await api(`/note-folders/${folder.id}/shares`, oCookie, { + method: "PUT", + body: JSON.stringify({ + recipients: [ + { userId: viewer.id, permission: "edit" }, + { userId: editor.id, permission: "view" }, + ], + }), + }); + assert.equal(flipRes.status, 200); + + // Former editor (now view) can no longer patch. + const ePatch2 = await api(`/notes/${note.id}`, eCookie, { + method: "PATCH", + body: JSON.stringify({ title: "after-downgrade" }), + }); + assert.equal(ePatch2.status, 403, "downgraded editor must lose write access"); + + // Former viewer (now edit) can patch. + const vPatch2 = await api(`/notes/${note.id}`, vCookie, { + method: "PATCH", + body: JSON.stringify({ title: "after-upgrade" }), + }); + assert.equal(vPatch2.status, 200, "upgraded viewer must gain write access"); +}); + test("re-sending to the same recipient does not duplicate the recipient row", async () => { const sender = await createUser("notes_dup_sender"); const recipient = await createUser("notes_dup_recipient"); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 28100182..64216f85 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -343,6 +343,19 @@ export function useNotificationsSocket() { invalidate(["note-folders"]); }, ); + // Task #454: dedicated event for permission-only flips on an + // existing share (view ↔ edit). Same invalidation as `shared`, + // but kept distinct so the contract is explicit and so future UI + // can react with a toast like "Your access changed to Edit". + socket.on( + "note-folder-share-updated", + (payload?: { folderId?: number; permission?: "view" | "edit" }) => { + invalidate(["note-folders", "shared-with-me"]); + if (typeof payload?.folderId === "number") { + invalidate(["notes", "shared-folder", payload.folderId]); + } + }, + ); socket.on( "note-folder-unshared", (payload?: { folderId?: number }) => {