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;
}
// 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 } {
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<void> => {
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<void> => {
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<void> => {
}
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<void> => {
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<void> => {
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<void> => {
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<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;
}
const desired = new Set<number>();
for (const x of body.recipientUserIds) {
const n = Number(x);
if (!Number.isInteger(n) || n <= 0) {
res.status(400).json({ error: "Invalid recipientUserIds" });
return;
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 })),
});
},
);
@@ -588,6 +588,30 @@ export function FoldersRail({
})}
</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
className={`shrink-0 text-[10px] tabular-nums ${
isSel ? "text-background/80" : "text-muted-foreground"
@@ -335,6 +335,12 @@ export function useNotificationsSocket() {
if (typeof payload?.folderId === "number") {
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(
+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
// 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.
// 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<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() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Partial<Note>) =>
req<Note>("/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<Note> & { id: number }) =>
req<Note>(`/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<FolderShareRecipient[]>(`/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[];
}
+4
View File
@@ -1067,6 +1067,10 @@
"share": "مشاركة المجلد",
"shareTitle": "مشاركة المجلد",
"shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.",
"shareDescriptionPerm": "اختر عرض أو تعديل لكل مستلم.",
"permissionView": "عرض",
"permissionEdit": "تعديل",
"canEdit": "يمكنك التعديل",
"shareSaved": "تم تحديث المشاركة",
"shareFailed": "تعذّر تحديث المشاركة",
"shareRevoked": "لم يعد هذا المجلد مشاركاً معك",
+4
View File
@@ -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",
+163 -38
View File
@@ -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 })}
/>
<button
onClick={() => onSend(note)}
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary"
aria-label={t("notes.send", "Send")}
data-testid={`note-card-send-${note.id}`}
>
<Send size={15} />
</button>
{!hideSend && (
<button
onClick={() => onSend(note)}
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary"
aria-label={t("notes.send", "Send")}
data-testid={`note-card-send-${note.id}`}
>
<Send size={15} />
</button>
)}
<button
onClick={() =>
update.mutate({ id: note.id, isArchived: !note.isArchived })
@@ -2515,10 +2524,15 @@ function Composer({
labels,
folderSelection,
onSend,
hideSend = false,
}: {
labels: NoteLabel[];
folderSelection: FolderSelection;
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 [open, setOpen] = useState(false);
@@ -2691,17 +2705,19 @@ function Composer({
then opens <SendNoteDialog> for the new note. Disabled
while empty or while the create request is in flight to
prevent duplicate notes from rapid clicks. */}
<Button
size="sm"
variant="ghost"
onClick={sendNew}
disabled={sendDisabled}
aria-label={t("notes.send", "Send")}
data-testid="notes-composer-send"
className="px-2"
>
<Send size={16} />
</Button>
{!hideSend && (
<Button
size="sm"
variant="ghost"
onClick={sendNew}
disabled={sendDisabled}
aria-label={t("notes.send", "Send")}
data-testid="notes-composer-send"
className="px-2"
>
<Send size={16} />
</Button>
)}
</div>
</div>
</>
@@ -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)}
</span>
{" · "}
{t("notes.folders.readOnly", "Read-only")}
{data.myPermission === "edit"
? t("notes.folders.canEdit", "You can edit")
: t("notes.folders.readOnly", "Read-only")}
</span>
</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 ? (
<Empty
icon={<StickyNote size={48} />}
@@ -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.
<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
className="gap-3 mt-2 [column-fill:_balance]"
@@ -3142,15 +3200,21 @@ function FolderShareDialog({
);
const update = useUpdateFolderShares();
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
// 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({
<div className="text-xs text-muted-foreground -mt-1">
<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 className="relative">
<Search
@@ -3236,19 +3316,64 @@ function FolderShareDialog({
)}
{!isLoading &&
filtered.map((u) => {
const checked = picked?.has(u.id) ?? false;
const perm = picked?.get(u.id);
const checked = perm !== undefined;
return (
<button
<div
key={u.id}
onClick={() => toggle(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" : ""
}`}
>
<span className="truncate">{userDisplayName(u, lang)}</span>
{checked && <Check size={14} className="text-primary" />}
</button>
<button
type="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>
+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
* 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(