@open-mercato/core 0.10.1-develop.7833.1.d6cbf0e3ab → 0.10.1-develop.7841.1.af87ac614d
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/modules/cart/api/current/lines/bulk/route.js +1 -1
- package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
- package/dist/modules/cart/api/current/lines/route.js +1 -1
- package/dist/modules/cart/api/current/lines/route.js.map +2 -2
- package/dist/modules/cart/commands/lines.js +5 -10
- package/dist/modules/cart/commands/lines.js.map +2 -2
- package/dist/modules/cart/lib/currentCartRoute.js +8 -0
- package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
- package/dist/modules/cart/lib/lineMutationLock.js +52 -0
- package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
- package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
- package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users.js +24 -7
- package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
- package/dist/modules/customer_accounts/data/validators.js +2 -1
- package/dist/modules/customer_accounts/data/validators.js.map +2 -2
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/cli.js +28 -110
- package/dist/modules/customers/cli.js.map +3 -3
- package/dist/modules/customers/components/DictionarySettings.js +1 -1
- package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
- package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskForm.js +1 -16
- package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
- package/dist/modules/customers/components/detail/TasksSection.js +38 -10
- package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
- package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
- package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
- package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
- package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
- package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
- package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
- package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
- package/dist/modules/customers/translations.js +9 -0
- package/dist/modules/customers/translations.js.map +7 -0
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +3 -3
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
- package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
- package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
- package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
- package/dist/modules/sync_excel/index.js +11 -1
- package/dist/modules/sync_excel/index.js.map +2 -2
- package/dist/modules/translations/di.js +2 -0
- package/dist/modules/translations/di.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
- package/src/modules/cart/api/current/lines/route.ts +1 -1
- package/src/modules/cart/commands/lines.ts +5 -10
- package/src/modules/cart/i18n/de.json +1 -0
- package/src/modules/cart/i18n/en.json +1 -0
- package/src/modules/cart/i18n/es.json +1 -0
- package/src/modules/cart/i18n/ko.json +1 -0
- package/src/modules/cart/i18n/pl.json +1 -0
- package/src/modules/cart/lib/currentCartRoute.ts +8 -0
- package/src/modules/cart/lib/lineMutationLock.ts +59 -0
- package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
- package/src/modules/customer_accounts/api/admin/users.ts +26 -7
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
- package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
- package/src/modules/customer_accounts/data/validators.ts +1 -0
- package/src/modules/customer_accounts/i18n/de.json +10 -0
- package/src/modules/customer_accounts/i18n/en.json +10 -0
- package/src/modules/customer_accounts/i18n/es.json +10 -0
- package/src/modules/customer_accounts/i18n/ko.json +10 -0
- package/src/modules/customer_accounts/i18n/pl.json +10 -0
- package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
- package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
- package/src/modules/customers/cli.ts +23 -124
- package/src/modules/customers/components/DictionarySettings.tsx +1 -1
- package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
- package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
- package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
- package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
- package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
- package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
- package/src/modules/customers/i18n/de.json +81 -0
- package/src/modules/customers/i18n/en.json +81 -0
- package/src/modules/customers/i18n/es.json +81 -0
- package/src/modules/customers/i18n/ko.json +81 -0
- package/src/modules/customers/i18n/pl.json +81 -0
- package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
- package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
- package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
- package/src/modules/customers/translations.ts +5 -0
- package/src/modules/eudr/index.ts +13 -0
- package/src/modules/sales/commands/documents.ts +3 -3
- package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
- package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/ko.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
- package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
- package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
- package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
- package/src/modules/sync_excel/index.ts +10 -0
- package/src/modules/translations/di.ts +2 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/staff/api/timesheets/access-requests/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTeamMember, StaffTimeProject } from '../../../data/entities'\nimport { emitStaffEvent } from '../../../events'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { STAFF_TIME_TRACKING_RESOURCE_KINDS } from '../../guards'\nimport { runTimesheetInterceptors } from '../_shared/withTimesheetInterceptors'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/access-requests' })\n\nconst VIEW_FEATURE = 'staff.timesheets.view'\nconst RESOURCE_KIND = STAFF_TIME_TRACKING_RESOURCE_KINDS.accessRequest\n\n/**\n * Spec D-6: one pending request per user per project (or per user for the\n * general, project-less request) per 24h. The window is held in the shared\n * cache rather than a table \u2014 the notification itself carries a `groupKey`, so\n * even if the cache is cold the manager still sees a single refreshed\n * notification instead of a duplicate row.\n */\nexport const ACCESS_REQUEST_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000\n\nconst GENERAL_REQUEST_SCOPE = 'general'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [VIEW_FEATURE] },\n}\n\nconst accessRequestSchema = z.object({\n timeProjectId: z.string().uuid().nullish(),\n})\n\nconst accessRequestResponseSchema = z.object({\n ok: z.literal(true),\n deduplicated: z.boolean(),\n})\n\ntype DedupeCacheLike = {\n get: (key: string) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number }) => Promise<void>\n}\n\ntype AccessRequestContext = {\n container: Awaited<ReturnType<typeof createRequestContainer>>\n em: EntityManager\n tenantId: string\n organizationId: string\n requesterUserId: string\n requesterEmail: string | null\n translate: (key: string, fallback?: string) => string\n}\n\nasync function resolveAccessRequestContext(req: Request): Promise<AccessRequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n }\n const requesterUserId =\n (typeof auth.sub === 'string' && auth.sub.trim().length > 0 && auth.sub) ||\n (typeof auth.userId === 'string' && auth.userId.trim().length > 0 && auth.userId) ||\n null\n if (!requesterUserId) {\n throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n }\n if (!auth.orgId) {\n throw new CrudHttpError(400, {\n error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.'),\n })\n }\n return {\n container,\n em: (container.resolve('em') as EntityManager).fork(),\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n requesterUserId,\n requesterEmail: typeof auth.email === 'string' && auth.email.trim().length > 0 ? auth.email : null,\n translate,\n }\n}\n\nfunction resolveDedupeCache(context: AccessRequestContext): DedupeCacheLike | null {\n try {\n const cache = context.container.resolve('cache') as Partial<DedupeCacheLike> | undefined\n if (typeof cache?.get !== 'function' || typeof cache?.set !== 'function') return null\n return cache as DedupeCacheLike\n } catch {\n return null\n }\n}\n\nexport function buildAccessRequestDedupeKey(input: {\n tenantId: string\n organizationId: string\n requesterUserId: string\n timeProjectId: string | null\n}): string {\n const scope = input.timeProjectId ?? GENERAL_REQUEST_SCOPE\n return `staff:timesheets:access-request:${input.tenantId}:${input.organizationId}:${input.requesterUserId}:${scope}`\n}\n\n/**\n * Resolves the requested project inside the caller's scope. A project the\n * caller's tenant/organization does not own resolves to `null` and the request\n * degrades to a general one \u2014 the response never differentiates, so the route\n * cannot be used to probe which project ids exist (screen 17 note 1).\n */\nasync function resolveRequestedProject(\n context: AccessRequestContext,\n timeProjectId: string | null,\n): Promise<{ id: string; name: string } | null> {\n if (!timeProjectId) return null\n const scope = { tenantId: context.tenantId, organizationId: context.organizationId }\n const project = await findOneWithDecryption(\n context.em,\n StaffTimeProject,\n { id: timeProjectId, tenantId: context.tenantId, organizationId: context.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!project) return null\n return { id: project.id, name: project.name }\n}\n\nasync function resolveRequesterName(context: AccessRequestContext): Promise<string> {\n const scope = { tenantId: context.tenantId, organizationId: context.organizationId }\n const staffMember = await findOneWithDecryption(\n context.em,\n StaffTeamMember,\n {\n userId: context.requesterUserId,\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const displayName = staffMember?.displayName\n if (typeof displayName === 'string' && displayName.trim().length > 0) return displayName\n return context.requesterEmail ?? context.requesterUserId\n}\n\nasync function POST(req: Request) {\n try {\n const context = await resolveAccessRequestContext(req)\n // The grant list the interceptors and the mutation-guard registry gate on\n // comes from the module's single RBAC authority, so it is always an array:\n // the nullable read it replaces could not say whether an empty answer meant\n // \"no grants\" or \"could not ask\". Authorization itself stays with the\n // declarative `requireFeatures` guard in `metadata`.\n const grantedFeatures = (\n await resolveFeatureAccess(context.container, context.requesterUserId, [VIEW_FEATURE], {\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n })\n ).grantedFeatures\n\n const interceptors = await runTimesheetInterceptors({\n request: req,\n method: 'POST',\n scope: {\n container: context.container,\n userId: context.requesterUserId,\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n userFeatures: grantedFeatures,\n },\n body: await readJsonSafe<Record<string, unknown>>(req, {}),\n })\n if (!interceptors.ok) return interceptors.response\n const { session } = interceptors\n\n const parsed = accessRequestSchema.parse(session.body)\n\n const guardResult = await runRouteMutationGuards({\n container: context.container,\n req,\n auth: {\n userId: context.requesterUserId,\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n userFeatures: grantedFeatures,\n },\n input: {\n resourceKind: RESOURCE_KIND,\n resourceId: parsed.timeProjectId ?? GENERAL_REQUEST_SCOPE,\n operation: 'create',\n mutationPayload: { timeProjectId: parsed.timeProjectId ?? null },\n },\n })\n if (!guardResult.ok) return guardResult.response\n\n const effective = guardResult.modifiedPayload\n ? accessRequestSchema.parse({ ...parsed, ...guardResult.modifiedPayload })\n : parsed\n\n const project = await resolveRequestedProject(context, effective.timeProjectId ?? null)\n const dedupeKey = buildAccessRequestDedupeKey({\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n requesterUserId: context.requesterUserId,\n timeProjectId: project?.id ?? null,\n })\n\n const cache = resolveDedupeCache(context)\n if (cache) {\n const pending = await cache.get(dedupeKey).catch(() => null)\n if (pending != null) {\n await guardResult.runAfterSuccess()\n return session.respond(200, { ok: true, deduplicated: true })\n }\n }\n\n const requesterName = await resolveRequesterName(context)\n await emitStaffEvent('staff.timesheets.project_access.requested', {\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n requesterUserId: context.requesterUserId,\n requesterName,\n timeProjectId: project?.id ?? null,\n timeProjectName: project?.name ?? null,\n requestedAt: new Date().toISOString(),\n })\n\n if (cache) {\n await cache\n .set(dedupeKey, { requestedAt: Date.now() }, { ttl: ACCESS_REQUEST_DEDUPE_WINDOW_MS })\n .catch((err: unknown) => {\n logger.warn('Failed to record the access-request dedupe window', { err })\n })\n }\n\n await guardResult.runAfterSuccess()\n\n return session.respond(200, { ok: true, deduplicated: false })\n } catch (err) {\n return errorResponse(err, 'staff.timesheets.access-requests.POST failed')\n }\n}\n\nasync function errorResponse(err: unknown, message: string) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n if (err instanceof z.ZodError) {\n return NextResponse.json(\n { error: translate('staff.errors.invalid_request', 'Invalid request'), details: err.issues },\n { status: 400 },\n )\n }\n logger.error(message, { err })\n return NextResponse.json(\n { error: translate('staff.errors.internal', 'Internal server error') },\n { status: 500 },\n )\n}\n\nconst postDoc: OpenApiMethodDoc = {\n summary: 'Request time tracking project access',\n description:\n 'Notifies every holder of staff.timesheets.projects.manage in the caller scope that a team member asked for project access. Omitting timeProjectId sends a general request from the no-assignments empty state.',\n tags: ['Staff'],\n requestBody: { schema: accessRequestSchema },\n responses: [\n {\n status: 200,\n description: 'Access request accepted (deduplicated when an identical request is still pending)',\n schema: accessRequestResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request body or missing organization scope' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Missing staff.timesheets.view' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Time tracking project access requests',\n methods: {\n POST: postDoc,\n },\n}\n\nexport { POST }\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,eAAe,uBAAuB;AAC/C,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AAEtC,SAAS,iBAAiB,wBAAwB;AAClD,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,SAAS,0CAA0C;AACnD,SAAS,gCAAgC;AACzC,SAAS,4BAA4B;AAErC,MAAM,SAAS,aAAa,OAAO,EAAE,MAAM,EAAE,WAAW,iCAAiC,CAAC;AAE1F,MAAM,eAAe;AACrB,MAAM,gBAAgB,mCAAmC;AASlD,MAAM,kCAAkC,KAAK,KAAK,KAAK;AAE9D,MAAM,wBAAwB;AAEvB,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,YAAY,EAAE;AAC7D;AAEA,MAAM,sBAAsB,EAAE,OAAO;AAAA,EACnC,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ;AAC3C,CAAC;AAED,MAAM,8BAA8B,EAAE,OAAO;AAAA,EAC3C,IAAI,EAAE,QAAQ,IAAI;AAAA,EAClB,cAAc,EAAE,QAAQ;AAC1B,CAAC;AAiBD,eAAe,4BAA4B,KAA6C;AACtF,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,MAAI,CAAC,QAAQ,CAAC,KAAK,UAAU;AAC3B,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,6BAA6B,cAAc,EAAE,CAAC;AAAA,EAChG;AACA,QAAM,kBACH,OAAO,KAAK,QAAQ,YAAY,KAAK,IAAI,KAAK,EAAE,SAAS,KAAK,KAAK,OACnE,OAAO,KAAK,WAAW,YAAY,KAAK,OAAO,KAAK,EAAE,SAAS,KAAK,KAAK,UAC1E;AACF,MAAI,CAAC,iBAAiB;AACpB,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,6BAA6B,cAAc,EAAE,CAAC;AAAA,EAChG;AACA,MAAI,CAAC,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTeamMember, StaffTimeProject } from '../../../data/entities'\nimport { emitStaffEvent } from '../../../events'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { STAFF_TIME_TRACKING_RESOURCE_KINDS } from '../../guards'\nimport { runTimesheetInterceptors } from '../_shared/withTimesheetInterceptors'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/access-requests' })\n\nconst VIEW_FEATURE = 'staff.timesheets.view'\nconst RESOURCE_KIND = STAFF_TIME_TRACKING_RESOURCE_KINDS.accessRequest\n\n/**\n * Spec D-6: one pending request per user per project (or per user for the\n * general, project-less request) per 24h. The window is held in the shared\n * cache rather than a table \u2014 the notification itself carries a `groupKey`, so\n * even if the cache is cold the manager still sees a single refreshed\n * notification instead of a duplicate row.\n */\nexport const ACCESS_REQUEST_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000\n\nconst GENERAL_REQUEST_SCOPE = 'general'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [VIEW_FEATURE] },\n}\n\nconst accessRequestSchema = z.object({\n timeProjectId: z.string().uuid().nullish(),\n})\n\nconst accessRequestResponseSchema = z.object({\n ok: z.literal(true),\n deduplicated: z.boolean(),\n})\n\ntype DedupeCacheLike = {\n get: (key: string) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number }) => Promise<void>\n}\n\ntype AccessRequestContext = {\n container: Awaited<ReturnType<typeof createRequestContainer>>\n em: EntityManager\n tenantId: string\n organizationId: string\n requesterUserId: string\n requesterEmail: string | null\n translate: (key: string, fallback?: string) => string\n}\n\nasync function resolveAccessRequestContext(req: Request): Promise<AccessRequestContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n }\n const requesterUserId =\n (typeof auth.sub === 'string' && auth.sub.trim().length > 0 && auth.sub) ||\n (typeof auth.userId === 'string' && auth.userId.trim().length > 0 && auth.userId) ||\n null\n if (!requesterUserId) {\n throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n }\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (!scope.tenantId || !scope.selectedId || scope.selectionRejected === true) {\n throw new CrudHttpError(400, {\n error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.'),\n })\n }\n return {\n container,\n em: (container.resolve('em') as EntityManager).fork(),\n tenantId: scope.tenantId,\n organizationId: scope.selectedId,\n requesterUserId,\n requesterEmail: typeof auth.email === 'string' && auth.email.trim().length > 0 ? auth.email : null,\n translate,\n }\n}\n\nfunction resolveDedupeCache(context: AccessRequestContext): DedupeCacheLike | null {\n try {\n const cache = context.container.resolve('cache') as Partial<DedupeCacheLike> | undefined\n if (typeof cache?.get !== 'function' || typeof cache?.set !== 'function') return null\n return cache as DedupeCacheLike\n } catch {\n return null\n }\n}\n\nexport function buildAccessRequestDedupeKey(input: {\n tenantId: string\n organizationId: string\n requesterUserId: string\n timeProjectId: string | null\n}): string {\n const scope = input.timeProjectId ?? GENERAL_REQUEST_SCOPE\n return `staff:timesheets:access-request:${input.tenantId}:${input.organizationId}:${input.requesterUserId}:${scope}`\n}\n\n/**\n * Resolves the requested project inside the caller's scope. A project the\n * caller's tenant/organization does not own resolves to `null` and the request\n * degrades to a general one \u2014 the response never differentiates, so the route\n * cannot be used to probe which project ids exist (screen 17 note 1).\n */\nasync function resolveRequestedProject(\n context: AccessRequestContext,\n timeProjectId: string | null,\n): Promise<{ id: string; name: string } | null> {\n if (!timeProjectId) return null\n const scope = { tenantId: context.tenantId, organizationId: context.organizationId }\n const project = await findOneWithDecryption(\n context.em,\n StaffTimeProject,\n { id: timeProjectId, tenantId: context.tenantId, organizationId: context.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!project) return null\n return { id: project.id, name: project.name }\n}\n\nasync function resolveRequesterName(context: AccessRequestContext): Promise<string> {\n const scope = { tenantId: context.tenantId, organizationId: context.organizationId }\n const staffMember = await findOneWithDecryption(\n context.em,\n StaffTeamMember,\n {\n userId: context.requesterUserId,\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const displayName = staffMember?.displayName\n if (typeof displayName === 'string' && displayName.trim().length > 0) return displayName\n return context.requesterEmail ?? context.requesterUserId\n}\n\nasync function POST(req: Request) {\n try {\n const context = await resolveAccessRequestContext(req)\n const featureAccess = await resolveFeatureAccess(context.container, context.requesterUserId, [VIEW_FEATURE], {\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n })\n if (!featureAccess.allowed) {\n throw new CrudHttpError(403, { error: context.translate('staff.errors.forbidden', 'Forbidden') })\n }\n const grantedFeatures = featureAccess.grantedFeatures\n\n const interceptors = await runTimesheetInterceptors({\n request: req,\n method: 'POST',\n scope: {\n container: context.container,\n userId: context.requesterUserId,\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n userFeatures: grantedFeatures,\n },\n body: await readJsonSafe<Record<string, unknown>>(req, {}),\n })\n if (!interceptors.ok) return interceptors.response\n const { session } = interceptors\n\n const parsed = accessRequestSchema.parse(session.body)\n\n const guardResult = await runRouteMutationGuards({\n container: context.container,\n req,\n auth: {\n userId: context.requesterUserId,\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n userFeatures: grantedFeatures,\n },\n input: {\n resourceKind: RESOURCE_KIND,\n resourceId: parsed.timeProjectId ?? GENERAL_REQUEST_SCOPE,\n operation: 'create',\n mutationPayload: { timeProjectId: parsed.timeProjectId ?? null },\n },\n })\n if (!guardResult.ok) return guardResult.response\n\n const effective = guardResult.modifiedPayload\n ? accessRequestSchema.parse({ ...parsed, ...guardResult.modifiedPayload })\n : parsed\n\n const project = await resolveRequestedProject(context, effective.timeProjectId ?? null)\n const dedupeKey = buildAccessRequestDedupeKey({\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n requesterUserId: context.requesterUserId,\n timeProjectId: project?.id ?? null,\n })\n\n const cache = resolveDedupeCache(context)\n if (cache) {\n const pending = await cache.get(dedupeKey).catch(() => null)\n if (pending != null) {\n await guardResult.runAfterSuccess()\n return session.respond(200, { ok: true, deduplicated: true })\n }\n }\n\n const requesterName = await resolveRequesterName(context)\n await emitStaffEvent('staff.timesheets.project_access.requested', {\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n requesterUserId: context.requesterUserId,\n requesterName,\n timeProjectId: project?.id ?? null,\n timeProjectName: project?.name ?? null,\n requestedAt: new Date().toISOString(),\n })\n\n if (cache) {\n await cache\n .set(dedupeKey, { requestedAt: Date.now() }, { ttl: ACCESS_REQUEST_DEDUPE_WINDOW_MS })\n .catch((err: unknown) => {\n logger.warn('Failed to record the access-request dedupe window', { err })\n })\n }\n\n await guardResult.runAfterSuccess()\n\n return session.respond(200, { ok: true, deduplicated: false })\n } catch (err) {\n return errorResponse(err, 'staff.timesheets.access-requests.POST failed')\n }\n}\n\nasync function errorResponse(err: unknown, message: string) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n if (err instanceof z.ZodError) {\n return NextResponse.json(\n { error: translate('staff.errors.invalid_request', 'Invalid request'), details: err.issues },\n { status: 400 },\n )\n }\n logger.error(message, { err })\n return NextResponse.json(\n { error: translate('staff.errors.internal', 'Internal server error') },\n { status: 500 },\n )\n}\n\nconst postDoc: OpenApiMethodDoc = {\n summary: 'Request time tracking project access',\n description:\n 'Notifies every holder of staff.timesheets.projects.manage in the caller scope that a team member asked for project access. Omitting timeProjectId sends a general request from the no-assignments empty state.',\n tags: ['Staff'],\n requestBody: { schema: accessRequestSchema },\n responses: [\n {\n status: 200,\n description: 'Access request accepted (deduplicated when an identical request is still pending)',\n schema: accessRequestResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request body or missing organization scope' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Missing staff.timesheets.view' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Time tracking project access requests',\n methods: {\n POST: postDoc,\n },\n}\n\nexport { POST }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
|
|
4
4
|
import { staffTimeProjectCrudEvents } from "../../../lib/crud.js";
|
|
5
|
+
import { PROJECT_SCOPE_TENANT_HEADER, PROJECT_SCOPE_ORGANIZATION_HEADER } from "../../../lib/time-tracking-ui/projectAccessScopeHeaders.js";
|
|
5
6
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
7
|
import { resolveCrudRecordId, parseScopedCommandInput } from "@open-mercato/shared/lib/api/scoped";
|
|
7
8
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
@@ -352,19 +353,21 @@ async function buildAccessDenialResponse() {
|
|
|
352
353
|
);
|
|
353
354
|
}
|
|
354
355
|
async function resolveProjectAccessDenial(req) {
|
|
356
|
+
const unresolved = { denial: null, scope: null };
|
|
355
357
|
const url = new URL(req.url);
|
|
356
358
|
const rawIds = url.searchParams.get("ids") ?? url.searchParams.get("id");
|
|
357
|
-
if (!isIdsParamProvided(rawIds)) return
|
|
359
|
+
if (!isIdsParamProvided(rawIds)) return unresolved;
|
|
358
360
|
const requestedIds = parseIdsParam(rawIds);
|
|
359
|
-
if (requestedIds.length === 0) return
|
|
361
|
+
if (requestedIds.length === 0) return unresolved;
|
|
360
362
|
try {
|
|
361
363
|
const container = await createRequestContainer();
|
|
362
364
|
const auth = await getAuthFromRequest(req);
|
|
363
|
-
if (!auth) return
|
|
365
|
+
if (!auth) return unresolved;
|
|
364
366
|
const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
365
367
|
const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null;
|
|
366
368
|
const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null;
|
|
367
|
-
if (!tenantId || !organizationId) return
|
|
369
|
+
if (!tenantId || !organizationId) return unresolved;
|
|
370
|
+
const scope = orgScope?.tenantId && orgScope.selectedId && orgScope.selectionRejected !== true ? { tenantId: orgScope.tenantId, organizationId: orgScope.selectedId } : null;
|
|
368
371
|
const access = await resolveRequestProjectAccess(
|
|
369
372
|
req,
|
|
370
373
|
() => loadProjectAccess(
|
|
@@ -374,18 +377,22 @@ async function resolveProjectAccessDenial(req) {
|
|
|
374
377
|
organizationId
|
|
375
378
|
)
|
|
376
379
|
);
|
|
377
|
-
if (access.canManageAll) return null;
|
|
378
|
-
if (requestedIds.some((id) => access.projectIds.includes(id))) return null;
|
|
379
|
-
return await buildAccessDenialResponse();
|
|
380
|
+
if (access.canManageAll) return { denial: null, scope };
|
|
381
|
+
if (requestedIds.some((id) => access.projectIds.includes(id))) return { denial: null, scope };
|
|
382
|
+
return { denial: await buildAccessDenialResponse(), scope };
|
|
380
383
|
} catch (err) {
|
|
381
384
|
logger.error("staff.timesheets.time-projects denial probe failed", { err });
|
|
382
|
-
return await buildAccessDenialResponse();
|
|
385
|
+
return { denial: await buildAccessDenialResponse(), scope: null };
|
|
383
386
|
}
|
|
384
387
|
}
|
|
385
388
|
async function GET(req) {
|
|
386
|
-
const denial = await resolveProjectAccessDenial(req);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
+
const { denial, scope } = await resolveProjectAccessDenial(req);
|
|
390
|
+
const response = denial ?? await crud.GET(req);
|
|
391
|
+
if (scope) {
|
|
392
|
+
response.headers.set(PROJECT_SCOPE_TENANT_HEADER, scope.tenantId);
|
|
393
|
+
response.headers.set(PROJECT_SCOPE_ORGANIZATION_HEADER, scope.organizationId);
|
|
394
|
+
}
|
|
395
|
+
return response;
|
|
389
396
|
}
|
|
390
397
|
const POST = crud.POST;
|
|
391
398
|
const PUT = crud.PUT;
|
|
@@ -451,7 +458,7 @@ const openApi = {
|
|
|
451
458
|
...baseOpenApi.methods?.GET ? {
|
|
452
459
|
GET: {
|
|
453
460
|
...baseOpenApi.methods.GET,
|
|
454
|
-
description: `${baseOpenApi.methods.GET.description ?? ""} Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
|
|
461
|
+
description: `${baseOpenApi.methods.GET.description ?? ""} Project-specific reads include optional x-om-project-scope-tenant-id and x-om-project-scope-organization-id response headers when the server resolves a scope. These identify the request scope for client hydration; a missing header provides no scope identity. Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
|
|
455
462
|
responses: [
|
|
456
463
|
...baseOpenApi.methods.GET.responses ?? [],
|
|
457
464
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/staff/api/timesheets/time-projects/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { staffTimeProjectCrudEvents } from '../../../lib/crud'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { isIdsParamProvided, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { StaffTimeProject, StaffTimeProjectMember, StaffTeamMember } from '../../../data/entities'\nimport { staffTimeProjectCreateSchema, staffTimeProjectUpdateSchema } from '../../../data/validators'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'\nimport { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'\nimport { buildSqlInClause } from '../../../lib/time-tracking/sqlInClause'\nimport { sanitizeSearchTerm, parseBooleanFlag } from '../../helpers'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\nimport { E } from '#generated/entities.ids.generated'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/time-projects' })\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n name: 'name',\n code: 'code',\n description: 'description',\n project_type: 'project_type',\n color: 'color',\n status: 'status',\n customer_id: 'customer_id',\n customer_snapshot: 'customer_snapshot',\n owner_user_id: 'owner_user_id',\n cost_center: 'cost_center',\n start_date: 'start_date',\n hourly_rate: 'hourly_rate',\n currency_code: 'currency_code',\n billable_by_default: 'billable_by_default',\n budget_kind: 'budget_kind',\n budget_value: 'budget_value',\n budget_warn_at_percent: 'budget_warn_at_percent',\n default_assignee_staff_member_id: 'default_assignee_staff_member_id',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n q: z.string().optional(),\n ids: z.string().optional(),\n projectType: z.string().optional(),\n status: z.string().optional(),\n customerId: z.string().uuid().optional(),\n mine: z.string().optional(),\n include: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\n/**\n * Discriminator returned with the 404 a project-scoped read answers when the\n * caller may not see the record. It is the whole body besides a generic error\n * string: naming the project or the customer here would confirm the resource to\n * someone who has no right to it (screen 17 note 1).\n *\n * Denied and genuinely-missing are deliberately indistinguishable for a caller\n * without `staff.timesheets.projects.manage` \u2014 both answer this same 404 \u2014 so the\n * endpoint cannot be used to enumerate which project ids exist. The discriminator\n * only tells the client \"you are not a member\", which is what screen 17 needs to\n * render the request-access affordance; a manager still gets a plain 200-empty\n * for an id that really does not exist.\n */\nexport const NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/**\n * Non-UUID sentinel used as the \"match nothing\" id filter. Kept in the shape the\n * `mine=true` branch has always used so both narrowing paths behave identically.\n */\nconst IMPOSSIBLE_PROJECT_ID = '00000000-0000-0000-0000-000000000000'\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n userHasAllFeatures?: (\n userId: string,\n required: string[],\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\n/**\n * Both entry points into a single request \u2014 the denial probe on `GET` and the\n * list narrowing in `buildFilters` \u2014 must agree on what the caller may see, and\n * neither should pay for the other's lookups. The resolved access is therefore\n * memoised per `Request`; the factory hands `buildFilters` the same object the\n * route received.\n */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveGrantedFeatures(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<string[]> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.getGrantedFeatures) return []\n return (await rbac.getGrantedFeatures(userId, scope)) ?? []\n } catch {\n // Fail closed: an unreadable grant set is no grant set, so the caller falls\n // back to their project memberships instead of being treated as a manager.\n return []\n }\n}\n\nasync function resolveUnrestricted(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.userHasAllFeatures) return false\n return (await rbac.userHasAllFeatures(userId, [MANAGE_PROJECTS_FEATURE], scope)) === true\n } catch {\n // Fail closed, exactly as the grant lookup does.\n return false\n }\n}\n\nasync function resolveAssignmentGraceDays(\n container: ContainerLike,\n tenantId: string,\n): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The two-call version\n // this replaces rescued `canManageAll` but still fed a possibly-empty array\n // into `userFeatures`, which the guards downstream read.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.time-projects access resolution failed', { err })\n return { ...DENIED_ACCESS }\n }\n}\n\nfunction resolveRequestProjectAccess(\n request: Request | undefined,\n load: () => Promise<ProjectAccess>,\n): Promise<ProjectAccess> {\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n\nfunction resolveCtxScope(ctx: CrudCtx): { tenantId: string | null; organizationId: string | null } {\n return {\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n }\n}\n\nasync function resolveListProjectAccess(ctx: CrudCtx): Promise<ProjectAccess> {\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n return resolveRequestProjectAccess(ctx.request, () =>\n loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId),\n )\n}\n\nasync function resolveCallerAssignedProjectIds(\n em: EntityManager,\n userId: string,\n tenantId: string,\n organizationId: string,\n): Promise<string[]> {\n const scopeCtx = { tenantId, organizationId }\n const staffMember = await findOneWithDecryption(\n em.fork(),\n StaffTeamMember,\n { userId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!staffMember) return []\n const memberships = await em.fork().find(StaffTimeProjectMember, {\n staffMemberId: staffMember.id,\n tenantId,\n organizationId,\n status: 'active',\n deletedAt: null,\n })\n return Array.from(new Set(memberships.map((m) => m.timeProjectId)))\n}\n\ntype ProjectEntryCountRow = {\n time_project_id: string\n entry_count: string | number\n locked_entry_count: string | number\n}\n\ntype ProjectListItem = {\n id?: unknown\n entryCount?: number\n lockedEntryCount?: number\n currencyLocked?: boolean\n}\n\nfunction toCount(value: string | number | null | undefined): number {\n const parsed = typeof value === 'number' ? value : Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/**\n * D-3 read-side signal: currency is read-only once hours have been logged, so the\n * project list/detail response carries the reason alongside the value.\n * `currencyLocked` tells the form to render the field read-only behind the explicit\n * change action, `entryCount` names why, and `lockedEntryCount` says whether even\n * that action will be refused (entries frozen in a closed report).\n *\n * One grouped aggregate for the whole page \u2014 never a per-row count.\n */\nexport async function decorateProjectCurrencyLock(\n items: ProjectListItem[],\n em: EntityManager,\n scope: { tenantId: string; organizationId: string },\n): Promise<void> {\n const ids = Array.from(\n new Set(items.map((item) => item.id).filter((value): value is string => typeof value === 'string' && value.length > 0)),\n )\n if (ids.length === 0) return\n\n const projectIn = buildSqlInClause('time_project_id', ids)\n const rows = (await em.getConnection().execute(\n `\n SELECT time_project_id,\n COUNT(*)::bigint AS entry_count,\n COUNT(locked_report_id)::bigint AS locked_entry_count\n FROM staff_time_entries\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND ${projectIn.sql}\n GROUP BY time_project_id\n `,\n [scope.tenantId, scope.organizationId, ...projectIn.params],\n )) as ProjectEntryCountRow[]\n\n const counts = new Map(rows.map((row) => [row.time_project_id, row]))\n for (const item of items) {\n const row = typeof item.id === 'string' ? counts.get(item.id) : undefined\n const entryCount = toCount(row?.entry_count)\n item.entryCount = entryCount\n item.lockedEntryCount = toCount(row?.locked_entry_count)\n item.currencyLocked = entryCount > 0\n }\n}\n\n/**\n * The list projection doubles as the detail projection (the edit page re-reads this\n * route with `?id=`), so every column the project form round-trips has to be here.\n */\nexport const timeProjectListFields = [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.name,\n F.code,\n F.description,\n F.project_type,\n F.color,\n F.status,\n F.customer_id,\n F.customer_snapshot,\n F.owner_user_id,\n F.cost_center,\n F.start_date,\n F.hourly_rate,\n F.currency_code,\n F.billable_by_default,\n F.budget_kind,\n F.budget_value,\n F.budget_warn_at_percent,\n F.default_assignee_staff_member_id,\n F.created_at,\n F.updated_at,\n] as const\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeProject,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n events: staffTimeProjectCrudEvents,\n indexer: { entityType: 'staff:staff_time_project' },\n enrichers: { entityId: 'staff:staff_time_project' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_project',\n fields: [...timeProjectListFields],\n decorateCustomFields: { entityIds: [E.staff.staff_time_project] },\n sortFieldMap: {\n name: F.name,\n code: F.code,\n status: F.status,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n startDate: F.start_date,\n },\n buildFilters: async (query, ctx) => {\n const filters: Record<string, unknown> = {}\n let narrowIds: string[] | null = null\n if (typeof query.ids === 'string' && query.ids.trim().length > 0) {\n narrowIds = query.ids\n .split(',')\n .map((value) => value.trim())\n .filter((value) => value.length > 0)\n }\n if (parseBooleanFlag(query.mine) && ctx.auth) {\n const tenantId = ctx.auth.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null\n if (!tenantId || !organizationId) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n const em = ctx.container.resolve('em') as EntityManager\n const assigned = await resolveCallerAssignedProjectIds(\n em,\n ctx.auth.sub,\n tenantId,\n organizationId,\n )\n narrowIds = narrowIds\n ? narrowIds.filter((id) => assigned.includes(id))\n : assigned\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n // Every project-scoped read filters through the one resolver: a caller\n // without `staff.timesheets.projects.manage` only ever sees the projects\n // they are an active member of. A non-member gets no row at all \u2014 not a\n // redacted one \u2014 so the customer and project name never leave the server.\n const access = await resolveListProjectAccess(ctx)\n if (!access.canManageAll) {\n narrowIds = narrowIds\n ? narrowIds.filter((id) => access.projectIds.includes(id))\n : [...access.projectIds]\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n if (narrowIds && narrowIds.length > 0) {\n filters[F.id] = { $in: narrowIds }\n }\n const term = sanitizeSearchTerm(query.q)\n if (term) {\n filters[F.name] = { $ilike: `%${escapeLikePattern(term)}%` }\n }\n if (typeof query.projectType === 'string' && query.projectType.trim().length > 0) {\n filters[F.project_type] = query.projectType.trim()\n }\n if (typeof query.status === 'string' && query.status.trim().length > 0) {\n filters[F.status] = query.status.trim()\n }\n if (typeof query.customerId === 'string' && query.customerId.trim().length > 0) {\n filters[F.customer_id] = query.customerId.trim()\n }\n return filters\n },\n },\n hooks: {\n afterList: async (res, ctx) => {\n const items = Array.isArray((res as { items?: unknown })?.items)\n ? ((res as { items: ProjectListItem[] }).items)\n : []\n if (items.length === 0) return\n const tenantId = ctx.auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (!tenantId || !organizationId) return\n const em = ctx.container.resolve('em') as EntityManager\n await decorateProjectCurrencyLock(items, em.fork(), { tenantId, organizationId })\n },\n },\n actions: {\n create: {\n commandId: 'staff.timesheets.time_projects.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeProjectId ?? null }),\n status: 201,\n },\n update: {\n commandId: 'staff.timesheets.time_projects.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'staff.timesheets.time_projects.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nasync function buildAccessDenialResponse(): Promise<Response> {\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n {\n // Same string a genuinely missing record gets: the body must not tell the\n // caller which of the two it is beyond the membership discriminator.\n error: translate('staff.timesheets.projects.errors.notFound', 'Project not found.'),\n reason: NO_PROJECT_ACCESS_REASON,\n },\n { status: 404 },\n )\n}\n\n/**\n * The list route doubles as the detail read (`?ids=<id>`, and defensively `?id=`),\n * so a caller asking for one specific project needs an answer the UI can turn\n * into screen 17 rather than an indistinguishable empty page. Returning 404 for\n * denied *and* unknown ids alike keeps the endpoint useless for enumeration; the\n * `reason` discriminator carries only \"you are not a member of this project\",\n * never the project or customer it is refusing to describe.\n */\nasync function resolveProjectAccessDenial(req: Request): Promise<Response | null> {\n const url = new URL(req.url)\n const rawIds = url.searchParams.get('ids') ?? url.searchParams.get('id')\n if (!isIdsParamProvided(rawIds)) return null\n const requestedIds = parseIdsParam(rawIds)\n // Malformed ids match nothing on their own; the factory answers an empty list.\n if (requestedIds.length === 0) return null\n\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n // Unauthenticated and unscoped requests are the factory's to reject.\n if (!auth) return null\n const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null\n const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null\n if (!tenantId || !organizationId) return null\n\n const access = await resolveRequestProjectAccess(req, () =>\n loadProjectAccess(\n container,\n typeof auth.sub === 'string' ? auth.sub : null,\n tenantId,\n organizationId,\n ),\n )\n if (access.canManageAll) return null\n if (requestedIds.some((id) => access.projectIds.includes(id))) return null\n return await buildAccessDenialResponse()\n } catch (err) {\n // Fail closed: an access decision that could not be made is a denial.\n logger.error('staff.timesheets.time-projects denial probe failed', { err })\n return await buildAccessDenialResponse()\n }\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const denial = await resolveProjectAccessDenial(req)\n if (denial) return denial\n return crud.GET(req)\n}\n\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const timeProjectListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n name: z.string().nullable().optional(),\n code: z.string().nullable().optional(),\n description: z.string().nullable().optional(),\n project_type: z.string().nullable().optional(),\n color: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n customer_snapshot: z.record(z.string(), z.unknown()).nullable().optional(),\n owner_user_id: z.string().uuid().nullable().optional(),\n cost_center: z.string().nullable().optional(),\n start_date: z.string().nullable().optional(),\n // numeric(14,4) reaches the client as a decimal string; a number stays accepted\n // for callers that read the column through a numeric-casting driver.\n hourly_rate: z.union([z.string(), z.number()]).nullable().optional(),\n currency_code: z.string().nullable().optional(),\n billable_by_default: z.boolean().nullable().optional(),\n budget_kind: z.string().nullable().optional(),\n budget_value: z.union([z.string(), z.number()]).nullable().optional(),\n budget_warn_at_percent: z.number().int().nullable().optional(),\n default_assignee_staff_member_id: z.string().uuid().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n entryCount: z.number().int().optional(),\n lockedEntryCount: z.number().int().optional(),\n currencyLocked: z.boolean().optional(),\n})\n\nexport const projectAccessDeniedResponseSchema = z.object({\n error: z.string(),\n reason: z.literal(NO_PROJECT_ACCESS_REASON),\n})\n\nconst baseOpenApi = createStaffCrudOpenApi({\n resourceName: 'TimeProject',\n pluralName: 'Time Projects',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeProjectListItemSchema),\n create: {\n schema: staffTimeProjectCreateSchema,\n description: 'Creates a time project.',\n },\n update: {\n schema: staffTimeProjectUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates a time project by id.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Soft-deletes a time project by id.',\n },\n})\n\nexport const openApi = {\n ...baseOpenApi,\n methods: {\n ...baseOpenApi.methods,\n ...(baseOpenApi.methods?.GET\n ? {\n GET: {\n ...baseOpenApi.methods.GET,\n description: `${baseOpenApi.methods.GET.description ?? ''} Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),\n responses: [\n ...(baseOpenApi.methods.GET.responses ?? []),\n {\n status: 404,\n description: 'The caller is not a member of the requested project (or it does not exist)',\n schema: projectAccessDeniedResponseSchema,\n },\n ],\n },\n }\n : {}),\n },\n} satisfies typeof baseOpenApi\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { staffTimeProjectCrudEvents } from '../../../lib/crud'\nimport { PROJECT_SCOPE_TENANT_HEADER, PROJECT_SCOPE_ORGANIZATION_HEADER, type ResolvedProjectAccessScope } from '../../../lib/time-tracking-ui/projectAccessScopeHeaders'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { isIdsParamProvided, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { StaffTimeProject, StaffTimeProjectMember, StaffTeamMember } from '../../../data/entities'\nimport { staffTimeProjectCreateSchema, staffTimeProjectUpdateSchema } from '../../../data/validators'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'\nimport { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'\nimport { buildSqlInClause } from '../../../lib/time-tracking/sqlInClause'\nimport { sanitizeSearchTerm, parseBooleanFlag } from '../../helpers'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\nimport { E } from '#generated/entities.ids.generated'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/time-projects' })\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n name: 'name',\n code: 'code',\n description: 'description',\n project_type: 'project_type',\n color: 'color',\n status: 'status',\n customer_id: 'customer_id',\n customer_snapshot: 'customer_snapshot',\n owner_user_id: 'owner_user_id',\n cost_center: 'cost_center',\n start_date: 'start_date',\n hourly_rate: 'hourly_rate',\n currency_code: 'currency_code',\n billable_by_default: 'billable_by_default',\n budget_kind: 'budget_kind',\n budget_value: 'budget_value',\n budget_warn_at_percent: 'budget_warn_at_percent',\n default_assignee_staff_member_id: 'default_assignee_staff_member_id',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n q: z.string().optional(),\n ids: z.string().optional(),\n projectType: z.string().optional(),\n status: z.string().optional(),\n customerId: z.string().uuid().optional(),\n mine: z.string().optional(),\n include: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\n/**\n * Discriminator returned with the 404 a project-scoped read answers when the\n * caller may not see the record. It is the whole body besides a generic error\n * string: naming the project or the customer here would confirm the resource to\n * someone who has no right to it (screen 17 note 1).\n *\n * Denied and genuinely-missing are deliberately indistinguishable for a caller\n * without `staff.timesheets.projects.manage` \u2014 both answer this same 404 \u2014 so the\n * endpoint cannot be used to enumerate which project ids exist. The discriminator\n * only tells the client \"you are not a member\", which is what screen 17 needs to\n * render the request-access affordance; a manager still gets a plain 200-empty\n * for an id that really does not exist.\n */\nexport const NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/**\n * Non-UUID sentinel used as the \"match nothing\" id filter. Kept in the shape the\n * `mine=true` branch has always used so both narrowing paths behave identically.\n */\nconst IMPOSSIBLE_PROJECT_ID = '00000000-0000-0000-0000-000000000000'\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n userHasAllFeatures?: (\n userId: string,\n required: string[],\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\n/**\n * Both entry points into a single request \u2014 the denial probe on `GET` and the\n * list narrowing in `buildFilters` \u2014 must agree on what the caller may see, and\n * neither should pay for the other's lookups. The resolved access is therefore\n * memoised per `Request`; the factory hands `buildFilters` the same object the\n * route received.\n */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveGrantedFeatures(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<string[]> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.getGrantedFeatures) return []\n return (await rbac.getGrantedFeatures(userId, scope)) ?? []\n } catch {\n // Fail closed: an unreadable grant set is no grant set, so the caller falls\n // back to their project memberships instead of being treated as a manager.\n return []\n }\n}\n\nasync function resolveUnrestricted(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.userHasAllFeatures) return false\n return (await rbac.userHasAllFeatures(userId, [MANAGE_PROJECTS_FEATURE], scope)) === true\n } catch {\n // Fail closed, exactly as the grant lookup does.\n return false\n }\n}\n\nasync function resolveAssignmentGraceDays(\n container: ContainerLike,\n tenantId: string,\n): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The two-call version\n // this replaces rescued `canManageAll` but still fed a possibly-empty array\n // into `userFeatures`, which the guards downstream read.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.time-projects access resolution failed', { err })\n return { ...DENIED_ACCESS }\n }\n}\n\nfunction resolveRequestProjectAccess(\n request: Request | undefined,\n load: () => Promise<ProjectAccess>,\n): Promise<ProjectAccess> {\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n\nfunction resolveCtxScope(ctx: CrudCtx): { tenantId: string | null; organizationId: string | null } {\n return {\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n }\n}\n\nasync function resolveListProjectAccess(ctx: CrudCtx): Promise<ProjectAccess> {\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n return resolveRequestProjectAccess(ctx.request, () =>\n loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId),\n )\n}\n\nasync function resolveCallerAssignedProjectIds(\n em: EntityManager,\n userId: string,\n tenantId: string,\n organizationId: string,\n): Promise<string[]> {\n const scopeCtx = { tenantId, organizationId }\n const staffMember = await findOneWithDecryption(\n em.fork(),\n StaffTeamMember,\n { userId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!staffMember) return []\n const memberships = await em.fork().find(StaffTimeProjectMember, {\n staffMemberId: staffMember.id,\n tenantId,\n organizationId,\n status: 'active',\n deletedAt: null,\n })\n return Array.from(new Set(memberships.map((m) => m.timeProjectId)))\n}\n\ntype ProjectEntryCountRow = {\n time_project_id: string\n entry_count: string | number\n locked_entry_count: string | number\n}\n\ntype ProjectListItem = {\n id?: unknown\n entryCount?: number\n lockedEntryCount?: number\n currencyLocked?: boolean\n}\n\nfunction toCount(value: string | number | null | undefined): number {\n const parsed = typeof value === 'number' ? value : Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/**\n * D-3 read-side signal: currency is read-only once hours have been logged, so the\n * project list/detail response carries the reason alongside the value.\n * `currencyLocked` tells the form to render the field read-only behind the explicit\n * change action, `entryCount` names why, and `lockedEntryCount` says whether even\n * that action will be refused (entries frozen in a closed report).\n *\n * One grouped aggregate for the whole page \u2014 never a per-row count.\n */\nexport async function decorateProjectCurrencyLock(\n items: ProjectListItem[],\n em: EntityManager,\n scope: { tenantId: string; organizationId: string },\n): Promise<void> {\n const ids = Array.from(\n new Set(items.map((item) => item.id).filter((value): value is string => typeof value === 'string' && value.length > 0)),\n )\n if (ids.length === 0) return\n\n const projectIn = buildSqlInClause('time_project_id', ids)\n const rows = (await em.getConnection().execute(\n `\n SELECT time_project_id,\n COUNT(*)::bigint AS entry_count,\n COUNT(locked_report_id)::bigint AS locked_entry_count\n FROM staff_time_entries\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND ${projectIn.sql}\n GROUP BY time_project_id\n `,\n [scope.tenantId, scope.organizationId, ...projectIn.params],\n )) as ProjectEntryCountRow[]\n\n const counts = new Map(rows.map((row) => [row.time_project_id, row]))\n for (const item of items) {\n const row = typeof item.id === 'string' ? counts.get(item.id) : undefined\n const entryCount = toCount(row?.entry_count)\n item.entryCount = entryCount\n item.lockedEntryCount = toCount(row?.locked_entry_count)\n item.currencyLocked = entryCount > 0\n }\n}\n\n/**\n * The list projection doubles as the detail projection (the edit page re-reads this\n * route with `?id=`), so every column the project form round-trips has to be here.\n */\nexport const timeProjectListFields = [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.name,\n F.code,\n F.description,\n F.project_type,\n F.color,\n F.status,\n F.customer_id,\n F.customer_snapshot,\n F.owner_user_id,\n F.cost_center,\n F.start_date,\n F.hourly_rate,\n F.currency_code,\n F.billable_by_default,\n F.budget_kind,\n F.budget_value,\n F.budget_warn_at_percent,\n F.default_assignee_staff_member_id,\n F.created_at,\n F.updated_at,\n] as const\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeProject,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n events: staffTimeProjectCrudEvents,\n indexer: { entityType: 'staff:staff_time_project' },\n enrichers: { entityId: 'staff:staff_time_project' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_project',\n fields: [...timeProjectListFields],\n decorateCustomFields: { entityIds: [E.staff.staff_time_project] },\n sortFieldMap: {\n name: F.name,\n code: F.code,\n status: F.status,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n startDate: F.start_date,\n },\n buildFilters: async (query, ctx) => {\n const filters: Record<string, unknown> = {}\n let narrowIds: string[] | null = null\n if (typeof query.ids === 'string' && query.ids.trim().length > 0) {\n narrowIds = query.ids\n .split(',')\n .map((value) => value.trim())\n .filter((value) => value.length > 0)\n }\n if (parseBooleanFlag(query.mine) && ctx.auth) {\n const tenantId = ctx.auth.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null\n if (!tenantId || !organizationId) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n const em = ctx.container.resolve('em') as EntityManager\n const assigned = await resolveCallerAssignedProjectIds(\n em,\n ctx.auth.sub,\n tenantId,\n organizationId,\n )\n narrowIds = narrowIds\n ? narrowIds.filter((id) => assigned.includes(id))\n : assigned\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n // Every project-scoped read filters through the one resolver: a caller\n // without `staff.timesheets.projects.manage` only ever sees the projects\n // they are an active member of. A non-member gets no row at all \u2014 not a\n // redacted one \u2014 so the customer and project name never leave the server.\n const access = await resolveListProjectAccess(ctx)\n if (!access.canManageAll) {\n narrowIds = narrowIds\n ? narrowIds.filter((id) => access.projectIds.includes(id))\n : [...access.projectIds]\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n if (narrowIds && narrowIds.length > 0) {\n filters[F.id] = { $in: narrowIds }\n }\n const term = sanitizeSearchTerm(query.q)\n if (term) {\n filters[F.name] = { $ilike: `%${escapeLikePattern(term)}%` }\n }\n if (typeof query.projectType === 'string' && query.projectType.trim().length > 0) {\n filters[F.project_type] = query.projectType.trim()\n }\n if (typeof query.status === 'string' && query.status.trim().length > 0) {\n filters[F.status] = query.status.trim()\n }\n if (typeof query.customerId === 'string' && query.customerId.trim().length > 0) {\n filters[F.customer_id] = query.customerId.trim()\n }\n return filters\n },\n },\n hooks: {\n afterList: async (res, ctx) => {\n const items = Array.isArray((res as { items?: unknown })?.items)\n ? ((res as { items: ProjectListItem[] }).items)\n : []\n if (items.length === 0) return\n const tenantId = ctx.auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (!tenantId || !organizationId) return\n const em = ctx.container.resolve('em') as EntityManager\n await decorateProjectCurrencyLock(items, em.fork(), { tenantId, organizationId })\n },\n },\n actions: {\n create: {\n commandId: 'staff.timesheets.time_projects.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeProjectId ?? null }),\n status: 201,\n },\n update: {\n commandId: 'staff.timesheets.time_projects.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'staff.timesheets.time_projects.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nasync function buildAccessDenialResponse(): Promise<Response> {\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n {\n // Same string a genuinely missing record gets: the body must not tell the\n // caller which of the two it is beyond the membership discriminator.\n error: translate('staff.timesheets.projects.errors.notFound', 'Project not found.'),\n reason: NO_PROJECT_ACCESS_REASON,\n },\n { status: 404 },\n )\n}\n\n/**\n * The list route doubles as the detail read (`?ids=<id>`, and defensively `?id=`),\n * so a caller asking for one specific project needs an answer the UI can turn\n * into screen 17 rather than an indistinguishable empty page. Returning 404 for\n * denied *and* unknown ids alike keeps the endpoint useless for enumeration; the\n * `reason` discriminator carries only \"you are not a member of this project\",\n * never the project or customer it is refusing to describe.\n */\nasync function resolveProjectAccessDenial(req: Request): Promise<{ denial: Response | null; scope: ResolvedProjectAccessScope | null }> {\n const unresolved = { denial: null, scope: null }\n const url = new URL(req.url)\n const rawIds = url.searchParams.get('ids') ?? url.searchParams.get('id')\n if (!isIdsParamProvided(rawIds)) return unresolved\n const requestedIds = parseIdsParam(rawIds)\n // Malformed ids match nothing on their own; the factory answers an empty list.\n if (requestedIds.length === 0) return unresolved\n\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n // Unauthenticated and unscoped requests are the factory's to reject.\n if (!auth) return unresolved\n const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null\n const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null\n if (!tenantId || !organizationId) return unresolved\n\n const scope = orgScope?.tenantId && orgScope.selectedId && orgScope.selectionRejected !== true\n ? { tenantId: orgScope.tenantId, organizationId: orgScope.selectedId }\n : null\n const access = await resolveRequestProjectAccess(req, () =>\n loadProjectAccess(\n container,\n typeof auth.sub === 'string' ? auth.sub : null,\n tenantId,\n organizationId,\n ),\n )\n if (access.canManageAll) return { denial: null, scope }\n if (requestedIds.some((id) => access.projectIds.includes(id))) return { denial: null, scope }\n return { denial: await buildAccessDenialResponse(), scope }\n } catch (err) {\n // Fail closed: an access decision that could not be made is a denial.\n logger.error('staff.timesheets.time-projects denial probe failed', { err })\n return { denial: await buildAccessDenialResponse(), scope: null }\n }\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { denial, scope } = await resolveProjectAccessDenial(req)\n const response = denial ?? await crud.GET(req)\n if (scope) {\n response.headers.set(PROJECT_SCOPE_TENANT_HEADER, scope.tenantId)\n response.headers.set(PROJECT_SCOPE_ORGANIZATION_HEADER, scope.organizationId)\n }\n return response\n}\n\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const timeProjectListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n name: z.string().nullable().optional(),\n code: z.string().nullable().optional(),\n description: z.string().nullable().optional(),\n project_type: z.string().nullable().optional(),\n color: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n customer_snapshot: z.record(z.string(), z.unknown()).nullable().optional(),\n owner_user_id: z.string().uuid().nullable().optional(),\n cost_center: z.string().nullable().optional(),\n start_date: z.string().nullable().optional(),\n // numeric(14,4) reaches the client as a decimal string; a number stays accepted\n // for callers that read the column through a numeric-casting driver.\n hourly_rate: z.union([z.string(), z.number()]).nullable().optional(),\n currency_code: z.string().nullable().optional(),\n billable_by_default: z.boolean().nullable().optional(),\n budget_kind: z.string().nullable().optional(),\n budget_value: z.union([z.string(), z.number()]).nullable().optional(),\n budget_warn_at_percent: z.number().int().nullable().optional(),\n default_assignee_staff_member_id: z.string().uuid().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n entryCount: z.number().int().optional(),\n lockedEntryCount: z.number().int().optional(),\n currencyLocked: z.boolean().optional(),\n})\n\nexport const projectAccessDeniedResponseSchema = z.object({\n error: z.string(),\n reason: z.literal(NO_PROJECT_ACCESS_REASON),\n})\n\nconst baseOpenApi = createStaffCrudOpenApi({\n resourceName: 'TimeProject',\n pluralName: 'Time Projects',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeProjectListItemSchema),\n create: {\n schema: staffTimeProjectCreateSchema,\n description: 'Creates a time project.',\n },\n update: {\n schema: staffTimeProjectUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates a time project by id.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Soft-deletes a time project by id.',\n },\n})\n\nexport const openApi = {\n ...baseOpenApi,\n methods: {\n ...baseOpenApi.methods,\n ...(baseOpenApi.methods?.GET\n ? {\n GET: {\n ...baseOpenApi.methods.GET,\n description: `${baseOpenApi.methods.GET.description ?? ''} Project-specific reads include optional x-om-project-scope-tenant-id and x-om-project-scope-organization-id response headers when the server resolves a scope. These identify the request scope for client hydration; a missing header provides no scope identity. Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),\n responses: [\n ...(baseOpenApi.methods.GET.responses ?? []),\n {\n status: 404,\n description: 'The caller is not a member of the requested project (or it does not exist)',\n schema: projectAccessDeniedResponseSchema,\n },\n ],\n },\n }\n : {}),\n },\n} satisfies typeof baseOpenApi\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -15,6 +15,8 @@ import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
|
15
15
|
import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
16
16
|
import { DEFAULT_BUDGET_WARN_AT_PERCENT } from "../../../../../../lib/time-tracking-ui/ProjectFormSections.js";
|
|
17
17
|
import { NoProjectAccess } from "../../../../../../lib/time-tracking-ui/NoProjectAccess.js";
|
|
18
|
+
import { useProjectAccessScope } from "../../../../../../lib/time-tracking-ui/projectAccessScope.js";
|
|
19
|
+
import { readResolvedProjectAccessScope } from "../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders.js";
|
|
18
20
|
import {
|
|
19
21
|
buildProjectPayload,
|
|
20
22
|
createProjectFormFields,
|
|
@@ -25,8 +27,9 @@ import {
|
|
|
25
27
|
const LIST_HREF = "/backend/staff/time-tracking/projects";
|
|
26
28
|
const NO_PROJECT_ACCESS_REASON = "no_project_access";
|
|
27
29
|
class ProjectAccessDeniedError extends Error {
|
|
28
|
-
constructor() {
|
|
30
|
+
constructor(resolvedScope) {
|
|
29
31
|
super("[internal] no project access");
|
|
32
|
+
this.resolvedScope = resolvedScope;
|
|
30
33
|
this.name = "ProjectAccessDeniedError";
|
|
31
34
|
}
|
|
32
35
|
}
|
|
@@ -86,27 +89,30 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
86
89
|
const t = useT();
|
|
87
90
|
const router = useRouter();
|
|
88
91
|
const scopeVersion = useOrganizationScopeVersion();
|
|
92
|
+
const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId);
|
|
89
93
|
const [initialValues, setInitialValues] = React.useState(null);
|
|
90
94
|
const [loading, setLoading] = React.useState(true);
|
|
91
95
|
const [error, setError] = React.useState(null);
|
|
92
96
|
const [isNotFound, setIsNotFound] = React.useState(false);
|
|
93
|
-
const [
|
|
97
|
+
const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState(null);
|
|
98
|
+
const [loadedScopeKey, setLoadedScopeKey] = React.useState(null);
|
|
99
|
+
const accessDenied = accessDeniedScopeKey === projectAccessScopeKey;
|
|
94
100
|
const fetchProject = React.useCallback(async () => {
|
|
95
101
|
const call = await apiCall(
|
|
96
102
|
`/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`
|
|
97
103
|
);
|
|
98
104
|
if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {
|
|
99
|
-
throw new ProjectAccessDeniedError();
|
|
105
|
+
throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response));
|
|
100
106
|
}
|
|
101
107
|
if (!call.ok) {
|
|
102
108
|
throw new Error(t("staff.timesheets.projects.errors.load", "Failed to load project."));
|
|
103
109
|
}
|
|
104
110
|
const record = Array.isArray(call.result?.items) ? call.result.items[0] : null;
|
|
105
|
-
return record ?? null;
|
|
111
|
+
return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) };
|
|
106
112
|
}, [projectId, t]);
|
|
107
113
|
const handleCurrencyChanged = React.useCallback(() => {
|
|
108
114
|
if (!projectId) return;
|
|
109
|
-
void fetchProject().then((record) => {
|
|
115
|
+
void fetchProject().then(({ record }) => {
|
|
110
116
|
if (record) setInitialValues(toValues(record));
|
|
111
117
|
}).catch(() => {
|
|
112
118
|
});
|
|
@@ -122,20 +128,22 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
122
128
|
if (!projectId) return;
|
|
123
129
|
let cancelled = false;
|
|
124
130
|
const replaced = lastLoadRef.current;
|
|
125
|
-
lastLoadRef.current = { projectId, scopeVersion };
|
|
126
|
-
const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0;
|
|
131
|
+
lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey };
|
|
132
|
+
const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0 && replaced.scopeKey === projectAccessScopeKey;
|
|
127
133
|
async function load() {
|
|
128
134
|
if (!keepScreen) setLoading(true);
|
|
129
135
|
const settle = (outcome) => {
|
|
130
136
|
if (cancelled) return;
|
|
131
|
-
|
|
137
|
+
setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null);
|
|
132
138
|
setIsNotFound(outcome.isNotFound === true);
|
|
133
139
|
setError(outcome.error ?? null);
|
|
134
140
|
if (outcome.values) setInitialValues(outcome.values);
|
|
141
|
+
setLoadedScopeKey(projectAccessScopeKey);
|
|
135
142
|
setLoading(false);
|
|
136
143
|
};
|
|
137
144
|
try {
|
|
138
|
-
const record = await fetchProject();
|
|
145
|
+
const { record, scope } = await fetchProject();
|
|
146
|
+
if (!cancelled) resolveScope(scope);
|
|
139
147
|
if (!record) {
|
|
140
148
|
settle({ isNotFound: true });
|
|
141
149
|
return;
|
|
@@ -143,6 +151,7 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
143
151
|
settle({ values: toValues(record) });
|
|
144
152
|
} catch (err) {
|
|
145
153
|
if (err instanceof ProjectAccessDeniedError) {
|
|
154
|
+
if (!cancelled) resolveScope(err.resolvedScope);
|
|
146
155
|
settle({ accessDenied: true });
|
|
147
156
|
return;
|
|
148
157
|
}
|
|
@@ -155,12 +164,12 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
155
164
|
return () => {
|
|
156
165
|
cancelled = true;
|
|
157
166
|
};
|
|
158
|
-
}, [projectId, fetchProject, scopeVersion, t]);
|
|
159
|
-
if (loading) {
|
|
167
|
+
}, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope]);
|
|
168
|
+
if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {
|
|
160
169
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(LoadingMessage, { label: t("staff.timesheets.projects.loading", "Loading project...") }) }) });
|
|
161
170
|
}
|
|
162
171
|
if (accessDenied) {
|
|
163
|
-
return /* @__PURE__ */ jsx(NoProjectAccess, { timeProjectId: projectId });
|
|
172
|
+
return /* @__PURE__ */ jsx(NoProjectAccess, { timeProjectId: projectId, resolvedScope });
|
|
164
173
|
}
|
|
165
174
|
if (isNotFound) {
|
|
166
175
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../../src/modules/staff/backend/staff/time-tracking/projects/%5Bid%5D/edit/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm } from '@open-mercato/ui/backend/CrudForm'\nimport { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { E } from '#generated/entities.ids.generated'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'\nimport { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'\nimport {\n buildProjectPayload,\n createProjectFormFields,\n createProjectFormGroups,\n createProjectFormSchema,\n pickCustomFieldValues,\n type ProjectFormValues,\n} from '../../projectFormConfig'\n\nconst LIST_HREF = '/backend/staff/time-tracking/projects'\n\n/**\n * Mirrors `NO_PROJECT_ACCESS_REASON` from\n * `api/timesheets/time-projects/route.ts`. The route module is server-only, so\n * the literal is restated here rather than imported into the client bundle;\n * `__tests__/no-project-access-contract.test.ts` pins the two together.\n */\nconst NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/** Thrown by the loader so the caller can branch on the guard state. */\nclass ProjectAccessDeniedError extends Error {\n constructor() {\n super('[internal] no project access')\n this.name = 'ProjectAccessDeniedError'\n }\n}\n\ntype ProjectRecord = Record<string, unknown>\n\nfunction readString(record: ProjectRecord, ...keys: string[]): string | null {\n for (const key of keys) {\n const value = record[key]\n if (typeof value === 'string' && value.length > 0) return value\n if (typeof value === 'number') return String(value)\n }\n return null\n}\n\nfunction readSnapshot(record: ProjectRecord): Record<string, unknown> | null {\n const raw = record.customerSnapshot ?? record.customer_snapshot\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>\n return null\n}\n\nfunction toValues(record: ProjectRecord): ProjectFormValues {\n const snapshot = readSnapshot(record)\n const budgetKindRaw = readString(record, 'budgetKind', 'budget_kind') ?? 'none'\n const budgetKind: ProjectBudgetKind =\n budgetKindRaw === 'hours' || budgetKindRaw === 'amount' ? budgetKindRaw : 'none'\n const entryCount = typeof record.entryCount === 'number' ? record.entryCount : 0\n\n return {\n id: String(record.id ?? ''),\n updatedAt: readString(record, 'updatedAt', 'updated_at'),\n name: String(record.name ?? ''),\n code: String(record.code ?? ''),\n // D-10: an existing project keeps the code it was saved with \u2014 the name no\n // longer drives it.\n codeManual: true,\n customerId: readString(record, 'customerId', 'customer_id'),\n customerName: typeof snapshot?.name === 'string' ? snapshot.name : null,\n customerKind: typeof snapshot?.kind === 'string' ? snapshot.kind : null,\n customerTaxId: typeof snapshot?.taxId === 'string' ? snapshot.taxId : null,\n description: readString(record, 'description'),\n hourlyRate: readString(record, 'hourlyRate', 'hourly_rate'),\n currencyCode: readString(record, 'currencyCode', 'currency_code'),\n currencyLocked: record.currencyLocked === true,\n entryCount,\n billableByDefault: (record.billableByDefault ?? record.billable_by_default) !== false,\n budgetEnabled: budgetKind !== 'none',\n budgetKind: budgetKind === 'none' ? 'hours' : budgetKind,\n budgetValue: readString(record, 'budgetValue', 'budget_value'),\n budgetWarnAtPercent:\n readString(record, 'budgetWarnAtPercent', 'budget_warn_at_percent') ?? String(DEFAULT_BUDGET_WARN_AT_PERCENT),\n defaultAssigneeStaffMemberId: readString(record, 'defaultAssigneeStaffMemberId', 'default_assignee_staff_member_id') ?? null,\n projectType: readString(record, 'projectType', 'project_type'),\n color: readString(record, 'color'),\n startDate: readString(record, 'startDate', 'start_date'),\n costCenter: readString(record, 'costCenter', 'cost_center'),\n status: readString(record, 'status') ?? 'active',\n // `decorateCustomFields` on the list route returns the saved values as\n // `cf_<key>`, which is exactly what `CrudForm` looks an initial value up by.\n ...pickCustomFieldValues(record as Record<string, unknown>),\n }\n}\n\nexport default function TimeTrackingProjectEditPage({ params }: { params?: { id?: string } }) {\n const projectId = params?.id\n const t = useT()\n const router = useRouter()\n const scopeVersion = useOrganizationScopeVersion()\n\n const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [isNotFound, setIsNotFound] = React.useState(false)\n const [accessDenied, setAccessDenied] = React.useState(false)\n\n const fetchProject = React.useCallback(async (): Promise<ProjectRecord | null> => {\n const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(\n `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,\n )\n if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {\n throw new ProjectAccessDeniedError()\n }\n if (!call.ok) {\n throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))\n }\n const record = Array.isArray(call.result?.items) ? call.result.items[0] : null\n return record ?? null\n }, [projectId, t])\n\n /**\n * D-3: the change-currency action writes to the project directly, so it bumped\n * both the currency and `updated_at`. Reload the record \u2014 the form derives its\n * optimistic-lock version from `initialValues.updatedAt`, and a stale one would\n * fail the next save with a conflict this same user had just caused. `CrudForm`\n * merges refreshed initial values without discarding fields being edited.\n */\n const handleCurrencyChanged = React.useCallback(() => {\n if (!projectId) return\n void fetchProject()\n .then((record) => { if (record) setInitialValues(toValues(record)) })\n .catch(() => {\n // Best effort: the optimistic-lock conflict bar remains the backstop.\n })\n }, [fetchProject, projectId])\n\n const formSchema = React.useMemo(() => createProjectFormSchema(), [])\n const fields = React.useMemo(\n () => createProjectFormFields(t, { onCurrencyChanged: handleCurrencyChanged }),\n [t, handleCurrencyChanged],\n )\n const groups = React.useMemo(() => createProjectFormGroups(t), [t])\n\n // The organization switcher settles the scope just after mount, bumping the\n // scope version from 0 and reloading the project. A screen resolved before\n // that stays up until the answer arrives: falling back to the full-page\n // loader would unmount it and drop its local state \u2014 the form's unsaved\n // edits, or an access request the user has just sent. A switch between two\n // known scopes still goes through the loader, so no form outlives the scope\n // it was loaded for. The decision looks at the load this one replaces,\n // settled or not: a version-0 load cancelled by a quick second switch must\n // not let that switch through.\n const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number } | null>(null)\n React.useEffect(() => {\n if (!projectId) return\n let cancelled = false\n const replaced = lastLoadRef.current\n lastLoadRef.current = { projectId, scopeVersion }\n const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0\n async function load() {\n if (!keepScreen) setLoading(true)\n const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {\n if (cancelled) return\n setAccessDenied(outcome.accessDenied === true)\n setIsNotFound(outcome.isNotFound === true)\n setError(outcome.error ?? null)\n if (outcome.values) setInitialValues(outcome.values)\n setLoading(false)\n }\n try {\n const record = await fetchProject()\n if (!record) {\n settle({ isNotFound: true })\n return\n }\n settle({ values: toValues(record) })\n } catch (err) {\n if (err instanceof ProjectAccessDeniedError) {\n settle({ accessDenied: true })\n return\n }\n settle({\n error: err instanceof Error\n ? err.message\n : t('staff.timesheets.projects.errors.load', 'Failed to load project.'),\n })\n }\n }\n load()\n return () => { cancelled = true }\n }, [projectId, fetchProject, scopeVersion, t])\n\n if (loading) {\n return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>\n }\n\n // Screen 17 \u2014 the caller may not open this project. Same guard state the\n // detail page renders; it names neither the customer nor the project.\n if (accessDenied) {\n return <NoProjectAccess timeProjectId={projectId} />\n }\n\n if (isNotFound) {\n return (\n <Page>\n <PageBody>\n <RecordNotFoundState\n label={t('staff.timesheets.projects.errors.notFound', 'Project not found.')}\n backHref={LIST_HREF}\n backLabel={t('staff.timesheets.projects.actions.backToList', 'Back to projects')}\n />\n </PageBody>\n </Page>\n )\n }\n\n if (error || !initialValues) {\n return <Page><PageBody><ErrorMessage label={error ?? t('staff.timesheets.projects.errors.load', 'Failed to load project.')} /></PageBody></Page>\n }\n\n const detailHref = `/backend/staff/time-tracking/projects/${projectId}`\n\n return (\n <Page>\n <PageBody>\n <CrudForm<ProjectFormValues>\n title={t('staff.time_tracking.projects.form.editTitle', 'Edit project')}\n titleHeadingLevel={1}\n backHref={detailHref}\n cancelHref={detailHref}\n fields={fields}\n groups={groups}\n schema={formSchema}\n initialValues={initialValues}\n entityIds={[E.staff.staff_time_project]}\n injectionSpotId={extensionPoints.hosts.projectForm.spotId}\n submitLabel={t('staff.timesheets.projects.form.actions.save', 'Save')}\n onDelete={async () => {\n await deleteCrud('staff/timesheets/time-projects', projectId!, {\n errorMessage: t('staff.timesheets.projects.errors.delete', 'Failed to delete project.'),\n })\n flash(t('staff.timesheets.projects.messages.deleted', 'Project deleted.'), 'success')\n router.push(LIST_HREF)\n }}\n onSubmit={async (values) => {\n if (!values.name?.trim() || !values.code?.trim() || !values.customerId) {\n const fieldErrors: Record<string, string> = {}\n if (!values.name?.trim()) {\n fieldErrors.name = t('staff.time_tracking.projects.validation.nameRequired', 'A project name is required.')\n }\n if (!values.code?.trim()) {\n fieldErrors.code = t('staff.time_tracking.projects.validation.codeRequired', 'A project code is required.')\n }\n if (!values.customerId) {\n fieldErrors.customerId = t('staff.time_tracking.projects.validation.customerRequired', 'A customer is required.')\n }\n throw createCrudFormError(\n t('staff.time_tracking.projects.errors.required', 'Fill in the required fields.'),\n fieldErrors,\n )\n }\n\n const payload = buildProjectPayload({ ...values, id: projectId })\n\n await updateCrud('staff/timesheets/time-projects', payload, {\n errorMessage: t('staff.timesheets.projects.errors.save', 'Failed to save project.'),\n })\n\n flash(t('staff.timesheets.projects.messages.saved', 'Project saved.'), 'success')\n router.push(detailHref)\n }}\n versionHistory={{\n resourceKind: 'staff.timesheets.time_project',\n resourceId: projectId!,\n canUndoRedo: true,\n autoCheckAcl: true,\n }}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm } from '@open-mercato/ui/backend/CrudForm'\nimport { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { E } from '#generated/entities.ids.generated'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'\nimport { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'\nimport { useProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScope'\nimport { readResolvedProjectAccessScope, type ResolvedProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'\nimport {\n buildProjectPayload,\n createProjectFormFields,\n createProjectFormGroups,\n createProjectFormSchema,\n pickCustomFieldValues,\n type ProjectFormValues,\n} from '../../projectFormConfig'\n\nconst LIST_HREF = '/backend/staff/time-tracking/projects'\n\n/**\n * Mirrors `NO_PROJECT_ACCESS_REASON` from\n * `api/timesheets/time-projects/route.ts`. The route module is server-only, so\n * the literal is restated here rather than imported into the client bundle;\n * `__tests__/no-project-access-contract.test.ts` pins the two together.\n */\nconst NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/** Thrown by the loader so the caller can branch on the guard state. */\nclass ProjectAccessDeniedError extends Error {\n constructor(readonly resolvedScope: ResolvedProjectAccessScope | null) {\n super('[internal] no project access')\n this.name = 'ProjectAccessDeniedError'\n }\n}\n\ntype ProjectRecord = Record<string, unknown>\n\nfunction readString(record: ProjectRecord, ...keys: string[]): string | null {\n for (const key of keys) {\n const value = record[key]\n if (typeof value === 'string' && value.length > 0) return value\n if (typeof value === 'number') return String(value)\n }\n return null\n}\n\nfunction readSnapshot(record: ProjectRecord): Record<string, unknown> | null {\n const raw = record.customerSnapshot ?? record.customer_snapshot\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>\n return null\n}\n\nfunction toValues(record: ProjectRecord): ProjectFormValues {\n const snapshot = readSnapshot(record)\n const budgetKindRaw = readString(record, 'budgetKind', 'budget_kind') ?? 'none'\n const budgetKind: ProjectBudgetKind =\n budgetKindRaw === 'hours' || budgetKindRaw === 'amount' ? budgetKindRaw : 'none'\n const entryCount = typeof record.entryCount === 'number' ? record.entryCount : 0\n\n return {\n id: String(record.id ?? ''),\n updatedAt: readString(record, 'updatedAt', 'updated_at'),\n name: String(record.name ?? ''),\n code: String(record.code ?? ''),\n // D-10: an existing project keeps the code it was saved with \u2014 the name no\n // longer drives it.\n codeManual: true,\n customerId: readString(record, 'customerId', 'customer_id'),\n customerName: typeof snapshot?.name === 'string' ? snapshot.name : null,\n customerKind: typeof snapshot?.kind === 'string' ? snapshot.kind : null,\n customerTaxId: typeof snapshot?.taxId === 'string' ? snapshot.taxId : null,\n description: readString(record, 'description'),\n hourlyRate: readString(record, 'hourlyRate', 'hourly_rate'),\n currencyCode: readString(record, 'currencyCode', 'currency_code'),\n currencyLocked: record.currencyLocked === true,\n entryCount,\n billableByDefault: (record.billableByDefault ?? record.billable_by_default) !== false,\n budgetEnabled: budgetKind !== 'none',\n budgetKind: budgetKind === 'none' ? 'hours' : budgetKind,\n budgetValue: readString(record, 'budgetValue', 'budget_value'),\n budgetWarnAtPercent:\n readString(record, 'budgetWarnAtPercent', 'budget_warn_at_percent') ?? String(DEFAULT_BUDGET_WARN_AT_PERCENT),\n defaultAssigneeStaffMemberId: readString(record, 'defaultAssigneeStaffMemberId', 'default_assignee_staff_member_id') ?? null,\n projectType: readString(record, 'projectType', 'project_type'),\n color: readString(record, 'color'),\n startDate: readString(record, 'startDate', 'start_date'),\n costCenter: readString(record, 'costCenter', 'cost_center'),\n status: readString(record, 'status') ?? 'active',\n // `decorateCustomFields` on the list route returns the saved values as\n // `cf_<key>`, which is exactly what `CrudForm` looks an initial value up by.\n ...pickCustomFieldValues(record as Record<string, unknown>),\n }\n}\n\nexport default function TimeTrackingProjectEditPage({ params }: { params?: { id?: string } }) {\n const projectId = params?.id\n const t = useT()\n const router = useRouter()\n const scopeVersion = useOrganizationScopeVersion()\n const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)\n\n const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [isNotFound, setIsNotFound] = React.useState(false)\n const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)\n const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)\n const accessDenied = accessDeniedScopeKey === projectAccessScopeKey\n\n const fetchProject = React.useCallback(async (): Promise<{ record: ProjectRecord | null; scope: ResolvedProjectAccessScope | null }> => {\n const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(\n `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,\n )\n if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {\n throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response))\n }\n if (!call.ok) {\n throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))\n }\n const record = Array.isArray(call.result?.items) ? call.result.items[0] : null\n return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) }\n }, [projectId, t])\n\n /**\n * D-3: the change-currency action writes to the project directly, so it bumped\n * both the currency and `updated_at`. Reload the record \u2014 the form derives its\n * optimistic-lock version from `initialValues.updatedAt`, and a stale one would\n * fail the next save with a conflict this same user had just caused. `CrudForm`\n * merges refreshed initial values without discarding fields being edited.\n */\n const handleCurrencyChanged = React.useCallback(() => {\n if (!projectId) return\n void fetchProject()\n .then(({ record }) => { if (record) setInitialValues(toValues(record)) })\n .catch(() => {\n // Best effort: the optimistic-lock conflict bar remains the backstop.\n })\n }, [fetchProject, projectId])\n\n const formSchema = React.useMemo(() => createProjectFormSchema(), [])\n const fields = React.useMemo(\n () => createProjectFormFields(t, { onCurrencyChanged: handleCurrencyChanged }),\n [t, handleCurrencyChanged],\n )\n const groups = React.useMemo(() => createProjectFormGroups(t), [t])\n\n // The organization switcher settles the scope just after mount, bumping the\n // scope version from 0 and reloading the project. A screen resolved before\n // that stays up until the answer arrives: falling back to the full-page\n // loader would unmount it and drop its local state \u2014 the form's unsaved\n // edits, or an access request the user has just sent. A switch between two\n // known scopes still goes through the loader, so no form outlives the scope\n // it was loaded for. The decision looks at the load this one replaces,\n // settled or not: a version-0 load cancelled by a quick second switch must\n // not let that switch through.\n const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number; scopeKey: string } | null>(null)\n React.useEffect(() => {\n if (!projectId) return\n let cancelled = false\n const replaced = lastLoadRef.current\n lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey }\n const keepScreen = replaced !== null\n && replaced.projectId === projectId\n && replaced.scopeVersion === 0\n && replaced.scopeKey === projectAccessScopeKey\n async function load() {\n if (!keepScreen) setLoading(true)\n const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {\n if (cancelled) return\n setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null)\n setIsNotFound(outcome.isNotFound === true)\n setError(outcome.error ?? null)\n if (outcome.values) setInitialValues(outcome.values)\n setLoadedScopeKey(projectAccessScopeKey)\n setLoading(false)\n }\n try {\n const { record, scope } = await fetchProject()\n if (!cancelled) resolveScope(scope)\n if (!record) {\n settle({ isNotFound: true })\n return\n }\n settle({ values: toValues(record) })\n } catch (err) {\n if (err instanceof ProjectAccessDeniedError) {\n if (!cancelled) resolveScope(err.resolvedScope)\n settle({ accessDenied: true })\n return\n }\n settle({\n error: err instanceof Error\n ? err.message\n : t('staff.timesheets.projects.errors.load', 'Failed to load project.'),\n })\n }\n }\n load()\n return () => { cancelled = true }\n }, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope])\n\n if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {\n return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>\n }\n\n // Screen 17 \u2014 the caller may not open this project. Same guard state the\n // detail page renders; it names neither the customer nor the project.\n if (accessDenied) {\n return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />\n }\n\n if (isNotFound) {\n return (\n <Page>\n <PageBody>\n <RecordNotFoundState\n label={t('staff.timesheets.projects.errors.notFound', 'Project not found.')}\n backHref={LIST_HREF}\n backLabel={t('staff.timesheets.projects.actions.backToList', 'Back to projects')}\n />\n </PageBody>\n </Page>\n )\n }\n\n if (error || !initialValues) {\n return <Page><PageBody><ErrorMessage label={error ?? t('staff.timesheets.projects.errors.load', 'Failed to load project.')} /></PageBody></Page>\n }\n\n const detailHref = `/backend/staff/time-tracking/projects/${projectId}`\n\n return (\n <Page>\n <PageBody>\n <CrudForm<ProjectFormValues>\n title={t('staff.time_tracking.projects.form.editTitle', 'Edit project')}\n titleHeadingLevel={1}\n backHref={detailHref}\n cancelHref={detailHref}\n fields={fields}\n groups={groups}\n schema={formSchema}\n initialValues={initialValues}\n entityIds={[E.staff.staff_time_project]}\n injectionSpotId={extensionPoints.hosts.projectForm.spotId}\n submitLabel={t('staff.timesheets.projects.form.actions.save', 'Save')}\n onDelete={async () => {\n await deleteCrud('staff/timesheets/time-projects', projectId!, {\n errorMessage: t('staff.timesheets.projects.errors.delete', 'Failed to delete project.'),\n })\n flash(t('staff.timesheets.projects.messages.deleted', 'Project deleted.'), 'success')\n router.push(LIST_HREF)\n }}\n onSubmit={async (values) => {\n if (!values.name?.trim() || !values.code?.trim() || !values.customerId) {\n const fieldErrors: Record<string, string> = {}\n if (!values.name?.trim()) {\n fieldErrors.name = t('staff.time_tracking.projects.validation.nameRequired', 'A project name is required.')\n }\n if (!values.code?.trim()) {\n fieldErrors.code = t('staff.time_tracking.projects.validation.codeRequired', 'A project code is required.')\n }\n if (!values.customerId) {\n fieldErrors.customerId = t('staff.time_tracking.projects.validation.customerRequired', 'A customer is required.')\n }\n throw createCrudFormError(\n t('staff.time_tracking.projects.errors.required', 'Fill in the required fields.'),\n fieldErrors,\n )\n }\n\n const payload = buildProjectPayload({ ...values, id: projectId })\n\n await updateCrud('staff/timesheets/time-projects', payload, {\n errorMessage: t('staff.timesheets.projects.errors.save', 'Failed to save project.'),\n })\n\n flash(t('staff.timesheets.projects.messages.saved', 'Project saved.'), 'success')\n router.push(detailHref)\n }}\n versionHistory={{\n resourceKind: 'staff.timesheets.time_project',\n resourceId: projectId!,\n canUndoRedo: true,\n autoCheckAcl: true,\n }}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|