@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,405 @@
1
+ import { DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from '../../bulk-translate-batches-collection.js';
2
+ import { DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from '../../bulk-translate-units-collection.js';
3
+ import { resolveTranslatableFields } from '../../lib/field-resolver.js';
4
+ import { createScopedLogger } from '../../lib/logger.js';
5
+ import { hashLocalizedSchema } from '../../lib/snapshot-select.js';
6
+ import { errorResponse, forbiddenOwnershipResponse, isEditorOrAdmin, ownsBatch, readJsonBody, unauthorizedResponse, verifyTotpCode, writeBulkAuditEntry } from './_helpers.js';
7
+ import { extractBatchId } from './status.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Handler
10
+ // ---------------------------------------------------------------------------
11
+ const REVERTABLE_TERMINAL = new Set([
12
+ 'success',
13
+ 'partial'
14
+ ]);
15
+ const IN_FLIGHT = new Set([
16
+ 'queued',
17
+ 'running',
18
+ 'cancelling'
19
+ ]);
20
+ /**
21
+ * `POST /api/translation-hub/bulk-translate/:id/revert`
22
+ *
23
+ * Restores the pre-run snapshot of every `success` unit in the batch.
24
+ * Decision #22 v2 / F-DA-REVERT: the snapshot is a nested-object
25
+ * payload directly passable to `payload.update({ data: snapshot })`.
26
+ *
27
+ * Refusal cases:
28
+ * - non-revertable batch status (must be `completed | partial`).
29
+ * - batch is currently in-flight (`queued | running | cancelling`).
30
+ * - batch's `completedAt` is more than `windowMs` ago.
31
+ * - TOTP required regardless of plugin config (Decision #33).
32
+ *
33
+ * Per-unit warnings (don't abort the run):
34
+ * - `schema_drift` — schema hash differs and `force !== true`.
35
+ * - `no_snapshot` — unit has no `preRunSnapshot` (older row).
36
+ * - `restore_failed` — `payload.update` threw.
37
+ * - `doc_deleted` — the source doc was deleted post-bulk; nothing
38
+ * to revert against.
39
+ *
40
+ * Writes are flagged via `context: { aiTranslateInternal: true,
41
+ * disableRevalidate: true }` so:
42
+ * - audit-log marks them `source: 'ai-translate'` (avoids confusing
43
+ * editor with 1000 "user updated" entries for a revert).
44
+ * - the plugin's allowlist-spread at translate.ts:483 + the
45
+ * consumer's revalidate hooks honor the disable flag — we'll
46
+ * fire a terminal revalidate sweep at the end.
47
+ */ export const getBulkTranslateRevertHandler = (options = {})=>async (req)=>{
48
+ const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
49
+ const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
50
+ const windowMs = options.windowMs ?? 24 * 60 * 60 * 1000;
51
+ const pageSize = options.pageSize ?? 100;
52
+ if (!req.user) {
53
+ return unauthorizedResponse(req);
54
+ }
55
+ if (!isEditorOrAdmin(req.user)) {
56
+ return errorResponse('forbidden', "You don't have permission to revert a bulk-translation batch. Contact an admin.", 403);
57
+ }
58
+ const batchId = extractBatchId(req.url ?? '');
59
+ if (!batchId) {
60
+ return errorResponse('invalid_batch_id', 'Batch ID could not be parsed from the request URL.', 400);
61
+ }
62
+ const parsed = await readJsonBody(req);
63
+ if (!parsed.ok) return parsed.res;
64
+ const body = parsed.body;
65
+ // ----- TOTP gate (Decision #33 — mandatory, not config-gated) -----
66
+ const totpResult = await verifyTotpCode(req.payload, req.user.id, body.totpCode, {
67
+ required: true,
68
+ // Revert is destructive — refuse if TOTP plugin is unavailable
69
+ // (better to fail loud than allow an unauthenticated revert).
70
+ allowSkipWhenUnavailable: false
71
+ });
72
+ if (!totpResult.ok) {
73
+ const status = totpResult.code === 'missing' ? 400 : 401;
74
+ return errorResponse('totp_' + totpResult.code, totpResult.message, status);
75
+ }
76
+ // ----- Load batch + state checks -----
77
+ let batch;
78
+ try {
79
+ batch = await req.payload.findByID({
80
+ collection: batchesSlug,
81
+ id: batchId,
82
+ overrideAccess: true,
83
+ depth: 0
84
+ });
85
+ } catch (err) {
86
+ const log = createScopedLogger(req.payload, {
87
+ component: 'hub.revert',
88
+ batchId
89
+ });
90
+ log.event('warn', 'hub.revert.batch-read.failed', {
91
+ err,
92
+ endpoint: 'hub.revert',
93
+ batchId
94
+ });
95
+ batch = undefined;
96
+ }
97
+ if (!batch) {
98
+ return errorResponse('not_found', 'This translation run no longer exists. Refresh the page.', 404);
99
+ }
100
+ // 1.2.8: editor can only revert a run they triggered. Admins pass.
101
+ // Revert is destructive so this gate is critical — without it an
102
+ // editor could roll back another editor's completed work.
103
+ if (!ownsBatch(req.user, batch)) {
104
+ return forbiddenOwnershipResponse();
105
+ }
106
+ if (IN_FLIGHT.has(batch.status)) {
107
+ return errorResponse('invalid_state', 'The run is still in progress. Cancel it first, then revert.', 409, {
108
+ status: batch.status
109
+ });
110
+ }
111
+ if (!REVERTABLE_TERMINAL.has(batch.status)) {
112
+ return errorResponse('invalid_state', 'Only completed or partial runs can be reverted — this run is in a different state.', 409, {
113
+ status: batch.status
114
+ });
115
+ }
116
+ if (!batch.completedAt) {
117
+ return errorResponse('invalid_state', "This run is missing a completion timestamp, so the system can't check if it's still revertable. Contact engineering.", 409);
118
+ }
119
+ const completedAtMs = new Date(batch.completedAt).getTime();
120
+ if (!Number.isFinite(completedAtMs)) {
121
+ return errorResponse('invalid_state', "This run's completion timestamp is unreadable. Contact engineering.", 409);
122
+ }
123
+ const ageMs = Date.now() - completedAtMs;
124
+ if (ageMs > windowMs) {
125
+ const hoursAgo = Math.floor(ageMs / 3_600_000);
126
+ const windowHours = Math.floor(windowMs / 3_600_000);
127
+ return errorResponse('window_expired', `The ${windowHours}-hour window to undo this run has passed (it ran ${hoursAgo} hours ago). Contact engineering if you need to restore earlier content.`, 409, {
128
+ hoursElapsed: String(hoursAgo),
129
+ hoursWindow: String(windowHours)
130
+ });
131
+ }
132
+ // ----- Compute current schema hashes per collection (Decision #34) -----
133
+ const currentSchemaHashByCollection = computeCurrentSchemaHashes(req.payload);
134
+ // ----- Paginate units and revert -----
135
+ const warnings = [];
136
+ let revertedUnits = 0;
137
+ let skippedUnits = 0;
138
+ const affectedCollections = new Set();
139
+ const affectedGlobals = new Set();
140
+ const pluginGlobals = new Set((req.payload.config?.globals ?? []).map((g)=>g.slug));
141
+ let page = 1;
142
+ // eslint-disable-next-line no-constant-condition
143
+ while(true){
144
+ const result = await req.payload.find({
145
+ collection: unitsSlug,
146
+ where: {
147
+ and: [
148
+ {
149
+ batchId: {
150
+ equals: batchId
151
+ }
152
+ },
153
+ {
154
+ status: {
155
+ equals: 'success'
156
+ }
157
+ }
158
+ ]
159
+ },
160
+ page,
161
+ limit: pageSize,
162
+ depth: 0,
163
+ overrideAccess: true
164
+ });
165
+ const docs = result.docs;
166
+ for (const u of docs){
167
+ const unit = {
168
+ id: u.id,
169
+ collection: String(u.collection ?? ''),
170
+ documentId: String(u.documentId ?? ''),
171
+ locale: String(u.locale ?? ''),
172
+ preRunSnapshot: u.preRunSnapshot && typeof u.preRunSnapshot === 'object' ? u.preRunSnapshot : null,
173
+ snapshotLocale: typeof u.snapshotLocale === 'string' ? u.snapshotLocale : null,
174
+ schemaHash: typeof u.schemaHash === 'string' ? u.schemaHash : null
175
+ };
176
+ const outcome = await revertSingleUnit({
177
+ payload: req.payload,
178
+ unit,
179
+ unitsSlug,
180
+ currentSchemaHashByCollection,
181
+ force: body.force === true,
182
+ pluginGlobals
183
+ });
184
+ if (outcome.reverted) {
185
+ revertedUnits += 1;
186
+ if (pluginGlobals.has(unit.collection)) {
187
+ affectedGlobals.add(unit.collection);
188
+ } else {
189
+ affectedCollections.add(unit.collection);
190
+ }
191
+ } else {
192
+ skippedUnits += 1;
193
+ if (outcome.warning) warnings.push(outcome.warning);
194
+ }
195
+ }
196
+ if (!result.hasNextPage) break;
197
+ page += 1;
198
+ }
199
+ // ----- Mark batch reverted -----
200
+ const revertedAt = new Date().toISOString();
201
+ try {
202
+ await req.payload.update({
203
+ collection: batchesSlug,
204
+ id: batchId,
205
+ data: {
206
+ status: 'reverted',
207
+ revertedAt,
208
+ revertedByUserId: String(req.user.id)
209
+ },
210
+ overrideAccess: true
211
+ });
212
+ } catch (err) {
213
+ req.payload.logger?.error?.(`[ai-translate] revert: failed to mark batch ${batchId} reverted: ${err instanceof Error ? err.message : String(err)}`);
214
+ }
215
+ // ----- Audit log (Decision #33) -----
216
+ const userEmail = typeof req.user.email === 'string' ? req.user.email : undefined;
217
+ await writeBulkAuditEntry(req.payload, {
218
+ userId: req.user.id,
219
+ userEmail,
220
+ action: 'bulk-translate.revert',
221
+ batchId,
222
+ metadata: {
223
+ revertedUnits,
224
+ skippedUnits,
225
+ warnings: warnings.length,
226
+ force: body.force === true,
227
+ affectedCollections: Array.from(affectedCollections),
228
+ affectedGlobals: Array.from(affectedGlobals)
229
+ }
230
+ });
231
+ // ----- Terminal revalidate sweep -----
232
+ await fireRevalidateSweep(req.payload, affectedCollections, affectedGlobals);
233
+ return Response.json({
234
+ data: {
235
+ batchId,
236
+ revertedUnits,
237
+ skippedUnits,
238
+ warnings,
239
+ revertedAt
240
+ }
241
+ });
242
+ };
243
+ function computeCurrentSchemaHashes(payload) {
244
+ const out = new Map();
245
+ for (const c of payload.config?.collections ?? []){
246
+ try {
247
+ const fields = resolveTranslatableFields(c.fields);
248
+ out.set(c.slug, hashLocalizedSchema(fields));
249
+ } catch {
250
+ // skip; the per-unit check will see a missing entry and warn
251
+ }
252
+ }
253
+ for (const g of payload.config?.globals ?? []){
254
+ try {
255
+ const fields = resolveTranslatableFields(g.fields);
256
+ out.set(g.slug, hashLocalizedSchema(fields));
257
+ } catch {
258
+ // skip
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+ export async function revertSingleUnit(params) {
264
+ const { payload, unit, unitsSlug, currentSchemaHashByCollection, force, pluginGlobals } = params;
265
+ if (!unit.preRunSnapshot || Object.keys(unit.preRunSnapshot).length === 0) {
266
+ return {
267
+ reverted: false,
268
+ warning: {
269
+ unitId: String(unit.id),
270
+ collection: unit.collection,
271
+ documentId: unit.documentId,
272
+ locale: unit.locale,
273
+ reason: 'no_snapshot',
274
+ message: 'No pre-run snapshot captured for this unit.'
275
+ }
276
+ };
277
+ }
278
+ // v1.2.12 — HARD gate, deliberately NOT overridable by `force`.
279
+ // Snapshots written before v1.2.12 were captured from the
280
+ // SOURCE-locale enumeration read and shared across every target
281
+ // locale of the doc; replaying one stamps source-language content
282
+ // over the translation it claims to restore (2026-06-11 prod
283
+ // incident: a batch revert turned every target locale English).
284
+ // Only a snapshot the worker captured FROM this unit's own locale
285
+ // may ever be written back to it.
286
+ if (unit.snapshotLocale !== unit.locale) {
287
+ return {
288
+ reverted: false,
289
+ warning: {
290
+ unitId: String(unit.id),
291
+ collection: unit.collection,
292
+ documentId: unit.documentId,
293
+ locale: unit.locale,
294
+ reason: 'snapshot_locale_mismatch',
295
+ message: unit.snapshotLocale === null ? 'Snapshot predates v1.2.12 and holds source-locale values — reverting would overwrite this translation with source-language content. Not revertable.' : `Snapshot holds "${unit.snapshotLocale}" values but this unit targets "${unit.locale}" — refusing to write cross-locale content.`
296
+ }
297
+ };
298
+ }
299
+ const currentHash = currentSchemaHashByCollection.get(unit.collection);
300
+ if (!force && currentHash && unit.schemaHash && currentHash !== unit.schemaHash) {
301
+ return {
302
+ reverted: false,
303
+ warning: {
304
+ unitId: String(unit.id),
305
+ collection: unit.collection,
306
+ documentId: unit.documentId,
307
+ locale: unit.locale,
308
+ reason: 'schema_drift',
309
+ message: 'Schema of localized translatable fields changed since snapshot. Re-run with force: true to override.'
310
+ }
311
+ };
312
+ }
313
+ // Restore the snapshot. We pass `context.aiTranslateInternal=true`
314
+ // to mark the write as machine-generated (audit-log will tag the
315
+ // entry `source: 'ai-translate'` rather than `user`) and
316
+ // `disableRevalidate=true` to suppress per-doc revalidation — we
317
+ // fire a single terminal sweep at the end of the revert.
318
+ try {
319
+ if (pluginGlobals.has(unit.collection)) {
320
+ await payload.updateGlobal({
321
+ slug: unit.collection,
322
+ locale: unit.locale,
323
+ data: unit.preRunSnapshot,
324
+ context: {
325
+ aiTranslateInternal: true,
326
+ disableRevalidate: true
327
+ },
328
+ overrideAccess: true
329
+ });
330
+ } else {
331
+ await payload.update({
332
+ collection: unit.collection,
333
+ id: unit.documentId,
334
+ locale: unit.locale,
335
+ data: unit.preRunSnapshot,
336
+ context: {
337
+ aiTranslateInternal: true,
338
+ disableRevalidate: true
339
+ },
340
+ overrideAccess: true
341
+ });
342
+ }
343
+ } catch (err) {
344
+ const message = err instanceof Error ? err.message : String(err);
345
+ const isMissing = /not found/i.test(message);
346
+ return {
347
+ reverted: false,
348
+ warning: {
349
+ unitId: String(unit.id),
350
+ collection: unit.collection,
351
+ documentId: unit.documentId,
352
+ locale: unit.locale,
353
+ reason: isMissing ? 'doc_deleted' : 'restore_failed',
354
+ message
355
+ }
356
+ };
357
+ }
358
+ try {
359
+ await payload.update({
360
+ collection: unitsSlug,
361
+ id: unit.id,
362
+ data: {
363
+ status: 'reverted'
364
+ },
365
+ overrideAccess: true
366
+ });
367
+ } catch (err) {
368
+ // Unit-row update failure shouldn't drop us into a warning — the
369
+ // real-world write succeeded. Log and move on.
370
+ payload.logger?.warn?.(`[ai-translate] revert: failed to mark unit ${unit.id} reverted: ${err instanceof Error ? err.message : String(err)}`);
371
+ }
372
+ return {
373
+ reverted: true
374
+ };
375
+ }
376
+ async function fireRevalidateSweep(payload, collections, globals) {
377
+ // The plugin doesn't take a hard dependency on Next.js — instead,
378
+ // we attempt to dynamically import `next/cache` and call
379
+ // `revalidateTag` for each affected surface. This is best-effort:
380
+ // running in a non-Next environment (e.g. tests, Payload CLI) is
381
+ // simply a no-op.
382
+ let revalidateTag;
383
+ try {
384
+ const mod = await import('next/cache');
385
+ revalidateTag = mod.revalidateTag;
386
+ } catch {
387
+ return;
388
+ }
389
+ if (!revalidateTag) return;
390
+ for (const slug of collections){
391
+ try {
392
+ revalidateTag(`${slug}-sitemap`);
393
+ revalidateTag(slug);
394
+ } catch (err) {
395
+ payload.logger?.warn?.(`[ai-translate] revert: revalidateTag(${slug}) failed: ${err instanceof Error ? err.message : String(err)}`);
396
+ }
397
+ }
398
+ for (const slug of globals){
399
+ try {
400
+ revalidateTag(slug);
401
+ } catch (err) {
402
+ payload.logger?.warn?.(`[ai-translate] revert: revalidateTag(${slug}) failed: ${err instanceof Error ? err.message : String(err)}`);
403
+ }
404
+ }
405
+ }
@@ -0,0 +1,45 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export type BatchJobSummary = {
3
+ unitId: string;
4
+ collection: string;
5
+ documentId: string;
6
+ locale: string;
7
+ status: string;
8
+ attempts: number;
9
+ failureCode: string | null;
10
+ failureMessage: string | null;
11
+ costUsd: number;
12
+ startedAt: string | null;
13
+ completedAt: string | null;
14
+ /**
15
+ * AI call time in milliseconds, summed across all batches for this
16
+ * unit's locale. Excludes token-bucket wait + rate-limit backoff —
17
+ * those happen before the SDK call and are not counted here.
18
+ * `null` on legacy rows where the column wasn't populated.
19
+ */
20
+ processingDurationMs: number | null;
21
+ };
22
+ export interface BulkStatusHandlerOptions {
23
+ batchesCollectionSlug?: string;
24
+ unitsCollectionSlug?: string;
25
+ }
26
+ /**
27
+ * `GET /api/translation-hub/bulk-translate/:id/status`
28
+ *
29
+ * Returns batch metadata + aggregate counts by unit status + a
30
+ * paginated drill-down list of units (filterable by status).
31
+ *
32
+ * Query params:
33
+ * - `cursor` — opaque cursor returned by the previous page
34
+ * - `limit` — page size (default 20, max 100)
35
+ * - `status` — filter to one unit status (`failed` is the typical
36
+ * value for the failure drill-down UI)
37
+ */
38
+ export declare const getBulkTranslateStatusHandler: (options?: BulkStatusHandlerOptions) => PayloadHandler;
39
+ /**
40
+ * Extracts the batch id from a URL of the shape
41
+ * `/api/translation-hub/bulk-translate/<id>/status`. Defensive across
42
+ * URL variants Payload may give us — we look for the segment between
43
+ * `bulk-translate` and `status`.
44
+ */
45
+ export declare function extractBatchId(url: string): string | undefined;