@open-mercato/core 0.6.6-develop.6458.1.113a54fb91 → 0.6.6-develop.6465.1.019f0fb26f

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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/auth.js +148 -82
  3. package/dist/helpers/integration/auth.js.map +2 -2
  4. package/dist/modules/query_index/api/status.js +21 -1
  5. package/dist/modules/query_index/api/status.js.map +2 -2
  6. package/dist/modules/sales/api/document-addresses/route.js +96 -6
  7. package/dist/modules/sales/api/document-addresses/route.js.map +2 -2
  8. package/dist/modules/sales/commands/documentAddresses.js +7 -0
  9. package/dist/modules/sales/commands/documentAddresses.js.map +2 -2
  10. package/dist/modules/staff/commands/activities.js +23 -12
  11. package/dist/modules/staff/commands/activities.js.map +2 -2
  12. package/dist/modules/staff/commands/addresses.js +20 -12
  13. package/dist/modules/staff/commands/addresses.js.map +2 -2
  14. package/dist/modules/staff/commands/comments.js +18 -11
  15. package/dist/modules/staff/commands/comments.js.map +2 -2
  16. package/dist/modules/staff/commands/job-histories.js +23 -12
  17. package/dist/modules/staff/commands/job-histories.js.map +2 -2
  18. package/dist/modules/staff/commands/leave-requests.js +37 -19
  19. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  20. package/dist/modules/staff/commands/shared.js +28 -1
  21. package/dist/modules/staff/commands/shared.js.map +2 -2
  22. package/dist/modules/staff/commands/tag-assignments.js +20 -3
  23. package/dist/modules/staff/commands/tag-assignments.js.map +2 -2
  24. package/dist/modules/staff/commands/team-members.js +21 -11
  25. package/dist/modules/staff/commands/team-members.js.map +2 -2
  26. package/dist/modules/staff/commands/team-roles.js +21 -11
  27. package/dist/modules/staff/commands/team-roles.js.map +2 -2
  28. package/dist/modules/staff/commands/teams.js +21 -11
  29. package/dist/modules/staff/commands/teams.js.map +2 -2
  30. package/dist/modules/staff/commands/timesheets-entries.js +24 -14
  31. package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
  32. package/dist/modules/staff/commands/timesheets-projects.js +34 -18
  33. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  34. package/dist/modules/workflows/api/instances/route.js +1 -1
  35. package/dist/modules/workflows/api/instances/route.js.map +2 -2
  36. package/package.json +7 -7
  37. package/src/helpers/integration/auth.ts +178 -86
  38. package/src/modules/query_index/api/status.ts +32 -4
  39. package/src/modules/sales/api/document-addresses/route.ts +140 -7
  40. package/src/modules/sales/commands/documentAddresses.ts +8 -0
  41. package/src/modules/staff/commands/activities.ts +23 -11
  42. package/src/modules/staff/commands/addresses.ts +20 -11
  43. package/src/modules/staff/commands/comments.ts +18 -10
  44. package/src/modules/staff/commands/job-histories.ts +27 -11
  45. package/src/modules/staff/commands/leave-requests.ts +38 -19
  46. package/src/modules/staff/commands/shared.ts +38 -0
  47. package/src/modules/staff/commands/tag-assignments.ts +19 -2
  48. package/src/modules/staff/commands/team-members.ts +21 -10
  49. package/src/modules/staff/commands/team-roles.ts +21 -10
  50. package/src/modules/staff/commands/teams.ts +21 -10
  51. package/src/modules/staff/commands/timesheets-entries.ts +24 -13
  52. package/src/modules/staff/commands/timesheets-projects.ts +34 -17
  53. package/src/modules/workflows/api/instances/route.ts +2 -2
@@ -1,4 +1,4 @@
1
- import { type Page } from '@playwright/test';
1
+ import { type Page, type Response } from '@playwright/test';
2
2
  import { readFileSync } from 'fs';
3
3
  import { resolve } from 'path';
