@open-mercato/documents 0.10.1-develop.8024.1.02dbc16b49 → 0.10.1-develop.8025.1.620dfa185f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
- import { assertTier, resolveUserAccess } from "../../../../lib/permissions.js";
3
+ import { assertTier, hasUserDocumentViewPermission, resolveUserDocumentTier } from "../../../../lib/permissions.js";
4
4
  import {
5
5
  handleDocumentsRouteError,
6
6
  readBody,
@@ -13,6 +13,7 @@ const accessCheckSchema = z.object({
13
13
  });
14
14
  const accessCheckResponseSchema = z.object({
15
15
  withoutAccess: z.array(z.string().uuid()),
16
+ shareableUserIds: z.array(z.string().uuid()).optional(),
16
17
  withoutAccessUsers: z.array(
17
18
  z.object({
18
19
  userId: z.string().uuid(),
@@ -38,22 +39,28 @@ async function POST(request, context) {
38
39
  const input = accessCheckSchema.parse(await readBody(request));
39
40
  const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())));
40
41
  const withoutAccess = [];
42
+ const shareableUserIds = [];
43
+ const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId };
41
44
  for (const userId of uniqueUserIds) {
42
- const tier = await resolveUserAccess(
45
+ const canView = await hasUserDocumentViewPermission(scope, userId, ctx.container);
46
+ const tier = canView ? await resolveUserDocumentTier(
43
47
  ctx.em,
44
48
  documentId,
45
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
49
+ scope,
46
50
  userId,
47
51
  ctx.container
48
- );
49
- if (!tier) withoutAccess.push(userId);
52
+ ) : null;
53
+ if (!tier) {
54
+ withoutAccess.push(userId);
55
+ if (canView) shareableUserIds.push(userId);
56
+ }
50
57
  }
51
58
  const withoutAccessUsers = withoutAccess.map((userId) => ({
52
59
  userId,
53
60
  label: null,
54
61
  secondary: null
55
62
  }));
56
- return NextResponse.json({ withoutAccess, withoutAccessUsers });
63
+ return NextResponse.json({ withoutAccess, withoutAccessUsers, shareableUserIds });
57
64
  } catch (error) {
58
65
  return handleDocumentsRouteError(error, "documents.comments.accessCheck");
59
66
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/documents/api/%5Bid%5D/comments/access-check/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { assertTier, resolveUserAccess } from '../../../../lib/permissions'\nimport {\n handleDocumentsRouteError,\n readBody,\n resolveDocumentsContext,\n routeErrorSchema,\n withDocumentsContextErrors,\n} from '../../../_shared'\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nconst accessCheckSchema = z.object({\n userIds: z.array(z.string().uuid()).max(50),\n})\n\nconst accessCheckResponseSchema = z.object({\n withoutAccess: z.array(z.string().uuid()),\n withoutAccessUsers: z.array(\n z.object({\n userId: z.string().uuid(),\n // Keep the published nullable-string contract for generated clients;\n // this route deliberately returns null values to avoid identity lookup.\n label: z.string().nullable(),\n secondary: z.string().nullable(),\n }),\n ),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['documents.view'] },\n}\n\nasync function resolveId(context: RouteContext): Promise<string> {\n const params = await context.params\n return params.id\n}\n\nexport async function POST(request: Request, context: RouteContext): Promise<Response> {\n try {\n const documentId = await resolveId(context)\n const ctx = await resolveDocumentsContext(request, ['documents.view'])\n await assertTier(ctx.em, documentId, ctx.auth, 'commenter')\n const input = accessCheckSchema.parse(await readBody(request))\n const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())))\n const withoutAccess: string[] = []\n\n for (const userId of uniqueUserIds) {\n const tier = await resolveUserAccess(\n ctx.em,\n documentId,\n { tenantId: ctx.tenantId, organizationId: ctx.organizationId },\n userId,\n ctx.container,\n )\n if (!tier) withoutAccess.push(userId)\n }\n // Keep the historical response shape for clients, but never turn this\n // authorization probe into a user-directory/PII lookup. The caller\n // already supplied these IDs and owns their local mention labels.\n const withoutAccessUsers = withoutAccess.map((userId) => ({\n userId,\n label: null,\n secondary: null,\n }))\n\n return NextResponse.json({ withoutAccess, withoutAccessUsers })\n } catch (error) {\n return handleDocumentsRouteError(error, 'documents.comments.accessCheck')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = withDocumentsContextErrors({\n tag: 'Documents',\n summary: 'Check mentioned user document access',\n pathParams: z.object({ id: z.string().uuid() }),\n methods: {\n POST: {\n summary: 'Check which mentioned users lack access',\n requestBody: { contentType: 'application/json', schema: accessCheckSchema },\n responses: [{ status: 200, description: 'Mention access check result', schema: accessCheckResponseSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: routeErrorSchema },\n { status: 401, description: 'Unauthorized', schema: routeErrorSchema },\n { status: 403, description: 'Forbidden', schema: routeErrorSchema },\n { status: 413, description: 'Request body exceeds the safe resource bound', schema: routeErrorSchema },\n ],\n },\n },\n})\n\nexport default { POST }\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,YAAY,yBAAyB;AAC9C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAMP,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE;AAC5C,CAAC;AAED,MAAM,4BAA4B,EAAE,OAAO;AAAA,EACzC,eAAe,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC;AAAA,EACxC,oBAAoB,EAAE;AAAA,IACpB,EAAE,OAAO;AAAA,MACP,QAAQ,EAAE,OAAO,EAAE,KAAK;AAAA;AAAA;AAAA,MAGxB,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,MAC3B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,IACjC,CAAC;AAAA,EACH;AACF,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AACjE;AAEA,eAAe,UAAU,SAAwC;AAC/D,QAAM,SAAS,MAAM,QAAQ;AAC7B,SAAO,OAAO;AAChB;AAEA,eAAsB,KAAK,SAAkB,SAA0C;AACrF,MAAI;AACF,UAAM,aAAa,MAAM,UAAU,OAAO;AAC1C,UAAM,MAAM,MAAM,wBAAwB,SAAS,CAAC,gBAAgB,CAAC;AACrE,UAAM,WAAW,IAAI,IAAI,YAAY,IAAI,MAAM,WAAW;AAC1D,UAAM,QAAQ,kBAAkB,MAAM,MAAM,SAAS,OAAO,CAAC;AAC7D,UAAM,gBAAgB,MAAM,KAAK,IAAI,IAAI,MAAM,QAAQ,IAAI,CAAC,WAAW,OAAO,YAAY,CAAC,CAAC,CAAC;AAC7F,UAAM,gBAA0B,CAAC;AAEjC,eAAW,UAAU,eAAe;AAClC,YAAM,OAAO,MAAM;AAAA,QACjB,IAAI;AAAA,QACJ;AAAA,QACA,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAAA,QAC7D;AAAA,QACA,IAAI;AAAA,MACN;AACA,UAAI,CAAC,KAAM,eAAc,KAAK,MAAM;AAAA,IACtC;AAIA,UAAM,qBAAqB,cAAc,IAAI,CAAC,YAAY;AAAA,MACxD;AAAA,MACA,OAAO;AAAA,MACP,WAAW;AAAA,IACb,EAAE;AAEF,WAAO,aAAa,KAAK,EAAE,eAAe,mBAAmB,CAAC;AAAA,EAChE,SAAS,OAAO;AACd,WAAO,0BAA0B,OAAO,gCAAgC;AAAA,EAC1E;AACF;AAEO,MAAM,UAA2B,2BAA2B;AAAA,EACjE,KAAK;AAAA,EACL,SAAS;AAAA,EACT,YAAY,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAAA,EAC9C,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa,EAAE,aAAa,oBAAoB,QAAQ,kBAAkB;AAAA,MAC1E,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,+BAA+B,QAAQ,0BAA0B,CAAC;AAAA,MAC1G,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,qBAAqB,QAAQ,iBAAiB;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,iBAAiB;AAAA,QACrE,EAAE,QAAQ,KAAK,aAAa,aAAa,QAAQ,iBAAiB;AAAA,QAClE,EAAE,QAAQ,KAAK,aAAa,gDAAgD,QAAQ,iBAAiB;AAAA,MACvG;AAAA,IACF;AAAA,EACF;AACF,CAAC;AAED,IAAO,gBAAQ,EAAE,KAAK;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { assertTier, hasUserDocumentViewPermission, resolveUserDocumentTier } from '../../../../lib/permissions'\nimport {\n handleDocumentsRouteError,\n readBody,\n resolveDocumentsContext,\n routeErrorSchema,\n withDocumentsContextErrors,\n} from '../../../_shared'\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nconst accessCheckSchema = z.object({\n userIds: z.array(z.string().uuid()).max(50),\n})\n\nconst accessCheckResponseSchema = z.object({\n withoutAccess: z.array(z.string().uuid()),\n shareableUserIds: z.array(z.string().uuid()).optional(),\n withoutAccessUsers: z.array(\n z.object({\n userId: z.string().uuid(),\n // Keep the published nullable-string contract for generated clients;\n // this route deliberately returns null values to avoid identity lookup.\n label: z.string().nullable(),\n secondary: z.string().nullable(),\n }),\n ),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['documents.view'] },\n}\n\nasync function resolveId(context: RouteContext): Promise<string> {\n const params = await context.params\n return params.id\n}\n\nexport async function POST(request: Request, context: RouteContext): Promise<Response> {\n try {\n const documentId = await resolveId(context)\n const ctx = await resolveDocumentsContext(request, ['documents.view'])\n await assertTier(ctx.em, documentId, ctx.auth, 'commenter')\n const input = accessCheckSchema.parse(await readBody(request))\n const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())))\n const withoutAccess: string[] = []\n const shareableUserIds: string[] = []\n const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }\n\n for (const userId of uniqueUserIds) {\n const canView = await hasUserDocumentViewPermission(scope, userId, ctx.container)\n const tier = canView ? await resolveUserDocumentTier(\n ctx.em,\n documentId,\n scope,\n userId,\n ctx.container,\n ) : null\n if (!tier) {\n withoutAccess.push(userId)\n if (canView) shareableUserIds.push(userId)\n }\n }\n // Keep the historical response shape for clients, but never turn this\n // authorization probe into a user-directory/PII lookup. The caller\n // already supplied these IDs and owns their local mention labels.\n const withoutAccessUsers = withoutAccess.map((userId) => ({\n userId,\n label: null,\n secondary: null,\n }))\n\n return NextResponse.json({ withoutAccess, withoutAccessUsers, shareableUserIds })\n } catch (error) {\n return handleDocumentsRouteError(error, 'documents.comments.accessCheck')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = withDocumentsContextErrors({\n tag: 'Documents',\n summary: 'Check mentioned user document access',\n pathParams: z.object({ id: z.string().uuid() }),\n methods: {\n POST: {\n summary: 'Check which mentioned users lack access',\n requestBody: { contentType: 'application/json', schema: accessCheckSchema },\n responses: [{ status: 200, description: 'Mention access check result', schema: accessCheckResponseSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: routeErrorSchema },\n { status: 401, description: 'Unauthorized', schema: routeErrorSchema },\n { status: 403, description: 'Forbidden', schema: routeErrorSchema },\n { status: 413, description: 'Request body exceeds the safe resource bound', schema: routeErrorSchema },\n ],\n },\n },\n})\n\nexport default { POST }\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,YAAY,+BAA+B,+BAA+B;AACnF;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAMP,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE;AAC5C,CAAC;AAED,MAAM,4BAA4B,EAAE,OAAO;AAAA,EACzC,eAAe,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC;AAAA,EACxC,kBAAkB,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,SAAS;AAAA,EACtD,oBAAoB,EAAE;AAAA,IACpB,EAAE,OAAO;AAAA,MACP,QAAQ,EAAE,OAAO,EAAE,KAAK;AAAA;AAAA;AAAA,MAGxB,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,MAC3B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,IACjC,CAAC;AAAA,EACH;AACF,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AACjE;AAEA,eAAe,UAAU,SAAwC;AAC/D,QAAM,SAAS,MAAM,QAAQ;AAC7B,SAAO,OAAO;AAChB;AAEA,eAAsB,KAAK,SAAkB,SAA0C;AACrF,MAAI;AACF,UAAM,aAAa,MAAM,UAAU,OAAO;AAC1C,UAAM,MAAM,MAAM,wBAAwB,SAAS,CAAC,gBAAgB,CAAC;AACrE,UAAM,WAAW,IAAI,IAAI,YAAY,IAAI,MAAM,WAAW;AAC1D,UAAM,QAAQ,kBAAkB,MAAM,MAAM,SAAS,OAAO,CAAC;AAC7D,UAAM,gBAAgB,MAAM,KAAK,IAAI,IAAI,MAAM,QAAQ,IAAI,CAAC,WAAW,OAAO,YAAY,CAAC,CAAC,CAAC;AAC7F,UAAM,gBAA0B,CAAC;AACjC,UAAM,mBAA6B,CAAC;AACpC,UAAM,QAAQ,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAE3E,eAAW,UAAU,eAAe;AAClC,YAAM,UAAU,MAAM,8BAA8B,OAAO,QAAQ,IAAI,SAAS;AAChF,YAAM,OAAO,UAAU,MAAM;AAAA,QAC3B,IAAI;AAAA,QACJ;AAAA,QACA;AAAA,QACA;AAAA,QACA,IAAI;AAAA,MACN,IAAI;AACJ,UAAI,CAAC,MAAM;AACT,sBAAc,KAAK,MAAM;AACzB,YAAI,QAAS,kBAAiB,KAAK,MAAM;AAAA,MAC3C;AAAA,IACF;AAIA,UAAM,qBAAqB,cAAc,IAAI,CAAC,YAAY;AAAA,MACxD;AAAA,MACA,OAAO;AAAA,MACP,WAAW;AAAA,IACb,EAAE;AAEF,WAAO,aAAa,KAAK,EAAE,eAAe,oBAAoB,iBAAiB,CAAC;AAAA,EAClF,SAAS,OAAO;AACd,WAAO,0BAA0B,OAAO,gCAAgC;AAAA,EAC1E;AACF;AAEO,MAAM,UAA2B,2BAA2B;AAAA,EACjE,KAAK;AAAA,EACL,SAAS;AAAA,EACT,YAAY,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAAA,EAC9C,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa,EAAE,aAAa,oBAAoB,QAAQ,kBAAkB;AAAA,MAC1E,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,+BAA+B,QAAQ,0BAA0B,CAAC;AAAA,MAC1G,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,qBAAqB,QAAQ,iBAAiB;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,iBAAiB;AAAA,QACrE,EAAE,QAAQ,KAAK,aAAa,aAAa,QAAQ,iBAAiB;AAAA,QAClE,EAAE,QAAQ,KAAK,aAAa,gDAAgD,QAAQ,iBAAiB;AAAA,MACvG;AAAA,IACF;AAAA,EACF;AACF,CAAC;AAED,IAAO,gBAAQ,EAAE,KAAK;",
6
6
  "names": []
7
7
  }
@@ -134,20 +134,25 @@ function useDocumentComments({ documentId, editor, canComment, canShare }) {
134
134
  );
135
135
  if (!isCurrentSubmission()) return { cancelled: true };
136
136
  if (!call.ok) throw new Error(t("documents.comments.error.save"));
137
- const withoutAccess = readWithoutAccess(call.result);
137
+ const withoutAccess = readWithoutAccess(call.result).filter((user) => userIds.includes(user.userId));
138
138
  if (withoutAccess.length === 0) return { cancelled: false };
139
- if (!canShare) {
140
- flash(t("documents.comments.grant.noAccessInfo"), "info");
141
- return { cancelled: false, grantAccessTo: [] };
139
+ const response = readRecord(call.result);
140
+ const shareableUserIds = new Set(
141
+ Array.isArray(response?.shareableUserIds) ? response.shareableUserIds.filter((value) => typeof value === "string").map((value) => value.toLowerCase()) : []
142
+ );
143
+ const shareableUsers = withoutAccess.filter((user) => shareableUserIds.has(user.userId));
144
+ if (!canShare || shareableUsers.length === 0) {
145
+ return { cancelled: false, grantAccessTo: [], hasUnnotifiedMentions: true };
142
146
  }
143
147
  const pendingMentionNames = new Map(
144
148
  activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name])
145
149
  );
146
- const decision = await requestGrantAccess(withoutAccess.map((user) => pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)));
150
+ const decision = await requestGrantAccess(shareableUsers.map((user) => pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)));
147
151
  if (decision === "cancel" || !isCurrentSubmission()) return { cancelled: true };
148
152
  return {
149
153
  cancelled: false,
150
- grantAccessTo: decision === "share" ? withoutAccess.map((user) => user.userId) : []
154
+ grantAccessTo: decision === "share" ? shareableUsers.map((user) => user.userId) : [],
155
+ hasUnnotifiedMentions: decision === "skip" || shareableUsers.length < withoutAccess.length
151
156
  };
152
157
  }, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t]);
153
158
  const submit = React.useCallback(async () => {
@@ -182,6 +187,9 @@ function useDocumentComments({ documentId, editor, canComment, canShare }) {
182
187
  mutationPayload: { body: trimmedBody, parentCommentId, mentions }
183
188
  });
184
189
  if (!isCurrentSubmission()) return;
190
+ if (grantResolution.hasUnnotifiedMentions) {
191
+ flash(t("documents.comments.grant.noAccessInfo"), "info");
192
+ }
185
193
  resetComposer();
186
194
  await reload();
187
195
  } catch (error) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/documents/backend/documents/%5Bid%5D/useDocumentComments.ts"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Editor } from '@tiptap/core'\nimport { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n DOCUMENTS_COMMENT_LIST_PAGE_SIZE,\n DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT,\n} from '../../../lib/historyPolicy'\nimport { readNumber, readRecord } from '../documentUi'\nimport type { CommentAnchor } from './CommentAnchorNavigation'\nimport {\n bodyContainsPendingMention,\n findCommentById,\n readCommentItems,\n readUserLabels,\n readWithoutAccess,\n type DocumentComment,\n type PendingMention,\n type UserLabels,\n} from './commentTypes'\n\ntype CommentsState =\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'ready'; comments: DocumentComment[]; userLabels: UserLabels }\n\ntype UseDocumentCommentsInput = {\n documentId: string\n editor: Editor | null\n canComment: boolean\n canShare: boolean\n}\n\ntype GrantAccessDecision = 'share' | 'skip' | 'cancel'\n\ntype GrantAccessResolution =\n | { cancelled: true }\n | { cancelled: false; grantAccessTo?: string[] }\n\nexport function useDocumentComments({ documentId, editor, canComment, canShare }: UseDocumentCommentsInput) {\n const t = useT()\n const [state, setState] = React.useState<CommentsState>({ status: 'loading' })\n const [body, setBody] = React.useState('')\n const [pendingMentions, setPendingMentions] = React.useState<PendingMention[]>([])\n const [parentCommentId, setParentCommentId] = React.useState<string | null>(null)\n const [pendingAnchor, setPendingAnchor] = React.useState<CommentAnchor | null>(null)\n const [isSubmitting, setIsSubmitting] = React.useState(false)\n const [resolvingCommentId, setResolvingCommentId] = React.useState<string | null>(null)\n const [grantAccessNames, setGrantAccessNames] = React.useState<string[] | null>(null)\n const grantResolver = React.useRef<((decision: GrantAccessDecision) => void) | null>(null)\n const submissionContextSequence = React.useRef(0)\n const reloadSequence = React.useRef(0)\n const activeReload = React.useRef<AbortController | null>(null)\n const activeDocumentId = React.useRef<string | null>(null)\n const mutationContextId = `documents-comments:${documentId}`\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n }>({ contextId: mutationContextId, blockedMessage: t('ui.forms.flash.saveBlocked') })\n const activePendingMentions = React.useMemo(\n () => pendingMentions.filter((mention) => bodyContainsPendingMention(body, mention)),\n [body, pendingMentions],\n )\n\n const reload = React.useCallback(async () => {\n const reloadId = ++reloadSequence.current\n const controller = new AbortController()\n activeReload.current?.abort()\n activeReload.current = controller\n const isCurrent = () => reloadSequence.current === reloadId && !controller.signal.aborted\n const documentChanged = activeDocumentId.current !== documentId\n activeDocumentId.current = documentId\n setState((current) => !documentChanged && current.status === 'ready' ? current : { status: 'loading' })\n try {\n const maxPages = Math.ceil(DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT / DOCUMENTS_COMMENT_LIST_PAGE_SIZE)\n const pathForPage = (page: number) => (\n `/api/documents/${encodeURIComponent(documentId)}/comments?page=${page}&pageSize=${DOCUMENTS_COMMENT_LIST_PAGE_SIZE}`\n )\n const firstCall = await apiCall<unknown>(pathForPage(1), { signal: controller.signal })\n if (!isCurrent()) return\n if (!firstCall.ok) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const firstRoot = readRecord(firstCall.result)\n const totalPages = Math.min(\n maxPages,\n Math.max(1, firstRoot ? readNumber(firstRoot, 'totalPages', 'total_pages') ?? 1 : 1),\n )\n const remainingCalls = totalPages > 1\n ? await Promise.all(Array.from({ length: totalPages - 1 }, (_, index) => (\n apiCall<unknown>(pathForPage(index + 2), { signal: controller.signal })\n )))\n : []\n if (!isCurrent()) return\n if (remainingCalls.some((call) => !call.ok)) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const calls = [firstCall, ...remainingCalls]\n const comments = [...calls].reverse().flatMap((call) => readCommentItems(call.result))\n const userLabels = calls.reduce<UserLabels>((labels, call) => ({\n ...labels,\n ...readUserLabels(call.result, t('documents.users.unknown')),\n }), {})\n setState({ status: 'ready', comments, userLabels })\n } catch (error) {\n if (isCurrent()) {\n setState({ status: 'error', message: error instanceof Error ? error.message : t('documents.comments.error.load') })\n }\n } finally {\n if (activeReload.current === controller) activeReload.current = null\n }\n }, [documentId, t])\n\n React.useEffect(() => {\n void reload()\n return () => {\n reloadSequence.current += 1\n activeReload.current?.abort()\n activeReload.current = null\n }\n }, [reload])\n\n const cancelPendingGrantAccess = React.useCallback(() => {\n const resolve = grantResolver.current\n grantResolver.current = null\n resolve?.('cancel')\n }, [])\n\n React.useEffect(() => {\n setGrantAccessNames(null)\n setIsSubmitting(false)\n return () => {\n submissionContextSequence.current += 1\n cancelPendingGrantAccess()\n }\n }, [cancelPendingGrantAccess, documentId])\n\n const labelFor = React.useCallback((userId: string) => {\n const labels = state.status === 'ready' ? state.userLabels : {}\n return labels[userId.toLowerCase()]?.label ?? t('documents.users.unknown')\n }, [state, t])\n\n const resetComposer = React.useCallback(() => {\n setBody('')\n setPendingMentions([])\n setParentCommentId(null)\n setPendingAnchor(null)\n }, [])\n\n const chooseGrantAccess = React.useCallback((share: boolean) => {\n const resolve = grantResolver.current\n grantResolver.current = null\n setGrantAccessNames(null)\n resolve?.(share ? 'share' : 'skip')\n }, [])\n\n const requestGrantAccess = React.useCallback((names: string[]) => new Promise<GrantAccessDecision>((resolve) => {\n cancelPendingGrantAccess()\n grantResolver.current = resolve\n setGrantAccessNames(names)\n }), [cancelPendingGrantAccess])\n\n const resolveGrantAccessTo = React.useCallback(async (\n isCurrentSubmission: () => boolean,\n ): Promise<GrantAccessResolution> => {\n const userIds = Array.from(new Set(activePendingMentions.map((mention) => mention.userId.toLowerCase())))\n if (userIds.length === 0) return { cancelled: false }\n // Intentionally outside useGuardedMutation: access-check is read-shaped\n // (a POST only to carry the user-id list in the body) and mutates nothing.\n const call = await apiCall<unknown>(\n `/api/documents/${encodeURIComponent(documentId)}/comments/access-check`,\n { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userIds }) },\n )\n if (!isCurrentSubmission()) return { cancelled: true }\n if (!call.ok) throw new Error(t('documents.comments.error.save'))\n const withoutAccess = readWithoutAccess(call.result)\n if (withoutAccess.length === 0) return { cancelled: false }\n if (!canShare) {\n flash(t('documents.comments.grant.noAccessInfo'), 'info')\n return { cancelled: false, grantAccessTo: [] }\n }\n const pendingMentionNames = new Map(\n activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name]),\n )\n const decision = await requestGrantAccess(withoutAccess.map((user) => (\n pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)\n )))\n if (decision === 'cancel' || !isCurrentSubmission()) return { cancelled: true }\n return {\n cancelled: false,\n grantAccessTo: decision === 'share' ? withoutAccess.map((user) => user.userId) : [],\n }\n }, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t])\n\n const submit = React.useCallback(async () => {\n const trimmedBody = body.trim()\n if (!trimmedBody || !canComment) return\n const submissionSequence = submissionContextSequence.current\n const isCurrentSubmission = () => submissionContextSequence.current === submissionSequence\n setIsSubmitting(true)\n try {\n const grantResolution = await resolveGrantAccessTo(isCurrentSubmission)\n if (grantResolution.cancelled || !isCurrentSubmission()) return\n const anchor = pendingAnchor ?? (editor\n ? (await import('./CommentAnchorNavigation')).captureCommentAnchor(editor)\n : null)\n if (!isCurrentSubmission()) return\n const mentions = activePendingMentions.map(({ userId }) => ({ userId }))\n await runMutation({\n operation: () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n body: trimmedBody,\n anchor,\n parentCommentId,\n mentions,\n grantAccessTo: grantResolution.grantAccessTo,\n }),\n },\n { errorMessage: t('documents.comments.error.save') },\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: documentId, retryLastMutation },\n mutationPayload: { body: trimmedBody, parentCommentId, mentions },\n })\n if (!isCurrentSubmission()) return\n resetComposer()\n await reload()\n } catch (error) {\n if (isCurrentSubmission()) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n if (isCurrentSubmission()) setIsSubmitting(false)\n }\n }, [activePendingMentions, body, canComment, documentId, editor, mutationContextId, parentCommentId, pendingAnchor, reload, resetComposer, resolveGrantAccessTo, retryLastMutation, runMutation, t])\n\n const resolveComment = React.useCallback(async (comment: DocumentComment) => {\n if (!comment.canResolve) return\n setResolvingCommentId(comment.id)\n try {\n await runMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(comment.updatedAt),\n () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: comment.id, resolved: comment.resolvedAt === null }) },\n { errorMessage: t('documents.comments.error.save') },\n ),\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: comment.id, retryLastMutation },\n mutationPayload: { id: comment.id, resolved: comment.resolvedAt === null },\n })\n await reload()\n } catch (error) {\n if (!surfaceRecordConflict(error, t, { onRefresh: () => { void reload() } })) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n setResolvingCommentId(null)\n }\n }, [documentId, mutationContextId, reload, retryLastMutation, runMutation, t])\n\n const comments = state.status === 'ready' ? state.comments : []\n const parentComment = parentCommentId ? findCommentById(comments, parentCommentId) : null\n return {\n state, comments, body, setBody, pendingMentions, setPendingMentions, parentCommentId,\n pendingAnchor, setPendingAnchor, isSubmitting, resolvingCommentId, grantAccessNames,\n chooseGrantAccess, labelFor, resetComposer, reload, submit, resolveComment,\n startReply: (comment: DocumentComment) => { setPendingAnchor(null); setParentCommentId(comment.id) },\n replyToName: parentComment ? labelFor(parentComment.authorUserId) : null,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Editor } from '@tiptap/core'\nimport { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n DOCUMENTS_COMMENT_LIST_PAGE_SIZE,\n DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT,\n} from '../../../lib/historyPolicy'\nimport { readNumber, readRecord } from '../documentUi'\nimport type { CommentAnchor } from './CommentAnchorNavigation'\nimport {\n bodyContainsPendingMention,\n findCommentById,\n readCommentItems,\n readUserLabels,\n readWithoutAccess,\n type DocumentComment,\n type PendingMention,\n type UserLabels,\n} from './commentTypes'\n\ntype CommentsState =\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'ready'; comments: DocumentComment[]; userLabels: UserLabels }\n\ntype UseDocumentCommentsInput = {\n documentId: string\n editor: Editor | null\n canComment: boolean\n canShare: boolean\n}\n\ntype GrantAccessDecision = 'share' | 'skip' | 'cancel'\n\ntype GrantAccessResolution =\n | { cancelled: true }\n | { cancelled: false; grantAccessTo?: string[]; hasUnnotifiedMentions?: boolean }\n\nexport function useDocumentComments({ documentId, editor, canComment, canShare }: UseDocumentCommentsInput) {\n const t = useT()\n const [state, setState] = React.useState<CommentsState>({ status: 'loading' })\n const [body, setBody] = React.useState('')\n const [pendingMentions, setPendingMentions] = React.useState<PendingMention[]>([])\n const [parentCommentId, setParentCommentId] = React.useState<string | null>(null)\n const [pendingAnchor, setPendingAnchor] = React.useState<CommentAnchor | null>(null)\n const [isSubmitting, setIsSubmitting] = React.useState(false)\n const [resolvingCommentId, setResolvingCommentId] = React.useState<string | null>(null)\n const [grantAccessNames, setGrantAccessNames] = React.useState<string[] | null>(null)\n const grantResolver = React.useRef<((decision: GrantAccessDecision) => void) | null>(null)\n const submissionContextSequence = React.useRef(0)\n const reloadSequence = React.useRef(0)\n const activeReload = React.useRef<AbortController | null>(null)\n const activeDocumentId = React.useRef<string | null>(null)\n const mutationContextId = `documents-comments:${documentId}`\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n }>({ contextId: mutationContextId, blockedMessage: t('ui.forms.flash.saveBlocked') })\n const activePendingMentions = React.useMemo(\n () => pendingMentions.filter((mention) => bodyContainsPendingMention(body, mention)),\n [body, pendingMentions],\n )\n\n const reload = React.useCallback(async () => {\n const reloadId = ++reloadSequence.current\n const controller = new AbortController()\n activeReload.current?.abort()\n activeReload.current = controller\n const isCurrent = () => reloadSequence.current === reloadId && !controller.signal.aborted\n const documentChanged = activeDocumentId.current !== documentId\n activeDocumentId.current = documentId\n setState((current) => !documentChanged && current.status === 'ready' ? current : { status: 'loading' })\n try {\n const maxPages = Math.ceil(DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT / DOCUMENTS_COMMENT_LIST_PAGE_SIZE)\n const pathForPage = (page: number) => (\n `/api/documents/${encodeURIComponent(documentId)}/comments?page=${page}&pageSize=${DOCUMENTS_COMMENT_LIST_PAGE_SIZE}`\n )\n const firstCall = await apiCall<unknown>(pathForPage(1), { signal: controller.signal })\n if (!isCurrent()) return\n if (!firstCall.ok) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const firstRoot = readRecord(firstCall.result)\n const totalPages = Math.min(\n maxPages,\n Math.max(1, firstRoot ? readNumber(firstRoot, 'totalPages', 'total_pages') ?? 1 : 1),\n )\n const remainingCalls = totalPages > 1\n ? await Promise.all(Array.from({ length: totalPages - 1 }, (_, index) => (\n apiCall<unknown>(pathForPage(index + 2), { signal: controller.signal })\n )))\n : []\n if (!isCurrent()) return\n if (remainingCalls.some((call) => !call.ok)) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const calls = [firstCall, ...remainingCalls]\n const comments = [...calls].reverse().flatMap((call) => readCommentItems(call.result))\n const userLabels = calls.reduce<UserLabels>((labels, call) => ({\n ...labels,\n ...readUserLabels(call.result, t('documents.users.unknown')),\n }), {})\n setState({ status: 'ready', comments, userLabels })\n } catch (error) {\n if (isCurrent()) {\n setState({ status: 'error', message: error instanceof Error ? error.message : t('documents.comments.error.load') })\n }\n } finally {\n if (activeReload.current === controller) activeReload.current = null\n }\n }, [documentId, t])\n\n React.useEffect(() => {\n void reload()\n return () => {\n reloadSequence.current += 1\n activeReload.current?.abort()\n activeReload.current = null\n }\n }, [reload])\n\n const cancelPendingGrantAccess = React.useCallback(() => {\n const resolve = grantResolver.current\n grantResolver.current = null\n resolve?.('cancel')\n }, [])\n\n React.useEffect(() => {\n setGrantAccessNames(null)\n setIsSubmitting(false)\n return () => {\n submissionContextSequence.current += 1\n cancelPendingGrantAccess()\n }\n }, [cancelPendingGrantAccess, documentId])\n\n const labelFor = React.useCallback((userId: string) => {\n const labels = state.status === 'ready' ? state.userLabels : {}\n return labels[userId.toLowerCase()]?.label ?? t('documents.users.unknown')\n }, [state, t])\n\n const resetComposer = React.useCallback(() => {\n setBody('')\n setPendingMentions([])\n setParentCommentId(null)\n setPendingAnchor(null)\n }, [])\n\n const chooseGrantAccess = React.useCallback((share: boolean) => {\n const resolve = grantResolver.current\n grantResolver.current = null\n setGrantAccessNames(null)\n resolve?.(share ? 'share' : 'skip')\n }, [])\n\n const requestGrantAccess = React.useCallback((names: string[]) => new Promise<GrantAccessDecision>((resolve) => {\n cancelPendingGrantAccess()\n grantResolver.current = resolve\n setGrantAccessNames(names)\n }), [cancelPendingGrantAccess])\n\n const resolveGrantAccessTo = React.useCallback(async (\n isCurrentSubmission: () => boolean,\n ): Promise<GrantAccessResolution> => {\n const userIds = Array.from(new Set(activePendingMentions.map((mention) => mention.userId.toLowerCase())))\n if (userIds.length === 0) return { cancelled: false }\n // Intentionally outside useGuardedMutation: access-check is read-shaped\n // (a POST only to carry the user-id list in the body) and mutates nothing.\n const call = await apiCall<unknown>(\n `/api/documents/${encodeURIComponent(documentId)}/comments/access-check`,\n { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userIds }) },\n )\n if (!isCurrentSubmission()) return { cancelled: true }\n if (!call.ok) throw new Error(t('documents.comments.error.save'))\n const withoutAccess = readWithoutAccess(call.result).filter((user) => userIds.includes(user.userId))\n if (withoutAccess.length === 0) return { cancelled: false }\n const response = readRecord(call.result)\n const shareableUserIds = new Set(\n Array.isArray(response?.shareableUserIds)\n ? response.shareableUserIds.filter((value): value is string => typeof value === 'string').map((value) => value.toLowerCase())\n : [],\n )\n const shareableUsers = withoutAccess.filter((user) => shareableUserIds.has(user.userId))\n if (!canShare || shareableUsers.length === 0) {\n return { cancelled: false, grantAccessTo: [], hasUnnotifiedMentions: true }\n }\n const pendingMentionNames = new Map(\n activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name]),\n )\n const decision = await requestGrantAccess(shareableUsers.map((user) => (\n pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)\n )))\n if (decision === 'cancel' || !isCurrentSubmission()) return { cancelled: true }\n return {\n cancelled: false,\n grantAccessTo: decision === 'share' ? shareableUsers.map((user) => user.userId) : [],\n hasUnnotifiedMentions: decision === 'skip' || shareableUsers.length < withoutAccess.length,\n }\n }, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t])\n\n const submit = React.useCallback(async () => {\n const trimmedBody = body.trim()\n if (!trimmedBody || !canComment) return\n const submissionSequence = submissionContextSequence.current\n const isCurrentSubmission = () => submissionContextSequence.current === submissionSequence\n setIsSubmitting(true)\n try {\n const grantResolution = await resolveGrantAccessTo(isCurrentSubmission)\n if (grantResolution.cancelled || !isCurrentSubmission()) return\n const anchor = pendingAnchor ?? (editor\n ? (await import('./CommentAnchorNavigation')).captureCommentAnchor(editor)\n : null)\n if (!isCurrentSubmission()) return\n const mentions = activePendingMentions.map(({ userId }) => ({ userId }))\n await runMutation({\n operation: () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n body: trimmedBody,\n anchor,\n parentCommentId,\n mentions,\n grantAccessTo: grantResolution.grantAccessTo,\n }),\n },\n { errorMessage: t('documents.comments.error.save') },\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: documentId, retryLastMutation },\n mutationPayload: { body: trimmedBody, parentCommentId, mentions },\n })\n if (!isCurrentSubmission()) return\n if (grantResolution.hasUnnotifiedMentions) {\n flash(t('documents.comments.grant.noAccessInfo'), 'info')\n }\n resetComposer()\n await reload()\n } catch (error) {\n if (isCurrentSubmission()) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n if (isCurrentSubmission()) setIsSubmitting(false)\n }\n }, [activePendingMentions, body, canComment, documentId, editor, mutationContextId, parentCommentId, pendingAnchor, reload, resetComposer, resolveGrantAccessTo, retryLastMutation, runMutation, t])\n\n const resolveComment = React.useCallback(async (comment: DocumentComment) => {\n if (!comment.canResolve) return\n setResolvingCommentId(comment.id)\n try {\n await runMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(comment.updatedAt),\n () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: comment.id, resolved: comment.resolvedAt === null }) },\n { errorMessage: t('documents.comments.error.save') },\n ),\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: comment.id, retryLastMutation },\n mutationPayload: { id: comment.id, resolved: comment.resolvedAt === null },\n })\n await reload()\n } catch (error) {\n if (!surfaceRecordConflict(error, t, { onRefresh: () => { void reload() } })) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n setResolvingCommentId(null)\n }\n }, [documentId, mutationContextId, reload, retryLastMutation, runMutation, t])\n\n const comments = state.status === 'ready' ? state.comments : []\n const parentComment = parentCommentId ? findCommentById(comments, parentCommentId) : null\n return {\n state, comments, body, setBody, pendingMentions, setPendingMentions, parentCommentId,\n pendingAnchor, setPendingAnchor, isSubmitting, resolvingCommentId, grantAccessNames,\n chooseGrantAccess, labelFor, resetComposer, reload, submit, resolveComment,\n startReply: (comment: DocumentComment) => { setPendingAnchor(null); setParentCommentId(comment.id) },\n replyToName: parentComment ? labelFor(parentComment.authorUserId) : null,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["comments"]
7
7
  }
