@open-mercato/core 0.6.7-develop.6851.1.c1680b00d8 → 0.6.7-develop.6860.1.f796b22e80
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 +2 -2
- package/dist/bootstrap.js +16 -1
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
- package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +17 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/route.js +104 -8
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +67 -1
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/di.js +7 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
- package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
- package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
- package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/attachments/lib/quota-service.js +224 -0
- package/dist/modules/attachments/lib/quota-service.js.map +7 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
- package/dist/modules/attachments/workers/quota-recovery.js +45 -0
- package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
- package/dist/modules/auth/lib/setup-app.js +16 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/dashboards/services/widgetDataService.js +3 -4
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/entities/cli.js +193 -19
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +15 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +110 -22
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/workflows.js +10 -7
- package/dist/modules/sales/workflows.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +27 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/attachment_quota_reservation/index.ts +15 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +17 -0
- package/package.json +7 -7
- package/src/bootstrap.ts +17 -2
- package/src/modules/attachments/api/route.ts +114 -7
- package/src/modules/attachments/data/entities.ts +59 -0
- package/src/modules/attachments/di.ts +9 -1
- package/src/modules/attachments/i18n/de.json +3 -0
- package/src/modules/attachments/i18n/en.json +3 -0
- package/src/modules/attachments/i18n/es.json +3 -0
- package/src/modules/attachments/i18n/ko.json +3 -0
- package/src/modules/attachments/i18n/pl.json +3 -0
- package/src/modules/attachments/lib/drivers/index.ts +1 -1
- package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
- package/src/modules/attachments/lib/drivers/types.ts +5 -0
- package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
- package/src/modules/attachments/lib/quota-service.ts +442 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
- package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
- package/src/modules/attachments/workers/quota-recovery.ts +68 -0
- package/src/modules/auth/lib/setup-app.ts +20 -2
- package/src/modules/dashboards/services/widgetDataService.ts +3 -6
- package/src/modules/entities/cli.ts +238 -21
- package/src/modules/messages/api/[id]/route.ts +21 -0
- package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
- package/src/modules/sales/commands/documents.ts +188 -29
- package/src/modules/sales/workflows.ts +10 -7
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/ko.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/lib/activity-executor.ts +40 -1
- package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
- package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/workflows/widgets/injection/order-approval/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { Badge } from '@open-mercato/ui/primitives/badge'\nimport { emitSalesDocumentDataRefresh } from '@open-mercato/core/modules/sales/lib/frontend/documentDataEvents'\nimport { fetchAllDictionaryEntries } from '@open-mercato/core/modules/dictionaries/lib/fetchAllEntries'\n\ntype OrderRecord = {\n id: string\n orderNumber?: string\n status?: string\n statusEntryId?: string\n}\n\ntype WorkflowInstance = {\n id: string\n workflowId: string\n status: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'FAILED' | 'CANCELLED' | 'WAITING_FOR_ACTIVITIES'\n currentStepId: string\n context: Record<string, any>\n}\n\ntype UserTask = {\n id: string\n taskName: string\n status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'\n workflowInstanceId: string\n claimedBy: string | null\n}\n\ntype DictionaryEntry = {\n id: string\n value: string\n label: string\n color?: string\n icon?: string\n}\n\nconst WORKFLOW_ID = 'sales.order-approval'\n\nexport default function OrderApprovalWidget({ data }: InjectionWidgetComponentProps<unknown, OrderRecord>) {\n const t = useT()\n const queryClient = useQueryClient()\n const orderId = data?.id\n\n const [decision, setDecision] = React.useState<'approve' | 'reject' | ''>('')\n const [comments, setComments] = React.useState('')\n const [error, setError] = React.useState<string | null>(null)\n\n // Track if we're waiting for workflow to process (for polling)\n const [isWaitingForProcessing, setIsWaitingForProcessing] = React.useState(false)\n\n // Fetch current order status directly (to detect when workflow updates it)\n // Use list endpoint with ID filter since single-item endpoint doesn't exist\n const { data: orderData } = useQuery({\n queryKey: ['order-status', orderId],\n queryFn: async () => {\n if (!orderId) return null\n const result = await apiCall<{ items: Array<{ id: string; status?: string | null }> }>(\n `/api/sales/orders?id=${orderId}&pageSize=1`\n )\n return result.ok && result.result?.items?.[0] ? result.result.items[0] : null\n },\n enabled: Boolean(orderId),\n staleTime: 5_000,\n // Poll when waiting for processing to detect status change\n refetchInterval: isWaitingForProcessing ? 2_000 : false,\n })\n\n // Use fresh order status from query, fallback to prop data\n // The API returns status as a string value (e.g., \"approved\", \"pending_approval\")\n const currentOrderStatus = orderData?.status || data?.status\n\n // Fetch active workflow instances for this order\n const { data: instancesData, isLoading: instancesLoading } = useQuery({\n queryKey: ['workflow-instances', orderId],\n queryFn: async () => {\n if (!orderId) return { data: [] }\n const result = await apiCall<{ data: WorkflowInstance[] }>(\n `/api/workflows/instances?entityId=${orderId}&status=RUNNING,PAUSED,WAITING_FOR_ACTIVITIES`\n )\n return result.ok ? result.result : { data: [] }\n },\n enabled: Boolean(orderId),\n staleTime: 5_000,\n // Poll every 2 seconds when waiting for processing\n refetchInterval: isWaitingForProcessing ? 2_000 : false,\n })\n\n const activeInstance = instancesData?.data?.find(\n (inst) => inst.workflowId === WORKFLOW_ID && !['COMPLETED', 'FAILED', 'CANCELLED'].includes(inst.status)\n )\n\n // Fetch pending user tasks for active instance\n const { data: tasksData, isLoading: tasksLoading } = useQuery({\n queryKey: ['workflow-tasks', activeInstance?.id],\n queryFn: async () => {\n if (!activeInstance?.id) return { data: [] }\n const result = await apiCall<{ data: UserTask[] }>(\n `/api/workflows/tasks?workflowInstanceId=${activeInstance.id}&status=PENDING,IN_PROGRESS`\n )\n return result.ok ? result.result : { data: [] }\n },\n enabled: Boolean(activeInstance?.id),\n staleTime: 5_000,\n // Poll every 2 seconds when waiting for processing\n refetchInterval: isWaitingForProcessing ? 2_000 : false,\n })\n\n const pendingTask = tasksData?.data?.[0]\n\n // Auto-detect when workflow is processing (active instance but no pending task)\n const isProcessing = activeInstance && !pendingTask && !['COMPLETED', 'FAILED', 'CANCELLED'].includes(activeInstance.status)\n\n // Track previous status to detect changes\n const prevStatusRef = React.useRef<string | undefined>(data?.status)\n\n // Update polling state when processing state changes\n React.useEffect(() => {\n if (isProcessing) {\n setIsWaitingForProcessing(true)\n } else if (!activeInstance || activeInstance.status === 'COMPLETED') {\n setIsWaitingForProcessing(false)\n }\n }, [isProcessing, activeInstance])\n\n // When order status changes, emit refresh event to update the page\n React.useEffect(() => {\n const newStatus = orderData?.status\n const oldStatus = prevStatusRef.current\n\n if (newStatus && oldStatus && newStatus !== oldStatus) {\n // Status changed - emit document refresh event to reload the page data\n if (orderId) {\n emitSalesDocumentDataRefresh({ documentId: orderId, kind: 'order' })\n }\n }\n\n prevStatusRef.current = newStatus || oldStatus\n }, [orderData?.status, orderId])\n\n // First fetch dictionaries to find the order_status dictionary ID\n const { data: dictionariesData } = useQuery({\n queryKey: ['dictionaries'],\n queryFn: async () => {\n const result = await apiCall<{ items: Array<{ id: string; key: string }> }>(\n '/api/dictionaries'\n )\n return result.ok ? result.result : { items: [] }\n },\n staleTime: 60_000,\n })\n\n const orderStatusDictionaryId = React.useMemo(() => {\n const dict = dictionariesData?.items?.find(d => d.key === 'sales.order_status')\n return dict?.id\n }, [dictionariesData])\n\n // Fetch order status dictionary entries using the dictionary ID\n const { data: statusEntriesData } = useQuery({\n queryKey: ['dictionary-entries', orderStatusDictionaryId],\n queryFn: async (): Promise<{ items: DictionaryEntry[] }> => {\n if (!orderStatusDictionaryId) return { items: [] }\n const result = await fetchAllDictionaryEntries(orderStatusDictionaryId)\n if (!result.ok) return { items: [] }\n const items = result.items.flatMap((entry) => {\n const id = typeof entry.id === 'string' ? entry.id : null\n const value = typeof entry.value === 'string' ? entry.value : null\n if (!id || !value) return []\n const label = typeof entry.label === 'string' && entry.label.trim().length ? entry.label : value\n return [{ id, value, label }]\n })\n return { items }\n },\n enabled: Boolean(orderStatusDictionaryId),\n staleTime: 60_000,\n })\n\n const findStatusId = React.useCallback((code: string) => {\n const entries = statusEntriesData?.items || []\n // The API returns 'value' as the code/key for dictionary entries\n const entry = entries.find(\n (e) => e.value === code || e.label?.toLowerCase() === code.toLowerCase()\n )\n return entry?.id\n }, [statusEntriesData])\n\n // Start workflow mutation\n const startWorkflowMutation = useMutation({\n mutationFn: async () => {\n const pendingApprovalStatusId = findStatusId('pending_approval')\n const approvedStatusId = findStatusId('approved')\n const rejectedStatusId = findStatusId('rejected')\n\n if (!pendingApprovalStatusId || !approvedStatusId || !rejectedStatusId) {\n throw new Error(t('workflows.orderApproval.missingStatuses', 'Missing order status entries. Please ensure pending_approval, approved, and rejected statuses exist in the sales.order_status dictionary.'))\n }\n\n const result = await apiCall<{ data: WorkflowInstance }>('/api/workflows/instances', {\n method: 'POST',\n body: JSON.stringify({\n workflowId: WORKFLOW_ID,\n initialContext: {\n orderId,\n pendingApprovalStatusId,\n approvedStatusId,\n rejectedStatusId,\n },\n metadata: {\n entityType: 'SalesOrder',\n entityId: orderId,\n },\n }),\n })\n\n if (!result.ok) {\n const errorResult = result.result as { error?: string } | null\n throw new Error(errorResult?.error || t('workflows.orderApproval.startError', 'Failed to start approval workflow'))\n }\n\n return result.result\n },\n onSuccess: () => {\n setError(null)\n queryClient.invalidateQueries({ queryKey: ['workflow-instances', orderId] })\n },\n onError: (err: Error) => {\n setError(err.message)\n },\n })\n\n // Complete task mutation\n const completeTaskMutation = useMutation({\n mutationFn: async ({ taskId, formData }: { taskId: string; formData: { decision: string; comments?: string } }) => {\n const result = await apiCall(`/api/workflows/tasks/${taskId}/complete`, {\n method: 'POST',\n body: JSON.stringify({ formData }),\n })\n\n if (!result.ok) {\n const errorResult = result.result as { error?: string } | null\n throw new Error(errorResult?.error || t('workflows.orderApproval.completeError', 'Failed to complete approval task'))\n }\n\n return result.result\n },\n onSuccess: () => {\n setError(null)\n setDecision('')\n setComments('')\n // Start polling immediately after submitting decision\n setIsWaitingForProcessing(true)\n queryClient.invalidateQueries({ queryKey: ['workflow-instances', orderId] })\n queryClient.invalidateQueries({ queryKey: ['workflow-tasks', activeInstance?.id] })\n // Also refresh the order data\n queryClient.invalidateQueries({ queryKey: ['sales-order', orderId] })\n },\n onError: (err: Error) => {\n setError(err.message)\n },\n })\n\n const handleStartWorkflow = () => {\n // Start polling after starting workflow\n setIsWaitingForProcessing(true)\n startWorkflowMutation.mutate()\n }\n\n const handleCompleteTask = () => {\n if (!pendingTask || !decision) return\n completeTaskMutation.mutate({\n taskId: pendingTask.id,\n formData: { decision, comments },\n })\n }\n\n // Handle keyboard shortcuts (Cmd/Ctrl+Enter to submit)\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n e.preventDefault()\n if (pendingTask && decision && !isSubmitting) {\n handleCompleteTask()\n }\n }\n }\n\n const isLoading = instancesLoading || tasksLoading\n const isSubmitting = startWorkflowMutation.isPending || completeTaskMutation.isPending\n\n // Don't render if no orderId\n if (!orderId) return null\n\n // Only show widget when order status is pending_approval\n const orderStatus = currentOrderStatus?.toLowerCase()\n if (orderStatus !== 'pending_approval') {\n return null\n }\n\n // Loading state\n if (isLoading) {\n return (\n <div className=\"flex items-center justify-center p-4\">\n <Spinner size=\"sm\" />\n <span className=\"ml-2 text-sm text-muted-foreground\">{t('common.loading', 'Loading...')}</span>\n </div>\n )\n }\n\n // Show workflow status badge\n const getStatusBadge = () => {\n if (!activeInstance) return null\n\n const statusVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {\n RUNNING: 'default',\n PAUSED: 'secondary',\n WAITING_FOR_ACTIVITIES: 'secondary',\n COMPLETED: 'default',\n FAILED: 'destructive',\n CANCELLED: 'outline',\n }\n\n return (\n <Badge variant={statusVariants[activeInstance.status] || 'outline'}>\n {t(`workflows.instances.statuses.${activeInstance.status}`, activeInstance.status)}\n </Badge>\n )\n }\n\n return (\n <div className=\"space-y-3 rounded-lg border bg-card p-4 shadow-sm\">\n <div className=\"flex items-start justify-between gap-3\">\n <div>\n <div className=\"text-sm font-semibold text-foreground\">\n {t('workflows.orderApproval.groupLabel', 'Order Approval')}\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t('workflows.orderApproval.groupDescription', 'Review and approve or reject this order')}\n </p>\n </div>\n {getStatusBadge()}\n </div>\n\n {error && (\n <div className=\"rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800\">\n {error}\n </div>\n )}\n\n {/* No active workflow - show request approval button */}\n {!activeInstance && (\n <div className=\"space-y-3\">\n <p className=\"text-sm text-muted-foreground\">\n {t('workflows.orderApproval.noWorkflowActive', 'No approval workflow is active for this order.')}\n </p>\n <Button\n onClick={handleStartWorkflow}\n disabled={isSubmitting}\n variant=\"default\"\n size=\"sm\"\n >\n {isSubmitting && <Spinner size=\"sm\" className=\"mr-2\" />}\n {t('workflows.orderApproval.requestApproval', 'Request Approval')}\n </Button>\n </div>\n )}\n\n {/* Active workflow with pending task - show approve/reject UI */}\n {activeInstance && pendingTask && (\n <div className=\"space-y-3\" onKeyDown={handleKeyDown}>\n <div className=\"rounded-md border border-amber-200 bg-amber-50 p-3\">\n <p className=\"text-sm font-medium text-amber-800\">\n {t('workflows.orderApproval.pendingTitle', 'Pending Approval')}\n </p>\n <p className=\"text-xs text-amber-700 mt-1\">\n {t('workflows.orderApproval.pendingDescription', 'This order requires approval before processing.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label className=\"text-sm font-medium\">\n {t('workflows.orderApproval.decisionLabel', 'Decision')}\n </label>\n <div className=\"flex gap-2\">\n <Button\n variant={decision === 'approve' ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => setDecision('approve')}\n className={decision === 'approve' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}\n >\n {t('workflows.orderApproval.approveButton', 'Approve')}\n </Button>\n <Button\n variant={decision === 'reject' ? 'destructive' : 'outline'}\n size=\"sm\"\n onClick={() => setDecision('reject')}\n >\n {t('workflows.orderApproval.rejectButton', 'Reject')}\n </Button>\n </div>\n </div>\n\n <div className=\"space-y-2\">\n <label className=\"text-sm font-medium\">\n {t('workflows.orderApproval.commentsLabel', 'Comments')} <span className=\"text-muted-foreground font-normal\">({t('common.optional', 'optional')})</span>\n </label>\n <Textarea\n value={comments}\n onChange={(e) => setComments(e.target.value)}\n placeholder={t('workflows.orderApproval.commentsPlaceholder', 'Add optional comments...')}\n rows={2}\n />\n </div>\n\n <Button\n onClick={handleCompleteTask}\n disabled={!decision || isSubmitting}\n variant=\"default\"\n size=\"sm\"\n className=\"w-full\"\n >\n {isSubmitting && <Spinner size=\"sm\" className=\"mr-2\" />}\n {t('workflows.orderApproval.submitDecision', 'Submit Decision')}\n </Button>\n </div>\n )}\n\n {/* Active workflow but no pending task (processing) */}\n {activeInstance && !pendingTask && activeInstance.status !== 'COMPLETED' && (\n <div className=\"rounded-md border border-blue-200 bg-blue-50 p-3\">\n <p className=\"text-sm text-blue-800\">\n {t('workflows.orderApproval.processing', 'Workflow is processing...')}\n </p>\n </div>\n )}\n\n {/* Completed workflow */}\n {activeInstance && activeInstance.status === 'COMPLETED' && (\n <div className=\"rounded-md border border-emerald-200 bg-emerald-50 p-3\">\n <p className=\"text-sm text-emerald-800\">\n {t('workflows.orderApproval.completed', 'Approval workflow completed.')}\n </p>\n </div>\n )}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { Badge } from '@open-mercato/ui/primitives/badge'\nimport { emitSalesDocumentDataRefresh } from '@open-mercato/core/modules/sales/lib/frontend/documentDataEvents'\nimport { fetchAllDictionaryEntries } from '@open-mercato/core/modules/dictionaries/lib/fetchAllEntries'\nimport type { OrderApprovalInitialContext } from './context-keys'\n\ntype OrderRecord = {\n id: string\n orderNumber?: string\n status?: string\n statusEntryId?: string\n}\n\ntype WorkflowInstance = {\n id: string\n workflowId: string\n status: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'FAILED' | 'CANCELLED' | 'WAITING_FOR_ACTIVITIES'\n currentStepId: string\n context: Record<string, any>\n}\n\ntype UserTask = {\n id: string\n taskName: string\n status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'\n workflowInstanceId: string\n claimedBy: string | null\n}\n\ntype DictionaryEntry = {\n id: string\n value: string\n label: string\n color?: string\n icon?: string\n}\n\nconst WORKFLOW_ID = 'sales.order-approval'\n\nexport default function OrderApprovalWidget({ data }: InjectionWidgetComponentProps<unknown, OrderRecord>) {\n const t = useT()\n const queryClient = useQueryClient()\n const orderId = data?.id\n\n const [decision, setDecision] = React.useState<'approve' | 'reject' | ''>('')\n const [comments, setComments] = React.useState('')\n const [error, setError] = React.useState<string | null>(null)\n\n // Track if we're waiting for workflow to process (for polling)\n const [isWaitingForProcessing, setIsWaitingForProcessing] = React.useState(false)\n\n // Fetch current order status directly (to detect when workflow updates it)\n // Use list endpoint with ID filter since single-item endpoint doesn't exist\n const { data: orderData } = useQuery({\n queryKey: ['order-status', orderId],\n queryFn: async () => {\n if (!orderId) return null\n const result = await apiCall<{ items: Array<{ id: string; status?: string | null }> }>(\n `/api/sales/orders?id=${orderId}&pageSize=1`\n )\n return result.ok && result.result?.items?.[0] ? result.result.items[0] : null\n },\n enabled: Boolean(orderId),\n staleTime: 5_000,\n // Poll when waiting for processing to detect status change\n refetchInterval: isWaitingForProcessing ? 2_000 : false,\n })\n\n // Use fresh order status from query, fallback to prop data\n // The API returns status as a string value (e.g., \"approved\", \"pending_approval\")\n const currentOrderStatus = orderData?.status || data?.status\n\n // Fetch active workflow instances for this order\n const { data: instancesData, isLoading: instancesLoading } = useQuery({\n queryKey: ['workflow-instances', orderId],\n queryFn: async () => {\n if (!orderId) return { data: [] }\n const result = await apiCall<{ data: WorkflowInstance[] }>(\n `/api/workflows/instances?entityId=${orderId}&status=RUNNING,PAUSED,WAITING_FOR_ACTIVITIES`\n )\n return result.ok ? result.result : { data: [] }\n },\n enabled: Boolean(orderId),\n staleTime: 5_000,\n // Poll every 2 seconds when waiting for processing\n refetchInterval: isWaitingForProcessing ? 2_000 : false,\n })\n\n const activeInstance = instancesData?.data?.find(\n (inst) => inst.workflowId === WORKFLOW_ID && !['COMPLETED', 'FAILED', 'CANCELLED'].includes(inst.status)\n )\n\n // Fetch pending user tasks for active instance\n const { data: tasksData, isLoading: tasksLoading } = useQuery({\n queryKey: ['workflow-tasks', activeInstance?.id],\n queryFn: async () => {\n if (!activeInstance?.id) return { data: [] }\n const result = await apiCall<{ data: UserTask[] }>(\n `/api/workflows/tasks?workflowInstanceId=${activeInstance.id}&status=PENDING,IN_PROGRESS`\n )\n return result.ok ? result.result : { data: [] }\n },\n enabled: Boolean(activeInstance?.id),\n staleTime: 5_000,\n // Poll every 2 seconds when waiting for processing\n refetchInterval: isWaitingForProcessing ? 2_000 : false,\n })\n\n const pendingTask = tasksData?.data?.[0]\n\n // Auto-detect when workflow is processing (active instance but no pending task)\n const isProcessing = activeInstance && !pendingTask && !['COMPLETED', 'FAILED', 'CANCELLED'].includes(activeInstance.status)\n\n // Track previous status to detect changes\n const prevStatusRef = React.useRef<string | undefined>(data?.status)\n\n // Update polling state when processing state changes\n React.useEffect(() => {\n if (isProcessing) {\n setIsWaitingForProcessing(true)\n } else if (!activeInstance || activeInstance.status === 'COMPLETED') {\n setIsWaitingForProcessing(false)\n }\n }, [isProcessing, activeInstance])\n\n // When order status changes, emit refresh event to update the page\n React.useEffect(() => {\n const newStatus = orderData?.status\n const oldStatus = prevStatusRef.current\n\n if (newStatus && oldStatus && newStatus !== oldStatus) {\n // Status changed - emit document refresh event to reload the page data\n if (orderId) {\n emitSalesDocumentDataRefresh({ documentId: orderId, kind: 'order' })\n }\n }\n\n prevStatusRef.current = newStatus || oldStatus\n }, [orderData?.status, orderId])\n\n // First fetch dictionaries to find the order_status dictionary ID\n const { data: dictionariesData } = useQuery({\n queryKey: ['dictionaries'],\n queryFn: async () => {\n const result = await apiCall<{ items: Array<{ id: string; key: string }> }>(\n '/api/dictionaries'\n )\n return result.ok ? result.result : { items: [] }\n },\n staleTime: 60_000,\n })\n\n const orderStatusDictionaryId = React.useMemo(() => {\n const dict = dictionariesData?.items?.find(d => d.key === 'sales.order_status')\n return dict?.id\n }, [dictionariesData])\n\n // Fetch order status dictionary entries using the dictionary ID\n const { data: statusEntriesData } = useQuery({\n queryKey: ['dictionary-entries', orderStatusDictionaryId],\n queryFn: async (): Promise<{ items: DictionaryEntry[] }> => {\n if (!orderStatusDictionaryId) return { items: [] }\n const result = await fetchAllDictionaryEntries(orderStatusDictionaryId)\n if (!result.ok) return { items: [] }\n const items = result.items.flatMap((entry) => {\n const id = typeof entry.id === 'string' ? entry.id : null\n const value = typeof entry.value === 'string' ? entry.value : null\n if (!id || !value) return []\n const label = typeof entry.label === 'string' && entry.label.trim().length ? entry.label : value\n return [{ id, value, label }]\n })\n return { items }\n },\n enabled: Boolean(orderStatusDictionaryId),\n staleTime: 60_000,\n })\n\n const findStatusId = React.useCallback((code: string) => {\n const entries = statusEntriesData?.items || []\n // The API returns 'value' as the code/key for dictionary entries\n const entry = entries.find(\n (e) => e.value === code || e.label?.toLowerCase() === code.toLowerCase()\n )\n return entry?.id\n }, [statusEntriesData])\n\n // Start workflow mutation\n const startWorkflowMutation = useMutation({\n mutationFn: async () => {\n const pendingApprovalStatusId = findStatusId('pending_approval')\n const approvedStatusId = findStatusId('approved')\n const rejectedStatusId = findStatusId('rejected')\n\n if (!orderId) {\n throw new Error(t('workflows.orderApproval.missingOrderId', 'Cannot start the approval workflow before the order has loaded.'))\n }\n\n if (!pendingApprovalStatusId || !approvedStatusId || !rejectedStatusId) {\n throw new Error(t('workflows.orderApproval.missingStatuses', 'Missing order status entries. Please ensure pending_approval, approved, and rejected statuses exist in the sales.order_status dictionary.'))\n }\n\n // Typed against the shared key list so a definition that references a new\n // {{context.*}} key cannot compile until this seed provides it.\n const initialContext: OrderApprovalInitialContext = {\n orderId,\n pendingApprovalStatusId,\n approvedStatusId,\n rejectedStatusId,\n }\n\n const result = await apiCall<{ data: WorkflowInstance }>('/api/workflows/instances', {\n method: 'POST',\n body: JSON.stringify({\n workflowId: WORKFLOW_ID,\n initialContext,\n metadata: {\n entityType: 'SalesOrder',\n entityId: orderId,\n },\n }),\n })\n\n if (!result.ok) {\n const errorResult = result.result as { error?: string } | null\n throw new Error(errorResult?.error || t('workflows.orderApproval.startError', 'Failed to start approval workflow'))\n }\n\n return result.result\n },\n onSuccess: () => {\n setError(null)\n queryClient.invalidateQueries({ queryKey: ['workflow-instances', orderId] })\n },\n onError: (err: Error) => {\n setError(err.message)\n },\n })\n\n // Complete task mutation\n const completeTaskMutation = useMutation({\n mutationFn: async ({ taskId, formData }: { taskId: string; formData: { decision: string; comments?: string } }) => {\n const result = await apiCall(`/api/workflows/tasks/${taskId}/complete`, {\n method: 'POST',\n body: JSON.stringify({ formData }),\n })\n\n if (!result.ok) {\n const errorResult = result.result as { error?: string } | null\n throw new Error(errorResult?.error || t('workflows.orderApproval.completeError', 'Failed to complete approval task'))\n }\n\n return result.result\n },\n onSuccess: () => {\n setError(null)\n setDecision('')\n setComments('')\n // Start polling immediately after submitting decision\n setIsWaitingForProcessing(true)\n queryClient.invalidateQueries({ queryKey: ['workflow-instances', orderId] })\n queryClient.invalidateQueries({ queryKey: ['workflow-tasks', activeInstance?.id] })\n // Also refresh the order data\n queryClient.invalidateQueries({ queryKey: ['sales-order', orderId] })\n },\n onError: (err: Error) => {\n setError(err.message)\n },\n })\n\n const handleStartWorkflow = () => {\n // Start polling after starting workflow\n setIsWaitingForProcessing(true)\n startWorkflowMutation.mutate()\n }\n\n const handleCompleteTask = () => {\n if (!pendingTask || !decision) return\n completeTaskMutation.mutate({\n taskId: pendingTask.id,\n formData: { decision, comments },\n })\n }\n\n // Handle keyboard shortcuts (Cmd/Ctrl+Enter to submit)\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n e.preventDefault()\n if (pendingTask && decision && !isSubmitting) {\n handleCompleteTask()\n }\n }\n }\n\n const isLoading = instancesLoading || tasksLoading\n const isSubmitting = startWorkflowMutation.isPending || completeTaskMutation.isPending\n\n // Don't render if no orderId\n if (!orderId) return null\n\n // Only show widget when order status is pending_approval\n const orderStatus = currentOrderStatus?.toLowerCase()\n if (orderStatus !== 'pending_approval') {\n return null\n }\n\n // Loading state\n if (isLoading) {\n return (\n <div className=\"flex items-center justify-center p-4\">\n <Spinner size=\"sm\" />\n <span className=\"ml-2 text-sm text-muted-foreground\">{t('common.loading', 'Loading...')}</span>\n </div>\n )\n }\n\n // Show workflow status badge\n const getStatusBadge = () => {\n if (!activeInstance) return null\n\n const statusVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {\n RUNNING: 'default',\n PAUSED: 'secondary',\n WAITING_FOR_ACTIVITIES: 'secondary',\n COMPLETED: 'default',\n FAILED: 'destructive',\n CANCELLED: 'outline',\n }\n\n return (\n <Badge variant={statusVariants[activeInstance.status] || 'outline'}>\n {t(`workflows.instances.statuses.${activeInstance.status}`, activeInstance.status)}\n </Badge>\n )\n }\n\n return (\n <div className=\"space-y-3 rounded-lg border bg-card p-4 shadow-sm\">\n <div className=\"flex items-start justify-between gap-3\">\n <div>\n <div className=\"text-sm font-semibold text-foreground\">\n {t('workflows.orderApproval.groupLabel', 'Order Approval')}\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t('workflows.orderApproval.groupDescription', 'Review and approve or reject this order')}\n </p>\n </div>\n {getStatusBadge()}\n </div>\n\n {error && (\n <div className=\"rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800\">\n {error}\n </div>\n )}\n\n {/* No active workflow - show request approval button */}\n {!activeInstance && (\n <div className=\"space-y-3\">\n <p className=\"text-sm text-muted-foreground\">\n {t('workflows.orderApproval.noWorkflowActive', 'No approval workflow is active for this order.')}\n </p>\n <Button\n onClick={handleStartWorkflow}\n disabled={isSubmitting}\n variant=\"default\"\n size=\"sm\"\n >\n {isSubmitting && <Spinner size=\"sm\" className=\"mr-2\" />}\n {t('workflows.orderApproval.requestApproval', 'Request Approval')}\n </Button>\n </div>\n )}\n\n {/* Active workflow with pending task - show approve/reject UI */}\n {activeInstance && pendingTask && (\n <div className=\"space-y-3\" onKeyDown={handleKeyDown}>\n <div className=\"rounded-md border border-amber-200 bg-amber-50 p-3\">\n <p className=\"text-sm font-medium text-amber-800\">\n {t('workflows.orderApproval.pendingTitle', 'Pending Approval')}\n </p>\n <p className=\"text-xs text-amber-700 mt-1\">\n {t('workflows.orderApproval.pendingDescription', 'This order requires approval before processing.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label className=\"text-sm font-medium\">\n {t('workflows.orderApproval.decisionLabel', 'Decision')}\n </label>\n <div className=\"flex gap-2\">\n <Button\n variant={decision === 'approve' ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => setDecision('approve')}\n className={decision === 'approve' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}\n >\n {t('workflows.orderApproval.approveButton', 'Approve')}\n </Button>\n <Button\n variant={decision === 'reject' ? 'destructive' : 'outline'}\n size=\"sm\"\n onClick={() => setDecision('reject')}\n >\n {t('workflows.orderApproval.rejectButton', 'Reject')}\n </Button>\n </div>\n </div>\n\n <div className=\"space-y-2\">\n <label className=\"text-sm font-medium\">\n {t('workflows.orderApproval.commentsLabel', 'Comments')} <span className=\"text-muted-foreground font-normal\">({t('common.optional', 'optional')})</span>\n </label>\n <Textarea\n value={comments}\n onChange={(e) => setComments(e.target.value)}\n placeholder={t('workflows.orderApproval.commentsPlaceholder', 'Add optional comments...')}\n rows={2}\n />\n </div>\n\n <Button\n onClick={handleCompleteTask}\n disabled={!decision || isSubmitting}\n variant=\"default\"\n size=\"sm\"\n className=\"w-full\"\n >\n {isSubmitting && <Spinner size=\"sm\" className=\"mr-2\" />}\n {t('workflows.orderApproval.submitDecision', 'Submit Decision')}\n </Button>\n </div>\n )}\n\n {/* Active workflow but no pending task (processing) */}\n {activeInstance && !pendingTask && activeInstance.status !== 'COMPLETED' && (\n <div className=\"rounded-md border border-blue-200 bg-blue-50 p-3\">\n <p className=\"text-sm text-blue-800\">\n {t('workflows.orderApproval.processing', 'Workflow is processing...')}\n </p>\n </div>\n )}\n\n {/* Completed workflow */}\n {activeInstance && activeInstance.status === 'COMPLETED' && (\n <div className=\"rounded-md border border-emerald-200 bg-emerald-50 p-3\">\n <p className=\"text-sm text-emerald-800\">\n {t('workflows.orderApproval.completed', 'Approval workflow completed.')}\n </p>\n </div>\n )}\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AA6TM,SACE,KADF;AA3TN,YAAY,WAAW;AAEvB,SAAS,UAAU,aAAa,sBAAsB;AACtD,SAAS,eAAe;AACxB,SAAS,YAAY;AACrB,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,gBAAgB;AACzB,SAAS,aAAa;AACtB,SAAS,oCAAoC;AAC7C,SAAS,iCAAiC;AAkC1C,MAAM,cAAc;AAEL,SAAR,oBAAqC,EAAE,KAAK,GAAwD;AACzG,QAAM,IAAI,KAAK;AACf,QAAM,cAAc,eAAe;AACnC,QAAM,UAAU,MAAM;AAEtB,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAoC,EAAE;AAC5E,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAS,EAAE;AACjD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAG5D,QAAM,CAAC,wBAAwB,yBAAyB,IAAI,MAAM,SAAS,KAAK;AAIhF,QAAM,EAAE,MAAM,UAAU,IAAI,SAAS;AAAA,IACnC,UAAU,CAAC,gBAAgB,OAAO;AAAA,IAClC,SAAS,YAAY;AACnB,UAAI,CAAC,QAAS,QAAO;AACrB,YAAM,SAAS,MAAM;AAAA,QACnB,wBAAwB,OAAO;AAAA,MACjC;AACA,aAAO,OAAO,MAAM,OAAO,QAAQ,QAAQ,CAAC,IAAI,OAAO,OAAO,MAAM,CAAC,IAAI;AAAA,IAC3E;AAAA,IACA,SAAS,QAAQ,OAAO;AAAA,IACxB,WAAW;AAAA;AAAA,IAEX,iBAAiB,yBAAyB,MAAQ;AAAA,EACpD,CAAC;AAID,QAAM,qBAAqB,WAAW,UAAU,MAAM;AAGtD,QAAM,EAAE,MAAM,eAAe,WAAW,iBAAiB,IAAI,SAAS;AAAA,IACpE,UAAU,CAAC,sBAAsB,OAAO;AAAA,IACxC,SAAS,YAAY;AACnB,UAAI,CAAC,QAAS,QAAO,EAAE,MAAM,CAAC,EAAE;AAChC,YAAM,SAAS,MAAM;AAAA,QACnB,qCAAqC,OAAO;AAAA,MAC9C;AACA,aAAO,OAAO,KAAK,OAAO,SAAS,EAAE,MAAM,CAAC,EAAE;AAAA,IAChD;AAAA,IACA,SAAS,QAAQ,OAAO;AAAA,IACxB,WAAW;AAAA;AAAA,IAEX,iBAAiB,yBAAyB,MAAQ;AAAA,EACpD,CAAC;AAED,QAAM,iBAAiB,eAAe,MAAM;AAAA,IAC1C,CAAC,SAAS,KAAK,eAAe,eAAe,CAAC,CAAC,aAAa,UAAU,WAAW,EAAE,SAAS,KAAK,MAAM;AAAA,EACzG;AAGA,QAAM,EAAE,MAAM,WAAW,WAAW,aAAa,IAAI,SAAS;AAAA,IAC5D,UAAU,CAAC,kBAAkB,gBAAgB,EAAE;AAAA,IAC/C,SAAS,YAAY;AACnB,UAAI,CAAC,gBAAgB,GAAI,QAAO,EAAE,MAAM,CAAC,EAAE;AAC3C,YAAM,SAAS,MAAM;AAAA,QACnB,2CAA2C,eAAe,EAAE;AAAA,MAC9D;AACA,aAAO,OAAO,KAAK,OAAO,SAAS,EAAE,MAAM,CAAC,EAAE;AAAA,IAChD;AAAA,IACA,SAAS,QAAQ,gBAAgB,EAAE;AAAA,IACnC,WAAW;AAAA;AAAA,IAEX,iBAAiB,yBAAyB,MAAQ;AAAA,EACpD,CAAC;AAED,QAAM,cAAc,WAAW,OAAO,CAAC;AAGvC,QAAM,eAAe,kBAAkB,CAAC,eAAe,CAAC,CAAC,aAAa,UAAU,WAAW,EAAE,SAAS,eAAe,MAAM;AAG3H,QAAM,gBAAgB,MAAM,OAA2B,MAAM,MAAM;AAGnE,QAAM,UAAU,MAAM;AACpB,QAAI,cAAc;AAChB,gCAA0B,IAAI;AAAA,IAChC,WAAW,CAAC,kBAAkB,eAAe,WAAW,aAAa;AACnE,gCAA0B,KAAK;AAAA,IACjC;AAAA,EACF,GAAG,CAAC,cAAc,cAAc,CAAC;AAGjC,QAAM,UAAU,MAAM;AACpB,UAAM,YAAY,WAAW;AAC7B,UAAM,YAAY,cAAc;AAEhC,QAAI,aAAa,aAAa,cAAc,WAAW;AAErD,UAAI,SAAS;AACX,qCAA6B,EAAE,YAAY,SAAS,MAAM,QAAQ,CAAC;AAAA,MACrE;AAAA,IACF;AAEA,kBAAc,UAAU,aAAa;AAAA,EACvC,GAAG,CAAC,WAAW,QAAQ,OAAO,CAAC;AAG/B,QAAM,EAAE,MAAM,iBAAiB,IAAI,SAAS;AAAA,IAC1C,UAAU,CAAC,cAAc;AAAA,IACzB,SAAS,YAAY;AACnB,YAAM,SAAS,MAAM;AAAA,QACnB;AAAA,MACF;AACA,aAAO,OAAO,KAAK,OAAO,SAAS,EAAE,OAAO,CAAC,EAAE;AAAA,IACjD;AAAA,IACA,WAAW;AAAA,EACb,CAAC;AAED,QAAM,0BAA0B,MAAM,QAAQ,MAAM;AAClD,UAAM,OAAO,kBAAkB,OAAO,KAAK,OAAK,EAAE,QAAQ,oBAAoB;AAC9E,WAAO,MAAM;AAAA,EACf,GAAG,CAAC,gBAAgB,CAAC;AAGrB,QAAM,EAAE,MAAM,kBAAkB,IAAI,SAAS;AAAA,IAC3C,UAAU,CAAC,sBAAsB,uBAAuB;AAAA,IACxD,SAAS,YAAmD;AAC1D,UAAI,CAAC,wBAAyB,QAAO,EAAE,OAAO,CAAC,EAAE;AACjD,YAAM,SAAS,MAAM,0BAA0B,uBAAuB;AACtE,UAAI,CAAC,OAAO,GAAI,QAAO,EAAE,OAAO,CAAC,EAAE;AACnC,YAAM,QAAQ,OAAO,MAAM,QAAQ,CAAC,UAAU;AAC5C,cAAM,KAAK,OAAO,MAAM,OAAO,WAAW,MAAM,KAAK;AACrD,cAAM,QAAQ,OAAO,MAAM,UAAU,WAAW,MAAM,QAAQ;AAC9D,YAAI,CAAC,MAAM,CAAC,MAAO,QAAO,CAAC;AAC3B,cAAM,QAAQ,OAAO,MAAM,UAAU,YAAY,MAAM,MAAM,KAAK,EAAE,SAAS,MAAM,QAAQ;AAC3F,eAAO,CAAC,EAAE,IAAI,OAAO,MAAM,CAAC;AAAA,MAC9B,CAAC;AACD,aAAO,EAAE,MAAM;AAAA,IACjB;AAAA,IACA,SAAS,QAAQ,uBAAuB;AAAA,IACxC,WAAW;AAAA,EACb,CAAC;AAED,QAAM,eAAe,MAAM,YAAY,CAAC,SAAiB;AACvD,UAAM,UAAU,mBAAmB,SAAS,CAAC;AAE7C,UAAM,QAAQ,QAAQ;AAAA,MACpB,CAAC,MAAM,EAAE,UAAU,QAAQ,EAAE,OAAO,YAAY,MAAM,KAAK,YAAY;AAAA,IACzE;AACA,WAAO,OAAO;AAAA,EAChB,GAAG,CAAC,iBAAiB,CAAC;AAGtB,QAAM,wBAAwB,YAAY;AAAA,IACxC,YAAY,YAAY;AACtB,YAAM,0BAA0B,aAAa,kBAAkB;AAC/D,YAAM,mBAAmB,aAAa,UAAU;AAChD,YAAM,mBAAmB,aAAa,UAAU;AAEhD,UAAI,CAAC,SAAS;AACZ,cAAM,IAAI,MAAM,EAAE,0CAA0C,iEAAiE,CAAC;AAAA,MAChI;AAEA,UAAI,CAAC,2BAA2B,CAAC,oBAAoB,CAAC,kBAAkB;AACtE,cAAM,IAAI,MAAM,EAAE,2CAA2C,2IAA2I,CAAC;AAAA,MAC3M;AAIA,YAAM,iBAA8C;AAAA,QAClD;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAEA,YAAM,SAAS,MAAM,QAAoC,4BAA4B;AAAA,QACnF,QAAQ;AAAA,QACR,MAAM,KAAK,UAAU;AAAA,UACnB,YAAY;AAAA,UACZ;AAAA,UACA,UAAU;AAAA,YACR,YAAY;AAAA,YACZ,UAAU;AAAA,UACZ;AAAA,QACF,CAAC;AAAA,MACH,CAAC;AAED,UAAI,CAAC,OAAO,IAAI;AACd,cAAM,cAAc,OAAO;AAC3B,cAAM,IAAI,MAAM,aAAa,SAAS,EAAE,sCAAsC,mCAAmC,CAAC;AAAA,MACpH;AAEA,aAAO,OAAO;AAAA,IAChB;AAAA,IACA,WAAW,MAAM;AACf,eAAS,IAAI;AACb,kBAAY,kBAAkB,EAAE,UAAU,CAAC,sBAAsB,OAAO,EAAE,CAAC;AAAA,IAC7E;AAAA,IACA,SAAS,CAAC,QAAe;AACvB,eAAS,IAAI,OAAO;AAAA,IACtB;AAAA,EACF,CAAC;AAGD,QAAM,uBAAuB,YAAY;AAAA,IACvC,YAAY,OAAO,EAAE,QAAQ,SAAS,MAA6E;AACjH,YAAM,SAAS,MAAM,QAAQ,wBAAwB,MAAM,aAAa;AAAA,QACtE,QAAQ;AAAA,QACR,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC;AAAA,MACnC,CAAC;AAED,UAAI,CAAC,OAAO,IAAI;AACd,cAAM,cAAc,OAAO;AAC3B,cAAM,IAAI,MAAM,aAAa,SAAS,EAAE,yCAAyC,kCAAkC,CAAC;AAAA,MACtH;AAEA,aAAO,OAAO;AAAA,IAChB;AAAA,IACA,WAAW,MAAM;AACf,eAAS,IAAI;AACb,kBAAY,EAAE;AACd,kBAAY,EAAE;AAEd,gCAA0B,IAAI;AAC9B,kBAAY,kBAAkB,EAAE,UAAU,CAAC,sBAAsB,OAAO,EAAE,CAAC;AAC3E,kBAAY,kBAAkB,EAAE,UAAU,CAAC,kBAAkB,gBAAgB,EAAE,EAAE,CAAC;AAElF,kBAAY,kBAAkB,EAAE,UAAU,CAAC,eAAe,OAAO,EAAE,CAAC;AAAA,IACtE;AAAA,IACA,SAAS,CAAC,QAAe;AACvB,eAAS,IAAI,OAAO;AAAA,IACtB;AAAA,EACF,CAAC;AAED,QAAM,sBAAsB,MAAM;AAEhC,8BAA0B,IAAI;AAC9B,0BAAsB,OAAO;AAAA,EAC/B;AAEA,QAAM,qBAAqB,MAAM;AAC/B,QAAI,CAAC,eAAe,CAAC,SAAU;AAC/B,yBAAqB,OAAO;AAAA,MAC1B,QAAQ,YAAY;AAAA,MACpB,UAAU,EAAE,UAAU,SAAS;AAAA,IACjC,CAAC;AAAA,EACH;AAGA,QAAM,gBAAgB,CAAC,MAA2B;AAChD,SAAK,EAAE,WAAW,EAAE,YAAY,EAAE,QAAQ,SAAS;AACjD,QAAE,eAAe;AACjB,UAAI,eAAe,YAAY,CAAC,cAAc;AAC5C,2BAAmB;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AAEA,QAAM,YAAY,oBAAoB;AACtC,QAAM,eAAe,sBAAsB,aAAa,qBAAqB;AAG7E,MAAI,CAAC,QAAS,QAAO;AAGrB,QAAM,cAAc,oBAAoB,YAAY;AACpD,MAAI,gBAAgB,oBAAoB;AACtC,WAAO;AAAA,EACT;AAGA,MAAI,WAAW;AACb,WACE,qBAAC,SAAI,WAAU,wCACb;AAAA,0BAAC,WAAQ,MAAK,MAAK;AAAA,MACnB,oBAAC,UAAK,WAAU,sCAAsC,YAAE,kBAAkB,YAAY,GAAE;AAAA,OAC1F;AAAA,EAEJ;AAGA,QAAM,iBAAiB,MAAM;AAC3B,QAAI,CAAC,eAAgB,QAAO;AAE5B,UAAM,iBAAsF;AAAA,MAC1F,SAAS;AAAA,MACT,QAAQ;AAAA,MACR,wBAAwB;AAAA,MACxB,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,WAAW;AAAA,IACb;AAEA,WACE,oBAAC,SAAM,SAAS,eAAe,eAAe,MAAM,KAAK,WACtD,YAAE,gCAAgC,eAAe,MAAM,IAAI,eAAe,MAAM,GACnF;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,qDACb;AAAA,yBAAC,SAAI,WAAU,0CACb;AAAA,2BAAC,SACC;AAAA,4BAAC,SAAI,WAAU,yCACZ,YAAE,sCAAsC,gBAAgB,GAC3D;AAAA,QACA,oBAAC,OAAE,WAAU,iCACV,YAAE,4CAA4C,yCAAyC,GAC1F;AAAA,SACF;AAAA,MACC,eAAe;AAAA,OAClB;AAAA,IAEC,SACC,oBAAC,SAAI,WAAU,uEACZ,iBACH;AAAA,IAID,CAAC,kBACA,qBAAC,SAAI,WAAU,aACb;AAAA,0BAAC,OAAE,WAAU,iCACV,YAAE,4CAA4C,gDAAgD,GACjG;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,SAAS;AAAA,UACT,UAAU;AAAA,UACV,SAAQ;AAAA,UACR,MAAK;AAAA,UAEJ;AAAA,4BAAgB,oBAAC,WAAQ,MAAK,MAAK,WAAU,QAAO;AAAA,YACpD,EAAE,2CAA2C,kBAAkB;AAAA;AAAA;AAAA,MAClE;AAAA,OACF;AAAA,IAID,kBAAkB,eACjB,qBAAC,SAAI,WAAU,aAAY,WAAW,eACpC;AAAA,2BAAC,SAAI,WAAU,sDACb;AAAA,4BAAC,OAAE,WAAU,sCACV,YAAE,wCAAwC,kBAAkB,GAC/D;AAAA,QACA,oBAAC,OAAE,WAAU,+BACV,YAAE,8CAA8C,iDAAiD,GACpG;AAAA,SACF;AAAA,MAEA,qBAAC,SAAI,WAAU,aACb;AAAA,4BAAC,WAAM,WAAU,uBACd,YAAE,yCAAyC,UAAU,GACxD;AAAA,QACA,qBAAC,SAAI,WAAU,cACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,SAAS,aAAa,YAAY,YAAY;AAAA,cAC9C,MAAK;AAAA,cACL,SAAS,MAAM,YAAY,SAAS;AAAA,cACpC,WAAW,aAAa,YAAY,wCAAwC;AAAA,cAE3E,YAAE,yCAAyC,SAAS;AAAA;AAAA,UACvD;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,SAAS,aAAa,WAAW,gBAAgB;AAAA,cACjD,MAAK;AAAA,cACL,SAAS,MAAM,YAAY,QAAQ;AAAA,cAElC,YAAE,wCAAwC,QAAQ;AAAA;AAAA,UACrD;AAAA,WACF;AAAA,SACF;AAAA,MAEA,qBAAC,SAAI,WAAU,aACb;AAAA,6BAAC,WAAM,WAAU,uBACd;AAAA,YAAE,yCAAyC,UAAU;AAAA,UAAE;AAAA,UAAC,qBAAC,UAAK,WAAU,qCAAoC;AAAA;AAAA,YAAE,EAAE,mBAAmB,UAAU;AAAA,YAAE;AAAA,aAAC;AAAA,WACnJ;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,OAAO;AAAA,YACP,UAAU,CAAC,MAAM,YAAY,EAAE,OAAO,KAAK;AAAA,YAC3C,aAAa,EAAE,+CAA+C,0BAA0B;AAAA,YACxF,MAAM;AAAA;AAAA,QACR;AAAA,SACF;AAAA,MAEA;AAAA,QAAC;AAAA;AAAA,UACC,SAAS;AAAA,UACT,UAAU,CAAC,YAAY;AAAA,UACvB,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,WAAU;AAAA,UAET;AAAA,4BAAgB,oBAAC,WAAQ,MAAK,MAAK,WAAU,QAAO;AAAA,YACpD,EAAE,0CAA0C,iBAAiB;AAAA;AAAA;AAAA,MAChE;AAAA,OACF;AAAA,IAID,kBAAkB,CAAC,eAAe,eAAe,WAAW,eAC3D,oBAAC,SAAI,WAAU,oDACb,8BAAC,OAAE,WAAU,yBACV,YAAE,sCAAsC,2BAA2B,GACtE,GACF;AAAA,IAID,kBAAkB,eAAe,WAAW,eAC3C,oBAAC,SAAI,WAAU,0DACb,8BAAC,OAAE,WAAU,4BACV,YAAE,qCAAqC,8BAA8B,GACxE,GACF;AAAA,KAEJ;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const id = "id";
|
|
2
|
+
export const tenant_id = "tenant_id";
|
|
3
|
+
export const organization_id = "organization_id";
|
|
4
|
+
export const reserved_bytes = "reserved_bytes";
|
|
5
|
+
export const actual_bytes = "actual_bytes";
|
|
6
|
+
export const status = "status";
|
|
7
|
+
export const source = "source";
|
|
8
|
+
export const storage_driver = "storage_driver";
|
|
9
|
+
export const partition_code = "partition_code";
|
|
10
|
+
export const storage_path = "storage_path";
|
|
11
|
+
export const lease_token = "lease_token";
|
|
12
|
+
export const upload_token_hash = "upload_token_hash";
|
|
13
|
+
export const expires_at = "expires_at";
|
|
14
|
+
export const created_at = "created_at";
|
|
15
|
+
export const updated_at = "updated_at";
|
|
@@ -117,7 +117,8 @@ export const M = {
|
|
|
117
117
|
},
|
|
118
118
|
"attachments": {
|
|
119
119
|
"attachment_partition": "attachments:attachment_partition",
|
|
120
|
-
"attachment": "attachments:attachment"
|
|
120
|
+
"attachment": "attachments:attachment",
|
|
121
|
+
"attachment_quota_reservation": "attachments:attachment_quota_reservation"
|
|
121
122
|
},
|
|
122
123
|
"catalog": {
|
|
123
124
|
"catalog_option_schema_template": "catalog:catalog_option_schema_template",
|
|
@@ -94,6 +94,23 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
|
|
|
94
94
|
"created_at": "created_at",
|
|
95
95
|
"updated_at": "updated_at"
|
|
96
96
|
},
|
|
97
|
+
"attachment_quota_reservation": {
|
|
98
|
+
"id": "id",
|
|
99
|
+
"tenant_id": "tenant_id",
|
|
100
|
+
"organization_id": "organization_id",
|
|
101
|
+
"reserved_bytes": "reserved_bytes",
|
|
102
|
+
"actual_bytes": "actual_bytes",
|
|
103
|
+
"status": "status",
|
|
104
|
+
"source": "source",
|
|
105
|
+
"storage_driver": "storage_driver",
|
|
106
|
+
"partition_code": "partition_code",
|
|
107
|
+
"storage_path": "storage_path",
|
|
108
|
+
"lease_token": "lease_token",
|
|
109
|
+
"upload_token_hash": "upload_token_hash",
|
|
110
|
+
"expires_at": "expires_at",
|
|
111
|
+
"created_at": "created_at",
|
|
112
|
+
"updated_at": "updated_at"
|
|
113
|
+
},
|
|
97
114
|
"business_rule": {
|
|
98
115
|
"id": "id",
|
|
99
116
|
"rule_id": "rule_id",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6860.1.f796b22e80",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -254,16 +254,16 @@
|
|
|
254
254
|
"zod": "^4.4.3"
|
|
255
255
|
},
|
|
256
256
|
"peerDependencies": {
|
|
257
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
258
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
259
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
257
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6860.1.f796b22e80",
|
|
258
|
+
"@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
|
|
259
|
+
"@open-mercato/ui": "0.6.7-develop.6860.1.f796b22e80",
|
|
260
260
|
"react": "^19.0.0",
|
|
261
261
|
"react-dom": "^19.0.0"
|
|
262
262
|
},
|
|
263
263
|
"devDependencies": {
|
|
264
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
265
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
266
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
264
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6860.1.f796b22e80",
|
|
265
|
+
"@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
|
|
266
|
+
"@open-mercato/ui": "0.6.7-develop.6860.1.f796b22e80",
|
|
267
267
|
"@testing-library/dom": "^10.4.1",
|
|
268
268
|
"@testing-library/jest-dom": "^7.0.0",
|
|
269
269
|
"@testing-library/react": "^16.3.1",
|
package/src/bootstrap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import { asValue } from 'awilix'
|
|
3
|
-
import type
|
|
3
|
+
import { getDefaultEncryptionMaps, type ModuleSubscriber } from '@open-mercato/shared/modules/registry'
|
|
4
|
+
import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'
|
|
4
5
|
import { createEventBus } from '@open-mercato/events/index'
|
|
5
6
|
import { setGlobalEventBus } from '@open-mercato/shared/modules/events'
|
|
6
7
|
import { createCacheService } from '@open-mercato/cache'
|
|
@@ -184,12 +185,26 @@ export async function bootstrap(container: AwilixContainer) {
|
|
|
184
185
|
// KMS + tenant encryption
|
|
185
186
|
const kmsService = createKmsService()
|
|
186
187
|
container.register({ kmsService: asValue(kmsService) })
|
|
188
|
+
let defaultEncryptionMaps: ModuleEncryptionMap[] = []
|
|
189
|
+
if (isTenantDataEncryptionEnabled()) {
|
|
190
|
+
try {
|
|
191
|
+
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
192
|
+
defaultEncryptionMaps = getDefaultEncryptionMaps(getModules())
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logger.error('Failed to load default encryption maps', { component: 'encryption', err })
|
|
195
|
+
throw err
|
|
196
|
+
}
|
|
197
|
+
}
|
|
187
198
|
try {
|
|
188
199
|
const em = container.resolve('em') as EntityManager
|
|
189
200
|
const cacheService = (() => {
|
|
190
201
|
try { return container.resolve('cache') as any } catch { return null }
|
|
191
202
|
})()
|
|
192
|
-
const tenantEncryptionService = new TenantDataEncryptionService(em, {
|
|
203
|
+
const tenantEncryptionService = new TenantDataEncryptionService(em, {
|
|
204
|
+
cache: cacheService,
|
|
205
|
+
kms: kmsService,
|
|
206
|
+
defaultEncryptionMaps,
|
|
207
|
+
})
|
|
193
208
|
container.register({ tenantEncryptionService: asValue(tenantEncryptionService) })
|
|
194
209
|
if (isTenantDataEncryptionEnabled() && kmsService.isHealthy()) {
|
|
195
210
|
try {
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from '../lib/upload-limits'
|
|
46
46
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
47
47
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
48
|
+
import type { AttachmentQuotaService } from '../lib/quota-service'
|
|
48
49
|
|
|
49
50
|
const logger = createLogger('attachments')
|
|
50
51
|
|
|
@@ -319,6 +320,20 @@ export async function POST(req: Request) {
|
|
|
319
320
|
const container = await createRequestContainer()
|
|
320
321
|
const em = container.resolve('em') as EntityManager
|
|
321
322
|
const dataEngine = container.resolve('dataEngine')
|
|
323
|
+
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
324
|
+
let attachmentQuotaRecoveryScheduler: ((
|
|
325
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
326
|
+
delayMs: number,
|
|
327
|
+
) => Promise<void>) | null = null
|
|
328
|
+
try {
|
|
329
|
+
attachmentQuotaService = container.resolve('attachmentQuotaService') as AttachmentQuotaService
|
|
330
|
+
attachmentQuotaRecoveryScheduler = container.resolve('attachmentQuotaRecoveryScheduler') as (
|
|
331
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
332
|
+
delayMs: number,
|
|
333
|
+
) => Promise<void>
|
|
334
|
+
} catch {
|
|
335
|
+
// Legacy fallback below when the quota service is not registered in this container.
|
|
336
|
+
}
|
|
322
337
|
const storageDriverFactory =
|
|
323
338
|
(container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
|
|
324
339
|
const orgId = await resolveAttachmentOrganizationId(container, auth, req)
|
|
@@ -363,11 +378,20 @@ export async function POST(req: Request) {
|
|
|
363
378
|
error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
|
|
364
379
|
}, { status: 413 })
|
|
365
380
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
381
|
+
if (!attachmentQuotaService) {
|
|
382
|
+
try {
|
|
383
|
+
const tenantUsageBytes = await readTenantAttachmentUsageBytes(em, tenantId)
|
|
384
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, file.size)) {
|
|
385
|
+
return NextResponse.json({
|
|
386
|
+
error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),
|
|
387
|
+
}, { status: 413 })
|
|
388
|
+
}
|
|
389
|
+
} catch (error) {
|
|
390
|
+
logger.error('Attachment quota accounting failed', { err: error })
|
|
391
|
+
return NextResponse.json({
|
|
392
|
+
error: t('attachments.errors.quotaUnavailable', 'Storage quota accounting is unavailable.'),
|
|
393
|
+
}, { status: 500 })
|
|
394
|
+
}
|
|
371
395
|
}
|
|
372
396
|
const buf = Buffer.from(await file.arrayBuffer())
|
|
373
397
|
const safeName = sanitizeUploadedFileName(file.name)
|
|
@@ -409,6 +433,62 @@ export async function POST(req: Request) {
|
|
|
409
433
|
return NextResponse.json({ error: t('attachments.errors.publicPartitionBlocked', 'Public storage partitions cannot be selected explicitly for this upload.') }, { status: 403 })
|
|
410
434
|
}
|
|
411
435
|
const uploadDriver = await storageDriverFactory.resolveForPartition(partition.code, { tenantId, organizationId: orgId })
|
|
436
|
+
const preparedStoragePath = attachmentQuotaService
|
|
437
|
+
? uploadDriver.prepareStoragePath?.({
|
|
438
|
+
partitionCode: partition.code,
|
|
439
|
+
orgId,
|
|
440
|
+
tenantId,
|
|
441
|
+
fileName: safeName,
|
|
442
|
+
})
|
|
443
|
+
: undefined
|
|
444
|
+
if (attachmentQuotaService && !preparedStoragePath) {
|
|
445
|
+
return NextResponse.json({
|
|
446
|
+
error: t('attachments.errors.quotaRecoveryUnsupported', 'Storage driver cannot participate in quota recovery.'),
|
|
447
|
+
}, { status: 500 })
|
|
448
|
+
}
|
|
449
|
+
let quotaReservation: { id: string; leaseToken: string; expiresAt: Date } | null = null
|
|
450
|
+
if (attachmentQuotaService) {
|
|
451
|
+
try {
|
|
452
|
+
quotaReservation = await attachmentQuotaService.reserve({
|
|
453
|
+
tenantId,
|
|
454
|
+
organizationId: orgId,
|
|
455
|
+
bytes: buf.length,
|
|
456
|
+
source: 'attachment',
|
|
457
|
+
storageDriver: uploadDriver.key,
|
|
458
|
+
storagePath: preparedStoragePath!,
|
|
459
|
+
partitionCode: partition.code,
|
|
460
|
+
})
|
|
461
|
+
if (!attachmentQuotaRecoveryScheduler) throw new Error('Attachment quota recovery is unavailable.')
|
|
462
|
+
await attachmentQuotaRecoveryScheduler({
|
|
463
|
+
reservationId: quotaReservation.id,
|
|
464
|
+
tenantId,
|
|
465
|
+
organizationId: orgId,
|
|
466
|
+
}, Math.max(1_000, quotaReservation.expiresAt.getTime() - Date.now()))
|
|
467
|
+
if (typeof attachmentQuotaService.beginStorage === 'function') {
|
|
468
|
+
await attachmentQuotaService.beginStorage(quotaReservation.id, quotaReservation.leaseToken)
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (quotaReservation) {
|
|
472
|
+
await attachmentQuotaService.release(quotaReservation.id, quotaReservation.leaseToken).catch(() => {})
|
|
473
|
+
quotaReservation = null
|
|
474
|
+
}
|
|
475
|
+
const code = (error as { code?: unknown })?.code
|
|
476
|
+
if (code === 'quota_exceeded') {
|
|
477
|
+
return NextResponse.json({
|
|
478
|
+
error: t('attachments.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.'),
|
|
479
|
+
}, { status: 413 })
|
|
480
|
+
}
|
|
481
|
+
if (code === 'quota_target_exists') {
|
|
482
|
+
return NextResponse.json({
|
|
483
|
+
error: t('attachments.errors.storagePathExists', 'The target storage path already exists.'),
|
|
484
|
+
}, { status: 409 })
|
|
485
|
+
}
|
|
486
|
+
logger.error('Attachment quota reservation failed', { err: error })
|
|
487
|
+
return NextResponse.json({
|
|
488
|
+
error: t('attachments.errors.quotaUnavailable', 'Storage quota accounting is unavailable.'),
|
|
489
|
+
}, { status: 500 })
|
|
490
|
+
}
|
|
491
|
+
}
|
|
412
492
|
let storedPath: string
|
|
413
493
|
try {
|
|
414
494
|
const stored = await uploadDriver.store({
|
|
@@ -417,10 +497,25 @@ export async function POST(req: Request) {
|
|
|
417
497
|
tenantId,
|
|
418
498
|
fileName: safeName,
|
|
419
499
|
buffer: buf,
|
|
500
|
+
storagePath: preparedStoragePath,
|
|
420
501
|
})
|
|
421
502
|
storedPath = stored.storagePath
|
|
503
|
+
if (quotaReservation) {
|
|
504
|
+
await attachmentQuotaService!.markStored(quotaReservation.id, quotaReservation.leaseToken)
|
|
505
|
+
}
|
|
422
506
|
} catch (error) {
|
|
423
507
|
logger.error('Failed to persist file', { err: error })
|
|
508
|
+
if (preparedStoragePath) {
|
|
509
|
+
try {
|
|
510
|
+
await (uploadDriver.deleteStrict?.(partition.code, preparedStoragePath)
|
|
511
|
+
?? uploadDriver.delete(partition.code, preparedStoragePath))
|
|
512
|
+
if (quotaReservation) {
|
|
513
|
+
await attachmentQuotaService!.release(quotaReservation.id, quotaReservation.leaseToken)
|
|
514
|
+
}
|
|
515
|
+
} catch (cleanupError) {
|
|
516
|
+
logger.error('Failed to compensate attachment storage', { err: cleanupError })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
424
519
|
return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
|
|
425
520
|
}
|
|
426
521
|
|
|
@@ -485,9 +580,21 @@ export async function POST(req: Request) {
|
|
|
485
580
|
values: customFieldValues,
|
|
486
581
|
})
|
|
487
582
|
}
|
|
583
|
+
if (quotaReservation) {
|
|
584
|
+
await attachmentQuotaService!.completeAttachment(quotaReservation.id, quotaReservation.leaseToken, tx)
|
|
585
|
+
}
|
|
488
586
|
})
|
|
489
587
|
} catch (error) {
|
|
490
588
|
logger.error('Failed to persist attachment with custom attributes', { err: error })
|
|
589
|
+
try {
|
|
590
|
+
await (uploadDriver.deleteStrict?.(partition.code, storedPath)
|
|
591
|
+
?? uploadDriver.delete(partition.code, storedPath))
|
|
592
|
+
if (quotaReservation) {
|
|
593
|
+
await attachmentQuotaService!.release(quotaReservation.id, quotaReservation.leaseToken)
|
|
594
|
+
}
|
|
595
|
+
} catch (cleanupError) {
|
|
596
|
+
logger.error('Failed to compensate attachment persistence', { err: cleanupError })
|
|
597
|
+
}
|
|
491
598
|
return NextResponse.json({ error: 'Failed to save attachment attributes.' }, { status: 500 })
|
|
492
599
|
}
|
|
493
600
|
|
|
@@ -551,8 +658,8 @@ async function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: strin
|
|
|
551
658
|
return Number.isFinite(parsed) ? parsed : 0
|
|
552
659
|
}
|
|
553
660
|
return 0
|
|
554
|
-
} catch {
|
|
555
|
-
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throw new Error('Attachment quota accounting is unavailable.', { cause: error })
|
|
556
663
|
}
|
|
557
664
|
}
|
|
558
665
|
|
|
@@ -99,3 +99,62 @@ export class Attachment {
|
|
|
99
99
|
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
100
100
|
createdAt: Date = new Date()
|
|
101
101
|
}
|
|
102
|
+
|
|
103
|
+
@Entity({ tableName: 'attachment_quota_reservations' })
|
|
104
|
+
@Unique({
|
|
105
|
+
name: 'attachment_quota_reservations_scope_path_unique',
|
|
106
|
+
properties: ['tenantId', 'storageDriver', 'storagePath'],
|
|
107
|
+
})
|
|
108
|
+
@Index({
|
|
109
|
+
name: 'attachment_quota_reservations_tenant_status_idx',
|
|
110
|
+
properties: ['tenantId', 'status'],
|
|
111
|
+
})
|
|
112
|
+
export class AttachmentQuotaReservation {
|
|
113
|
+
[OptionalProps]?: 'actualBytes' | 'createdAt' | 'status' | 'updatedAt' | 'uploadTokenHash'
|
|
114
|
+
|
|
115
|
+
@PrimaryKey({ type: 'uuid' })
|
|
116
|
+
id!: string
|
|
117
|
+
|
|
118
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
119
|
+
tenantId!: string
|
|
120
|
+
|
|
121
|
+
@Property({ name: 'organization_id', type: 'uuid' })
|
|
122
|
+
organizationId!: string
|
|
123
|
+
|
|
124
|
+
@Property({ name: 'reserved_bytes', type: 'bigint' })
|
|
125
|
+
reservedBytes!: number
|
|
126
|
+
|
|
127
|
+
@Property({ name: 'actual_bytes', type: 'bigint', nullable: true })
|
|
128
|
+
actualBytes?: number | null
|
|
129
|
+
|
|
130
|
+
@Property({ type: 'text', default: 'reserved' })
|
|
131
|
+
status: 'reserved' | 'storing' | 'stored' | 'recovering' | 'committed' = 'reserved'
|
|
132
|
+
|
|
133
|
+
@Property({ type: 'text' })
|
|
134
|
+
source!: string
|
|
135
|
+
|
|
136
|
+
@Property({ name: 'storage_driver', type: 'text' })
|
|
137
|
+
storageDriver!: string
|
|
138
|
+
|
|
139
|
+
@Property({ name: 'partition_code', type: 'text', nullable: true })
|
|
140
|
+
partitionCode?: string | null
|
|
141
|
+
|
|
142
|
+
@Property({ name: 'storage_path', type: 'text' })
|
|
143
|
+
storagePath!: string
|
|
144
|
+
|
|
145
|
+
@Property({ name: 'lease_token', type: 'uuid' })
|
|
146
|
+
leaseToken!: string
|
|
147
|
+
|
|
148
|
+
@Property({ name: 'upload_token_hash', type: 'text', nullable: true })
|
|
149
|
+
uploadTokenHash?: string | null
|
|
150
|
+
|
|
151
|
+
@Property({ name: 'expires_at', type: Date, nullable: true })
|
|
152
|
+
@Index({ name: 'attachment_quota_reservations_expires_idx' })
|
|
153
|
+
expiresAt?: Date | null
|
|
154
|
+
|
|
155
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
156
|
+
createdAt: Date = new Date()
|
|
157
|
+
|
|
158
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
159
|
+
updatedAt: Date = new Date()
|
|
160
|
+
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { asFunction } from 'awilix'
|
|
1
|
+
import { asFunction, asValue } from 'awilix'
|
|
2
2
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
3
|
import { StorageDriverFactory } from './lib/drivers/driverFactory'
|
|
4
|
+
import { createAttachmentQuotaService } from './lib/quota-service'
|
|
5
|
+
import { scheduleAttachmentQuotaRecovery } from './lib/quota-recovery-queue'
|
|
4
6
|
|
|
5
7
|
export function register(container: AppContainer) {
|
|
6
8
|
container.register({
|
|
9
|
+
attachmentQuotaRecoveryScheduler: asValue(scheduleAttachmentQuotaRecovery),
|
|
10
|
+
attachmentQuotaService: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
|
|
11
|
+
createAttachmentQuotaService(em),
|
|
12
|
+
)
|
|
13
|
+
.scoped()
|
|
14
|
+
.proxy(),
|
|
7
15
|
storageDriverFactory: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
|
|
8
16
|
new StorageDriverFactory(em),
|
|
9
17
|
)
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "Öffentliche Speicherpartitionen können für diesen Upload nicht explizit ausgewählt werden.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Das Speicherlimit für Anhänge dieses Tenants wurde überschritten.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "Der Speichertreiber kann nicht an der Kontingent-Wiederherstellung teilnehmen.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "Die Speicherkontingent-Abrechnung ist nicht verfügbar.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Wählen Sie eine bestimmte Organisation aus, bevor Sie einen Anhang hochladen.",
|
|
10
|
+
"attachments.errors.storagePathExists": "Der Zielspeicherpfad existiert bereits.",
|
|
8
11
|
"attachments.library.actions.copied": "Link kopiert.",
|
|
9
12
|
"attachments.library.actions.copyError": "Link konnte nicht kopiert werden.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "URL kopieren",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "Public storage partitions cannot be selected explicitly for this upload.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "Storage driver cannot participate in quota recovery.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Select a specific organization before uploading an attachment.",
|
|
10
|
+
"attachments.errors.storagePathExists": "The target storage path already exists.",
|
|
8
11
|
"attachments.library.actions.copied": "Link copied.",
|
|
9
12
|
"attachments.library.actions.copyError": "Unable to copy link.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "Copy URL",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "No se pueden seleccionar explícitamente particiones de almacenamiento públicas para esta carga.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este tenant.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "El controlador de almacenamiento no puede participar en la recuperación de cuota.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Selecciona una organización específica antes de subir un adjunto.",
|
|
10
|
+
"attachments.errors.storagePathExists": "La ruta de almacenamiento de destino ya existe.",
|
|
8
11
|
"attachments.library.actions.copied": "Enlace copiado.",
|
|
9
12
|
"attachments.library.actions.copyError": "No se pudo copiar el enlace.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "Copiar URL",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "첨부 파일이 최대 업로드 크기를 초과합니다.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "이 업로드에는 공개 저장소 파티션을 명시적으로 선택할 수 없습니다.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "이 테넌트의 첨부 파일 저장소 할당량을 초과했습니다.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "저장 용량 복구가 지원되지 않습니다.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "첨부파일 저장 용량 정보를 확인할 수 없습니다.",
|
|
7
9
|
"attachments.errors.selectOrganization": "첨부파일을 업로드하기 전에 특정 조직을 선택하세요.",
|
|
10
|
+
"attachments.errors.storagePathExists": "대상 저장 경로가 이미 존재합니다.",
|
|
8
11
|
"attachments.library.actions.copied": "링크가 복사되었습니다.",
|
|
9
12
|
"attachments.library.actions.copyError": "링크를 복사할 수 없습니다.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "URL 복사",
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"attachments.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
|
|
5
5
|
"attachments.errors.publicPartitionBlocked": "Nie można jawnie wybrać publicznej partycji do tego przesyłania.",
|
|
6
6
|
"attachments.errors.quotaExceeded": "Przekroczono limit miejsca na załączniki dla tego tenantu.",
|
|
7
|
+
"attachments.errors.quotaRecoveryUnsupported": "Sterownik przechowywania nie może uczestniczyć w odzyskiwaniu limitu.",
|
|
8
|
+
"attachments.errors.quotaUnavailable": "Rozliczanie limitu przestrzeni dyskowej jest niedostępne.",
|
|
7
9
|
"attachments.errors.selectOrganization": "Wybierz konkretną organizację przed przesłaniem załącznika.",
|
|
10
|
+
"attachments.errors.storagePathExists": "Docelowa ścieżka przechowywania już istnieje.",
|
|
8
11
|
"attachments.library.actions.copied": "Link skopiowany.",
|
|
9
12
|
"attachments.library.actions.copyError": "Nie można skopiować linku.",
|
|
10
13
|
"attachments.library.actions.copyUrl": "Kopiuj URL",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
|
|
1
|
+
export type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from './types'
|
|
2
2
|
export { LocalStorageDriver } from './localDriver'
|
|
3
3
|
export { LegacyPublicStorageDriver } from './legacyPublicDriver'
|
|
4
4
|
export { StorageDriverFactory, registerExternalStorageDriver, registerExternalCredentialEnhancer } from './driverFactory'
|