4
4
 
@@ -136,102 +136,194 @@ async function recoverGenericErrorPageIfPresent(page: Page): Promise<void> {
136
136
  await dismissGlobalNoticesIfPresent(page);
137
137
  }
138
138
 
139
- export async function login(page: Page, role: Role = 'admin'): Promise<void> {
140
- const creds = DEFAULT_CREDENTIALS[role];
141
- const loginReadySelector = 'form[data-auth-ready="1"]';
142
- const hasBackendUrl = (): boolean => /\/backend(?:\/.*)?$/.test(page.url());
143
- const waitForBackend = async (timeout: number): Promise<boolean> => {
144
- try {
145
- await page.waitForURL(/\/backend(?:\/.*)?$/, { timeout });
146
- return true;
147
- } catch {
148
- return hasBackendUrl();
149
- }
150
- };
139
+ type AuthNavigationTraceEntry = {
140
+ path: string;
141
+ status: number;
142
+ location: string | null;
143
+ };
151
144
 
152
- await acknowledgeGlobalNotices(page);
153
- const apiLoginForm = new URLSearchParams();
154
- apiLoginForm.set('email', creds.email);
155
- apiLoginForm.set('password', creds.password);
156
- // Retry-on-429 against the auth rate limit (5/60s per email). Capped
157
- // exponential backoff: 1s, 2s, 4s — worst-case ~7s.
158
- let apiLoginResponse: Awaited<ReturnType<typeof page.request.post>> | null = null;
159
- for (let retry = 0; retry < 4; retry += 1) {
160
- apiLoginResponse = await page.request.post('/api/auth/login', {
161
- headers: {
162
- 'content-type': 'application/x-www-form-urlencoded',
163
- },
164
- data: apiLoginForm.toString(),
165
- }).catch(() => null);
166
- if (!apiLoginResponse || apiLoginResponse.status() !== 429) break;
167
- await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** retry));
145
+ function toPathOnly(rawUrl: string): string {
146
+ try {
147
+ const url = new URL(rawUrl);
148
+ return `${url.pathname}${url.search}`;
149
+ } catch {
150
+ return '[unparseable-url]';
151
+ }
152
+ }
153
+
154
+ function toRedactedLocation(rawLocation: string | undefined): string | null {
155
+ if (!rawLocation) return null;
156
+ try {
157
+ const url = new URL(rawLocation, 'http://integration.local');
158
+ return `${url.pathname}${url.search}`;
159
+ } catch {
160
+ return '[unparseable-location]';
168
161
  }
169
- if (apiLoginResponse?.ok()) {
170
- const apiLoginBody = (await apiLoginResponse.json().catch(() => null)) as { token?: string } | null;
171
- const claims = typeof apiLoginBody?.token === 'string' ? decodeJwtClaims(apiLoginBody.token) : null;
172
- const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
173
- const cookies = [];
174
- if (claims?.tenantId) {
175
- cookies.push({
176
- name: 'om_selected_tenant',
177
- value: claims.tenantId,
178
- url: baseUrl,
179
- sameSite: 'Lax' as const,
162
+ }
163
+
164
+ function createAuthNavigationTrace(page: Page): {
165
+ entries: AuthNavigationTraceEntry[];
166
+ stop: () => void;
167
+ } {
168
+ const entries: AuthNavigationTraceEntry[] = [];
169
+ const onResponse = (response: Response) => {
170
+ const path = toPathOnly(response.url());
171
+ if (path === '/backend' || path.startsWith('/api/auth/session/refresh')) {
172
+ entries.push({
173
+ path,
174
+ status: response.status(),
175
+ location: toRedactedLocation(response.headers().location),
180
176
  });
181
177
  }
182
- if (claims?.orgId) {
183
- cookies.push({
184
- name: 'om_selected_org',
185
- value: claims.orgId,
186
- url: baseUrl,
187
- sameSite: 'Lax' as const,
188
- });
178
+ };
179
+ page.on('response', onResponse);
180
+ return {
181
+ entries,
182
+ stop: () => page.off('response', onResponse),
183
+ };
184
+ }
185
+
186
+ async function formatLoginDiagnostics(
187
+ page: Page,
188
+ role: Role,
189
+ entries: AuthNavigationTraceEntry[],
190
+ ): Promise<string> {
191
+ const cookieNames = Array.from(
192
+ new Set((await page.context().cookies()).map((cookie) => cookie.name)),
193
+ ).sort((left, right) => left.localeCompare(right));
194
+ const traceLines = entries.length
195
+ ? entries.map((entry) => {
196
+ const location = entry.location ? ` -> ${entry.location}` : '';
197
+ return `${entry.status} ${entry.path}${location}`;
198
+ })
199
+ : ['none'];
200
+
201
+ return [
202
+ `Login diagnostics for role "${role}":`,
203
+ `current URL: ${toPathOnly(page.url())}`,
204
+ `browser cookie names: ${cookieNames.length ? cookieNames.join(', ') : 'none'}`,
205
+ 'backend/refresh trace:',
206
+ ...traceLines,
207
+ ].join('\n');
208
+ }
209
+
210
+ async function throwLoginErrorWithDiagnostics(
211
+ page: Page,
212
+ role: Role,
213
+ entries: AuthNavigationTraceEntry[],
214
+ error: unknown,
215
+ ): Promise<never> {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ throw new Error([
218
+ `Login did not reach backend for role: ${role}.`,
219
+ await formatLoginDiagnostics(page, role, entries),
220
+ `Original error: ${message}`,
221
+ ].join('\n'));
222
+ }
223
+
224
+ export async function login(page: Page, role: Role = 'admin'): Promise<void> {
225
+ const authTrace = createAuthNavigationTrace(page);
226
+ try {
227
+ const creds = DEFAULT_CREDENTIALS[role];
228
+ const loginReadySelector = 'form[data-auth-ready="1"]';
229
+ const hasBackendUrl = (): boolean => /\/backend(?:\/.*)?$/.test(page.url());
230
+ const waitForBackend = async (timeout: number): Promise<boolean> => {
231
+ try {
232
+ await page.waitForURL(/\/backend(?:\/.*)?$/, { timeout });
233
+ return true;
234
+ } catch {
235
+ return hasBackendUrl();
236
+ }
237
+ };
238
+
239
+ await acknowledgeGlobalNotices(page);
240
+ const apiLoginForm = new URLSearchParams();
241
+ apiLoginForm.set('email', creds.email);
242
+ apiLoginForm.set('password', creds.password);
243
+ // Retry-on-429 against the auth rate limit (5/60s per email). Capped
244
+ // exponential backoff: 1s, 2s, 4s — worst-case ~7s.
245
+ let apiLoginResponse: Awaited<ReturnType<typeof page.request.post>> | null = null;
246
+ for (let retry = 0; retry < 4; retry += 1) {
247
+ apiLoginResponse = await page.request.post('/api/auth/login', {
248
+ headers: {
249
+ 'content-type': 'application/x-www-form-urlencoded',
250
+ },
251
+ data: apiLoginForm.toString(),
252
+ }).catch(() => null);
253
+ if (!apiLoginResponse || apiLoginResponse.status() !== 429) break;
254
+ await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** retry));
189
255
  }
190
- if (cookies.length > 0) {
191
- await page.context().addCookies(cookies);
256
+ if (apiLoginResponse?.ok()) {
257
+ const apiLoginBody = (await apiLoginResponse.json().catch(() => null)) as { token?: string } | null;
258
+ const claims = typeof apiLoginBody?.token === 'string' ? decodeJwtClaims(apiLoginBody.token) : null;
259
+ const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
260
+ const cookies = [];
261
+ if (claims?.tenantId) {
262
+ cookies.push({
263
+ name: 'om_selected_tenant',
264
+ value: claims.tenantId,
265
+ url: baseUrl,
266
+ sameSite: 'Lax' as const,
267
+ });
268
+ }
269
+ if (claims?.orgId) {
270
+ cookies.push({
271
+ name: 'om_selected_org',
272
+ value: claims.orgId,
273
+ url: baseUrl,
274
+ sameSite: 'Lax' as const,
275
+ });
276
+ }
277
+ if (cookies.length > 0) {
278
+ await page.context().addCookies(cookies);
279
+ }
280
+ await page.goto('/backend', { waitUntil: 'domcontentloaded' });
281
+ if (await waitForBackend(8_000)) return;
192
282
  }
193
- await page.goto('/backend', { waitUntil: 'domcontentloaded' });
194
- if (await waitForBackend(8_000)) return;
195
- }
196
283
 
197
- for (let attempt = 0; attempt < 4; attempt += 1) {
198
- await page.goto('/login', { waitUntil: 'domcontentloaded' });
199
- await dismissGlobalNoticesIfPresent(page);
200
- await recoverClientSideErrorPageIfPresent(page);
201
- await recoverGenericErrorPageIfPresent(page);
202
- await page.waitForSelector(loginReadySelector, { state: 'visible', timeout: 3_000 }).catch(() => null);
203
- if (await page.getByLabel('Email').isVisible().catch(() => false)) break;
204
- if (attempt === 3) {
205
- throw new Error(`Login form is unavailable for role: ${role}; current URL: ${page.url()}`);
284
+ for (let attempt = 0; attempt < 4; attempt += 1) {
285
+ await page.goto('/login', { waitUntil: 'domcontentloaded' });
286
+ await dismissGlobalNoticesIfPresent(page);
287
+ await recoverClientSideErrorPageIfPresent(page);
288
+ await recoverGenericErrorPageIfPresent(page);
289
+ await page.waitForSelector(loginReadySelector, { state: 'visible', timeout: 3_000 }).catch(() => null);
290
+ if (await page.getByLabel('Email').isVisible().catch(() => false)) break;
291
+ if (attempt === 3) {
292
+ throw new Error(`Login form is unavailable for role: ${role}; current URL: ${page.url()}`);
293
+ }
206
294
  }
207
- }
208
- await page.getByLabel('Email').fill(creds.email);
295
+ await page.getByLabel('Email').fill(creds.email);
209
296
 
210
- const passwordInput = page.getByLabel('Password', { exact: true }).first();
211
- if (await passwordInput.isVisible().catch(() => false)) {
212
- await passwordInput.fill(creds.password);
213
- await passwordInput.press('Enter');
214
- } else {
215
- const submitButton = page.getByRole('button', { name: /login|sign in|continue with sso/i }).first();
216
- await submitButton.click();
217
- }
297
+ const passwordInput = page.getByLabel('Password', { exact: true }).first();
298
+ if (await passwordInput.isVisible().catch(() => false)) {
299
+ await passwordInput.fill(creds.password);
300
+ await passwordInput.press('Enter');
301
+ } else {
302
+ const submitButton = page.getByRole('button', { name: /login|sign in|continue with sso/i }).first();
303
+ await submitButton.click();
304
+ }
218
305
 
219
- if (await waitForBackend(7_000)) return;
306
+ if (await waitForBackend(7_000)) return;
220
307
 
221
- const loginForm = page.locator('form').first();
222
- if (await loginForm.isVisible().catch(() => false)) {
223
- await loginForm.evaluate((element) => {
224
- const form = element as HTMLFormElement
225
- form.requestSubmit()
226
- }).catch(() => {})
227
- }
228
- if (await waitForBackend(5_000)) return;
308
+ const loginForm = page.locator('form').first();
309
+ if (await loginForm.isVisible().catch(() => false)) {
310
+ await loginForm.evaluate((element) => {
311
+ const form = element as HTMLFormElement
312
+ form.requestSubmit()
313
+ }).catch(() => {})
314
+ }
315
+ if (await waitForBackend(5_000)) return;
229
316
 
230
- const loginButton = page.getByRole('button', { name: /login|sign in|continue with sso/i }).first();
231
- if (await loginButton.isVisible().catch(() => false)) {
232
- await loginButton.click({ force: true });
233
- }
234
- if (await waitForBackend(8_000)) return;
317
+ const loginButton = page.getByRole('button', { name: /login|sign in|continue with sso/i }).first();
318
+ if (await loginButton.isVisible().catch(() => false)) {
319
+ await loginButton.click({ force: true });
320
+ }
321
+ if (await waitForBackend(8_000)) return;
235
322
 
236
- throw new Error(`Login did not reach backend for role: ${role}; current URL: ${page.url()}`);
323
+ throw new Error(`Login did not reach backend for role: ${role}; current URL: ${page.url()}`);
324
+ } catch (error) {
325
+ await throwLoginErrorWithDiagnostics(page, role, authTrace.entries, error);
326
+ } finally {
327
+ authTrace.stop();
328
+ }
237
329
  }
@@ -4,7 +4,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
5
5
  import type { EntityManager } from '@mikro-orm/postgresql'
6
6
  import { sql } from 'kysely'
7
- import { readCoverageSnapshots, refreshCoverageSnapshot } from '../lib/coverage'
7
+ import { readCoverageSnapshots, refreshCoverageSnapshot, type CoverageSnapshot } from '../lib/coverage'
8
8
  import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
9
9
  import type { FullTextSearchStrategy } from '@open-mercato/search/strategies'
10
10
  import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
@@ -17,6 +17,33 @@ export const metadata = {
17
17
  GET: { requireAuth: true, requireFeatures: ['query_index.status.view'] },
18
18
  }
19
19
 
20
+ const STATUS_REFRESH_COOLDOWN_MS = 60_000
21
+
22
+ function getCoverageSnapshotRefreshedAt(snapshot: Pick<CoverageSnapshot, 'refreshed_at'> | null | undefined): number | null {
23
+ const value = snapshot?.refreshed_at
24
+ if (value instanceof Date) {
25
+ const time = value.getTime()
26
+ return Number.isFinite(time) ? time : null
27
+ }
28
+ if (typeof value === 'string') {
29
+ const time = new Date(value).getTime()
30
+ return Number.isFinite(time) ? time : null
31
+ }
32
+ return null
33
+ }
34
+
35
+ function hasFreshCoverageSnapshots(
36
+ snapshots: Map<string, CoverageSnapshot>,
37
+ entityIds: string[],
38
+ now: number,
39
+ ): boolean {
40
+ for (const entityId of entityIds) {
41
+ const refreshedAt = getCoverageSnapshotRefreshedAt(snapshots.get(entityId))
42
+ if (refreshedAt === null || now - refreshedAt >= STATUS_REFRESH_COOLDOWN_MS) return false
43
+ }
44
+ return entityIds.length > 0
45
+ }
46
+
20
47
  export async function GET(req: Request) {
21
48
  const auth = await getAuthFromRequest(req)
22
49
  if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -303,9 +330,10 @@ export async function GET(req: Request) {
303
330
  // event emitted below, never inline per entity.
304
331
  const snapshotByEntity = await readCoverageSnapshots(db, { entityTypes: entityIds, ...coverageScope })
305
332
 
306
- // An explicit refresh action (?refresh) is allowed to block: recompute every entity's
307
- // coverage with bounded concurrency, then re-read the freshly written snapshots.
308
- if (forceRefresh && entityIds.length > 0) {
333
+ // An explicit refresh action (?refresh) may block, but only when the durable
334
+ // coverage snapshots are stale. Recent persisted snapshots survive workers/restarts,
335
+ // so repeated refresh requests use them instead of hammering base-table counts.
336
+ if (forceRefresh && entityIds.length > 0 && !hasFreshCoverageSnapshots(snapshotByEntity, entityIds, Date.now())) {
309
337
  await mapWithConcurrency(entityIds, COVERAGE_REFRESH_CONCURRENCY, (entityId) =>
310
338
  refreshCoverageSnapshot(em, { entityType: entityId, ...coverageScope }).catch(() => undefined),
311
339
  )
@@ -1,10 +1,13 @@
1
1
  import { z } from 'zod'
2
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
2
+ import { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'
3
3
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
8
  import { createSalesCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../openapi'
6
9
  import { withScopedPayload } from '../utils'
7
- import { SalesDocumentAddress } from '../../data/entities'
10
+ import { SalesDocumentAddress, SalesOrder, SalesQuote } from '../../data/entities'
8
11
  import {
9
12
  documentAddressCreateSchema,
10
13
  documentAddressDeleteSchema,
@@ -34,8 +37,131 @@ const routeMetadata = {
34
37
 
35
38
  export const metadata = routeMetadata
36
39
 
40
+ type DocumentKind = 'order' | 'quote'
41
+ type DocumentAddressAccess = 'view' | 'manage'
42
+ type Translate = (key: string, fallback?: string) => string
43
+
44
+ const DOCUMENT_ADDRESS_FEATURES: Record<DocumentKind, Record<DocumentAddressAccess, string>> = {
45
+ order: { view: 'sales.orders.view', manage: 'sales.orders.manage' },
46
+ quote: { view: 'sales.quotes.view', manage: 'sales.quotes.manage' },
47
+ }
48
+
49
+ function toDocumentKind(value: unknown): DocumentKind | null {
50
+ return value === 'order' || value === 'quote' ? value : null
51
+ }
52
+
53
+ async function resolveDocumentKindForRead(query: { documentId?: string; documentKind?: DocumentKind }, ctx: CrudCtx) {
54
+ if (query.documentKind) return query.documentKind
55
+
56
+ const tenantId = ctx.auth?.tenantId ?? null
57
+ const organizationId =
58
+ ctx.selectedOrganizationId ??
59
+ ctx.auth?.orgId ??
60
+ (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length === 1 ? ctx.organizationIds[0] : null)
61
+ if (!query.documentId || !tenantId || !organizationId) return null
62
+
63
+ const em = ctx.container.resolve('em') as EntityManager
64
+ const scope = { tenantId, organizationId }
65
+ const order = await findOneWithDecryption(em, SalesOrder, { id: query.documentId, tenantId, organizationId }, {}, scope)
66
+ if (order) return 'order'
67
+ const quote = await findOneWithDecryption(em, SalesQuote, { id: query.documentId, tenantId, organizationId }, {}, scope)
68
+ if (quote) return 'quote'
69
+ return null
70
+ }
71
+
72
+ async function ensureDocumentAddressReadAccess(query: { documentId?: string; documentKind?: DocumentKind }, ctx: CrudCtx) {
73
+ const documentKind = await resolveDocumentKindForRead(query, ctx)
74
+ const requiredFeatures = documentKind
75
+ ? [DOCUMENT_ADDRESS_FEATURES[documentKind].view]
76
+ : [DOCUMENT_ADDRESS_FEATURES.order.view, DOCUMENT_ADDRESS_FEATURES.quote.view]
77
+ await ensureDocumentAddressAccess(ctx, requiredFeatures)
78
+ return documentKind
79
+ }
80
+
81
+ async function resolveExistingAddressKind(
82
+ ctx: CrudCtx,
83
+ id: string,
84
+ scope: { tenantId: string; organizationId: string },
85
+ ): Promise<DocumentKind | null> {
86
+ const em = ctx.container.resolve('em') as EntityManager
87
+ const address = await findOneWithDecryption(
88
+ em,
89
+ SalesDocumentAddress,
90
+ { id, tenantId: scope.tenantId, organizationId: scope.organizationId },
91
+ {},
92
+ scope,
93
+ )
94
+ return toDocumentKind(address?.documentKind)
95
+ }
96
+
97
+ async function ensureDocumentAddressAccess(
98
+ ctx: CrudCtx,
99
+ requiredFeatures: string[],
100
+ translate?: Translate,
101
+ ): Promise<void> {
102
+ const resolvedTranslate = translate ?? (await resolveTranslations()).translate
103
+ const auth = ctx.auth
104
+ if (!auth?.sub) {
105
+ throw new CrudHttpError(401, { error: resolvedTranslate('api.errors.unauthorized', 'Unauthorized') })
106
+ }
107
+
108
+ let rbac: RbacService | null = null
109
+ try {
110
+ rbac = ctx.container.resolve('rbacService') as RbacService
111
+ } catch {
112
+ rbac = null
113
+ }
114
+
115
+ const ok = rbac
116
+ ? await rbac.userHasAllFeatures(auth.sub, requiredFeatures, {
117
+ tenantId: auth.tenantId ?? null,
118
+ organizationId: ctx.selectedOrganizationId ?? auth.orgId ?? null,
119
+ })
120
+ : false
121
+ if (!ok) {
122
+ throw new CrudHttpError(403, {
123
+ error: resolvedTranslate('api.errors.forbidden', 'Forbidden'),
124
+ requiredFeatures,
125
+ })
126
+ }
127
+ }
128
+
129
+ async function ensureDocumentAddressKindAccess(
130
+ ctx: CrudCtx,
131
+ documentKind: DocumentKind,
132
+ access: DocumentAddressAccess,
133
+ translate?: Translate,
134
+ ): Promise<void> {
135
+ await ensureDocumentAddressAccess(ctx, [DOCUMENT_ADDRESS_FEATURES[documentKind][access]], translate)
136
+ }
137
+
138
+ async function ensureDocumentAddressMutationAccess(
139
+ ctx: CrudCtx,
140
+ input: { id: string; documentKind: DocumentKind; tenantId: string; organizationId: string },
141
+ options: { includeTargetKind: boolean; translate?: Translate },
142
+ ): Promise<void> {
143
+ const existingKind = await resolveExistingAddressKind(ctx, input.id, {
144
+ tenantId: input.tenantId,
145
+ organizationId: input.organizationId,
146
+ })
147
+ const featureKinds = new Set<DocumentKind>()
148
+ if (existingKind) featureKinds.add(existingKind)
149
+ if (!existingKind || options.includeTargetKind) featureKinds.add(input.documentKind)
150
+
151
+ await ensureDocumentAddressAccess(
152
+ ctx,
153
+ [...featureKinds].map((kind) => DOCUMENT_ADDRESS_FEATURES[kind].manage),
154
+ options.translate,
155
+ )
156
+ }
157
+
37
158
  const crud = makeCrudRoute({
38
159
  metadata: routeMetadata,
160
+ hooks: {
161
+ beforeList: async (query, ctx) => {
162
+ await ensureDocumentAddressReadAccess(query, ctx)
163
+ },
164
+ },
39
165
  orm: {
40
166
  entity: SalesDocumentAddress,
41
167
  idField: 'id',
@@ -76,12 +202,13 @@ const crud = makeCrudRoute({
76
202
  createdAt: 'created_at',
77
203
  updatedAt: 'updated_at',
78
204
  },
79
- buildFilters: async (query: any) => {
205
+ buildFilters: async (query: any, ctx) => {
206
+ const documentKind = await resolveDocumentKindForRead(query, ctx)
80
207
  const filters: Record<string, any> = {
81
208
  document_id: { $eq: query.documentId },
82
209
  }
83
210
 
84
- if (query.documentKind) filters.document_kind = { $eq: query.documentKind }
211
+ if (query.documentKind ?? documentKind) filters.document_kind = { $eq: query.documentKind ?? documentKind }
85
212
  return filters
86
213
  },
87
214
  },
@@ -91,7 +218,9 @@ const crud = makeCrudRoute({
91
218
  schema: rawBodySchema,
92
219
  mapInput: async ({ raw, ctx }) => {
93
220
  const { translate } = await resolveTranslations()
94
- return documentAddressCreateSchema.parse(withScopedPayload(raw ?? {}, ctx, translate))
221
+ const input = documentAddressCreateSchema.parse(withScopedPayload(raw ?? {}, ctx, translate))
222
+ await ensureDocumentAddressKindAccess(ctx, input.documentKind, 'manage', translate)
223
+ return input
95
224
  },
96
225
  response: ({ result }) => ({ id: result?.id ?? null }),
97
226
  status: 201,
@@ -101,7 +230,9 @@ const crud = makeCrudRoute({
101
230
  schema: rawBodySchema,
102
231
  mapInput: async ({ raw, ctx }) => {
103
232
  const { translate } = await resolveTranslations()
104
- return documentAddressUpdateSchema.parse(withScopedPayload(raw ?? {}, ctx, translate))
233
+ const input = documentAddressUpdateSchema.parse(withScopedPayload(raw ?? {}, ctx, translate))
234
+ await ensureDocumentAddressMutationAccess(ctx, input, { includeTargetKind: true, translate })
235
+ return input
105
236
  },
106
237
  response: () => ({ ok: true }),
107
238
  },
@@ -128,7 +259,9 @@ const crud = makeCrudRoute({
128
259
  error: translate('sales.documents.detail.error', 'Document not found or inaccessible.'),
129
260
  })
130
261
  }
131
- return documentAddressDeleteSchema.parse(withScopedPayload({ id, documentId, documentKind }, ctx, translate))
262
+ const input = documentAddressDeleteSchema.parse(withScopedPayload({ id, documentId, documentKind }, ctx, translate))
263
+ await ensureDocumentAddressMutationAccess(ctx, input, { includeTargetKind: false, translate })
264
+ return input
132
265
  },
133
266
  response: () => ({ ok: true }),
134
267
  },
@@ -104,6 +104,12 @@ function applyDocumentAddressSnapshot(em: EntityManager, entity: SalesDocumentAd
104
104
  entity.quote = snapshot.documentKind === 'quote' ? em.getReference(SalesQuote, snapshot.documentId) : null
105
105
  }
106
106
 
107
+ function assertDocumentAddressParent(entity: SalesDocumentAddress, input: { documentId: string; documentKind: 'order' | 'quote' }): void {
108
+ if (entity.documentId !== input.documentId || entity.documentKind !== input.documentKind) {
109
+ throw new CrudHttpError(404, { error: 'sales.document.address.not_found' })
110
+ }
111
+ }
112
+
107
113
  async function emitDocumentAddressIndexSideEffects(
108
114
  ctx: { container: { resolve: (name: string) => unknown } },
109
115
  action: 'created' | 'updated' | 'deleted',
@@ -256,6 +262,7 @@ const updateDocumentAddress: CommandHandler<DocumentAddressUpdateInput, { id: st
256
262
  'sales.document.address.not_found'
257
263
  )
258
264
  ensureSameScope(entity, input.organizationId, input.tenantId)
265
+ assertDocumentAddressParent(entity, input)
259
266
  const document = await requireDocument(em, input.documentKind, input.documentId, input.organizationId, input.tenantId)
260
267
  if (input.documentKind === 'order') {
261
268
  await assertAddressEditable(em, {
@@ -344,6 +351,7 @@ const deleteDocumentAddress: CommandHandler<
344
351
  'sales.document.address.not_found'
345
352
  )
346
353
  ensureSameScope(entity, input.organizationId, input.tenantId)
354
+ assertDocumentAddressParent(entity, input)
347
355
  const document = await requireDocument(em, input.documentKind, input.documentId, input.organizationId, input.tenantId)
348
356
  if (input.documentKind === 'order') {
349
357
  await assertAddressEditable(em, {