Notes: live folder-sharing (read-only) — Task #445

Owner can share a whole folder with other users; recipients see it
under "Shared with me" in the folders rail and view notes read-only
(cannot edit, add, move, or delete).

- DB: new noteFolderSharesTable (folderId/recipientUserId, cascade,
  unique idx). Pushed via drizzle-kit.
- API (artifacts/api-server/src/routes/notes.ts):
  - GET/PATCH/POST /note-folders return sharedWithCount.
  - GET /note-folders/shared-with-me, GET /note-folders/:id/shares,
    PUT /note-folders/:id/shares (idempotent diff), DELETE
    /note-folders/:id/shares/:userId, GET /note-folders/:id/shared-notes
    (verifies recipient via noteFolderSharesTable, stamps readOnly).
  - Emits note-folder-shared / note-folder-unshared on share changes.
  - Existing note write paths remain owner-scoped → recipients can't
    mutate owner notes.
- Frontend types/hooks (notes-api.ts): SharedFolder, FolderShareRecipient,
  SharedFolderNote/View; useFolderShares, useUpdateFolderShares,
  useSharedWithMeFolders, useSharedFolderNotes.
- FoldersRail: Share menu item, sharedWithCount badge, "Shared with me"
  section, "shared-folder" selection kind, onShareFolder prop.
- notes.tsx: SharedFolderView (no Composer, read-only cards using
  ChecklistView), FolderShareDialog (seeds existing recipients,
  idempotent PUT on save), revocation fallback effect, dialog mount.
- use-notifications-socket.ts: subscribe to note-folder-shared /
  -unshared → invalidate shared-with-me + open shared-folder notes for
  live rail/view refresh.
- i18n: AR/EN keys for share/sharedBy/sharedByName/sharedWithCount/
  sharedWithMe/readOnly/shareRevoked/sharedEmpty/shareSaved/shareFailed.

