@open-mercato/core 0.10.1-develop.7836.1.0ef0f453c6 → 0.10.1-develop.7847.1.7d83dc5794
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/api_keys/api/keys/route.js +15 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +16 -1
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/users/page.js +13 -2
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/catalog/commands/products.js +4 -2
- package/dist/modules/catalog/commands/products.js.map +2 -2
- 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/directory/api/organization-branding/route.js +6 -9
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -3
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.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/dist/modules/wms/api/listEnrichers.js +3 -1
- package/dist/modules/wms/api/listEnrichers.js.map +2 -2
- package/dist/modules/wms/index.js +1 -1
- package/dist/modules/wms/index.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +15 -1
- package/src/modules/api_keys/i18n/de.json +1 -0
- package/src/modules/api_keys/i18n/en.json +1 -0
- package/src/modules/api_keys/i18n/es.json +1 -0
- package/src/modules/api_keys/i18n/ko.json +1 -0
- package/src/modules/api_keys/i18n/pl.json +1 -0
- package/src/modules/auth/api/users/route.ts +18 -1
- package/src/modules/auth/backend/users/page.tsx +14 -2
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/ko.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/commands/products.ts +4 -1
- 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/directory/api/organization-branding/route.ts +5 -8
- package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
- package/src/modules/eudr/index.ts +13 -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
- package/src/modules/wms/api/listEnrichers.ts +5 -2
- package/src/modules/wms/index.ts +1 -1
|
@@ -15,13 +15,15 @@ import { Button } from "@open-mercato/ui/primitives/button";
|
|
|
15
15
|
import { Input } from "@open-mercato/ui/primitives/input";
|
|
16
16
|
import { SwitchField } from "@open-mercato/ui/primitives/switch-field";
|
|
17
17
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
18
|
-
import { useOrganizationScopeDetail } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
18
|
+
import { useOrganizationScopeDetail, useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
19
|
+
import { getCurrentOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/organizationEvents";
|
|
19
20
|
const BRANDING_API = "/api/directory/organization-branding";
|
|
20
21
|
const BRANDING_ENTITY_ID = "directory.organization";
|
|
21
22
|
function OrganizationBrandingPage() {
|
|
22
23
|
const t = useT();
|
|
23
24
|
const queryClient = useQueryClient();
|
|
24
25
|
const { organizationId, tenantId } = useOrganizationScopeDetail();
|
|
26
|
+
const scopeVersion = useOrganizationScopeVersion();
|
|
25
27
|
const [logoUrl, setLogoUrl] = React.useState("");
|
|
26
28
|
const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false);
|
|
27
29
|
const [selectedFile, setSelectedFile] = React.useState(null);
|
|
@@ -45,7 +47,8 @@ function OrganizationBrandingPage() {
|
|
|
45
47
|
setLogoUrl(data?.logoUrl ?? "");
|
|
46
48
|
setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false);
|
|
47
49
|
setSelectedFile(null);
|
|
48
|
-
|
|
50
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
51
|
+
}, [scopeVersion, organizationId, tenantId, data?.organizationId, data?.logoPreserveAspectRatio, data?.logoUrl]);
|
|
49
52
|
React.useEffect(() => {
|
|
50
53
|
if (!selectedFile || typeof URL === "undefined") {
|
|
51
54
|
setFilePreviewUrl(null);
|
|
@@ -81,7 +84,13 @@ function OrganizationBrandingPage() {
|
|
|
81
84
|
try {
|
|
82
85
|
await runMutation({
|
|
83
86
|
operation: async () => {
|
|
87
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) {
|
|
88
|
+
throw new Error("[internal] Organization scope changed during branding save");
|
|
89
|
+
}
|
|
84
90
|
const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null;
|
|
91
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) {
|
|
92
|
+
throw new Error("[internal] Organization scope changed during branding save");
|
|
93
|
+
}
|
|
85
94
|
const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim();
|
|
86
95
|
const response = await withScopedApiRequestHeaders(
|
|
87
96
|
buildOptimisticLockHeader(data.updatedAt),
|
|
@@ -112,12 +121,15 @@ function OrganizationBrandingPage() {
|
|
|
112
121
|
hasUpload: shouldUpload
|
|
113
122
|
}
|
|
114
123
|
});
|
|
124
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) return;
|
|
115
125
|
await queryClient.invalidateQueries({ queryKey: ["directory-organization-branding"] });
|
|
126
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) return;
|
|
116
127
|
window.dispatchEvent(new Event("om:refresh-sidebar"));
|
|
117
128
|
setSelectedFile(null);
|
|
118
129
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
119
130
|
flash(t("directory.branding.flash.saved", "Organization branding updated"), "success");
|
|
120
131
|
} catch (err) {
|
|
132
|
+
if (getCurrentOrganizationScopeVersion() !== scopeVersion) return;
|
|
121
133
|
if (surfaceRecordConflict(err, t)) return;
|
|
122
134
|
const fallback = t("directory.branding.errors.save", "Failed to update organization branding");
|
|
123
135
|
const message = err instanceof Error ? err.message : fallback;
|
|
@@ -125,7 +137,7 @@ function OrganizationBrandingPage() {
|
|
|
125
137
|
} finally {
|
|
126
138
|
setSaving(false);
|
|
127
139
|
}
|
|
128
|
-
}, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo]);
|
|
140
|
+
}, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, scopeVersion, selectedFile, t, uploadLogo]);
|
|
129
141
|
const handleSubmit = React.useCallback((event) => {
|
|
130
142
|
event.preventDefault();
|
|
131
143
|
void saveBranding();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/directory/backend/directory/branding/page.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'\nimport { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'\nimport { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { SwitchField } from '@open-mercato/ui/primitives/switch-field'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\n\ntype BrandingPayload = {\n organizationId: string\n organizationName: string\n tenantId: string\n logoUrl: string | null\n logoPreserveAspectRatio: boolean\n updatedAt: string | null\n}\n\ntype UploadPayload = {\n ok: true\n item: {\n id: string\n url: string\n thumbnailUrl?: string\n }\n}\n\nconst BRANDING_API = '/api/directory/organization-branding'\nconst BRANDING_ENTITY_ID = 'directory.organization'\n\nexport default function OrganizationBrandingPage() {\n const t = useT()\n const queryClient = useQueryClient()\n const { organizationId, tenantId } = useOrganizationScopeDetail()\n const [logoUrl, setLogoUrl] = React.useState('')\n const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false)\n const [selectedFile, setSelectedFile] = React.useState<File | null>(null)\n const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const { runMutation } = useGuardedMutation({\n contextId: 'directory.organization-branding',\n blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),\n })\n\n const { data, isLoading, error } = useQuery<BrandingPayload>({\n queryKey: ['directory-organization-branding', organizationId, tenantId],\n enabled: Boolean(organizationId),\n queryFn: () => readApiResultOrThrow<BrandingPayload>(\n BRANDING_API,\n undefined,\n { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },\n ),\n })\n\n React.useEffect(() => {\n setLogoUrl(data?.logoUrl ?? '')\n setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false)\n setSelectedFile(null)\n }, [data?.logoPreserveAspectRatio, data?.logoUrl, organizationId, tenantId])\n\n React.useEffect(() => {\n if (!selectedFile || typeof URL === 'undefined') {\n setFilePreviewUrl(null)\n return\n }\n const nextPreviewUrl = URL.createObjectURL(selectedFile)\n setFilePreviewUrl(nextPreviewUrl)\n return () => URL.revokeObjectURL(nextPreviewUrl)\n }, [selectedFile])\n\n const currentPreviewUrl = filePreviewUrl ?? logoUrl\n\n const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {\n if (!selectedFile) return null\n const form = new FormData()\n form.set('entityId', BRANDING_ENTITY_ID)\n form.set('recordId', organizationId)\n form.set('file', selectedFile)\n form.set('tags', JSON.stringify(['organization-logo']))\n\n const upload = await readApiResultOrThrow<UploadPayload>(\n '/api/attachments',\n {\n method: 'POST',\n body: form,\n },\n { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },\n )\n return upload?.item.url ?? upload?.item.thumbnailUrl ?? null\n }, [selectedFile, t])\n\n const saveBranding = React.useCallback(async (\n nextLogoUrl?: string,\n options?: { skipUpload?: boolean; logoPreserveAspectRatio?: boolean },\n ) => {\n if (!data) return\n const shouldUpload = Boolean(selectedFile && !options?.skipUpload)\n const resolvedLogoPreserveAspectRatio = options?.logoPreserveAspectRatio ?? logoPreserveAspectRatio\n setSaving(true)\n try {\n await runMutation({\n operation: async () => {\n const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null\n const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data.updatedAt),\n () => apiCallOrThrow<BrandingPayload>(\n BRANDING_API,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n logoUrl: resolvedLogoUrl || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n }),\n },\n { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },\n ),\n )\n return response.result\n },\n context: {\n entityId: BRANDING_ENTITY_ID,\n recordId: data.organizationId,\n operation: 'update-branding',\n },\n mutationPayload: {\n organizationId: data.organizationId,\n logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n hasUpload: shouldUpload,\n },\n })\n await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')\n } catch (err: unknown) {\n if (surfaceRecordConflict(err, t)) return\n const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n } finally {\n setSaving(false)\n }\n }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])\n\n const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n void saveBranding()\n }, [saveBranding])\n\n if (!organizationId) {\n return (\n <Page>\n <PageHeader title={t('directory.branding.title', 'Organization branding')} />\n <PageBody>\n <Alert status=\"information\">\n <AlertDescription>\n {t(\n 'directory.branding.organizationRequired',\n 'Branding is managed for one organization at a time. Select a single organization to continue.',\n )}\n </AlertDescription>\n </Alert>\n </PageBody>\n </Page>\n )\n }\n\n if (isLoading) {\n return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />\n }\n\n if (error || !data) {\n return (\n <ErrorMessage\n label={t('directory.branding.errors.load', 'Failed to load organization branding')}\n description={error instanceof Error ? error.message : undefined}\n />\n )\n }\n\n return (\n <Page>\n <PageHeader\n title={t('directory.branding.title', 'Organization branding')}\n description={t(\n 'directory.branding.description',\n 'Set the logo used in the backend sidebar for the currently selected organization.',\n )}\n />\n <PageBody>\n <form className=\"space-y-5\" onSubmit={handleSubmit}>\n <div className=\"grid gap-5 lg:grid-cols-[260px_1fr]\">\n <div className=\"space-y-3\">\n <div className=\"flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30\">\n {currentPreviewUrl ? (\n <img\n src={currentPreviewUrl}\n alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}\n className={`h-full w-full ${logoPreserveAspectRatio ? 'object-contain' : 'rounded-full object-cover'}`}\n />\n ) : (\n <ImagePlus className=\"size-10 text-muted-foreground\" aria-hidden />\n )}\n </div>\n <p className=\"text-sm font-medium text-foreground\">{data.organizationName}</p>\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.currentScope', 'Current organization')}\n </p>\n </div>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-file\" className=\"text-sm font-medium\">\n {t('directory.branding.file.label', 'Upload logo')}\n </label>\n <Input\n ref={fileInputRef}\n id=\"organization-logo-file\"\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n onChange={(event) => {\n const file = event.currentTarget.files?.[0]\n if (!file) return\n setSelectedFile(file)\n }}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.file.hint', 'PNG, JPG, or WebP works best. Uploaded files are stored as organization attachments.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-url\" className=\"text-sm font-medium\">\n {t('directory.branding.url.label', 'Logo URL')}\n </label>\n <Input\n id=\"organization-logo-url\"\n value={logoUrl}\n onChange={(event) => setLogoUrl(event.currentTarget.value)}\n placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}\n </p>\n </div>\n\n <SwitchField\n id=\"organization-logo-preserve-aspect-ratio\"\n label={t('directory.branding.aspectRatio.label', 'Keep the aspect ratio')}\n description={t(\n 'directory.branding.aspectRatio.description',\n 'When disabled, the sidebar crops the logo into the standard icon shape.',\n )}\n checked={logoPreserveAspectRatio}\n disabled={saving}\n onCheckedChange={setLogoPreserveAspectRatio}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"submit\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 size-4 animate-spin\" aria-hidden /> : <Save className=\"mr-2 size-4\" aria-hidden />}\n {t('directory.branding.actions.save', 'Save branding')}\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={saving}\n onClick={() => {\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n setLogoUrl('')\n setLogoPreserveAspectRatio(false)\n void saveBranding('', { skipUpload: true, logoPreserveAspectRatio: false })\n }}\n >\n <RotateCcw className=\"mr-2 size-4\" aria-hidden />\n {t('directory.branding.actions.reset', 'Use default logo')}\n </Button>\n </div>\n </div>\n </div>\n </form>\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'\nimport { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'\nimport { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { SwitchField } from '@open-mercato/ui/primitives/switch-field'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeDetail, useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { getCurrentOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/organizationEvents'\n\ntype BrandingPayload = {\n organizationId: string\n organizationName: string\n tenantId: string\n logoUrl: string | null\n logoPreserveAspectRatio: boolean\n updatedAt: string | null\n}\n\ntype UploadPayload = {\n ok: true\n item: {\n id: string\n url: string\n thumbnailUrl?: string\n }\n}\n\nconst BRANDING_API = '/api/directory/organization-branding'\nconst BRANDING_ENTITY_ID = 'directory.organization'\n\nexport default function OrganizationBrandingPage() {\n const t = useT()\n const queryClient = useQueryClient()\n const { organizationId, tenantId } = useOrganizationScopeDetail()\n const scopeVersion = useOrganizationScopeVersion()\n const [logoUrl, setLogoUrl] = React.useState('')\n const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false)\n const [selectedFile, setSelectedFile] = React.useState<File | null>(null)\n const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const { runMutation } = useGuardedMutation({\n contextId: 'directory.organization-branding',\n blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),\n })\n\n const { data, isLoading, error } = useQuery<BrandingPayload>({\n queryKey: ['directory-organization-branding', organizationId, tenantId],\n enabled: Boolean(organizationId),\n queryFn: () => readApiResultOrThrow<BrandingPayload>(\n BRANDING_API,\n undefined,\n { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },\n ),\n })\n\n React.useEffect(() => {\n setLogoUrl(data?.logoUrl ?? '')\n setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false)\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n }, [scopeVersion, organizationId, tenantId, data?.organizationId, data?.logoPreserveAspectRatio, data?.logoUrl])\n\n React.useEffect(() => {\n if (!selectedFile || typeof URL === 'undefined') {\n setFilePreviewUrl(null)\n return\n }\n const nextPreviewUrl = URL.createObjectURL(selectedFile)\n setFilePreviewUrl(nextPreviewUrl)\n return () => URL.revokeObjectURL(nextPreviewUrl)\n }, [selectedFile])\n\n const currentPreviewUrl = filePreviewUrl ?? logoUrl\n\n const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {\n if (!selectedFile) return null\n const form = new FormData()\n form.set('entityId', BRANDING_ENTITY_ID)\n form.set('recordId', organizationId)\n form.set('file', selectedFile)\n form.set('tags', JSON.stringify(['organization-logo']))\n\n const upload = await readApiResultOrThrow<UploadPayload>(\n '/api/attachments',\n {\n method: 'POST',\n body: form,\n },\n { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },\n )\n return upload?.item.url ?? upload?.item.thumbnailUrl ?? null\n }, [selectedFile, t])\n\n const saveBranding = React.useCallback(async (\n nextLogoUrl?: string,\n options?: { skipUpload?: boolean; logoPreserveAspectRatio?: boolean },\n ) => {\n if (!data) return\n const shouldUpload = Boolean(selectedFile && !options?.skipUpload)\n const resolvedLogoPreserveAspectRatio = options?.logoPreserveAspectRatio ?? logoPreserveAspectRatio\n setSaving(true)\n try {\n await runMutation({\n operation: async () => {\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) {\n throw new Error('[internal] Organization scope changed during branding save')\n }\n const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) {\n throw new Error('[internal] Organization scope changed during branding save')\n }\n const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data.updatedAt),\n () => apiCallOrThrow<BrandingPayload>(\n BRANDING_API,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n logoUrl: resolvedLogoUrl || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n }),\n },\n { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },\n ),\n )\n return response.result\n },\n context: {\n entityId: BRANDING_ENTITY_ID,\n recordId: data.organizationId,\n operation: 'update-branding',\n },\n mutationPayload: {\n organizationId: data.organizationId,\n logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n hasUpload: shouldUpload,\n },\n })\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) return\n await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) return\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')\n } catch (err: unknown) {\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) return\n if (surfaceRecordConflict(err, t)) return\n const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n } finally {\n setSaving(false)\n }\n }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, scopeVersion, selectedFile, t, uploadLogo])\n\n const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n void saveBranding()\n }, [saveBranding])\n\n if (!organizationId) {\n return (\n <Page>\n <PageHeader title={t('directory.branding.title', 'Organization branding')} />\n <PageBody>\n <Alert status=\"information\">\n <AlertDescription>\n {t(\n 'directory.branding.organizationRequired',\n 'Branding is managed for one organization at a time. Select a single organization to continue.',\n )}\n </AlertDescription>\n </Alert>\n </PageBody>\n </Page>\n )\n }\n\n if (isLoading) {\n return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />\n }\n\n if (error || !data) {\n return (\n <ErrorMessage\n label={t('directory.branding.errors.load', 'Failed to load organization branding')}\n description={error instanceof Error ? error.message : undefined}\n />\n )\n }\n\n return (\n <Page>\n <PageHeader\n title={t('directory.branding.title', 'Organization branding')}\n description={t(\n 'directory.branding.description',\n 'Set the logo used in the backend sidebar for the currently selected organization.',\n )}\n />\n <PageBody>\n <form className=\"space-y-5\" onSubmit={handleSubmit}>\n <div className=\"grid gap-5 lg:grid-cols-[260px_1fr]\">\n <div className=\"space-y-3\">\n <div className=\"flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30\">\n {currentPreviewUrl ? (\n <img\n src={currentPreviewUrl}\n alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}\n className={`h-full w-full ${logoPreserveAspectRatio ? 'object-contain' : 'rounded-full object-cover'}`}\n />\n ) : (\n <ImagePlus className=\"size-10 text-muted-foreground\" aria-hidden />\n )}\n </div>\n <p className=\"text-sm font-medium text-foreground\">{data.organizationName}</p>\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.currentScope', 'Current organization')}\n </p>\n </div>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-file\" className=\"text-sm font-medium\">\n {t('directory.branding.file.label', 'Upload logo')}\n </label>\n <Input\n ref={fileInputRef}\n id=\"organization-logo-file\"\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n onChange={(event) => {\n const file = event.currentTarget.files?.[0]\n if (!file) return\n setSelectedFile(file)\n }}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.file.hint', 'PNG, JPG, or WebP works best. Uploaded files are stored as organization attachments.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-url\" className=\"text-sm font-medium\">\n {t('directory.branding.url.label', 'Logo URL')}\n </label>\n <Input\n id=\"organization-logo-url\"\n value={logoUrl}\n onChange={(event) => setLogoUrl(event.currentTarget.value)}\n placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}\n </p>\n </div>\n\n <SwitchField\n id=\"organization-logo-preserve-aspect-ratio\"\n label={t('directory.branding.aspectRatio.label', 'Keep the aspect ratio')}\n description={t(\n 'directory.branding.aspectRatio.description',\n 'When disabled, the sidebar crops the logo into the standard icon shape.',\n )}\n checked={logoPreserveAspectRatio}\n disabled={saving}\n onCheckedChange={setLogoPreserveAspectRatio}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"submit\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 size-4 animate-spin\" aria-hidden /> : <Save className=\"mr-2 size-4\" aria-hidden />}\n {t('directory.branding.actions.save', 'Save branding')}\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={saving}\n onClick={() => {\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n setLogoUrl('')\n setLogoPreserveAspectRatio(false)\n void saveBranding('', { skipUpload: true, logoPreserveAspectRatio: false })\n }}\n >\n <RotateCcw className=\"mr-2 size-4\" aria-hidden />\n {t('directory.branding.actions.reset', 'Use default logo')}\n </Button>\n </div>\n </div>\n </div>\n </form>\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["organizationId"]
|
|
7
7
|
}
|
|
@@ -5,6 +5,19 @@ const metadata = {
|
|
|
5
5
|
description: "EU Deforestation Regulation compliance: product commodity mappings, supplier origin evidence, due diligence statements.",
|
|
6
6
|
author: "Open Mercato Team",
|
|
7
7
|
license: "MIT",
|
|
8
|
+
/**
|
|
9
|
+
* `catalog` is a HARD dependency, and saying so is the fix.
|
|
10
|
+
*
|
|
11
|
+
* `data/enrichers.ts` reads `E.catalog.catalog_product` at module scope, so with `catalog` disabled
|
|
12
|
+
* `E.catalog` is undefined and the read throws while the enricher registry is being imported — which takes
|
|
13
|
+
* `/api/auth/login` with it. An installation shaped as a CRM rather than a shop could not reach its own login
|
|
14
|
+
* screen, and the error named a property rather than a module.
|
|
15
|
+
*
|
|
16
|
+
* Declared rather than defended with optional chaining. This module exists to map PRODUCTS to deforestation
|
|
17
|
+
* compliance evidence, so without a catalogue it has nothing to work on at all — the honest answer is that it
|
|
18
|
+
* cannot be enabled, which the generator now says before anybody starts the app rather than after.
|
|
19
|
+
*/
|
|
20
|
+
requires: ["catalog"],
|
|
8
21
|
ejectable: true
|
|
9
22
|
};
|
|
10
23
|
import { features } from "./acl.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/eudr/index.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ModuleInfo } from '@open-mercato/shared/modules/registry'\n\nexport const metadata: ModuleInfo = {\n name: 'eudr',\n title: 'EUDR Compliance',\n version: '0.1.0',\n description: 'EU Deforestation Regulation compliance: product commodity mappings, supplier origin evidence, due diligence statements.',\n author: 'Open Mercato Team',\n license: 'MIT',\n ejectable: true,\n}\n\nexport { features } from './acl'\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,WAAuB;AAAA,EAClC,MAAM;AAAA,EACN,OAAO;AAAA,EACP,SAAS;AAAA,EACT,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,SAAS;AAAA,
|
|
4
|
+
"sourcesContent": ["import type { ModuleInfo } from '@open-mercato/shared/modules/registry'\n\nexport const metadata: ModuleInfo = {\n name: 'eudr',\n title: 'EUDR Compliance',\n version: '0.1.0',\n description: 'EU Deforestation Regulation compliance: product commodity mappings, supplier origin evidence, due diligence statements.',\n author: 'Open Mercato Team',\n license: 'MIT',\n /**\n * `catalog` is a HARD dependency, and saying so is the fix.\n *\n * `data/enrichers.ts` reads `E.catalog.catalog_product` at module scope, so with `catalog` disabled\n * `E.catalog` is undefined and the read throws while the enricher registry is being imported \u2014 which takes\n * `/api/auth/login` with it. An installation shaped as a CRM rather than a shop could not reach its own login\n * screen, and the error named a property rather than a module.\n *\n * Declared rather than defended with optional chaining. This module exists to map PRODUCTS to deforestation\n * compliance evidence, so without a catalogue it has nothing to work on at all \u2014 the honest answer is that it\n * cannot be enabled, which the generator now says before anybody starts the app rather than after.\n */\n requires: ['catalog'],\n ejectable: true,\n}\n\nexport { features } from './acl'\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,WAAuB;AAAA,EAClC,MAAM;AAAA,EACN,OAAO;AAAA,EACP,SAAS;AAAA,EACT,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaT,UAAU,CAAC,SAAS;AAAA,EACpB,WAAW;AACb;AAEA,SAAS,gBAAgB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
4
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
5
|
+
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
5
6
|
import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
6
7
|
import { runRouteMutationGuards } from "@open-mercato/shared/lib/crud/route-mutation-guard";
|
|
7
8
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
@@ -39,7 +40,8 @@ async function resolveAccessRequestContext(req) {
|
|
|
39
40
|
if (!requesterUserId) {
|
|
40
41
|
throw new CrudHttpError(401, { error: translate("staff.errors.unauthorized", "Unauthorized") });
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
44
|
+
if (!scope.tenantId || !scope.selectedId || scope.selectionRejected === true) {
|
|
43
45
|
throw new CrudHttpError(400, {
|
|
44
46
|
error: translate("staff.errors.missingScope", "Missing tenant or organization scope.")
|
|
45
47
|
});
|
|
@@ -47,8 +49,8 @@ async function resolveAccessRequestContext(req) {
|
|
|
47
49
|
return {
|
|
48
50
|
container,
|
|
49
51
|
em: container.resolve("em").fork(),
|
|
50
|
-
tenantId:
|
|
51
|
-
organizationId:
|
|
52
|
+
tenantId: scope.tenantId,
|
|
53
|
+
organizationId: scope.selectedId,
|
|
52
54
|
requesterUserId,
|
|
53
55
|
requesterEmail: typeof auth.email === "string" && auth.email.trim().length > 0 ? auth.email : null,
|
|
54
56
|
translate
|
|
@@ -101,10 +103,14 @@ async function resolveRequesterName(context) {
|
|
|
101
103
|
async function POST(req) {
|
|
102
104
|
try {
|
|
103
105
|
const context = await resolveAccessRequestContext(req);
|
|
104
|
-
const
|
|
106
|
+
const featureAccess = await resolveFeatureAccess(context.container, context.requesterUserId, [VIEW_FEATURE], {
|
|
105
107
|
tenantId: context.tenantId,
|
|
106
108
|
organizationId: context.organizationId
|
|
107
|
-
})
|
|
109
|
+
});
|
|
110
|
+
if (!featureAccess.allowed) {
|
|
111
|
+
throw new CrudHttpError(403, { error: context.translate("staff.errors.forbidden", "Forbidden") });
|
|
112
|
+
}
|
|
113
|
+
const grantedFeatures = featureAccess.grantedFeatures;
|
|
108
114
|
const interceptors = await runTimesheetInterceptors({
|
|
109
115
|
request: req,
|
|
110
116
|
method: "POST",
|
|
@@ -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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,qBAAmC;AAC5C,SAAS,kCAAkC;AAC3C,SAAS,6BAA6B,yCAA0E;AAChH,SAAS,2BAA2B;AACpC,SAAS,qBAAqB,+BAA+B;AAC7D,SAAS,yBAAyB;AAClC,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,yCAAyC;AAClD,SAAS,oBAAoB,qBAAqB;AAClD,SAAS,oBAAoB;AAE7B,SAAS,kBAAkB,wBAAwB,uBAAuB;AAC1E,SAAS,8BAA8B,oCAAoC;AAC3E,SAAS,4BAA4B;AACrC,SAAS,yBAAyB,4BAAgD;AAClF,SAAS,gCAAgC;AACzC,SAAS,wBAAwB;AACjC,SAAS,oBAAoB,wBAAwB;AACrD,SAAS,wBAAwB,+BAA+B,+BAA+B;AAC/F,SAAS,SAAS;AAElB,MAAM,SAAS,aAAa,OAAO,EAAE,MAAM,EAAE,WAAW,+BAA+B,CAAC;AAExF,MAAM,IAAI;AAAA,EACR,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,MAAM;AAAA,EACN,MAAM;AAAA,EACN,aAAa;AAAA,EACb,cAAc;AAAA,EACd,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,eAAe;AAAA,EACf,qBAAqB;AAAA,EACrB,aAAa;AAAA,EACb,cAAc;AAAA,EACd,wBAAwB;AAAA,EACxB,kCAAkC;AAAA,EAClC,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AACd;AAEA,MAAM,gBAAgB;AAAA,EACpB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gCAAgC,EAAE;AAAA,EAC9E,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,kCAAkC,EAAE;AAAA,EACjF,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,kCAAkC,EAAE;AAAA,EAChF,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,kCAAkC,EAAE;AACrF;AAEO,MAAM,WAAW;AAExB,MAAM,gBAAgB,EAAE,OAAO,CAAC,CAAC,EAAE,YAAY;AAE/C,MAAM,aAAa,EAChB,OAAO;AAAA,EACN,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,GAAG,EAAE,OAAO,EAAE,SAAS;AAAA,EACvB,KAAK,EAAE,OAAO,EAAE,SAAS;AAAA,EACzB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,YAAY,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACvC,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,SAAS,EAAE,KAAK,CAAC,OAAO,MAAM,CAAC,EAAE,SAAS;AAC5C,CAAC,EACA,YAAY;AAeR,MAAM,2BAA2B;AAMxC,MAAM,wBAAwB;AAE9B,MAAM,gBAA+B,EAAE,cAAc,OAAO,YAAY,CAAC,GAAG,eAAe,KAAK;AAuBhG,MAAM,kBAAkB,oBAAI,QAAyC;AAErE,eAAe,uBACb,WACA,QACA,OACmB;AACnB,MAAI;AACF,UAAM,OAAO,UAAU,QAAQ,aAAa;AAC5C,QAAI,CAAC,MAAM,mBAAoB,QAAO,CAAC;AACvC,WAAQ,MAAM,KAAK,mBAAmB,QAAQ,KAAK,KAAM,CAAC;AAAA,EAC5D,QAAQ;AAGN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,eAAe,oBACb,WACA,QACA,OACkB;AAClB,MAAI;AACF,UAAM,OAAO,UAAU,QAAQ,aAAa;AAC5C,QAAI,CAAC,MAAM,mBAAoB,QAAO;AACtC,WAAQ,MAAM,KAAK,mBAAmB,QAAQ,CAAC,uBAAuB,GAAG,KAAK,MAAO;AAAA,EACvF,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,2BACb,WACA,UACwB;AACxB,MAAI;AACF,UAAM,gBAAgB,UAAU,QAAQ,qBAAqB;AAC7D,UAAM,WAAW,MAAM,yBAAyB,eAAe,EAAE,SAAS,CAAC;AAC3E,WAAO,SAAS,OAAO;AAAA,EACzB,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,kBACb,WACA,QACA,UACA,gBACwB;AACxB,MAAI,CAAC,YAAY,CAAC,eAAgB,QAAO,EAAE,GAAG,cAAc;AAC5D,QAAM,QAAQ,EAAE,UAAU,eAAe;AACzC,MAAI;AAIF,UAAM,SAAS,MAAM,qBAAqB,WAAW,QAAQ,CAAC,uBAAuB,GAAG,KAAK;AAC7F,UAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,WAAO,MAAM,qBAAqB;AAAA,MAChC,IAAI,GAAG,KAAK;AAAA,MACZ;AAAA,MACA;AAAA,MACA;AAAA,MACA,cAAc,OAAO;AAAA,MACrB,cAAc,OAAO;AAAA,MACrB,qBAAqB,MAAM,2BAA2B,WAAW,QAAQ;AAAA,IAC3E,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,2DAA2D,EAAE,IAAI,CAAC;AAC/E,WAAO,EAAE,GAAG,cAAc;AAAA,EAC5B;AACF;AAEA,SAAS,4BACP,SACA,MACwB;AACxB,MAAI,CAAC,QAAS,QAAO,KAAK;AAC1B,QAAM,SAAS,gBAAgB,IAAI,OAAO;AAC1C,MAAI,OAAQ,QAAO;AACnB,QAAM,UAAU,KAAK;AACrB,kBAAgB,IAAI,SAAS,OAAO;AACpC,SAAO;AACT;AAEA,SAAS,gBAAgB,KAA0E;AACjG,SAAO;AAAA,IACL,UAAU,IAAI,MAAM,YAAY;AAAA,IAChC,gBAAgB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AAAA,EACnE;AACF;AAEA,eAAe,yBAAyB,KAAsC;AAC5E,QAAM,EAAE,UAAU,eAAe,IAAI,gBAAgB,GAAG;AACxD,SAAO;AAAA,IAA4B,IAAI;AAAA,IAAS,MAC9C,kBAAkB,IAAI,WAAW,IAAI,MAAM,OAAO,MAAM,UAAU,cAAc;AAAA,EAClF;AACF;AAEA,eAAe,gCACb,IACA,QACA,UACA,gBACmB;AACnB,QAAM,WAAW,EAAE,UAAU,eAAe;AAC5C,QAAM,cAAc,MAAM;AAAA,IACxB,GAAG,KAAK;AAAA,IACR;AAAA,IACA,EAAE,QAAQ,UAAU,gBAAgB,WAAW,KAAK;AAAA,IACpD,CAAC;AAAA,IACD;AAAA,EACF;AACA,MAAI,CAAC,YAAa,QAAO,CAAC;AAC1B,QAAM,cAAc,MAAM,GAAG,KAAK,EAAE,KAAK,wBAAwB;AAAA,IAC/D,eAAe,YAAY;AAAA,IAC3B;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,IACR,WAAW;AAAA,EACb,CAAC;AACD,SAAO,MAAM,KAAK,IAAI,IAAI,YAAY,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AACpE;AAeA,SAAS,QAAQ,OAAmD;AAClE,QAAM,SAAS,OAAO,UAAU,WAAW,QAAQ,OAAO,SAAS,CAAC;AACpE,SAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC5C;AAWA,eAAsB,4BACpB,OACA,IACA,OACe;AACf,QAAM,MAAM,MAAM;AAAA,IAChB,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,KAAK,EAAE,EAAE,OAAO,CAAC,UAA2B,OAAO,UAAU,YAAY,MAAM,SAAS,CAAC,CAAC;AAAA,EACxH;AACA,MAAI,IAAI,WAAW,EAAG;AAEtB,QAAM,YAAY,iBAAiB,mBAAmB,GAAG;AACzD,QAAM,OAAQ,MAAM,GAAG,cAAc,EAAE;AAAA,IACrC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAQU,UAAU,GAAG;AAAA;AAAA;AAAA,IAGvB,CAAC,MAAM,UAAU,MAAM,gBAAgB,GAAG,UAAU,MAAM;AAAA,EAC5D;AAEA,QAAM,SAAS,IAAI,IAAI,KAAK,IAAI,CAAC,QAAQ,CAAC,IAAI,iBAAiB,GAAG,CAAC,CAAC;AACpE,aAAW,QAAQ,OAAO;AACxB,UAAM,MAAM,OAAO,KAAK,OAAO,WAAW,OAAO,IAAI,KAAK,EAAE,IAAI;AAChE,UAAM,aAAa,QAAQ,KAAK,WAAW;AAC3C,SAAK,aAAa;AAClB,SAAK,mBAAmB,QAAQ,KAAK,kBAAkB;AACvD,SAAK,iBAAiB,aAAa;AAAA,EACrC;AACF;AAMO,MAAM,wBAAwB;AAAA,EACnC,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AACJ;AAEA,MAAM,OAAO,cAAc;AAAA,EACzB,UAAU;AAAA,EACV,KAAK;AAAA,IACH,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,UAAU;AAAA,IACV,aAAa;AAAA,IACb,iBAAiB;AAAA,EACnB;AAAA,EACA,QAAQ;AAAA,EACR,SAAS,EAAE,YAAY,2BAA2B;AAAA,EAClD,WAAW,EAAE,UAAU,2BAA2B;AAAA,EAClD,MAAM;AAAA,IACJ,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,QAAQ,CAAC,GAAG,qBAAqB;AAAA,IACjC,sBAAsB,EAAE,WAAW,CAAC,EAAE,MAAM,kBAAkB,EAAE;AAAA,IAChE,cAAc;AAAA,MACZ,MAAM,EAAE;AAAA,MACR,MAAM,EAAE;AAAA,MACR,QAAQ,EAAE;AAAA,MACV,WAAW,EAAE;AAAA,MACb,WAAW,EAAE;AAAA,MACb,WAAW,EAAE;AAAA,IACf;AAAA,IACA,cAAc,OAAO,OAAO,QAAQ;AAClC,YAAM,UAAmC,CAAC;AAC1C,UAAI,YAA6B;AACjC,UAAI,OAAO,MAAM,QAAQ,YAAY,MAAM,IAAI,KAAK,EAAE,SAAS,GAAG;AAChE,oBAAY,MAAM,IACf,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AAAA,MACvC;AACA,UAAI,iBAAiB,MAAM,IAAI,KAAK,IAAI,MAAM;AAC5C,cAAM,WAAW,IAAI,KAAK,YAAY;AACtC,cAAM,iBAAiB,IAAI,0BAA0B,IAAI,KAAK,SAAS;AACvE,YAAI,CAAC,YAAY,CAAC,gBAAgB;AAChC,kBAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,qBAAqB,EAAE;AAC/C,iBAAO;AAAA,QACT;AACA,cAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACrC,cAAM,WAAW,MAAM;AAAA,UACrB;AAAA,UACA,IAAI,KAAK;AAAA,UACT;AAAA,UACA;AAAA,QACF;AACA,oBAAY,YACR,UAAU,OAAO,CAAC,OAAO,SAAS,SAAS,EAAE,CAAC,IAC9C;AACJ,YAAI,UAAU,WAAW,GAAG;AAC1B,kBAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,qBAAqB,EAAE;AAC/C,iBAAO;AAAA,QACT;AAAA,MACF;AAKA,YAAM,SAAS,MAAM,yBAAyB,GAAG;AACjD,UAAI,CAAC,OAAO,cAAc;AACxB,oBAAY,YACR,UAAU,OAAO,CAAC,OAAO,OAAO,WAAW,SAAS,EAAE,CAAC,IACvD,CAAC,GAAG,OAAO,UAAU;AACzB,YAAI,UAAU,WAAW,GAAG;AAC1B,kBAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,qBAAqB,EAAE;AAC/C,iBAAO;AAAA,QACT;AAAA,MACF;AACA,UAAI,aAAa,UAAU,SAAS,GAAG;AACrC,gBAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,UAAU;AAAA,MACnC;AACA,YAAM,OAAO,mBAAmB,MAAM,CAAC;AACvC,UAAI,MAAM;AACR,gBAAQ,EAAE,IAAI,IAAI,EAAE,QAAQ,IAAI,kBAAkB,IAAI,CAAC,IAAI;AAAA,MAC7D;AACA,UAAI,OAAO,MAAM,gBAAgB,YAAY,MAAM,YAAY,KAAK,EAAE,SAAS,GAAG;AAChF,gBAAQ,EAAE,YAAY,IAAI,MAAM,YAAY,KAAK;AAAA,MACnD;AACA,UAAI,OAAO,MAAM,WAAW,YAAY,MAAM,OAAO,KAAK,EAAE,SAAS,GAAG;AACtE,gBAAQ,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK;AAAA,MACxC;AACA,UAAI,OAAO,MAAM,eAAe,YAAY,MAAM,WAAW,KAAK,EAAE,SAAS,GAAG;AAC9E,gBAAQ,EAAE,WAAW,IAAI,MAAM,WAAW,KAAK;AAAA,MACjD;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EACA,OAAO;AAAA,IACL,WAAW,OAAO,KAAK,QAAQ;AAC7B,YAAM,QAAQ,MAAM,QAAS,KAA6B,KAAK,IACzD,IAAqC,QACvC,CAAC;AACL,UAAI,MAAM,WAAW,EAAG;AACxB,YAAM,WAAW,IAAI,MAAM,YAAY;AACvC,YAAM,iBAAiB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACxE,UAAI,CAAC,YAAY,CAAC,eAAgB;AAClC,YAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACrC,YAAM,4BAA4B,OAAO,GAAG,KAAK,GAAG,EAAE,UAAU,eAAe,CAAC;AAAA,IAClF;AAAA,EACF;AAAA,EACA,SAAS;AAAA,IACP,QAAQ;AAAA,MACN,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,UAAU,OAAO,EAAE,KAAK,IAAI,MAAM;AAChC,cAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,eAAO,wBAAwB,8BAA8B,OAAO,CAAC,GAAG,KAAK,SAAS;AAAA,MACxF;AAAA,MACA,UAAU,CAAC,EAAE,OAAO,OAAO,EAAE,IAAI,QAAQ,iBAAiB,KAAK;AAAA,MAC/D,QAAQ;AAAA,IACV;AAAA,IACA,QAAQ;AAAA,MACN,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,UAAU,OAAO,EAAE,KAAK,IAAI,MAAM;AAChC,cAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,eAAO,wBAAwB,8BAA8B,OAAO,CAAC,GAAG,KAAK,SAAS;AAAA,MACxF;AAAA,MACA,UAAU,OAAO,EAAE,IAAI,KAAK;AAAA,IAC9B;AAAA,IACA,QAAQ;AAAA,MACN,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,UAAU,OAAO,EAAE,QAAQ,IAAI,MAAM;AACnC,cAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,cAAM,KAAK,oBAAoB,QAAQ,KAAK,SAAS;AACrD,eAAO,EAAE,GAAG;AAAA,MACd;AAAA,MACA,UAAU,OAAO,EAAE,IAAI,KAAK;AAAA,IAC9B;AAAA,EACF;AACF,CAAC;AAED,eAAe,4BAA+C;AAC5D,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,SAAO,aAAa;AAAA,IAClB;AAAA;AAAA;AAAA,MAGE,OAAO,UAAU,6CAA6C,oBAAoB;AAAA,MAClF,QAAQ;AAAA,IACV;AAAA,IACA,EAAE,QAAQ,IAAI;AAAA,EAChB;AACF;AAUA,eAAe,2BAA2B,KAA8F;AACtI,QAAM,aAAa,EAAE,QAAQ,MAAM,OAAO,KAAK;AAC/C,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,SAAS,IAAI,aAAa,IAAI,KAAK,KAAK,IAAI,aAAa,IAAI,IAAI;AACvE,MAAI,CAAC,mBAAmB,MAAM,EAAG,QAAO;AACxC,QAAM,eAAe,cAAc,MAAM;AAEzC,MAAI,aAAa,WAAW,EAAG,QAAO;AAEtC,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,OAAO,MAAM,mBAAmB,GAAG;AAEzC,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,WAAW,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AAC3F,UAAM,WAAW,UAAU,YAAY,KAAK,YAAY;AACxD,UAAM,iBAAiB,kCAAkC,UAAU,IAAI,KAAK;AAC5E,QAAI,CAAC,YAAY,CAAC,eAAgB,QAAO;AAEzC,UAAM,QAAQ,UAAU,YAAY,SAAS,cAAc,SAAS,sBAAsB,OACtF,EAAE,UAAU,SAAS,UAAU,gBAAgB,SAAS,WAAW,IACnE;AACJ,UAAM,SAAS,MAAM;AAAA,MAA4B;AAAA,MAAK,MACpD;AAAA,QACE;AAAA,QACA,OAAO,KAAK,QAAQ,WAAW,KAAK,MAAM;AAAA,QAC1C;AAAA,QACA;AAAA,MACF;AAAA,IACF;AACA,QAAI,OAAO,aAAc,QAAO,EAAE,QAAQ,MAAM,MAAM;AACtD,QAAI,aAAa,KAAK,CAAC,OAAO,OAAO,WAAW,SAAS,EAAE,CAAC,EAAG,QAAO,EAAE,QAAQ,MAAM,MAAM;AAC5F,WAAO,EAAE,QAAQ,MAAM,0BAA0B,GAAG,MAAM;AAAA,EAC5D,SAAS,KAAK;AAEZ,WAAO,MAAM,sDAAsD,EAAE,IAAI,CAAC;AAC1E,WAAO,EAAE,QAAQ,MAAM,0BAA0B,GAAG,OAAO,KAAK;AAAA,EAClE;AACF;AAEA,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,QAAQ,MAAM,IAAI,MAAM,2BAA2B,GAAG;AAC9D,QAAM,WAAW,UAAU,MAAM,KAAK,IAAI,GAAG;AAC7C,MAAI,OAAO;AACT,aAAS,QAAQ,IAAI,6BAA6B,MAAM,QAAQ;AAChE,aAAS,QAAQ,IAAI,mCAAmC,MAAM,cAAc;AAAA,EAC9E;AACA,SAAO;AACT;AAEO,MAAM,OAAO,KAAK;AAClB,MAAM,MAAM,KAAK;AACjB,MAAM,SAAS,KAAK;AAEpB,MAAM,4BAA4B,EAAE,OAAO;AAAA,EAChD,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACvD,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACjD,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACrC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACrC,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC5C,cAAc,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC7C,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACtC,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACvC,aAAa,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACnD,mBAAmB,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC,EAAE,SAAS,EAAE,SAAS;AAAA,EACzE,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACrD,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC5C,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA;AAAA,EAG3C,aAAa,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS;AAAA,EACnE,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC9C,qBAAqB,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS;AAAA,EACrD,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC5C,cAAc,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS;AAAA,EACpE,wBAAwB,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS;AAAA,EAC7D,kCAAkC,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACxE,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,YAAY,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS;AAAA,EACtC,kBAAkB,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS;AAAA,EAC5C,gBAAgB,EAAE,QAAQ,EAAE,SAAS;AACvC,CAAC;AAEM,MAAM,oCAAoC,EAAE,OAAO;AAAA,EACxD,OAAO,EAAE,OAAO;AAAA,EAChB,QAAQ,EAAE,QAAQ,wBAAwB;AAC5C,CAAC;AAED,MAAM,cAAc,uBAAuB;AAAA,EACzC,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,oBAAoB,8BAA8B,yBAAyB;AAAA,EAC3E,QAAQ;AAAA,IACN,QAAQ;AAAA,IACR,aAAa;AAAA,EACf;AAAA,EACA,QAAQ;AAAA,IACN,QAAQ;AAAA,IACR,gBAAgB;AAAA,IAChB,aAAa;AAAA,EACf;AAAA,EACA,KAAK;AAAA,IACH,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAAA,IAC1C,gBAAgB;AAAA,IAChB,aAAa;AAAA,EACf;AACF,CAAC;AAEM,MAAM,UAAU;AAAA,EACrB,GAAG;AAAA,EACH,SAAS;AAAA,IACP,GAAG,YAAY;AAAA,IACf,GAAI,YAAY,SAAS,MACrB;AAAA,MACE,KAAK;AAAA,QACH,GAAG,YAAY,QAAQ;AAAA,QACvB,aAAa,GAAG,YAAY,QAAQ,IAAI,eAAe,EAAE,2eAA2e,wBAAwB,iJAAiJ,KAAK;AAAA,QACltB,WAAW;AAAA,UACT,GAAI,YAAY,QAAQ,IAAI,aAAa,CAAC;AAAA,UAC1C;AAAA,YACE,QAAQ;AAAA,YACR,aAAa;AAAA,YACb,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF,IACA,CAAC;AAAA,EACP;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|