@open-mercato/core 0.9.1-develop.7459.1.99e3accd30 → 0.9.1-develop.7462.1.94c09f158f
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/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
|
@@ -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",
|
|
@@ -107,7 +107,6 @@
|
|
|
107
107
|
"communication_channels.profile.flash.connected": "Channel connected.",
|
|
108
108
|
"communication_channels.profile.flash.connectedWithProvider": "Channel connected ({provider}).",
|
|
109
109
|
"communication_channels.profile.flash.error": "Failed to connect channel.",
|
|
110
|
-
"communication_channels.profile.flash.errorWithCode": "Failed to connect channel — {code}.",
|
|
111
110
|
"communication_channels.profile.flash.replayedState": "This connection link was already used — please start the connection again.",
|
|
112
111
|
"communication_channels.profile.flash.stateStoreUnavailable": "A server error prevented completing the connection. Please try again in a moment.",
|
|
113
112
|
"communication_channels.profile.group": "Profile",
|
|
@@ -107,7 +107,6 @@
|
|
|
107
107
|
"communication_channels.profile.flash.connected": "Canal conectado.",
|
|
108
108
|
"communication_channels.profile.flash.connectedWithProvider": "Canal conectado ({provider}).",
|
|
109
109
|
"communication_channels.profile.flash.error": "No se pudo conectar el canal.",
|
|
110
|
-
"communication_channels.profile.flash.errorWithCode": "No se pudo conectar el canal — {code}.",
|
|
111
110
|
"communication_channels.profile.flash.replayedState": "Este enlace de conexión ya se utilizó — inicia la conexión de nuevo.",
|
|
112
111
|
"communication_channels.profile.flash.stateStoreUnavailable": "Un error del servidor impidió completar la conexión. Inténtalo de nuevo en un momento.",
|
|
113
112
|
"communication_channels.profile.group": "Perfil",
|
|
@@ -107,7 +107,6 @@
|
|
|
107
107
|
"communication_channels.profile.flash.connected": "채널이 연결되었습니다.",
|
|
108
108
|
"communication_channels.profile.flash.connectedWithProvider": "채널이 연결되었습니다 ({provider}).",
|
|
109
109
|
"communication_channels.profile.flash.error": "채널 연결에 실패했습니다.",
|
|
110
|
-
"communication_channels.profile.flash.errorWithCode": "채널 연결에 실패했습니다 — {code}.",
|
|
111
110
|
"communication_channels.profile.flash.replayedState": "이 연결 링크는 이미 사용되었습니다. 연결을 다시 시작해 주세요.",
|
|
112
111
|
"communication_channels.profile.flash.stateStoreUnavailable": "서버 오류로 연결을 완료할 수 없습니다. 잠시 후 다시 시도해 주세요.",
|
|
113
112
|
"communication_channels.profile.group": "프로필",
|
|
@@ -107,7 +107,6 @@
|
|
|
107
107
|
"communication_channels.profile.flash.connected": "Kanał połączony.",
|
|
108
108
|
"communication_channels.profile.flash.connectedWithProvider": "Kanał połączony ({provider}).",
|
|
109
109
|
"communication_channels.profile.flash.error": "Nie udało się połączyć kanału.",
|
|
110
|
-
"communication_channels.profile.flash.errorWithCode": "Nie udało się połączyć kanału — {code}.",
|
|
111
110
|
"communication_channels.profile.flash.replayedState": "Ten link połączenia został już użyty — rozpocznij połączenie ponownie.",
|
|
112
111
|
"communication_channels.profile.flash.stateStoreUnavailable": "Błąd serwera uniemożliwił dokończenie połączenia. Spróbuj ponownie za chwilę.",
|
|
113
112
|
"communication_channels.profile.group": "Profil",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The OAuth callback reports its outcome to the return page through this query
|
|
2
|
+
// param. It must not be `flash`: the global <FlashMessages> host reads `?flash=`
|
|
3
|
+
// as the message text and would render the raw outcome ("error"/"connected") as
|
|
4
|
+
// a success toast (#6402).
|
|
5
|
+
export const OAUTH_RESULT_QUERY_PARAM = 'oauth'
|
|
6
|
+
|
|
7
|
+
const OAUTH_RESULT_QUERY_PARAMS = [OAUTH_RESULT_QUERY_PARAM, 'code', 'provider', 'channelId']
|
|
8
|
+
|
|
9
|
+
export function stripOAuthResultParams(href: string): string {
|
|
10
|
+
const url = new URL(href)
|
|
11
|
+
for (const param of OAUTH_RESULT_QUERY_PARAMS) url.searchParams.delete(param)
|
|
12
|
+
return `${url.pathname}${url.search}${url.hash}`
|
|
13
|
+
}
|