Skip to content

Commit f1d949b

Browse files
committed
feat(content/list-documents): include accessRole/canEdit/canManage on each row
`list-documents` previously returned only document metadata, leaving the UI to compute 'can I edit this doc?' separately or optimistically — which led to subtle bugs where viewers saw edit affordances they couldn't actually use. The action now joins access-resolution into the listing: - Owner detection: `ownerEmail === userEmail` AND owner-scope matches the active orgId (mirrors the `accessFilter` change in aa93f2f). - Per-share lookup: a single bulk query against `documentShares` fetches user + org grants for all returned documents in one round-trip; `strongerRole()` picks the highest grant per resource id. - Visibility floor: org-scoped documents the viewer accesses via visibility alone get a `viewer` role. - Final shape adds `accessRole`, `canEdit`, `canManage` so the sidebar / list UI can render the right affordances directly off the listing response.
1 parent 91b685f commit f1d949b

1 file changed

Lines changed: 111 additions & 15 deletions

File tree

templates/content/actions/list-documents.ts

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { defineAction } from "@agent-native/core";
2-
import { asc } from "drizzle-orm";
2+
import { and, asc, eq, inArray, or } from "drizzle-orm";
33
import { getDb, schema } from "../server/db/index.js";
44
import { parseDocumentFavorite } from "../server/lib/documents.js";
5-
import { accessFilter } from "@agent-native/core/sharing";
5+
import {
6+
accessFilter,
7+
ROLE_RANK,
8+
type ShareRole,
9+
} from "@agent-native/core/sharing";
10+
import {
11+
getRequestOrgId,
12+
getRequestUserEmail,
13+
} from "@agent-native/core/server/request-context";
614
import { z } from "zod";
715

816
function contentPreview(content: string, maxLength = 180) {
@@ -11,32 +19,120 @@ function contentPreview(content: string, maxLength = 180) {
1119
return `${compact.slice(0, maxLength).trimEnd()}...`;
1220
}
1321

22+
type EffectiveRole = "owner" | ShareRole;
23+
24+
function canEditRole(role: EffectiveRole) {
25+
return role === "owner" || role === "admin" || role === "editor";
26+
}
27+
28+
function canManageRole(role: EffectiveRole) {
29+
return role === "owner" || role === "admin";
30+
}
31+
32+
function strongerRole(
33+
current: ShareRole | null,
34+
next: ShareRole,
35+
): ShareRole {
36+
if (!current || ROLE_RANK[next] > ROLE_RANK[current]) return next;
37+
return current;
38+
}
39+
1440
export default defineAction({
1541
description:
1642
"List document metadata ordered by position. Does not return full document bodies; use get-document for one document's content.",
1743
schema: z.object({}),
1844
http: { method: "GET" },
1945
run: async () => {
2046
const db = getDb();
47+
const userEmail = getRequestUserEmail();
48+
const orgId = getRequestOrgId();
2149
const documents = await db
2250
.select()
2351
.from(schema.documents)
2452
.where(accessFilter(schema.documents, schema.documentShares))
2553
.orderBy(asc(schema.documents.position));
2654

27-
const mapped = documents.map((d) => ({
28-
id: d.id,
29-
parentId: d.parentId,
30-
title: d.title,
31-
contentPreview: contentPreview(d.content),
32-
contentLength: d.content.length,
33-
icon: d.icon,
34-
position: d.position,
35-
isFavorite: parseDocumentFavorite(d.isFavorite),
36-
visibility: d.visibility,
37-
createdAt: d.createdAt,
38-
updatedAt: d.updatedAt,
39-
}));
55+
const shareRoleByDocumentId = new Map<string, ShareRole>();
56+
if (documents.length > 0) {
57+
const principalClauses: NonNullable<ReturnType<typeof and>>[] = [];
58+
if (userEmail) {
59+
principalClauses.push(
60+
and(
61+
eq(schema.documentShares.principalType, "user"),
62+
eq(schema.documentShares.principalId, userEmail),
63+
),
64+
);
65+
}
66+
if (orgId) {
67+
principalClauses.push(
68+
and(
69+
eq(schema.documentShares.principalType, "org"),
70+
eq(schema.documentShares.principalId, orgId),
71+
),
72+
);
73+
}
74+
75+
if (principalClauses.length > 0) {
76+
const shareRows = await db
77+
.select({
78+
resourceId: schema.documentShares.resourceId,
79+
role: schema.documentShares.role,
80+
})
81+
.from(schema.documentShares)
82+
.where(
83+
and(
84+
inArray(
85+
schema.documentShares.resourceId,
86+
documents.map((d) => d.id),
87+
),
88+
or(...principalClauses),
89+
),
90+
);
91+
92+
for (const row of shareRows) {
93+
shareRoleByDocumentId.set(
94+
row.resourceId,
95+
strongerRole(
96+
shareRoleByDocumentId.get(row.resourceId) ?? null,
97+
row.role,
98+
),
99+
);
100+
}
101+
}
102+
}
103+
104+
const mapped = documents.map((d) => {
105+
let accessRole: EffectiveRole = "viewer";
106+
const shareRole = shareRoleByDocumentId.get(d.id) ?? null;
107+
108+
if (shareRole && ROLE_RANK[shareRole] > ROLE_RANK[accessRole]) {
109+
accessRole = shareRole;
110+
}
111+
if (
112+
userEmail &&
113+
d.ownerEmail === userEmail &&
114+
(orgId ? d.orgId === orgId : !d.orgId)
115+
) {
116+
accessRole = "owner";
117+
}
118+
119+
return {
120+
id: d.id,
121+
parentId: d.parentId,
122+
title: d.title,
123+
contentPreview: contentPreview(d.content),
124+
contentLength: d.content.length,
125+
icon: d.icon,
126+
position: d.position,
127+
isFavorite: parseDocumentFavorite(d.isFavorite),
128+
visibility: d.visibility,
129+
accessRole,
130+
canEdit: canEditRole(accessRole),
131+
canManage: canManageRole(accessRole),
132+
createdAt: d.createdAt,
133+
updatedAt: d.updatedAt,
134+
};
135+
});
40136

41137
return { documents: mapped };
42138
},

0 commit comments

Comments
 (0)