@@ -129,11 +129,15 @@ async function loadScopedDocument(em, documentId, scope) {
129
129
  );
130
130
  }
131
131
  async function resolveLoadedDocumentUserAccess(em, document, scope, userId, container) {
132
+ if (!await hasUserDocumentViewPermission(scope, userId, container)) return null;
133
+ return resolveLoadedDocumentUserTier(em, document, scope, userId, container);
134
+ }
135
+ async function hasUserDocumentViewPermission(scope, userId, container) {
132
136
  const rbacService = resolveDocumentsRbacService(container);
133
- if (!rbacService) return null;
137
+ if (!rbacService) return false;
134
138
  if (!await rbacService.userHasAllFeatures(userId, ["documents.view"], scope)) {
135
139
  const organizationScopeService = resolveOrganizationScopeService(container);
136
- if (!organizationScopeService) return null;
140
+ if (!organizationScopeService) return false;
137
141
  const authorization = await organizationScopeService.resolveFresh({
138
142
  auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },
139
143
  selectedId: scope.organizationId,
@@ -143,9 +147,9 @@ async function resolveLoadedDocumentUserAccess(em, document, scope, userId, cont
143
147
  grantedFeatures: authorization.acl.features,
144
148
  unrestricted: authorization.acl.isSuperAdmin,
145
149
  scopeAllowed: authorization.scope.tenantId === scope.tenantId && !authorization.scope.selectionRejected && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope)
146
- })) return null;
150
+ })) return false;
147
151
  }
