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:
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1067,6 +1067,10 @@
|
||||
"share": "مشاركة المجلد",
|
||||
"shareTitle": "مشاركة المجلد",
|
||||
"shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.",
|
||||
"shareDescriptionPerm": "اختر عرض أو تعديل لكل مستلم.",
|
||||
"permissionView": "عرض",
|
||||
"permissionEdit": "تعديل",
|
||||
"canEdit": "يمكنك التعديل",
|
||||
"shareSaved": "تم تحديث المشاركة",
|
||||
"shareFailed": "تعذّر تحديث المشاركة",
|
||||
"shareRevoked": "لم يعد هذا المجلد مشاركاً معك",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user