Pre-existing failing `test` workflow (executive-meetings type errors)
is unrelated and out of scope.
This commit is contained in:
riyadhafraa
2026-05-09 11:07:30 +00:00
parent e287278819
commit a4949983f3
8 changed files with 924 additions and 16 deletions
+303 -4
View File
@@ -8,6 +8,7 @@ import {
noteLabelAssignmentsTable,
noteRecipientsTable,
noteRepliesTable,
noteFolderSharesTable,
usersTable,
notificationsTable,
} from "@workspace/db";
@@ -1136,7 +1137,7 @@ router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
eq(notesTable.userId, userId),
eq(notesTable.isArchived, archived),
);
const [folders, counts] = await Promise.all([
const [folders, counts, shareCounts] = await Promise.all([
db
.select()
.from(noteFoldersTable)
@@ -1150,12 +1151,34 @@ router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
.from(notesTable)
.where(noteScope)
.groupBy(notesTable.folderId),
db
.select({
folderId: noteFolderSharesTable.folderId,
count: sql<number>`count(*)::int`,
})
.from(noteFolderSharesTable)
.innerJoin(
noteFoldersTable,
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
)
.where(eq(noteFoldersTable.userId, userId))
.groupBy(noteFolderSharesTable.folderId),
]);
const countMap = new Map<number, number>();
for (const row of counts) {
if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
}
res.json(folders.map((f) => ({ ...f, noteCount: countMap.get(f.id) ?? 0 })));
const shareCountMap = new Map<number, number>();
for (const row of shareCounts) {
shareCountMap.set(row.folderId, Number(row.count));
}
res.json(
folders.map((f) => ({
...f,
noteCount: countMap.get(f.id) ?? 0,
sharedWithCount: shareCountMap.get(f.id) ?? 0,
})),
);
});
router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
@@ -1170,7 +1193,7 @@ router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
.insert(noteFoldersTable)
.values({ userId, name: parsed.name })
.returning();
res.status(201).json({ ...folder, noteCount: 0 });
res.status(201).json({ ...folder, noteCount: 0, sharedWithCount: 0 });
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
@@ -1215,7 +1238,15 @@ router.patch("/note-folders/:id", requireAuth, async (req, res): Promise<void> =
eq(notesTable.folderId, folder.id),
),
);
res.json({ ...folder, noteCount: Number(countRow?.count ?? 0) });
const [shareRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, folder.id));
res.json({
...folder,
noteCount: Number(countRow?.count ?? 0),
sharedWithCount: Number(shareRow?.count ?? 0),
});
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
@@ -1243,4 +1274,272 @@ router.delete("/note-folders/:id", requireAuth, async (req, res): Promise<void>
res.json({ success: true });
});
// ---------- Folder-level live sharing ----------
//
// A folder owner can share a folder read-only with other users. Recipients see
// the owner's CURRENT notes inside the folder (live, not snapshot). They cannot
// edit, add, move, or delete anything; the existing per-note routes already
// filter by `notesTable.userId = req.session.userId`, so any write attempt by a
// recipient just 404s — no extra gating required there.
router.get(
"/note-folders/shared-with-me",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const rows = await db
.select({
id: noteFoldersTable.id,
name: noteFoldersTable.name,
ownerId: noteFoldersTable.userId,
sharedAt: noteFolderSharesTable.sharedAt,
ownerUsername: usersTable.username,
ownerDisplayNameAr: usersTable.displayNameAr,
ownerDisplayNameEn: usersTable.displayNameEn,
})
.from(noteFolderSharesTable)
.innerJoin(
noteFoldersTable,
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
)
.innerJoin(usersTable, eq(noteFoldersTable.userId, usersTable.id))
.where(eq(noteFolderSharesTable.recipientUserId, userId))
.orderBy(noteFoldersTable.name);
res.json(rows);
},
);
router.get(
"/note-folders/:id/shares",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
if (!(await userOwnsFolder(userId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
const rows = await db
.select({
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt,
})
.from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
.where(eq(noteFolderSharesTable.folderId, id.id))
.orderBy(noteFolderSharesTable.sharedAt);
res.json(rows);
},
);
router.put(
"/note-folders/:id/shares",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
if (!(await userOwnsFolder(userId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
const body = req.body;
if (!body || typeof body !== "object" || !Array.isArray(body.recipientUserIds)) {
res.status(400).json({ error: "Invalid recipientUserIds" });
return;
}
const desired = new Set<number>();
for (const x of body.recipientUserIds) {
const n = Number(x);
if (Number.isInteger(n) && n > 0 && n !== userId) desired.add(n);
}
// Validate that every desired recipient actually exists.
if (desired.size > 0) {
const valid = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(usersTable.id, Array.from(desired)));
const validIds = new Set(valid.map((r) => r.id));
for (const id of Array.from(desired)) {
if (!validIds.has(id)) desired.delete(id);
}
}
// Diff current vs desired and apply incrementally so repeated PUTs are
// idempotent and we only emit notifications for genuinely new recipients.
const current = await db
.select({ recipientUserId: noteFolderSharesTable.recipientUserId })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, id.id));
const currentSet = new Set(current.map((r) => r.recipientUserId));
const toAdd: number[] = [];
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);
if (toAdd.length > 0) {
await db
.insert(noteFolderSharesTable)
.values(toAdd.map((rid) => ({ folderId: id.id, recipientUserId: rid })))
.onConflictDoNothing();
}
if (toRemove.length > 0) {
await db
.delete(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
inArray(noteFolderSharesTable.recipientUserId, toRemove),
),
);
}
// Emit live socket events so recipients' rails refresh without polling.
for (const rid of toAdd) {
void emitToUser(rid, "note-folder-shared", { folderId: id.id });
}
for (const rid of toRemove) {
void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
}
const rows = await db
.select({
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt,
})
.from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
.where(eq(noteFolderSharesTable.folderId, id.id))
.orderBy(noteFolderSharesTable.sharedAt);
res.json(rows);
},
);
router.delete(
"/note-folders/:id/shares/:userId",
requireAuth,
async (req, res): Promise<void> => {
const ownerId = req.session.userId!;
const id = parseIdParam(req.params.id);
const target = parseIdParam(req.params.userId);
if (!id.ok || !target.ok) {
res.status(400).json({ error: "Invalid id" });
return;
}
if (!(await userOwnsFolder(ownerId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
await db
.delete(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, target.id),
),
);
void emitToUser(target.id, "note-folder-unshared", { folderId: id.id });
res.json({ success: true });
},
);
/**
* GET /note-folders/:id/shared-notes
*
* Recipient view of a shared folder. Returns the OWNER's current notes inside
* the folder (live join — not a snapshot), with `readOnly: true` so the client
* knows to disable every write affordance. 403 if the caller is not a current
* recipient of the share.
*/
router.get(
"/note-folders/:id/shared-notes",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
const [share] = await db
.select({ folderId: noteFolderSharesTable.folderId })
.from(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, userId),
),
);
if (!share) {
res.status(403).json({ error: "Forbidden" });
return;
}
const [folder] = await db
.select()
.from(noteFoldersTable)
.where(eq(noteFoldersTable.id, id.id));
if (!folder) {
res.status(404).json({ error: "Folder not found" });
return;
}
const notes = await db
.select()
.from(notesTable)
.where(
and(
eq(notesTable.userId, folder.userId),
eq(notesTable.folderId, folder.id),
eq(notesTable.isArchived, false),
),
)
.orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt));
let withLabels: Array<(typeof notes)[number] & { labelIds: number[] }> = [];
if (notes.length > 0) {
const ids = notes.map((n) => n.id);
const assignments = await db
.select()
.from(noteLabelAssignmentsTable)
.where(inArray(noteLabelAssignmentsTable.noteId, ids));
const map = new Map<number, number[]>();
for (const a of assignments) {
const arr = map.get(a.noteId) ?? [];
arr.push(a.labelId);
map.set(a.noteId, arr);
}
withLabels = notes.map((n) => ({ ...n, labelIds: map.get(n.id) ?? [] }));
}
const [owner] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, folder.userId));
res.json({
folder: { id: folder.id, name: folder.name, ownerId: folder.userId },
owner: owner ?? null,
notes: withLabels.map((n) => ({ ...n, readOnly: true as const })),
});
},
);
export default router;