148
- return resolveLoadedDocumentUserTier(em, document, scope, userId, container);
152
+ return true;
149
153
  }
150
154
  async function resolveLoadedDocumentUserTier(em, document, scope, userId, container) {
151
155
  if (document.ownerUserId === userId) return "owner";
@@ -238,6 +242,7 @@ export {
238
242
  TIER_RANK,
239
243
  assertTier,
240
244
  hasTier,
245
+ hasUserDocumentViewPermission,
241
246
  loadScopedDocument,
242
247
  resolveActiveSubjectRoleIds,
243
248
  resolveActiveUserRoleIds,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/documents/lib/permissions.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { forbidden } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Document, DocumentShare } from '../data/entities'\nimport { hasResolvedDocumentsOrganizationAccess } from './organizationAccess'\nimport {\n resolveApiKeyPrincipalService,\n resolveAuthPrincipalService,\n resolveDocumentsRbacService,\n resolveOrganizationScopeService,\n type DocumentsServiceContainer,\n} from './platformServices'\n\nexport type DocumentTier = 'owner' | 'editor' | 'commenter' | 'viewer'\n\nexport const TIER_RANK: Record<DocumentTier, number> = {\n owner: 3,\n editor: 2,\n commenter: 1,\n viewer: 0,\n}\n\ntype DocumentsPermissionContext = NonNullable<AuthContext> & {\n features?: string[]\n roleIds?: string[]\n resolvedRoleIds?: string[]\n organizationId?: string | null\n}\n\nfunction resolveUserId(ctx: AuthContext): string | null {\n if (!ctx) return null\n if (typeof ctx.userId === 'string' && ctx.userId.trim().length > 0) return ctx.userId\n if (typeof ctx.sub === 'string' && ctx.sub.trim().length > 0 && !ctx.sub.startsWith('api_key:')) return ctx.sub\n return null\n}\n\nfunction resolveOrganizationId(ctx: DocumentsPermissionContext): string | null {\n if (typeof ctx.organizationId === 'string' && ctx.organizationId.trim().length > 0) {\n return ctx.organizationId\n }\n if (typeof ctx.orgId === 'string' && ctx.orgId.trim().length > 0) return ctx.orgId\n return null\n}\n\nfunction normalizeStrings(values: unknown): string[] {\n if (!Array.isArray(values)) return []\n return Array.from(\n new Set(\n values\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ),\n )\n}\n\nfunction hasDocumentsManage(ctx: DocumentsPermissionContext): boolean {\n if (ctx.isSuperAdmin === true) return true\n const features = normalizeStrings(ctx.features)\n return hasAllFeatures(['documents.manage'], features)\n}\n\nexport async function resolveActiveUserRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n): Promise<string[]> {\n const service = resolveAuthPrincipalService(container)\n if (!service) return []\n return normalizeStrings(await service.resolveActiveUserRoleIds(userId, scope))\n}\n\nexport async function resolveActiveSubjectRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n subject: string,\n): Promise<string[]> {\n if (!subject.startsWith('api_key:')) {\n return resolveActiveUserRoleIds(container, scope, subject)\n }\n\n const apiKeyId = subject.slice('api_key:'.length).trim()\n if (!apiKeyId) return []\n const apiKeyService = resolveApiKeyPrincipalService(container)\n const authService = resolveAuthPrincipalService(container)\n if (!apiKeyService || !authService) return []\n const assigned = normalizeStrings(await apiKeyService.resolveAssignedRoleIds(apiKeyId, scope))\n if (assigned.length === 0) return []\n return normalizeStrings(await authService.filterActiveRoleIds(assigned, scope))\n}\n\nfunction maxShareTier(shares: DocumentShare[]): DocumentTier | null {\n let best: DocumentTier | null = null\n for (const share of shares) {\n const permission = share.permission\n if (permission !== 'viewer' && permission !== 'commenter' && permission !== 'editor') continue\n if (!best || TIER_RANK[permission] > TIER_RANK[best]) best = permission\n }\n return best\n}\n\nexport async function resolvePermission(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n): Promise<DocumentTier | null> {\n if (!ctx || !ctx.tenantId) return null\n const permissionCtx = ctx as DocumentsPermissionContext\n const organizationId = resolveOrganizationId(permissionCtx)\n if (!organizationId) return null\n\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n if (!document) return null\n\n const userId = resolveUserId(ctx)\n if ((userId && document.ownerUserId === userId) || hasDocumentsManage(permissionCtx)) {\n return 'owner'\n }\n if (!userId) return null\n\n // Never trust role ids/names carried by the request token. Role shares are\n // resolved from active UserRole links to active roles in the current tenant.\n // Only consume the server-projected role set. Raw token roleIds/roles are\n // authentication hints and can outlive an assignment revocation.\n const roleIds = normalizeStrings(permissionCtx.resolvedRoleIds)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n\n return maxShareTier(shares)\n}\n\nexport async function loadScopedDocument(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<Document | null> {\n return findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n}\n\n/**\n * Resolve one user's tier against an already scoped document. Callers that\n * evaluate many users for the same document load it once and reuse it here\n * instead of re-reading the identical row per user.\n */\nexport async function resolveLoadedDocumentUserAccess(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const rbacService = resolveDocumentsRbacService(container)\n if (!rbacService) return null\n if (!await rbacService.userHasAllFeatures(userId, ['documents.view'], scope)) {\n const organizationScopeService = resolveOrganizationScopeService(container)\n if (!organizationScopeService) return null\n const authorization = await organizationScopeService.resolveFresh({\n auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },\n selectedId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n if (!authorizeFeatures(['documents.view'], {\n grantedFeatures: authorization.acl.features,\n unrestricted: authorization.acl.isSuperAdmin,\n scopeAllowed: authorization.scope.tenantId === scope.tenantId\n && !authorization.scope.selectionRejected\n && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope),\n })) return null\n }\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nasync function resolveLoadedDocumentUserTier(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n if (document.ownerUserId === userId) return 'owner'\n if (await resolveDocumentsRbacService(container)?.userHasAllFeatures(\n userId,\n ['documents.manage'],\n scope,\n )) return 'owner'\n\n const roleIds = await resolveActiveUserRoleIds(container, scope, userId)\n\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId: document.id,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n\n return maxShareTier(shares)\n}\n\nexport async function resolveUserDocumentTier(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nexport async function resolveUserAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserAccess(em, document, scope, userId, container)\n}\n\nexport async function resolveSubjectAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n identity: { subject: string; userId: string },\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!document) return null\n if (document.ownerUserId === identity.userId) return 'owner'\n\n const roleIds = await resolveActiveSubjectRoleIds(container, scope, identity.subject)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: identity.userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n return maxShareTier(shares)\n}\n\nexport function hasTier(tier: DocumentTier | null, required: DocumentTier): boolean {\n if (!tier) return false\n return TIER_RANK[tier] >= TIER_RANK[required]\n}\n\nexport async function assertTier(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n required: DocumentTier,\n): Promise<DocumentTier> {\n const tier = await resolvePermission(em, documentId, ctx)\n if (!tier || !hasTier(tier, required)) {\n throw forbidden('Forbidden')\n }\n return tier\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { forbidden } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Document, DocumentShare } from '../data/entities'\nimport { hasResolvedDocumentsOrganizationAccess } from './organizationAccess'\nimport {\n resolveApiKeyPrincipalService,\n resolveAuthPrincipalService,\n resolveDocumentsRbacService,\n resolveOrganizationScopeService,\n type DocumentsServiceContainer,\n} from './platformServices'\n\nexport type DocumentTier = 'owner' | 'editor' | 'commenter' | 'viewer'\n\nexport const TIER_RANK: Record<DocumentTier, number> = {\n owner: 3,\n editor: 2,\n commenter: 1,\n viewer: 0,\n}\n\ntype DocumentsPermissionContext = NonNullable<AuthContext> & {\n features?: string[]\n roleIds?: string[]\n resolvedRoleIds?: string[]\n organizationId?: string | null\n}\n\nfunction resolveUserId(ctx: AuthContext): string | null {\n if (!ctx) return null\n if (typeof ctx.userId === 'string' && ctx.userId.trim().length > 0) return ctx.userId\n if (typeof ctx.sub === 'string' && ctx.sub.trim().length > 0 && !ctx.sub.startsWith('api_key:')) return ctx.sub\n return null\n}\n\nfunction resolveOrganizationId(ctx: DocumentsPermissionContext): string | null {\n if (typeof ctx.organizationId === 'string' && ctx.organizationId.trim().length > 0) {\n return ctx.organizationId\n }\n if (typeof ctx.orgId === 'string' && ctx.orgId.trim().length > 0) return ctx.orgId\n return null\n}\n\nfunction normalizeStrings(values: unknown): string[] {\n if (!Array.isArray(values)) return []\n return Array.from(\n new Set(\n values\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ),\n )\n}\n\nfunction hasDocumentsManage(ctx: DocumentsPermissionContext): boolean {\n if (ctx.isSuperAdmin === true) return true\n const features = normalizeStrings(ctx.features)\n return hasAllFeatures(['documents.manage'], features)\n}\n\nexport async function resolveActiveUserRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n): Promise<string[]> {\n const service = resolveAuthPrincipalService(container)\n if (!service) return []\n return normalizeStrings(await service.resolveActiveUserRoleIds(userId, scope))\n}\n\nexport async function resolveActiveSubjectRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n subject: string,\n): Promise<string[]> {\n if (!subject.startsWith('api_key:')) {\n return resolveActiveUserRoleIds(container, scope, subject)\n }\n\n const apiKeyId = subject.slice('api_key:'.length).trim()\n if (!apiKeyId) return []\n const apiKeyService = resolveApiKeyPrincipalService(container)\n const authService = resolveAuthPrincipalService(container)\n if (!apiKeyService || !authService) return []\n const assigned = normalizeStrings(await apiKeyService.resolveAssignedRoleIds(apiKeyId, scope))\n if (assigned.length === 0) return []\n return normalizeStrings(await authService.filterActiveRoleIds(assigned, scope))\n}\n\nfunction maxShareTier(shares: DocumentShare[]): DocumentTier | null {\n let best: DocumentTier | null = null\n for (const share of shares) {\n const permission = share.permission\n if (permission !== 'viewer' && permission !== 'commenter' && permission !== 'editor') continue\n if (!best || TIER_RANK[permission] > TIER_RANK[best]) best = permission\n }\n return best\n}\n\nexport async function resolvePermission(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n): Promise<DocumentTier | null> {\n if (!ctx || !ctx.tenantId) return null\n const permissionCtx = ctx as DocumentsPermissionContext\n const organizationId = resolveOrganizationId(permissionCtx)\n if (!organizationId) return null\n\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n if (!document) return null\n\n const userId = resolveUserId(ctx)\n if ((userId && document.ownerUserId === userId) || hasDocumentsManage(permissionCtx)) {\n return 'owner'\n }\n if (!userId) return null\n\n // Never trust role ids/names carried by the request token. Role shares are\n // resolved from active UserRole links to active roles in the current tenant.\n // Only consume the server-projected role set. Raw token roleIds/roles are\n // authentication hints and can outlive an assignment revocation.\n const roleIds = normalizeStrings(permissionCtx.resolvedRoleIds)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n\n return maxShareTier(shares)\n}\n\nexport async function loadScopedDocument(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<Document | null> {\n return findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n}\n\n/**\n * Resolve one user's tier against an already scoped document. Callers that\n * evaluate many users for the same document load it once and reuse it here\n * instead of re-reading the identical row per user.\n */\nexport async function resolveLoadedDocumentUserAccess(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n if (!await hasUserDocumentViewPermission(scope, userId, container)) return null\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nexport async function hasUserDocumentViewPermission(\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<boolean> {\n const rbacService = resolveDocumentsRbacService(container)\n if (!rbacService) return false\n if (!await rbacService.userHasAllFeatures(userId, ['documents.view'], scope)) {\n const organizationScopeService = resolveOrganizationScopeService(container)\n if (!organizationScopeService) return false\n const authorization = await organizationScopeService.resolveFresh({\n auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },\n selectedId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n if (!authorizeFeatures(['documents.view'], {\n grantedFeatures: authorization.acl.features,\n unrestricted: authorization.acl.isSuperAdmin,\n scopeAllowed: authorization.scope.tenantId === scope.tenantId\n && !authorization.scope.selectionRejected\n && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope),\n })) return false\n }\n return true\n}\n\nasync function resolveLoadedDocumentUserTier(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n if (document.ownerUserId === userId) return 'owner'\n if (await resolveDocumentsRbacService(container)?.userHasAllFeatures(\n userId,\n ['documents.manage'],\n scope,\n )) return 'owner'\n\n const roleIds = await resolveActiveUserRoleIds(container, scope, userId)\n\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId: document.id,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n\n return maxShareTier(shares)\n}\n\nexport async function resolveUserDocumentTier(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nexport async function resolveUserAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserAccess(em, document, scope, userId, container)\n}\n\nexport async function resolveSubjectAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n identity: { subject: string; userId: string },\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!document) return null\n if (document.ownerUserId === identity.userId) return 'owner'\n\n const roleIds = await resolveActiveSubjectRoleIds(container, scope, identity.subject)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: identity.userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n return maxShareTier(shares)\n}\n\nexport function hasTier(tier: DocumentTier | null, required: DocumentTier): boolean {\n if (!tier) return false\n return TIER_RANK[tier] >= TIER_RANK[required]\n}\n\nexport async function assertTier(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n required: DocumentTier,\n): Promise<DocumentTier> {\n const tier = await resolvePermission(em, documentId, ctx)\n if (!tier || !hasTier(tier, required)) {\n throw forbidden('Forbidden')\n }\n return tier\n}\n"],
5
+ "mappings": "AAGA,SAAS,sBAAsB;AAC/B,SAAS,yBAAyB;AAClC,SAAS,iBAAiB;AAC1B,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,UAAU,qBAAqB;AACxC,SAAS,8CAA8C;AACvD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAIA,MAAM,YAA0C;AAAA,EACrD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,QAAQ;AACV;AASA,SAAS,cAAc,KAAiC;AACtD,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI,OAAO,IAAI,WAAW,YAAY,IAAI,OAAO,KAAK,EAAE,SAAS,EAAG,QAAO,IAAI;AAC/E,MAAI,OAAO,IAAI,QAAQ,YAAY,IAAI,IAAI,KAAK,EAAE,SAAS,KAAK,CAAC,IAAI,IAAI,WAAW,UAAU,EAAG,QAAO,IAAI;AAC5G,SAAO;AACT;AAEA,SAAS,sBAAsB,KAAgD;AAC7E,MAAI,OAAO,IAAI,mBAAmB,YAAY,IAAI,eAAe,KAAK,EAAE,SAAS,GAAG;AAClF,WAAO,IAAI;AAAA,EACb;AACA,MAAI,OAAO,IAAI,UAAU,YAAY,IAAI,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,IAAI;AAC7E,SAAO;AACT;AAEA,SAAS,iBAAiB,QAA2B;AACnD,MAAI,CAAC,MAAM,QAAQ,MAAM,EAAG,QAAO,CAAC;AACpC,SAAO,MAAM;AAAA,IACX,IAAI;AAAA,MACF,OACG,IAAI,CAAC,UAAW,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI,EAAG,EAC9D,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AAAA,IACvC;AAAA,EACF;AACF;AAEA,SAAS,mBAAmB,KAA0C;AACpE,MAAI,IAAI,iBAAiB,KAAM,QAAO;AACtC,QAAM,WAAW,iBAAiB,IAAI,QAAQ;AAC9C,SAAO,eAAe,CAAC,kBAAkB,GAAG,QAAQ;AACtD;AAEA,eAAsB,yBACpB,WACA,OACA,QACmB;AACnB,QAAM,UAAU,4BAA4B,SAAS;AACrD,MAAI,CAAC,QAAS,QAAO,CAAC;AACtB,SAAO,iBAAiB,MAAM,QAAQ,yBAAyB,QAAQ,KAAK,CAAC;AAC/E;AAEA,eAAsB,4BACpB,WACA,OACA,SACmB;AACnB,MAAI,CAAC,QAAQ,WAAW,UAAU,GAAG;AACnC,WAAO,yBAAyB,WAAW,OAAO,OAAO;AAAA,EAC3D;AAEA,QAAM,WAAW,QAAQ,MAAM,WAAW,MAAM,EAAE,KAAK;AACvD,MAAI,CAAC,SAAU,QAAO,CAAC;AACvB,QAAM,gBAAgB,8BAA8B,SAAS;AAC7D,QAAM,cAAc,4BAA4B,SAAS;AACzD,MAAI,CAAC,iBAAiB,CAAC,YAAa,QAAO,CAAC;AAC5C,QAAM,WAAW,iBAAiB,MAAM,cAAc,uBAAuB,UAAU,KAAK,CAAC;AAC7F,MAAI,SAAS,WAAW,EAAG,QAAO,CAAC;AACnC,SAAO,iBAAiB,MAAM,YAAY,oBAAoB,UAAU,KAAK,CAAC;AAChF;AAEA,SAAS,aAAa,QAA8C;AAClE,MAAI,OAA4B;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,aAAa,MAAM;AACzB,QAAI,eAAe,YAAY,eAAe,eAAe,eAAe,SAAU;AACtF,QAAI,CAAC,QAAQ,UAAU,UAAU,IAAI,UAAU,IAAI,EAAG,QAAO;AAAA,EAC/D;AACA,SAAO;AACT;AAEA,eAAsB,kBACpB,IACA,YACA,KAC8B;AAC9B,MAAI,CAAC,OAAO,CAAC,IAAI,SAAU,QAAO;AAClC,QAAM,gBAAgB;AACtB,QAAM,iBAAiB,sBAAsB,aAAa;AAC1D,MAAI,CAAC,eAAgB,QAAO;AAE5B,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI;AAAA,MACJ,UAAU,IAAI;AAAA,MACd;AAAA,MACA,WAAW;AAAA,IACb;AAAA,IACA;AAAA,IACA,EAAE,UAAU,IAAI,UAAU,eAAe;AAAA,EAC3C;AACA,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,SAAS,cAAc,GAAG;AAChC,MAAK,UAAU,SAAS,gBAAgB,UAAW,mBAAmB,aAAa,GAAG;AACpF,WAAO;AAAA,EACT;AACA,MAAI,CAAC,OAAQ,QAAO;AAMpB,QAAM,UAAU,iBAAiB,cAAc,eAAe;AAC9D,QAAM,aAAgD;AAAA,IACpD,EAAE,eAAe,QAAQ,aAAa,OAAO;AAAA,EAC/C;AACA,MAAI,QAAQ,SAAS,GAAG;AACtB,eAAW,KAAK,EAAE,eAAe,QAAQ,aAAa,EAAE,KAAK,QAAQ,EAAE,CAA+B;AAAA,EACxG;AAEA,QAAM,SAAS,MAAM;AAAA,IACnB;AAAA,IACA;AAAA,IACA;AAAA,MACE;AAAA,MACA,UAAU,IAAI;AAAA,MACd;AAAA,MACA,WAAW;AAAA,MACX,KAAK;AAAA,IACP;AAAA,IACA;AAAA,IACA,EAAE,UAAU,IAAI,UAAU,eAAe;AAAA,EAC3C;AAEA,SAAO,aAAa,MAAM;AAC5B;AAEA,eAAsB,mBACpB,IACA,YACA,OAC0B;AAC1B,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI;AAAA,MACJ,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAOA,eAAsB,gCACpB,IACA,UACA,OACA,QACA,WAC8B;AAC9B,MAAI,CAAC,MAAM,8BAA8B,OAAO,QAAQ,SAAS,EAAG,QAAO;AAC3E,SAAO,8BAA8B,IAAI,UAAU,OAAO,QAAQ,SAAS;AAC7E;AAEA,eAAsB,8BACpB,OACA,QACA,WACkB;AAClB,QAAM,cAAc,4BAA4B,SAAS;AACzD,MAAI,CAAC,YAAa,QAAO;AACzB,MAAI,CAAC,MAAM,YAAY,mBAAmB,QAAQ,CAAC,gBAAgB,GAAG,KAAK,GAAG;AAC5E,UAAM,2BAA2B,gCAAgC,SAAS;AAC1E,QAAI,CAAC,yBAA0B,QAAO;AACtC,UAAM,gBAAgB,MAAM,yBAAyB,aAAa;AAAA,MAChE,MAAM,EAAE,KAAK,QAAQ,QAAQ,UAAU,MAAM,UAAU,OAAO,MAAM,gBAAgB,cAAc,MAAM;AAAA,MACxG,YAAY,MAAM;AAAA,MAClB,UAAU,MAAM;AAAA,IAClB,CAAC;AACD,QAAI,CAAC,kBAAkB,CAAC,gBAAgB,GAAG;AAAA,MACzC,iBAAiB,cAAc,IAAI;AAAA,MACnC,cAAc,cAAc,IAAI;AAAA,MAChC,cAAc,cAAc,MAAM,aAAa,MAAM,YAChD,CAAC,cAAc,MAAM,qBACrB,uCAAuC,cAAc,KAAK,MAAM,gBAAgB,cAAc,KAAK;AAAA,IAC1G,CAAC,EAAG,QAAO;AAAA,EACb;AACA,SAAO;AACT;AAEA,eAAe,8BACb,IACA,UACA,OACA,QACA,WAC8B;AAC9B,MAAI,SAAS,gBAAgB,OAAQ,QAAO;AAC5C,MAAI,MAAM,4BAA4B,SAAS,GAAG;AAAA,IAChD;AAAA,IACA,CAAC,kBAAkB;AAAA,IACnB;AAAA,EACF,EAAG,QAAO;AAEV,QAAM,UAAU,MAAM,yBAAyB,WAAW,OAAO,MAAM;AAEvE,QAAM,aAAgD;AAAA,IACpD,EAAE,eAAe,QAAQ,aAAa,OAAO;AAAA,EAC/C;AACA,MAAI,QAAQ,SAAS,GAAG;AACtB,eAAW,KAAK,EAAE,eAAe,QAAQ,aAAa,EAAE,KAAK,QAAQ,EAAE,CAA+B;AAAA,EACxG;AAEA,QAAM,SAAS,MAAM;AAAA,IACnB;AAAA,IACA;AAAA,IACA;AAAA,MACE,YAAY,SAAS;AAAA,MACrB,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW;AAAA,MACX,KAAK;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,SAAO,aAAa,MAAM;AAC5B;AAEA,eAAsB,wBACpB,IACA,YACA,OACA,QACA,WAC8B;AAC9B,QAAM,WAAW,MAAM,mBAAmB,IAAI,YAAY,KAAK;AAC/D,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,8BAA8B,IAAI,UAAU,OAAO,QAAQ,SAAS;AAC7E;AAEA,eAAsB,kBACpB,IACA,YACA,OACA,QACA,WAC8B;AAC9B,QAAM,WAAW,MAAM,mBAAmB,IAAI,YAAY,KAAK;AAC/D,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,gCAAgC,IAAI,UAAU,OAAO,QAAQ,SAAS;AAC/E;AAEA,eAAsB,qBACpB,IACA,YACA,OACA,UACA,WAC8B;AAC9B,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI;AAAA,MACJ,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI,SAAS,gBAAgB,SAAS,OAAQ,QAAO;AAErD,QAAM,UAAU,MAAM,4BAA4B,WAAW,OAAO,SAAS,OAAO;AACpF,QAAM,aAAgD;AAAA,IACpD,EAAE,eAAe,QAAQ,aAAa,SAAS,OAAO;AAAA,EACxD;AACA,MAAI,QAAQ,SAAS,GAAG;AACtB,eAAW,KAAK,EAAE,eAAe,QAAQ,aAAa,EAAE,KAAK,QAAQ,EAAE,CAA+B;AAAA,EACxG;AAEA,QAAM,SAAS,MAAM;AAAA,IACnB;AAAA,IACA;AAAA,IACA;AAAA,MACE;AAAA,MACA,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW;AAAA,MACX,KAAK;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,SAAO,aAAa,MAAM;AAC5B;AAEO,SAAS,QAAQ,MAA2B,UAAiC;AAClF,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,UAAU,IAAI,KAAK,UAAU,QAAQ;AAC9C;AAEA,eAAsB,WACpB,IACA,YACA,KACA,UACuB;AACvB,QAAM,OAAO,MAAM,kBAAkB,IAAI,YAAY,GAAG;AACxD,MAAI,CAAC,QAAQ,CAAC,QAAQ,MAAM,QAAQ,GAAG;AACrC,UAAM,UAAU,WAAW;AAAA,EAC7B;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/documents",
3
- "version": "0.10.1-develop.8024.1.02dbc16b49",
3
+ "version": "0.10.1-develop.8025.1.620dfa185f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  "@hocuspocus/server": "^4.4.0",
76
76
  "@hocuspocus/transformer": "^4.3.0",
77
77
  "@mikro-orm/core": "^7.1.14",
78
- "@open-mercato/events": "0.10.1-develop.8024.1.02dbc16b49",
79
- "@open-mercato/ui": "0.10.1-develop.8024.1.02dbc16b49",
78
+ "@open-mercato/events": "0.10.1-develop.8025.1.620dfa185f",
79
+ "@open-mercato/ui": "0.10.1-develop.8025.1.620dfa185f",
80
80
  "@tiptap/core": "3.32.0",
81
81
  "@tiptap/extension-bubble-menu": "3.32.0",
82
82
  "@tiptap/extension-collaboration": "3.32.0",
@@ -112,14 +112,14 @@
112
112
  },
