adserver-dashboard 1.0.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 (525) hide show
  1. package/.ci/staging.yml +191 -0
  2. package/.dockerignore +117 -0
  3. package/.env +40 -0
  4. package/.env.staging +38 -0
  5. package/.gitlab-ci.yml +16 -0
  6. package/DEMO_STATUS.md +579 -0
  7. package/Dockerfile +61 -0
  8. package/Influence-MW-AdServer-12-02-2026/client/index.html +17 -0
  9. package/Influence-MW-AdServer-12-02-2026/client/public/favicon.png +0 -0
  10. package/Influence-MW-AdServer-12-02-2026/client/src/App.tsx +91 -0
  11. package/Influence-MW-AdServer-12-02-2026/client/src/components/advanced-map-drawer.tsx +1131 -0
  12. package/Influence-MW-AdServer-12-02-2026/client/src/components/ai-recommendation-panel.tsx +379 -0
  13. package/Influence-MW-AdServer-12-02-2026/client/src/components/app-sidebar.tsx +183 -0
  14. package/Influence-MW-AdServer-12-02-2026/client/src/components/auto-optimize-button.tsx +184 -0
  15. package/Influence-MW-AdServer-12-02-2026/client/src/components/availability-drawer.tsx +385 -0
  16. package/Influence-MW-AdServer-12-02-2026/client/src/components/brand-insights-panel.tsx +87 -0
  17. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-agency-drawer.tsx +198 -0
  18. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-brand-drawer.tsx +275 -0
  19. package/Influence-MW-AdServer-12-02-2026/client/src/components/creative-assignment.tsx +526 -0
  20. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table-toolbar.tsx +148 -0
  21. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table.tsx +158 -0
  22. package/Influence-MW-AdServer-12-02-2026/client/src/components/filter-drawer.tsx +356 -0
  23. package/Influence-MW-AdServer-12-02-2026/client/src/components/form-insights-panel.tsx +82 -0
  24. package/Influence-MW-AdServer-12-02-2026/client/src/components/geography-selector.tsx +699 -0
  25. package/Influence-MW-AdServer-12-02-2026/client/src/components/header-user-menu.tsx +178 -0
  26. package/Influence-MW-AdServer-12-02-2026/client/src/components/history-drawer.tsx +313 -0
  27. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-availability-section.tsx +176 -0
  28. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-format-drawer.tsx +173 -0
  29. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-selector.tsx +401 -0
  30. package/Influence-MW-AdServer-12-02-2026/client/src/components/manual-inventory-drawer.tsx +368 -0
  31. package/Influence-MW-AdServer-12-02-2026/client/src/components/mapbox-map.tsx +368 -0
  32. package/Influence-MW-AdServer-12-02-2026/client/src/components/market-insights-panel.tsx +202 -0
  33. package/Influence-MW-AdServer-12-02-2026/client/src/components/media-owner-drawer.tsx +217 -0
  34. package/Influence-MW-AdServer-12-02-2026/client/src/components/metric-card.tsx +58 -0
  35. package/Influence-MW-AdServer-12-02-2026/client/src/components/page-header.tsx +27 -0
  36. package/Influence-MW-AdServer-12-02-2026/client/src/components/player-status-indicator.tsx +137 -0
  37. package/Influence-MW-AdServer-12-02-2026/client/src/components/poi-targeting-drawer.tsx +298 -0
  38. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommendation-score-badge.tsx +102 -0
  39. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommended-inventories-panel.tsx +248 -0
  40. package/Influence-MW-AdServer-12-02-2026/client/src/components/searchable-combobox.tsx +134 -0
  41. package/Influence-MW-AdServer-12-02-2026/client/src/components/signal-visualizations.tsx +407 -0
  42. package/Influence-MW-AdServer-12-02-2026/client/src/components/status-badge.tsx +35 -0
  43. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-provider.tsx +73 -0
  44. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-toggle.tsx +37 -0
  45. package/Influence-MW-AdServer-12-02-2026/client/src/components/traffic-slider.tsx +75 -0
  46. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/accordion.tsx +56 -0
  47. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert-dialog.tsx +139 -0
  48. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert.tsx +59 -0
  49. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/aspect-ratio.tsx +5 -0
  50. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/avatar.tsx +51 -0
  51. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/badge.tsx +38 -0
  52. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/breadcrumb.tsx +115 -0
  53. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/button.tsx +62 -0
  54. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/calendar.tsx +68 -0
  55. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/card.tsx +85 -0
  56. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/carousel.tsx +260 -0
  57. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/chart.tsx +365 -0
  58. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/checkbox.tsx +28 -0
  59. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/collapsible.tsx +11 -0
  60. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/command.tsx +151 -0
  61. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/context-menu.tsx +198 -0
  62. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dialog.tsx +122 -0
  63. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/drawer.tsx +118 -0
  64. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dropdown-menu.tsx +198 -0
  65. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/form.tsx +178 -0
  66. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/hover-card.tsx +29 -0
  67. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input-otp.tsx +69 -0
  68. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input.tsx +23 -0
  69. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/label.tsx +24 -0
  70. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/menubar.tsx +256 -0
  71. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/navigation-menu.tsx +128 -0
  72. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/pagination.tsx +117 -0
  73. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/popover.tsx +29 -0
  74. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/progress.tsx +28 -0
  75. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/radio-group.tsx +42 -0
  76. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/resizable.tsx +45 -0
  77. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/scroll-area.tsx +46 -0
  78. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/select.tsx +160 -0
  79. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/separator.tsx +29 -0
  80. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sheet.tsx +140 -0
  81. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sidebar.tsx +727 -0
  82. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/skeleton.tsx +15 -0
  83. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/slider.tsx +26 -0
  84. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/switch.tsx +27 -0
  85. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/table.tsx +117 -0
  86. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tabs.tsx +53 -0
  87. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/textarea.tsx +22 -0
  88. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toast.tsx +127 -0
  89. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toaster.tsx +33 -0
  90. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle-group.tsx +61 -0
  91. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle.tsx +43 -0
  92. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tooltip.tsx +30 -0
  93. package/Influence-MW-AdServer-12-02-2026/client/src/components/vendor-stores-modal.tsx +336 -0
  94. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-drawer.tsx +359 -0
  95. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-selector.tsx +436 -0
  96. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-mobile.tsx +19 -0
  97. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-toast.ts +191 -0
  98. package/Influence-MW-AdServer-12-02-2026/client/src/index.css +244 -0
  99. package/Influence-MW-AdServer-12-02-2026/client/src/lib/queryClient.ts +57 -0
  100. package/Influence-MW-AdServer-12-02-2026/client/src/lib/utils.ts +39 -0
  101. package/Influence-MW-AdServer-12-02-2026/client/src/lib/venue-taxonomy.ts +532 -0
  102. package/Influence-MW-AdServer-12-02-2026/client/src/main.tsx +5 -0
  103. package/Influence-MW-AdServer-12-02-2026/client/src/pages/assign-creative.tsx +781 -0
  104. package/Influence-MW-AdServer-12-02-2026/client/src/pages/content-hub.tsx +995 -0
  105. package/Influence-MW-AdServer-12-02-2026/client/src/pages/custom-pois.tsx +431 -0
  106. package/Influence-MW-AdServer-12-02-2026/client/src/pages/dashboard.tsx +620 -0
  107. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-detail.tsx +1062 -0
  108. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-form.tsx +1570 -0
  109. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deals.tsx +716 -0
  110. package/Influence-MW-AdServer-12-02-2026/client/src/pages/edit-creative-assignment.tsx +1051 -0
  111. package/Influence-MW-AdServer-12-02-2026/client/src/pages/geotargeting.tsx +675 -0
  112. package/Influence-MW-AdServer-12-02-2026/client/src/pages/integrations.tsx +425 -0
  113. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-creatives.tsx +622 -0
  114. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-form.tsx +3132 -0
  115. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-items.tsx +530 -0
  116. package/Influence-MW-AdServer-12-02-2026/client/src/pages/not-found.tsx +21 -0
  117. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play-upload.tsx +479 -0
  118. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play.tsx +880 -0
  119. package/Influence-MW-AdServer-12-02-2026/client/src/pages/reports.tsx +235 -0
  120. package/Influence-MW-AdServer-12-02-2026/client/src/pages/settings.tsx +652 -0
  121. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signal-form.tsx +1117 -0
  122. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signals.tsx +366 -0
  123. package/Influence-MW-AdServer-12-02-2026/client/src/pages/tags.tsx +332 -0
  124. package/Influence-MW-AdServer-12-02-2026/client/src/pages/venues.tsx +381 -0
  125. package/Influence-MW-AdServer-12-02-2026/client/src/types/mapbox-gl-draw.d.ts +37 -0
  126. package/Influence-MW-AdServer-12-02-2026/client/src/types/react-simple-maps.d.ts +57 -0
  127. package/Influence-MW-AdServer-12-02-2026/components.json +20 -0
  128. package/Influence-MW-AdServer-12-02-2026/docs/PRD.md +3373 -0
  129. package/Influence-MW-AdServer-12-02-2026/docs/influence-feature-mapping.csv +498 -0
  130. package/Influence-MW-AdServer-12-02-2026/drizzle.config.ts +14 -0
  131. package/Influence-MW-AdServer-12-02-2026/package-lock.json +9672 -0
  132. package/Influence-MW-AdServer-12-02-2026/package.json +118 -0
  133. package/Influence-MW-AdServer-12-02-2026/postcss.config.js +6 -0
  134. package/Influence-MW-AdServer-12-02-2026/replit.md +91 -0
  135. package/Influence-MW-AdServer-12-02-2026/script/build.ts +67 -0
  136. package/Influence-MW-AdServer-12-02-2026/scripts/create-miro-diagrams.cjs +318 -0
  137. package/Influence-MW-AdServer-12-02-2026/scripts/create-remaining-diagrams.cjs +270 -0
  138. package/Influence-MW-AdServer-12-02-2026/server/index.ts +103 -0
  139. package/Influence-MW-AdServer-12-02-2026/server/recommendation-service.ts +319 -0
  140. package/Influence-MW-AdServer-12-02-2026/server/routes.ts +1890 -0
  141. package/Influence-MW-AdServer-12-02-2026/server/static.ts +19 -0
  142. package/Influence-MW-AdServer-12-02-2026/server/storage.ts +2058 -0
  143. package/Influence-MW-AdServer-12-02-2026/server/vite.ts +58 -0
  144. package/Influence-MW-AdServer-12-02-2026/shared/schema.ts +1595 -0
  145. package/Influence-MW-AdServer-12-02-2026/tailwind.config.ts +107 -0
  146. package/Influence-MW-AdServer-12-02-2026/tsconfig.json +23 -0
  147. package/Influence-MW-AdServer-12-02-2026/vite.config.ts +40 -0
  148. package/LINE_ITEM_BUDGET_FIELD_MAPPING.md +178 -0
  149. package/PCM/.env.example +92 -0
  150. package/PCM/README.md +558 -0
  151. package/PCM/docs/TEST_CASES.md +422 -0
  152. package/PCM/index.js +106 -0
  153. package/PCM/package-lock.json +3282 -0
  154. package/PCM/package.json +32 -0
  155. package/PCM/replit.md +64 -0
  156. package/PCM/schema.sql +495 -0
  157. package/PCM/scripts/export-schema.js +183 -0
  158. package/PCM/scripts/seed-comprehensive.js +631 -0
  159. package/PCM/scripts/seed-production.js +477 -0
  160. package/PCM/src/config/db.js +56 -0
  161. package/PCM/src/config/swagger.js +5975 -0
  162. package/PCM/src/dto/EmailRequestDTO.js +166 -0
  163. package/PCM/src/middleware/errorHandler.js +52 -0
  164. package/PCM/src/middleware/logger.js +26 -0
  165. package/PCM/src/migrations/001_add_campaign_mode_fields.sql +36 -0
  166. package/PCM/src/migrations/002_create_deal_id_counters.sql +22 -0
  167. package/PCM/src/migrations/003_update_publishers_column.sql +15 -0
  168. package/PCM/src/migrations/004_add_direct_dealtype_and_advertiser.sql +5 -0
  169. package/PCM/src/migrations/005_add_programmatic_fields_and_update_enums.sql +31 -0
  170. package/PCM/src/migrations/006_add_line_item_programmatic_fields.sql +12 -0
  171. package/PCM/src/migrations/007_add_line_item_direct_fields.sql +15 -0
  172. package/PCM/src/migrations/008_add_inventory_fields.sql +45 -0
  173. package/PCM/src/migrations/009_move_inventory_fields_to_metadata.sql +32 -0
  174. package/PCM/src/migrations/010_add_draft_status_and_line_items_count.sql +23 -0
  175. package/PCM/src/migrations/011_add_planning_field.sql +21 -0
  176. package/PCM/src/migrations/012_fix_inventory_composite_pk.sql +17 -0
  177. package/PCM/src/migrations/013_make_external_id_optional.sql +3 -0
  178. package/PCM/src/migrations/014_create_change_history.sql +38 -0
  179. package/PCM/src/migrations/016_create_publisher_insertion_orders.sql +33 -0
  180. package/PCM/src/migrations/017_fix_line_item_id_fk_reference.sql +86 -0
  181. package/PCM/src/migrations/018_create_approval_tables.sql +44 -0
  182. package/PCM/src/migrations/019_add_encrypted_token_column.sql +2 -0
  183. package/PCM/src/migrations/020_add_rejection_reason_to_deals.sql +10 -0
  184. package/PCM/src/migrations/021_add_publisher_external_id_to_inventories.sql +12 -0
  185. package/PCM/src/migrations/022_add_line_item_extended_fields.sql +24 -0
  186. package/PCM/src/migrations/023_add_base_price_fields.sql +8 -0
  187. package/PCM/src/migrations/run-migrations.js +46 -0
  188. package/PCM/src/models/ApprovalOTP.js +51 -0
  189. package/PCM/src/models/ApprovalToken.js +79 -0
  190. package/PCM/src/models/ChangeHistory.js +107 -0
  191. package/PCM/src/models/Deal.js +186 -0
  192. package/PCM/src/models/DealIdCounter.js +28 -0
  193. package/PCM/src/models/LineItem.js +227 -0
  194. package/PCM/src/models/LineItemCreative.js +89 -0
  195. package/PCM/src/models/LineItemInventory.js +115 -0
  196. package/PCM/src/models/PublisherInsertionOrder.js +93 -0
  197. package/PCM/src/models/TransactionHistory.js +34 -0
  198. package/PCM/src/models/associations.js +81 -0
  199. package/PCM/src/routes/approval.js +321 -0
  200. package/PCM/src/routes/creatives.js +437 -0
  201. package/PCM/src/routes/deals.js +1638 -0
  202. package/PCM/src/routes/digitalSignage.js +242 -0
  203. package/PCM/src/routes/insertionOrders.js +380 -0
  204. package/PCM/src/routes/lineItems.js +926 -0
  205. package/PCM/src/routes/system.js +384 -0
  206. package/PCM/src/services/ApprovalService.js +885 -0
  207. package/PCM/src/services/CampaignImportConverter.js +631 -0
  208. package/PCM/src/services/CampaignModeService.js +273 -0
  209. package/PCM/src/services/CampaignStatusService.js +395 -0
  210. package/PCM/src/services/ChangeHistoryService.js +316 -0
  211. package/PCM/src/services/DealIdService.js +94 -0
  212. package/PCM/src/services/DealResponseFormatter.js +90 -0
  213. package/PCM/src/services/EmailNotificationService.js +315 -0
  214. package/PCM/src/services/LineItemResponseFormatter.js +122 -0
  215. package/PCM/src/services/LineItemStatusService.js +380 -0
  216. package/PCM/src/tests/comprehensiveTestRunner.js +360 -0
  217. package/PCM/src/tests/comprehensiveTests.js +1277 -0
  218. package/PCM/src/tests/dealTypeUnitTests.js +1058 -0
  219. package/PCM/src/tests/testRunner.js +248 -0
  220. package/PCM/src/utils/caseConverter.js +92 -0
  221. package/PCM/src/utils/dealCalculations.js +206 -0
  222. package/PCM/src/utils/lineItemPayloadNormalizer.js +41 -0
  223. package/PCM/src/utils/payloadNormalizer.js +34 -0
  224. package/PCM/src/utils/sourceNormalizer.js +56 -0
  225. package/PCM/src/validators/creativeValidator.js +27 -0
  226. package/PCM/src/validators/dealValidator.js +203 -0
  227. package/PCM/src/validators/lineItemValidator.js +489 -0
  228. package/PCM/tests/approval-flows.test.js +238 -0
  229. package/PCM/tests/approval-workflow.test.js +291 -0
  230. package/PCM/tests/campaign-import-converter.test.js +543 -0
  231. package/PCM/tests/campaign-import-e2e.test.js +520 -0
  232. package/PCM/tests/campaign-status.test.js +539 -0
  233. package/PCM/tests/direct-publisher-split-reimport.test.js +460 -0
  234. package/PCM/tests/e2e/digital-signage.test.js +145 -0
  235. package/PCM/tests/e2e/search-filter-pagination.test.js +399 -0
  236. package/PCM/tests/e2e-comprehensive.test.js +3446 -0
  237. package/PCM/tests/edge-cases.test.js +340 -0
  238. package/PCM/tests/line-item-status.test.js +340 -0
  239. package/PCM/tests/seller-account-external-ids.test.js +877 -0
  240. package/PCM/tests/source-validation.test.js +324 -0
  241. package/PRD.md +3373 -0
  242. package/README.md +186 -0
  243. package/client/index.html +35 -0
  244. package/client/public/DEMO_STATUS.md +579 -0
  245. package/client/public/img/MW-logo-trans_1754045676555.png +0 -0
  246. package/client/public/locales/ar/approval.json +144 -0
  247. package/client/public/locales/ar/buyer.json +61 -0
  248. package/client/public/locales/ar/campaigns.json +1 -0
  249. package/client/public/locales/ar/common.json +218 -0
  250. package/client/public/locales/ar/contentHub.json +266 -0
  251. package/client/public/locales/ar/creatives.json +79 -0
  252. package/client/public/locales/ar/dashboard.json +57 -0
  253. package/client/public/locales/ar/deals.json +886 -0
  254. package/client/public/locales/ar/dsp.json +131 -0
  255. package/client/public/locales/ar/inventory.json +201 -0
  256. package/client/public/locales/ar/lineItems.json +553 -0
  257. package/client/public/locales/ar/navigation.json +48 -0
  258. package/client/public/locales/ar/wizard.json +1 -0
  259. package/client/public/locales/en/approval.json +144 -0
  260. package/client/public/locales/en/buyer.json +65 -0
  261. package/client/public/locales/en/campaigns.json +1 -0
  262. package/client/public/locales/en/common.json +218 -0
  263. package/client/public/locales/en/contentHub.json +266 -0
  264. package/client/public/locales/en/creatives.json +79 -0
  265. package/client/public/locales/en/dashboard.json +57 -0
  266. package/client/public/locales/en/deals.json +886 -0
  267. package/client/public/locales/en/dsp.json +131 -0
  268. package/client/public/locales/en/inventory.json +201 -0
  269. package/client/public/locales/en/lineItems.json +659 -0
  270. package/client/public/locales/en/navigation.json +48 -0
  271. package/client/public/locales/en/wizard.json +1 -0
  272. package/client/public/locales/ja/approval.json +144 -0
  273. package/client/public/locales/ja/buyer.json +61 -0
  274. package/client/public/locales/ja/campaigns.json +1 -0
  275. package/client/public/locales/ja/common.json +218 -0
  276. package/client/public/locales/ja/contentHub.json +266 -0
  277. package/client/public/locales/ja/creatives.json +79 -0
  278. package/client/public/locales/ja/dashboard.json +57 -0
  279. package/client/public/locales/ja/deals.json +886 -0
  280. package/client/public/locales/ja/dsp.json +131 -0
  281. package/client/public/locales/ja/inventory.json +201 -0
  282. package/client/public/locales/ja/lineItems.json +553 -0
  283. package/client/public/locales/ja/navigation.json +48 -0
  284. package/client/public/locales/ja/wizard.json +1 -0
  285. package/client/public/locales/zh/approval.json +144 -0
  286. package/client/public/locales/zh/buyer.json +61 -0
  287. package/client/public/locales/zh/campaigns.json +1 -0
  288. package/client/public/locales/zh/common.json +218 -0
  289. package/client/public/locales/zh/contentHub.json +266 -0
  290. package/client/public/locales/zh/creatives.json +79 -0
  291. package/client/public/locales/zh/dashboard.json +57 -0
  292. package/client/public/locales/zh/deals.json +886 -0
  293. package/client/public/locales/zh/dsp.json +131 -0
  294. package/client/public/locales/zh/inventory.json +201 -0
  295. package/client/public/locales/zh/lineItems.json +553 -0
  296. package/client/public/locales/zh/navigation.json +48 -0
  297. package/client/public/locales/zh/wizard.json +1 -0
  298. package/client/public/manifest.json +36 -0
  299. package/client/src/App.tsx +464 -0
  300. package/client/src/components/app-sidebar.tsx +312 -0
  301. package/client/src/components/approval/approval-decision-form.test.tsx +294 -0
  302. package/client/src/components/approval/approval-decision-form.tsx +326 -0
  303. package/client/src/components/approval/approval-sheet.tsx +631 -0
  304. package/client/src/components/approval/line-item-details-sheet.tsx +371 -0
  305. package/client/src/components/approval/otp-verification.test.tsx +337 -0
  306. package/client/src/components/approval/otp-verification.tsx +180 -0
  307. package/client/src/components/content-hub/bulk-transcode-dialog.tsx +379 -0
  308. package/client/src/components/content-hub/content-hub-manager-v2.tsx +574 -0
  309. package/client/src/components/content-hub/content-hub-manager.tsx +330 -0
  310. package/client/src/components/content-hub/creative-card.tsx +456 -0
  311. package/client/src/components/content-hub/creative-detail-sheet.tsx +685 -0
  312. package/client/src/components/content-hub/creative-filters.tsx +457 -0
  313. package/client/src/components/content-hub/creative-grid.tsx +329 -0
  314. package/client/src/components/content-hub/creative-selector.tsx +415 -0
  315. package/client/src/components/content-hub/creative-upload.tsx +547 -0
  316. package/client/src/components/content-hub/folder-dialogs.tsx +445 -0
  317. package/client/src/components/content-hub/folder-list.tsx +280 -0
  318. package/client/src/components/content-hub/review-dialogs.tsx +268 -0
  319. package/client/src/components/content-hub/transcode-dialog.tsx +226 -0
  320. package/client/src/components/creative-library/creative-details-view.tsx +446 -0
  321. package/client/src/components/creative-library/creative-filters-panel.tsx +203 -0
  322. package/client/src/components/creative-library/creative-list.tsx +360 -0
  323. package/client/src/components/creative-library/creative-status-badge.tsx +71 -0
  324. package/client/src/components/creative-library/folder-card.tsx +78 -0
  325. package/client/src/components/creative-library/index.ts +27 -0
  326. package/client/src/components/creative-library/new-creative-card.tsx +211 -0
  327. package/client/src/components/creative-library/upload-creative-dialog.tsx +261 -0
  328. package/client/src/components/dashboard-overview.tsx +109 -0
  329. package/client/src/components/deals/approval-history-panel.test.tsx +240 -0
  330. package/client/src/components/deals/approval-history-panel.tsx +156 -0
  331. package/client/src/components/deals/deal-status-badge.tsx +92 -0
  332. package/client/src/components/deals/import-from-planner-dialog.tsx +399 -0
  333. package/client/src/components/deals/market-insights-panel.tsx +237 -0
  334. package/client/src/components/deals/reopen-deal-sheet.tsx +191 -0
  335. package/client/src/components/deals/request-approval-sheet.test.tsx +323 -0
  336. package/client/src/components/deals/request-approval-sheet.tsx +136 -0
  337. package/client/src/components/deals/resend-approval-sheet.tsx +201 -0
  338. package/client/src/components/direct-campaigns/campaign-card.tsx +283 -0
  339. package/client/src/components/direct-campaigns/deal-filter-panel.tsx +325 -0
  340. package/client/src/components/inventory/advanced-filters-panel.tsx +273 -0
  341. package/client/src/components/inventory/csv-upload-modal.tsx +639 -0
  342. package/client/src/components/inventory/inventory-availability-view.tsx +486 -0
  343. package/client/src/components/inventory/inventory-details-sheet.tsx +376 -0
  344. package/client/src/components/inventory/inventory-map-view.tsx +596 -0
  345. package/client/src/components/inventory/inventory-settings-menu.tsx +52 -0
  346. package/client/src/components/language-switcher.tsx +53 -0
  347. package/client/src/components/line-items/campaign-forecast-panel.tsx +138 -0
  348. package/client/src/components/line-items/form-insights.tsx +89 -0
  349. package/client/src/components/line-items/geofencing/LocationCsvUploadDrawer.tsx +100 -0
  350. package/client/src/components/line-items/geofencing/POIDropdown.tsx +379 -0
  351. package/client/src/components/line-items/geofencing/SelectedLocationsSidebar.tsx +436 -0
  352. package/client/src/components/line-items/geofencing/ViewFileLocationDrawer.tsx +199 -0
  353. package/client/src/components/line-items/geofencing/components/ExistingFilesTab.tsx +268 -0
  354. package/client/src/components/line-items/geofencing/components/TemplateDownloadSection.tsx +59 -0
  355. package/client/src/components/line-items/geofencing/components/UploadTab.tsx +215 -0
  356. package/client/src/components/line-items/geofencing-map.tsx +1270 -0
  357. package/client/src/components/line-items/inventory-availability-section.tsx +178 -0
  358. package/client/src/components/line-items/line-item-schedule-manager.tsx +313 -0
  359. package/client/src/components/line-items/manual-inventory-drawer.tsx +346 -0
  360. package/client/src/components/line-items/planner-inventory-card.tsx +495 -0
  361. package/client/src/components/line-items/planner-schedule-grid.tsx +495 -0
  362. package/client/src/components/line-items/schedule-rule-editor.tsx +649 -0
  363. package/client/src/components/line-items/schedule-rule-types.ts +122 -0
  364. package/client/src/components/line-items/steps/creatives-step.tsx +681 -0
  365. package/client/src/components/line-items/steps/inventory-schedule-step.tsx +1596 -0
  366. package/client/src/components/line-items/steps/inventory-step.tsx +1533 -0
  367. package/client/src/components/line-items/steps/line-item-details-step.tsx +916 -0
  368. package/client/src/components/line-items/steps/schedule-step.tsx +273 -0
  369. package/client/src/components/line-items/steps/summary-step.tsx +680 -0
  370. package/client/src/components/line-items/steps/targeting-step.tsx +1708 -0
  371. package/client/src/components/product-switcher.tsx +105 -0
  372. package/client/src/components/protected-route.tsx +49 -0
  373. package/client/src/components/skip-link.tsx +22 -0
  374. package/client/src/components/stat-card.tsx +53 -0
  375. package/client/src/components/status-badge.tsx +96 -0
  376. package/client/src/components/ui/hierarchical-venue-selector.tsx +389 -0
  377. package/client/src/components/ui/toaster.tsx +111 -0
  378. package/client/src/contexts/auth-context.tsx +181 -0
  379. package/client/src/contexts/sidebar-state.tsx +50 -0
  380. package/client/src/contexts/theme-context.tsx +66 -0
  381. package/client/src/data/campaign-data.json +107 -0
  382. package/client/src/data/countries.json +22 -0
  383. package/client/src/hooks/use-approval.ts +366 -0
  384. package/client/src/hooks/use-keyboard-shortcuts.ts +74 -0
  385. package/client/src/hooks/use-media-query.ts +46 -0
  386. package/client/src/hooks/use-mobile.tsx +19 -0
  387. package/client/src/hooks/use-page-title.ts +21 -0
  388. package/client/src/hooks/use-toast.ts +195 -0
  389. package/client/src/index.css +694 -0
  390. package/client/src/lib/__tests__/accessibility.test.ts +104 -0
  391. package/client/src/lib/__tests__/date-utils.test.ts +199 -0
  392. package/client/src/lib/__tests__/dsp-buyer-api.test.ts +127 -0
  393. package/client/src/lib/__tests__/dsp-buyer-integration.test.ts +247 -0
  394. package/client/src/lib/__tests__/storage-utils.test.ts +167 -0
  395. package/client/src/lib/__tests__/utils.test.ts +57 -0
  396. package/client/src/lib/accessibility.ts +141 -0
  397. package/client/src/lib/api-config.ts +9 -0
  398. package/client/src/lib/auth-service.ts +209 -0
  399. package/client/src/lib/campaign-creative-api.ts +82 -0
  400. package/client/src/lib/company-api.ts +61 -0
  401. package/client/src/lib/content-hub-api.ts +407 -0
  402. package/client/src/lib/creative-mapper.ts +61 -0
  403. package/client/src/lib/date-utils.ts +119 -0
  404. package/client/src/lib/deal-helpers.ts +220 -0
  405. package/client/src/lib/dsp-buyer-api.ts +196 -0
  406. package/client/src/lib/geo-import-api.ts +151 -0
  407. package/client/src/lib/google-poi-api.ts +305 -0
  408. package/client/src/lib/i18n/__tests__/formatting.test.ts +202 -0
  409. package/client/src/lib/i18n/formatting.ts +130 -0
  410. package/client/src/lib/i18n/index.ts +8 -0
  411. package/client/src/lib/i18n-compat.ts +76 -0
  412. package/client/src/lib/influence-deals-api.ts +896 -0
  413. package/client/src/lib/inventory-api.ts +399 -0
  414. package/client/src/lib/oauth-service.ts +678 -0
  415. package/client/src/lib/poi-types.ts +75 -0
  416. package/client/src/lib/queryClient.ts +144 -0
  417. package/client/src/lib/recommendation-api.ts +380 -0
  418. package/client/src/lib/storage-utils.ts +104 -0
  419. package/client/src/lib/tolgee.ts +85 -0
  420. package/client/src/lib/utils.ts +0 -0
  421. package/client/src/main.tsx +67 -0
  422. package/client/src/mapbox-draw-modes.d.ts +32 -0
  423. package/client/src/pages/all-folders.tsx +203 -0
  424. package/client/src/pages/auth-callback.tsx +115 -0
  425. package/client/src/pages/buyer-form.tsx +339 -0
  426. package/client/src/pages/buyer-list.tsx +622 -0
  427. package/client/src/pages/content-hub.tsx +1358 -0
  428. package/client/src/pages/create-deal.tsx +2093 -0
  429. package/client/src/pages/creative-assignment-page.tsx +548 -0
  430. package/client/src/pages/creatives.tsx +5 -0
  431. package/client/src/pages/custom-pois.tsx +425 -0
  432. package/client/src/pages/dashboard.tsx +615 -0
  433. package/client/src/pages/deal-history.tsx +434 -0
  434. package/client/src/pages/deal-line-items.tsx +1703 -0
  435. package/client/src/pages/demo-status.tsx +113 -0
  436. package/client/src/pages/direct-campaign-details.tsx +361 -0
  437. package/client/src/pages/direct-campaigns-new.tsx +824 -0
  438. package/client/src/pages/dsp-form.tsx +803 -0
  439. package/client/src/pages/dsp-list.tsx +239 -0
  440. package/client/src/pages/folder-content.tsx +336 -0
  441. package/client/src/pages/integrations.tsx +429 -0
  442. package/client/src/pages/line-item-creatives.tsx +789 -0
  443. package/client/src/pages/line-item-detail-page.tsx +684 -0
  444. package/client/src/pages/line-item-form-page.tsx +3261 -0
  445. package/client/src/pages/line-item-wizard.tsx +1207 -0
  446. package/client/src/pages/login.tsx +154 -0
  447. package/client/src/pages/not-found.tsx +23 -0
  448. package/client/src/pages/proof-of-play.tsx +397 -0
  449. package/client/src/pages/public-approval.tsx +551 -0
  450. package/client/src/pages/reports.tsx +231 -0
  451. package/client/src/pages/settings.tsx +760 -0
  452. package/client/src/pages/signals.tsx +389 -0
  453. package/client/src/pages/tags.tsx +318 -0
  454. package/client/src/pages/test-results.tsx +328 -0
  455. package/client/src/store/hooks.ts +5 -0
  456. package/client/src/store/index.ts +15 -0
  457. package/client/src/store/mapMarkerLocationsSlice.ts +241 -0
  458. package/client/src/styles/design-tokens.css +324 -0
  459. package/client/src/test/setup.ts +261 -0
  460. package/client/src/test/test-utils.tsx +40 -0
  461. package/client/src/types/approval.ts +221 -0
  462. package/client/src/types/content-hub.ts +209 -0
  463. package/client/src/types/geofencing.ts +67 -0
  464. package/client/src/types/transcoding.ts +140 -0
  465. package/client/src/vite-env.d.ts +18 -0
  466. package/components.json +20 -0
  467. package/creative-api.json +1 -0
  468. package/docs/AI_REFERENCE.md +459 -0
  469. package/docs/MWDesign-Prompt.md +132 -0
  470. package/docs/MWDesign-System.md +344 -0
  471. package/docs/test-plan.md +277 -0
  472. package/e2e/AUTONOMOUS-TESTING.md +406 -0
  473. package/e2e/README.md +219 -0
  474. package/e2e/autonomous-flow.spec.ts +308 -0
  475. package/e2e/debug-sso.spec.ts +163 -0
  476. package/e2e/direct-campaigns.spec.ts +219 -0
  477. package/e2e/explore-sso.spec.ts +149 -0
  478. package/e2e/fixtures/auth.ts +26 -0
  479. package/e2e/fixtures/enhanced-test.ts +331 -0
  480. package/e2e/pagination.spec.ts +280 -0
  481. package/e2e/view-toggle.spec.ts +312 -0
  482. package/generated-icon.png +0 -0
  483. package/i18next-scanner.config.cjs +46 -0
  484. package/package.json +141 -0
  485. package/playwright.config.ts +93 -0
  486. package/postcss.config.js +6 -0
  487. package/replit.md +196 -0
  488. package/screenshot-after-login.png +0 -0
  489. package/screenshot-contenthub-grid.png +0 -0
  490. package/screenshot-contenthub-list-fixed.png +0 -0
  491. package/screenshot-contenthub-list.png +0 -0
  492. package/screenshot-create-deal.png +0 -0
  493. package/screenshot-dashboard.png +0 -0
  494. package/screenshot-deals.png +0 -0
  495. package/screenshot-login-filled.png +0 -0
  496. package/screenshot-login.png +0 -0
  497. package/screenshot.mjs +24 -0
  498. package/scripts/deploy-stg.sh +185 -0
  499. package/shared/direct-io-schema.ts +383 -0
  500. package/shared/schema.ts +439 -0
  501. package/shared/screen-types.ts +149 -0
  502. package/springdocDefault.json +1 -0
  503. package/swagger-ui-bundle.js +2 -0
  504. package/swagger-ui-init.js +10316 -0
  505. package/tailwind.config.ts +282 -0
  506. package/terraform/README.md +306 -0
  507. package/terraform/cloudfront.tf +289 -0
  508. package/terraform/ecs.tf +727 -0
  509. package/terraform/environments/dev.tfvars +59 -0
  510. package/terraform/environments/production.tfvars +60 -0
  511. package/terraform/main.tf +47 -0
  512. package/terraform/outputs.tf +145 -0
  513. package/terraform/s3.tf +192 -0
  514. package/terraform/variables.tf +226 -0
  515. package/terraform/waf.tf +165 -0
  516. package/terraform-frontend/.terraform.lock.hcl +25 -0
  517. package/terraform-frontend/README.md +85 -0
  518. package/terraform-frontend/cloudfront.tf +125 -0
  519. package/terraform-frontend/main.tf +31 -0
  520. package/terraform-frontend/outputs.tf +24 -0
  521. package/terraform-frontend/terraform.tfvars +12 -0
  522. package/terraform-frontend/variables.tf +53 -0
  523. package/tsconfig.json +23 -0
  524. package/vite.config.ts +226 -0
  525. package/vitest.config.ts +56 -0
