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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user