113
113
  "peerDependencies": {
114
114
  "@mikro-orm/postgresql": "^7.0.14",
115
- "@open-mercato/shared": "0.10.1-develop.8024.1.02dbc16b49",
115
+ "@open-mercato/shared": "0.10.1-develop.8025.1.620dfa185f",
116
116
  "react": "^19.0.0",
117
117
  "react-dom": "^19.0.0",
118
118
  "react-is": ">=18.0.0"
119
119
  },
120
120
  "devDependencies": {
121
- "@open-mercato/core": "0.10.1-develop.8024.1.02dbc16b49",
122
- "@open-mercato/shared": "0.10.1-develop.8024.1.02dbc16b49",
121
+ "@open-mercato/core": "0.10.1-develop.8025.1.620dfa185f",
122
+ "@open-mercato/shared": "0.10.1-develop.8025.1.620dfa185f",
123
123
  "@types/jest": "^30.0.0",
124
124
  "@types/pg": "^8.23.1",
125
125
  "@types/react": "^19.2.18",
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
- import { assertTier, resolveUserAccess } from '../../../../lib/permissions'
4
+ import { assertTier, hasUserDocumentViewPermission, resolveUserDocumentTier } from '../../../../lib/permissions'
5
5
  import {
6
6
  handleDocumentsRouteError,
7
7
  readBody,
@@ -20,6 +20,7 @@ const accessCheckSchema = z.object({
20
20
 
21
21
  const accessCheckResponseSchema = z.object({
22
22
  withoutAccess: z.array(z.string().uuid()),
23
+ shareableUserIds: z.array(z.string().uuid()).optional(),
23
24
  withoutAccessUsers: z.array(
24
25
  z.object({
25
26
  userId: z.string().uuid(),
@@ -48,16 +49,22 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
48
49
  const input = accessCheckSchema.parse(await readBody(request))
49
50
  const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())))
50
51
  const withoutAccess: string[] = []
52
+ const shareableUserIds: string[] = []
53
+ const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
51
54
 
52
55
  for (const userId of uniqueUserIds) {
53
- const tier = await resolveUserAccess(
56
+ const canView = await hasUserDocumentViewPermission(scope, userId, ctx.container)
57
+ const tier = canView ? await resolveUserDocumentTier(
54
58
  ctx.em,
55
59
  documentId,
56
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
60
+ scope,
57
61
  userId,
58
62
  ctx.container,
59
- )
60
- if (!tier) withoutAccess.push(userId)
63
+ ) : null
64
+ if (!tier) {
65
+ withoutAccess.push(userId)
66
+ if (canView) shareableUserIds.push(userId)
67
+ }
61
68
  }
62
69
  // Keep the historical response shape for clients, but never turn this
63
70
  // authorization probe into a user-directory/PII lookup. The caller
@@ -68,7 +75,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
68
75
  secondary: null,
69
76
  }))
70
77
 
71
- return NextResponse.json({ withoutAccess, withoutAccessUsers })
78
+ return NextResponse.json({ withoutAccess, withoutAccessUsers, shareableUserIds })
72
79
  } catch (error) {
73
80
  return handleDocumentsRouteError(error, 'documents.comments.accessCheck')
74
81
  }
@@ -41,7 +41,7 @@ type GrantAccessDecision = 'share' | 'skip' | 'cancel'
41
41
 
42
42
  type GrantAccessResolution =
43
43
  | { cancelled: true }
44
- | { cancelled: false; grantAccessTo?: string[] }
44
+ | { cancelled: false; grantAccessTo?: string[]; hasUnnotifiedMentions?: boolean }
45
45
 
46
46
  export function useDocumentComments({ documentId, editor, canComment, canShare }: UseDocumentCommentsInput) {
47
47
  const t = useT()
@@ -185,22 +185,29 @@ export function useDocumentComments({ documentId, editor, canComment, canShare }
185
185
  )
186
186
  if (!isCurrentSubmission()) return { cancelled: true }
187
187
  if (!call.ok) throw new Error(t('documents.comments.error.save'))
188
- const withoutAccess = readWithoutAccess(call.result)
188
+ const withoutAccess = readWithoutAccess(call.result).filter((user) => userIds.includes(user.userId))
189
189
  if (withoutAccess.length === 0) return { cancelled: false }
190
- if (!canShare) {
191
- flash(t('documents.comments.grant.noAccessInfo'), 'info')
192
- return { cancelled: false, grantAccessTo: [] }
190
+ const response = readRecord(call.result)
191
+ const shareableUserIds = new Set(
192
+ Array.isArray(response?.shareableUserIds)
193
+ ? response.shareableUserIds.filter((value): value is string => typeof value === 'string').map((value) => value.toLowerCase())
194
+ : [],
195
+ )
196
+ const shareableUsers = withoutAccess.filter((user) => shareableUserIds.has(user.userId))
197
+ if (!canShare || shareableUsers.length === 0) {
198
+ return { cancelled: false, grantAccessTo: [], hasUnnotifiedMentions: true }
193
199
  }
194
200
  const pendingMentionNames = new Map(
195
201
  activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name]),
196
202
  )
197
- const decision = await requestGrantAccess(withoutAccess.map((user) => (
203
+ const decision = await requestGrantAccess(shareableUsers.map((user) => (
198
204
  pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)
199
205
  )))
200
206
  if (decision === 'cancel' || !isCurrentSubmission()) return { cancelled: true }
201
207
  return {
202
208
  cancelled: false,
203
- grantAccessTo: decision === 'share' ? withoutAccess.map((user) => user.userId) : [],
209
+ grantAccessTo: decision === 'share' ? shareableUsers.map((user) => user.userId) : [],
210
+ hasUnnotifiedMentions: decision === 'skip' || shareableUsers.length < withoutAccess.length,
204
211
  }
205
212
  }, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t])
206
213
 
@@ -237,6 +244,9 @@ export function useDocumentComments({ documentId, editor, canComment, canShare }
237
244
  mutationPayload: { body: trimmedBody, parentCommentId, mentions },
238
245
  })