@@ -0,0 +1,1638 @@
1
+ import express from 'express';
2
+ import { Op } from 'sequelize';
3
+ import { dealSchema, dealUpdateSchema, dealSyncSchema } from '../validators/dealValidator.js';
4
+ import { lineItemSchema } from '../validators/lineItemValidator.js';
5
+ import Deal from '../models/Deal.js';
6
+ import LineItem from '../models/LineItem.js';
7
+ import LineItemInventory from '../models/LineItemInventory.js';
8
+ import LineItemCreative from '../models/LineItemCreative.js';
9
+ import PublisherInsertionOrder from '../models/PublisherInsertionOrder.js';
10
+ import sequelize from '../config/db.js';
11
+ import { calculateDealMetrics } from '../utils/dealCalculations.js';
12
+ import { toCamelCase, toSnakeCase, stripKeys } from '../utils/caseConverter.js';
13
+ import { flattenPayload } from '../utils/payloadNormalizer.js';
14
+ import CampaignModeService from '../services/CampaignModeService.js';
15
+ import DealIdService from '../services/DealIdService.js';
16
+ import DealResponseFormatter from '../services/DealResponseFormatter.js';
17
+ import { CampaignImportConverter, PayloadType } from '../services/CampaignImportConverter.js';
18
+ import ChangeHistoryService from '../services/ChangeHistoryService.js';
19
+ import CampaignStatusService, { CAMPAIGN_STATUSES, WORKFLOW_TYPES, SOURCE_TYPES, TRANSITION_OWNERS } from '../services/CampaignStatusService.js';
20
+ import LineItemStatusService, { LINE_ITEM_STATUSES } from '../services/LineItemStatusService.js';
21
+ import { normalizeSource, validateSourceForImport, getSourceDisplayInfo, isInternalSource } from '../utils/sourceNormalizer.js';
22
+ import ApprovalService from '../services/ApprovalService.js';
23
+
24
+ const router = express.Router();
25
+
26
+ const findDealByIdentifier = async (identifier) => {
27
+ let deal = await Deal.findOne({ where: { deal_id: identifier } });
28
+
29
+ if (!deal && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(identifier)) {
30
+ deal = await Deal.findOne({ where: { id: identifier } });
31
+ }
32
+
33
+ return deal;
34
+ };
35
+
36
+ router.get('/', async (req, res, next) => {
37
+ try {
38
+ const page = parseInt(req.query.page) || 1;
39
+ const limit = parseInt(req.query.limit) || 10;
40
+ const offset = (page - 1) * limit;
41
+
42
+ const where = {};
43
+
44
+ if (req.query.search) {
45
+ const searchTerm = `%${req.query.search}%`;
46
+ where[Op.or] = [
47
+ { name: { [Op.iLike]: searchTerm } },
48
+ { deal_id: { [Op.iLike]: searchTerm } },
49
+ { external_id: { [Op.iLike]: searchTerm } },
50
+ { brand: { [Op.iLike]: searchTerm } }
51
+ ];
52
+ }
53
+
54
+ if (req.query.source) {
55
+ where.source = { [Op.iLike]: `%${req.query.source}%` };
56
+ }
57
+
58
+ if (req.query.mode) {
59
+ where.mode = req.query.mode.toUpperCase();
60
+ }
61
+
62
+ if (req.query.dealType) {
63
+ where.deal_type = req.query.dealType.toUpperCase();
64
+ }
65
+
66
+ if (req.query.status) {
67
+ where.status = req.query.status.toUpperCase();
68
+ }
69
+
70
+ if (req.query.currency) {
71
+ where.currency = req.query.currency.toUpperCase();
72
+ }
73
+
74
+ if (req.query.brand) {
75
+ where.brand = { [Op.iLike]: `%${req.query.brand}%` };
76
+ }
77
+
78
+ if (req.query.advertiser) {
79
+ where[Op.and] = where[Op.and] || [];
80
+ where[Op.and].push(
81
+ sequelize.where(
82
+ sequelize.cast(sequelize.col('advertiser'), 'text'),
83
+ { [Op.iLike]: `%${req.query.advertiser}%` }
84
+ )
85
+ );
86
+ }
87
+
88
+ if (req.query.seller) {
89
+ where[Op.and] = where[Op.and] || [];
90
+ where[Op.and].push(
91
+ sequelize.where(
92
+ sequelize.cast(sequelize.col('seller'), 'text'),
93
+ { [Op.iLike]: `%${req.query.seller}%` }
94
+ )
95
+ );
96
+ }
97
+
98
+ if (req.query.publisher) {
99
+ where[Op.and] = where[Op.and] || [];
100
+ where[Op.and].push(
101
+ sequelize.where(
102
+ sequelize.cast(sequelize.col('publishers'), 'text'),
103
+ { [Op.iLike]: `%${req.query.publisher}%` }
104
+ )
105
+ );
106
+ }
107
+
108
+ if (req.query.startDate) {
109
+ where.start_date = { [Op.gte]: req.query.startDate };
110
+ }
111
+
112
+ if (req.query.endDate) {
113
+ where.end_date = { [Op.lte]: req.query.endDate };
114
+ }
115
+
116
+ const { count, rows } = await Deal.findAndCountAll({
117
+ where,
118
+ limit,
119
+ offset,
120
+ order: [['created_at', 'DESC']]
121
+ });
122
+
123
+ const { publisherId } = req.query;
124
+
125
+ // Format deals and include insertion orders for DIRECT deals
126
+ const deals = await Promise.all(rows.map(async (deal) => {
127
+ const formatOptions = {};
128
+
129
+ // Always include insertion orders for DIRECT deals (filter by publisherId if provided)
130
+ if (deal.mode === 'DIRECT') {
131
+ const ioWhere = { deal_id: deal.deal_id };
132
+ if (publisherId) {
133
+ ioWhere.publisher_id = publisherId;
134
+ }
135
+ const insertionOrders = await PublisherInsertionOrder.findAll({ where: ioWhere });
136
+ formatOptions.insertionOrders = insertionOrders.map(io => io.toJSON());
137
+ }
138
+
139
+ return DealResponseFormatter.formatDealResponse(deal, formatOptions);
140
+ }));
141
+
142
+ res.json({
143
+ requestId: req.requestId,
144
+ status: 200,
145
+ result: {
146
+ data: deals,
147
+ pagination: {
148
+ page,
149
+ limit,
150
+ total: count,
151
+ totalPages: Math.ceil(count / limit)
152
+ }
153
+ }
154
+ });
155
+ } catch (error) {
156
+ next(error);
157
+ }
158
+ });
159
+
160
+ router.post('/', async (req, res, next) => {
161
+ try {
162
+ const bodyInCamelCase = toCamelCase(req.body);
163
+ const flattenedBody = flattenPayload(bodyInCamelCase);
164
+
165
+ const originalAuctionType = flattenedBody.auctionType;
166
+ const originalCostType = flattenedBody.costType;
167
+
168
+ const dataWithDefaults = CampaignModeService.applyDefaults(flattenedBody);
169
+
170
+ const { error, value } = dealSchema.validate(dataWithDefaults);
171
+ if (error) {
172
+ error.name = 'ValidationError';
173
+ return next(error);
174
+ }
175
+
176
+ const modeValidation = CampaignModeService.validateDirectModeFields(value);
177
+ if (!modeValidation.valid) {
178
+ const validationError = new Error('Campaign mode validation failed');
179
+ validationError.name = 'ValidationError';
180
+ validationError.details = modeValidation.errors.map(e => ({
181
+ message: e.message,
182
+ path: [e.field],
183
+ type: 'campaign.mode.required'
184
+ }));
185
+ return next(validationError);
186
+ }
187
+
188
+ const auctionValidation = CampaignModeService.validateAuctionType(value.dealType, originalAuctionType);
189
+ if (!auctionValidation.valid) {
190
+ const validationError = new Error(auctionValidation.error);
191
+ validationError.name = 'ValidationError';
192
+ validationError.details = [{ message: auctionValidation.error, path: ['auctionType'], type: 'auction.type.invalid' }];
193
+ return next(validationError);
194
+ }
195
+
196
+ const costTypeValidation = CampaignModeService.validateCostType(value.dealType, originalCostType);
197
+ if (!costTypeValidation.valid) {
198
+ const validationError = new Error(costTypeValidation.error);
199
+ validationError.name = 'ValidationError';
200
+ validationError.details = [{ message: costTypeValidation.error, path: ['costType'], type: 'cost.type.invalid' }];
201
+ return next(validationError);
202
+ }
203
+
204
+ const dealData = toSnakeCase(value);
205
+
206
+ if (dealData.external_id) {
207
+ const existingDeal = await Deal.findOne({
208
+ where: { source: dealData.source, external_id: dealData.external_id }
209
+ });
210
+
211
+ if (existingDeal) {
212
+ return res.status(409).json({
213
+ code: 'CONFLICT',
214
+ message: 'Deal already exists'
215
+ });
216
+ }
217
+ }
218
+
219
+ const generatedDealId = await DealIdService.generateDealId(value.dealType, value.mode);
220
+ dealData.deal_id = generatedDealId;
221
+
222
+ const deal = await Deal.create(dealData);
223
+
224
+ await ChangeHistoryService.trackCreate('DEAL', deal, ChangeHistoryService.extractContext(req));
225
+
226
+ res.status(201).json({
227
+ requestId: req.requestId,
228
+ status: 201,
229
+ result: {
230
+ id: deal.id,
231
+ dealId: deal.deal_id,
232
+ source: deal.source,
233
+ externalId: deal.external_id,
234
+ status: deal.status,
235
+ createdAt: deal.created_at,
236
+ updatedAt: deal.updated_at,
237
+ version: deal.version
238
+ }
239
+ });
240
+ } catch (error) {
241
+ next(error);
242
+ }
243
+ });
244
+
245
+ router.get('/:dealId', async (req, res, next) => {
246
+ try {
247
+ const { dealId } = req.params;
248
+ const { embed, publisherId } = req.query;
249
+
250
+ let deal = await Deal.findOne({ where: { deal_id: dealId } });
251
+
252
+ if (!deal && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(dealId)) {
253
+ deal = await Deal.findOne({ where: { id: dealId } });
254
+ }
255
+
256
+ if (!deal) {
257
+ return res.status(404).json({
258
+ code: 'NOT_FOUND',
259
+ message: 'Deal not found'
260
+ });
261
+ }
262
+
263
+ const formatOptions = {};
264
+
265
+ // Always include insertion orders for DIRECT deals (filter by publisherId if provided)
266
+ if (deal.mode === 'DIRECT') {
267
+ const ioWhere = { deal_id: deal.deal_id };
268
+ if (publisherId) {
269
+ ioWhere.publisher_id = publisherId;
270
+ }
271
+ const insertionOrders = await PublisherInsertionOrder.findAll({ where: ioWhere });
272
+ formatOptions.insertionOrders = insertionOrders.map(io => io.toJSON());
273
+ }
274
+
275
+ const result = DealResponseFormatter.formatDealResponse(deal, formatOptions);
276
+
277
+ const embedList = embed ? embed.split(',') : [];
278
+ if (embedList.includes('lineItems')) {
279
+ const lineItemWhere = { deal_id: deal.deal_id };
280
+ if (publisherId) {
281
+ lineItemWhere.publisher_id = publisherId;
282
+ }
283
+ const lineItems = await LineItem.findAll({ where: lineItemWhere });
284
+ result.lineItems = lineItems.map(li => toCamelCase(li.toJSON()));
285
+ }
286
+
287
+ res.json({
288
+ requestId: req.requestId,
289
+ status: 200,
290
+ result
291
+ });
292
+ } catch (error) {
293
+ next(error);
294
+ }
295
+ });
296
+
297
+ router.put('/:dealId', async (req, res, next) => {
298
+ try {
299
+ const { dealId } = req.params;
300
+ const bodyInCamelCase = toCamelCase(req.body);
301
+ const flattenedBody = flattenPayload(bodyInCamelCase);
302
+ const { error, value } = dealUpdateSchema.validate(flattenedBody);
303
+
304
+ if (error) {
305
+ error.name = 'ValidationError';
306
+ return next(error);
307
+ }
308
+
309
+ let deal = await Deal.findOne({ where: { deal_id: dealId } });
310
+
311
+ if (!deal && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(dealId)) {
312
+ deal = await Deal.findOne({ where: { id: dealId } });
313
+ }
314
+
315
+ if (!deal) {
316
+ return res.status(404).json({
317
+ code: 'NOT_FOUND',
318
+ message: 'Deal not found'
319
+ });
320
+ }
321
+
322
+ const editableStatuses = ['REQUESTED', 'GENERATED', 'PENDING'];
323
+ const isStatusOnlyUpdate = Object.keys(value).length === 1 && value.status !== undefined;
324
+
325
+ if (deal.mode === 'PROGRAMMATIC' && !editableStatuses.includes(deal.status) && !isStatusOnlyUpdate) {
326
+ return res.status(400).json({
327
+ code: 'VALIDATION_ERROR',
328
+ message: `PROGRAMMATIC deals can only be edited when status is REQUESTED, GENERATED, or PENDING. Current status: ${deal.status}`
329
+ });
330
+ }
331
+
332
+ if (['APPROVED', 'LIVE'].includes(deal.status) &&
333
+ (value.dealType || value.startDate || value.endDate)) {
334
+ return res.status(400).json({
335
+ code: 'VALIDATION_ERROR',
336
+ message: 'Cannot update immutable fields for APPROVED or LIVE deals'
337
+ });
338
+ }
339
+
340
+ const currentMode = deal.mode;
341
+ const newMode = value.mode || currentMode;
342
+
343
+ const existingDealCamel = toCamelCase(deal.toJSON());
344
+
345
+ if (CampaignModeService.isDirectMode(newMode)) {
346
+ const mergedData = {
347
+ brand: value.brand !== undefined ? value.brand : existingDealCamel.brand,
348
+ clientType: value.clientType !== undefined ? value.clientType : existingDealCamel.clientType,
349
+ mode: newMode,
350
+ approvalEmails: value.approvalEmails !== undefined ? value.approvalEmails : existingDealCamel.approvalEmails,
351
+ marketSelection: value.marketSelection !== undefined ? value.marketSelection : existingDealCamel.marketSelection,
352
+ budgetSetup: value.budgetSetup !== undefined ? value.budgetSetup : existingDealCamel.budgetSetup,
353
+ campaignGoal: value.campaignGoal !== undefined ? value.campaignGoal : existingDealCamel.campaignGoal
354
+ };
355
+
356
+ const modeValidation = CampaignModeService.validateDirectModeFields(mergedData);
357
+ if (!modeValidation.valid) {
358
+ const validationError = new Error('DIRECT mode requires all campaign mode fields');
359
+ validationError.name = 'ValidationError';
360
+ validationError.details = modeValidation.errors.map(e => ({
361
+ message: e.message,
362
+ path: [e.field],
363
+ type: 'campaign.mode.required'
364
+ }));
365
+ return next(validationError);
366
+ }
367
+ }
368
+
369
+ let sanitizedValue = { ...value };
370
+ if (currentMode === 'DIRECT' && newMode === 'PROGRAMMATIC') {
371
+ sanitizedValue.approvalEmails = null;
372
+ sanitizedValue.marketSelection = null;
373
+ sanitizedValue.budgetSetup = null;
374
+ sanitizedValue.campaignGoal = null;
375
+ }
376
+
377
+ if (CampaignModeService.isProgrammaticMode(newMode)) {
378
+ const effectiveDealType = value.dealType || deal.deal_type;
379
+ const effectiveAuctionType = value.auctionType !== undefined ? value.auctionType : deal.auction_type;
380
+ const effectiveCostType = value.costType !== undefined ? value.costType : deal.cost_type;
381
+
382
+ const auctionValidation = CampaignModeService.validateAuctionType(effectiveDealType, effectiveAuctionType);
383
+ if (!auctionValidation.valid) {
384
+ const validationError = new Error(auctionValidation.error);
385
+ validationError.name = 'ValidationError';
386
+ validationError.details = [{ message: auctionValidation.error, path: ['auctionType'], type: 'auction.type.invalid' }];
387
+ return next(validationError);
388
+ }
389
+
390
+ const costTypeValidation = CampaignModeService.validateCostType(effectiveDealType, effectiveCostType);
391
+ if (!costTypeValidation.valid) {
392
+ const validationError = new Error(costTypeValidation.error);
393
+ validationError.name = 'ValidationError';
394
+ validationError.details = [{ message: costTypeValidation.error, path: ['costType'], type: 'cost.type.invalid' }];
395
+ return next(validationError);
396
+ }
397
+
398
+ if (value.dealType && !value.auctionType) {
399
+ sanitizedValue.auctionType = CampaignModeService.getDefaultAuctionType(value.dealType, effectiveAuctionType);
400
+ }
401
+ }
402
+
403
+ const updateData = toSnakeCase(sanitizedValue);
404
+ updateData.version = deal.version + 1;
405
+
406
+ const previousData = deal.toJSON();
407
+ await deal.update(updateData);
408
+ await ChangeHistoryService.trackUpdate('DEAL', previousData, deal, ChangeHistoryService.extractContext(req));
409
+
410
+ res.json({
411
+ requestId: req.requestId,
412
+ status: 200,
413
+ result: {
414
+ id: deal.id,
415
+ dealId: deal.deal_id,
416
+ status: deal.status,
417
+ version: deal.version,
418
+ updatedAt: deal.updated_at
419
+ }
420
+ });
421
+ } catch (error) {
422
+ next(error);
423
+ }
424
+ });
425
+
426
+ router.delete('/:dealId', async (req, res, next) => {
427
+ try {
428
+ const { dealId } = req.params;
429
+
430
+ let deal = await Deal.findOne({ where: { deal_id: dealId } });
431
+
432
+ if (!deal && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(dealId)) {
433
+ deal = await Deal.findOne({ where: { id: dealId } });
434
+ }
435
+
436
+ if (!deal) {
437
+ return res.status(404).json({
438
+ code: 'NOT_FOUND',
439
+ message: 'Deal not found'
440
+ });
441
+ }
442
+
443
+ const previousData = deal.toJSON();
444
+ await deal.update({ status: 'ARCHIVED' });
445
+ await ChangeHistoryService.trackUpdate('DEAL', previousData, deal, ChangeHistoryService.extractContext(req));
446
+
447
+ res.json({
448
+ requestId: req.requestId,
449
+ status: 200,
450
+ result: {
451
+ dealId: deal.deal_id,
452
+ status: 'ARCHIVED'
453
+ }
454
+ });
455
+ } catch (error) {
456
+ next(error);
457
+ }
458
+ });
459
+
460
+ router.post('/:dealId/status', async (req, res, next) => {
461
+ try {
462
+ const { dealId } = req.params;
463
+ const { status: newStatus, owner, reason } = req.body;
464
+
465
+ if (!newStatus) {
466
+ return res.status(400).json({
467
+ code: 'VALIDATION_ERROR',
468
+ message: 'Status is required'
469
+ });
470
+ }
471
+
472
+ const validStatuses = Object.values(CAMPAIGN_STATUSES);
473
+ if (!validStatuses.includes(newStatus.toUpperCase())) {
474
+ return res.status(400).json({
475
+ code: 'VALIDATION_ERROR',
476
+ message: `Invalid status. Valid values: ${validStatuses.join(', ')}`
477
+ });
478
+ }
479
+
480
+ const deal = await findDealByIdentifier(dealId);
481
+ if (!deal) {
482
+ return res.status(404).json({
483
+ code: 'NOT_FOUND',
484
+ message: 'Deal not found'
485
+ });
486
+ }
487
+
488
+ const transitionOwner = owner || TRANSITION_OWNERS.USER;
489
+ const sourceType = normalizeSource(deal.source);
490
+ const workflowType = deal.mode === 'PROGRAMMATIC' ? WORKFLOW_TYPES.PROGRAMMATIC : WORKFLOW_TYPES.DIRECT;
491
+
492
+ const validation = CampaignStatusService.validateTransition(
493
+ { status: deal.status, source: sourceType, mode: workflowType },
494
+ newStatus.toUpperCase(),
495
+ { owner: transitionOwner, source: sourceType, workflow: workflowType }
496
+ );
497
+
498
+ if (!validation.valid) {
499
+ return res.status(400).json({
500
+ code: 'INVALID_STATUS_TRANSITION',
501
+ message: validation.errors[0]?.message || 'Invalid status transition',
502
+ errors: validation.errors,
503
+ currentStatus: deal.status,
504
+ requestedStatus: newStatus.toUpperCase(),
505
+ allowedTransitions: CampaignStatusService.getValidTransitions(deal.status),
506
+ workflow: workflowType,
507
+ source: sourceType
508
+ });
509
+ }
510
+
511
+ const previousData = deal.toJSON();
512
+ await deal.update({
513
+ status: newStatus.toUpperCase(),
514
+ version: deal.version + 1
515
+ });
516
+ await ChangeHistoryService.trackUpdate('DEAL', previousData, deal, {
517
+ ...ChangeHistoryService.extractContext(req),
518
+ transitionOwner,
519
+ reason
520
+ });
521
+
522
+ let cascadedLineItems = [];
523
+ let skippedLineItems = [];
524
+ const cascadeStatus = LineItemStatusService.getCascadeStatus(newStatus.toUpperCase());
525
+ if (cascadeStatus && LineItemStatusService.shouldCascadeStatus(newStatus.toUpperCase())) {
526
+ const lineItems = await LineItem.findAll({ where: { deal_id: deal.deal_id } });
527
+
528
+ for (const li of lineItems) {
529
+ if (li.status === cascadeStatus) continue;
530
+ if (li.status === 'ARCHIVED') {
531
+ skippedLineItems.push({
532
+ lineItemId: li.id,
533
+ externalId: li.external_id,
534
+ reason: 'Archived line items are not updated by cascade'
535
+ });
536
+ continue;
537
+ }
538
+
539
+ const validation = LineItemStatusService.validateTransition(
540
+ { status: li.status, source: sourceType },
541
+ cascadeStatus,
542
+ { owner: TRANSITION_OWNERS.ADMIN, source: sourceType, campaignStatus: newStatus.toUpperCase() }
543
+ );
544
+
545
+ if (!validation.valid) {
546
+ skippedLineItems.push({
547
+ lineItemId: li.id,
548
+ externalId: li.external_id,
549
+ reason: validation.errors[0]?.message || 'Invalid transition'
550
+ });
551
+ continue;
552
+ }
553
+
554
+ const liPrevious = li.status;
555
+ await li.update({ status: cascadeStatus });
556
+ cascadedLineItems.push({
557
+ lineItemId: li.id,
558
+ externalId: li.external_id,
559
+ previousStatus: liPrevious,
560
+ currentStatus: cascadeStatus
561
+ });
562
+ }
563
+ }
564
+
565
+ res.json({
566
+ requestId: req.requestId,
567
+ status: 200,
568
+ result: {
569
+ id: deal.id,
570
+ dealId: deal.deal_id,
571
+ previousStatus: previousData.status,
572
+ currentStatus: deal.status,
573
+ workflow: workflowType,
574
+ source: sourceType,
575
+ transitionOwner,
576
+ reason,
577
+ version: deal.version,
578
+ updatedAt: deal.updated_at,
579
+ cascadedLineItems: cascadedLineItems.length > 0 ? cascadedLineItems : undefined,
580
+ skippedLineItems: skippedLineItems.length > 0 ? skippedLineItems : undefined
581
+ }
582
+ });
583
+ } catch (error) {
584
+ next(error);
585
+ }
586
+ });
587
+
588
+ router.post('/:dealId/reopen', async (req, res, next) => {
589
+ const transaction = await sequelize.transaction();
590
+
591
+ try {
592
+ const { dealId } = req.params;
593
+ const { reason } = req.body;
594
+
595
+ const deal = await findDealByIdentifier(dealId);
596
+ if (!deal) {
597
+ await transaction.rollback();
598
+ return res.status(404).json({
599
+ code: 'NOT_FOUND',
600
+ message: 'Deal not found'
601
+ });
602
+ }
603
+
604
+ if (deal.mode !== 'DIRECT') {
605
+ await transaction.rollback();
606
+ return res.status(400).json({
607
+ code: 'INVALID_MODE',
608
+ message: 'Reopen is only available for DIRECT deals'
609
+ });
610
+ }
611
+
612
+ if (!CampaignStatusService.canReopen(deal.status)) {
613
+ await transaction.rollback();
614
+ return res.status(400).json({
615
+ code: 'CANNOT_REOPEN',
616
+ message: `Cannot reopen deal with status ${deal.status}`,
617
+ currentStatus: deal.status,
618
+ allowedStatuses: ['APPROVED', 'LIVE', 'COMPLETED', 'EXPIRED', 'REJECTED', 'ARCHIVED']
619
+ });
620
+ }
621
+
622
+ const sourceType = normalizeSource(deal.source);
623
+ const workflowType = WORKFLOW_TYPES.DIRECT;
624
+
625
+ const validation = CampaignStatusService.validateTransition(
626
+ { status: deal.status, source: sourceType, mode: workflowType },
627
+ 'REOPENED',
628
+ { owner: TRANSITION_OWNERS.USER, source: sourceType, workflow: workflowType }
629
+ );
630
+
631
+ if (!validation.valid) {
632
+ await transaction.rollback();
633
+ return res.status(400).json({
634
+ code: 'INVALID_STATUS_TRANSITION',
635
+ message: validation.errors[0]?.message || 'Invalid status transition',
636
+ errors: validation.errors,
637
+ currentStatus: deal.status,
638
+ requestedStatus: 'REOPENED'
639
+ });
640
+ }
641
+
642
+ const previousData = deal.toJSON();
643
+ await deal.update({
644
+ status: 'REOPENED',
645
+ version: deal.version + 1
646
+ }, { transaction });
647
+
648
+ await ChangeHistoryService.trackUpdate('DEAL', previousData, deal, {
649
+ ...ChangeHistoryService.extractContext(req),
650
+ transitionOwner: TRANSITION_OWNERS.USER,
651
+ reason
652
+ });
653
+
654
+ let cascadedLineItems = [];
655
+ let skippedLineItems = [];
656
+ const lineItems = await LineItem.findAll({ where: { deal_id: deal.deal_id }, transaction });
657
+
658
+ for (const li of lineItems) {
659
+ if (li.status === 'REOPENED') {
660
+ skippedLineItems.push({
661
+ lineItemId: li.id,
662
+ externalId: li.external_id,
663
+ reason: 'Already in REOPENED status'
664
+ });
665
+ continue;
666
+ }
667
+ if (li.status === 'ARCHIVED') {
668
+ skippedLineItems.push({
669
+ lineItemId: li.id,
670
+ externalId: li.external_id,
671
+ reason: 'Archived line items are not updated by reopen'
672
+ });
673
+ continue;
674
+ }
675
+
676
+ const liValidation = LineItemStatusService.validateTransition(
677
+ { status: li.status, source: sourceType },
678
+ 'REOPENED',
679
+ { owner: TRANSITION_OWNERS.ADMIN, source: sourceType, campaignStatus: 'REOPENED', isReopenCascade: true }
680
+ );
681
+
682
+ if (!liValidation.valid) {
683
+ skippedLineItems.push({
684
+ lineItemId: li.id,
685
+ externalId: li.external_id,
686
+ reason: liValidation.errors[0]?.message || 'Invalid transition'
687
+ });
688
+ continue;
689
+ }
690
+
691
+ const liPrevious = li.status;
692
+ await li.update({ status: 'REOPENED' }, { transaction });
693
+ cascadedLineItems.push({
694
+ lineItemId: li.id,
695
+ externalId: li.external_id,
696
+ previousStatus: liPrevious,
697
+ currentStatus: 'REOPENED'
698
+ });
699
+ }
700
+
701
+ await transaction.commit();
702
+
703
+ res.json({
704
+ requestId: req.requestId,
705
+ status: 200,
706
+ result: {
707
+ id: deal.id,
708
+ dealId: deal.deal_id,
709
+ previousStatus: previousData.status,
710
+ currentStatus: 'REOPENED',
711
+ reason,
712
+ version: deal.version,
713
+ updatedAt: deal.updated_at,
714
+ cascadedLineItems: cascadedLineItems.length > 0 ? cascadedLineItems : undefined,
715
+ skippedLineItems: skippedLineItems.length > 0 ? skippedLineItems : undefined
716
+ }
717
+ });
718
+ } catch (error) {
719
+ await transaction.rollback();
720
+ next(error);
721
+ }
722
+ });
723
+
724
+ router.get('/:dealId/status/transitions', async (req, res, next) => {
725
+ try {
726
+ const { dealId } = req.params;
727
+
728
+ const deal = await findDealByIdentifier(dealId);
729
+ if (!deal) {
730
+ return res.status(404).json({
731
+ code: 'NOT_FOUND',
732
+ message: 'Deal not found'
733
+ });
734
+ }
735
+
736
+ const sourceType = normalizeSource(deal.source);
737
+ const workflowType = deal.mode === 'PROGRAMMATIC' ? WORKFLOW_TYPES.PROGRAMMATIC : WORKFLOW_TYPES.DIRECT;
738
+
739
+ const validTransitions = CampaignStatusService.getValidTransitions(deal.status);
740
+ const editableFields = CampaignStatusService.getEditableFields(deal.status, sourceType, workflowType);
741
+ const statusDefinition = CampaignStatusService.getStatusDefinition(deal.status, workflowType);
742
+
743
+ res.json({
744
+ requestId: req.requestId,
745
+ status: 200,
746
+ result: {
747
+ dealId: deal.deal_id,
748
+ currentStatus: deal.status,
749
+ workflow: workflowType,
750
+ source: sourceType,
751
+ statusDefinition,
752
+ validTransitions,
753
+ editableFields,
754
+ canReopen: CampaignStatusService.canReopen(deal.status),
755
+ workflowRules: CampaignStatusService.getWorkflowRules(),
756
+ transitionOwnership: CampaignStatusService.getTransitionOwnership()
757
+ }
758
+ });
759
+ } catch (error) {
760
+ next(error);
761
+ }
762
+ });
763
+
764
+ router.get('/status/definitions', async (req, res) => {
765
+ const { workflow } = req.query;
766
+ const workflowType = workflow?.toUpperCase() === 'PROGRAMMATIC' ? WORKFLOW_TYPES.PROGRAMMATIC : WORKFLOW_TYPES.DIRECT;
767
+
768
+ const definitions = {};
769
+ for (const status of Object.values(CAMPAIGN_STATUSES)) {
770
+ definitions[status] = CampaignStatusService.getStatusDefinition(status, workflowType);
771
+ }
772
+
773
+ res.json({
774
+ requestId: req.requestId,
775
+ status: 200,
776
+ result: {
777
+ workflow: workflowType,
778
+ statuses: CAMPAIGN_STATUSES,
779
+ definitions,
780
+ transitionOwnership: CampaignStatusService.getTransitionOwnership(),
781
+ workflowRules: CampaignStatusService.getWorkflowRules()
782
+ }
783
+ });
784
+ });
785
+
786
+ router.post('/:dealId/approval/request', async (req, res, next) => {
787
+ try {
788
+ const { dealId } = req.params;
789
+ const { frontendUrl } = req.body;
790
+
791
+ const deal = await findDealByIdentifier(dealId);
792
+ if (!deal) {
793
+ return res.status(404).json({
794
+ code: 'NOT_FOUND',
795
+ message: 'Deal not found'
796
+ });
797
+ }
798
+
799
+ if (deal.mode !== 'DIRECT') {
800
+ return res.status(400).json({
801
+ code: 'INVALID_MODE',
802
+ message: 'Approval workflow is only available for DIRECT campaigns'
803
+ });
804
+ }
805
+
806
+ const emails = deal.approval_emails || [];
807
+
808
+ if (emails.length === 0) {
809
+ const result = await ApprovalService.autoApprove(deal.deal_id, req.requestId);
810
+ return res.json({
811
+ requestId: req.requestId,
812
+ status: 200,
813
+ result
814
+ });
815
+ }
816
+
817
+ const result = await ApprovalService.initiateApprovalWorkflow(deal.deal_id, emails, req.requestId, { frontendUrl });
818
+
819
+ res.json({
820
+ requestId: req.requestId,
821
+ status: 200,
822
+ result
823
+ });
824
+ } catch (error) {
825
+ next(error);
826
+ }
827
+ });
828
+
829
+ router.get('/:dealId/approval/history', async (req, res, next) => {
830
+ try {
831
+ const { dealId } = req.params;
832
+
833
+ const deal = await findDealByIdentifier(dealId);
834
+ if (!deal) {
835
+ return res.status(404).json({
836
+ code: 'NOT_FOUND',
837
+ message: 'Deal not found'
838
+ });
839
+ }
840
+
841
+ const history = await ApprovalService.getApprovalHistory(deal.deal_id);
842
+
843
+ res.json({
844
+ requestId: req.requestId,
845
+ status: 200,
846
+ result: {
847
+ dealId: deal.deal_id,
848
+ approvalHistory: history,
849
+ summary: {
850
+ total: history.length,
851
+ pending: history.filter(h => h.status === 'PENDING').length,
852
+ approved: history.filter(h => h.status === 'APPROVED').length,
853
+ rejected: history.filter(h => h.status === 'REJECTED').length
854
+ }
855
+ }
856
+ });
857
+ } catch (error) {
858
+ next(error);
859
+ }
860
+ });
861
+
862
+ router.post('/:dealId/approval/resend', async (req, res, next) => {
863
+ try {
864
+ const { dealId } = req.params;
865
+ const { frontendUrl, emails } = req.body;
866
+
867
+ const deal = await findDealByIdentifier(dealId);
868
+ if (!deal) {
869
+ return res.status(404).json({
870
+ code: 'NOT_FOUND',
871
+ message: 'Deal not found'
872
+ });
873
+ }
874
+
875
+ const result = await ApprovalService.resendApprovalEmails(deal.deal_id, req.requestId, {
876
+ frontendUrl,
877
+ emails
878
+ });
879
+
880
+ res.json({
881
+ requestId: req.requestId,
882
+ status: 200,
883
+ result
884
+ });
885
+ } catch (error) {
886
+ if (error.statusCode) {
887
+ return res.status(error.statusCode).json({
888
+ code: error.code || 'ERROR',
889
+ message: error.message
890
+ });
891
+ }
892
+ next(error);
893
+ }
894
+ });
895
+
896
+ router.post('/sync', async (req, res, next) => {
897
+ try {
898
+ const syncData = req.body;
899
+
900
+ if (!Array.isArray(syncData)) {
901
+ return res.status(400).json({
902
+ code: 'VALIDATION_ERROR',
903
+ message: 'Sync payload must be an array'
904
+ });
905
+ }
906
+
907
+ const results = [];
908
+
909
+ for (const item of syncData) {
910
+ const { deal, line_items } = item;
911
+
912
+ if (!deal) {
913
+ results.push({
914
+ success: false,
915
+ error: 'Missing deal object'
916
+ });
917
+ continue;
918
+ }
919
+
920
+ const transaction = await sequelize.transaction();
921
+
922
+ try {
923
+ const dealCamel = toCamelCase(deal);
924
+ const flattenedDeal = flattenPayload(dealCamel);
925
+
926
+ const { error: dealValidationError, value: validatedDeal } = dealSyncSchema.validate(flattenedDeal, { abortEarly: false, stripUnknown: true });
927
+ if (dealValidationError) {
928
+ throw new Error(`Deal validation failed: ${dealValidationError.details.map(d => d.message).join(', ')}`);
929
+ }
930
+
931
+ const dealWithDefaults = CampaignModeService.applyDefaults(validatedDeal);
932
+
933
+ // Find existing deal by unique constraint (source + external_id)
934
+ let dealRecord = await Deal.findOne({
935
+ where: {
936
+ source: deal.source || dealWithDefaults.source,
937
+ external_id: deal.external_id || deal.externalId || dealWithDefaults.externalId
938
+ },
939
+ transaction
940
+ });
941
+
942
+ let finalDealData = dealWithDefaults;
943
+
944
+ if (dealRecord) {
945
+ if (dealWithDefaults.dealId && dealWithDefaults.dealId !== dealRecord.deal_id) {
946
+ throw new Error(`Cannot change deal ID of existing deal. Current ID: ${dealRecord.deal_id}, Attempted: ${dealWithDefaults.dealId}`);
947
+ }
948
+
949
+ const existingDealCamel = toCamelCase(dealRecord.toJSON());
950
+ const currentMode = existingDealCamel.mode;
951
+ const newMode = dealWithDefaults.mode || currentMode;
952
+
953
+ // Validate DIRECT mode requirements on update
954
+ if (CampaignModeService.isDirectMode(newMode)) {
955
+ const mergedData = {
956
+ brand: dealWithDefaults.brand !== undefined ? dealWithDefaults.brand : existingDealCamel.brand,
957
+ clientType: dealWithDefaults.clientType !== undefined ? dealWithDefaults.clientType : existingDealCamel.clientType,
958
+ mode: newMode,
959
+ approvalEmails: dealWithDefaults.approvalEmails !== undefined ? dealWithDefaults.approvalEmails : existingDealCamel.approvalEmails,
960
+ marketSelection: dealWithDefaults.marketSelection !== undefined ? dealWithDefaults.marketSelection : existingDealCamel.marketSelection,
961
+ budgetSetup: dealWithDefaults.budgetSetup !== undefined ? dealWithDefaults.budgetSetup : existingDealCamel.budgetSetup,
962
+ campaignGoal: dealWithDefaults.campaignGoal !== undefined ? dealWithDefaults.campaignGoal : existingDealCamel.campaignGoal
963
+ };
964
+
965
+ const modeValidation = CampaignModeService.validateDirectModeFields(mergedData);
966
+ if (!modeValidation.valid) {
967
+ throw new Error(`DIRECT mode validation failed: ${modeValidation.errors.map(e => e.message).join(', ')}`);
968
+ }
969
+ }
970
+
971
+ // Sanitize when switching from DIRECT to PROGRAMMATIC
972
+ if (currentMode === 'DIRECT' && newMode === 'PROGRAMMATIC') {
973
+ finalDealData = { ...dealWithDefaults };
974
+ finalDealData.approvalEmails = null;
975
+ finalDealData.marketSelection = null;
976
+ finalDealData.budgetSetup = null;
977
+ finalDealData.campaignGoal = null;
978
+ }
979
+
980
+ const sanitizedForUpdate = stripKeys(finalDealData, ['dealId', 'deal_id', 'id']);
981
+ const dealData = toSnakeCase(sanitizedForUpdate);
982
+ await dealRecord.update(dealData, { transaction });
983
+ } else {
984
+ // Validate DIRECT mode requirements on create
985
+ if (CampaignModeService.isDirectMode(dealWithDefaults.mode)) {
986
+ const modeValidation = CampaignModeService.validateDirectModeFields(dealWithDefaults);
987
+ if (!modeValidation.valid) {
988
+ throw new Error(`DIRECT mode validation failed: ${modeValidation.errors.map(e => e.message).join(', ')}`);
989
+ }
990
+ }
991
+
992
+ const dealData = toSnakeCase(finalDealData);
993
+
994
+ if (!dealData.deal_id) {
995
+ const generatedDealId = await DealIdService.generateDealId(
996
+ dealWithDefaults.dealType || 'GUARANTEED',
997
+ dealWithDefaults.mode,
998
+ transaction
999
+ );
1000
+ dealData.deal_id = generatedDealId;
1001
+ } else {
1002
+ if (!DealIdService.isValidDealIdFormat(dealData.deal_id)) {
1003
+ throw new Error(`Invalid deal ID format: ${dealData.deal_id}. Expected format: {PREFIX}-I-XXXXX-XXXXX where PREFIX is GD, PD, PA, EG, or DIR`);
1004
+ }
1005
+
1006
+ const existingDealWithId = await Deal.findOne({
1007
+ where: { deal_id: dealData.deal_id },
1008
+ transaction
1009
+ });
1010
+ if (existingDealWithId) {
1011
+ throw new Error(`Deal ID ${dealData.deal_id} already exists`);
1012
+ }
1013
+ }
1014
+
1015
+ dealRecord = await Deal.create(dealData, { transaction });
1016
+ }
1017
+
1018
+ let lineItemsProcessed = 0;
1019
+ let inventoriesProcessed = 0;
1020
+ let creativesProcessed = 0;
1021
+
1022
+ if (line_items && Array.isArray(line_items)) {
1023
+ for (const lineItemData of line_items) {
1024
+ const { line_item, inventories, creatives } = lineItemData;
1025
+
1026
+ if (line_item) {
1027
+ // Validate line item using Joi schema
1028
+ const lineItemCamelCase = toCamelCase(line_item);
1029
+ const { error: lineItemError } = lineItemSchema.validate(lineItemCamelCase);
1030
+
1031
+ if (lineItemError) {
1032
+ throw new Error(`Line item validation failed: ${lineItemError.message}`);
1033
+ }
1034
+
1035
+ const lineItemPayload = toSnakeCase(line_item);
1036
+ lineItemPayload.deal_id = dealRecord.deal_id;
1037
+ lineItemPayload.source = dealRecord.source;
1038
+
1039
+ // Find existing line item by unique constraint (source + external_id)
1040
+ let lineItemRecord = await LineItem.findOne({
1041
+ where: {
1042
+ source: lineItemPayload.source,
1043
+ external_id: lineItemPayload.external_id
1044
+ },
1045
+ transaction
1046
+ });
1047
+
1048
+ if (lineItemRecord) {
1049
+ // Update existing line item (exclude id and other non-updatable fields)
1050
+ const { id, ...updatePayload } = lineItemPayload;
1051
+ await lineItemRecord.update(updatePayload, { transaction });
1052
+ } else {
1053
+ // Create new line item
1054
+ lineItemRecord = await LineItem.create(lineItemPayload, { transaction });
1055
+ }
1056
+ lineItemsProcessed++;
1057
+
1058
+ if (inventories && Array.isArray(inventories)) {
1059
+ for (const inventory of inventories) {
1060
+ const inventoryPayload = toSnakeCase(inventory);
1061
+ inventoryPayload.line_item_id = lineItemRecord.id;
1062
+ inventoryPayload.deal_id = dealRecord.deal_id;
1063
+
1064
+ await LineItemInventory.upsert(inventoryPayload, {
1065
+ transaction
1066
+ });
1067
+ inventoriesProcessed++;
1068
+ }
1069
+ }
1070
+
1071
+ if (creatives && Array.isArray(creatives)) {
1072
+ for (const creative of creatives) {
1073
+ const creativePayload = toSnakeCase(creative);
1074
+ creativePayload.line_item_id = lineItemRecord.id;
1075
+ creativePayload.deal_id = dealRecord.deal_id;
1076
+
1077
+ // Find existing creative by unique constraint (creative_id + deal_id)
1078
+ let creativeRecord = await LineItemCreative.findOne({
1079
+ where: {
1080
+ creative_id: creativePayload.creative_id,
1081
+ deal_id: creativePayload.deal_id
1082
+ },
1083
+ transaction
1084
+ });
1085
+
1086
+ if (creativeRecord) {
1087
+ // Update existing creative (exclude id from update)
1088
+ const { id, ...updateCreativePayload } = creativePayload;
1089
+ await creativeRecord.update(updateCreativePayload, { transaction });
1090
+ } else {
1091
+ // Create new creative
1092
+ await LineItemCreative.create(creativePayload, { transaction });
1093
+ }
1094
+ creativesProcessed++;
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // Auto-calculate deal metrics from line items (including publishers from inventories)
1102
+ if (lineItemsProcessed > 0) {
1103
+ const lineItemRecords = await LineItem.findAll({
1104
+ where: { deal_id: dealRecord.deal_id },
1105
+ include: [{
1106
+ model: LineItemInventory,
1107
+ as: 'inventories'
1108
+ }],
1109
+ transaction
1110
+ });
1111
+
1112
+ const calculatedMetrics = calculateDealMetrics(lineItemRecords.map(li => li.toJSON()), dealRecord.mode || 'PROGRAMMATIC');
1113
+ await dealRecord.update(calculatedMetrics, { transaction });
1114
+ }
1115
+
1116
+ await transaction.commit();
1117
+
1118
+ results.push({
1119
+ success: true,
1120
+ deal_id: dealRecord.deal_id,
1121
+ line_items_processed: lineItemsProcessed,
1122
+ inventories_processed: inventoriesProcessed,
1123
+ creatives_processed: creativesProcessed
1124
+ });
1125
+ } catch (error) {
1126
+ await transaction.rollback();
1127
+
1128
+ results.push({
1129
+ success: false,
1130
+ deal_id: deal?.deal_id || deal?.external_id || 'unknown',
1131
+ error: error.message,
1132
+ error_type: error.name
1133
+ });
1134
+ }
1135
+ }
1136
+
1137
+ const successCount = results.filter(r => r.success).length;
1138
+ const failureCount = results.filter(r => !r.success).length;
1139
+
1140
+ const httpStatus = failureCount > 0 && successCount === 0 ? 400 : 200;
1141
+
1142
+ res.status(httpStatus).json({
1143
+ requestId: req.requestId,
1144
+ status: httpStatus,
1145
+ result: {
1146
+ total: results.length,
1147
+ successful: successCount,
1148
+ failed: failureCount,
1149
+ items: results.length === 1 ? results[0] : results
1150
+ }
1151
+ });
1152
+ } catch (error) {
1153
+ next(error);
1154
+ }
1155
+ });
1156
+
1157
+ router.get('/:dealId/inventories/minified', async (req, res, next) => {
1158
+ try {
1159
+ const { dealId } = req.params;
1160
+
1161
+ const deal = await findDealByIdentifier(dealId);
1162
+ if (!deal) {
1163
+ return res.status(404).json({
1164
+ code: 'NOT_FOUND',
1165
+ message: 'Deal not found'
1166
+ });
1167
+ }
1168
+
1169
+ const inventories = await LineItemInventory.findAll({
1170
+ where: { deal_id: deal.deal_id },
1171
+ attributes: ['id', 'name', 'publisher_id', 'publisher_name', 'publisher_external_id', 'size'],
1172
+ order: [['name', 'ASC']]
1173
+ });
1174
+
1175
+ const uniqueInventories = new Map();
1176
+ for (const inv of inventories) {
1177
+ if (!uniqueInventories.has(inv.id)) {
1178
+ uniqueInventories.set(inv.id, {
1179
+ id: inv.id,
1180
+ name: inv.name,
1181
+ publisher: {
1182
+ id: inv.publisher_id,
1183
+ name: inv.publisher_name,
1184
+ externalId: inv.publisher_external_id || null
1185
+ },
1186
+ resolution: inv.size
1187
+ });
1188
+ }
1189
+ }
1190
+
1191
+ const minifiedInventories = Array.from(uniqueInventories.values());
1192
+
1193
+ res.json({
1194
+ requestId: req.requestId,
1195
+ status: 200,
1196
+ result: {
1197
+ data: minifiedInventories,
1198
+ total: minifiedInventories.length
1199
+ }
1200
+ });
1201
+ } catch (error) {
1202
+ next(error);
1203
+ }
1204
+ });
1205
+
1206
+ router.post('/import/convert', async (req, res, next) => {
1207
+ try {
1208
+ const { payloadType, externalPayload, options = {} } = req.body;
1209
+
1210
+ if (!payloadType) {
1211
+ return res.status(400).json({
1212
+ code: 'VALIDATION_ERROR',
1213
+ message: 'payloadType is required. Valid values: PROGRAMMATIC_STANDARD, DIRECT_STANDARD, DIRECT_PUBLISHER_SPLIT'
1214
+ });
1215
+ }
1216
+
1217
+ if (!Object.values(PayloadType).includes(payloadType)) {
1218
+ return res.status(400).json({
1219
+ code: 'VALIDATION_ERROR',
1220
+ message: `Invalid payloadType: ${payloadType}. Valid values: ${Object.values(PayloadType).join(', ')}`
1221
+ });
1222
+ }
1223
+
1224
+ if (!externalPayload) {
1225
+ return res.status(400).json({
1226
+ code: 'VALIDATION_ERROR',
1227
+ message: 'externalPayload is required'
1228
+ });
1229
+ }
1230
+
1231
+ const validation = CampaignImportConverter.validateExternalPayload(externalPayload, payloadType);
1232
+ if (!validation.valid) {
1233
+ return res.status(400).json({
1234
+ code: 'VALIDATION_ERROR',
1235
+ message: 'External payload validation failed',
1236
+ errors: validation.errors
1237
+ });
1238
+ }
1239
+
1240
+ const convertedResult = CampaignImportConverter.convert(externalPayload, payloadType, options);
1241
+
1242
+ const syncPayload = CampaignImportConverter.toSyncPayload(convertedResult);
1243
+
1244
+ res.json({
1245
+ requestId: req.requestId,
1246
+ status: 200,
1247
+ result: {
1248
+ converted: convertedResult,
1249
+ syncPayload,
1250
+ metadata: convertedResult.metadata
1251
+ }
1252
+ });
1253
+ } catch (error) {
1254
+ next(error);
1255
+ }
1256
+ });
1257
+
1258
+ router.post('/import', async (req, res, next) => {
1259
+ try {
1260
+ const { payloadType, externalPayload, options = {} } = req.body;
1261
+
1262
+ console.log('[Import] External Platform Request Payload:', JSON.stringify({
1263
+ payloadType,
1264
+ externalPayloadSize: externalPayload ? JSON.stringify(externalPayload).length : 0,
1265
+ externalPayload: externalPayload
1266
+ }, null, 2));
1267
+
1268
+ if (!payloadType) {
1269
+ return res.status(400).json({
1270
+ code: 'VALIDATION_ERROR',
1271
+ message: 'payloadType is required. Valid values: PROGRAMMATIC_STANDARD, DIRECT_STANDARD, DIRECT_PUBLISHER_SPLIT'
1272
+ });
1273
+ }
1274
+
1275
+ if (!Object.values(PayloadType).includes(payloadType)) {
1276
+ return res.status(400).json({
1277
+ code: 'VALIDATION_ERROR',
1278
+ message: `Invalid payloadType: ${payloadType}. Valid values: ${Object.values(PayloadType).join(', ')}`
1279
+ });
1280
+ }
1281
+
1282
+ if (!externalPayload) {
1283
+ return res.status(400).json({
1284
+ code: 'VALIDATION_ERROR',
1285
+ message: 'externalPayload is required'
1286
+ });
1287
+ }
1288
+
1289
+ const validation = CampaignImportConverter.validateExternalPayload(externalPayload, payloadType);
1290
+ if (!validation.valid) {
1291
+ return res.status(400).json({
1292
+ code: 'VALIDATION_ERROR',
1293
+ message: 'External payload validation failed',
1294
+ errors: validation.errors
1295
+ });
1296
+ }
1297
+
1298
+ const convertedResult = CampaignImportConverter.convert(externalPayload, payloadType, options);
1299
+ const { deal: dealData, lineItems } = convertedResult;
1300
+
1301
+ const transaction = await sequelize.transaction();
1302
+
1303
+ try {
1304
+ const dealCamel = toCamelCase(dealData);
1305
+ const flattenedDeal = flattenPayload(dealCamel);
1306
+
1307
+ const { error: dealValidationError, value: validatedDeal } = dealSyncSchema.validate(flattenedDeal, { abortEarly: false, stripUnknown: true });
1308
+ if (dealValidationError) {
1309
+ throw new Error(`Deal validation failed: ${dealValidationError.details.map(d => d.message).join(', ')}`);
1310
+ }
1311
+
1312
+ const dealWithDefaults = CampaignModeService.applyDefaults(validatedDeal);
1313
+
1314
+ if (CampaignModeService.isDirectMode(dealWithDefaults.mode)) {
1315
+ const modeValidation = CampaignModeService.validateDirectModeFields(dealWithDefaults);
1316
+ if (!modeValidation.valid) {
1317
+ throw new Error(`DIRECT mode validation failed: ${modeValidation.errors.map(e => e.message).join(', ')}`);
1318
+ }
1319
+ }
1320
+
1321
+ const dealSnake = toSnakeCase(dealWithDefaults);
1322
+
1323
+ let dealRecord = await Deal.findOne({
1324
+ where: {
1325
+ source: dealSnake.source,
1326
+ external_id: dealSnake.external_id
1327
+ },
1328
+ transaction
1329
+ });
1330
+
1331
+ const isExistingDeal = !!dealRecord;
1332
+
1333
+ if (dealRecord) {
1334
+ // Upsert is ONLY allowed when the deal is in REOPENED status
1335
+ if (dealRecord.status !== 'REOPENED') {
1336
+ await transaction.rollback();
1337
+ return res.status(409).json({
1338
+ code: 'UPSERT_NOT_ALLOWED',
1339
+ message: `Cannot update existing campaign. Deal ${dealRecord.deal_id} must be in REOPENED status to accept updates via import.`,
1340
+ currentStatus: dealRecord.status,
1341
+ requiredStatus: 'REOPENED',
1342
+ dealId: dealRecord.deal_id,
1343
+ hint: 'Use POST /deals/{dealId}/reopen to reopen the campaign before re-importing.'
1344
+ });
1345
+ }
1346
+
1347
+ const sanitizedForUpdate = stripKeys(dealSnake, ['deal_id', 'id']);
1348
+ await dealRecord.update(sanitizedForUpdate, { transaction });
1349
+ } else {
1350
+ if (!dealSnake.deal_id) {
1351
+ const generatedDealId = await DealIdService.generateDealId(
1352
+ dealWithDefaults.dealType || 'GUARANTEED',
1353
+ dealWithDefaults.mode,
1354
+ transaction
1355
+ );
1356
+ dealSnake.deal_id = generatedDealId;
1357
+ }
1358
+ dealRecord = await Deal.create(dealSnake, { transaction });
1359
+ }
1360
+
1361
+ let lineItemsProcessed = 0;
1362
+ let lineItemsArchived = 0;
1363
+ let inventoriesProcessed = 0;
1364
+ let insertionOrdersCreated = 0;
1365
+ let insertionOrdersArchived = 0;
1366
+
1367
+ // Track which line item externalIds are in the current payload
1368
+ const incomingLineItemExternalIds = new Set(lineItems.map(li => li.externalId));
1369
+
1370
+ // Track which publishers are in the current payload
1371
+ const incomingPublisherIds = new Set(
1372
+ convertedResult.metadata?.publisherBreakdown?.map(p => p.publisherId) || []
1373
+ );
1374
+
1375
+ if (payloadType === PayloadType.DIRECT_PUBLISHER_SPLIT && convertedResult.metadata?.publisherBreakdown) {
1376
+ const publisherLineItemMap = {};
1377
+ for (const li of lineItems) {
1378
+ const pubId = li.publisherId;
1379
+ if (pubId) {
1380
+ if (!publisherLineItemMap[pubId]) {
1381
+ publisherLineItemMap[pubId] = [];
1382
+ }
1383
+ publisherLineItemMap[pubId].push(li);
1384
+ }
1385
+ }
1386
+
1387
+ for (const pubInfo of convertedResult.metadata.publisherBreakdown) {
1388
+ const pubLineItems = publisherLineItemMap[pubInfo.publisherId] || [];
1389
+
1390
+ const pubStartDates = pubLineItems.map(li => li.startDate).filter(Boolean);
1391
+ const pubEndDates = pubLineItems.map(li => li.endDate).filter(Boolean);
1392
+ const pubStartDate = pubStartDates.length > 0 ? pubStartDates.sort()[0] : null;
1393
+ const pubEndDate = pubEndDates.length > 0 ? pubEndDates.sort().reverse()[0] : null;
1394
+
1395
+ const pubBudgets = pubLineItems.map(li => li.direct?.budgetSetup?.budgetAmount || 0);
1396
+ const pubTotalBudget = pubBudgets.reduce((sum, b) => sum + b, 0);
1397
+
1398
+ const existingIO = await PublisherInsertionOrder.findOne({
1399
+ where: {
1400
+ deal_id: dealRecord.deal_id,
1401
+ publisher_id: pubInfo.publisherId
1402
+ },
1403
+ transaction
1404
+ });
1405
+
1406
+ if (!existingIO) {
1407
+ await PublisherInsertionOrder.create({
1408
+ deal_id: dealRecord.deal_id,
1409
+ publisher_id: pubInfo.publisherId,
1410
+ publisher_name: pubInfo.publisherName,
1411
+ status: 'DRAFT',
1412
+ currency: dealRecord.currency,
1413
+ start_date: pubStartDate,
1414
+ end_date: pubEndDate,
1415
+ budget_amount: pubTotalBudget > 0 ? pubTotalBudget : null
1416
+ }, { transaction });
1417
+ insertionOrdersCreated++;
1418
+ } else if (existingIO.status === 'ARCHIVED') {
1419
+ await existingIO.update({
1420
+ status: 'DRAFT',
1421
+ start_date: pubStartDate,
1422
+ end_date: pubEndDate,
1423
+ budget_amount: pubTotalBudget > 0 ? pubTotalBudget : null
1424
+ }, { transaction });
1425
+ } else {
1426
+ await existingIO.update({
1427
+ start_date: pubStartDate,
1428
+ end_date: pubEndDate,
1429
+ budget_amount: pubTotalBudget > 0 ? pubTotalBudget : null
1430
+ }, { transaction });
1431
+ }
1432
+ }
1433
+
1434
+ // Archive insertion orders for publishers no longer in payload (only for existing deals)
1435
+ if (isExistingDeal) {
1436
+ const allExistingIOs = await PublisherInsertionOrder.findAll({
1437
+ where: {
1438
+ deal_id: dealRecord.deal_id,
1439
+ status: { [Op.ne]: 'ARCHIVED' }
1440
+ },
1441
+ transaction
1442
+ });
1443
+
1444
+ for (const io of allExistingIOs) {
1445
+ if (!incomingPublisherIds.has(io.publisher_id)) {
1446
+ await io.update({ status: 'ARCHIVED' }, { transaction });
1447
+ insertionOrdersArchived++;
1448
+ }
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ for (const lineItemData of lineItems) {
1454
+ const lineItemCamel = toCamelCase(lineItemData);
1455
+ const { error: lineItemError } = lineItemSchema.validate(lineItemCamel, { stripUnknown: true });
1456
+
1457
+ if (lineItemError) {
1458
+ throw new Error(`Line item validation failed: ${lineItemError.message}`);
1459
+ }
1460
+
1461
+ const flattenedLineItem = { ...lineItemData };
1462
+ if (flattenedLineItem.direct && typeof flattenedLineItem.direct === 'object') {
1463
+ const directFlatFields = ['budgetSetup', 'campaignGoal', 'targeting', 'planning'];
1464
+ for (const [key, value] of Object.entries(flattenedLineItem.direct)) {
1465
+ if (directFlatFields.includes(key) && value !== undefined) {
1466
+ flattenedLineItem[key] = value;
1467
+ }
1468
+ }
1469
+ delete flattenedLineItem.direct;
1470
+ }
1471
+
1472
+ const lineItemPayload = toSnakeCase(flattenedLineItem);
1473
+ lineItemPayload.deal_id = dealRecord.deal_id;
1474
+ lineItemPayload.source = dealRecord.source;
1475
+
1476
+ const inventories = lineItemPayload.inventories || [];
1477
+ delete lineItemPayload.inventories;
1478
+
1479
+ let lineItemRecord = await LineItem.findOne({
1480
+ where: {
1481
+ source: lineItemPayload.source,
1482
+ external_id: lineItemPayload.external_id
1483
+ },
1484
+ transaction
1485
+ });
1486
+
1487
+ if (lineItemRecord) {
1488
+ const { id, ...updatePayload } = lineItemPayload;
1489
+ await lineItemRecord.update(updatePayload, { transaction });
1490
+ } else {
1491
+ lineItemRecord = await LineItem.create(lineItemPayload, { transaction });
1492
+ }
1493
+ lineItemsProcessed++;
1494
+
1495
+ for (const inventory of inventories) {
1496
+ const inventoryPayload = toSnakeCase(inventory);
1497
+ inventoryPayload.line_item_id = lineItemRecord.id;
1498
+ inventoryPayload.deal_id = dealRecord.deal_id;
1499
+
1500
+ await LineItemInventory.upsert(inventoryPayload, { transaction });
1501
+ inventoriesProcessed++;
1502
+ }
1503
+ }
1504
+
1505
+ // Archive line items no longer in payload (only for existing deals with DIRECT_PUBLISHER_SPLIT)
1506
+ if (isExistingDeal && payloadType === PayloadType.DIRECT_PUBLISHER_SPLIT) {
1507
+ const existingLineItems = await LineItem.findAll({
1508
+ where: {
1509
+ deal_id: dealRecord.deal_id,
1510
+ source: dealRecord.source,
1511
+ status: { [Op.ne]: 'ARCHIVED' }
1512
+ },
1513
+ transaction
1514
+ });
1515
+
1516
+ for (const li of existingLineItems) {
1517
+ if (!incomingLineItemExternalIds.has(li.external_id)) {
1518
+ await li.update({ status: 'ARCHIVED' }, { transaction });
1519
+ lineItemsArchived++;
1520
+ }
1521
+ }
1522
+ }
1523
+
1524
+ if (payloadType === PayloadType.DIRECT_PUBLISHER_SPLIT) {
1525
+ const allStartDates = lineItems.map(li => li.startDate).filter(Boolean);
1526
+ const allEndDates = lineItems.map(li => li.endDate).filter(Boolean);
1527
+
1528
+ const dealUpdates = {};
1529
+ if (!dealRecord.start_date && allStartDates.length > 0) {
1530
+ dealUpdates.start_date = allStartDates.sort()[0];
1531
+ }
1532
+ if (!dealRecord.end_date && allEndDates.length > 0) {
1533
+ dealUpdates.end_date = allEndDates.sort().reverse()[0];
1534
+ }
1535
+
1536
+ if (Object.keys(dealUpdates).length > 0) {
1537
+ await dealRecord.update(dealUpdates, { transaction });
1538
+ }
1539
+ }
1540
+
1541
+ await transaction.commit();
1542
+
1543
+ const updatedDeal = await Deal.findByPk(dealRecord.id);
1544
+ const formattedDeal = DealResponseFormatter.formatDealResponse(updatedDeal);
1545
+
1546
+ res.status(201).json({
1547
+ requestId: req.requestId,
1548
+ status: 201,
1549
+ result: {
1550
+ deal: formattedDeal,
1551
+ summary: {
1552
+ payloadType,
1553
+ isUpdate: isExistingDeal,
1554
+ lineItemsProcessed,
1555
+ lineItemsArchived,
1556
+ inventoriesProcessed,
1557
+ insertionOrdersCreated,
1558
+ insertionOrdersArchived,
1559
+ ...convertedResult.metadata
1560
+ }
1561
+ }
1562
+ });
1563
+ } catch (error) {
1564
+ await transaction.rollback();
1565
+ throw error;
1566
+ }
1567
+ } catch (error) {
1568
+ next(error);
1569
+ }
1570
+ });
1571
+
1572
+ router.get('/:dealId/history', async (req, res, next) => {
1573
+ try {
1574
+ const { dealId } = req.params;
1575
+ const { limit = 50, offset = 0, includeLineItems = 'true', action, startDate, endDate } = req.query;
1576
+
1577
+ const deal = await findDealByIdentifier(dealId);
1578
+ if (!deal) {
1579
+ return res.status(404).json({
1580
+ code: 'NOT_FOUND',
1581
+ message: 'Deal not found'
1582
+ });
1583
+ }
1584
+
1585
+ const history = await ChangeHistoryService.getDealHistory(deal.deal_id, {
1586
+ limit: parseInt(limit),
1587
+ offset: parseInt(offset),
1588
+ includeLineItems: includeLineItems === 'true',
1589
+ action,
1590
+ startDate,
1591
+ endDate
1592
+ });
1593
+
1594
+ res.json({
1595
+ requestId: req.requestId,
1596
+ status: 200,
1597
+ result: {
1598
+ dealId: deal.deal_id,
1599
+ ...history
1600
+ }
1601
+ });
1602
+ } catch (error) {
1603
+ next(error);
1604
+ }
1605
+ });
1606
+
1607
+ router.get('/:dealId/history/minified', async (req, res, next) => {
1608
+ try {
1609
+ const { dealId } = req.params;
1610
+ const { limit = 100, includeLineItems = 'true' } = req.query;
1611
+
1612
+ const deal = await findDealByIdentifier(dealId);
1613
+ if (!deal) {
1614
+ return res.status(404).json({
1615
+ code: 'NOT_FOUND',
1616
+ message: 'Deal not found'
1617
+ });
1618
+ }
1619
+
1620
+ const logs = await ChangeHistoryService.getMinifiedHistory(deal.deal_id, {
1621
+ limit: parseInt(limit),
1622
+ includeLineItems: includeLineItems === 'true'
1623
+ });
1624
+
1625
+ res.json({
1626
+ requestId: req.requestId,
1627
+ status: 200,
1628
+ result: {
1629
+ dealId: deal.deal_id,
1630
+ logs
1631
+ }
1632
+ });
1633
+ } catch (error) {
1634
+ next(error);
1635
+ }
1636
+ });
1637
+
1638
+ export default router;