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.
- package/.ci/staging.yml +191 -0
- package/.dockerignore +117 -0
- package/.env +40 -0
- package/.env.staging +38 -0
- package/.gitlab-ci.yml +16 -0
- package/DEMO_STATUS.md +579 -0
- package/Dockerfile +61 -0
- package/Influence-MW-AdServer-12-02-2026/client/index.html +17 -0
- package/Influence-MW-AdServer-12-02-2026/client/public/favicon.png +0 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/App.tsx +91 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/advanced-map-drawer.tsx +1131 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ai-recommendation-panel.tsx +379 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/app-sidebar.tsx +183 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/auto-optimize-button.tsx +184 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/availability-drawer.tsx +385 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/brand-insights-panel.tsx +87 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/create-agency-drawer.tsx +198 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/create-brand-drawer.tsx +275 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/creative-assignment.tsx +526 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table-toolbar.tsx +148 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table.tsx +158 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/filter-drawer.tsx +356 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/form-insights-panel.tsx +82 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/geography-selector.tsx +699 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/header-user-menu.tsx +178 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/history-drawer.tsx +313 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-availability-section.tsx +176 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-format-drawer.tsx +173 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-selector.tsx +401 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/manual-inventory-drawer.tsx +368 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/mapbox-map.tsx +368 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/market-insights-panel.tsx +202 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/media-owner-drawer.tsx +217 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/metric-card.tsx +58 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/page-header.tsx +27 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/player-status-indicator.tsx +137 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/poi-targeting-drawer.tsx +298 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/recommendation-score-badge.tsx +102 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/recommended-inventories-panel.tsx +248 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/searchable-combobox.tsx +134 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/signal-visualizations.tsx +407 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/status-badge.tsx +35 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-provider.tsx +73 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-toggle.tsx +37 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/traffic-slider.tsx +75 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/accordion.tsx +56 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert-dialog.tsx +139 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert.tsx +59 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/aspect-ratio.tsx +5 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/avatar.tsx +51 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/badge.tsx +38 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/breadcrumb.tsx +115 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/button.tsx +62 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/calendar.tsx +68 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/card.tsx +85 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/carousel.tsx +260 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/chart.tsx +365 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/checkbox.tsx +28 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/collapsible.tsx +11 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/command.tsx +151 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/context-menu.tsx +198 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dialog.tsx +122 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/drawer.tsx +118 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dropdown-menu.tsx +198 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/form.tsx +178 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/hover-card.tsx +29 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input-otp.tsx +69 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input.tsx +23 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/label.tsx +24 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/menubar.tsx +256 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/navigation-menu.tsx +128 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/pagination.tsx +117 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/popover.tsx +29 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/progress.tsx +28 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/radio-group.tsx +42 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/resizable.tsx +45 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/scroll-area.tsx +46 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/select.tsx +160 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/separator.tsx +29 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sheet.tsx +140 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sidebar.tsx +727 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/skeleton.tsx +15 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/slider.tsx +26 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/switch.tsx +27 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/table.tsx +117 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tabs.tsx +53 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/textarea.tsx +22 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toast.tsx +127 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toaster.tsx +33 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle-group.tsx +61 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle.tsx +43 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tooltip.tsx +30 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/vendor-stores-modal.tsx +336 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-drawer.tsx +359 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-selector.tsx +436 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-mobile.tsx +19 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-toast.ts +191 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/index.css +244 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/lib/queryClient.ts +57 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/lib/utils.ts +39 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/lib/venue-taxonomy.ts +532 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/main.tsx +5 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/assign-creative.tsx +781 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/content-hub.tsx +995 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/custom-pois.tsx +431 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/dashboard.tsx +620 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-detail.tsx +1062 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-form.tsx +1570 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/deals.tsx +716 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/edit-creative-assignment.tsx +1051 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/geotargeting.tsx +675 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/integrations.tsx +425 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-creatives.tsx +622 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-form.tsx +3132 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-items.tsx +530 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/not-found.tsx +21 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play-upload.tsx +479 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play.tsx +880 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/reports.tsx +235 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/settings.tsx +652 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/signal-form.tsx +1117 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/signals.tsx +366 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/tags.tsx +332 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/venues.tsx +381 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/types/mapbox-gl-draw.d.ts +37 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/types/react-simple-maps.d.ts +57 -0
- package/Influence-MW-AdServer-12-02-2026/components.json +20 -0
- package/Influence-MW-AdServer-12-02-2026/docs/PRD.md +3373 -0
- package/Influence-MW-AdServer-12-02-2026/docs/influence-feature-mapping.csv +498 -0
- package/Influence-MW-AdServer-12-02-2026/drizzle.config.ts +14 -0
- package/Influence-MW-AdServer-12-02-2026/package-lock.json +9672 -0
- package/Influence-MW-AdServer-12-02-2026/package.json +118 -0
- package/Influence-MW-AdServer-12-02-2026/postcss.config.js +6 -0
- package/Influence-MW-AdServer-12-02-2026/replit.md +91 -0
- package/Influence-MW-AdServer-12-02-2026/script/build.ts +67 -0
- package/Influence-MW-AdServer-12-02-2026/scripts/create-miro-diagrams.cjs +318 -0
- package/Influence-MW-AdServer-12-02-2026/scripts/create-remaining-diagrams.cjs +270 -0
- package/Influence-MW-AdServer-12-02-2026/server/index.ts +103 -0
- package/Influence-MW-AdServer-12-02-2026/server/recommendation-service.ts +319 -0
- package/Influence-MW-AdServer-12-02-2026/server/routes.ts +1890 -0
- package/Influence-MW-AdServer-12-02-2026/server/static.ts +19 -0
- package/Influence-MW-AdServer-12-02-2026/server/storage.ts +2058 -0
- package/Influence-MW-AdServer-12-02-2026/server/vite.ts +58 -0
- package/Influence-MW-AdServer-12-02-2026/shared/schema.ts +1595 -0
- package/Influence-MW-AdServer-12-02-2026/tailwind.config.ts +107 -0
- package/Influence-MW-AdServer-12-02-2026/tsconfig.json +23 -0
- package/Influence-MW-AdServer-12-02-2026/vite.config.ts +40 -0
- package/LINE_ITEM_BUDGET_FIELD_MAPPING.md +178 -0
- package/PCM/.env.example +92 -0
- package/PCM/README.md +558 -0
- package/PCM/docs/TEST_CASES.md +422 -0
- package/PCM/index.js +106 -0
- package/PCM/package-lock.json +3282 -0
- package/PCM/package.json +32 -0
- package/PCM/replit.md +64 -0
- package/PCM/schema.sql +495 -0
- package/PCM/scripts/export-schema.js +183 -0
- package/PCM/scripts/seed-comprehensive.js +631 -0
- package/PCM/scripts/seed-production.js +477 -0
- package/PCM/src/config/db.js +56 -0
- package/PCM/src/config/swagger.js +5975 -0
- package/PCM/src/dto/EmailRequestDTO.js +166 -0
- package/PCM/src/middleware/errorHandler.js +52 -0
- package/PCM/src/middleware/logger.js +26 -0
- package/PCM/src/migrations/001_add_campaign_mode_fields.sql +36 -0
- package/PCM/src/migrations/002_create_deal_id_counters.sql +22 -0
- package/PCM/src/migrations/003_update_publishers_column.sql +15 -0
- package/PCM/src/migrations/004_add_direct_dealtype_and_advertiser.sql +5 -0
- package/PCM/src/migrations/005_add_programmatic_fields_and_update_enums.sql +31 -0
- package/PCM/src/migrations/006_add_line_item_programmatic_fields.sql +12 -0
- package/PCM/src/migrations/007_add_line_item_direct_fields.sql +15 -0
- package/PCM/src/migrations/008_add_inventory_fields.sql +45 -0
- package/PCM/src/migrations/009_move_inventory_fields_to_metadata.sql +32 -0
- package/PCM/src/migrations/010_add_draft_status_and_line_items_count.sql +23 -0
- package/PCM/src/migrations/011_add_planning_field.sql +21 -0
- package/PCM/src/migrations/012_fix_inventory_composite_pk.sql +17 -0
- package/PCM/src/migrations/013_make_external_id_optional.sql +3 -0
- package/PCM/src/migrations/014_create_change_history.sql +38 -0
- package/PCM/src/migrations/016_create_publisher_insertion_orders.sql +33 -0
- package/PCM/src/migrations/017_fix_line_item_id_fk_reference.sql +86 -0
- package/PCM/src/migrations/018_create_approval_tables.sql +44 -0
- package/PCM/src/migrations/019_add_encrypted_token_column.sql +2 -0
- package/PCM/src/migrations/020_add_rejection_reason_to_deals.sql +10 -0
- package/PCM/src/migrations/021_add_publisher_external_id_to_inventories.sql +12 -0
- package/PCM/src/migrations/022_add_line_item_extended_fields.sql +24 -0
- package/PCM/src/migrations/023_add_base_price_fields.sql +8 -0
- package/PCM/src/migrations/run-migrations.js +46 -0
- package/PCM/src/models/ApprovalOTP.js +51 -0
- package/PCM/src/models/ApprovalToken.js +79 -0
- package/PCM/src/models/ChangeHistory.js +107 -0
- package/PCM/src/models/Deal.js +186 -0
- package/PCM/src/models/DealIdCounter.js +28 -0
- package/PCM/src/models/LineItem.js +227 -0
- package/PCM/src/models/LineItemCreative.js +89 -0
- package/PCM/src/models/LineItemInventory.js +115 -0
- package/PCM/src/models/PublisherInsertionOrder.js +93 -0
- package/PCM/src/models/TransactionHistory.js +34 -0
- package/PCM/src/models/associations.js +81 -0
- package/PCM/src/routes/approval.js +321 -0
- package/PCM/src/routes/creatives.js +437 -0
- package/PCM/src/routes/deals.js +1638 -0
- package/PCM/src/routes/digitalSignage.js +242 -0
- package/PCM/src/routes/insertionOrders.js +380 -0
- package/PCM/src/routes/lineItems.js +926 -0
- package/PCM/src/routes/system.js +384 -0
- package/PCM/src/services/ApprovalService.js +885 -0
- package/PCM/src/services/CampaignImportConverter.js +631 -0
- package/PCM/src/services/CampaignModeService.js +273 -0
- package/PCM/src/services/CampaignStatusService.js +395 -0
- package/PCM/src/services/ChangeHistoryService.js +316 -0
- package/PCM/src/services/DealIdService.js +94 -0
- package/PCM/src/services/DealResponseFormatter.js +90 -0
- package/PCM/src/services/EmailNotificationService.js +315 -0
- package/PCM/src/services/LineItemResponseFormatter.js +122 -0
- package/PCM/src/services/LineItemStatusService.js +380 -0
- package/PCM/src/tests/comprehensiveTestRunner.js +360 -0
- package/PCM/src/tests/comprehensiveTests.js +1277 -0
- package/PCM/src/tests/dealTypeUnitTests.js +1058 -0
- package/PCM/src/tests/testRunner.js +248 -0
- package/PCM/src/utils/caseConverter.js +92 -0
- package/PCM/src/utils/dealCalculations.js +206 -0
- package/PCM/src/utils/lineItemPayloadNormalizer.js +41 -0
- package/PCM/src/utils/payloadNormalizer.js +34 -0
- package/PCM/src/utils/sourceNormalizer.js +56 -0
- package/PCM/src/validators/creativeValidator.js +27 -0
- package/PCM/src/validators/dealValidator.js +203 -0
- package/PCM/src/validators/lineItemValidator.js +489 -0
- package/PCM/tests/approval-flows.test.js +238 -0
- package/PCM/tests/approval-workflow.test.js +291 -0
- package/PCM/tests/campaign-import-converter.test.js +543 -0
- package/PCM/tests/campaign-import-e2e.test.js +520 -0
- package/PCM/tests/campaign-status.test.js +539 -0
- package/PCM/tests/direct-publisher-split-reimport.test.js +460 -0
- package/PCM/tests/e2e/digital-signage.test.js +145 -0
- package/PCM/tests/e2e/search-filter-pagination.test.js +399 -0
- package/PCM/tests/e2e-comprehensive.test.js +3446 -0
- package/PCM/tests/edge-cases.test.js +340 -0
- package/PCM/tests/line-item-status.test.js +340 -0
- package/PCM/tests/seller-account-external-ids.test.js +877 -0
- package/PCM/tests/source-validation.test.js +324 -0
- package/PRD.md +3373 -0
- package/README.md +186 -0
- package/client/index.html +35 -0
- package/client/public/DEMO_STATUS.md +579 -0
- package/client/public/img/MW-logo-trans_1754045676555.png +0 -0
- package/client/public/locales/ar/approval.json +144 -0
- package/client/public/locales/ar/buyer.json +61 -0
- package/client/public/locales/ar/campaigns.json +1 -0
- package/client/public/locales/ar/common.json +218 -0
- package/client/public/locales/ar/contentHub.json +266 -0
- package/client/public/locales/ar/creatives.json +79 -0
- package/client/public/locales/ar/dashboard.json +57 -0
- package/client/public/locales/ar/deals.json +886 -0
- package/client/public/locales/ar/dsp.json +131 -0
- package/client/public/locales/ar/inventory.json +201 -0
- package/client/public/locales/ar/lineItems.json +553 -0
- package/client/public/locales/ar/navigation.json +48 -0
- package/client/public/locales/ar/wizard.json +1 -0
- package/client/public/locales/en/approval.json +144 -0
- package/client/public/locales/en/buyer.json +65 -0
- package/client/public/locales/en/campaigns.json +1 -0
- package/client/public/locales/en/common.json +218 -0
- package/client/public/locales/en/contentHub.json +266 -0
- package/client/public/locales/en/creatives.json +79 -0
- package/client/public/locales/en/dashboard.json +57 -0
- package/client/public/locales/en/deals.json +886 -0
- package/client/public/locales/en/dsp.json +131 -0
- package/client/public/locales/en/inventory.json +201 -0
- package/client/public/locales/en/lineItems.json +659 -0
- package/client/public/locales/en/navigation.json +48 -0
- package/client/public/locales/en/wizard.json +1 -0
- package/client/public/locales/ja/approval.json +144 -0
- package/client/public/locales/ja/buyer.json +61 -0
- package/client/public/locales/ja/campaigns.json +1 -0
- package/client/public/locales/ja/common.json +218 -0
- package/client/public/locales/ja/contentHub.json +266 -0
- package/client/public/locales/ja/creatives.json +79 -0
- package/client/public/locales/ja/dashboard.json +57 -0
- package/client/public/locales/ja/deals.json +886 -0
- package/client/public/locales/ja/dsp.json +131 -0
- package/client/public/locales/ja/inventory.json +201 -0
- package/client/public/locales/ja/lineItems.json +553 -0
- package/client/public/locales/ja/navigation.json +48 -0
- package/client/public/locales/ja/wizard.json +1 -0
- package/client/public/locales/zh/approval.json +144 -0
- package/client/public/locales/zh/buyer.json +61 -0
- package/client/public/locales/zh/campaigns.json +1 -0
- package/client/public/locales/zh/common.json +218 -0
- package/client/public/locales/zh/contentHub.json +266 -0
- package/client/public/locales/zh/creatives.json +79 -0
- package/client/public/locales/zh/dashboard.json +57 -0
- package/client/public/locales/zh/deals.json +886 -0
- package/client/public/locales/zh/dsp.json +131 -0
- package/client/public/locales/zh/inventory.json +201 -0
- package/client/public/locales/zh/lineItems.json +553 -0
- package/client/public/locales/zh/navigation.json +48 -0
- package/client/public/locales/zh/wizard.json +1 -0
- package/client/public/manifest.json +36 -0
- package/client/src/App.tsx +464 -0
- package/client/src/components/app-sidebar.tsx +312 -0
- package/client/src/components/approval/approval-decision-form.test.tsx +294 -0
- package/client/src/components/approval/approval-decision-form.tsx +326 -0
- package/client/src/components/approval/approval-sheet.tsx +631 -0
- package/client/src/components/approval/line-item-details-sheet.tsx +371 -0
- package/client/src/components/approval/otp-verification.test.tsx +337 -0
- package/client/src/components/approval/otp-verification.tsx +180 -0
- package/client/src/components/content-hub/bulk-transcode-dialog.tsx +379 -0
- package/client/src/components/content-hub/content-hub-manager-v2.tsx +574 -0
- package/client/src/components/content-hub/content-hub-manager.tsx +330 -0
- package/client/src/components/content-hub/creative-card.tsx +456 -0
- package/client/src/components/content-hub/creative-detail-sheet.tsx +685 -0
- package/client/src/components/content-hub/creative-filters.tsx +457 -0
- package/client/src/components/content-hub/creative-grid.tsx +329 -0
- package/client/src/components/content-hub/creative-selector.tsx +415 -0
- package/client/src/components/content-hub/creative-upload.tsx +547 -0
- package/client/src/components/content-hub/folder-dialogs.tsx +445 -0
- package/client/src/components/content-hub/folder-list.tsx +280 -0
- package/client/src/components/content-hub/review-dialogs.tsx +268 -0
- package/client/src/components/content-hub/transcode-dialog.tsx +226 -0
- package/client/src/components/creative-library/creative-details-view.tsx +446 -0
- package/client/src/components/creative-library/creative-filters-panel.tsx +203 -0
- package/client/src/components/creative-library/creative-list.tsx +360 -0
- package/client/src/components/creative-library/creative-status-badge.tsx +71 -0
- package/client/src/components/creative-library/folder-card.tsx +78 -0
- package/client/src/components/creative-library/index.ts +27 -0
- package/client/src/components/creative-library/new-creative-card.tsx +211 -0
- package/client/src/components/creative-library/upload-creative-dialog.tsx +261 -0
- package/client/src/components/dashboard-overview.tsx +109 -0
- package/client/src/components/deals/approval-history-panel.test.tsx +240 -0
- package/client/src/components/deals/approval-history-panel.tsx +156 -0
- package/client/src/components/deals/deal-status-badge.tsx +92 -0
- package/client/src/components/deals/import-from-planner-dialog.tsx +399 -0
- package/client/src/components/deals/market-insights-panel.tsx +237 -0
- package/client/src/components/deals/reopen-deal-sheet.tsx +191 -0
- package/client/src/components/deals/request-approval-sheet.test.tsx +323 -0
- package/client/src/components/deals/request-approval-sheet.tsx +136 -0
- package/client/src/components/deals/resend-approval-sheet.tsx +201 -0
- package/client/src/components/direct-campaigns/campaign-card.tsx +283 -0
- package/client/src/components/direct-campaigns/deal-filter-panel.tsx +325 -0
- package/client/src/components/inventory/advanced-filters-panel.tsx +273 -0
- package/client/src/components/inventory/csv-upload-modal.tsx +639 -0
- package/client/src/components/inventory/inventory-availability-view.tsx +486 -0
- package/client/src/components/inventory/inventory-details-sheet.tsx +376 -0
- package/client/src/components/inventory/inventory-map-view.tsx +596 -0
- package/client/src/components/inventory/inventory-settings-menu.tsx +52 -0
- package/client/src/components/language-switcher.tsx +53 -0
- package/client/src/components/line-items/campaign-forecast-panel.tsx +138 -0
- package/client/src/components/line-items/form-insights.tsx +89 -0
- package/client/src/components/line-items/geofencing/LocationCsvUploadDrawer.tsx +100 -0
- package/client/src/components/line-items/geofencing/POIDropdown.tsx +379 -0
- package/client/src/components/line-items/geofencing/SelectedLocationsSidebar.tsx +436 -0
- package/client/src/components/line-items/geofencing/ViewFileLocationDrawer.tsx +199 -0
- package/client/src/components/line-items/geofencing/components/ExistingFilesTab.tsx +268 -0
- package/client/src/components/line-items/geofencing/components/TemplateDownloadSection.tsx +59 -0
- package/client/src/components/line-items/geofencing/components/UploadTab.tsx +215 -0
- package/client/src/components/line-items/geofencing-map.tsx +1270 -0
- package/client/src/components/line-items/inventory-availability-section.tsx +178 -0
- package/client/src/components/line-items/line-item-schedule-manager.tsx +313 -0
- package/client/src/components/line-items/manual-inventory-drawer.tsx +346 -0
- package/client/src/components/line-items/planner-inventory-card.tsx +495 -0
- package/client/src/components/line-items/planner-schedule-grid.tsx +495 -0
- package/client/src/components/line-items/schedule-rule-editor.tsx +649 -0
- package/client/src/components/line-items/schedule-rule-types.ts +122 -0
- package/client/src/components/line-items/steps/creatives-step.tsx +681 -0
- package/client/src/components/line-items/steps/inventory-schedule-step.tsx +1596 -0
- package/client/src/components/line-items/steps/inventory-step.tsx +1533 -0
- package/client/src/components/line-items/steps/line-item-details-step.tsx +916 -0
- package/client/src/components/line-items/steps/schedule-step.tsx +273 -0
- package/client/src/components/line-items/steps/summary-step.tsx +680 -0
- package/client/src/components/line-items/steps/targeting-step.tsx +1708 -0
- package/client/src/components/product-switcher.tsx +105 -0
- package/client/src/components/protected-route.tsx +49 -0
- package/client/src/components/skip-link.tsx +22 -0
- package/client/src/components/stat-card.tsx +53 -0
- package/client/src/components/status-badge.tsx +96 -0
- package/client/src/components/ui/hierarchical-venue-selector.tsx +389 -0
- package/client/src/components/ui/toaster.tsx +111 -0
- package/client/src/contexts/auth-context.tsx +181 -0
- package/client/src/contexts/sidebar-state.tsx +50 -0
- package/client/src/contexts/theme-context.tsx +66 -0
- package/client/src/data/campaign-data.json +107 -0
- package/client/src/data/countries.json +22 -0
- package/client/src/hooks/use-approval.ts +366 -0
- package/client/src/hooks/use-keyboard-shortcuts.ts +74 -0
- package/client/src/hooks/use-media-query.ts +46 -0
- package/client/src/hooks/use-mobile.tsx +19 -0
- package/client/src/hooks/use-page-title.ts +21 -0
- package/client/src/hooks/use-toast.ts +195 -0
- package/client/src/index.css +694 -0
- package/client/src/lib/__tests__/accessibility.test.ts +104 -0
- package/client/src/lib/__tests__/date-utils.test.ts +199 -0
- package/client/src/lib/__tests__/dsp-buyer-api.test.ts +127 -0
- package/client/src/lib/__tests__/dsp-buyer-integration.test.ts +247 -0
- package/client/src/lib/__tests__/storage-utils.test.ts +167 -0
- package/client/src/lib/__tests__/utils.test.ts +57 -0
- package/client/src/lib/accessibility.ts +141 -0
- package/client/src/lib/api-config.ts +9 -0
- package/client/src/lib/auth-service.ts +209 -0
- package/client/src/lib/campaign-creative-api.ts +82 -0
- package/client/src/lib/company-api.ts +61 -0
- package/client/src/lib/content-hub-api.ts +407 -0
- package/client/src/lib/creative-mapper.ts +61 -0
- package/client/src/lib/date-utils.ts +119 -0
- package/client/src/lib/deal-helpers.ts +220 -0
- package/client/src/lib/dsp-buyer-api.ts +196 -0
- package/client/src/lib/geo-import-api.ts +151 -0
- package/client/src/lib/google-poi-api.ts +305 -0
- package/client/src/lib/i18n/__tests__/formatting.test.ts +202 -0
- package/client/src/lib/i18n/formatting.ts +130 -0
- package/client/src/lib/i18n/index.ts +8 -0
- package/client/src/lib/i18n-compat.ts +76 -0
- package/client/src/lib/influence-deals-api.ts +896 -0
- package/client/src/lib/inventory-api.ts +399 -0
- package/client/src/lib/oauth-service.ts +678 -0
- package/client/src/lib/poi-types.ts +75 -0
- package/client/src/lib/queryClient.ts +144 -0
- package/client/src/lib/recommendation-api.ts +380 -0
- package/client/src/lib/storage-utils.ts +104 -0
- package/client/src/lib/tolgee.ts +85 -0
- package/client/src/lib/utils.ts +0 -0
- package/client/src/main.tsx +67 -0
- package/client/src/mapbox-draw-modes.d.ts +32 -0
- package/client/src/pages/all-folders.tsx +203 -0
- package/client/src/pages/auth-callback.tsx +115 -0
- package/client/src/pages/buyer-form.tsx +339 -0
- package/client/src/pages/buyer-list.tsx +622 -0
- package/client/src/pages/content-hub.tsx +1358 -0
- package/client/src/pages/create-deal.tsx +2093 -0
- package/client/src/pages/creative-assignment-page.tsx +548 -0
- package/client/src/pages/creatives.tsx +5 -0
- package/client/src/pages/custom-pois.tsx +425 -0
- package/client/src/pages/dashboard.tsx +615 -0
- package/client/src/pages/deal-history.tsx +434 -0
- package/client/src/pages/deal-line-items.tsx +1703 -0
- package/client/src/pages/demo-status.tsx +113 -0
- package/client/src/pages/direct-campaign-details.tsx +361 -0
- package/client/src/pages/direct-campaigns-new.tsx +824 -0
- package/client/src/pages/dsp-form.tsx +803 -0
- package/client/src/pages/dsp-list.tsx +239 -0
- package/client/src/pages/folder-content.tsx +336 -0
- package/client/src/pages/integrations.tsx +429 -0
- package/client/src/pages/line-item-creatives.tsx +789 -0
- package/client/src/pages/line-item-detail-page.tsx +684 -0
- package/client/src/pages/line-item-form-page.tsx +3261 -0
- package/client/src/pages/line-item-wizard.tsx +1207 -0
- package/client/src/pages/login.tsx +154 -0
- package/client/src/pages/not-found.tsx +23 -0
- package/client/src/pages/proof-of-play.tsx +397 -0
- package/client/src/pages/public-approval.tsx +551 -0
- package/client/src/pages/reports.tsx +231 -0
- package/client/src/pages/settings.tsx +760 -0
- package/client/src/pages/signals.tsx +389 -0
- package/client/src/pages/tags.tsx +318 -0
- package/client/src/pages/test-results.tsx +328 -0
- package/client/src/store/hooks.ts +5 -0
- package/client/src/store/index.ts +15 -0
- package/client/src/store/mapMarkerLocationsSlice.ts +241 -0
- package/client/src/styles/design-tokens.css +324 -0
- package/client/src/test/setup.ts +261 -0
- package/client/src/test/test-utils.tsx +40 -0
- package/client/src/types/approval.ts +221 -0
- package/client/src/types/content-hub.ts +209 -0
- package/client/src/types/geofencing.ts +67 -0
- package/client/src/types/transcoding.ts +140 -0
- package/client/src/vite-env.d.ts +18 -0
- package/components.json +20 -0
- package/creative-api.json +1 -0
- package/docs/AI_REFERENCE.md +459 -0
- package/docs/MWDesign-Prompt.md +132 -0
- package/docs/MWDesign-System.md +344 -0
- package/docs/test-plan.md +277 -0
- package/e2e/AUTONOMOUS-TESTING.md +406 -0
- package/e2e/README.md +219 -0
- package/e2e/autonomous-flow.spec.ts +308 -0
- package/e2e/debug-sso.spec.ts +163 -0
- package/e2e/direct-campaigns.spec.ts +219 -0
- package/e2e/explore-sso.spec.ts +149 -0
- package/e2e/fixtures/auth.ts +26 -0
- package/e2e/fixtures/enhanced-test.ts +331 -0
- package/e2e/pagination.spec.ts +280 -0
- package/e2e/view-toggle.spec.ts +312 -0
- package/generated-icon.png +0 -0
- package/i18next-scanner.config.cjs +46 -0
- package/package.json +141 -0
- package/playwright.config.ts +93 -0
- package/postcss.config.js +6 -0
- package/replit.md +196 -0
- package/screenshot-after-login.png +0 -0
- package/screenshot-contenthub-grid.png +0 -0
- package/screenshot-contenthub-list-fixed.png +0 -0
- package/screenshot-contenthub-list.png +0 -0
- package/screenshot-create-deal.png +0 -0
- package/screenshot-dashboard.png +0 -0
- package/screenshot-deals.png +0 -0
- package/screenshot-login-filled.png +0 -0
- package/screenshot-login.png +0 -0
- package/screenshot.mjs +24 -0
- package/scripts/deploy-stg.sh +185 -0
- package/shared/direct-io-schema.ts +383 -0
- package/shared/schema.ts +439 -0
- package/shared/screen-types.ts +149 -0
- package/springdocDefault.json +1 -0
- package/swagger-ui-bundle.js +2 -0
- package/swagger-ui-init.js +10316 -0
- package/tailwind.config.ts +282 -0
- package/terraform/README.md +306 -0
- package/terraform/cloudfront.tf +289 -0
- package/terraform/ecs.tf +727 -0
- package/terraform/environments/dev.tfvars +59 -0
- package/terraform/environments/production.tfvars +60 -0
- package/terraform/main.tf +47 -0
- package/terraform/outputs.tf +145 -0
- package/terraform/s3.tf +192 -0
- package/terraform/variables.tf +226 -0
- package/terraform/waf.tf +165 -0
- package/terraform-frontend/.terraform.lock.hcl +25 -0
- package/terraform-frontend/README.md +85 -0
- package/terraform-frontend/cloudfront.tf +125 -0
- package/terraform-frontend/main.tf +31 -0
- package/terraform-frontend/outputs.tf +24 -0
- package/terraform-frontend/terraform.tfvars +12 -0
- package/terraform-frontend/variables.tf +53 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +226 -0
- package/vitest.config.ts +56 -0
|
@@ -0,0 +1,1890 @@
|
|
|
1
|
+
import type { Express } from "express";
|
|
2
|
+
import { createServer, type Server } from "http";
|
|
3
|
+
import archiver from "archiver";
|
|
4
|
+
import { storage } from "./storage";
|
|
5
|
+
import {
|
|
6
|
+
insertAdvertiserSchema,
|
|
7
|
+
insertAgencySchema,
|
|
8
|
+
insertBrandSchema,
|
|
9
|
+
insertMediaOwnerSchema,
|
|
10
|
+
insertVenueSchema,
|
|
11
|
+
insertScreenSchema,
|
|
12
|
+
insertDealSchema,
|
|
13
|
+
insertLineItemSchema,
|
|
14
|
+
insertSspPartnerSchema,
|
|
15
|
+
insertDspPartnerSchema,
|
|
16
|
+
insertCreativeSchema,
|
|
17
|
+
insertCreativeFolderSchema,
|
|
18
|
+
insertLineItemCreativeSchema,
|
|
19
|
+
insertSignalSchema,
|
|
20
|
+
insertSignalRuleSchema,
|
|
21
|
+
insertProofOfPlaySchema,
|
|
22
|
+
insertPlaylogUploadSchema,
|
|
23
|
+
insertGeoTargetingRuleSchema,
|
|
24
|
+
} from "@shared/schema";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
|
|
27
|
+
function validateBody<T>(schema: z.ZodSchema<T>, body: unknown): { success: true; data: T } | { success: false; error: string } {
|
|
28
|
+
const result = schema.safeParse(body);
|
|
29
|
+
if (result.success) {
|
|
30
|
+
return { success: true, data: result.data };
|
|
31
|
+
}
|
|
32
|
+
return { success: false, error: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ') };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function registerRoutes(
|
|
36
|
+
httpServer: Server,
|
|
37
|
+
app: Express
|
|
38
|
+
): Promise<Server> {
|
|
39
|
+
// Dashboard
|
|
40
|
+
app.get("/api/dashboard/metrics", async (req, res) => {
|
|
41
|
+
const metrics = await storage.getDashboardMetrics();
|
|
42
|
+
res.json(metrics);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Advertisers
|
|
46
|
+
app.get("/api/advertisers", async (req, res) => {
|
|
47
|
+
const advertisers = await storage.getAdvertisers();
|
|
48
|
+
res.json(advertisers);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.get("/api/advertisers/:id", async (req, res) => {
|
|
52
|
+
const advertiser = await storage.getAdvertiser(req.params.id);
|
|
53
|
+
if (!advertiser) {
|
|
54
|
+
return res.status(404).json({ error: "Advertiser not found" });
|
|
55
|
+
}
|
|
56
|
+
res.json(advertiser);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.post("/api/advertisers", async (req, res) => {
|
|
60
|
+
const validation = validateBody(insertAdvertiserSchema, req.body);
|
|
61
|
+
if (!validation.success) {
|
|
62
|
+
return res.status(400).json({ error: validation.error });
|
|
63
|
+
}
|
|
64
|
+
const advertiser = await storage.createAdvertiser(validation.data);
|
|
65
|
+
res.status(201).json(advertiser);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.patch("/api/advertisers/:id", async (req, res) => {
|
|
69
|
+
const validation = validateBody(insertAdvertiserSchema.partial(), req.body);
|
|
70
|
+
if (!validation.success) {
|
|
71
|
+
return res.status(400).json({ error: validation.error });
|
|
72
|
+
}
|
|
73
|
+
const advertiser = await storage.updateAdvertiser(req.params.id, validation.data);
|
|
74
|
+
if (!advertiser) {
|
|
75
|
+
return res.status(404).json({ error: "Advertiser not found" });
|
|
76
|
+
}
|
|
77
|
+
res.json(advertiser);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
app.delete("/api/advertisers/:id", async (req, res) => {
|
|
81
|
+
const deleted = await storage.deleteAdvertiser(req.params.id);
|
|
82
|
+
if (!deleted) {
|
|
83
|
+
return res.status(404).json({ error: "Advertiser not found" });
|
|
84
|
+
}
|
|
85
|
+
res.status(204).send();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Agencies
|
|
89
|
+
app.get("/api/agencies", async (req, res) => {
|
|
90
|
+
const agencies = await storage.getAgencies();
|
|
91
|
+
res.json(agencies);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
app.get("/api/agencies/:id", async (req, res) => {
|
|
95
|
+
const agency = await storage.getAgency(req.params.id);
|
|
96
|
+
if (!agency) {
|
|
97
|
+
return res.status(404).json({ error: "Agency not found" });
|
|
98
|
+
}
|
|
99
|
+
res.json(agency);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.post("/api/agencies", async (req, res) => {
|
|
103
|
+
const validation = validateBody(insertAgencySchema, req.body);
|
|
104
|
+
if (!validation.success) {
|
|
105
|
+
return res.status(400).json({ error: validation.error });
|
|
106
|
+
}
|
|
107
|
+
const agency = await storage.createAgency(validation.data);
|
|
108
|
+
res.status(201).json(agency);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
app.patch("/api/agencies/:id", async (req, res) => {
|
|
112
|
+
const validation = validateBody(insertAgencySchema.partial(), req.body);
|
|
113
|
+
if (!validation.success) {
|
|
114
|
+
return res.status(400).json({ error: validation.error });
|
|
115
|
+
}
|
|
116
|
+
const agency = await storage.updateAgency(req.params.id, validation.data);
|
|
117
|
+
if (!agency) {
|
|
118
|
+
return res.status(404).json({ error: "Agency not found" });
|
|
119
|
+
}
|
|
120
|
+
res.json(agency);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.delete("/api/agencies/:id", async (req, res) => {
|
|
124
|
+
const deleted = await storage.deleteAgency(req.params.id);
|
|
125
|
+
if (!deleted) {
|
|
126
|
+
return res.status(404).json({ error: "Agency not found" });
|
|
127
|
+
}
|
|
128
|
+
res.status(204).send();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Brands
|
|
132
|
+
app.get("/api/brands", async (req, res) => {
|
|
133
|
+
const brands = await storage.getBrands();
|
|
134
|
+
res.json(brands);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.get("/api/brands/:id", async (req, res) => {
|
|
138
|
+
const brand = await storage.getBrand(req.params.id);
|
|
139
|
+
if (!brand) {
|
|
140
|
+
return res.status(404).json({ error: "Brand not found" });
|
|
141
|
+
}
|
|
142
|
+
res.json(brand);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
app.post("/api/brands", async (req, res) => {
|
|
146
|
+
const validation = validateBody(insertBrandSchema, req.body);
|
|
147
|
+
if (!validation.success) {
|
|
148
|
+
return res.status(400).json({ error: validation.error });
|
|
149
|
+
}
|
|
150
|
+
const brand = await storage.createBrand(validation.data);
|
|
151
|
+
res.status(201).json(brand);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
app.patch("/api/brands/:id", async (req, res) => {
|
|
155
|
+
const validation = validateBody(insertBrandSchema.partial(), req.body);
|
|
156
|
+
if (!validation.success) {
|
|
157
|
+
return res.status(400).json({ error: validation.error });
|
|
158
|
+
}
|
|
159
|
+
const brand = await storage.updateBrand(req.params.id, validation.data);
|
|
160
|
+
if (!brand) {
|
|
161
|
+
return res.status(404).json({ error: "Brand not found" });
|
|
162
|
+
}
|
|
163
|
+
res.json(brand);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
app.delete("/api/brands/:id", async (req, res) => {
|
|
167
|
+
const deleted = await storage.deleteBrand(req.params.id);
|
|
168
|
+
if (!deleted) {
|
|
169
|
+
return res.status(404).json({ error: "Brand not found" });
|
|
170
|
+
}
|
|
171
|
+
res.status(204).send();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Media Owners
|
|
175
|
+
app.get("/api/media-owners", async (req, res) => {
|
|
176
|
+
const mediaOwners = await storage.getMediaOwners();
|
|
177
|
+
res.json(mediaOwners);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
app.get("/api/media-owners/:id", async (req, res) => {
|
|
181
|
+
const mediaOwner = await storage.getMediaOwner(req.params.id);
|
|
182
|
+
if (!mediaOwner) {
|
|
183
|
+
return res.status(404).json({ error: "Media owner not found" });
|
|
184
|
+
}
|
|
185
|
+
res.json(mediaOwner);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
app.post("/api/media-owners", async (req, res) => {
|
|
189
|
+
const validation = validateBody(insertMediaOwnerSchema, req.body);
|
|
190
|
+
if (!validation.success) {
|
|
191
|
+
return res.status(400).json({ error: validation.error });
|
|
192
|
+
}
|
|
193
|
+
const mediaOwner = await storage.createMediaOwner(validation.data);
|
|
194
|
+
res.status(201).json(mediaOwner);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
app.patch("/api/media-owners/:id", async (req, res) => {
|
|
198
|
+
const validation = validateBody(insertMediaOwnerSchema.partial(), req.body);
|
|
199
|
+
if (!validation.success) {
|
|
200
|
+
return res.status(400).json({ error: validation.error });
|
|
201
|
+
}
|
|
202
|
+
const mediaOwner = await storage.updateMediaOwner(req.params.id, validation.data);
|
|
203
|
+
if (!mediaOwner) {
|
|
204
|
+
return res.status(404).json({ error: "Media owner not found" });
|
|
205
|
+
}
|
|
206
|
+
res.json(mediaOwner);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
app.delete("/api/media-owners/:id", async (req, res) => {
|
|
210
|
+
const deleted = await storage.deleteMediaOwner(req.params.id);
|
|
211
|
+
if (!deleted) {
|
|
212
|
+
return res.status(404).json({ error: "Media owner not found" });
|
|
213
|
+
}
|
|
214
|
+
res.status(204).send();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Venues
|
|
218
|
+
app.get("/api/venues", async (req, res) => {
|
|
219
|
+
const venues = await storage.getVenues();
|
|
220
|
+
res.json(venues);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
app.get("/api/venues/:id", async (req, res) => {
|
|
224
|
+
const venue = await storage.getVenue(req.params.id);
|
|
225
|
+
if (!venue) {
|
|
226
|
+
return res.status(404).json({ error: "Venue not found" });
|
|
227
|
+
}
|
|
228
|
+
res.json(venue);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
app.post("/api/venues", async (req, res) => {
|
|
232
|
+
const validation = validateBody(insertVenueSchema, req.body);
|
|
233
|
+
if (!validation.success) {
|
|
234
|
+
return res.status(400).json({ error: validation.error });
|
|
235
|
+
}
|
|
236
|
+
const venue = await storage.createVenue(validation.data);
|
|
237
|
+
res.status(201).json(venue);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
app.patch("/api/venues/:id", async (req, res) => {
|
|
241
|
+
const validation = validateBody(insertVenueSchema.partial(), req.body);
|
|
242
|
+
if (!validation.success) {
|
|
243
|
+
return res.status(400).json({ error: validation.error });
|
|
244
|
+
}
|
|
245
|
+
const venue = await storage.updateVenue(req.params.id, validation.data);
|
|
246
|
+
if (!venue) {
|
|
247
|
+
return res.status(404).json({ error: "Venue not found" });
|
|
248
|
+
}
|
|
249
|
+
res.json(venue);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
app.delete("/api/venues/:id", async (req, res) => {
|
|
253
|
+
const deleted = await storage.deleteVenue(req.params.id);
|
|
254
|
+
if (!deleted) {
|
|
255
|
+
return res.status(404).json({ error: "Venue not found" });
|
|
256
|
+
}
|
|
257
|
+
res.status(204).send();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Screens
|
|
261
|
+
app.get("/api/screens", async (req, res) => {
|
|
262
|
+
const { country, sspId, mediaOwnerId } = req.query;
|
|
263
|
+
let screens = await storage.getScreens();
|
|
264
|
+
|
|
265
|
+
// Apply filters if provided
|
|
266
|
+
if (country) {
|
|
267
|
+
const countries = (country as string).split(',').map(c => c.trim());
|
|
268
|
+
screens = screens.filter(s => s.country && countries.includes(s.country));
|
|
269
|
+
}
|
|
270
|
+
if (sspId) {
|
|
271
|
+
screens = screens.filter(s => s.sspId === sspId);
|
|
272
|
+
}
|
|
273
|
+
if (mediaOwnerId) {
|
|
274
|
+
screens = screens.filter(s => s.mediaOwnerId === mediaOwnerId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
res.json(screens);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
app.get("/api/screens/:id", async (req, res) => {
|
|
281
|
+
const screen = await storage.getScreen(req.params.id);
|
|
282
|
+
if (!screen) {
|
|
283
|
+
return res.status(404).json({ error: "Screen not found" });
|
|
284
|
+
}
|
|
285
|
+
res.json(screen);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
app.post("/api/screens", async (req, res) => {
|
|
289
|
+
const validation = validateBody(insertScreenSchema, req.body);
|
|
290
|
+
if (!validation.success) {
|
|
291
|
+
return res.status(400).json({ error: validation.error });
|
|
292
|
+
}
|
|
293
|
+
if (validation.data.venueId) {
|
|
294
|
+
const venue = await storage.getVenue(validation.data.venueId);
|
|
295
|
+
if (!venue) {
|
|
296
|
+
return res.status(400).json({ error: "Referenced venue does not exist" });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const screen = await storage.createScreen(validation.data);
|
|
300
|
+
res.status(201).json(screen);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
app.patch("/api/screens/:id", async (req, res) => {
|
|
304
|
+
const validation = validateBody(insertScreenSchema.partial(), req.body);
|
|
305
|
+
if (!validation.success) {
|
|
306
|
+
return res.status(400).json({ error: validation.error });
|
|
307
|
+
}
|
|
308
|
+
if (validation.data.venueId) {
|
|
309
|
+
const venue = await storage.getVenue(validation.data.venueId);
|
|
310
|
+
if (!venue) {
|
|
311
|
+
return res.status(400).json({ error: "Referenced venue does not exist" });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const screen = await storage.updateScreen(req.params.id, validation.data);
|
|
315
|
+
if (!screen) {
|
|
316
|
+
return res.status(404).json({ error: "Screen not found" });
|
|
317
|
+
}
|
|
318
|
+
res.json(screen);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
app.delete("/api/screens/:id", async (req, res) => {
|
|
322
|
+
const deleted = await storage.deleteScreen(req.params.id);
|
|
323
|
+
if (!deleted) {
|
|
324
|
+
return res.status(404).json({ error: "Screen not found" });
|
|
325
|
+
}
|
|
326
|
+
res.status(204).send();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
app.post("/api/screens/:id/sync-playlogs", async (req, res) => {
|
|
330
|
+
const screen = await storage.getScreen(req.params.id);
|
|
331
|
+
if (!screen) {
|
|
332
|
+
return res.status(404).json({ error: "Screen not found" });
|
|
333
|
+
}
|
|
334
|
+
const playerStatuses = await storage.getPlayerStatuses([req.params.id]);
|
|
335
|
+
const playerStatus = playerStatuses[0];
|
|
336
|
+
if (!playerStatus || playerStatus.status !== "online") {
|
|
337
|
+
return res.status(400).json({ error: "Player is not online. Cannot sync playlogs." });
|
|
338
|
+
}
|
|
339
|
+
const now = new Date();
|
|
340
|
+
const syncedRecords = [];
|
|
341
|
+
for (let i = 0; i < 3; i++) {
|
|
342
|
+
const startTime = new Date(now.getTime() - (i + 1) * 60 * 1000);
|
|
343
|
+
const endTime = new Date(startTime.getTime() + 15000);
|
|
344
|
+
const record = await storage.createProofOfPlayRecord({
|
|
345
|
+
screenId: screen.id,
|
|
346
|
+
playerId: playerStatus.playerId,
|
|
347
|
+
creativeId: null,
|
|
348
|
+
dealId: null,
|
|
349
|
+
lineItemId: null,
|
|
350
|
+
startTimestamp: startTime.toISOString(),
|
|
351
|
+
endTimestamp: endTime.toISOString(),
|
|
352
|
+
durationSeconds: 15,
|
|
353
|
+
impressionCount: 1,
|
|
354
|
+
mediaFileUrl: null,
|
|
355
|
+
proofImageUrl: null,
|
|
356
|
+
proofVideoUrl: null,
|
|
357
|
+
source: "cms",
|
|
358
|
+
status: "processed",
|
|
359
|
+
});
|
|
360
|
+
syncedRecords.push(record);
|
|
361
|
+
}
|
|
362
|
+
res.json({ success: true, message: `Synced ${syncedRecords.length} playlog records from CMS.`, records: syncedRecords });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Player Statuses
|
|
366
|
+
app.get("/api/player-statuses", async (req, res) => {
|
|
367
|
+
const { screenIds } = req.query;
|
|
368
|
+
const ids = screenIds ? (screenIds as string).split(",") : undefined;
|
|
369
|
+
const statuses = await storage.getPlayerStatuses(ids);
|
|
370
|
+
res.json(statuses);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Deals
|
|
374
|
+
app.get("/api/deals", async (req, res) => {
|
|
375
|
+
const deals = await storage.getDeals();
|
|
376
|
+
res.json(deals);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
app.get("/api/deals/:id", async (req, res) => {
|
|
380
|
+
const deal = await storage.getDeal(req.params.id);
|
|
381
|
+
if (!deal) {
|
|
382
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
383
|
+
}
|
|
384
|
+
res.json(deal);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
app.post("/api/deals", async (req, res) => {
|
|
388
|
+
const validation = validateBody(insertDealSchema, req.body);
|
|
389
|
+
if (!validation.success) {
|
|
390
|
+
return res.status(400).json({ error: validation.error });
|
|
391
|
+
}
|
|
392
|
+
if (validation.data.advertiserId) {
|
|
393
|
+
const advertiser = await storage.getAdvertiser(validation.data.advertiserId);
|
|
394
|
+
if (!advertiser) {
|
|
395
|
+
return res.status(400).json({ error: "Referenced advertiser does not exist" });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const now = new Date().toISOString();
|
|
399
|
+
const changedBy = req.body.createdBy || "System";
|
|
400
|
+
validation.data.createdBy = changedBy;
|
|
401
|
+
validation.data.createdAt = now;
|
|
402
|
+
const deal = await storage.createDeal(validation.data);
|
|
403
|
+
|
|
404
|
+
await storage.createChangeLog({
|
|
405
|
+
entityType: "deal",
|
|
406
|
+
entityId: deal.id,
|
|
407
|
+
action: "created",
|
|
408
|
+
changedBy,
|
|
409
|
+
changedAt: now,
|
|
410
|
+
description: `Deal "${deal.name}" created`,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
res.status(201).json(deal);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
app.patch("/api/deals/:id", async (req, res) => {
|
|
417
|
+
const validation = validateBody(insertDealSchema.partial(), req.body);
|
|
418
|
+
if (!validation.success) {
|
|
419
|
+
return res.status(400).json({ error: validation.error });
|
|
420
|
+
}
|
|
421
|
+
if (validation.data.advertiserId) {
|
|
422
|
+
const advertiser = await storage.getAdvertiser(validation.data.advertiserId);
|
|
423
|
+
if (!advertiser) {
|
|
424
|
+
return res.status(400).json({ error: "Referenced advertiser does not exist" });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const existing = await storage.getDeal(req.params.id);
|
|
429
|
+
if (!existing) {
|
|
430
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const changes: Record<string, { old: unknown; new: unknown }> = {};
|
|
434
|
+
for (const [key, value] of Object.entries(validation.data)) {
|
|
435
|
+
if (key !== "updatedAt" && (existing as any)[key] !== value) {
|
|
436
|
+
changes[key] = { old: (existing as any)[key], new: value };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
validation.data.updatedAt = new Date().toISOString();
|
|
441
|
+
const deal = await storage.updateDeal(req.params.id, validation.data);
|
|
442
|
+
|
|
443
|
+
const changedBy = req.body.changedBy || "System";
|
|
444
|
+
if (Object.keys(changes).length > 0) {
|
|
445
|
+
await storage.createChangeLog({
|
|
446
|
+
entityType: "deal",
|
|
447
|
+
entityId: req.params.id,
|
|
448
|
+
action: "updated",
|
|
449
|
+
changedBy,
|
|
450
|
+
changedAt: validation.data.updatedAt,
|
|
451
|
+
changes,
|
|
452
|
+
description: `Deal "${deal?.name}" updated`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
res.json(deal);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
app.delete("/api/deals/:id", async (req, res) => {
|
|
460
|
+
const deleted = await storage.deleteDeal(req.params.id);
|
|
461
|
+
if (!deleted) {
|
|
462
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
463
|
+
}
|
|
464
|
+
res.status(204).send();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Duplicate deal
|
|
468
|
+
app.post("/api/deals/:id/duplicate", async (req, res) => {
|
|
469
|
+
const deal = await storage.getDeal(req.params.id);
|
|
470
|
+
if (!deal) {
|
|
471
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const now = new Date().toISOString();
|
|
475
|
+
const { id, createdAt, updatedAt, ...dealData } = deal;
|
|
476
|
+
const duplicated = await storage.createDeal({
|
|
477
|
+
...dealData,
|
|
478
|
+
name: `${deal.name} (Copy)`,
|
|
479
|
+
status: "paused",
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
res.status(201).json(duplicated);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Line Items (now directly under Deals - 4-level hierarchy)
|
|
486
|
+
app.get("/api/line-items", async (req, res) => {
|
|
487
|
+
const items = await storage.getLineItems();
|
|
488
|
+
res.json(items);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
app.get("/api/line-items/:id", async (req, res) => {
|
|
492
|
+
const item = await storage.getLineItem(req.params.id);
|
|
493
|
+
if (!item) {
|
|
494
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
495
|
+
}
|
|
496
|
+
res.json(item);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
app.post("/api/line-items", async (req, res) => {
|
|
500
|
+
const validation = validateBody(insertLineItemSchema, req.body);
|
|
501
|
+
if (!validation.success) {
|
|
502
|
+
return res.status(400).json({ error: validation.error });
|
|
503
|
+
}
|
|
504
|
+
if (validation.data.dealId) {
|
|
505
|
+
const deal = await storage.getDeal(validation.data.dealId);
|
|
506
|
+
if (!deal) {
|
|
507
|
+
return res.status(400).json({ error: "Referenced deal does not exist" });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (validation.data.trafficAllocation != null) {
|
|
511
|
+
if (validation.data.trafficAllocation < 0 || validation.data.trafficAllocation > 100) {
|
|
512
|
+
return res.status(400).json({ error: "Traffic allocation must be between 0 and 100" });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (validation.data.priority != null) {
|
|
516
|
+
if (validation.data.priority < 1 || validation.data.priority > 10) {
|
|
517
|
+
return res.status(400).json({ error: "Priority must be between 1 and 10" });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const now = new Date().toISOString();
|
|
521
|
+
const changedBy = req.body.createdBy || "System";
|
|
522
|
+
validation.data.createdBy = changedBy;
|
|
523
|
+
validation.data.createdAt = now;
|
|
524
|
+
const item = await storage.createLineItem(validation.data);
|
|
525
|
+
|
|
526
|
+
await storage.createChangeLog({
|
|
527
|
+
entityType: "line_item",
|
|
528
|
+
entityId: item.id,
|
|
529
|
+
action: "created",
|
|
530
|
+
changedBy,
|
|
531
|
+
changedAt: now,
|
|
532
|
+
description: `Line Item "${item.name}" created`,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
res.status(201).json(item);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
app.patch("/api/line-items/:id", async (req, res) => {
|
|
539
|
+
const validation = validateBody(insertLineItemSchema.partial(), req.body);
|
|
540
|
+
if (!validation.success) {
|
|
541
|
+
return res.status(400).json({ error: validation.error });
|
|
542
|
+
}
|
|
543
|
+
if (validation.data.dealId) {
|
|
544
|
+
const deal = await storage.getDeal(validation.data.dealId);
|
|
545
|
+
if (!deal) {
|
|
546
|
+
return res.status(400).json({ error: "Referenced deal does not exist" });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (validation.data.trafficAllocation != null) {
|
|
550
|
+
if (validation.data.trafficAllocation < 0 || validation.data.trafficAllocation > 100) {
|
|
551
|
+
return res.status(400).json({ error: "Traffic allocation must be between 0 and 100" });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (validation.data.priority != null) {
|
|
555
|
+
if (validation.data.priority < 1 || validation.data.priority > 10) {
|
|
556
|
+
return res.status(400).json({ error: "Priority must be between 1 and 10" });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const existing = await storage.getLineItem(req.params.id);
|
|
561
|
+
if (!existing) {
|
|
562
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const changes: Record<string, { old: unknown; new: unknown }> = {};
|
|
566
|
+
for (const [key, value] of Object.entries(validation.data)) {
|
|
567
|
+
if (key !== "updatedAt" && (existing as any)[key] !== value) {
|
|
568
|
+
changes[key] = { old: (existing as any)[key], new: value };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
validation.data.updatedAt = new Date().toISOString();
|
|
573
|
+
const item = await storage.updateLineItem(req.params.id, validation.data);
|
|
574
|
+
|
|
575
|
+
const changedBy = req.body.changedBy || "System";
|
|
576
|
+
if (Object.keys(changes).length > 0) {
|
|
577
|
+
await storage.createChangeLog({
|
|
578
|
+
entityType: "line_item",
|
|
579
|
+
entityId: req.params.id,
|
|
580
|
+
action: "updated",
|
|
581
|
+
changedBy,
|
|
582
|
+
changedAt: validation.data.updatedAt,
|
|
583
|
+
changes,
|
|
584
|
+
description: `Line Item "${item?.name}" updated`,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
res.json(item);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
app.delete("/api/line-items/:id", async (req, res) => {
|
|
592
|
+
const deleted = await storage.deleteLineItem(req.params.id);
|
|
593
|
+
if (!deleted) {
|
|
594
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
595
|
+
}
|
|
596
|
+
res.status(204).send();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Duplicate line item
|
|
600
|
+
app.post("/api/line-items/:id/duplicate", async (req, res) => {
|
|
601
|
+
const item = await storage.getLineItem(req.params.id);
|
|
602
|
+
if (!item) {
|
|
603
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const duplicated = await storage.createLineItem({
|
|
607
|
+
name: `${item.name} (Copy)`,
|
|
608
|
+
dealId: item.dealId,
|
|
609
|
+
mediaOwnerId: item.mediaOwnerId,
|
|
610
|
+
status: "paused",
|
|
611
|
+
startDate: item.startDate,
|
|
612
|
+
endDate: item.endDate,
|
|
613
|
+
budget: item.budget,
|
|
614
|
+
cpm: item.cpm,
|
|
615
|
+
priority: item.priority,
|
|
616
|
+
trafficAllocation: item.trafficAllocation,
|
|
617
|
+
targetImpressions: item.targetImpressions,
|
|
618
|
+
deliveredImpressions: 0,
|
|
619
|
+
creativeType: item.creativeType,
|
|
620
|
+
creativeDuration: item.creativeDuration,
|
|
621
|
+
floorRateType: item.floorRateType,
|
|
622
|
+
floorRate: item.floorRate,
|
|
623
|
+
pushToDsp: item.pushToDsp,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
res.status(201).json(duplicated);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// SSP Partners
|
|
630
|
+
app.get("/api/ssp-partners", async (req, res) => {
|
|
631
|
+
const partners = await storage.getSspPartners();
|
|
632
|
+
res.json(partners);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
app.get("/api/ssp-partners/:id", async (req, res) => {
|
|
636
|
+
const partner = await storage.getSspPartner(req.params.id);
|
|
637
|
+
if (!partner) {
|
|
638
|
+
return res.status(404).json({ error: "SSP partner not found" });
|
|
639
|
+
}
|
|
640
|
+
res.json(partner);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
app.post("/api/ssp-partners", async (req, res) => {
|
|
644
|
+
const validation = validateBody(insertSspPartnerSchema, req.body);
|
|
645
|
+
if (!validation.success) {
|
|
646
|
+
return res.status(400).json({ error: validation.error });
|
|
647
|
+
}
|
|
648
|
+
const partner = await storage.createSspPartner(validation.data);
|
|
649
|
+
res.status(201).json(partner);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
app.patch("/api/ssp-partners/:id", async (req, res) => {
|
|
653
|
+
const validation = validateBody(insertSspPartnerSchema.partial(), req.body);
|
|
654
|
+
if (!validation.success) {
|
|
655
|
+
return res.status(400).json({ error: validation.error });
|
|
656
|
+
}
|
|
657
|
+
const partner = await storage.updateSspPartner(req.params.id, validation.data);
|
|
658
|
+
if (!partner) {
|
|
659
|
+
return res.status(404).json({ error: "SSP partner not found" });
|
|
660
|
+
}
|
|
661
|
+
res.json(partner);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
app.delete("/api/ssp-partners/:id", async (req, res) => {
|
|
665
|
+
const deleted = await storage.deleteSspPartner(req.params.id);
|
|
666
|
+
if (!deleted) {
|
|
667
|
+
return res.status(404).json({ error: "SSP partner not found" });
|
|
668
|
+
}
|
|
669
|
+
res.status(204).send();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// DSP Partners
|
|
673
|
+
app.get("/api/dsp-partners", async (req, res) => {
|
|
674
|
+
const partners = await storage.getDspPartners();
|
|
675
|
+
res.json(partners);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
app.get("/api/dsp-partners/:id", async (req, res) => {
|
|
679
|
+
const partner = await storage.getDspPartner(req.params.id);
|
|
680
|
+
if (!partner) {
|
|
681
|
+
return res.status(404).json({ error: "DSP partner not found" });
|
|
682
|
+
}
|
|
683
|
+
res.json(partner);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
app.post("/api/dsp-partners", async (req, res) => {
|
|
687
|
+
const validation = validateBody(insertDspPartnerSchema, req.body);
|
|
688
|
+
if (!validation.success) {
|
|
689
|
+
return res.status(400).json({ error: validation.error });
|
|
690
|
+
}
|
|
691
|
+
const partner = await storage.createDspPartner(validation.data);
|
|
692
|
+
res.status(201).json(partner);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
app.patch("/api/dsp-partners/:id", async (req, res) => {
|
|
696
|
+
const validation = validateBody(insertDspPartnerSchema.partial(), req.body);
|
|
697
|
+
if (!validation.success) {
|
|
698
|
+
return res.status(400).json({ error: validation.error });
|
|
699
|
+
}
|
|
700
|
+
const partner = await storage.updateDspPartner(req.params.id, validation.data);
|
|
701
|
+
if (!partner) {
|
|
702
|
+
return res.status(404).json({ error: "DSP partner not found" });
|
|
703
|
+
}
|
|
704
|
+
res.json(partner);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
app.delete("/api/dsp-partners/:id", async (req, res) => {
|
|
708
|
+
const deleted = await storage.deleteDspPartner(req.params.id);
|
|
709
|
+
if (!deleted) {
|
|
710
|
+
return res.status(404).json({ error: "DSP partner not found" });
|
|
711
|
+
}
|
|
712
|
+
res.status(204).send();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Traffic Allocation Endpoints
|
|
716
|
+
app.get("/api/deals/:id/traffic-summary", async (req, res) => {
|
|
717
|
+
const deal = await storage.getDeal(req.params.id);
|
|
718
|
+
if (!deal) {
|
|
719
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
720
|
+
}
|
|
721
|
+
const summary = await storage.getDealTrafficSummary(req.params.id);
|
|
722
|
+
res.json(summary);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Get line items for a deal (4-level hierarchy)
|
|
726
|
+
app.get("/api/deals/:id/line-items", async (req, res) => {
|
|
727
|
+
const deal = await storage.getDeal(req.params.id);
|
|
728
|
+
if (!deal) {
|
|
729
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
730
|
+
}
|
|
731
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.id);
|
|
732
|
+
res.json(lineItems);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// Request Acceptance - Push deal to DSP
|
|
736
|
+
app.post("/api/deals/:id/request-acceptance", async (req, res) => {
|
|
737
|
+
const deal = await storage.getDeal(req.params.id);
|
|
738
|
+
if (!deal) {
|
|
739
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Validate deal is programmatic (not traditional)
|
|
743
|
+
if (deal.dealType === "traditional") {
|
|
744
|
+
return res.status(400).json({ error: "Request Acceptance is only available for programmatic deals" });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Check that deal has at least one line item
|
|
748
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.id);
|
|
749
|
+
if (lineItems.length === 0) {
|
|
750
|
+
return res.status(400).json({ error: "At least one line item is required to request acceptance" });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// In production, this would push to the DSP's API
|
|
754
|
+
// For now, we'll simulate the request by updating the deal
|
|
755
|
+
// source indicates it was pushed via Influence SSP
|
|
756
|
+
const updatedDeal = await storage.updateDeal(req.params.id, {
|
|
757
|
+
source: "influence_ssp",
|
|
758
|
+
externalDealId: `DEAL-${Date.now()}`, // Simulated deal ID from SSP
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
res.json({
|
|
762
|
+
success: true,
|
|
763
|
+
message: "Deal has been pushed to DSP for acceptance. Awaiting DSP response.",
|
|
764
|
+
deal: updatedDeal,
|
|
765
|
+
lineItemCount: lineItems.length,
|
|
766
|
+
dspId: deal.dspId || "dsp-activate-default",
|
|
767
|
+
dealId: updatedDeal?.externalDealId,
|
|
768
|
+
status: "pending_acceptance",
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Creative Folders
|
|
773
|
+
app.get("/api/creative-folders", async (req, res) => {
|
|
774
|
+
const folders = await storage.getCreativeFolders();
|
|
775
|
+
res.json(folders);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
app.post("/api/creative-folders", async (req, res) => {
|
|
779
|
+
const validation = validateBody(insertCreativeFolderSchema, req.body);
|
|
780
|
+
if (!validation.success) {
|
|
781
|
+
return res.status(400).json({ error: validation.error });
|
|
782
|
+
}
|
|
783
|
+
const folder = await storage.createCreativeFolder(validation.data);
|
|
784
|
+
res.status(201).json(folder);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
app.delete("/api/creative-folders/:id", async (req, res) => {
|
|
788
|
+
const deleted = await storage.deleteCreativeFolder(req.params.id);
|
|
789
|
+
if (!deleted) {
|
|
790
|
+
return res.status(404).json({ error: "Creative folder not found" });
|
|
791
|
+
}
|
|
792
|
+
res.status(204).send();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// Creatives
|
|
796
|
+
app.get("/api/creatives", async (req, res) => {
|
|
797
|
+
const { folderId, type, status, search } = req.query;
|
|
798
|
+
const filters: { folderId?: string; type?: string; status?: string; search?: string } = {};
|
|
799
|
+
|
|
800
|
+
if (folderId) filters.folderId = folderId as string;
|
|
801
|
+
if (type) filters.type = type as string;
|
|
802
|
+
if (status) filters.status = status as string;
|
|
803
|
+
if (search) filters.search = search as string;
|
|
804
|
+
|
|
805
|
+
const creatives = await storage.getCreatives(Object.keys(filters).length > 0 ? filters : undefined);
|
|
806
|
+
res.json(creatives);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Get assignable creatives (only Tier 1 approved) - must come before :id route
|
|
810
|
+
app.get("/api/creatives/assignable", async (req, res) => {
|
|
811
|
+
const creatives = await storage.getAssignableCreatives();
|
|
812
|
+
res.json(creatives);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
app.get("/api/creatives/:id", async (req, res) => {
|
|
816
|
+
const creative = await storage.getCreative(req.params.id);
|
|
817
|
+
if (!creative) {
|
|
818
|
+
return res.status(404).json({ error: "Creative not found" });
|
|
819
|
+
}
|
|
820
|
+
res.json(creative);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
app.post("/api/creatives", async (req, res) => {
|
|
824
|
+
const validation = validateBody(insertCreativeSchema, req.body);
|
|
825
|
+
if (!validation.success) {
|
|
826
|
+
return res.status(400).json({ error: validation.error });
|
|
827
|
+
}
|
|
828
|
+
if (validation.data.folderId) {
|
|
829
|
+
const folder = await storage.getCreativeFolder(validation.data.folderId);
|
|
830
|
+
if (!folder) {
|
|
831
|
+
return res.status(400).json({ error: "Referenced folder does not exist" });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const creative = await storage.createCreative(validation.data);
|
|
835
|
+
res.status(201).json(creative);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
app.patch("/api/creatives/:id", async (req, res) => {
|
|
839
|
+
const validation = validateBody(insertCreativeSchema.partial(), req.body);
|
|
840
|
+
if (!validation.success) {
|
|
841
|
+
return res.status(400).json({ error: validation.error });
|
|
842
|
+
}
|
|
843
|
+
if (validation.data.folderId) {
|
|
844
|
+
const folder = await storage.getCreativeFolder(validation.data.folderId);
|
|
845
|
+
if (!folder) {
|
|
846
|
+
return res.status(400).json({ error: "Referenced folder does not exist" });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const creative = await storage.updateCreative(req.params.id, validation.data);
|
|
850
|
+
if (!creative) {
|
|
851
|
+
return res.status(404).json({ error: "Creative not found" });
|
|
852
|
+
}
|
|
853
|
+
res.json(creative);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
app.delete("/api/creatives/:id", async (req, res) => {
|
|
857
|
+
const deleted = await storage.deleteCreative(req.params.id);
|
|
858
|
+
if (!deleted) {
|
|
859
|
+
return res.status(404).json({ error: "Creative not found" });
|
|
860
|
+
}
|
|
861
|
+
res.status(204).send();
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Line Item Creative Assignments (Tier 2 approval workflow)
|
|
865
|
+
// Get all line item creative assignments
|
|
866
|
+
app.get("/api/line-item-creatives", async (req, res) => {
|
|
867
|
+
const assignments = await storage.getAllLineItemCreatives();
|
|
868
|
+
res.json(assignments);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Get creatives assigned to a line item
|
|
872
|
+
app.get("/api/line-items/:id/creatives", async (req, res) => {
|
|
873
|
+
const lineItem = await storage.getLineItem(req.params.id);
|
|
874
|
+
if (!lineItem) {
|
|
875
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
876
|
+
}
|
|
877
|
+
const assignments = await storage.getLineItemCreativeWithDetails(req.params.id);
|
|
878
|
+
res.json(assignments);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Assign a creative to a line item
|
|
882
|
+
app.post("/api/line-items/:id/creatives", async (req, res) => {
|
|
883
|
+
const lineItem = await storage.getLineItem(req.params.id);
|
|
884
|
+
if (!lineItem) {
|
|
885
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const { creativeId, displayOrder, weight } = req.body;
|
|
889
|
+
if (!creativeId) {
|
|
890
|
+
return res.status(400).json({ error: "creativeId is required" });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Validate creative exists and is accepted (Tier 1 approved)
|
|
894
|
+
const creative = await storage.getCreative(creativeId);
|
|
895
|
+
if (!creative) {
|
|
896
|
+
return res.status(400).json({ error: "Creative not found" });
|
|
897
|
+
}
|
|
898
|
+
if (creative.status !== "accepted") {
|
|
899
|
+
return res.status(400).json({
|
|
900
|
+
error: "Only Tier 1 approved (Accepted) creatives can be assigned to line items"
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Check if already assigned
|
|
905
|
+
const existingAssignments = await storage.getLineItemCreatives(req.params.id);
|
|
906
|
+
const alreadyAssigned = existingAssignments.some(a => a.creativeId === creativeId);
|
|
907
|
+
if (alreadyAssigned) {
|
|
908
|
+
return res.status(400).json({ error: "Creative is already assigned to this line item" });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const assignment = await storage.assignCreativeToLineItem({
|
|
912
|
+
lineItemId: req.params.id,
|
|
913
|
+
creativeId,
|
|
914
|
+
tier2Status: "pending",
|
|
915
|
+
assignedAt: new Date().toISOString(),
|
|
916
|
+
displayOrder: displayOrder ?? existingAssignments.length,
|
|
917
|
+
weight: weight ?? 100,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
res.status(201).json(assignment);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// Remove a creative from a line item
|
|
924
|
+
app.delete("/api/line-items/:lineItemId/creatives/:creativeId", async (req, res) => {
|
|
925
|
+
const removed = await storage.removeCreativeFromLineItem(
|
|
926
|
+
req.params.lineItemId,
|
|
927
|
+
req.params.creativeId
|
|
928
|
+
);
|
|
929
|
+
if (!removed) {
|
|
930
|
+
return res.status(404).json({ error: "Creative assignment not found" });
|
|
931
|
+
}
|
|
932
|
+
res.status(204).send();
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// Get a single creative assignment by ID
|
|
936
|
+
app.get("/api/line-item-creatives/:id", async (req, res) => {
|
|
937
|
+
const assignment = await storage.getLineItemCreativeById(req.params.id);
|
|
938
|
+
if (!assignment) {
|
|
939
|
+
return res.status(404).json({ error: "Creative assignment not found" });
|
|
940
|
+
}
|
|
941
|
+
res.json(assignment);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// Update a creative assignment (Tier 2 approval)
|
|
945
|
+
app.patch("/api/line-item-creatives/:id", async (req, res) => {
|
|
946
|
+
const validation = validateBody(insertLineItemCreativeSchema.partial(), req.body);
|
|
947
|
+
if (!validation.success) {
|
|
948
|
+
return res.status(400).json({ error: validation.error });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// If approving/rejecting/requesting changes, add timestamp and default reviewer
|
|
952
|
+
if (validation.data.tier2Status === "approved" || validation.data.tier2Status === "rejected" || validation.data.tier2Status === "changes_requested") {
|
|
953
|
+
validation.data.tier2ReviewedAt = new Date().toISOString();
|
|
954
|
+
if (!validation.data.tier2ReviewedBy) {
|
|
955
|
+
validation.data.tier2ReviewedBy = "Media Owner";
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const updated = await storage.updateLineItemCreative(req.params.id, validation.data);
|
|
960
|
+
if (!updated) {
|
|
961
|
+
return res.status(404).json({ error: "Creative assignment not found" });
|
|
962
|
+
}
|
|
963
|
+
res.json(updated);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// Tier 2 Approve a creative assignment
|
|
967
|
+
app.post("/api/line-item-creatives/:id/approve", async (req, res) => {
|
|
968
|
+
const { reviewedBy } = req.body;
|
|
969
|
+
const updated = await storage.updateLineItemCreative(req.params.id, {
|
|
970
|
+
tier2Status: "approved",
|
|
971
|
+
tier2ReviewedBy: reviewedBy ?? "Media Owner",
|
|
972
|
+
tier2ReviewedAt: new Date().toISOString(),
|
|
973
|
+
});
|
|
974
|
+
if (!updated) {
|
|
975
|
+
return res.status(404).json({ error: "Creative assignment not found" });
|
|
976
|
+
}
|
|
977
|
+
res.json(updated);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Tier 2 Reject a creative assignment
|
|
981
|
+
app.post("/api/line-item-creatives/:id/reject", async (req, res) => {
|
|
982
|
+
const { reviewedBy, reason } = req.body;
|
|
983
|
+
if (!reason) {
|
|
984
|
+
return res.status(400).json({ error: "Rejection reason is required" });
|
|
985
|
+
}
|
|
986
|
+
const updated = await storage.updateLineItemCreative(req.params.id, {
|
|
987
|
+
tier2Status: "rejected",
|
|
988
|
+
tier2ReviewedBy: reviewedBy ?? "Media Owner",
|
|
989
|
+
tier2ReviewedAt: new Date().toISOString(),
|
|
990
|
+
tier2RejectionReason: reason,
|
|
991
|
+
});
|
|
992
|
+
if (!updated) {
|
|
993
|
+
return res.status(404).json({ error: "Creative assignment not found" });
|
|
994
|
+
}
|
|
995
|
+
res.json(updated);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// Change Logs (Audit Trail)
|
|
999
|
+
app.get("/api/change-logs", async (req, res) => {
|
|
1000
|
+
const { entityType, entityId, limit } = req.query;
|
|
1001
|
+
|
|
1002
|
+
if (entityType && entityId) {
|
|
1003
|
+
const validTypes = ["deal", "order", "line_item", "creative"];
|
|
1004
|
+
if (!validTypes.includes(entityType as string)) {
|
|
1005
|
+
return res.status(400).json({ error: "Invalid entity type" });
|
|
1006
|
+
}
|
|
1007
|
+
const logs = await storage.getChangeLogs(entityType as any, entityId as string);
|
|
1008
|
+
return res.json(logs);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const logs = await storage.getRecentChangeLogs(limit ? parseInt(limit as string) : 50);
|
|
1012
|
+
res.json(logs);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
app.get("/api/change-logs/:entityType/:entityId", async (req, res) => {
|
|
1016
|
+
const { entityType, entityId } = req.params;
|
|
1017
|
+
const validTypes = ["deal", "order", "line_item", "creative"];
|
|
1018
|
+
if (!validTypes.includes(entityType)) {
|
|
1019
|
+
return res.status(400).json({ error: "Invalid entity type" });
|
|
1020
|
+
}
|
|
1021
|
+
const logs = await storage.getChangeLogs(entityType as any, entityId);
|
|
1022
|
+
res.json(logs);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
app.get("/api/change-logs/hierarchy/:entityType/:entityId", async (req, res) => {
|
|
1026
|
+
const { entityType, entityId } = req.params;
|
|
1027
|
+
const validTypes = ["deal", "order", "line_item", "creative"];
|
|
1028
|
+
if (!validTypes.includes(entityType)) {
|
|
1029
|
+
return res.status(400).json({ error: "Invalid entity type" });
|
|
1030
|
+
}
|
|
1031
|
+
const logs = await storage.getHierarchicalChangeLogs(entityType as any, entityId);
|
|
1032
|
+
res.json(logs);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Signals
|
|
1036
|
+
app.get("/api/signals", async (req, res) => {
|
|
1037
|
+
const signals = await storage.getSignals();
|
|
1038
|
+
res.json(signals);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
app.get("/api/signals/:id", async (req, res) => {
|
|
1042
|
+
const signal = await storage.getSignal(req.params.id);
|
|
1043
|
+
if (!signal) {
|
|
1044
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1045
|
+
}
|
|
1046
|
+
res.json(signal);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
app.post("/api/signals", async (req, res) => {
|
|
1050
|
+
const validation = validateBody(insertSignalSchema, req.body);
|
|
1051
|
+
if (!validation.success) {
|
|
1052
|
+
return res.status(400).json({ error: validation.error });
|
|
1053
|
+
}
|
|
1054
|
+
const now = new Date().toISOString();
|
|
1055
|
+
validation.data.createdAt = now;
|
|
1056
|
+
const signal = await storage.createSignal(validation.data);
|
|
1057
|
+
res.status(201).json(signal);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
app.patch("/api/signals/:id", async (req, res) => {
|
|
1061
|
+
const validation = validateBody(insertSignalSchema.partial(), req.body);
|
|
1062
|
+
if (!validation.success) {
|
|
1063
|
+
return res.status(400).json({ error: validation.error });
|
|
1064
|
+
}
|
|
1065
|
+
const signal = await storage.updateSignal(req.params.id, validation.data);
|
|
1066
|
+
if (!signal) {
|
|
1067
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1068
|
+
}
|
|
1069
|
+
res.json(signal);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
app.delete("/api/signals/:id", async (req, res) => {
|
|
1073
|
+
const deleted = await storage.deleteSignal(req.params.id);
|
|
1074
|
+
if (!deleted) {
|
|
1075
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1076
|
+
}
|
|
1077
|
+
res.status(204).send();
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// Signal Rules
|
|
1081
|
+
app.get("/api/signals/:id/rules", async (req, res) => {
|
|
1082
|
+
const signal = await storage.getSignal(req.params.id);
|
|
1083
|
+
if (!signal) {
|
|
1084
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1085
|
+
}
|
|
1086
|
+
const rules = await storage.getSignalRules(req.params.id);
|
|
1087
|
+
res.json(rules);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
app.post("/api/signals/:id/rules", async (req, res) => {
|
|
1091
|
+
const signal = await storage.getSignal(req.params.id);
|
|
1092
|
+
if (!signal) {
|
|
1093
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1094
|
+
}
|
|
1095
|
+
const validation = validateBody(insertSignalRuleSchema, { ...req.body, signalId: req.params.id });
|
|
1096
|
+
if (!validation.success) {
|
|
1097
|
+
return res.status(400).json({ error: validation.error });
|
|
1098
|
+
}
|
|
1099
|
+
const rule = await storage.createSignalRule(validation.data);
|
|
1100
|
+
res.status(201).json(rule);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
app.patch("/api/signals/:id/rules/:ruleId", async (req, res) => {
|
|
1104
|
+
const signal = await storage.getSignal(req.params.id);
|
|
1105
|
+
if (!signal) {
|
|
1106
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1107
|
+
}
|
|
1108
|
+
const existingRule = await storage.getSignalRule(req.params.ruleId);
|
|
1109
|
+
if (!existingRule || existingRule.signalId !== req.params.id) {
|
|
1110
|
+
return res.status(404).json({ error: "Signal rule not found" });
|
|
1111
|
+
}
|
|
1112
|
+
const validation = validateBody(insertSignalRuleSchema.partial(), req.body);
|
|
1113
|
+
if (!validation.success) {
|
|
1114
|
+
return res.status(400).json({ error: validation.error });
|
|
1115
|
+
}
|
|
1116
|
+
const rule = await storage.updateSignalRule(req.params.ruleId, validation.data);
|
|
1117
|
+
res.json(rule);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
app.delete("/api/signals/:id/rules/:ruleId", async (req, res) => {
|
|
1121
|
+
const signal = await storage.getSignal(req.params.id);
|
|
1122
|
+
if (!signal) {
|
|
1123
|
+
return res.status(404).json({ error: "Signal not found" });
|
|
1124
|
+
}
|
|
1125
|
+
const existingRule = await storage.getSignalRule(req.params.ruleId);
|
|
1126
|
+
if (!existingRule || existingRule.signalId !== req.params.id) {
|
|
1127
|
+
return res.status(404).json({ error: "Signal rule not found" });
|
|
1128
|
+
}
|
|
1129
|
+
const deleted = await storage.deleteSignalRule(req.params.ruleId);
|
|
1130
|
+
if (!deleted) {
|
|
1131
|
+
return res.status(404).json({ error: "Signal rule not found" });
|
|
1132
|
+
}
|
|
1133
|
+
res.status(204).send();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// POIs
|
|
1137
|
+
app.get("/api/pois", async (req, res) => {
|
|
1138
|
+
const { country } = req.query;
|
|
1139
|
+
const pois = await storage.getPOIs(country as string | undefined);
|
|
1140
|
+
res.json(pois);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
app.get("/api/pois/:id", async (req, res) => {
|
|
1144
|
+
const poi = await storage.getPOI(req.params.id);
|
|
1145
|
+
if (!poi) {
|
|
1146
|
+
return res.status(404).json({ error: "POI not found" });
|
|
1147
|
+
}
|
|
1148
|
+
res.json(poi);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// Player Status (read-only from CMS)
|
|
1152
|
+
app.get("/api/player-status", async (req, res) => {
|
|
1153
|
+
const { screenIds } = req.query;
|
|
1154
|
+
const ids = screenIds ? (screenIds as string).split(",") : undefined;
|
|
1155
|
+
const statuses = await storage.getPlayerStatuses(ids);
|
|
1156
|
+
res.json(statuses);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
app.get("/api/player-status/:screenId", async (req, res) => {
|
|
1160
|
+
const status = await storage.getPlayerStatus(req.params.screenId);
|
|
1161
|
+
if (!status) {
|
|
1162
|
+
return res.status(404).json({ error: "Player status not found for this screen" });
|
|
1163
|
+
}
|
|
1164
|
+
res.json(status);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Player status by line item (for Line Item Creatives page)
|
|
1168
|
+
// In production, this would query Inventory Management to get screens assigned to this line item
|
|
1169
|
+
// For the demo, we map line items to sample screens based on line item index
|
|
1170
|
+
app.get("/api/line-items/:lineItemId/player-status", async (req, res) => {
|
|
1171
|
+
const { lineItemId } = req.params;
|
|
1172
|
+
const lineItem = await storage.getLineItem(lineItemId);
|
|
1173
|
+
if (!lineItem) {
|
|
1174
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Get all player statuses
|
|
1178
|
+
const allStatuses = await storage.getPlayerStatuses();
|
|
1179
|
+
|
|
1180
|
+
// For demo purposes, distribute screens across line items based on hash of line item ID
|
|
1181
|
+
// In production, this would query the actual screen-to-line-item mappings from Inventory Management
|
|
1182
|
+
const lineItemHash = lineItemId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
1183
|
+
const statusCount = allStatuses.length;
|
|
1184
|
+
|
|
1185
|
+
// Each line item gets a subset of screens (at least 2)
|
|
1186
|
+
const startIndex = lineItemHash % statusCount;
|
|
1187
|
+
const numScreens = Math.max(2, Math.min(statusCount, (lineItemHash % 4) + 2));
|
|
1188
|
+
|
|
1189
|
+
const assignedStatuses = [];
|
|
1190
|
+
for (let i = 0; i < numScreens; i++) {
|
|
1191
|
+
assignedStatuses.push(allStatuses[(startIndex + i) % statusCount]);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
res.json(assignedStatuses);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
// Proof of Play
|
|
1198
|
+
app.get("/api/proof-of-play", async (req, res) => {
|
|
1199
|
+
const { dealId, lineItemId, creativeId, mediaOwnerId, source, status, startDate, endDate } = req.query;
|
|
1200
|
+
const records = await storage.getProofOfPlayRecords({
|
|
1201
|
+
dealId: dealId as string | undefined,
|
|
1202
|
+
lineItemId: lineItemId as string | undefined,
|
|
1203
|
+
creativeId: creativeId as string | undefined,
|
|
1204
|
+
mediaOwnerId: mediaOwnerId as string | undefined,
|
|
1205
|
+
source: source as string | undefined,
|
|
1206
|
+
status: status as string | undefined,
|
|
1207
|
+
startDate: startDate as string | undefined,
|
|
1208
|
+
endDate: endDate as string | undefined,
|
|
1209
|
+
});
|
|
1210
|
+
res.json(records);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
app.get("/api/proof-of-play/:id", async (req, res) => {
|
|
1214
|
+
const record = await storage.getProofOfPlayRecord(req.params.id);
|
|
1215
|
+
if (!record) {
|
|
1216
|
+
return res.status(404).json({ error: "Proof of play record not found" });
|
|
1217
|
+
}
|
|
1218
|
+
res.json(record);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
app.post("/api/proof-of-play", async (req, res) => {
|
|
1222
|
+
const validation = validateBody(insertProofOfPlaySchema, req.body);
|
|
1223
|
+
if (!validation.success) {
|
|
1224
|
+
return res.status(400).json({ error: validation.error });
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
const record = await storage.createProofOfPlayRecord(validation.data);
|
|
1228
|
+
res.status(201).json(record);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
res.status(400).json({ error: "Failed to create proof of play record" });
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
app.post("/api/proof-of-play/screenshot", async (req, res) => {
|
|
1235
|
+
const { screenId, imageData } = req.body;
|
|
1236
|
+
if (!screenId || !imageData) {
|
|
1237
|
+
return res.status(400).json({ error: "screenId and imageData are required" });
|
|
1238
|
+
}
|
|
1239
|
+
if (typeof imageData !== "string" || !imageData.startsWith("data:image/")) {
|
|
1240
|
+
return res.status(400).json({ error: "Invalid image data format" });
|
|
1241
|
+
}
|
|
1242
|
+
const maxSize = 5 * 1024 * 1024;
|
|
1243
|
+
if (imageData.length > maxSize) {
|
|
1244
|
+
return res.status(400).json({ error: "Image data too large (max 5MB)" });
|
|
1245
|
+
}
|
|
1246
|
+
const screen = await storage.getScreen(screenId);
|
|
1247
|
+
if (!screen) {
|
|
1248
|
+
return res.status(404).json({ error: "Screen not found" });
|
|
1249
|
+
}
|
|
1250
|
+
const now = new Date().toISOString();
|
|
1251
|
+
const record = await storage.createProofOfPlayRecord({
|
|
1252
|
+
screenId,
|
|
1253
|
+
playerId: "webcam-capture",
|
|
1254
|
+
creativeId: null,
|
|
1255
|
+
dealId: null,
|
|
1256
|
+
lineItemId: null,
|
|
1257
|
+
startTimestamp: now,
|
|
1258
|
+
endTimestamp: now,
|
|
1259
|
+
durationSeconds: 0,
|
|
1260
|
+
impressionCount: 1,
|
|
1261
|
+
mediaFileUrl: null,
|
|
1262
|
+
proofImageUrl: imageData,
|
|
1263
|
+
proofVideoUrl: null,
|
|
1264
|
+
source: "cms",
|
|
1265
|
+
status: "processed",
|
|
1266
|
+
});
|
|
1267
|
+
res.status(201).json(record);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
app.patch("/api/proof-of-play/:id", async (req, res) => {
|
|
1271
|
+
const record = await storage.updateProofOfPlayRecord(req.params.id, req.body);
|
|
1272
|
+
if (!record) {
|
|
1273
|
+
return res.status(404).json({ error: "Proof of play record not found" });
|
|
1274
|
+
}
|
|
1275
|
+
res.json(record);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
app.delete("/api/proof-of-play/:id", async (req, res) => {
|
|
1279
|
+
const success = await storage.deleteProofOfPlayRecord(req.params.id);
|
|
1280
|
+
if (!success) {
|
|
1281
|
+
return res.status(404).json({ error: "Proof of play record not found" });
|
|
1282
|
+
}
|
|
1283
|
+
res.status(204).send();
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// Playlog Uploads
|
|
1287
|
+
app.get("/api/playlogs", async (req, res) => {
|
|
1288
|
+
const { dealId, lineItemId, mediaOwnerId, status } = req.query;
|
|
1289
|
+
const uploads = await storage.getPlaylogUploads({
|
|
1290
|
+
dealId: dealId as string | undefined,
|
|
1291
|
+
lineItemId: lineItemId as string | undefined,
|
|
1292
|
+
mediaOwnerId: mediaOwnerId as string | undefined,
|
|
1293
|
+
status: status as string | undefined,
|
|
1294
|
+
});
|
|
1295
|
+
res.json(uploads);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
// Playlog Template Download - must be before :id route
|
|
1299
|
+
app.get("/api/playlogs/template", async (req, res) => {
|
|
1300
|
+
const { dealId, lineItemId, dealName, lineItemName, format } = req.query;
|
|
1301
|
+
|
|
1302
|
+
const dealIdValue = dealId ? String(dealId) : "[Enter Deal ID]";
|
|
1303
|
+
const lineItemIdValue = lineItemId ? String(lineItemId) : "[Enter Line Item ID]";
|
|
1304
|
+
const sampleNote = dealName ? `Sample for ${dealName}` : "Sample row - replace with your data";
|
|
1305
|
+
|
|
1306
|
+
// Guidelines section as comments
|
|
1307
|
+
const guidelines = `# PLAYLOG UPLOAD TEMPLATE
|
|
1308
|
+
# ========================
|
|
1309
|
+
#
|
|
1310
|
+
# INSTRUCTIONS:
|
|
1311
|
+
# 1. Do NOT modify or delete the header row below
|
|
1312
|
+
# 2. The sample row shows the expected format - replace it with your data
|
|
1313
|
+
# 3. Duration and Impressions are calculated automatically by the Measure platform
|
|
1314
|
+
# 4. Date format: ISO 8601 with timezone (e.g. 2024-01-15T10:00:00Z)
|
|
1315
|
+
#
|
|
1316
|
+
# COLUMN DESCRIPTIONS:
|
|
1317
|
+
# - Deal ID: The Deal ID from Influence platform (required for mapping)
|
|
1318
|
+
# - Line Item ID: The Line Item ID from Influence platform (required for mapping)
|
|
1319
|
+
# - Reference ID: Your inventory/screen reference ID
|
|
1320
|
+
# - Player ID: Player device ID (optional - leave blank if same as Reference ID)
|
|
1321
|
+
# - Creative ID: Creative ID from Influence or your internal reference
|
|
1322
|
+
# - Start Time: When the content started playing (ISO 8601 format)
|
|
1323
|
+
# - End Time: When the content finished playing (ISO 8601 format)
|
|
1324
|
+
# - Notes: Optional notes for this record
|
|
1325
|
+
#
|
|
1326
|
+
# TIPS:
|
|
1327
|
+
# - Select Deal and Line Item on the upload page to pre-fill IDs in this template
|
|
1328
|
+
# - You can upload Excel (.xlsx) files - they will be automatically converted
|
|
1329
|
+
# - Each row represents one play event
|
|
1330
|
+
#
|
|
1331
|
+
`;
|
|
1332
|
+
|
|
1333
|
+
const templateCSV = `${guidelines}Deal ID,Line Item ID,Reference ID,Player ID,Creative ID,Start Time,End Time,Notes
|
|
1334
|
+
${dealIdValue},${lineItemIdValue},INV-REF-001,,CREATIVE-001,2024-01-15T10:00:00Z,2024-01-15T10:00:15Z,${sampleNote}`;
|
|
1335
|
+
|
|
1336
|
+
res.setHeader("Content-Type", "text/csv");
|
|
1337
|
+
res.setHeader("Content-Disposition", "attachment; filename=playlog_template.csv");
|
|
1338
|
+
res.send(templateCSV);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
app.get("/api/playlogs/:id", async (req, res) => {
|
|
1342
|
+
const upload = await storage.getPlaylogUpload(req.params.id);
|
|
1343
|
+
if (!upload) {
|
|
1344
|
+
return res.status(404).json({ error: "Playlog upload not found" });
|
|
1345
|
+
}
|
|
1346
|
+
res.json(upload);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
app.post("/api/playlogs/upload", async (req, res) => {
|
|
1350
|
+
const validation = validateBody(insertPlaylogUploadSchema, req.body);
|
|
1351
|
+
if (!validation.success) {
|
|
1352
|
+
return res.status(400).json({ error: validation.error });
|
|
1353
|
+
}
|
|
1354
|
+
try {
|
|
1355
|
+
const upload = await storage.createPlaylogUpload(validation.data);
|
|
1356
|
+
res.status(201).json(upload);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
res.status(400).json({ error: "Failed to create playlog upload" });
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
app.patch("/api/playlogs/:id", async (req, res) => {
|
|
1363
|
+
const upload = await storage.updatePlaylogUpload(req.params.id, req.body);
|
|
1364
|
+
if (!upload) {
|
|
1365
|
+
return res.status(404).json({ error: "Playlog upload not found" });
|
|
1366
|
+
}
|
|
1367
|
+
res.json(upload);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
app.delete("/api/playlogs/:id", async (req, res) => {
|
|
1371
|
+
const success = await storage.deletePlaylogUpload(req.params.id);
|
|
1372
|
+
if (!success) {
|
|
1373
|
+
return res.status(404).json({ error: "Playlog upload not found" });
|
|
1374
|
+
}
|
|
1375
|
+
res.status(204).send();
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// Geotargeting Rules
|
|
1379
|
+
app.get("/api/geotargeting/:entityType/:entityId", async (req, res) => {
|
|
1380
|
+
const { entityType, entityId } = req.params;
|
|
1381
|
+
const rules = await storage.getGeoTargetingRules(entityType, entityId);
|
|
1382
|
+
res.json(rules);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
app.get("/api/geotargeting/rule/:id", async (req, res) => {
|
|
1386
|
+
const rule = await storage.getGeoTargetingRule(req.params.id);
|
|
1387
|
+
if (!rule) {
|
|
1388
|
+
return res.status(404).json({ error: "Geotargeting rule not found" });
|
|
1389
|
+
}
|
|
1390
|
+
res.json(rule);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
app.post("/api/geotargeting", async (req, res) => {
|
|
1394
|
+
const validation = validateBody(insertGeoTargetingRuleSchema, req.body);
|
|
1395
|
+
if (!validation.success) {
|
|
1396
|
+
return res.status(400).json({ error: validation.error });
|
|
1397
|
+
}
|
|
1398
|
+
try {
|
|
1399
|
+
const rule = await storage.createGeoTargetingRule(validation.data);
|
|
1400
|
+
res.status(201).json(rule);
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
res.status(400).json({ error: "Failed to create geotargeting rule" });
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
app.patch("/api/geotargeting/:id", async (req, res) => {
|
|
1407
|
+
const updateSchema = insertGeoTargetingRuleSchema.partial();
|
|
1408
|
+
const validation = validateBody(updateSchema, req.body);
|
|
1409
|
+
if (!validation.success) {
|
|
1410
|
+
return res.status(400).json({ error: validation.error });
|
|
1411
|
+
}
|
|
1412
|
+
const rule = await storage.updateGeoTargetingRule(req.params.id, validation.data);
|
|
1413
|
+
if (!rule) {
|
|
1414
|
+
return res.status(404).json({ error: "Geotargeting rule not found" });
|
|
1415
|
+
}
|
|
1416
|
+
res.json(rule);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
app.delete("/api/geotargeting/:id", async (req, res) => {
|
|
1420
|
+
const success = await storage.deleteGeoTargetingRule(req.params.id);
|
|
1421
|
+
if (!success) {
|
|
1422
|
+
return res.status(404).json({ error: "Geotargeting rule not found" });
|
|
1423
|
+
}
|
|
1424
|
+
res.status(204).send();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// Transit Routes and Zones
|
|
1428
|
+
app.get("/api/transit/routes", async (req, res) => {
|
|
1429
|
+
const { city } = req.query;
|
|
1430
|
+
const routes = await storage.getTransitRoutes(city as string | undefined);
|
|
1431
|
+
res.json(routes);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
app.get("/api/transit/routes/:id", async (req, res) => {
|
|
1435
|
+
const route = await storage.getTransitRoute(req.params.id);
|
|
1436
|
+
if (!route) {
|
|
1437
|
+
return res.status(404).json({ error: "Transit route not found" });
|
|
1438
|
+
}
|
|
1439
|
+
res.json(route);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
app.get("/api/transit/zones", async (req, res) => {
|
|
1443
|
+
const { city } = req.query;
|
|
1444
|
+
const zones = await storage.getTransitZones(city as string | undefined);
|
|
1445
|
+
res.json(zones);
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
app.get("/api/transit/zones/:id", async (req, res) => {
|
|
1449
|
+
const zone = await storage.getTransitZone(req.params.id);
|
|
1450
|
+
if (!zone) {
|
|
1451
|
+
return res.status(404).json({ error: "Transit zone not found" });
|
|
1452
|
+
}
|
|
1453
|
+
res.json(zone);
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// POI Search with category filtering
|
|
1457
|
+
app.get("/api/poi/search", async (req, res) => {
|
|
1458
|
+
const { query, category, country, limit = "20" } = req.query;
|
|
1459
|
+
let pois = await storage.getPOIs(country as string | undefined);
|
|
1460
|
+
|
|
1461
|
+
if (query) {
|
|
1462
|
+
const searchTerm = (query as string).toLowerCase();
|
|
1463
|
+
pois = pois.filter(poi =>
|
|
1464
|
+
poi.name.toLowerCase().includes(searchTerm) ||
|
|
1465
|
+
(poi.address && poi.address.toLowerCase().includes(searchTerm)) ||
|
|
1466
|
+
(poi.city && poi.city.toLowerCase().includes(searchTerm))
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
if (category) {
|
|
1471
|
+
const categories = (category as string).split(',').map(c => c.toLowerCase().trim());
|
|
1472
|
+
pois = pois.filter(poi =>
|
|
1473
|
+
poi.category && categories.some(cat => poi.category!.toLowerCase().includes(cat))
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
res.json(pois.slice(0, parseInt(limit as string, 10)));
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
// Creative Specifications endpoint - returns keyed object for frontend consumption
|
|
1481
|
+
app.get("/api/creative-specs", async (req, res) => {
|
|
1482
|
+
const specs: Record<string, {
|
|
1483
|
+
formats: string[];
|
|
1484
|
+
maxFileSize: string;
|
|
1485
|
+
maxFileSizeBytes: number;
|
|
1486
|
+
dimensions: Array<{ width: number; height: number; label: string }>;
|
|
1487
|
+
maxDuration?: number;
|
|
1488
|
+
notes: string[];
|
|
1489
|
+
}> = {
|
|
1490
|
+
display: {
|
|
1491
|
+
formats: ["JPG", "JPEG", "PNG", "GIF", "WEBP"],
|
|
1492
|
+
maxFileSize: "5 MB",
|
|
1493
|
+
maxFileSizeBytes: 5 * 1024 * 1024,
|
|
1494
|
+
dimensions: [
|
|
1495
|
+
{ width: 1920, height: 1080, label: "Full HD (16:9)" },
|
|
1496
|
+
{ width: 1080, height: 1920, label: "Portrait Full HD (9:16)" },
|
|
1497
|
+
{ width: 3840, height: 2160, label: "4K UHD (16:9)" },
|
|
1498
|
+
{ width: 1280, height: 720, label: "HD (16:9)" },
|
|
1499
|
+
{ width: 1024, height: 768, label: "Standard (4:3)" },
|
|
1500
|
+
],
|
|
1501
|
+
notes: ["RGB color mode", "72-300 DPI resolution"],
|
|
1502
|
+
},
|
|
1503
|
+
video: {
|
|
1504
|
+
formats: ["MP4", "MOV", "WEBM", "AVI"],
|
|
1505
|
+
maxFileSize: "100 MB",
|
|
1506
|
+
maxFileSizeBytes: 100 * 1024 * 1024,
|
|
1507
|
+
dimensions: [
|
|
1508
|
+
{ width: 1920, height: 1080, label: "Full HD (16:9)" },
|
|
1509
|
+
{ width: 1080, height: 1920, label: "Portrait Full HD (9:16)" },
|
|
1510
|
+
{ width: 3840, height: 2160, label: "4K UHD (16:9)" },
|
|
1511
|
+
],
|
|
1512
|
+
maxDuration: 60,
|
|
1513
|
+
notes: ["H.264 or H.265 codec", "AAC audio", "25-60 fps", "Duration: 5-60 seconds"],
|
|
1514
|
+
},
|
|
1515
|
+
html: {
|
|
1516
|
+
formats: ["HTML", "ZIP"],
|
|
1517
|
+
maxFileSize: "10 MB",
|
|
1518
|
+
maxFileSizeBytes: 10 * 1024 * 1024,
|
|
1519
|
+
dimensions: [
|
|
1520
|
+
{ width: 1920, height: 1080, label: "Full HD (16:9)" },
|
|
1521
|
+
{ width: 1080, height: 1920, label: "Portrait Full HD (9:16)" },
|
|
1522
|
+
],
|
|
1523
|
+
notes: [
|
|
1524
|
+
"Self-contained HTML5",
|
|
1525
|
+
"No external dependencies",
|
|
1526
|
+
"Must include index.html",
|
|
1527
|
+
"Responsive design recommended",
|
|
1528
|
+
],
|
|
1529
|
+
},
|
|
1530
|
+
native: {
|
|
1531
|
+
formats: ["JPG", "JPEG", "PNG"],
|
|
1532
|
+
maxFileSize: "2 MB",
|
|
1533
|
+
maxFileSizeBytes: 2 * 1024 * 1024,
|
|
1534
|
+
dimensions: [
|
|
1535
|
+
{ width: 1200, height: 628, label: "Native (1.91:1)" },
|
|
1536
|
+
{ width: 1080, height: 1080, label: "Square (1:1)" },
|
|
1537
|
+
],
|
|
1538
|
+
notes: ["Headline: max 50 characters", "Description: max 150 characters"],
|
|
1539
|
+
},
|
|
1540
|
+
};
|
|
1541
|
+
res.json(specs);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// =====================================================
|
|
1545
|
+
// RECOMMENDATION ENGINE ROUTES
|
|
1546
|
+
// =====================================================
|
|
1547
|
+
|
|
1548
|
+
// Get inventory recommendations based on campaign parameters
|
|
1549
|
+
app.post("/api/recommendations", async (req, res) => {
|
|
1550
|
+
try {
|
|
1551
|
+
const { recommendationRequestSchema } = await import("@shared/schema");
|
|
1552
|
+
const validation = recommendationRequestSchema.safeParse(req.body);
|
|
1553
|
+
if (!validation.success) {
|
|
1554
|
+
return res.status(400).json({
|
|
1555
|
+
error: validation.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const { recommendationService } = await import("./recommendation-service");
|
|
1560
|
+
const screens = await storage.getScreens();
|
|
1561
|
+
const recommendations = await recommendationService.getRecommendations(validation.data, screens);
|
|
1562
|
+
res.json(recommendations);
|
|
1563
|
+
} catch (error: any) {
|
|
1564
|
+
console.error("Error getting recommendations:", error);
|
|
1565
|
+
res.status(500).json({ error: error.message || "Failed to get recommendations" });
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
// Auto-optimize a line item with AI-recommended inventory
|
|
1570
|
+
app.post("/api/line-items/:id/auto-optimize", async (req, res) => {
|
|
1571
|
+
try {
|
|
1572
|
+
const { autoOptimizeRequestSchema } = await import("@shared/schema");
|
|
1573
|
+
const validation = autoOptimizeRequestSchema.safeParse(req.body);
|
|
1574
|
+
if (!validation.success) {
|
|
1575
|
+
return res.status(400).json({
|
|
1576
|
+
error: validation.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const lineItem = await storage.getLineItem(req.params.id);
|
|
1581
|
+
if (!lineItem) {
|
|
1582
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const deal = await storage.getDeal(lineItem.dealId);
|
|
1586
|
+
if (!deal) {
|
|
1587
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const { recommendationService } = await import("./recommendation-service");
|
|
1591
|
+
const screens = await storage.getScreens();
|
|
1592
|
+
|
|
1593
|
+
const optimizeRequest = {
|
|
1594
|
+
lineItemId: req.params.id,
|
|
1595
|
+
dealId: lineItem.dealId,
|
|
1596
|
+
budget: validation.data.budget || (lineItem.budget ? parseFloat(lineItem.budget) : undefined),
|
|
1597
|
+
goalType: validation.data.goalType || (deal as any).goalType,
|
|
1598
|
+
goalValue: validation.data.goalValue || (deal as any).goalValue,
|
|
1599
|
+
maxInventories: validation.data.maxInventories || 5,
|
|
1600
|
+
preferredTypes: validation.data.preferredTypes,
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
const result = await recommendationService.autoOptimize(optimizeRequest, deal, screens);
|
|
1604
|
+
|
|
1605
|
+
// Persist the recommended inventories to the line item
|
|
1606
|
+
const existingScreens = (lineItem as any).selectedScreens || [];
|
|
1607
|
+
const newScreenIds = result.selectedInventories.map(inv => inv.inventoryId);
|
|
1608
|
+
const mergedScreenIds = [...new Set([...existingScreens, ...newScreenIds])];
|
|
1609
|
+
|
|
1610
|
+
await storage.updateLineItem(req.params.id, {
|
|
1611
|
+
selectedScreens: mergedScreenIds,
|
|
1612
|
+
} as any);
|
|
1613
|
+
|
|
1614
|
+
res.json(result);
|
|
1615
|
+
} catch (error: any) {
|
|
1616
|
+
console.error("Error auto-optimizing line item:", error);
|
|
1617
|
+
res.status(500).json({ error: error.message || "Failed to auto-optimize" });
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
// Get recommendations for a specific deal
|
|
1622
|
+
app.get("/api/deals/:id/recommendations", async (req, res) => {
|
|
1623
|
+
try {
|
|
1624
|
+
const deal = await storage.getDeal(req.params.id);
|
|
1625
|
+
if (!deal) {
|
|
1626
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const { recommendationService } = await import("./recommendation-service");
|
|
1630
|
+
const screens = await storage.getScreens();
|
|
1631
|
+
|
|
1632
|
+
const request = {
|
|
1633
|
+
country: (deal as any).countries?.[0] || "US",
|
|
1634
|
+
startDate: deal.startDate,
|
|
1635
|
+
endDate: deal.endDate,
|
|
1636
|
+
budget: deal.budget ? parseFloat(deal.budget) : undefined,
|
|
1637
|
+
goalType: (deal as any).goalType,
|
|
1638
|
+
goalValue: (deal as any).goalValue,
|
|
1639
|
+
limit: parseInt(req.query.limit as string) || 10,
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
const recommendations = await recommendationService.getRecommendations(request, screens);
|
|
1643
|
+
res.json(recommendations);
|
|
1644
|
+
} catch (error: any) {
|
|
1645
|
+
console.error("Error getting deal recommendations:", error);
|
|
1646
|
+
res.status(500).json({ error: error.message || "Failed to get recommendations" });
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
// ============================================================
|
|
1651
|
+
// VAST Tag Distribution
|
|
1652
|
+
// ============================================================
|
|
1653
|
+
|
|
1654
|
+
function generateVastUrl(dealId: string, lineItemId: string, host: string): string {
|
|
1655
|
+
return `${host}/vast/v1/${dealId}/${lineItemId}?screen_id={screen_id}&player_id={player_id}`;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function generateVastHtml(dealName: string, lineItemName: string, vastUrl: string): string {
|
|
1659
|
+
return `<!DOCTYPE html>
|
|
1660
|
+
<html lang="en">
|
|
1661
|
+
<head>
|
|
1662
|
+
<meta charset="UTF-8">
|
|
1663
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1664
|
+
<title>VAST Player - ${lineItemName}</title>
|
|
1665
|
+
<style>
|
|
1666
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
1667
|
+
body{background:#000;display:flex;align-items:center;justify-content:center;height:100vh;overflow:hidden}
|
|
1668
|
+
#player{width:100%;height:100%;position:relative}
|
|
1669
|
+
video{width:100%;height:100%;object-fit:contain}
|
|
1670
|
+
#fallback{color:#fff;text-align:center;font-family:Arial,sans-serif;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
|
|
1671
|
+
#status{position:fixed;bottom:10px;right:10px;color:#666;font-size:11px;font-family:monospace}
|
|
1672
|
+
</style>
|
|
1673
|
+
</head>
|
|
1674
|
+
<body>
|
|
1675
|
+
<div id="player">
|
|
1676
|
+
<video id="ad-video" autoplay muted playsinline></video>
|
|
1677
|
+
<div id="fallback" style="display:none">
|
|
1678
|
+
<p>Waiting for ad content...</p>
|
|
1679
|
+
</div>
|
|
1680
|
+
</div>
|
|
1681
|
+
<div id="status">VAST Player v1.0</div>
|
|
1682
|
+
<script>
|
|
1683
|
+
(function(){
|
|
1684
|
+
var VAST_URL="${vastUrl}";
|
|
1685
|
+
var POLL_INTERVAL=60000;
|
|
1686
|
+
var video=document.getElementById("ad-video");
|
|
1687
|
+
var fallback=document.getElementById("fallback");
|
|
1688
|
+
var status=document.getElementById("status");
|
|
1689
|
+
|
|
1690
|
+
function log(msg){status.textContent=msg;console.log("[VAST]",msg)}
|
|
1691
|
+
|
|
1692
|
+
function fetchVast(){
|
|
1693
|
+
log("Fetching VAST...");
|
|
1694
|
+
fetch(VAST_URL).then(function(r){return r.text()}).then(function(xml){
|
|
1695
|
+
var parser=new DOMParser();
|
|
1696
|
+
var doc=parser.parseFromString(xml,"text/xml");
|
|
1697
|
+
var mediaFiles=doc.querySelectorAll("MediaFile");
|
|
1698
|
+
if(mediaFiles.length>0){
|
|
1699
|
+
var mediaUrl=mediaFiles[0].textContent.trim();
|
|
1700
|
+
if(video.src!==mediaUrl){
|
|
1701
|
+
video.src=mediaUrl;
|
|
1702
|
+
video.play().catch(function(){});
|
|
1703
|
+
fallback.style.display="none";
|
|
1704
|
+
log("Playing: "+mediaUrl.split("/").pop());
|
|
1705
|
+
}
|
|
1706
|
+
}else{
|
|
1707
|
+
fallback.style.display="block";
|
|
1708
|
+
log("No media in VAST response");
|
|
1709
|
+
}
|
|
1710
|
+
}).catch(function(e){
|
|
1711
|
+
fallback.style.display="block";
|
|
1712
|
+
log("Error: "+e.message);
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
video.addEventListener("ended",function(){fetchVast()});
|
|
1717
|
+
video.addEventListener("error",function(){
|
|
1718
|
+
fallback.style.display="block";
|
|
1719
|
+
log("Video error - retrying...");
|
|
1720
|
+
setTimeout(fetchVast,5000);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
fetchVast();
|
|
1724
|
+
setInterval(fetchVast,POLL_INTERVAL);
|
|
1725
|
+
})();
|
|
1726
|
+
</script>
|
|
1727
|
+
</body>
|
|
1728
|
+
</html>`;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
app.get("/api/deals/:id/distribution", async (req, res) => {
|
|
1732
|
+
const deal = await storage.getDeal(req.params.id);
|
|
1733
|
+
if (!deal) {
|
|
1734
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (!deal.acceptanceSent) {
|
|
1738
|
+
return res.status(400).json({ error: "Distribution is only available after deal activation" });
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.id);
|
|
1742
|
+
const host = `${req.protocol}://${req.get("host")}`;
|
|
1743
|
+
|
|
1744
|
+
const distribution = lineItems.map((li) => ({
|
|
1745
|
+
lineItemId: li.id,
|
|
1746
|
+
lineItemName: li.name,
|
|
1747
|
+
status: li.status,
|
|
1748
|
+
creativeType: li.creativeType,
|
|
1749
|
+
vastUrl: generateVastUrl(req.params.id, li.id, host),
|
|
1750
|
+
}));
|
|
1751
|
+
|
|
1752
|
+
res.json({
|
|
1753
|
+
dealId: req.params.id,
|
|
1754
|
+
dealName: deal.name,
|
|
1755
|
+
dealType: deal.dealType,
|
|
1756
|
+
lineItems: distribution,
|
|
1757
|
+
});
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
app.get("/api/deals/:dealId/line-items/:lineItemId/vast-tag/html", async (req, res) => {
|
|
1761
|
+
const deal = await storage.getDeal(req.params.dealId);
|
|
1762
|
+
if (!deal) {
|
|
1763
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.dealId);
|
|
1767
|
+
const lineItem = lineItems.find((li) => li.id === req.params.lineItemId);
|
|
1768
|
+
if (!lineItem) {
|
|
1769
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const host = `${req.protocol}://${req.get("host")}`;
|
|
1773
|
+
const vastUrl = generateVastUrl(req.params.dealId, req.params.lineItemId, host);
|
|
1774
|
+
const html = generateVastHtml(deal.name, lineItem.name, vastUrl);
|
|
1775
|
+
|
|
1776
|
+
const sanitizedName = lineItem.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1777
|
+
res.setHeader("Content-Type", "text/html");
|
|
1778
|
+
res.setHeader("Content-Disposition", `attachment; filename="vast-player-${sanitizedName}.html"`);
|
|
1779
|
+
res.send(html);
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
app.get("/api/deals/:dealId/line-items/:lineItemId/vast-tag/zip", async (req, res) => {
|
|
1783
|
+
const deal = await storage.getDeal(req.params.dealId);
|
|
1784
|
+
if (!deal) {
|
|
1785
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.dealId);
|
|
1789
|
+
const lineItem = lineItems.find((li) => li.id === req.params.lineItemId);
|
|
1790
|
+
if (!lineItem) {
|
|
1791
|
+
return res.status(404).json({ error: "Line item not found" });
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const host = `${req.protocol}://${req.get("host")}`;
|
|
1795
|
+
const vastUrl = generateVastUrl(req.params.dealId, req.params.lineItemId, host);
|
|
1796
|
+
const html = generateVastHtml(deal.name, lineItem.name, vastUrl);
|
|
1797
|
+
|
|
1798
|
+
const sanitizedName = lineItem.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1799
|
+
|
|
1800
|
+
res.setHeader("Content-Type", "application/zip");
|
|
1801
|
+
res.setHeader("Content-Disposition", `attachment; filename="vast-player-${sanitizedName}.zip"`);
|
|
1802
|
+
|
|
1803
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
1804
|
+
archive.pipe(res);
|
|
1805
|
+
archive.append(html, { name: `vast-player-${sanitizedName}/index.html` });
|
|
1806
|
+
archive.append(
|
|
1807
|
+
JSON.stringify({ name: lineItem.name, dealId: req.params.dealId, lineItemId: req.params.lineItemId, vastUrl }, null, 2),
|
|
1808
|
+
{ name: `vast-player-${sanitizedName}/config.json` }
|
|
1809
|
+
);
|
|
1810
|
+
archive.finalize();
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
app.get("/api/deals/:id/distribution/download-all", async (req, res) => {
|
|
1814
|
+
const deal = await storage.getDeal(req.params.id);
|
|
1815
|
+
if (!deal) {
|
|
1816
|
+
return res.status(404).json({ error: "Deal not found" });
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (!deal.acceptanceSent) {
|
|
1820
|
+
return res.status(400).json({ error: "Distribution is only available after deal activation" });
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.id);
|
|
1824
|
+
if (lineItems.length === 0) {
|
|
1825
|
+
return res.status(400).json({ error: "No line items found for this deal" });
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const host = `${req.protocol}://${req.get("host")}`;
|
|
1829
|
+
const sanitizedDealName = deal.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1830
|
+
|
|
1831
|
+
res.setHeader("Content-Type", "application/zip");
|
|
1832
|
+
res.setHeader("Content-Disposition", `attachment; filename="vast-players-${sanitizedDealName}.zip"`);
|
|
1833
|
+
|
|
1834
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
1835
|
+
archive.pipe(res);
|
|
1836
|
+
|
|
1837
|
+
lineItems.forEach((li) => {
|
|
1838
|
+
const vastUrl = generateVastUrl(req.params.id, li.id, host);
|
|
1839
|
+
const html = generateVastHtml(deal.name, li.name, vastUrl);
|
|
1840
|
+
const sanitizedLiName = li.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1841
|
+
archive.append(html, { name: `${sanitizedDealName}/${sanitizedLiName}/index.html` });
|
|
1842
|
+
archive.append(
|
|
1843
|
+
JSON.stringify({ name: li.name, dealId: req.params.id, lineItemId: li.id, vastUrl }, null, 2),
|
|
1844
|
+
{ name: `${sanitizedDealName}/${sanitizedLiName}/config.json` }
|
|
1845
|
+
);
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
archive.finalize();
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// VAST endpoint (simulated response)
|
|
1852
|
+
app.get("/vast/v1/:dealId/:lineItemId", async (req, res) => {
|
|
1853
|
+
const deal = await storage.getDeal(req.params.dealId);
|
|
1854
|
+
if (!deal) {
|
|
1855
|
+
return res.status(404).send('<VAST version="3.0"/>');
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const lineItems = await storage.getLineItemsByDeal(req.params.dealId);
|
|
1859
|
+
const lineItem = lineItems.find((li) => li.id === req.params.lineItemId);
|
|
1860
|
+
if (!lineItem) {
|
|
1861
|
+
return res.status(404).send('<VAST version="3.0"/>');
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
res.setHeader("Content-Type", "application/xml");
|
|
1865
|
+
res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|
1866
|
+
<VAST version="3.0">
|
|
1867
|
+
<Ad id="${lineItem.id}">
|
|
1868
|
+
<InLine>
|
|
1869
|
+
<AdSystem>Influence DOOH Adserver</AdSystem>
|
|
1870
|
+
<AdTitle>${lineItem.name}</AdTitle>
|
|
1871
|
+
<Impression><![CDATA[${req.protocol}://${req.get("host")}/api/track/impression?deal=${req.params.dealId}&li=${req.params.lineItemId}&screen=${req.query.screen_id || ""}&player=${req.query.player_id || ""}]]></Impression>
|
|
1872
|
+
<Creatives>
|
|
1873
|
+
<Creative>
|
|
1874
|
+
<Linear>
|
|
1875
|
+
<Duration>00:00:15</Duration>
|
|
1876
|
+
<MediaFiles>
|
|
1877
|
+
<MediaFile delivery="progressive" type="video/mp4" width="1920" height="1080">
|
|
1878
|
+
<![CDATA[${req.protocol}://${req.get("host")}/api/creatives/${lineItem.id}/media]]>
|
|
1879
|
+
</MediaFile>
|
|
1880
|
+
</MediaFiles>
|
|
1881
|
+
</Linear>
|
|
1882
|
+
</Creative>
|
|
1883
|
+
</Creatives>
|
|
1884
|
+
</InLine>
|
|
1885
|
+
</Ad>
|
|
1886
|
+
</VAST>`);
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
return httpServer;
|
|
1890
|
+
}
|