239
246
  if (!isCurrentSubmission()) return
247
+ if (grantResolution.hasUnnotifiedMentions) {
248
+ flash(t('documents.comments.grant.noAccessInfo'), 'info')
249
+ }
240
250
  resetComposer()
241
251
  await reload()
242
252
  } catch (error) {
@@ -192,11 +192,20 @@ export async function resolveLoadedDocumentUserAccess(
192
192
  userId: string,
193
193
  container?: DocumentsServiceContainer | null,
194
194
  ): Promise<DocumentTier | null> {
195
+ if (!await hasUserDocumentViewPermission(scope, userId, container)) return null
196
+ return resolveLoadedDocumentUserTier(em, document, scope, userId, container)
197
+ }
198
+
199
+ export async function hasUserDocumentViewPermission(
200
+ scope: { tenantId: string; organizationId: string },
201
+ userId: string,
202
+ container?: DocumentsServiceContainer | null,
203
+ ): Promise<boolean> {
195
204
  const rbacService = resolveDocumentsRbacService(container)
196
- if (!rbacService) return null
205
+ if (!rbacService) return false
197
206
  if (!await rbacService.userHasAllFeatures(userId, ['documents.view'], scope)) {
198
207
  const organizationScopeService = resolveOrganizationScopeService(container)
199
- if (!organizationScopeService) return null
208
+ if (!organizationScopeService) return false
200
209
  const authorization = await organizationScopeService.resolveFresh({
201
210
  auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },
202
211
  selectedId: scope.organizationId,
@@ -208,9 +217,9 @@ export async function resolveLoadedDocumentUserAccess(
208
217
  scopeAllowed: authorization.scope.tenantId === scope.tenantId
209
218
  && !authorization.scope.selectionRejected
210
219
  && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope),
211
- })) return null
220
+ })) return false
212
221
  }
213
- return resolveLoadedDocumentUserTier(em, document, scope, userId, container)
222
+ return true
214
223
  }
215
224
 
216
225
  async function resolveLoadedDocumentUserTier(