Introduction
AdSpot is a full-stack, multi-tenant digital out-of-home (DOOH) advertising platform. It provides a marketplace where advertisers can create and manage campaigns displayed on digital screens owned by publishers, while admins oversee the entire ecosystem.
Key Highlights
Targeted Advertising
Target campaigns by city, region, venue type, and time slots for maximum impact.
Automated Billing
Per-view charging with dynamic rate multipliers, daily budgets, and real-time spend tracking.
Real-time Monitoring
Socket.io-powered live screen heartbeats, campaign status updates, and instant analytics.
Multi-language Support
Full i18n with RTL support, database-backed translations, and admin translation management.
Architecture
AdSpot follows a modular monolith architecture with clear separation between frontend and backend, connected via RESTful APIs and WebSocket channels.
Tech Stack
Frontend
- React 18 + TypeScript
- Vite (build tool & HMR)
- Ant Design (UI components)
- Tailwind CSS (utility styling)
- Redux Toolkit (state management)
- React Router v7 (routing)
- Socket.io Client (real-time)
- Recharts (data visualization)
Backend
- NestJS v11 + Express
- TypeScript (strict mode)
- Prisma ORM (type-safe queries)
- MySQL 8.0 (primary database)
- Redis (optional caching)
- Passport + JWT (auth)
- Socket.io (WebSockets)
- Multer (file uploads)
DevOps & Tools
- Docker + Docker Compose
- Vercel (serverless deploy)
- Prisma Migrations
- ESLint + TypeScript checks
- Swagger / OpenAPI docs
- Nodemailer (SMTP email)
- Supabase Storage (optional)
- bcrypt (password hashing)
- Jest + Supertest (E2E testing)
- React Testing Library (unit tests)
- Service Worker (offline support)
Features
Campaign Management
Create, schedule, and track advertising campaigns with timeslot scheduling, location targeting, and budget controls.
Screen Network
Register digital screens with code-based onboarding, heartbeat monitoring, and real-time status tracking.
Media Library
Upload images and videos with admin approval workflow. Manage media assets across campaigns.
Analytics Dashboard
Real-time view tracking, revenue metrics, screen performance, and campaign ROI analysis.
Payment Processing
Integrated Stripe, PayPal, and Razorpay gateways with webhook handling, balance management, and transaction history.
Dynamic Pricing
Per-view charging with location multipliers, peak hour pricing, and configurable rate management.
Multi-role Access
Dedicated dashboards for Admin, Advertiser, and Publisher roles with granular permission controls.
Internationalization
Database-driven i18n with RTL support, bulk translation management, and import/export capabilities.
Early Bird Program
Time-limited prelaunch offers with reduced fees, higher revenue shares, and trial credits for early adopters.
Landing Page CMS
Admin-editable landing page with hero slides, benefits, features, testimonials, and how-it-works sections.
Location Targeting
Configurable cities, regions, and venue types for precise geographic campaign targeting.
Activity Logging
Comprehensive audit trail with IP tracking, user agent logging, and configurable retention policies.
Dynamic Branding
Admin-controlled business name, logo, and email that propagate across the landing page, dashboards, and notifications. Users can upload personal profile avatars.
Multi-currency Support
Admin sets a platform-wide primary currency. All monetary values display with the correct symbol and conversion in real time across Admin, Advertiser, and Publisher views.
Offline Ad Playback
Service Worker-based media caching ensures ads continue playing during network outages, with automatic sync when connectivity is restored.
Screen Concurrency Control
WebSocket-based single-session enforcement per screen prevents double-billing and unauthorized concurrent connections.
Platform Fee & Tax System
Granular fee and tax calculation with per-transaction metadata, earnings breakdowns, and early-bird rate overrides.
Automated Testing
Jest unit tests and 18 E2E test suites covering financial calculations, WebSocket sessions, and API endpoints with HTML report generation.
How It Works
AdSpot operates as a three-sided marketplace connecting advertisers, publishers, and an admin platform.
For Advertisers
Sign Up
Create account & add funds
Upload Media
Images or videos
Create Campaign
Target screens & set budget
Go Live
Ads display on screens
Track Results
Views, spend & ROI
For Publishers
Register
Create publisher account
Add Screens
Register with code
Set Location
City, venue & coordinates
Display Ads
Screen shows campaigns
Earn Revenue
Request payouts
Campaign Lifecycle
Draft
Advertiser creates a campaign, selects media, targets screens by location/venue type, sets daily and total budgets, and configures timeslot schedules.
Active
Campaign goes live during scheduled times. Ads are displayed on assigned screens. Each view is tracked and charged against the campaign budget in real-time.
Monitoring
Advertisers track views, spend, and performance via the analytics dashboard. Daily budgets automatically reset at configured times.
Completion
Campaign ends when the date range expires or budget is exhausted. Final analytics and spend reports are available for review.
Use Cases
Retail Stores
Display promotional ads on in-store screens. Target specific locations, schedule ads during peak shopping hours, and track foot traffic impact.
Restaurants & Cafes
Digital menu boards and promotional displays. Schedule lunch specials during morning hours and dinner deals in the afternoon.
Gyms & Fitness
Target health-conscious audiences with fitness products, supplements, and wellness brand advertisements.
Transit Hubs
High-traffic locations like bus stations and airports. Maximize impressions with always-on campaigns targeting commuters.
Hotels & Hospitality
Lobby and common area displays for local attractions, restaurant recommendations, and event promotions.
Office Buildings
Corporate lobby displays for B2B advertising, tenant announcements, and professional service promotions.
Shopping Malls
Directory screens and promotional displays across multiple zones. Target by floor, wing, or venue type within the mall.
Healthcare
Waiting room displays in hospitals and clinics. Promote health services, pharmaceuticals, and wellness programs.
User Roles & Permissions
ADMIN Administrator
- Full system access and configuration
- User management (create, edit, deactivate, adjust balances)
- Media content approval and rejection
- Screen and campaign oversight
- Payment gateway configuration (Stripe/PayPal/Razorpay)
- Location management (cities, regions, venue types)
- Language and translation management
- Landing page CMS editing
- Activity log review and audit
- Early bird program configuration
- Rate and charging management
- Publisher payout approval
- Platform-wide branding — business name, logo, primary currency
- Upload profile avatar
ADVERTISER Advertiser
- Create and manage advertising campaigns via full Campaign Workspace
- Upload media (images/videos) for approval
- Configure timeslot scheduling and location targeting
- Set daily and total budget limits
- Add funds via Stripe, PayPal, or Razorpay
- View campaign analytics and ROI metrics
- Track spending and transaction history
- Monitor real-time campaign performance (spend, cost-per-day, budget %)
- Upload profile avatar
PUBLISHER Publisher
- Register and manage digital screens
- Set screen location, venue type, and coordinates using admin-defined city/region lists
- Monitor screen heartbeat and online status
- View earnings with full Gross / Net breakdown
- Request payouts (bank transfer)
- Track screen occupancy and view metrics
- Generate and manage screen access codes
- Upload profile avatar
Campaign Management
Campaigns are the core of AdSpot. Advertisers create campaigns to display their media content on targeted digital screens. The create and edit flows are full-page Campaign Workspace views that include live Campaign Insights — showing real-time spend, remaining budget, cost per day, and budget usage — rather than simple modals.
Campaign Properties
| Property | Description |
|---|---|
| Name & Description | Campaign title and details |
| Media | Linked approved media asset (image or video) |
| Date Range | Start and end dates for the campaign |
| Total Budget | Maximum total spend for the campaign |
| Daily Budget | Maximum daily spend (resets automatically) |
| Target Screens | Selected screens filtered by location and venue type |
| Schedule Type | ALWAYS_ON or SCHEDULED with timeslot rules |
| Timezone | Timezone for schedule calculations |
| Status | DRAFT → ACTIVE → PAUSED → COMPLETED |
| Spent Amount | Real-time total charged against the campaign so far |
| Cost Per Day | Calculated daily spend rate based on recent charging history |
Ad Scheduling (Time-Slot)
Campaigns can be set to Always On (run at all times when active) or a Custom Schedule where ads only display during specific days and hours. This lets advertisers target peak engagement windows — for example, a breakfast brand running ads Mon–Fri 7am–10am only.
| Field | Description |
|---|---|
scheduleEnabled | false = Always On; true = Custom Schedule active |
scheduleData.timezone | IANA timezone string (e.g. America/New_York). Schedule rules are evaluated in this timezone. |
scheduleData.rules[] | Array of rule objects: { days, start, end } |
rule.days | Array of day numbers: 0 = Sunday … 6 = Saturday |
rule.start / rule.end | 24-hour time strings: "09:00" – "17:00" |
Example schedule payload (Mon–Fri, 9am–5pm Eastern):
{
"scheduleEnabled": true,
"scheduleData": {
"timezone": "America/New_York",
"rules": [
{ "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "17:00" }
]
}
}
Campaign Lifecycle Automation
Two automated cron jobs manage campaign state without manual intervention:
- Auto-Complete — Runs every minute; marks any
ACTIVEcampaign whoseend_datehas passed asCOMPLETED. - Daily Spend Reset — Runs at midnight UTC; resets the
daily_spentcounter for all campaigns, restoring daily budget headroom.
Screen Management
Screens are digital displays registered by publishers that show advertising campaigns to viewers.
Screen Registration
Publishers register screens using a code-based system:
Generate Code
Publisher creates a new screen entry and receives a unique registration code.
Enter Code on Device
The physical screen device uses the code to authenticate and link to the publisher's account.
Configure Details
Set resolution, orientation, venue type, city/region, and GPS coordinates.
Screen Properties
| Property | Description |
|---|---|
| Resolution | Width and height in pixels |
| Orientation | landscape or portrait |
| Venue Type | RETAIL, RESTAURANT, CAFE, GYM, TRANSIT, OFFICE, HOSPITAL, MALL, HOTEL, OTHER |
| Location | City, region, venue name, latitude/longitude |
| Status | active, inactive, maintenance |
| Access Code | Authentication code for screen device (regeneratable) |
Heartbeat System
Active screens send periodic heartbeat signals to the server over the /screens WebSocket namespace, updating their last_active timestamp. This allows the platform to track screen online/offline status in real-time and alert publishers to connectivity issues. Each heartbeat also triggers a charging calculation for active campaigns.
Concurrency Control
Only one WebSocket session is permitted per screen at any time. Duplicate connection attempts are rejected with a screen:already_in_use event. Screens that stop sending heartbeats are automatically disconnected after 90 seconds, freeing the slot for re-authentication. See the Screen Concurrency section for full details.
Availability Schedule
Publishers can define operating hours for each screen — the hours during which the screen accepts and displays ad campaigns. Outside these hours, the screen returns an empty playlist regardless of what campaigns are assigned to it.
The availability schedule follows the same structure as campaign time-slot rules:
| Field | Description |
|---|---|
availabilityEnabled | false = Always available (default); true = Custom hours enforced |
availabilitySchedule.timezone | IANA timezone string for evaluating the rules (e.g. Asia/Kolkata) |
availabilitySchedule.rules[] | Same { days, start, end } rule objects as campaign schedules |
Example — a coffee shop screen open every day 8am–10pm IST:
PUT /api/screens/:id/availability
{
"availabilityEnabled": true,
"availabilitySchedule": {
"timezone": "Asia/Kolkata",
"rules": [
{ "days": [0,1,2,3,4,5,6], "start": "08:00", "end": "22:00" }
]
}
}
Media Library
The media library stores all advertising content (images and videos) uploaded by advertisers.
Approval Workflow
Upload
Advertiser uploads media
Pending
Awaits admin review
Approved
Ready for campaigns
- Supported formats: JPEG, PNG, GIF, MP4, AVI
- Maximum file size: 10MB (configurable)
- Admins can approve or reject media with reasons
- Only approved media can be used in campaigns
- Users can only view and manage their own media
Analytics & Reporting
AdSpot provides comprehensive analytics for all user roles.
Tracked Metrics
Views
Total views, view duration, views per screen, and views per campaign with time-series data.
Revenue
Revenue per view, total revenue, daily revenue, and revenue breakdown by screen/campaign.
Performance
Campaign ROI, screen occupancy rates, trend analysis, and comparative performance metrics.
Time Periods
Analytics can be filtered by predefined periods (7d, 30d, 90d) or custom date ranges. Each role sees metrics relevant to their perspective:
- Admin: System-wide analytics, total revenue, user growth
- Advertiser: Campaign performance, spend tracking, ROI
- Publisher: Earnings, screen performance, occupancy metrics
Payment System
AdSpot integrates with Stripe, PayPal, and Razorpay for secure payment processing.
Payment Flow
Checkout Session
Advertiser initiates a payment. The backend creates a checkout session with the selected gateway (Stripe, PayPal, or Razorpay).
Payment Processing
User completes payment on the gateway's hosted page. Supports multiple currencies (default: USD).
Webhook Confirmation
Payment gateway sends a webhook to confirm the transaction. Raw body verification ensures security.
Balance Update
User's account balance is credited. Transaction is recorded in the payment history.
Charging & Rate Management
AdSpot uses a per-view charging model with dynamic rate calculation.
Rate Calculation
Final Rate = Base Rate × Location Multiplier × Time Multiplier Example: Base Rate: $0.05 per view Location Multiplier: 1.5x (mall location) Time Multiplier: 2.0x (peak hours 9AM-5PM) ──────────────────────────────── Final Rate: $0.15 per view
Charging Features
- 10-second billing intervals — Charges calculated per view period
- Peak hour pricing — Configurable peak hours (default 9AM-5PM) with time multiplier
- Location-based pricing — Different rates for different venue types
- Budget validation — Prevents charging when daily/total budgets are exhausted
- Platform fee & tax — Automatically deducted per transaction; configurable per-role rates
- Audit trail — Every charge is recorded with rate, fee, and tax details for full transparency
Early Bird Program
A time-limited prelaunch program offering special benefits to early adopters.
| Benefit | Early Bird | Regular |
|---|---|---|
| Platform Fee | 5% | 15% |
| Publisher Revenue Share | 85% | 70% |
| Trial Credit | $25 | None |
| Setup Fee | Free | Standard |
| Duration | 12 months | - |
Admins can configure the program end date, available slots, pricing, and benefit percentages via the admin dashboard.
Internationalization (i18n)
AdSpot supports multiple languages with full RTL (right-to-left) layout support.
Features
- Database-backed translations — All translations stored in the database, not JSON files
- Namespace organization — Translations grouped by feature area
- Status tracking — Each translation marked as
missing,translated,needs_review, orapproved - Bulk operations — Import/export translations, bulk updates
- RTL support — Automatic layout direction switching for Arabic, Hebrew, etc.
- Completion stats — Track translation coverage per language
- Locale file sync — Validate and sync with frontend locale files
Dynamic Branding
AdSpot can be white-labelled entirely from the Admin dashboard. All brand-related settings persist in the database and propagate instantly across every surface — no code changes or redeployment required.
Configurable Brand Elements
| Element | Surfaces |
|---|---|
| Business Name | Landing page hero & footer, dashboard headers, email subjects & footers, browser title |
| Logo | Landing page navbar, admin / advertiser / publisher sidebars, email headers |
| Admin Contact Email | Landing page newsletter / contact section displayed below the subscription subtitle |
Logo Upload
The Admin settings page includes a logo upload control wired to a dedicated endpoint. The uploaded file is stored and served as the platform logo. Changing the logo takes effect immediately for all logged-in users on the next page refresh.
Profile Avatars
All user roles — Admin, Advertiser, and Publisher — can upload a personal profile avatar. The avatar is stored server-side and persists across sessions. It appears in the dashboard navigation bar and profile pages.
Multi-currency Support
The platform operates in a single admin-defined primary currency. All monetary amounts — campaign budgets, charges, earnings, transaction history — display with the correct currency symbol and are converted in real time when the currency changes.
How It Works
- Primary currency is set once by the Admin in System Settings (e.g. USD, EUR, GBP, INR). This acts as the platform base currency.
- Dynamic symbol display — every monetary field across Admin, Advertiser, and Publisher dashboards renders the configured currency symbol automatically.
- Real-time conversion — when the primary currency is changed, all displayed values update immediately without requiring data migration.
- Payment gateways (Stripe, PayPal, Razorpay) are initiated with the configured currency code, ensuring checkout sessions match the platform setting.
ChargingTransaction and PaymentTransaction records store raw numeric amounts without an embedded currency code. Changing the primary currency will cause historical records to display in the new currency symbol. For consistency, set the primary currency before going live and avoid changing it after real transactions have occurred.
Where to Configure
Go to Admin → Settings → Currency to select the platform primary currency from the supported list.
Platform Fees & Tax System
AdSpot applies a transparent, multi-tier fee and tax structure to every charging transaction. Fees are calculated per view and recorded with granular metadata for full auditability.
Fee Calculation
Advertiser pays: Full charged amount (e.g. $10.00)
Platform Fee: chargeAmount × platformFeeRate
e.g. $10.00 × 15% = $1.50
Publisher Tax: platformFee × publisherTaxRate
e.g. $1.50 × 18% = $0.27
Total Deduction: platformFee + tax = $1.77
Publisher Earning: chargeAmount − totalDeduction = $8.23
Fee Rates
| Setting | Default | Early Bird |
|---|---|---|
| Platform Fee | 15% | 5% |
| Publisher Tax (on fee) | 18% | 18% |
| Advertiser Tax | 18% | 18% |
| Publisher Revenue Share | 70% | 85% |
Publisher Earnings Breakdown
The Publisher Dashboard displays a complete earnings breakdown for full financial transparency:
- Gross Revenue — Total amount charged to advertisers for views on the screen
- Platform Fee — AdSpot's share deducted from gross revenue
- Tax on Fee — Tax applied to the platform fee portion
- Net Earnings — Amount actually credited to the publisher (Gross − Platform Fee − Tax)
ChargingTransaction record stores the exact rates applied at the time of charge — base rate, location multiplier, time multiplier, platform fee rate, and tax rate — ensuring complete auditability even if rates change later.
Admin Configuration
All fee rates are configurable from the Admin dashboard under System Settings. Changes apply to future transactions only; existing transaction records retain their original rates.
Offline Ad Support & Sync
Screens continue playing ads even when internet connectivity is disrupted. A Service Worker intercepts media requests and serves cached content as a fallback, then automatically synchronizes when the network is restored.
How It Works
Media Caching (Online)
When a screen fetches ad media (/api/media/) successfully, the Service Worker stores the response in the ads-media-cache-v1 browser cache. Images and videos are cached on first load.
Network-First Fetch Strategy
Every subsequent media request first attempts a live network fetch to get the freshest content. On success, the cache is updated. This ensures screens always have up-to-date ad content when online.
Offline Fallback
If the network request fails (connectivity loss), the Service Worker serves the previously cached media directly. The ad loop continues uninterrupted using locally cached images and videos.
Video Streaming Support
Cached videos support HTTP range requests (byte-range / partial content, HTTP 206). The Service Worker reconstructs range responses from the full cached video, enabling smooth video playback offline.
Automatic Sync on Reconnect
When connectivity is restored, the screen automatically resumes live fetching, updates cached content with any new assets, and re-establishes its WebSocket heartbeat connection to resume normal billing.
Implementation Details
| Component | Location | Role |
|---|---|---|
| Service Worker | public/sw.js | Intercepts media requests, manages cache |
| SW Registration | src/main.tsx | Registers SW on app load |
| Cache Name | ads-media-cache-v1 | Versioned cache store for media assets |
| Intercepted Routes | /api/media/* | All ad media fetch requests |
503 Service Unavailable response. The screen player handles this gracefully by skipping the unavailable asset and continuing with cached content.
Screen Concurrency Restriction
To prevent double-billing and unauthorized simultaneous sessions, AdSpot enforces a strict one-active-connection-per-screen rule via the WebSocket gateway.
How It Works
The ScreensGateway maintains an in-memory map of active screen connections. When a screen authenticates over WebSocket:
- The gateway checks if another socket is already registered for the same screen ID.
- If a duplicate is detected, the new connection is rejected immediately with a
screen:already_in_useevent. - Only one socket may actively stream heartbeats and trigger billing for any given screen at a time.
- When a screen disconnects, its entry is removed from the map, allowing a new connection to authenticate.
WebSocket Events
| Event | Direction | Description |
|---|---|---|
screen:authenticate | Client → Server | Screen sends access code to authenticate. Blocked if screen already has an active session. |
screen:already_in_use | Server → Client | Emitted to a rejected connection attempt when another session is active for the same screen. |
screen:heartbeat | Client → Server | Periodic health signal. Triggers charging. Resets the 90-second timeout timer. |
screen:disconnected | Server → Client | Notifies client of server-side disconnect (e.g. heartbeat timeout). |
Heartbeat Timeout
Each connected screen has a 90-second inactivity timer. A global monitor runs every 30 seconds and disconnects any screen that has not sent a heartbeat within the window. This prevents "zombie" connections that could block legitimate sessions.
WebSocket Gateway Config
Namespace: /screens Ping Interval: 25 000 ms (server → client keep-alive ping) Ping Timeout: 60 000 ms (client must respond within 60 s) HB Timeout: 90 000 ms (server disconnects if no heartbeat in 90 s) HB Monitor: every 30 s (global check across all connected screens)
Time-Slot Based Ad Scheduling
AdSpot enforces a two-gate intersection model for determining when an ad plays on a screen. Both gates must be open simultaneously for any ad to be displayed.
Gate 1 — Screen Availability
Publisher-defined operating hours. The screen's hard cutoff — if the screen is closed, nothing plays regardless of campaign settings.
Gate 2 — Campaign Schedule
Advertiser-defined active hours per campaign. Controls when a specific campaign is eligible to display on any screen.
An ad only plays during the intersection of both windows. If a screen is open 8am–10pm and a campaign runs 9am–5pm weekdays, ads play Mon–Fri 9am–5pm only. Neither side alone determines the result.
Worked Example
Suppose Screen 1 is a coffee shop display with operating hours 12pm–1pm every day (lunch rush only). Three campaigns are assigned:
| Campaign | Schedule | Plays on Screen 1? |
|---|---|---|
| Campaign A | Always On | ✓ Yes — campaign has no restriction; screen is open 12pm–1pm → ad plays 12pm–1pm |
| Campaign B | Mon–Fri 10am–2pm | ✓ Yes (weekdays) — intersection of 10am–2pm ∩ 12pm–1pm = 12pm–1pm on weekdays |
| Campaign C | Mon–Fri 2pm–6pm | ✗ No — campaign window (2pm–6pm) does not overlap with screen window (12pm–1pm) |
Evaluation Logic
When a screen requests its active playlist (GET /api/campaigns/screen/:id), the backend applies the two gates in order:
Screen Gate
Is availabilityEnabled true? If yes, is the current time within any availability rule for this screen? If the screen is outside its operating hours → return an empty playlist [] immediately. No campaign is evaluated.
Campaign Gate
For each ACTIVE campaign assigned to the screen: is scheduleEnabled true? If yes, is the current time within one of its schedule rules? Campaigns outside their active window are filtered out. Always-On campaigns (scheduleEnabled: false) always pass this gate.
Playlist Returned
The campaigns that passed both gates form the active playlist delivered to the screen. Budget enforcement and charging run on top of this filtered set.
Timezone Handling
All schedule rules are evaluated in the timezone stored with the rule, not the server's local time. The backend uses Intl.DateTimeFormat.formatToParts() with the stored IANA timezone to extract the local day-of-week and HH:mm for comparison — no external packages required.
// Pseudocode — same logic for both campaign schedule and screen availability function isOpen(enabled, schedule, now = new Date()) { if (!enabled) return true; // no restriction const { rules, timezone } = schedule; const { dayOfWeek, HH, mm } = extractParts(now, timezone); // Intl API const currentTime = `${HH}:${mm}`; return rules.some(r => r.days.includes(dayOfWeek) && currentTime >= r.start && currentTime <= r.end ); }
availabilityEnabled = true but no rules are defined yet (publisher hasn't configured hours), the screen is treated as open — avoiding unintentional blackouts during setup.For campaign schedule: if
scheduleEnabled = true but no rules are defined, the campaign is treated as inactive (misconfigured) — preventing an accidental "always show" state when the advertiser intended to restrict it.
Data Model
| Model | Field | Type | Default | Description |
|---|---|---|---|---|
| Campaign | scheduleEnabled | Boolean | false | Activates custom schedule mode |
| Campaign | scheduleData | JSON | null | { timezone, rules[] } |
| Screen | availabilityEnabled | Boolean | false | Activates operating hours mode |
| Screen | availabilitySchedule | JSON | null | { timezone, rules[] } |
Both fields share the same JSON rule shape:
{
"timezone": "America/Chicago", // IANA timezone string
"rules": [
{
"days": [1, 2, 3, 4, 5], // 0=Sun, 1=Mon ... 6=Sat
"start": "09:00", // 24-hour HH:mm
"end": "17:00"
},
{
"days": [6], // Saturday only
"start": "10:00",
"end": "14:00"
}
]
}
API Reference
| Endpoint | Role | Description |
|---|---|---|
PUT /api/campaigns/:id/schedule | Advertiser | Set or update campaign schedule (scheduleEnabled + scheduleData) |
GET /api/campaigns/:id/schedule/status | Advertiser | Check if campaign is currently within its schedule window |
PUT /api/screens/:id/availability | Publisher | Set or update screen operating hours (availabilityEnabled + availabilitySchedule) |
GET /api/campaigns/screen/:id | Screen | Fetch active playlist — applies both gates before returning results |
Frontend UI — ScheduleBuilder Component
Both the campaign form (Advertiser) and the screen edit form (Publisher) use a shared <ScheduleBuilder> React component. The Admin campaign detail view shows a read-only version of the same component.
- Toggle — "Always On" vs "Custom Schedule" radio selector
- Timezone picker — Searchable dropdown of all IANA timezones via
Intl.supportedValuesOf('timeZone') - Day buttons — M T W T F S S toggles; enable/disable per day
- Time range — Start & end TimePicker (24h format) per rule slot
- Overlap guard — Inline error if two slots on the same day overlap
- Weekly summary — "X active hrs/week" calculated from all enabled rules
ScheduleBuilder validates time slots in real time. If two rules for the same day overlap (e.g. 09:00–13:00 and 12:00–17:00), an inline error is shown and form submission is blocked. The same validation runs server-side in validateScheduleRulesOverlap() before any schedule is persisted.
Programmatic Buying (SSP/DSP Integration)
AdSpot includes a built-in Supply-Side Platform (SSP) that opens publisher screen inventory to external buyers — ad agencies, trading desks, and demand-side platforms (DSPs) such as Vistar Media, StackAdapt, and The Trade Desk. Buyers submit real-time bids for individual screens via an OpenRTB 2.5-compliant inbound API. On every screen heartbeat, AdSpot's mediation engine selects the highest-value winner between programmatic bids and existing direct campaigns.
Architecture Overview
Two actors and two request flows:
AdSpot Admin
Registers buyer accounts (DSP Connections), sets inbound API keys, monitors fill rate, win rate, avg CPM, and manages pre-funded buyer balances from the Admin → DSP Connections dashboard.
Buyer / DSP / Agency
Calls POST /api/programmatic/bid to discover matching screen inventory, then POST /api/programmatic/submit to place a bid at a chosen price with a creative URL. No bid endpoint of their own is required.
Publisher
Enables programmatic on each screen individually and sets a floor CPM. Only bids at or above the floor compete. Publishers see programmatic revenue alongside direct campaign earnings in Publisher Analytics.
Mediation Engine
On each 30-second screen heartbeat, compares the best pending programmatic bid against the best direct campaign CPM. The higher value wins and its creative is delivered to the screen via WebSocket.
DSP Onboarding Flow
Admin Registers the Buyer
Via Admin → DSP Connections → Add DSP. Fields: Name, Inbound API Key (generated by AdSpot), Supported Venue Types, Bid Timeout (ms), Status (testing during integration, active for live billing), and Pre-funded Balance. No bid endpoint URL is needed for the inbound model.
Admin Shares Credentials with the Buyer
The buyer receives their Inbound API Key and two endpoint URLs: POST /api/programmatic/bid (discover inventory) and POST /api/programmatic/submit (place bid). AdSpot's OpenRTB 2.5 inventory spec is provided as documentation.
Publisher Enables Programmatic on Their Screen
Publisher → Screens → Edit → Programmatic Buying section. Toggle Enable Programmatic Buying ON and set a Floor CPM (e.g. $2.00 per 1,000 impressions). AdSpot only accepts bids at or above this floor.
Buyer Discovers Available Inventory
The buyer's system calls POST /api/programmatic/bid with an OpenRTB 2.5 bid request specifying venue types, city/region, and a minimum bid floor. AdSpot responds with matching screens and their details.
Buyer Submits a Bid
The buyer calls POST /api/programmatic/submit with their chosen screen ID, bid price, and creative URL (adMarkup — must be an https:// direct media URL). The bid is stored as pending for the next 30-second heartbeat window.
Mediation on Screen Heartbeat
On each screen heartbeat, AdSpot finds the highest pending bid within the last 30 seconds, compares it to the best direct campaign CPM (daily_price / 144 × 1000), and selects the winner. The winning creative is sent to the screen via screen:programmatic_ad WebSocket event.
Impression Confirmed & Billed
The screen renders the ad for displayDuration seconds, then emits screen:programmatic_ad_displayed back to the server. Only at this point is the DSP's balance debited and the publisher's earnings credited — preventing billing for failed renders.
Bid Discovery — POST /api/programmatic/bid
Authenticated via Authorization: Bearer <inbound-api-key> (or x-api-key header). Returns matching screens as an OpenRTB 2.5 BidResponse.
Request
POST /api/programmatic/bid
Authorization: Bearer <inbound-api-key>
Content-Type: application/json
{
"id": "buyer-auction-id-abc123", // buyer's own auction ID (UUID)
"imp": [{
"id": "imp-1",
"bidfloor": 2.00, // minimum CPM the buyer will pay
"bidfloorcur": "USD"
}],
"dooh": {
"venuetype": ["transit", "retail"] // optional: filter by venue
},
"site": {
"geo": {
"city": "Mumbai", // optional: filter by location
"region": "Maharashtra"
}
}
}
Response
{
"id": "buyer-auction-id-abc123",
"seatbid": [{
"bid": [
{
"id": "scr-xxxxxxxx",
"price": 2.50, // screen's floor CPM
"w": 1920, "h": 1080,
"ext": {
"screenId": "scr-xxxxxxxx",
"screenName": "Mall Entrance — Ground Floor",
"venuetype": "1.2", // IAB DOOH venue code
"location": "Mumbai, Maharashtra"
}
}
]
}]
}
Bid Submission — POST /api/programmatic/submit
After reviewing the inventory response, the buyer submits a bid for a specific screen:
POST /api/programmatic/submit
Authorization: Bearer <inbound-api-key>
{
"screenId": "scr-xxxxxxxx",
"bidPrice": 3.50, // CPM in USD — must be ≥ screen's floor
"adMarkup": "https://cdn.buyer.com/creative.mp4", // https:// direct URL only
"winNoticeUrl": "https://buyer.com/win?price=${AUCTION_PRICE}",
"lossNoticeUrl": "https://buyer.com/loss?reason=${AUCTION_LOSS}",
"currency": "USD"
}
adMarkup must be a direct https:// URL pointing to an image (JPEG, PNG, WebP) or video (MP4, WebM). http://, data URIs, and javascript: URLs are rejected. VAST XML and HTML/JS banners are out of scope for v1.
Mediation Engine
On each screen:heartbeat event (~30 seconds), the engine runs:
| Step | Logic |
|---|---|
| 1. Check programmatic flag | If screen.programmaticEnabled = false, skip to direct campaign charging (existing path). |
| 2. Find best pending bid | Query programmatic_bids WHERE status = 'pending' AND created_at > NOW() - 30s ORDER BY bidPrice DESC LIMIT 1. |
| 3. Find best direct CPM | Query active CampaignScreen records: estimate CPM as (daily_price / 144) × 1000. Take the maximum. |
| 4. Compare | programmaticWins = bestBid && bestBid.bidPrice ≥ floorCpm && bestBid.bidPrice > bestDirectCpm |
| 5a. Programmatic wins | Mark bid pending_display → emit screen:programmatic_ad to screen → await display confirmation → bill on confirm. |
| 5b. Direct wins | Run chargingService.processScreenCharging() (existing path). If a bid existed but lost, mark it lost and fire loss notice. |
Bid Status Lifecycle
| Status | Meaning |
|---|---|
pending | Bid submitted; eligible for the next heartbeat mediation window. |
pending_display | Bid won mediation; creative sent to screen; waiting for display confirmation. |
won | Screen confirmed display. DSP balance debited; publisher earnings credited. |
lost | Outbid by a higher programmatic bid or a direct campaign with better CPM. |
timeout | Bid aged beyond the 30-second window without being selected. |
error | Display confirmation not received within 15 seconds after sending the creative. |
nobid | No bids were submitted for this screen in the current window. |
Win & Loss Notices
AdSpot fires fire-and-forget HTTP callbacks to the buyer's registered URLs using OpenRTB macro substitution:
- Win notice (
nurl) — fired whenscreen:programmatic_ad_displayedis confirmed.${AUCTION_PRICE}is replaced with the clearing price. - Loss notice (
lurl) — fired when a bid loses mediation.${AUCTION_LOSS}is replaced with an IAB loss reason code (e.g.102= lost to higher bid).
# Win notice example (called server-side, fire-and-forget) GET https://buyer.com/win?price=3.50&screen=scr-xxxxxxxx # Loss notice example GET https://buyer.com/loss?reason=102&screen=scr-xxxxxxxx
Billing & Fee Split
Billing is triggered only after the screen emits screen:programmatic_ad_displayed. The gross amount is calculated as bidPrice (CPM) / 1000 per impression. The split mirrors the direct campaign model:
| Component | Formula | Example ($3.50 CPM bid) |
|---|---|---|
| Gross per impression | bidPrice / 1000 | $0.0035 |
| Platform fee | gross × platformFeePercent | $0.000525 (15%) |
| Tax on fee | platformFee × publisherTax | varies by config |
| Publisher earning | gross − fee − tax | ~$0.002975 |
| DSP balance debit | gross | −$0.0035 |
| Publisher balance credit | publisherEarning | +$0.002975 |
All three operations (create ProgrammaticImpression, decrement DspConnection.balance, increment User.balance for publisher) run inside a single Prisma transaction.
402 Insufficient Balance.
Database Models
DspConnection
| Field | Type | Description |
|---|---|---|
id | UUID | Primary key |
name | String | Buyer display name (e.g. "Vistar Media") |
inboundApiKey | String | Secret key the buyer includes in every API call |
endpoint | String? | Future: outbound bid endpoint URL |
status | DspStatus | active | inactive | testing |
bidTimeoutMs | Int | Max ms to wait for bid response (default 150) |
supportedVenueTypes | JSON | Array of venue types this buyer targets |
balance | Decimal | Pre-funded USD balance; decremented on each won impression |
enabled | Boolean | Toggle to pause a buyer without deleting |
ProgrammaticBid
| Field | Type | Description |
|---|---|---|
auctionId | UUID | Buyer's own auction reference ID |
dspId | UUID | FK → DspConnection |
screenId | UUID | FK → Screen |
bidPrice | Decimal(10,4) | Bid CPM in USD |
adMarkup | Text | Creative URL (https:// only) |
winNoticeUrl | String? | URL called on win with ${AUCTION_PRICE} macro |
lossNoticeUrl | String? | URL called on loss with ${AUCTION_LOSS} macro |
status | ProgrammaticBidStatus | pending → pending_display → won / lost / error / timeout |
lossReasonCode | Int? | IAB OpenRTB loss reason code |
ProgrammaticImpression
| Field | Type | Description |
|---|---|---|
screenId | UUID | Screen that displayed the ad |
bidId | UUID | Winning ProgrammaticBid |
publisherId | UUID | Publisher who owns the screen |
dspId | UUID | Buyer that won the auction |
grossAmount | Decimal | Total charged to DSP |
platformFee | Decimal | AdSpot's cut |
publisherEarning | Decimal | Amount credited to publisher |
impressionAt | DateTime | Timestamp of confirmed display |
Screen Extension
Two new fields are added to the Screen model:
| Field | Default | Description |
|---|---|---|
programmaticEnabled | false | Publisher opt-in toggle. Must be true for the screen to participate in auctions. |
programmaticFloorCpm | null | Minimum acceptable CPM. Bids below this value are rejected at submission time. |
Updated via: PATCH /api/screens/:id/programmatic (Publisher role required).
API Endpoints Reference
Admin Endpoints (JWT + ADMIN role)
Buyer Endpoints (x-api-key / Bearer token)
Publisher Endpoints (JWT + PUBLISHER role)
Venue Type → IAB DOOH Code Map
| AdSpot Venue Type | IAB DOOH Code |
|---|---|
| RETAIL | 1.1 |
| MALL | 1.2 |
| RESTAURANT | 2.1 |
| CAFE | 2.2 |
| GYM | 3.1 |
| TRANSIT | 4.1 |
| OFFICE | 5.1 |
| HOSPITAL | 6.1 |
| HOTEL | 7.1 |
| OTHER | 0 |
Frontend Surfaces
Admin → DSP Connections
Full CRUD for buyer accounts. Stats row (Total DSPs, Active, Fill Rate, Avg CPM). Tabs: DSP Connections · Auction History · Impressions. Balance credit/debit modal per DSP.
Publisher → Add/Edit Screen
New Programmatic Buying card below Pricing: on/off toggle + conditional Floor CPM input. Settings saved via the dedicated PATCH /screens/:id/programmatic endpoint.
Publisher → Analytics → Programmatic tab
Impressions, Revenue, Avg CPM stats cards + daily area chart of programmatic earnings. Loaded on demand when the tab is first selected.
Screen Device (RemoteScreen)
Listens for screen:programmatic_ad WebSocket event. Renders external image/video fullscreen as a z-index overlay, interrupting the normal campaign rotation. Emits screen:programmatic_ad_displayed after displayDuration seconds to trigger billing.
AI Chat Assistant
AdSpot includes a built-in AI assistant (powered by Claude claude-haiku-4-5) accessible on every authenticated page as a floating chat widget, and on the landing page with predefined quick-start cards.
Floating Widget
Visible on all authenticated pages (Admin, Advertiser, Publisher). Click the chat button (bottom-right) to open. Supports free-form conversation about the platform, screens, campaigns, and bids.
Landing Page Quick Start
Three predefined cards before free-form chat begins: About the Platform · Screen Availability · Ongoing Bids. Selecting a card sets the conversation topic and seeds the AI context with live database data.
The AI endpoint (POST /api/ai-chat/message) requires no authentication — making it accessible to unauthenticated landing page visitors. It fetches live data (active screens by venue and city, current DSP bid counts) when the topic is screens or bids, providing contextually accurate answers.
How does a DSP actually bid? Do they need their own bid endpoint?
POST /api/programmatic/bid to discover inventory and POST /api/programmatic/submit to place a bid. AdSpot does all the auction logic server-side. This means any buyer — even a small ad agency with no bidding infrastructure — can integrate in hours rather than months.
What happens if the screen fails to render the programmatic ad?
error and the DSP is not charged. Billing only happens after the screen emits screen:programmatic_ad_displayed. If no confirmation is received within 15 seconds, the server auto-expires the pending impression and the DSP retains their balance for that slot.
Can programmatic ads run alongside direct campaigns on the same screen?
How is the DSP balance managed?
What ad formats does the programmatic endpoint support?
adMarkup field must be an https:// URL pointing directly to the media file. VAST XML, HTML/JS banners, and MRAID are out of scope for the current release. DSPs must provide a CDN-hosted direct URL, not a wrapper.
USB Export — Offline Digital Signage
Publishers can export campaign content packages as ZIP files, load them onto USB drives, and play ads on Android TV displays without any internet connection. USB mode is an extension of the existing AdSpot Flutter TV app — no separate player is needed.
How It Works
playlist.json manifest, and bundles everything into a downloadable archive.Two-Stage Billing
USB exports use a two-stage billing model to protect advertisers from paying for content that is never deployed to a screen.
| Stage | Amount | When | Transaction Type |
|---|---|---|---|
| Booking Fee | 25% of campaign cost | Immediately on export generation | USB_EXPORT_BOOKING — COMPLETED |
| Delivery Fee | 75% of campaign cost | Held until publisher confirms deployment | USB_EXPORT_DELIVERY — PENDING |
If the publisher does not confirm deployment within the grace period after the export expires, all PENDING delivery transactions are auto-refunded to advertisers by a daily scheduled job.
Cost Calculation
Cost is calculated per campaign, then summed for the export total:
── Per-campaign ──────────────────────────────────────────────────────────────
Base Cost = usbBaseDailyRate × numberOfDays × locationMultiplier
× earlyBirdMultiplier (if advertiser qualifies; otherwise × 1)
Booking Fee = Base Cost × usbBookingFeePercent / 100 (default: 25%)
Delivery Fee = Base Cost − Booking Fee (default: 75%)
Platform Fee = Base Cost × platformFeeRate
Tax = Platform Fee × publisherTaxRate
Publisher Earning = Base Cost − Platform Fee − Tax
── Export totals ─────────────────────────────────────────────────────────────
Total Cost = Σ Base Cost (all included campaigns)
Total Booking Fee = Σ Booking Fee (all included campaigns)
Total Delivery Fee = Σ Delivery Fee (all included campaigns)
Total Publisher Earning = Σ Publisher Earning (all included campaigns)
Cost Parameters
| Parameter | Description | Default |
|---|---|---|
usbBaseDailyRate | Base cost per campaign per day | $0.50 (Admin-configurable) |
locationMultiplier | From the active ScreenRate record for the screen | Inherited from screen |
numberOfDays | Duration of the export period | From startDate / endDate |
earlyBirdMultiplier | Discount for early-bird advertisers (e.g. 0.5 = 50% off) | usbEarlyBirdDiscount setting |
platformFeeRate | Platform commission for the advertiser (early bird or standard) | 5% early bird / 15% standard |
publisherTaxRate | Tax applied to the platform fee | 18% |
usbBookingFeePercent | % of base cost charged immediately at export time | 25 |
Advertiser Opt-In
Only campaigns with allowUsbExport = true are eligible for USB exports. Advertisers control this toggle per-campaign in their campaign settings. Default is off (safe by default).
- When the toggle is off, the campaign is never included in any USB export regardless of targeting
- When the toggle is on, the advertiser is shown the billing explanation: 25% charged at export time, 75% held until deployment is confirmed
- Budget validation checks the full cost (booking + delivery) before including a campaign — if balance is insufficient, the campaign is excluded and the advertiser is notified
Admin Settings
| Setting | Type | Default | Description |
|---|---|---|---|
usbBaseDailyRate | Decimal | 0.50 | Base rate per campaign per day |
usbMaxExportDays | Integer | 30 | Maximum allowed export duration in days |
usbMaxPackageSizeMb | Integer | 500 | Maximum ZIP size in MB |
usbAllowedMediaFormats | String[] | ["mp4","jpg","png","gif"] | Allowed media types for USB bundles |
usbEarlyBirdDiscount | Decimal | 0.5 | Cost multiplier for early-bird advertisers |
usbBookingFeePercent | Integer | 25 | % of total cost charged at export time |
usbDeliveryGracePeriodDays | Integer | 7 | Days after expiry before unconfirmed delivery fee is auto-refunded |
API Endpoints
| Method | Endpoint | Role | Description |
|---|---|---|---|
| POST | /api/usb-export/:screenId/preview | Publisher | Cost preview — returns per-campaign breakdown before any charges |
| POST | /api/usb-export/:screenId | Publisher | Generate & download ZIP; charges booking fee, holds delivery fee |
| GET | /api/usb-export/history | Publisher/Admin | List past exports with pagination; filter by screen, date, status |
| GET | /api/usb-export/:exportId/download | Publisher/Admin | Re-download a previous export ZIP |
| POST | /api/usb-export/:exportId/confirm-deployment | Publisher/Admin | Mark export as deployed; releases delivery fee to publisher |
| PATCH | /api/usb-export/:exportId/expire | Admin | Force-expire an export |
| GET | /api/usb-export/settings | Admin | Get global USB settings |
| PUT | /api/usb-export/settings | Admin | Update global USB settings |
Flutter Player App (USB Mode)
USB mode is an extension of the existing AdSpot Flutter TV app — no separate install is required. When a USB drive is inserted, the app detects it, validates the manifest, and switches to offline playback mode automatically.
- Manifest validation — Checks HMAC-SHA256 signature, expiry date, and screen ID match before loading
- Schedule engine — Reuses
schedule_utils.dartfrom the existing app to evaluate campaign schedules against USB manifest data - Fallback content — If no valid USB content is found, the app falls back to the configured fallback media
- Auto-revert — When the USB is removed, the app returns to online mode and reconnects via WebSocket
LAUNCHER intent filter alongside the TV launcher, and the USB Host API (ACTION_MEDIA_MOUNTED, UsbManager) is identical on both form factors.
You'll need a USB-C to USB-A OTG adapter to connect the USB drive to the phone. Build the APK in debug mode (
flutter build apk --debug), sideload it, insert the USB drive via the adapter, and USB detection works exactly as on TV.
Lock the phone to landscape orientation for accurate layout testing. Note: the Android Emulator cannot simulate physical USB mount events — a real device is required.
Notification System
AdSpot delivers real-time in-app notifications to users via WebSockets (Socket.io) and persists them in the database so nothing is lost if the user is offline.
Real-Time Push
Notifications are pushed instantly to connected clients over a dedicated Socket.io namespace (/notifications). Each user joins their own private room so messages are never leaked across accounts.
Persistent Store
Every notification is written to the database before the WebSocket push. Users who are offline at the time of delivery retrieve their full notification history via the REST API when they reconnect.
Admin Broadcast
The notifyAdmins() helper queries all users with the ADMIN role and fans out an individual notification to each one — no bespoke broadcast loop required at the call site.
Architecture
Two NestJS providers collaborate to deliver every notification:
NotificationsService
Receives a CreateNotificationDto, persists the record via Prisma, then delegates to the gateway for live delivery. Also exposes findAll, markAsRead, markAllAsRead, delete, and the notifyAdmins broadcast helper.
NotificationsGateway
A Socket.io WebSocket gateway under the /notifications namespace. On connection it places each client in a private room (user_<userId>) using the userId query parameter or a subsequent joinNotificationRoom message. Calls sendNotificationToUser() to emit to that room.
WebSocket Connection
Connect from the frontend using the Socket.io client targeting the /notifications namespace:
import { io } from 'socket.io-client';
const socket = io('/notifications', {
query: { userId: currentUser.id }, // auto-joins room on connect
});
// Or join manually after connection:
socket.emit('joinNotificationRoom', currentUser.id);
// Listen for incoming notifications:
socket.on('notification', (notification) => {
// { id, userId, title, message, type, isRead, metadata, created_at }
dispatch(addNotification(notification));
});
Data Model
| Field | Type | Description |
|---|---|---|
id | string (cuid) | Unique identifier |
userId | string | Owner of the notification |
title | string | Short heading shown in the notification bell |
message | string | Full notification body |
type | string | Free-form category tag (e.g. campaign, payment, media) |
isRead | boolean | Read state; defaults to false |
metadata | JSON | Optional structured payload (e.g. linked entity IDs) |
created_at | DateTime | Creation timestamp |
REST Endpoints
All endpoints require a valid JWT (Authorization: Bearer <token>). The service scopes queries to the authenticated user's ID automatically.
POST /api/notifications endpoint. The NotificationsService.create() and notifyAdmins() methods are called directly from peer services via NestJS dependency injection.
Testing
AdSpot ships with a comprehensive automated test suite covering both unit and end-to-end scenarios.
Unit Tests
Critical frontend components and API interactions are covered with Jest and React Testing Library. Tests are colocated with their components and focus on rendered output and user interaction behaviour.
E2E Test Suite
The backend E2E suite uses Jest with Supertest (HTTP) and socket.io-client (WebSocket), running against a fully bootstrapped NestJS application with mocked Prisma and email providers.
| Spec File | Coverage Area |
|---|---|
charging.e2e-spec.ts | Rate calculation, platform fee/tax, budget enforcement |
screen-concurrency.e2e-spec.ts | WebSocket single-session enforcement, duplicate rejection |
early-bird.e2e-spec.ts | Enrollment, settings, trial credit, stats |
campaigns.e2e-spec.ts | Campaign CRUD, status transitions, budget operations |
screens.e2e-spec.ts | Screen registration, status management |
payments.e2e-spec.ts | Checkout sessions, webhook handling |
analytics.e2e-spec.ts | View tracking, dashboard statistics |
auth.e2e-spec.ts | JWT auth, login/register flows |
publisher.e2e-spec.ts | Earnings, payouts, dashboard |
media.e2e-spec.ts | Upload, approval workflow |
| + 8 more | Users, locations, i18n, branding, health, logging, landing, TV app |
Test Commands
# Run all E2E tests npm run test:e2e # Watch mode (re-run on file change) npm run test:e2e:watch # Generate coverage report npm run test:e2e:coverage # Generate HTML test report npm run test:e2e:report # Output: test/reports/e2e-test-report.html
API Overview
The AdSpot API follows RESTful conventions with a global /api prefix. All responses are wrapped in a standard format:
{
"success": true,
"message": "Operation completed successfully",
"data": { ... }
}
Pagination
List endpoints return paginated results with metadata:
{
"success": true,
"data": [ ... ],
"meta": {
"total": 150,
"page": 1,
"limit": 20,
"hasNext": true,
"hasPrev": false
}
}
Authentication
The API uses JWT (JSON Web Token) for authentication with access and refresh token support.
| Token | Expiry | Usage |
|---|---|---|
| Access Token | 24 hours | Sent as Bearer token in the Authorization header |
| Refresh Token | 7 days | Used to obtain a new access token without re-login |
Auth Endpoints
API Endpoints
Users
Campaigns
Screens
Media
Analytics
Payments
Publisher
Locations
i18n
Notifications
System
Project Structure
Environment Setup
Create a .env file in the project root with the following variables:
# Database DATABASE_URL=mysql://username:password@localhost:3306/adspot_db DATABASE_TYPE=mysql MYSQL_HOST=localhost MYSQL_PORT=3306 MYSQL_USERNAME=your_username MYSQL_PASSWORD=your_password MYSQL_DATABASE=adspot_db # JWT Authentication JWT_SECRET=your-secret-key JWT_EXPIRES_IN=7d # Server PORT=3001 NODE_ENV=development # URLs API_BASE_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 # File Upload UPLOAD_MAX_SIZE=10485760 UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,video/mp4,video/avi # Payment Gateways STRIPE_SECRET_KEY=sk_test_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx PAYPAL_CLIENT_ID=xxx PAYPAL_CLIENT_SECRET=xxx RAZORPAY_KEY_ID=rzp_test_xxx RAZORPAY_KEY_SECRET=xxx RAZORPAY_WEBHOOK_SECRET=xxx # Email (SMTP) SMTP_HOST=smtp.example.com SMTP_USER=your_email@example.com SMTP_PASS=your_password # Supabase Storage (optional) SUPABASE_URL=https://xxx.supabase.co SUPABASE_ANON_KEY=xxx # Redis (optional) REDIS_PASSWORD=redis_password
Installation
Prerequisites
- Node.js 20+
- npm or yarn
- MySQL 8.0 (or Docker)
- Redis (optional, for caching)
Quick Start
# Clone and install git clone <repository-url> cd adspot_project npm install # Setup database npm run prisma:generate npm run prisma:migrate:deploy # Seed initial data npm run seed:admin # Admin user only npm run seed:all # All seed data # Start development npm run dev
Development
Commands
# Run both frontend and backend npm run dev # Run frontend only (Vite) npm run client:dev # Run backend only (NestJS) npm run server:dev # Open Prisma Studio (database GUI) npm run prisma:studio
Development URLs
| Service | URL |
|---|---|
| Frontend (Vite) | http://localhost:5173 |
| Backend API | http://localhost:3001 |
| Swagger Docs | http://localhost:3001/api/docs |
| Prisma Studio | http://localhost:5555 |
Build
# Production build (frontend + backend) npm run build:production # Frontend only npm run build:frontend # Backend only npm run build:backend # Start production server npm run start
Deployment
Docker Compose (Recommended)
# Start all services docker-compose up -d # View logs docker-compose logs -f # Stop services docker-compose down # Rebuild and start docker-compose up -d --build
Docker Services
| Service | Port | Description |
|---|---|---|
| app | 3002 (API), 5174 (Dev) | Application server |
| mysql | 3307 | MySQL 8.0 database |
| redis | 6379 | Redis cache |
Docker Standalone
# Build image npm run docker:build # Run container npm run docker:run
Vercel (Serverless)
The project includes a vercel.json configuration for serverless deployment:
# Install Vercel CLI npm i -g vercel # Deploy vercel
api/index.ts bootstraps NestJS on each cold start and reuses the instance for subsequent requests.
Applying Updates
This section explains how to safely upgrade an existing AdSpot installation to a newer version — whether you are running a local development environment, a self-hosted server, or a Docker-based deployment.
mysqldump -u root -p adspot_db > adspot_backup_$(date +%F).sql
Standard Update (Self-hosted / PM2)
Follow these steps in order. Every step must complete successfully before moving to the next.
Pull latest code
git fetch origin main git reset --hard origin/main
Install / update dependencies
npm ci --production=false
Using npm ci (instead of npm install) ensures a clean, reproducible install that matches package-lock.json exactly.
Regenerate Prisma client
npm run db:generate
Always run this after pulling, even if you do not see schema changes — the generated client must match the checked-in schema.
Apply database migrations
npm run db:migrate:deploy
This applies any pending Prisma migrations to your database in a non-destructive way. It is safe to run on an already up-to-date database.
Rebuild the application
npm run build:production
Restart the server
# If using PM2 pm2 restart adspot --update-env # If running manually npm run start
Automated Deploy Script
The project ships with scripts/deploy.sh, which runs all six steps above in sequence. Use it on any server where the repository is checked out at /var/www/adspot and PM2 manages the process.
# Make executable once chmod +x scripts/deploy.sh # Run update npm run deploy # or directly: ./scripts/deploy.sh
--update-env flag reloads environment variables from the process file on restart. If you use the cluster mode (pm2 start ecosystem.config.js), PM2 performs a rolling reload with no dropped requests.
Environment Variable Checklist
Before restarting, compare your .env file against the latest .env.example (or the Environment Setup section). New features often introduce new required variables.
| Added With | Variable | Purpose |
|---|---|---|
| Platform Fees | PLATFORM_FEE_RATE | Overrides default 15% platform fee (stored in DB settings, no env var required — configure from Admin dashboard) |
| SMTP / Email | SMTP_HOST, SMTP_USER, SMTP_PASS | Required for notification emails; falls back to Ethereal if missing |
| Supabase | SUPABASE_URL, SUPABASE_ANON_KEY | Optional; only needed if using Supabase for media storage |
| Redis | REDIS_PASSWORD | Optional; only needed if Redis caching is enabled |
Updating a Docker Deployment
# Pull latest code git fetch origin main git reset --hard origin/main # Rebuild images and restart containers docker-compose up -d --build # Migrations run automatically on container start via the entrypoint. # To run manually inside the container: docker-compose exec app npm run db:migrate:deploy
mysql_data) and any uploaded media. These volumes are preserved across docker-compose down and rebuild cycles, so your data is safe.
Feature-specific Migration Notes
Some releases introduced database schema changes. If you are upgrading from a version before the date shown, pay attention to the notes below.
Platform Fees & Tax system — 25 Mar 2026
New columns were added to ChargingTransaction to store platformFeeRate, platformFeeAmount, taxRate, taxAmount, and publisherEarning. The migration adds these columns with a default of 0 for existing rows, so no data is lost.
After the migration, navigate to Admin → System Settings to review and confirm the default platform fee (15%) and tax rates (18%). These can be adjusted without any code change.
Run: npm run db:migrate:deploy then npm run db:generate
Screen Concurrency Restriction — 25 Mar 2026
This feature is entirely in-memory (no database changes). No migration is required. After deploying, all new WebSocket connections go through the concurrency check automatically.
If a screen device was already connected before the update, it will remain connected but will be subject to the new concurrency enforcement on its next reconnect.
Offline Ad Support — 24 Mar 2026
The Service Worker (public/sw.js) is a static file shipped with the frontend. No database migration is needed.
After deploying the updated frontend build, browsers will automatically install the new Service Worker on the next page load. Cached media from previous sessions will continue to work.
If you need to force-refresh the cache (e.g. after a cache-naming change), increment the CACHE_NAME constant in public/sw.js before building.
Unit & E2E Testing setup — 21 Mar 2026
Jest, ts-jest, Supertest, and supporting devDependencies were added to package.json. Run npm ci --production=false to install them.
Test configuration lives in test/jest-e2e.config.ts. No environment variables or database changes are required to run the tests — all Prisma operations are mocked.
Verify the setup after upgrading: npm run test:e2e
Rolling Back an Update
If an update causes issues, use the rollback script to revert to a previous commit. Note that database migrations are not automatically reversed — plan accordingly.
# Rollback to the previous commit npm run rollback # or directly (optionally pass a specific commit hash): ./scripts/rollback.sh ./scripts/rollback.sh <commit-hash>
Post-update Verification
# Check API health curl http://localhost:3001/api/health # Full system health (DB + Redis connectivity) curl http://localhost:3001/api/health/full # Check migration status npm run migrate:status # Run E2E test suite to confirm nothing broke npm run test:e2e
Database
Migration Commands
# Create a new migration npm run prisma:migrate # Apply migrations to production npm run prisma:migrate:deploy # Reset database (deletes all data!) npm run prisma:migrate:reset # Custom migration scripts npm run migrate:run npm run migrate:status npm run migrate:rollback npm run migrate:history
Seeding
# Seed admin user npm run seed:admin # Seed all data npm run seed:all # Clear all seed data npm run seed:clear # Reset and reseed npm run seed:reset
Key Models
| Model | Description |
|---|---|
User | Users with roles, balances, and early bird status |
Campaign | Advertising campaigns with scheduling and budgets |
Screen | Digital displays with location and heartbeat data |
Media | Uploaded content with approval workflow |
CampaignScreen | Junction table linking campaigns to screens |
ScreenRate | Pricing rules with multipliers and peak hours |
ChargingTransaction | Per-view charge records with rate details |
PaymentTransaction | Gateway payment records (Stripe/PayPal/Razorpay) |
Transaction | Balance transactions (deposits, charges, refunds) |
Language | Supported languages with RTL configuration |
Translation | Key-value translation entries by namespace |
ActivityLog | Audit trail with IP and user agent tracking |
Code Quality
# Run ESLint npm run lint # TypeScript type check npm run check
Frequently Asked Questions
How does campaign billing work?
How do I register a new screen?
What payment methods are supported?
How does the media approval process work?
Can I target specific locations for my campaign?
What is the Early Bird program?
How does screen heartbeat monitoring work?
Does AdSpot support multiple languages?
How are publisher earnings calculated?
What are the deployment options?
How do I run database migrations?
Use Prisma CLI commands: npm run prisma:migrate to create a new migration, npm run prisma:migrate:deploy to apply migrations to production. After any schema change, always run npm run prisma:generate to regenerate the Prisma client.
Can the landing page be customized without code changes?
What happens when a screen loses internet connectivity?
How does AdSpot prevent double-billing on screens?
screen:already_in_use event. Since charging only occurs on heartbeat events from the single authorised socket, it is impossible for the same screen to be billed twice for the same interval.
How is the publisher's net earning calculated?
How do I run the automated tests?
npm run test:e2e to execute all 18 backend E2E test suites. Use npm run test:e2e:report to generate an HTML report at test/reports/e2e-test-report.html. Frontend unit tests using Jest and React Testing Library are colocated with their components and can be run via the standard npm test command.