@purposeinplay/payload-ai-translate 0.1.0

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.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
@@ -0,0 +1,529 @@
1
+ import { DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from '../../bulk-translate-batches-collection.js';
2
+ import { collectBlocksConfig, extractTranslationUnits } from '../../lib/content-extractor.js';
3
+ import { checkAndIncrementDailySpend } from '../../lib/daily-spend-cap.js';
4
+ import { getEffectiveExcludePatternsForSurface, getGlobalKillSwitches } from '../../lib/effective-locales.js';
5
+ import { resolveTranslatableFields } from '../../lib/field-resolver.js';
6
+ import { findByIdNoFallback } from '../../lib/payload-read.js';
7
+ import { DEFAULT_SETTINGS_GLOBAL_SLUG } from '../../settings-global.js';
8
+ import { BULK_TRANSLATE_COORDINATOR_SLUG } from '../../tasks/bulk-translate-coordinator.js';
9
+ import { errorResponse, getAiTranslateConfig, isEditorOrAdmin, readJsonBody, unauthorizedResponse, verifyTotpCode } from './_helpers.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Handler
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * `POST /api/translation-hub/bulk-translate` — enqueue a bulk run.
15
+ *
16
+ * Flow (see Design-2026-05-27-bulk-translate.md §4 PR2):
17
+ * 1. Auth — admin role (Decision #31).
18
+ * 2. TOTP friction — gated by `bulk.requireTotp` (Decision #13 v2).
19
+ * Friction-not-boundary: the daily USD cap is the real guard.
20
+ * 3. Body validation — discriminated by `mode`.
21
+ * 4. Concurrency refusal — one bulk run at a time per Decision #2.
22
+ * 5. Cost estimate — sum provider's `estimate()` across the resolved
23
+ * doc list. Hard-reject if `undefined` (Decision #29). Canary
24
+ * mode skips estimation — too cheap to matter.
25
+ * 6. Daily cap — `checkAndIncrementDailySpend` (Decision #14).
26
+ * 7. Snapshot pinned config — `{ providerKey, modelId, sourceLocale }`
27
+ * captured at enqueue (Decision #5).
28
+ * 8. Create batch row → queue coordinator task → 202.
29
+ */ export const getBulkTranslateEnqueueHandler = (options = {})=>async (req)=>{
30
+ const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
31
+ const coordinatorTaskSlug = options.coordinatorTaskSlug ?? BULK_TRANSLATE_COORDINATOR_SLUG;
32
+ if (!req.user) {
33
+ return unauthorizedResponse(req);
34
+ }
35
+ if (!isEditorOrAdmin(req.user)) {
36
+ return errorResponse('forbidden', "You don't have permission to start a bulk translation. Contact an admin.", 403);
37
+ }
38
+ const config = getAiTranslateConfig(req.payload);
39
+ if (!config) {
40
+ return errorResponse('not_configured', "The translation system isn't set up for this site. Contact engineering.", 500);
41
+ }
42
+ // Bulk engine is on whenever the consumer passes a `bulk` block
43
+ // and hasn't explicitly opted out (`enabled: false`). Mirrors the
44
+ // boot-time gate in `plugin.ts`.
45
+ const bulkConfig = config.bulk;
46
+ if (!bulkConfig || bulkConfig.enabled === false) {
47
+ return errorResponse('not_configured', "Bulk translation isn't enabled for this site. Contact engineering.", 403);
48
+ }
49
+ // Plugin-wide bulk-translate kill switch (v1.2.8). When admin
50
+ // flipped `globalBulkTranslateEnabled` off in the settings global,
51
+ // NEW bulk runs are rejected here. In-flight runs that were
52
+ // already enqueued continue to completion — the worker
53
+ // (`bulk-translate-doc-task`) does not poll this flag, so this
54
+ // gate is "block new enqueues only" by design. Manual Translate
55
+ // and auto-translate are unaffected.
56
+ const killSwitches = await getGlobalKillSwitches(req.payload, config);
57
+ if (!killSwitches.bulkEnabled) {
58
+ return errorResponse('bulk_translate_disabled', 'Bulk Translate is paused site-wide. An admin can re-enable it in Translation Settings. Any run that was already in progress will continue to completion.', 403);
59
+ }
60
+ const parsed = await readJsonBody(req);
61
+ if (!parsed.ok) return parsed.res;
62
+ const body = parsed.body;
63
+ const validationError = validateEnqueueBody(body, config);
64
+ if (validationError) return validationError;
65
+ // ----- TOTP gate (Decision #13 v2) -----
66
+ // Accept both `totpCode` (canonical) and `totp` (UI alias) — the
67
+ // modal sends the shorter name.
68
+ const totpCode = body.totpCode ?? body.totp;
69
+ const totpResult = await verifyTotpCode(req.payload, req.user.id, totpCode, {
70
+ required: bulkConfig.requireTotp === true,
71
+ // Skip when TOTP plugin isn't installed — Decision #13 v2
72
+ // explicitly downgrades TOTP to friction-not-boundary; the
73
+ // daily USD cap is the real enforcement and applies below.
74
+ allowSkipWhenUnavailable: true
75
+ });
76
+ if (!totpResult.ok) {
77
+ const status = totpResult.code === 'missing' ? 400 : 401;
78
+ return errorResponse('totp_' + totpResult.code, totpResult.message, status);
79
+ }
80
+ // ----- Concurrency refusal (Decision #2) -----
81
+ const inFlight = await findInFlightBatchForUser(req.payload, batchesSlug, String(req.user.id));
82
+ if (inFlight) {
83
+ return errorResponse('concurrent_batch', 'A translation run is already in progress. Cancel the current run before starting a new one.', 409, {
84
+ existingBatchId: String(inFlight.id)
85
+ });
86
+ }
87
+ // ----- Resolve scope → document list -----
88
+ const resolvedScope = resolveScope(body.scope, config, bulkConfig);
89
+ if (resolvedScope.collections.length === 0 && resolvedScope.globals.length === 0) {
90
+ return errorResponse('empty_scope', 'The selected scope contains nothing to translate — pick at least one collection or global.', 400, {
91
+ scope: 'empty'
92
+ });
93
+ }
94
+ // ----- Cost estimate (Decision #29: hard-reject undefined) -----
95
+ // Canary mode skips estimation (Decision #26: small fixed sample).
96
+ let estimatedCostUsd;
97
+ let estimatedDocs = 0;
98
+ if (body.mode === 'canary') {
99
+ estimatedCostUsd = 0; // free pass — canary is bounded by canaryLimit
100
+ estimatedDocs = body.canaryLimit ?? bulkConfig.canaryDefaultSize ?? 10;
101
+ } else {
102
+ const estimate = await estimateBatchCost(req.payload, config, resolvedScope);
103
+ if (estimate.estimatedCostUsd === undefined) {
104
+ return errorResponse('cost_unavailable', "A cost estimate couldn't be calculated for the current AI model. The run is blocked as a safety measure — contact engineering to check the model's pricing configuration.", 402);
105
+ }
106
+ estimatedCostUsd = estimate.estimatedCostUsd;
107
+ estimatedDocs = estimate.documentCount;
108
+ }
109
+ // ----- Daily cap (Decision #14) -----
110
+ const capResult = await checkAndIncrementDailySpend(req.payload, estimatedCostUsd, {
111
+ capUsd: bulkConfig.dailyUsdCap
112
+ });
113
+ if (!capResult.allowed) {
114
+ // Fire onCapExceeded so consumers can page on-call before the
115
+ // cap silently locks out the day.
116
+ try {
117
+ await bulkConfig.onCapExceeded?.({
118
+ todaySpentUsd: capResult.todaySpentUsd,
119
+ capUsd: capResult.capUsd,
120
+ rejectedEstimateUsd: estimatedCostUsd,
121
+ requestPath: 'bulk-endpoint'
122
+ });
123
+ } catch {
124
+ // Best-effort hook — never block the response on a consumer
125
+ // callback exception.
126
+ }
127
+ return errorResponse(capResult.reason === 'cap_exceeded' ? 'daily_cap_exceeded' : 'invalid_estimate', capResult.message, 402, {
128
+ todaySpentUsd: String(capResult.todaySpentUsd),
129
+ remainingUsd: String(capResult.remainingUsd),
130
+ capUsd: String(capResult.capUsd)
131
+ });
132
+ }
133
+ // ----- Snapshot pinned config (Decision #5) -----
134
+ const snapshot = await snapshotActiveProviderConfig(req.payload, config);
135
+ // ----- Create batch row -----
136
+ const enqueuedAt = new Date().toISOString();
137
+ const userEmail = typeof req.user.email === 'string' ? req.user.email : undefined;
138
+ const totalLocales = resolvedScope.locales.length;
139
+ const totalUnits = estimatedDocs * Math.max(1, totalLocales);
140
+ const batch = await req.payload.create({
141
+ collection: batchesSlug,
142
+ data: {
143
+ scope: {
144
+ collections: resolvedScope.collections,
145
+ globals: resolvedScope.globals,
146
+ locales: resolvedScope.locales,
147
+ excludeCollections: resolvedScope.excludeCollections,
148
+ documentIds: resolvedScope.documentIdsFlat,
149
+ sourceLocale: snapshot.sourceLocale,
150
+ mode: body.mode,
151
+ canaryLimit: body.mode === 'canary' ? body.canaryLimit ?? bulkConfig.canaryDefaultSize ?? 10 : undefined
152
+ },
153
+ mode: body.mode,
154
+ canaryLimit: body.mode === 'canary' ? body.canaryLimit ?? bulkConfig.canaryDefaultSize ?? 10 : undefined,
155
+ snapshotProviderKey: snapshot.providerKey,
156
+ snapshotModelId: snapshot.modelId,
157
+ snapshotSourceLocale: snapshot.sourceLocale,
158
+ estimatedCostUsd,
159
+ actualCostUsd: 0,
160
+ status: 'queued',
161
+ totalUnits,
162
+ completedUnits: 0,
163
+ failedUnits: 0,
164
+ skippedUnits: 0,
165
+ triggeredByUserId: String(req.user.id),
166
+ triggeredByEmail: userEmail,
167
+ triggerReason: body.triggerReason,
168
+ enqueuedAt
169
+ },
170
+ overrideAccess: true
171
+ });
172
+ // ----- Queue coordinator -----
173
+ try {
174
+ await req.payload.jobs.queue({
175
+ task: coordinatorTaskSlug,
176
+ input: {
177
+ batchId: String(batch.id)
178
+ }
179
+ });
180
+ } catch (err) {
181
+ // Coordinator queue failed — mark batch failed immediately so
182
+ // the user gets a real error rather than a phantom queued row.
183
+ await req.payload.update({
184
+ collection: batchesSlug,
185
+ id: batch.id,
186
+ data: {
187
+ status: 'failed',
188
+ completedAt: new Date().toISOString(),
189
+ failures: {
190
+ queue_error: 1,
191
+ message: err instanceof Error ? err.message : String(err)
192
+ }
193
+ },
194
+ overrideAccess: true
195
+ });
196
+ return errorResponse('queue_failed', "The translation run couldn't be started. Try again, or contact engineering if it keeps happening.", 500);
197
+ }
198
+ const batchId = String(batch.id);
199
+ return Response.json({
200
+ data: {
201
+ batchId,
202
+ status: 'queued',
203
+ statusUrl: `/api/translation-hub/bulk-translate/${batchId}/status`,
204
+ estimatedCostUsd,
205
+ estimatedDocs,
206
+ totalUnits,
207
+ enqueuedAt
208
+ }
209
+ }, {
210
+ status: 202
211
+ });
212
+ };
213
+ function validateEnqueueBody(body, config) {
214
+ if (!body || typeof body !== 'object') {
215
+ return errorResponse('invalid_body', 'Request body must be an object.', 400);
216
+ }
217
+ // Default scope to empty object when omitted. `resolveScope()` fills
218
+ // it in from the plugin config — clicking "Bulk Translate" from the
219
+ // Hub with no narrowing means "everything tracked", and the UI
220
+ // shouldn't need to re-state the plugin's collection list.
221
+ if (!body.scope) {
222
+ body.scope = {};
223
+ }
224
+ if (typeof body.scope !== 'object') {
225
+ return errorResponse('invalid_body', '`scope` must be an object when provided.', 400, {
226
+ scope: 'invalid'
227
+ });
228
+ }
229
+ const mode = body.mode;
230
+ if (mode !== 'changed' && mode !== 'force' && mode !== 'canary') {
231
+ return errorResponse('invalid_body', 'mode must be one of: changed | force | canary.', 400, {
232
+ mode: 'invalid'
233
+ });
234
+ }
235
+ if (mode === 'canary') {
236
+ const limit = body.canaryLimit;
237
+ if (limit !== undefined && (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0)) {
238
+ return errorResponse('invalid_body', 'canaryLimit must be a positive number when provided.', 400, {
239
+ canaryLimit: 'invalid'
240
+ });
241
+ }
242
+ }
243
+ // Validate locales against the plugin's configured `targetLocales`.
244
+ // The coordinator already filters source-locale matches, so we only
245
+ // gate against typos / locales the plugin can't translate to.
246
+ const locales = body.scope.locales;
247
+ if (locales !== undefined) {
248
+ if (!Array.isArray(locales)) {
249
+ return errorResponse('invalid_body', 'scope.locales must be an array of locale codes.', 400, {
250
+ 'scope.locales': 'invalid'
251
+ });
252
+ }
253
+ const allowed = new Set(config.targetLocales);
254
+ const invalid = locales.filter((l)=>!allowed.has(l));
255
+ if (invalid.length > 0) {
256
+ return errorResponse('invalid_locale', `Locale(s) not configured in plugin.targetLocales: ${invalid.join(', ')}.`, 400, {
257
+ 'scope.locales': 'invalid'
258
+ });
259
+ }
260
+ // An explicit empty list is a contradiction, not a default — a run
261
+ // that translates into zero locales can't mean anything. Reject it
262
+ // rather than silently falling back to all targetLocales.
263
+ if (locales.length === 0) {
264
+ return errorResponse('invalid_body', 'scope.locales must contain at least one locale when provided.', 400, {
265
+ 'scope.locales': 'empty'
266
+ });
267
+ }
268
+ }
269
+ for (const key of [
270
+ 'collections',
271
+ 'globals',
272
+ 'excludeCollections'
273
+ ]){
274
+ const v = body.scope[key];
275
+ if (v !== undefined && (!Array.isArray(v) || v.some((s)=>typeof s !== 'string'))) {
276
+ return errorResponse('invalid_body', `scope.${key} must be an array of strings when provided.`, 400, {
277
+ [`scope.${key}`]: 'invalid'
278
+ });
279
+ }
280
+ }
281
+ if (body.scope.documentIds !== undefined) {
282
+ if (typeof body.scope.documentIds !== 'object' || body.scope.documentIds === null || Array.isArray(body.scope.documentIds)) {
283
+ return errorResponse('invalid_body', 'scope.documentIds must be a record of collection → string[].', 400, {
284
+ 'scope.documentIds': 'invalid'
285
+ });
286
+ }
287
+ for (const [collection, ids] of Object.entries(body.scope.documentIds)){
288
+ if (!Array.isArray(ids) || ids.some((id)=>typeof id !== 'string')) {
289
+ return errorResponse('invalid_body', `scope.documentIds.${collection} must be a string[].`, 400, {
290
+ [`scope.documentIds.${collection}`]: 'invalid'
291
+ });
292
+ }
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+ export function resolveScope(scope, config, bulk) {
298
+ const pluginCollections = config.collections ?? [];
299
+ const pluginGlobals = config.globals ?? [];
300
+ const pluginExcludes = new Set(bulk.excludeCollections ?? []);
301
+ // ABSENT means "everything the plugin tracks" (back-compat default);
302
+ // an EXPLICIT array — including an empty one — means exactly what the
303
+ // caller selected. The previous `length > 0` check collapsed "the user
304
+ // deselected every global" into "unspecified", so a run scoped to a
305
+ // single collection silently fanned out to all globals (and vice
306
+ // versa for collections).
307
+ const requestedCollections = Array.isArray(scope.collections) ? scope.collections : pluginCollections;
308
+ const requestedGlobals = Array.isArray(scope.globals) ? scope.globals : pluginGlobals;
309
+ const excludeCollections = new Set([
310
+ ...scope.excludeCollections ?? [],
311
+ ...pluginExcludes
312
+ ]);
313
+ // Filter by both the requested set AND the plugin's tracked
314
+ // collections — a caller can't bulk-translate a collection the
315
+ // plugin doesn't know about (and shouldn't be able to). Plugin-
316
+ // tracked-only also closes the security N5 doppelganger here.
317
+ const trackedCollections = new Set(pluginCollections);
318
+ const trackedGlobals = new Set(pluginGlobals);
319
+ const collectionsList = requestedCollections.filter((c)=>trackedCollections.has(c) && !excludeCollections.has(c));
320
+ const globalsList = requestedGlobals.filter((g)=>trackedGlobals.has(g));
321
+ const locales = scope.locales && scope.locales.length > 0 ? scope.locales : [
322
+ ...config.targetLocales
323
+ ];
324
+ // Flatten documentIds map → flat array of ids the coordinator can
325
+ // consume. We don't preserve the collection→ids mapping in the
326
+ // batch row's scope.documentIds because the coordinator currently
327
+ // accepts only a flat list (matches its existing semantics —
328
+ // documents-explicit mode walks the same collection it's enumerating).
329
+ let documentIdsFlat = [];
330
+ if (scope.documentIds) {
331
+ for (const [coll, ids] of Object.entries(scope.documentIds)){
332
+ if (!collectionsList.includes(coll)) continue;
333
+ documentIdsFlat = documentIdsFlat.concat(ids);
334
+ }
335
+ }
336
+ return {
337
+ collections: collectionsList,
338
+ globals: globalsList,
339
+ locales,
340
+ excludeCollections: Array.from(excludeCollections),
341
+ documentIdsFlat
342
+ };
343
+ }
344
+ async function findInFlightBatchForUser(payload, batchesSlug, userId) {
345
+ const result = await payload.find({
346
+ collection: batchesSlug,
347
+ where: {
348
+ and: [
349
+ {
350
+ triggeredByUserId: {
351
+ equals: userId
352
+ }
353
+ },
354
+ {
355
+ status: {
356
+ in: [
357
+ 'queued',
358
+ 'running',
359
+ 'cancelling'
360
+ ]
361
+ }
362
+ }
363
+ ]
364
+ },
365
+ limit: 1,
366
+ depth: 0,
367
+ overrideAccess: true
368
+ });
369
+ const doc = result.docs[0];
370
+ return doc;
371
+ }
372
+ /**
373
+ * Enumerate the scope's docs, run the provider's `estimate()` once
374
+ * with the aggregated items, multiply by locale count. Mirrors the
375
+ * pattern in `translate.ts:estimateRequestCost` but at batch scale.
376
+ *
377
+ * Returns `estimatedCostUsd: undefined` only when the provider has no
378
+ * `estimate()` method — the enqueue handler then hard-rejects per
379
+ * Decision #29.
380
+ */ export async function estimateBatchCost(payload, config, scope) {
381
+ if (!config.provider.estimate) {
382
+ return {
383
+ estimatedCostUsd: undefined,
384
+ documentCount: 0
385
+ };
386
+ }
387
+ const allItems = [];
388
+ let documentCount = 0;
389
+ for (const collectionSlug of scope.collections){
390
+ const collections = payload.config?.collections ?? [];
391
+ const collectionConfig = collections.find((c)=>c.slug === collectionSlug);
392
+ if (!collectionConfig) continue;
393
+ const translatableFields = resolveTranslatableFields(collectionConfig.fields);
394
+ const blocksConfig = collectBlocksConfig(collectionConfig.fields);
395
+ const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, collectionSlug);
396
+ // If documentIdsFlat is non-empty, only estimate those docs in
397
+ // this collection. Otherwise paginate the whole collection.
398
+ const idsForThisCollection = scope.documentIdsFlat;
399
+ const docs = [];
400
+ if (idsForThisCollection.length > 0) {
401
+ for (const id of idsForThisCollection){
402
+ const doc = await findByIdNoFallback(payload, collectionSlug, id, config.sourceLocale, {
403
+ draft: true
404
+ });
405
+ if (doc) docs.push(doc);
406
+ }
407
+ } else {
408
+ // Paginate. Cap at 500 docs per collection to bound the
409
+ // estimate's wall-clock — the enqueue handler is request-
410
+ // scoped and a 5000-doc estimate would push past serverless
411
+ // limits.
412
+ const maxDocsPerCollection = 500;
413
+ let page = 1;
414
+ const limit = 100;
415
+ while(docs.length < maxDocsPerCollection){
416
+ const result = await payload.find({
417
+ collection: collectionSlug,
418
+ page,
419
+ limit,
420
+ depth: 0,
421
+ locale: config.sourceLocale,
422
+ overrideAccess: true,
423
+ draft: true
424
+ });
425
+ docs.push(...result.docs);
426
+ if (!result.hasNextPage) break;
427
+ page += 1;
428
+ }
429
+ }
430
+ for (const doc of docs){
431
+ const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
432
+ for (const unit of units){
433
+ allItems.push({
434
+ id: unit.id,
435
+ text: unit.text,
436
+ kind: unit.kind
437
+ });
438
+ }
439
+ documentCount += 1;
440
+ }
441
+ }
442
+ for (const globalSlug of scope.globals){
443
+ const globals = payload.config?.globals ?? [];
444
+ const globalConfig = globals.find((g)=>g.slug === globalSlug);
445
+ if (!globalConfig) continue;
446
+ const translatableFields = resolveTranslatableFields(globalConfig.fields);
447
+ const blocksConfig = collectBlocksConfig(globalConfig.fields);
448
+ const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, globalSlug);
449
+ let doc;
450
+ try {
451
+ doc = await payload.findGlobal({
452
+ slug: globalSlug,
453
+ locale: config.sourceLocale,
454
+ fallbackLocale: null,
455
+ overrideAccess: true
456
+ });
457
+ } catch {
458
+ doc = undefined;
459
+ }
460
+ if (!doc) continue;
461
+ const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
462
+ for (const unit of units){
463
+ allItems.push({
464
+ id: unit.id,
465
+ text: unit.text,
466
+ kind: unit.kind
467
+ });
468
+ }
469
+ documentCount += 1;
470
+ }
471
+ if (allItems.length === 0) {
472
+ // No content to translate. Return 0 cost (the batch will land
473
+ // as `completed` with `totalUnits=0`).
474
+ return {
475
+ estimatedCostUsd: 0,
476
+ documentCount
477
+ };
478
+ }
479
+ const mockRequest = {
480
+ items: allItems,
481
+ sourceLocale: config.sourceLocale,
482
+ targetLocale: scope.locales[0] ?? '',
483
+ context: {
484
+ collectionSlug: scope.collections[0] ?? '',
485
+ fieldPath: '*'
486
+ }
487
+ };
488
+ const estimate = await config.provider.estimate(mockRequest);
489
+ const totalCost = estimate.estimatedCostUsd !== undefined ? estimate.estimatedCostUsd * Math.max(1, scope.locales.length) : undefined;
490
+ return {
491
+ estimatedCostUsd: totalCost,
492
+ documentCount
493
+ };
494
+ }
495
+ /**
496
+ * Decision #5 + F-DA-MODEL-SWAP: pin the active `{provider, model,
497
+ * sourceLocale}` into the batch row at enqueue. Mid-run admin toggles
498
+ * must not bisect a batch across configurations.
499
+ *
500
+ * Resolution order matches `api.ts:resolveProvider`:
501
+ * 1. `translation-settings.activeProvider` keyed into `config.providers`
502
+ * 2. fallback to `config.provider`
503
+ */ async function snapshotActiveProviderConfig(payload, config) {
504
+ let providerKey = 'default';
505
+ let activeProvider = config.provider;
506
+ if (config.providers && Object.keys(config.providers).length > 0) {
507
+ const settingsSlug = config.settings?.globalSlug ?? DEFAULT_SETTINGS_GLOBAL_SLUG;
508
+ try {
509
+ const settings = await payload.findGlobal({
510
+ slug: settingsSlug,
511
+ depth: 0,
512
+ overrideAccess: true
513
+ });
514
+ const active = settings?.activeProvider;
515
+ if (active && config.providers[active]) {
516
+ providerKey = active;
517
+ activeProvider = config.providers[active];
518
+ }
519
+ } catch {
520
+ // Settings global not present — fall back to default below.
521
+ }
522
+ }
523
+ const modelId = typeof activeProvider.model === 'string' && activeProvider.model.length > 0 ? activeProvider.model : 'unknown';
524
+ return {
525
+ providerKey,
526
+ modelId,
527
+ sourceLocale: config.sourceLocale
528
+ };
529
+ }
@@ -0,0 +1,12 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export interface BulkFailuresHandlerOptions {
3
+ unitsCollectionSlug?: string;
4
+ }
5
+ /**
6
+ * `GET /api/translation-hub/bulk-translate/:id/failures`
7
+ *
8
+ * Paginated drill-down of failed units in a batch. Used by the failure
9
+ * drawer in the terminal card. Response shape:
10
+ * { rows: BulkTranslateFailureRow[], nextCursor: string | null }
11
+ */
12
+ export declare const getBulkTranslateFailuresHandler: (options?: BulkFailuresHandlerOptions) => PayloadHandler;
@@ -0,0 +1,67 @@
1
+ import { DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from '../../bulk-translate-units-collection.js';
2
+ import { decodeCursor, encodeCursor, errorResponse, isEditorOrAdmin, unauthorizedResponse } from './_helpers.js';
3
+ import { extractBatchId } from './status.js';
4
+ /**
5
+ * `GET /api/translation-hub/bulk-translate/:id/failures`
6
+ *
7
+ * Paginated drill-down of failed units in a batch. Used by the failure
8
+ * drawer in the terminal card. Response shape:
9
+ * { rows: BulkTranslateFailureRow[], nextCursor: string | null }
10
+ */ export const getBulkTranslateFailuresHandler = (options = {})=>async (req)=>{
11
+ const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
12
+ if (!req.user) {
13
+ return unauthorizedResponse(req);
14
+ }
15
+ if (!isEditorOrAdmin(req.user)) {
16
+ return errorResponse('forbidden', "You don't have permission to view failure details. Contact an admin.", 403);
17
+ }
18
+ const batchId = extractBatchId(req.url ?? '');
19
+ if (!batchId) {
20
+ return errorResponse('invalid_batch_id', 'Batch ID could not be parsed from the request URL.', 400);
21
+ }
22
+ const url = new URL(req.url ?? 'http://localhost');
23
+ const cursor = url.searchParams.get('cursor') ?? undefined;
24
+ const limitRaw = url.searchParams.get('limit');
25
+ let limit = limitRaw ? Number.parseInt(limitRaw, 10) : 25;
26
+ if (!Number.isFinite(limit) || limit <= 0) limit = 25;
27
+ if (limit > 100) limit = 100;
28
+ const offset = decodeCursor(cursor);
29
+ const page = Math.floor(offset / limit) + 1;
30
+ const result = await req.payload.find({
31
+ collection: unitsSlug,
32
+ where: {
33
+ and: [
34
+ {
35
+ batchId: {
36
+ equals: batchId
37
+ }
38
+ },
39
+ {
40
+ status: {
41
+ equals: 'failed'
42
+ }
43
+ }
44
+ ]
45
+ },
46
+ sort: 'createdAt',
47
+ page,
48
+ limit,
49
+ depth: 0,
50
+ overrideAccess: true
51
+ });
52
+ const rows = result.docs.map((u)=>({
53
+ unitId: String(u.id),
54
+ collection: String(u.collection ?? ''),
55
+ documentId: String(u.documentId ?? ''),
56
+ documentTitle: null,
57
+ locale: String(u.locale ?? ''),
58
+ failureCode: typeof u.failureCode === 'string' ? u.failureCode : null,
59
+ failureMessage: typeof u.failureMessage === 'string' ? u.failureMessage : '',
60
+ attempts: Number(u.attempts ?? 0)
61
+ }));
62
+ const nextCursor = rows.length === limit ? encodeCursor(offset + limit) : null;
63
+ return Response.json({
64
+ rows,
65
+ nextCursor
66
+ });
67
+ };
@@ -0,0 +1,20 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export interface BulkForceResetHandlerOptions {
3
+ batchesCollectionSlug?: string;
4
+ unitsCollectionSlug?: string;
5
+ }
6
+ /**
7
+ * `POST /api/translation-hub/bulk-translate/:id/force-reset`
8
+ *
9
+ * Recovery action — F-SEC-CRON-RESET. Marks every `running` unit in
10
+ * the batch as `failed` with `failureCode: 'transient.crashed'` and
11
+ * the batch itself as `failed`. Used by on-call when the worker /
12
+ * coordinator stalled and the normal janitor sweep hasn't picked it
13
+ * up.
14
+ *
15
+ * Auth: admin only — explicitly NO TOTP gate (this is a recovery
16
+ * action; admins must be able to unblock production without a TOTP
17
+ * dance if their mobile device is unavailable). The daily USD cap
18
+ * already enforced spending bounds for the batch in question.
19
+ */
20
+ export declare const getBulkTranslateForceResetHandler: (options?: BulkForceResetHandlerOptions) => PayloadHandler;