@open-mercato/core 0.9.1-develop.7459.1.99e3accd30 → 0.9.1-develop.7464.1.9a392fca41
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/attachments/api/route.js +29 -3
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/ocrLimits.js +49 -0
- package/dist/modules/attachments/lib/ocrLimits.js.map +7 -0
- package/dist/modules/attachments/lib/ocrQueue.js +76 -2
- package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
- package/dist/modules/attachments/lib/ocrService.js +4 -0
- package/dist/modules/attachments/lib/ocrService.js.map +2 -2
- package/dist/modules/attachments/lib/pdfProcessing.js +14 -2
- package/dist/modules/attachments/lib/pdfProcessing.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +4 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/textExtraction.js +13 -1
- package/dist/modules/attachments/lib/textExtraction.js.map +2 -2
- package/dist/modules/catalog/search.js +3 -1
- package/dist/modules/catalog/search.js.map +2 -2
- package/dist/modules/communication_channels/api/get/oauth/[provider]/callback/route.js +2 -1
- package/dist/modules/communication_channels/api/get/oauth/[provider]/callback/route.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +10 -7
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/lib/oauth-result.js +12 -0
- package/dist/modules/communication_channels/lib/oauth-result.js.map +7 -0
- package/dist/modules/customers/components/detail/ActivitiesSection.js +8 -1
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/route.ts +36 -3
- package/src/modules/attachments/lib/ocrLimits.ts +62 -0
- package/src/modules/attachments/lib/ocrQueue.ts +95 -2
- package/src/modules/attachments/lib/ocrService.ts +4 -0
- package/src/modules/attachments/lib/pdfProcessing.ts +14 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +4 -2
- package/src/modules/attachments/lib/textExtraction.ts +14 -1
- package/src/modules/catalog/search.ts +5 -1
- package/src/modules/communication_channels/api/get/oauth/[provider]/callback/route.ts +2 -1
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +12 -9
- package/src/modules/communication_channels/i18n/de.json +0 -1
- package/src/modules/communication_channels/i18n/en.json +0 -1
- package/src/modules/communication_channels/i18n/es.json +0 -1
- package/src/modules/communication_channels/i18n/ko.json +0 -1
- package/src/modules/communication_channels/i18n/pl.json +0 -1
- package/src/modules/communication_channels/lib/oauth-result.ts +13 -0
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +8 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customers/components/detail/ActivitiesSection.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Clock, Search } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport type { SectionAction, TabEmptyStateConfig } from '@open-mercato/ui/backend/detail'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Kbd } from '@open-mercato/ui/primitives/kbd'\nimport { ActivityTimelineFilters } from './ActivityTimelineFilters'\nimport { ActivityTimeline } from './ActivityTimeline'\nimport { resolveAuthorUserNames } from './authorUserLookup'\nimport type { ActivitySummary, InteractionSummary } from './types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\ntype GuardedMutationRunner = <T>(\n operation: () => Promise<T>,\n mutationPayload?: Record<string, unknown>,\n) => Promise<T>\n\nexport type ActivitiesSectionProps = {\n entityId: string | null\n entityName?: string | null\n dealId?: string | null\n useCanonicalInteractions?: boolean\n addActionLabel: string\n emptyState: TabEmptyStateConfig\n onActionChange?: (action: SectionAction | null) => void\n onLoadingChange?: (isLoading: boolean) => void\n onDataRefresh?: () => void\n dealOptions?: Array<{ id: string; label: string }>\n entityOptions?: Array<{ id: string; label: string }>\n defaultEntityId?: string | null\n runGuardedMutation?: GuardedMutationRunner\n refreshKey?: number\n onEditActivity?: (activity: InteractionSummary) => void\n /** Interaction type hidden from the timeline by default ('task' unless overridden); pass null to show every type. */\n excludeInteractionType?: string | null\n}\n\nfunction toDateOnly(value: string | null | undefined): string {\n if (!value) return ''\n const date = new Date(value)\n return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)\n}\n\nfunction normalizeLegacyActivity(activity: ActivitySummary): InteractionSummary {\n return {\n id: activity.id,\n interactionType: activity.activityType,\n title: activity.subject ?? null,\n body: activity.body ?? null,\n status: 'done',\n scheduledAt: null,\n occurredAt: activity.occurredAt ?? null,\n priority: null,\n authorUserId: activity.authorUserId ?? null,\n ownerUserId: null,\n appearanceIcon: activity.appearanceIcon ?? null,\n appearanceColor: activity.appearanceColor ?? null,\n source: 'legacy-activity',\n entityId: activity.entityId ?? null,\n dealId: activity.dealId ?? null,\n organizationId: null,\n tenantId: null,\n authorName: activity.authorName ?? null,\n authorEmail: activity.authorEmail ?? null,\n dealTitle: activity.dealTitle ?? null,\n customValues: activity.customValues ?? null,\n createdAt: activity.createdAt,\n updatedAt: activity.createdAt,\n }\n}\n\nfunction sortTimelineActivities(items: InteractionSummary[]): InteractionSummary[] {\n const now = Date.now()\n return [...items].sort((left, right) => {\n const leftScheduled = left.scheduledAt ? new Date(left.scheduledAt).getTime() : Number.NaN\n const rightScheduled = right.scheduledAt ? new Date(right.scheduledAt).getTime() : Number.NaN\n const leftIsPlanned = left.status === 'planned' && Number.isFinite(leftScheduled)\n const rightIsPlanned = right.status === 'planned' && Number.isFinite(rightScheduled)\n const leftIsUpcoming = leftIsPlanned && leftScheduled >= now\n const rightIsUpcoming = rightIsPlanned && rightScheduled >= now\n\n if (leftIsUpcoming !== rightIsUpcoming) {\n return leftIsUpcoming ? -1 : 1\n }\n\n if (leftIsUpcoming && rightIsUpcoming) {\n if (leftScheduled === rightScheduled) return left.id.localeCompare(right.id)\n return leftScheduled - rightScheduled\n }\n\n const leftTime = left.occurredAt ?? left.createdAt\n const rightTime = right.occurredAt ?? right.createdAt\n const compare = rightTime.localeCompare(leftTime)\n if (compare !== 0) return compare\n return right.id.localeCompare(left.id)\n })\n}\n\nexport function ActivitiesSection({\n entityId,\n entityName,\n dealId,\n useCanonicalInteractions = false,\n onActionChange,\n onLoadingChange,\n refreshKey = 0,\n onEditActivity,\n runGuardedMutation,\n excludeInteractionType = 'task',\n}: ActivitiesSectionProps) {\n const t = useT()\n const [filterTypes, setFilterTypes] = React.useState<string[]>([])\n const [filterDateFrom, setFilterDateFrom] = React.useState('')\n const [filterDateTo, setFilterDateTo] = React.useState('')\n const [searchTerm, setSearchTerm] = React.useState('')\n const [activities, setActivities] = React.useState<InteractionSummary[]>([])\n const [loading, setLoading] = React.useState(false)\n const [hasMore, setHasMore] = React.useState(false)\n const [loadedPages, setLoadedPages] = React.useState(1)\n const searchInputRef = React.useRef<HTMLInputElement>(null)\n\n React.useEffect(() => {\n if (!entityId) return\n function handleShortcut(event: KeyboardEvent) {\n if ((event.metaKey || event.ctrlKey) && event.key === '1') {\n event.preventDefault()\n searchInputRef.current?.focus()\n }\n }\n window.addEventListener('keydown', handleShortcut)\n return () => window.removeEventListener('keydown', handleShortcut)\n }, [entityId])\n\n const visibleActivities = React.useMemo(() => {\n const term = searchTerm.trim().toLowerCase()\n if (!term) return activities\n return activities.filter((activity) => {\n const haystack = [\n activity.title,\n activity.body,\n activity.authorName,\n activity.dealTitle,\n activity.interactionType,\n ]\n .filter((value): value is string => typeof value === 'string' && value.length > 0)\n .join(' ')\n .toLowerCase()\n return haystack.includes(term)\n })\n }, [activities, searchTerm])\n\n React.useEffect(() => {\n onActionChange?.(null)\n return () => onActionChange?.(null)\n }, [onActionChange])\n\n React.useEffect(() => {\n onLoadingChange?.(loading)\n }, [loading, onLoadingChange])\n\n const loadActivities = React.useCallback(async () => {\n if (!entityId) {\n setActivities([])\n return\n }\n\n setLoading(true)\n try {\n // Always fetch canonical interactions (new activities are always created here)\n const excludedFilterActive = excludeInteractionType ? filterTypes.includes(excludeInteractionType) : true\n const canonicalParams = new URLSearchParams({\n entityId,\n limit: '50',\n sortField: 'occurredAt',\n sortDir: 'desc',\n })\n // Hide the configured type (tasks by default \u2014 they have their own tab)\n // from the activity timeline, but lift the exclusion when the user\n // explicitly toggled that type's chip on (mirrors\n // `ActivityHistorySection.tsx` after the #1805 fix).\n if (excludeInteractionType && !excludedFilterActive) canonicalParams.set('excludeInteractionType', excludeInteractionType)\n if (dealId) canonicalParams.set('dealId', dealId)\n if (filterTypes.length > 0) canonicalParams.set('type', filterTypes.join(','))\n if (filterDateFrom) canonicalParams.set('from', filterDateFrom)\n if (filterDateTo) canonicalParams.set('to', filterDateTo)\n\n const canonicalItems: InteractionSummary[] = []\n let canonicalCursor: string | undefined\n let canonicalHasMore = false\n let pageIndex = 0\n do {\n const params = new URLSearchParams(canonicalParams)\n if (canonicalCursor) params.set('cursor', canonicalCursor)\n const canonicalPayload = await readApiResultOrThrow<{ items?: InteractionSummary[]; nextCursor?: string }>(\n `/api/customers/interactions?${params.toString()}`,\n ).catch(() => ({ items: [] as InteractionSummary[], nextCursor: undefined }))\n canonicalItems.push(...(Array.isArray(canonicalPayload?.items) ? canonicalPayload.items : []))\n canonicalCursor = typeof canonicalPayload?.nextCursor === 'string' ? canonicalPayload.nextCursor : undefined\n canonicalHasMore = Boolean(canonicalCursor)\n pageIndex += 1\n } while (canonicalCursor && pageIndex < loadedPages)\n\n if (useCanonicalInteractions) {\n setActivities(sortTimelineActivities(canonicalItems))\n setHasMore(canonicalHasMore)\n return\n }\n\n // In legacy mode, also fetch legacy activities and merge with canonical.\n // Legacy fallback uses known page numbers, so request every page up front\n // and resolve them together instead of awaiting each one sequentially.\n const legacyPageNumbers = Array.from({ length: loadedPages }, (_, index) => index + 1)\n const legacyPayloads = await Promise.all(\n legacyPageNumbers.map((legacyPage) => {\n const legacyParams = new URLSearchParams({\n entityId,\n page: String(legacyPage),\n pageSize: '50',\n sortField: 'occurredAt',\n sortDir: 'desc',\n })\n if (dealId) legacyParams.set('dealId', dealId)\n return readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(\n `/api/customers/activities?${legacyParams.toString()}`,\n ).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 }))\n }),\n )\n // Merge in page order so timeline ordering stays stable regardless of\n // which request settles first.\n const legacyItems: InteractionSummary[] = []\n let legacyTotalPages = 1\n for (const legacyPayload of legacyPayloads) {\n legacyItems.push(...(Array.isArray(legacyPayload?.items) ? legacyPayload.items.map(normalizeLegacyActivity) : []))\n legacyTotalPages = typeof legacyPayload?.totalPages === 'number' ? legacyPayload.totalPages : legacyTotalPages\n }\n const legacyFiltered = legacyItems.filter((entry) => {\n if (filterTypes.length > 0 && !filterTypes.includes(entry.interactionType)) return false\n const dateOnly = toDateOnly(entry.occurredAt ?? entry.createdAt)\n if (filterDateFrom && dateOnly < filterDateFrom) return false\n if (filterDateTo && dateOnly > filterDateTo) return false\n return true\n })\n\n // Merge and deduplicate by id, sort newest first\n const seen = new Set<string>()\n const merged: InteractionSummary[] = []\n for (const item of [...canonicalItems, ...legacyFiltered]) {\n if (!seen.has(item.id)) {\n seen.add(item.id)\n merged.push(item)\n }\n }\n setActivities(sortTimelineActivities(merged))\n setHasMore(canonicalHasMore || legacyTotalPages > loadedPages)\n } catch (error) {\n logger.error('customers.activities.history failed', { err: error })\n flash(t('customers.activities.loadFailed', 'Failed to load activities.'), 'error')\n setActivities([])\n setHasMore(false)\n } finally {\n setLoading(false)\n }\n }, [dealId, entityId, excludeInteractionType, filterDateFrom, filterDateTo, filterTypes, loadedPages, useCanonicalInteractions, refreshKey, t])\n\n React.useEffect(() => {\n setLoadedPages(1)\n }, [dealId, entityId, filterDateFrom, filterDateTo, filterTypes, useCanonicalInteractions])\n\n const handleMarkDone = React.useCallback(async (activityId: string) => {\n try {\n const operation = () =>\n apiCallOrThrow('/api/customers/interactions/complete', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: activityId, occurredAt: new Date().toISOString() }),\n })\n if (runGuardedMutation) {\n await runGuardedMutation(operation, {\n id: activityId,\n status: 'done',\n operation: 'completeActivity',\n })\n } else {\n await operation()\n }\n flash(t('customers.activities.actions.markDoneSuccess', 'Activity marked done'), 'success')\n await loadActivities()\n } catch (err) {\n logger.warn('Mark done failed', { component: 'ActivitiesSection', activityId, err })\n flash(t('customers.activities.actions.markDoneError', 'Could not mark activity as done'), 'error')\n }\n }, [loadActivities, runGuardedMutation, t])\n\n const resolvedUserIdsRef = React.useRef(new Set<string>())\n\n // Resolve missing author names from user IDs\n React.useEffect(() => {\n loadActivities()\n .then(() => { resolvedUserIdsRef.current = new Set() })\n .catch((err) => logger.warn('loadActivities failed', { component: 'ActivitiesSection', err }))\n }, [loadActivities])\n\n React.useEffect(() => {\n const unresolvedIds = new Set<string>()\n for (const a of activities) {\n if (a.authorUserId && !a.authorName && !resolvedUserIdsRef.current.has(a.authorUserId)) {\n unresolvedIds.add(a.authorUserId)\n }\n }\n if (unresolvedIds.size === 0) return\n\n const controller = new AbortController()\n resolveAuthorUserNames([...unresolvedIds], controller.signal)\n .then(({ names: nameMap, resolvedIds }) => {\n if (controller.signal.aborted) return\n // Only ids the server actually answered for are remembered. Marking an id whose request\n // failed \u2014 or one the server silently dropped past its `?ids=` cap \u2014 would leave that\n // author blank for the life of the component, even though the next attempt would work.\n for (const uid of resolvedIds) resolvedUserIdsRef.current.add(uid)\n if (nameMap.size > 0) {\n setActivities((prev) =>\n prev.map((a) => {\n if (a.authorUserId && !a.authorName && nameMap.has(a.authorUserId)) {\n return { ...a, authorName: nameMap.get(a.authorUserId) ?? null }\n }\n return a\n }),\n )\n }\n })\n .catch((err) => logger.warn('resolve author names failed', { component: 'ActivitiesSection', err }))\n return () => controller.abort()\n }, [activities])\n\n const totalCount = activities.length\n const visibleCount = visibleActivities.length\n\n return (\n <div className=\"flex flex-col gap-3.5 rounded-[10px] border border-border bg-card pt-4 pb-[18px] px-[18px]\">\n <div className=\"flex items-center gap-2\">\n <Clock className=\"size-[15px] text-muted-foreground\" />\n <h3 className=\"text-[13px] font-semibold text-foreground\">\n {entityName\n ? t('customers.timeline.history.title', 'Interaction history with {{name}}', { name: entityName })\n : t('customers.timeline.history.titleGeneric', 'Interaction history')}\n </h3>\n </div>\n\n <label className=\"relative flex items-center\">\n <Search className=\"pointer-events-none absolute left-2.5 size-5 text-muted-foreground\" aria-hidden />\n <input\n ref={searchInputRef}\n type=\"search\"\n value={searchTerm}\n onChange={(event) => setSearchTerm(event.target.value)}\n placeholder={t('customers.timeline.history.searchPlaceholder', 'Search...')}\n aria-label={t('customers.timeline.history.searchAriaLabel', 'Search interaction history')}\n className=\"h-9 w-full rounded-[10px] border border-border bg-card pl-9 pr-14 text-sm text-foreground shadow-xs placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\"\n />\n <Kbd className=\"pointer-events-none absolute right-2 hidden text-[11px] uppercase tracking-[0.48px] sm:inline-flex\">\n \u23181\n </Kbd>\n </label>\n\n <ActivityTimelineFilters\n entityId={entityId}\n activeTypes={filterTypes}\n dateFrom={filterDateFrom}\n dateTo={filterDateTo}\n onTypesChange={setFilterTypes}\n onDateFromChange={setFilterDateFrom}\n onDateToChange={setFilterDateTo}\n onReset={() => {\n setFilterTypes([])\n setFilterDateFrom('')\n setFilterDateTo('')\n }}\n />\n\n {loading && totalCount === 0 ? (\n <div className=\"rounded-lg border border-dashed border-border/70 px-4 py-8 text-sm text-muted-foreground\">\n {t('customers.people.detail.activities.loading', 'Loading activities\u2026')}\n </div>\n ) : (\n <>\n <ActivityTimeline\n activities={visibleActivities}\n onEdit={onEditActivity}\n onMarkDone={handleMarkDone}\n />\n {totalCount > 0 ? (\n <div className=\"flex items-center justify-between gap-3 border-t border-border/60 pt-3\">\n <span className=\"text-xs text-muted-foreground\">\n {searchTerm.trim()\n ? t('customers.activities.seeMatching', 'Showing {visible} of {total} activities', {\n visible: visibleCount,\n total: totalCount,\n })\n : t('customers.activities.seeAll', 'See all {count} activities', { count: totalCount })}\n </span>\n {hasMore ? (\n <Button type=\"button\" variant=\"link\" size=\"sm\" onClick={() => setLoadedPages((value) => value + 1)}>\n {t('customers.activities.loadMore', 'Load more')}\n </Button>\n ) : null}\n </div>\n ) : null}\n </>\n )}\n </div>\n )\n}\n\nexport default ActivitiesSection\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Clock, Search } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport type { SectionAction, TabEmptyStateConfig } from '@open-mercato/ui/backend/detail'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Kbd } from '@open-mercato/ui/primitives/kbd'\nimport { ActivityTimelineFilters } from './ActivityTimelineFilters'\nimport { ActivityTimeline } from './ActivityTimeline'\nimport { resolveAuthorUserNames } from './authorUserLookup'\nimport type { ActivitySummary, InteractionSummary } from './types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\ntype GuardedMutationRunner = <T>(\n operation: () => Promise<T>,\n mutationPayload?: Record<string, unknown>,\n) => Promise<T>\n\nexport type ActivitiesSectionProps = {\n entityId: string | null\n entityName?: string | null\n dealId?: string | null\n useCanonicalInteractions?: boolean\n addActionLabel: string\n emptyState: TabEmptyStateConfig\n onActionChange?: (action: SectionAction | null) => void\n onLoadingChange?: (isLoading: boolean) => void\n onDataRefresh?: () => void\n dealOptions?: Array<{ id: string; label: string }>\n entityOptions?: Array<{ id: string; label: string }>\n defaultEntityId?: string | null\n runGuardedMutation?: GuardedMutationRunner\n refreshKey?: number\n onEditActivity?: (activity: InteractionSummary) => void\n /** Interaction type hidden from the timeline by default ('task' unless overridden); pass null to show every type. */\n excludeInteractionType?: string | null\n}\n\nfunction toDateOnly(value: string | null | undefined): string {\n if (!value) return ''\n const date = new Date(value)\n return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)\n}\n\nfunction normalizeLegacyActivity(activity: ActivitySummary): InteractionSummary {\n return {\n id: activity.id,\n interactionType: activity.activityType,\n title: activity.subject ?? null,\n body: activity.body ?? null,\n status: 'done',\n scheduledAt: null,\n occurredAt: activity.occurredAt ?? null,\n priority: null,\n authorUserId: activity.authorUserId ?? null,\n ownerUserId: null,\n appearanceIcon: activity.appearanceIcon ?? null,\n appearanceColor: activity.appearanceColor ?? null,\n source: 'legacy-activity',\n entityId: activity.entityId ?? null,\n dealId: activity.dealId ?? null,\n organizationId: null,\n tenantId: null,\n authorName: activity.authorName ?? null,\n authorEmail: activity.authorEmail ?? null,\n dealTitle: activity.dealTitle ?? null,\n customValues: activity.customValues ?? null,\n createdAt: activity.createdAt,\n updatedAt: activity.createdAt,\n }\n}\n\nfunction sortTimelineActivities(items: InteractionSummary[]): InteractionSummary[] {\n const now = Date.now()\n return [...items].sort((left, right) => {\n const leftScheduled = left.scheduledAt ? new Date(left.scheduledAt).getTime() : Number.NaN\n const rightScheduled = right.scheduledAt ? new Date(right.scheduledAt).getTime() : Number.NaN\n const leftIsPlanned = left.status === 'planned' && Number.isFinite(leftScheduled)\n const rightIsPlanned = right.status === 'planned' && Number.isFinite(rightScheduled)\n const leftIsUpcoming = leftIsPlanned && leftScheduled >= now\n const rightIsUpcoming = rightIsPlanned && rightScheduled >= now\n\n if (leftIsUpcoming !== rightIsUpcoming) {\n return leftIsUpcoming ? -1 : 1\n }\n\n if (leftIsUpcoming && rightIsUpcoming) {\n if (leftScheduled === rightScheduled) return left.id.localeCompare(right.id)\n return leftScheduled - rightScheduled\n }\n\n const leftTime = left.occurredAt ?? left.createdAt\n const rightTime = right.occurredAt ?? right.createdAt\n const compare = rightTime.localeCompare(leftTime)\n if (compare !== 0) return compare\n return right.id.localeCompare(left.id)\n })\n}\n\nexport function ActivitiesSection({\n entityId,\n entityName,\n dealId,\n useCanonicalInteractions = false,\n onActionChange,\n onLoadingChange,\n onDataRefresh,\n refreshKey = 0,\n onEditActivity,\n runGuardedMutation,\n excludeInteractionType = 'task',\n}: ActivitiesSectionProps) {\n const t = useT()\n const [filterTypes, setFilterTypes] = React.useState<string[]>([])\n const [filterDateFrom, setFilterDateFrom] = React.useState('')\n const [filterDateTo, setFilterDateTo] = React.useState('')\n const [searchTerm, setSearchTerm] = React.useState('')\n const [activities, setActivities] = React.useState<InteractionSummary[]>([])\n const [loading, setLoading] = React.useState(false)\n const [hasMore, setHasMore] = React.useState(false)\n const [loadedPages, setLoadedPages] = React.useState(1)\n const searchInputRef = React.useRef<HTMLInputElement>(null)\n\n React.useEffect(() => {\n if (!entityId) return\n function handleShortcut(event: KeyboardEvent) {\n if ((event.metaKey || event.ctrlKey) && event.key === '1') {\n event.preventDefault()\n searchInputRef.current?.focus()\n }\n }\n window.addEventListener('keydown', handleShortcut)\n return () => window.removeEventListener('keydown', handleShortcut)\n }, [entityId])\n\n const visibleActivities = React.useMemo(() => {\n const term = searchTerm.trim().toLowerCase()\n if (!term) return activities\n return activities.filter((activity) => {\n const haystack = [\n activity.title,\n activity.body,\n activity.authorName,\n activity.dealTitle,\n activity.interactionType,\n ]\n .filter((value): value is string => typeof value === 'string' && value.length > 0)\n .join(' ')\n .toLowerCase()\n return haystack.includes(term)\n })\n }, [activities, searchTerm])\n\n React.useEffect(() => {\n onActionChange?.(null)\n return () => onActionChange?.(null)\n }, [onActionChange])\n\n React.useEffect(() => {\n onLoadingChange?.(loading)\n }, [loading, onLoadingChange])\n\n const loadActivities = React.useCallback(async () => {\n if (!entityId) {\n setActivities([])\n return\n }\n\n setLoading(true)\n try {\n // Always fetch canonical interactions (new activities are always created here)\n const excludedFilterActive = excludeInteractionType ? filterTypes.includes(excludeInteractionType) : true\n const canonicalParams = new URLSearchParams({\n entityId,\n limit: '50',\n sortField: 'occurredAt',\n sortDir: 'desc',\n })\n // Hide the configured type (tasks by default \u2014 they have their own tab)\n // from the activity timeline, but lift the exclusion when the user\n // explicitly toggled that type's chip on (mirrors\n // `ActivityHistorySection.tsx` after the #1805 fix).\n if (excludeInteractionType && !excludedFilterActive) canonicalParams.set('excludeInteractionType', excludeInteractionType)\n if (dealId) canonicalParams.set('dealId', dealId)\n if (filterTypes.length > 0) canonicalParams.set('type', filterTypes.join(','))\n if (filterDateFrom) canonicalParams.set('from', filterDateFrom)\n if (filterDateTo) canonicalParams.set('to', filterDateTo)\n\n const canonicalItems: InteractionSummary[] = []\n let canonicalCursor: string | undefined\n let canonicalHasMore = false\n let pageIndex = 0\n do {\n const params = new URLSearchParams(canonicalParams)\n if (canonicalCursor) params.set('cursor', canonicalCursor)\n const canonicalPayload = await readApiResultOrThrow<{ items?: InteractionSummary[]; nextCursor?: string }>(\n `/api/customers/interactions?${params.toString()}`,\n ).catch(() => ({ items: [] as InteractionSummary[], nextCursor: undefined }))\n canonicalItems.push(...(Array.isArray(canonicalPayload?.items) ? canonicalPayload.items : []))\n canonicalCursor = typeof canonicalPayload?.nextCursor === 'string' ? canonicalPayload.nextCursor : undefined\n canonicalHasMore = Boolean(canonicalCursor)\n pageIndex += 1\n } while (canonicalCursor && pageIndex < loadedPages)\n\n if (useCanonicalInteractions) {\n setActivities(sortTimelineActivities(canonicalItems))\n setHasMore(canonicalHasMore)\n return\n }\n\n // In legacy mode, also fetch legacy activities and merge with canonical.\n // Legacy fallback uses known page numbers, so request every page up front\n // and resolve them together instead of awaiting each one sequentially.\n const legacyPageNumbers = Array.from({ length: loadedPages }, (_, index) => index + 1)\n const legacyPayloads = await Promise.all(\n legacyPageNumbers.map((legacyPage) => {\n const legacyParams = new URLSearchParams({\n entityId,\n page: String(legacyPage),\n pageSize: '50',\n sortField: 'occurredAt',\n sortDir: 'desc',\n })\n if (dealId) legacyParams.set('dealId', dealId)\n return readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(\n `/api/customers/activities?${legacyParams.toString()}`,\n ).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 }))\n }),\n )\n // Merge in page order so timeline ordering stays stable regardless of\n // which request settles first.\n const legacyItems: InteractionSummary[] = []\n let legacyTotalPages = 1\n for (const legacyPayload of legacyPayloads) {\n legacyItems.push(...(Array.isArray(legacyPayload?.items) ? legacyPayload.items.map(normalizeLegacyActivity) : []))\n legacyTotalPages = typeof legacyPayload?.totalPages === 'number' ? legacyPayload.totalPages : legacyTotalPages\n }\n const legacyFiltered = legacyItems.filter((entry) => {\n if (filterTypes.length > 0 && !filterTypes.includes(entry.interactionType)) return false\n const dateOnly = toDateOnly(entry.occurredAt ?? entry.createdAt)\n if (filterDateFrom && dateOnly < filterDateFrom) return false\n if (filterDateTo && dateOnly > filterDateTo) return false\n return true\n })\n\n // Merge and deduplicate by id, sort newest first\n const seen = new Set<string>()\n const merged: InteractionSummary[] = []\n for (const item of [...canonicalItems, ...legacyFiltered]) {\n if (!seen.has(item.id)) {\n seen.add(item.id)\n merged.push(item)\n }\n }\n setActivities(sortTimelineActivities(merged))\n setHasMore(canonicalHasMore || legacyTotalPages > loadedPages)\n } catch (error) {\n logger.error('customers.activities.history failed', { err: error })\n flash(t('customers.activities.loadFailed', 'Failed to load activities.'), 'error')\n setActivities([])\n setHasMore(false)\n } finally {\n setLoading(false)\n }\n }, [dealId, entityId, excludeInteractionType, filterDateFrom, filterDateTo, filterTypes, loadedPages, useCanonicalInteractions, refreshKey, t])\n\n React.useEffect(() => {\n setLoadedPages(1)\n }, [dealId, entityId, filterDateFrom, filterDateTo, filterTypes, useCanonicalInteractions])\n\n const handleMarkDone = React.useCallback(async (activityId: string) => {\n try {\n const operation = () =>\n apiCallOrThrow('/api/customers/interactions/complete', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: activityId, occurredAt: new Date().toISOString() }),\n })\n if (runGuardedMutation) {\n await runGuardedMutation(operation, {\n id: activityId,\n status: 'done',\n operation: 'completeActivity',\n })\n } else {\n await operation()\n }\n flash(t('customers.activities.actions.markDoneSuccess', 'Activity marked done'), 'success')\n await loadActivities()\n } catch (err) {\n logger.warn('Mark done failed', { component: 'ActivitiesSection', activityId, err })\n flash(t('customers.activities.actions.markDoneError', 'Could not mark activity as done'), 'error')\n return\n }\n try {\n await Promise.resolve(onDataRefresh?.())\n } catch (err) {\n logger.warn('Parent refresh after mark done failed', { component: 'ActivitiesSection', activityId, err })\n }\n }, [loadActivities, onDataRefresh, runGuardedMutation, t])\n\n const resolvedUserIdsRef = React.useRef(new Set<string>())\n\n // Resolve missing author names from user IDs\n React.useEffect(() => {\n loadActivities()\n .then(() => { resolvedUserIdsRef.current = new Set() })\n .catch((err) => logger.warn('loadActivities failed', { component: 'ActivitiesSection', err }))\n }, [loadActivities])\n\n React.useEffect(() => {\n const unresolvedIds = new Set<string>()\n for (const a of activities) {\n if (a.authorUserId && !a.authorName && !resolvedUserIdsRef.current.has(a.authorUserId)) {\n unresolvedIds.add(a.authorUserId)\n }\n }\n if (unresolvedIds.size === 0) return\n\n const controller = new AbortController()\n resolveAuthorUserNames([...unresolvedIds], controller.signal)\n .then(({ names: nameMap, resolvedIds }) => {\n if (controller.signal.aborted) return\n // Only ids the server actually answered for are remembered. Marking an id whose request\n // failed \u2014 or one the server silently dropped past its `?ids=` cap \u2014 would leave that\n // author blank for the life of the component, even though the next attempt would work.\n for (const uid of resolvedIds) resolvedUserIdsRef.current.add(uid)\n if (nameMap.size > 0) {\n setActivities((prev) =>\n prev.map((a) => {\n if (a.authorUserId && !a.authorName && nameMap.has(a.authorUserId)) {\n return { ...a, authorName: nameMap.get(a.authorUserId) ?? null }\n }\n return a\n }),\n )\n }\n })\n .catch((err) => logger.warn('resolve author names failed', { component: 'ActivitiesSection', err }))\n return () => controller.abort()\n }, [activities])\n\n const totalCount = activities.length\n const visibleCount = visibleActivities.length\n\n return (\n <div className=\"flex flex-col gap-3.5 rounded-[10px] border border-border bg-card pt-4 pb-[18px] px-[18px]\">\n <div className=\"flex items-center gap-2\">\n <Clock className=\"size-[15px] text-muted-foreground\" />\n <h3 className=\"text-[13px] font-semibold text-foreground\">\n {entityName\n ? t('customers.timeline.history.title', 'Interaction history with {{name}}', { name: entityName })\n : t('customers.timeline.history.titleGeneric', 'Interaction history')}\n </h3>\n </div>\n\n <label className=\"relative flex items-center\">\n <Search className=\"pointer-events-none absolute left-2.5 size-5 text-muted-foreground\" aria-hidden />\n <input\n ref={searchInputRef}\n type=\"search\"\n value={searchTerm}\n onChange={(event) => setSearchTerm(event.target.value)}\n placeholder={t('customers.timeline.history.searchPlaceholder', 'Search...')}\n aria-label={t('customers.timeline.history.searchAriaLabel', 'Search interaction history')}\n className=\"h-9 w-full rounded-[10px] border border-border bg-card pl-9 pr-14 text-sm text-foreground shadow-xs placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\"\n />\n <Kbd className=\"pointer-events-none absolute right-2 hidden text-[11px] uppercase tracking-[0.48px] sm:inline-flex\">\n \u23181\n </Kbd>\n </label>\n\n <ActivityTimelineFilters\n entityId={entityId}\n activeTypes={filterTypes}\n dateFrom={filterDateFrom}\n dateTo={filterDateTo}\n onTypesChange={setFilterTypes}\n onDateFromChange={setFilterDateFrom}\n onDateToChange={setFilterDateTo}\n onReset={() => {\n setFilterTypes([])\n setFilterDateFrom('')\n setFilterDateTo('')\n }}\n />\n\n {loading && totalCount === 0 ? (\n <div className=\"rounded-lg border border-dashed border-border/70 px-4 py-8 text-sm text-muted-foreground\">\n {t('customers.people.detail.activities.loading', 'Loading activities\u2026')}\n </div>\n ) : (\n <>\n <ActivityTimeline\n activities={visibleActivities}\n onEdit={onEditActivity}\n onMarkDone={handleMarkDone}\n />\n {totalCount > 0 ? (\n <div className=\"flex items-center justify-between gap-3 border-t border-border/60 pt-3\">\n <span className=\"text-xs text-muted-foreground\">\n {searchTerm.trim()\n ? t('customers.activities.seeMatching', 'Showing {visible} of {total} activities', {\n visible: visibleCount,\n total: totalCount,\n })\n : t('customers.activities.seeAll', 'See all {count} activities', { count: totalCount })}\n </span>\n {hasMore ? (\n <Button type=\"button\" variant=\"link\" size=\"sm\" onClick={() => setLoadedPages((value) => value + 1)}>\n {t('customers.activities.loadMore', 'Load more')}\n </Button>\n ) : null}\n </div>\n ) : null}\n </>\n )}\n </div>\n )\n}\n\nexport default ActivitiesSection\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7464.1.9a392fca41",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -264,16 +264,16 @@
|
|
|
264
264
|
"zod": "^4.4.3"
|
|
265
265
|
},
|
|
266
266
|
"peerDependencies": {
|
|
267
|
-
"@open-mercato/ai-assistant": "0.9.1-develop.
|
|
268
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
269
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
267
|
+
"@open-mercato/ai-assistant": "0.9.1-develop.7464.1.9a392fca41",
|
|
268
|
+
"@open-mercato/shared": "0.9.1-develop.7464.1.9a392fca41",
|
|
269
|
+
"@open-mercato/ui": "0.9.1-develop.7464.1.9a392fca41",
|
|
270
270
|
"react": "^19.0.0",
|
|
271
271
|
"react-dom": "^19.0.0"
|
|
272
272
|
},
|
|
273
273
|
"devDependencies": {
|
|
274
|
-
"@open-mercato/ai-assistant": "0.9.1-develop.
|
|
275
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
276
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
274
|
+
"@open-mercato/ai-assistant": "0.9.1-develop.7464.1.9a392fca41",
|
|
275
|
+
"@open-mercato/shared": "0.9.1-develop.7464.1.9a392fca41",
|
|
276
|
+
"@open-mercato/ui": "0.9.1-develop.7464.1.9a392fca41",
|
|
277
277
|
"@testing-library/dom": "^10.4.1",
|
|
278
278
|
"@testing-library/jest-dom": "^7.0.1",
|
|
279
279
|
"@testing-library/react": "^16.3.3",
|
|
@@ -46,6 +46,9 @@ import {
|
|
|
46
46
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
47
47
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
48
48
|
import type { AttachmentQuotaService } from '../lib/quota-service'
|
|
49
|
+
import { resolveAttachmentsUploadRateLimitConfig } from '../lib/ocrLimits'
|
|
50
|
+
import { checkRateLimit, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_ERROR_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
51
|
+
import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
|
|
49
52
|
|
|
50
53
|
const logger = createLogger('attachments')
|
|
51
54
|
|
|
@@ -283,6 +286,33 @@ export async function POST(req: Request) {
|
|
|
283
286
|
}
|
|
284
287
|
const tenantId = auth.tenantId
|
|
285
288
|
|
|
289
|
+
const container = await createRequestContainer()
|
|
290
|
+
let rateLimiterService: RateLimiterService | null = null
|
|
291
|
+
try {
|
|
292
|
+
rateLimiterService = container.resolve('rateLimiterService') as RateLimiterService | null
|
|
293
|
+
} catch (error) {
|
|
294
|
+
// Fail-open: uploads proceed when the limiter cannot be resolved (minimal/test containers).
|
|
295
|
+
logger.warn('Attachment upload rate limiter unavailable; throttle skipped', { err: error })
|
|
296
|
+
}
|
|
297
|
+
if (rateLimiterService) {
|
|
298
|
+
const principal = typeof auth.sub === 'string' && auth.sub.length > 0
|
|
299
|
+
? auth.sub
|
|
300
|
+
: (typeof auth.userId === 'string' && auth.userId.length > 0 ? auth.userId : null)
|
|
301
|
+
if (!principal) {
|
|
302
|
+
logger.warn('Attachment upload rate limit skipped: missing authenticated principal')
|
|
303
|
+
} else {
|
|
304
|
+
const rateLimitResponse = await checkRateLimit(
|
|
305
|
+
rateLimiterService,
|
|
306
|
+
resolveAttachmentsUploadRateLimitConfig(),
|
|
307
|
+
`${tenantId}:${principal}`,
|
|
308
|
+
t(RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK),
|
|
309
|
+
)
|
|
310
|
+
if (rateLimitResponse) return rateLimitResponse
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
logger.warn('Attachment upload rate limiter not registered; throttle skipped')
|
|
314
|
+
}
|
|
315
|
+
|
|
286
316
|
const contentType = req.headers.get('content-type') || ''
|
|
287
317
|
if (!contentType.toLowerCase().includes('multipart/form-data')) {
|
|
288
318
|
return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
|
|
@@ -319,7 +349,6 @@ export async function POST(req: Request) {
|
|
|
319
349
|
const tags = parseFormTags(form.get('tags'))
|
|
320
350
|
const assignmentsFromForm = parseFormAssignments(form.get('assignments'))
|
|
321
351
|
|
|
322
|
-
const container = await createRequestContainer()
|
|
323
352
|
const em = container.resolve('em') as EntityManager
|
|
324
353
|
const dataEngine = container.resolve('dataEngine')
|
|
325
354
|
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
@@ -603,9 +632,13 @@ export async function POST(req: Request) {
|
|
|
603
632
|
}
|
|
604
633
|
|
|
605
634
|
if (useLlmOcr) {
|
|
606
|
-
|
|
635
|
+
// Await so wait-queue overflow can finish the inline text fallback before
|
|
636
|
+
// the response returns (background LLM OCR still schedules via setImmediate).
|
|
637
|
+
try {
|
|
638
|
+
await requestOcrProcessing(em, att, uploadDriver, storedPath)
|
|
639
|
+
} catch (error) {
|
|
607
640
|
logger.error('Failed to queue OCR processing', { err: error })
|
|
608
|
-
}
|
|
641
|
+
}
|
|
609
642
|
} else if (wantsLlmOcr) {
|
|
610
643
|
logger.warn('OCR requested but OPENAI_API_KEY not configured, falling back to text extraction when available')
|
|
611
644
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
|
|
2
|
+
import type { RateLimitConfig } from '@open-mercato/shared/lib/ratelimit/types'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_OCR_PAGES = 50
|
|
5
|
+
const DEFAULT_OCR_PAGE_TIMEOUT_MS = 60_000
|
|
6
|
+
const DEFAULT_OCR_MAX_OUTPUT_TOKENS = 4_096
|
|
7
|
+
const DEFAULT_OCR_MAX_CONCURRENCY = 2
|
|
8
|
+
const DEFAULT_OCR_MAX_WAIT_QUEUE = 50
|
|
9
|
+
|
|
10
|
+
function parsePositiveInt(raw: string | undefined, fallback: number): number {
|
|
11
|
+
if (raw === undefined || raw.trim() === '') return fallback
|
|
12
|
+
const parsed = Number(raw)
|
|
13
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
|
|
14
|
+
return Math.floor(parsed)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Cap PDF pages processed for OCR / text extraction (env: OM_ATTACHMENT_OCR_MAX_PAGES). */
|
|
18
|
+
export function resolveMaxOcrPages(): number {
|
|
19
|
+
return parsePositiveInt(process.env.OM_ATTACHMENT_OCR_MAX_PAGES, DEFAULT_MAX_OCR_PAGES)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Per-page LLM OCR call timeout in ms (env: OM_ATTACHMENT_OCR_PAGE_TIMEOUT_MS). */
|
|
23
|
+
export function resolveOcrPageTimeoutMs(): number {
|
|
24
|
+
return parsePositiveInt(process.env.OM_ATTACHMENT_OCR_PAGE_TIMEOUT_MS, DEFAULT_OCR_PAGE_TIMEOUT_MS)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Max output tokens for a single OCR generateText call (env: OM_ATTACHMENT_OCR_MAX_OUTPUT_TOKENS). */
|
|
28
|
+
export function resolveOcrMaxOutputTokens(): number {
|
|
29
|
+
return parsePositiveInt(process.env.OM_ATTACHMENT_OCR_MAX_OUTPUT_TOKENS, DEFAULT_OCR_MAX_OUTPUT_TOKENS)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Max concurrent in-process OCR jobs (env: OM_ATTACHMENT_OCR_MAX_CONCURRENCY). */
|
|
33
|
+
export function resolveOcrMaxConcurrency(): number {
|
|
34
|
+
return parsePositiveInt(process.env.OM_ATTACHMENT_OCR_MAX_CONCURRENCY, DEFAULT_OCR_MAX_CONCURRENCY)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Max waiters blocked on the in-process OCR concurrency slot (env: OM_ATTACHMENT_OCR_MAX_WAIT_QUEUE). */
|
|
38
|
+
export function resolveOcrMaxWaitQueue(): number {
|
|
39
|
+
return parsePositiveInt(process.env.OM_ATTACHMENT_OCR_MAX_WAIT_QUEUE, DEFAULT_OCR_MAX_WAIT_QUEUE)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many PDF pages to iterate given document length and the configured cap. */
|
|
43
|
+
export function resolvePdfPageIterationLimit(numPages: number, maxPages: number = resolveMaxOcrPages()): number {
|
|
44
|
+
if (!Number.isFinite(numPages) || numPages <= 0) return 0
|
|
45
|
+
if (!Number.isFinite(maxPages) || maxPages <= 0) return 0
|
|
46
|
+
return Math.min(Math.floor(numPages), Math.floor(maxPages))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-tenant/user upload throttle for POST /api/attachments.
|
|
51
|
+
* Env: RATE_LIMIT_ATTACHMENTS_UPLOAD_{POINTS,DURATION,BLOCK_DURATION}.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveAttachmentsUploadRateLimitConfig(): RateLimitConfig {
|
|
54
|
+
// Default sits above typical multi-file library batches (dialog has no hard
|
|
55
|
+
// file cap). Client retry/partial-progress UX for rejected mid-batch files is
|
|
56
|
+
// tracked in https://github.com/open-mercato/open-mercato/issues/6397.
|
|
57
|
+
return readEndpointRateLimitConfig('ATTACHMENTS_UPLOAD', {
|
|
58
|
+
points: 100,
|
|
59
|
+
duration: 60,
|
|
60
|
+
keyPrefix: 'attachments_upload',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -3,6 +3,8 @@ import { Attachment, AttachmentPartition } from '../data/entities'
|
|
|
3
3
|
import { OcrService } from './ocrService'
|
|
4
4
|
import type { StorageDriver } from './drivers/types'
|
|
5
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { resolveOcrMaxConcurrency, resolveOcrMaxWaitQueue } from './ocrLimits'
|
|
7
|
+
import { extractAttachmentContent } from './textExtraction'
|
|
6
8
|
|
|
7
9
|
const logger = createLogger('attachments').child({ component: 'ocr' })
|
|
8
10
|
|
|
@@ -15,6 +17,45 @@ export type OcrRequestedEvent = {
|
|
|
15
17
|
tenantId: string | null
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** Outcome of `requestOcrProcessing` — background LLM OCR vs inline text fallback. */
|
|
21
|
+
export type OcrProcessingDispatch = 'queued' | 'inline_fallback'
|
|
22
|
+
|
|
23
|
+
let activeOcrJobs = 0
|
|
24
|
+
const ocrWaitQueue: Array<() => void> = []
|
|
25
|
+
// Jobs scheduled with setImmediate that have not yet entered a slot or the wait queue.
|
|
26
|
+
// Counted toward the wait-queue cap so a burst in one event-loop turn cannot pass the check together.
|
|
27
|
+
let pendingOcrJobs = 0
|
|
28
|
+
|
|
29
|
+
/** Test-only: reset in-process OCR concurrency bookkeeping. */
|
|
30
|
+
export function resetOcrConcurrencyStateForTests(): void {
|
|
31
|
+
activeOcrJobs = 0
|
|
32
|
+
ocrWaitQueue.length = 0
|
|
33
|
+
pendingOcrJobs = 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Test-only: inspect in-process OCR concurrency counters. */
|
|
37
|
+
export function getOcrConcurrencyStateForTests(): { active: number; waiting: number } {
|
|
38
|
+
return { active: activeOcrJobs, waiting: ocrWaitQueue.length }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function withOcrConcurrencySlot<T>(run: () => Promise<T>): Promise<T> {
|
|
42
|
+
const maxConcurrency = resolveOcrMaxConcurrency()
|
|
43
|
+
// Re-check after wake: multiple waiters can resume when one slot frees (barging).
|
|
44
|
+
while (activeOcrJobs >= maxConcurrency) {
|
|
45
|
+
await new Promise<void>((resolve) => {
|
|
46
|
+
ocrWaitQueue.push(resolve)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
activeOcrJobs += 1
|
|
50
|
+
try {
|
|
51
|
+
return await run()
|
|
52
|
+
} finally {
|
|
53
|
+
activeOcrJobs -= 1
|
|
54
|
+
const next = ocrWaitQueue.shift()
|
|
55
|
+
if (next) next()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
18
59
|
export async function processAttachmentOcr(
|
|
19
60
|
em: EntityManager,
|
|
20
61
|
payload: OcrRequestedEvent,
|
|
@@ -73,12 +114,49 @@ export async function processAttachmentOcr(
|
|
|
73
114
|
}
|
|
74
115
|
}
|
|
75
116
|
|
|
117
|
+
/**
|
|
118
|
+
* When the in-process OCR wait queue is full, extract plain text/PDF/DOCX content
|
|
119
|
+
* inline (no LLM) so `attachment.content` is not left NULL forever.
|
|
120
|
+
*/
|
|
121
|
+
async function persistInlineTextExtractionFallback(
|
|
122
|
+
em: EntityManager,
|
|
123
|
+
payload: OcrRequestedEvent,
|
|
124
|
+
driver: StorageDriver,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const { attachmentId, storagePath, mimeType, partitionCode } = payload
|
|
127
|
+
const { filePath, cleanup } = await driver.toLocalPath(partitionCode, storagePath)
|
|
128
|
+
try {
|
|
129
|
+
const content = await extractAttachmentContent({ filePath, mimeType })
|
|
130
|
+
if (!content) {
|
|
131
|
+
logger.info('OCR wait queue full; inline extraction produced no content', { attachmentId })
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const row = await em.findOne(Attachment, { id: attachmentId })
|
|
135
|
+
if (!row) {
|
|
136
|
+
logger.error('Attachment not found during OCR overflow fallback', { attachmentId })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
row.content = content
|
|
140
|
+
await em.persist(row).flush()
|
|
141
|
+
logger.info('OCR wait queue full; stored inline text extraction', {
|
|
142
|
+
attachmentId,
|
|
143
|
+
contentLength: content.length,
|
|
144
|
+
})
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger.error('OCR wait queue overflow inline extraction failed', { attachmentId, err: error })
|
|
147
|
+
} finally {
|
|
148
|
+
await cleanup().catch((cleanupError) => {
|
|
149
|
+
logger.warn('Temp file cleanup failed after OCR overflow fallback', { err: cleanupError })
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
76
154
|
export async function requestOcrProcessing(
|
|
77
155
|
em: EntityManager,
|
|
78
156
|
attachment: Attachment,
|
|
79
157
|
driver: StorageDriver,
|
|
80
158
|
storagePath: string,
|
|
81
|
-
): Promise<
|
|
159
|
+
): Promise<OcrProcessingDispatch> {
|
|
82
160
|
const payload: OcrRequestedEvent = {
|
|
83
161
|
attachmentId: attachment.id,
|
|
84
162
|
storagePath,
|
|
@@ -97,9 +175,24 @@ export async function requestOcrProcessing(
|
|
|
97
175
|
|
|
98
176
|
const workerEm = em.fork()
|
|
99
177
|
|
|
178
|
+
const maxWaitQueue = resolveOcrMaxWaitQueue()
|
|
179
|
+
if (ocrWaitQueue.length + pendingOcrJobs >= maxWaitQueue) {
|
|
180
|
+
logger.warn('OCR wait queue full; falling back to inline text extraction', {
|
|
181
|
+
attachmentId: attachment.id,
|
|
182
|
+
waiting: ocrWaitQueue.length,
|
|
183
|
+
pending: pendingOcrJobs,
|
|
184
|
+
maxWaitQueue,
|
|
185
|
+
})
|
|
186
|
+
await persistInlineTextExtractionFallback(workerEm, payload, driver)
|
|
187
|
+
return 'inline_fallback'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pendingOcrJobs += 1
|
|
100
191
|
setImmediate(() => {
|
|
101
|
-
|
|
192
|
+
pendingOcrJobs -= 1
|
|
193
|
+
withOcrConcurrencySlot(() => processAttachmentOcr(workerEm, payload, driver)).catch((error) => {
|
|
102
194
|
logger.error('Background processing error', { err: error })
|
|
103
195
|
})
|
|
104
196
|
})
|
|
197
|
+
return 'queued'
|
|
105
198
|
}
|
|
@@ -3,6 +3,7 @@ import { createOpenAI } from '@ai-sdk/openai'
|
|
|
3
3
|
import fs from 'fs/promises'
|
|
4
4
|
import path from 'path'
|
|
5
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { resolveOcrMaxOutputTokens, resolveOcrPageTimeoutMs } from './ocrLimits'
|
|
6
7
|
|
|
7
8
|
const logger = createLogger('attachments').child({ component: 'ocr-service' })
|
|
8
9
|
|
|
@@ -102,6 +103,9 @@ export class OcrService {
|
|
|
102
103
|
try {
|
|
103
104
|
const result = await generateText({
|
|
104
105
|
model: client(model),
|
|
106
|
+
abortSignal: AbortSignal.timeout(resolveOcrPageTimeoutMs()),
|
|
107
|
+
maxRetries: 0,
|
|
108
|
+
maxOutputTokens: resolveOcrMaxOutputTokens(),
|
|
105
109
|
messages: [
|
|
106
110
|
{
|
|
107
111
|
role: 'user',
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import fs from 'fs/promises'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { createRequire } from 'module'
|
|
4
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
+
import { resolveMaxOcrPages, resolvePdfPageIterationLimit } from './ocrLimits'
|
|
4
6
|
|
|
5
7
|
const moduleRequire = createRequire(path.join(process.cwd(), 'package.json'))
|
|
6
8
|
const pdfJsPackageRoot = path.dirname(moduleRequire.resolve('pdfjs-dist/package.json'))
|
|
7
9
|
const cMapUrl = `${path.join(pdfJsPackageRoot, 'cmaps')}${path.sep}`
|
|
8
10
|
const standardFontDataUrl = `${path.join(pdfJsPackageRoot, 'standard_fonts')}${path.sep}`
|
|
11
|
+
const logger = createLogger('attachments').child({ component: 'pdf-processing' })
|
|
9
12
|
|
|
10
13
|
const MIN_RENDER_SCALE = 2
|
|
11
14
|
const MAX_RENDER_SCALE = 4
|
|
@@ -113,8 +116,17 @@ export async function preparePdfPagesForOcr(filePath: string): Promise<PdfOcrPre
|
|
|
113
116
|
|
|
114
117
|
try {
|
|
115
118
|
const pages: PdfPageOcrInput[] = []
|
|
119
|
+
const maxPages = resolveMaxOcrPages()
|
|
120
|
+
const pageLimit = resolvePdfPageIterationLimit(pdfDocument.numPages, maxPages)
|
|
121
|
+
if (pdfDocument.numPages > pageLimit) {
|
|
122
|
+
logger.warn('PDF page count exceeds OCR cap; truncating', {
|
|
123
|
+
numPages: pdfDocument.numPages,
|
|
124
|
+
maxPages: pageLimit,
|
|
125
|
+
filePath,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
116
128
|
|
|
117
|
-
for (let pageNumber = 1; pageNumber <=
|
|
129
|
+
for (let pageNumber = 1; pageNumber <= pageLimit; pageNumber += 1) {
|
|
118
130
|
const page = await pdfDocument.getPage(pageNumber)
|
|
119
131
|
|
|
120
132
|
try {
|
|
@@ -141,7 +153,7 @@ export async function preparePdfPagesForOcr(filePath: string): Promise<PdfOcrPre
|
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
return {
|
|
144
|
-
pageCount:
|
|
156
|
+
pageCount: pages.length,
|
|
145
157
|
pages,
|
|
146
158
|
}
|
|
147
159
|
} finally {
|
|
@@ -265,9 +265,11 @@ export class ScopedAttachmentUploadService {
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
if (useLlmOcr) {
|
|
268
|
-
|
|
268
|
+
try {
|
|
269
|
+
await requestOcrProcessing(em, attachment, driver, storedPath)
|
|
270
|
+
} catch (error) {
|
|
269
271
|
logger.error('Scoped attachment OCR scheduling failed', { err: error })
|
|
270
|
-
}
|
|
272
|
+
}
|
|
271
273
|
}
|
|
272
274
|
if (this.deps.dataEngine) {
|
|
273
275
|
await emitCrudSideEffects({
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import fs from 'fs/promises'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { createRequire } from 'module'
|
|
4
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
+
import { resolveMaxOcrPages, resolvePdfPageIterationLimit } from './ocrLimits'
|
|
6
|
+
|
|
7
|
+
const logger = createLogger('attachments').child({ component: 'text-extraction' })
|
|
4
8
|
|
|
5
9
|
// NOTE: child_process is intentionally NOT imported here.
|
|
6
10
|
// This module MUST NOT shell out to any external binary for content extraction.
|
|
@@ -68,8 +72,17 @@ async function extractPdfText(filePath: string): Promise<string | null> {
|
|
|
68
72
|
})
|
|
69
73
|
const pdfDocument = await loadingTask.promise
|
|
70
74
|
const textParts: string[] = []
|
|
75
|
+
const maxPages = resolveMaxOcrPages()
|
|
76
|
+
const pageLimit = resolvePdfPageIterationLimit(pdfDocument.numPages, maxPages)
|
|
77
|
+
if (pdfDocument.numPages > pageLimit) {
|
|
78
|
+
logger.warn('PDF page count exceeds text-extraction cap; truncating', {
|
|
79
|
+
numPages: pdfDocument.numPages,
|
|
80
|
+
maxPages: pageLimit,
|
|
81
|
+
filePath,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
71
84
|
try {
|
|
72
|
-
for (let pageNumber = 1; pageNumber <=
|
|
85
|
+
for (let pageNumber = 1; pageNumber <= pageLimit; pageNumber += 1) {
|
|
73
86
|
const page = await pdfDocument.getPage(pageNumber)
|
|
74
87
|
try {
|
|
75
88
|
const textContent = await page.getTextContent()
|
|
@@ -131,10 +131,14 @@ function buildProductPresenter(
|
|
|
131
131
|
) ?? label
|
|
132
132
|
const isActive = record.is_active ?? record.isActive
|
|
133
133
|
const statusText = isActive === false ? translate('catalog.search.status.inactive', 'Inactive') : null
|
|
134
|
+
const productType = readRecordText(record, 'product_type', 'productType')
|
|
135
|
+
const productTypeLabel = productType
|
|
136
|
+
? translate(`catalog.products.types.${productType}`, productType)
|
|
137
|
+
: null
|
|
134
138
|
const subtitle = formatSubtitle(
|
|
135
139
|
readRecordText(record, 'subtitle'),
|
|
136
140
|
readRecordText(record, 'sku'),
|
|
137
|
-
|
|
141
|
+
productTypeLabel,
|
|
138
142
|
statusText,
|
|
139
143
|
) ?? snippet(readRecordText(record, 'description'))
|
|
140
144
|
return { title, subtitle, icon: 'package', badge: label }
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
verifyOAuthState,
|
|
16
16
|
type OAuthStateConsumeStore,
|
|
17
17
|
} from '../../../../../lib/oauth-state'
|
|
18
|
+
import { OAUTH_RESULT_QUERY_PARAM } from '../../../../../lib/oauth-result'
|
|
18
19
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
19
20
|
|
|
20
21
|
const logger = createLogger('communication_channels').child({ component: 'oauth-callback' })
|
|
@@ -51,7 +52,7 @@ function redirectWithFlash(
|
|
|
51
52
|
normalizeOAuthReturnUrl(returnUrl, DEFAULT_OAUTH_RETURN_URL),
|
|
52
53
|
getAppBaseUrl(req),
|
|
53
54
|
)
|
|
54
|
-
base.searchParams.set(
|
|
55
|
+
base.searchParams.set(OAUTH_RESULT_QUERY_PARAM, flash.type)
|
|
55
56
|
if (flash.code) base.searchParams.set('code', flash.code)
|
|
56
57
|
if (flash.provider) base.searchParams.set('provider', flash.provider)
|
|
57
58
|
if (flash.channelId) base.searchParams.set('channelId', flash.channelId)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'
|
|
5
5
|
import { getImportHistoryLimits } from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'
|
|
6
|
+
import { OAUTH_RESULT_QUERY_PARAM, stripOAuthResultParams } from '@open-mercato/core/modules/communication_channels/lib/oauth-result'
|
|
6
7
|
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
7
8
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
8
9
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -76,7 +77,7 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
76
77
|
const t = useT()
|
|
77
78
|
const router = useRouter()
|
|
78
79
|
const searchParams = useSearchParams()
|
|
79
|
-
const flashType = searchParams?.get(
|
|
80
|
+
const flashType = searchParams?.get(OAUTH_RESULT_QUERY_PARAM)
|
|
80
81
|
const flashCode = searchParams?.get('code')
|
|
81
82
|
const flashProvider = searchParams?.get('provider')
|
|
82
83
|
|
|
@@ -96,10 +97,13 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
96
97
|
|
|
97
98
|
React.useEffect(() => {
|
|
98
99
|
if (flashType === 'connected') {
|
|
100
|
+
const providerLabel = flashProvider
|
|
101
|
+
? t(`communication_channels.channel.providers.${flashProvider}`, '')
|
|
102
|
+
: ''
|
|
99
103
|
flash(
|
|
100
|
-
|
|
104
|
+
providerLabel
|
|
101
105
|
? t('communication_channels.profile.flash.connectedWithProvider', 'Channel connected ({provider}).', {
|
|
102
|
-
provider:
|
|
106
|
+
provider: providerLabel,
|
|
103
107
|
})
|
|
104
108
|
: t('communication_channels.profile.flash.connected', 'Channel connected.'),
|
|
105
109
|
'success',
|
|
@@ -126,15 +130,14 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
126
130
|
'communication_channels.profile.flash.stateStoreUnavailable',
|
|
127
131
|
'A server error prevented completing the connection. Please try again in a moment.',
|
|
128
132
|
)
|
|
129
|
-
:
|
|
130
|
-
? t('communication_channels.profile.flash.errorWithCode', 'Failed to connect channel — {code}.', {
|
|
131
|
-
code: flashCode,
|
|
132
|
-
})
|
|
133
|
-
: t('communication_channels.profile.flash.error', 'Failed to connect channel.'),
|
|
133
|
+
: t('communication_channels.profile.flash.error', 'Failed to connect channel.'),
|
|
134
134
|
'error',
|
|
135
135
|
)
|
|
136
|
+
} else {
|
|
137
|
+
return
|
|
136
138
|
}
|
|
137
|
-
|
|
139
|
+
router.replace(stripOAuthResultParams(window.location.href), { scroll: false })
|
|
140
|
+
}, [flashType, flashCode, flashProvider, router, t])
|
|
138
141
|
|
|
139
142
|
React.useEffect(() => {
|
|
140
143
|
let cancelled = false
|
|
@@ -107,7 +107,6 @@
|
|
|
107
107
|
"communication_channels.profile.flash.connected": "Kanal verbunden.",
|
|
108
108
|
"communication_channels.profile.flash.connectedWithProvider": "Kanal verbunden ({provider}).",
|
|
109
109
|
"communication_channels.profile.flash.error": "Kanal konnte nicht verbunden werden.",
|
|
110
|
-
"communication_channels.profile.flash.errorWithCode": "Kanal konnte nicht verbunden werden — {code}.",
|
|
111
110
|
"communication_channels.profile.flash.replayedState": "Dieser Verbindungslink wurde bereits verwendet — bitte starten Sie die Verbindung erneut.",
|
|
112
111
|
"communication_channels.profile.flash.stateStoreUnavailable": "Ein Serverfehler hat die Verbindung verhindert. Bitte versuchen Sie es in Kürze erneut.",
|
|
113
112
|
"communication_channels.profile.group": "Profil",
|