@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/auth.js +148 -82
- package/dist/helpers/integration/auth.js.map +2 -2
- package/dist/modules/query_index/api/status.js +21 -1
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/sales/api/document-addresses/route.js +96 -6
- package/dist/modules/sales/api/document-addresses/route.js.map +2 -2
- package/dist/modules/sales/commands/documentAddresses.js +7 -0
- package/dist/modules/sales/commands/documentAddresses.js.map +2 -2
- package/dist/modules/staff/commands/activities.js +23 -12
- package/dist/modules/staff/commands/activities.js.map +2 -2
- package/dist/modules/staff/commands/addresses.js +20 -12
- package/dist/modules/staff/commands/addresses.js.map +2 -2
- package/dist/modules/staff/commands/comments.js +18 -11
- package/dist/modules/staff/commands/comments.js.map +2 -2
- package/dist/modules/staff/commands/job-histories.js +23 -12
- package/dist/modules/staff/commands/job-histories.js.map +2 -2
- package/dist/modules/staff/commands/leave-requests.js +37 -19
- package/dist/modules/staff/commands/leave-requests.js.map +2 -2
- package/dist/modules/staff/commands/shared.js +28 -1
- package/dist/modules/staff/commands/shared.js.map +2 -2
- package/dist/modules/staff/commands/tag-assignments.js +20 -3
- package/dist/modules/staff/commands/tag-assignments.js.map +2 -2
- package/dist/modules/staff/commands/team-members.js +21 -11
- package/dist/modules/staff/commands/team-members.js.map +2 -2
- package/dist/modules/staff/commands/team-roles.js +21 -11
- package/dist/modules/staff/commands/team-roles.js.map +2 -2
- package/dist/modules/staff/commands/teams.js +21 -11
- package/dist/modules/staff/commands/teams.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-entries.js +24 -14
- package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-projects.js +34 -18
- package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
- package/dist/modules/workflows/api/instances/route.js +1 -1
- package/dist/modules/workflows/api/instances/route.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/auth.ts +178 -86
- package/src/modules/query_index/api/status.ts +32 -4
- package/src/modules/sales/api/document-addresses/route.ts +140 -7
- package/src/modules/sales/commands/documentAddresses.ts +8 -0
- package/src/modules/staff/commands/activities.ts +23 -11
- package/src/modules/staff/commands/addresses.ts +20 -11
- package/src/modules/staff/commands/comments.ts +18 -10
- package/src/modules/staff/commands/job-histories.ts +27 -11
- package/src/modules/staff/commands/leave-requests.ts +38 -19
- package/src/modules/staff/commands/shared.ts +38 -0
- package/src/modules/staff/commands/tag-assignments.ts +19 -2
- package/src/modules/staff/commands/team-members.ts +21 -10
- package/src/modules/staff/commands/team-roles.ts +21 -10
- package/src/modules/staff/commands/teams.ts +21 -10
- package/src/modules/staff/commands/timesheets-entries.ts +24 -13
- package/src/modules/staff/commands/timesheets-projects.ts +34 -17
- 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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 (
|
|
191
|
-
await
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
306
|
+
if (await waitForBackend(7_000)) return;
|
|
220
307
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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)
|
|
307
|
-
// coverage
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|