# AdCenter Revamp

AdMedia's next-generation advertising management platform built on **CodeIgniter 4.7.2**.

## Repository

Git clone: `https://bit.admedia.com/scm/ad/adcenter.admedia.com.git`

Browse: [https://bit.admedia.com/projects/AD/repos/adcenter.admedia.com/browse](https://bit.admedia.com/projects/AD/repos/adcenter.admedia.com/browse)

---

## Table of Contents

1. [Server Requirements](#server-requirements)
2. [Development Setup](#development-setup)
3. [Accessing the Application](#accessing-the-application)
4. [Project Structure](#project-structure)
5. [Modules & Features](#modules--features)
6. [Authentication & Access Control](#authentication--access-control)
7. [Database Connections](#database-connections)
8. [External API Integrations](#external-api-integrations)
9. [Demographic Data Caching](#demographic-data-caching)
10. [Breakdown View](#breakdown-view)
11. [Scheduled Maintenance](#scheduled-maintenance)
12. [Database Migrations](#database-migrations)
13. [Routing Notes](#routing-notes)

---

## Server Requirements

- **PHP** 8.2+ with extensions: `intl`, `curl`, `mbstring`, `json`, `mysqlnd`
- **MySQL** 5.7+ or MariaDB
- **Composer**

---

## Development Setup

### 1. Clone / pull the repository

```bash
git clone https://bit.admedia.com/scm/ad/adcenter.admedia.com.git adcenter.admedia.com
cd adcenter.admedia.com/html
```

### 2. Install dependencies

```bash
composer install
```

### 3. Configure `bootstrap.php`

The base URL is auto-detected from `$_SERVER['SCRIPT_NAME']` in `app/Config/Constants.php` — no manual changes needed. The path will resolve automatically to your username folder (e.g. `192.168.30.106/php82/<your_username>/adcenter.admedia.com/html/public/`).

### 4. Create the writable directory structure

```bash
mkdir -p writable/{cache,debugbar,logs,session}
chmod 755 writable
```

Expected layout:

```
html/writable/
├── cache/
├── debugbar/
├── logs/
└── session/
```

### 5. Configure database connections

Database credentials are loaded from the shared `MSACOMMON_PATH . "dbConstants.php"` file — no changes to `app/Config/Database.php` are needed in most environments. If your environment requires different credentials, update that file or override via environment variables.

---

## Accessing the Application

### URL

```
http://192.168.30.106/php82/<your_username>/adcenter.admedia.com/html/public/?/login
```

> The `?/` between `/public` and the route is required by the server rewrite configuration.

### Credentials

- **Advertiser login** — advertiser's own username + password (validated against `Advertiser_info` table).
- **MNGT "login as" mode** — use an advertiser's identity + your mngt username as the password. Password validation is skipped; session is scoped to the advertiser's account.

---

## Project Structure

```
html/
├── app/
│   ├── Commands/        # Spark CLI commands
│   ├── Config/          # Application configuration
│   ├── Controllers/     # Request handlers (one per module)
│   │   └── Lists/       # Domain, keyword, source list controllers
│   ├── Database/
│   │   └── Migrations/  # Schema migrations
│   ├── Filters/         # Auth, NoAuth, AdOps route filters
│   ├── Helpers/         # Global helper functions
│   ├── Models/          # Database models
│   ├── Services/        # Business logic services (one folder per feature)
│   └── Views/           # Blade-style PHP view templates
├── public/              # Web root (index.php)
├── system/              # CodeIgniter 4 core (do not edit)
├── tests/               # PHPUnit test suite
└── writable/            # Cache, logs, sessions, debugbar
```

---

## Modules & Features

### Dashboard

Entry point after login. Displays account-level KPIs and campaign summary.  
Route: `GET /` or `GET|POST /dashboard`

---

### Campaign Management

Full campaign lifecycle — create, edit, clone, bulk-action.

**Supported campaign types** (all under `campaigns/create/<type>`, `GET` shows form, `POST` saves):

| Type | Route |
|---|---|
| Display | `campaigns/create/display` |
| Text Search | `campaigns/create/text-search` |
| Text Pop | `campaigns/create/text-pop` |
| Native | `campaigns/create/native` |
| Shopping | `campaigns/create/shopping` |
| Swicki | `campaigns/create/swicki` |

Management list: `GET /campaigns`.

**Bulk API actions** (all `POST /api/campaign/*`):  
`bulk-pause`, `bulk-resume`, `bulk-delete`, `bulk-denied`, `bulk-clone`, `bulk-budget`, `bulk-bid`

---

### Creative Management

Upload, edit, and link creative assets to campaigns.

**Bulk API actions** (all `POST /api/creative/*`):  
`bulk-delete`, `bulk-denied`, `bulk-pause`, `bulk-resume`, `bulk-clone`, `bulk-update-destination`, `bulk-update-headline`, `bulk-update-description`, `bulk-update-display-url`

---

### Campaign Performance

Tabular view of per-campaign stats with demographic filtering (gender / age group), date range, and CSV export.  
Routes: `GET|POST /campaign-performance`, `GET /campaign-performance/csv`

---

### Breakdown View

Multi-dimension analytics charts. Delegates to a service factory (`BreakdownServiceFactory`) to pick the correct strategy.

**Available breakdown dimensions:**

| Dimension | Service |
|---|---|
| Gender | `GenderBreakdownService` |
| Age Group | `AgeGroupBreakdownService` |
| Device | `DeviceBreakdownService` |
| Location | `LocationBreakdownService` |
| Daytime | `DaytimeBreakdownService` |
| Network | `NetworkBreakdownService` |
| Ad Format | `AdFormatBreakdownService` |
| Landing Page | `LandingPageBreakdownService` |
| Search Term | `SearchTermBreakdownService` |
| Conversion Type | `ConversionTypeBreakdownService` |

Routes: `GET|POST /breakdown-view`, `GET /api/breakdown/getBreakdownData`, `GET /api/breakdown/getBreakdownTableData`

---

### Custom Reports

Create, edit, schedule, and download custom reports.

- Reports can be scheduled for recurring email delivery (`ScheduledReportsMailer`).
- CSV download and generated file viewer included.
- Cron entry-point (secret-gated): `GET /reports/cron?pass=<secret>` — see [Scheduled Reports Email](#scheduled-reports-email) below.

---

### Ad Schedule

Time-based campaign and creative scheduling.  
Route: `GET /ad-schedule`

---

### Checklist

Internal task/checklist management linked to campaigns.  
Route: `GET /checklist`

---

### Conversion Tracking / Pixels

Retargeting pixel generation and management.  
Route: `GET /conversion-tracking`

---

### Potential Reach

Forecasting tool for estimating delivery and targeting options before launching a campaign. Includes geo search, targeting suggestions, and DMA browsing.  
Route: `GET /potential-reach`

---

### Billing

Deposit funds and view billing history.  
Route: `GET /billing`

---

### Account Settings

Update profile, change password.  
Route: `GET /account-settings`

---

### Account Users

Manage sub-users on an advertiser account.  
Route: `GET /users`

---

### API Access

View and manage API keys for the advertiser.  
Route: `GET /api-access`

---

### Promo Calendar

Upload, manage, and download promotional calendar attachments.  
Route: `GET /promo-calendar`

---

### Alerts Manager *(Ad Ops only)*

Create and manage automated account alerts.  
Route: `GET /alerts-manager`

---

### Import / Export *(Ad Ops only)*

Bulk CSV/ZIP import and export of campaigns and creatives. Also supports DMA uploads and bulk status changes.  
Route: `GET /import-export`

---

### Lists *(Ad Ops only)*

Manage targeting and exclusion lists used across campaigns.

| List Type | Route prefix |
|---|---|
| Domain Lists | `/lists/domains` |
| Keyword Lists | `/lists/keyword-lists` |
| Flat Keywords | `/lists/keywords` |
| Sources | `/lists/sources` |

---

## Authentication & Access Control

Auto-routing is **disabled**. Every route must be explicitly declared. Three route filters are used:

| Filter | Class | Effect |
|---|---|---|
| `auth` | `App\Filters\Auth` | Requires active session; redirects to `/login` if not authenticated |
| `noauth` | `App\Filters\NoAuth` | Redirects already-logged-in users away from auth pages |
| `adops` | `App\Filters\AdOpsFilter` | Requires `mngt`/adops role; returns 401 JSON for AJAX, redirects for browser |

**Two-factor authentication** is supported via `TwoFactorService` (routes: `GET|POST /verify-2fa`, `POST /resend-2fa-key`).

**Password reset** is email-based (routes: `POST /forgot-password`, `GET|POST /reset-password`).

---

## Database Connections

Credentials are pulled from the shared `dbConstants.php` file (path defined by `MSACOMMON_PATH`).

| Group | Database | Used For |
|---|---|---|
| `default` | `Admin` | Campaigns, creatives, users, API keys, configuration |
| `keywords` | `keywords` | Keyword lists & dictionary tables |
| `clicks` | `keywords` | Partitioned click/impression/conversion stats (`adv_clicks_*`) |
| `stats` | `stats` | Aggregated stats tables |
| `shorty` | `Shorty` | Legacy URL-shortener / tracking |
| `whale` | `whale` | Whale data warehouse |
| `adcenter` | `adcenter` | Legacy adcenter tables |
| `heatwave` | `keywords` | Read replica used by Breakdown benchmark queries |

All connections use the `MySQLi` driver with `utf8` charset.

---

## External API Integrations

| API | Base URL | Purpose |
|---|---|---|
| **AdMedia Insights API** | `https://apiad.admedia.com/v1/reports/insights` | Demographic (gender/age) breakdown data per campaign |
| **AdMedia Internal API** (`advertisers7api`) | `https://apiad.admedia.com/v1/` (prod) / local (dev) | Campaign/creative import-export operations |
| **Jira API** | `https://admedia-jira.atlassian.net/rest/api/3` | Checklist / task tracking integration |
| **Slack API** | `https://slack.com/api` | Placeholder — not yet implemented |
| **Fireflies API** | — | Placeholder — not yet implemented |

> **Security note:** `ADMEDIA_API_USER` and `ADMEDIA_API_PASS` should be stored in `.env` (keys `ADMEDIA_API_USER` / `ADMEDIA_API_PASS`). The values in `app/Config/ApiConstants.php` are fallbacks only.

---

## Demographic Data Caching

Demographic breakdown data (gender / age group) is fetched from the Insights API and cached in the `adv_demographic_data` table (Admin DB) to avoid repeated API calls.

### How it works

1. On each request, `DemographicCacheModel::isFresh()` checks if any row for the advertiser + dimension was fetched within the last **24 hours**.
2. If stale (or absent), `DemographicCacheService::fetchAndStore()` runs:
   - Looks up the advertiser's API key from `api_keys`.
   - Fetches up to 50 active campaigns (`status IN ('A','P')`).
   - Calls the Insights API **in parallel** for all campaigns using `curl_multi`.
   - Normalizes values (e.g. `"65 or more"` → `"65+"`, unmapped genders → `"other"`).
   - Distributes real spend/conversions from `adv_clicks_*` tables proportionally by impression share.
   - Upserts rows in batches of 500 via `INSERT ... ON DUPLICATE KEY UPDATE`.
3. Subsequent reads within the TTL window are served directly from `adv_demographic_data`.

### Table: `adv_demographic_data`

| Column | Description |
|---|---|
| `adv_id` | Advertiser ID |
| `campaign_id` | Campaign ID |
| `date` | Stats date (`Y-m-d`) |
| `dimension` | `gender` or `age_group` |
| `value` | Canonical dimension value (e.g. `male`, `18-24`) |
| `impressions` | API impression count |
| `clicks` | API click count |
| `spend` | Proportionally distributed from DB |
| `conversions` | Proportionally distributed from DB |
| `fetched_at` | Timestamp of last upsert (used for TTL check) |

---

## Scheduled Maintenance

### Scheduled Reports Email

Scheduled report emails are triggered via an HTTP cron entry-point rather than a Spark CLI command. The endpoint is **not** behind the `auth` filter — it is authenticated by a shared secret.

**Endpoint:** `GET /reports/cron`

**Secret:** Defined by the `REPORTS_CRON_SECRET` constant in `app/Config/Constants.php`. Override it in your `.env` file:

```ini
REPORTS_CRON_SECRET=your_secret_here
```

#### Two run modes

| Mode | URL | Description |
|---|---|---|
| **Index run** | `GET /reports/cron?pass=<secret>` | Iterates every scheduled report whose latest generated file is older than its frequency window, regenerates the CSV, and emails it. Mirrors legacy `Generate_Reports::index()`. |
| **Email run** | `GET /reports/cron?pass=<secret>&adv=<advId>&freq=<once\|daily\|weekly\|monthly>` | Frequency-aware run for a specific advertiser and frequency. Respects configured time-of-day, day-of-week, and day-of-month gates. Mirrors legacy `Generate_Reports::email()`. |

**Optional parameter for email run:** `&sent=<id1_id2_...>` — underscore-joined list of already-sent report IDs to skip (defaults to loading from the state file at `writable/reports_sent_state.txt`).

**Optional `&force=1`** (email run only) — bypasses the day-of-week / hour-of-day gate. Intended for ad-hoc manual triggers and testing; **do not** use this flag in production crontab entries or every poll will re-send the report.

#### Non-production email safety net

Outbound emails are intercepted in non-prod so test runs can never reach real advertisers. Controlled by `REPORTS_DEV_EMAIL_OVERRIDE` in `app/Config/Constants.php`:

```php
defined('REPORTS_DEV_EMAIL_OVERRIDE') || define(
    'REPORTS_DEV_EMAIL_OVERRIDE',
    ENVIRONMENT === 'production' ? '' : 'koushik.basu@admedia.com'
);
```

When the constant is non-empty, `ScheduledReportsMailer::sendOne()` rewrites every recipient to that address and prepends `[DEV -> <original>]` to the subject. The redirect is logged at `info` level. In production the constant is empty and the original recipient is used unchanged.

#### Response format

All responses are `text/plain` and mirror the legacy format so existing cron parsers continue to work:

```
# Index run
result<sent>/<total>|<success|partial>result

# Email run
result<sent_report_ids>|<message>result
```

Example success responses:
```
result3/3|successresult
result12_47|sent 2result
```

#### Default send schedule (email run)

| Frequency | Default day | Default time |
|---|---|---|
| Daily | Every day | 10:00 |
| Weekly | Thursday | 09:00 |
| Monthly | 16th | 11:00 |

These defaults are overridden per-report by the schedule settings stored in `adv_reports.json`.

#### Recommended crontab entries

```cron
# Index run — every 30 minutes (catches stale reports across all frequencies)
*/30 * * * * curl -s "https://adcenter.admedia.com/reports/cron?pass=YOUR_SECRET" >> /var/log/reports_cron.log 2>&1

# Email run — daily reports at 10:05
5 10 * * * curl -s "https://adcenter.admedia.com/reports/cron?pass=YOUR_SECRET&freq=daily" >> /var/log/reports_cron.log 2>&1

# Email run — weekly reports on Thursday at 09:05
5 9 * * 4 curl -s "https://adcenter.admedia.com/reports/cron?pass=YOUR_SECRET&freq=weekly" >> /var/log/reports_cron.log 2>&1

# Email run — monthly reports on 16th at 11:05
5 11 16 * * curl -s "https://adcenter.admedia.com/reports/cron?pass=YOUR_SECRET&freq=monthly" >> /var/log/reports_cron.log 2>&1
```

> Replace `YOUR_SECRET` with the value of `REPORTS_CRON_SECRET`. If the server does not have outbound HTTP to itself, use `php spark` to invoke a Spark command wrapper instead (not yet implemented).

#### Dev-mode URL form (query-string routing)

Dev hosts use query-string routing (no Apache rewrite). Use `?/path?key=value` — note the **single `?` separator** between path and params, not `&`:

```bash
# Correct (dev)
curl 'http://dev.host/.../public/?/reports/cron?pass=SECRET&adv=15325&freq=weekly&force=1'

# Wrong — 404, because `&pass=...` becomes part of the route path
curl 'http://dev.host/.../public/?/reports/cron&pass=SECRET&adv=15325&freq=weekly'
```

Production (with rewrites) uses the clean form: `/reports/cron?pass=SECRET&...`.

#### Report frequencies and date ranges

Each report stores its config in `adv_reports.json`. The `filter.date.range` field controls the window the CSV covers — common values: `today`, `yesterday`, `last_7_days`, `last_30_days`, `mtd`, `ytd`, `custom`, and `custom_rolling`.

**Custom rolling window** (`custom_rolling`): produces a 1- to 7-day window relative to today, defined by two day-of-week anchors stored on the config:

- `rolling_start_day` (0=Sun … 6=Sat) — the window’s first day
- `rolling_end_day` (0=Sun … 6=Sat) — the window’s last day

The resolver finds the most recent past `rolling_end_day` (strictly before today, 1–7 days back), then walks back to the matching `rolling_start_day`. Example: with start=Saturday, end=Friday, running on Monday 2026-06-01 produces `2026-05-23 → 2026-05-29`. Toggle this in the create/edit UI via the "Use Custom Rolling Window" checkbox.

---

### Demographic Cache Purge

Rows in `adv_demographic_data` are never automatically removed by normal request flow. Run the purge command periodically to prevent unbounded table growth:

```bash
# Delete rows with a date older than 90 days (default)
php spark demographic:purge

# Custom retention window
php spark demographic:purge --days 60
```

**Recommended cron** (daily at 02:00):
```
0 2 * * * /usr/bin/php8.2 /var/www/php82/<username>/adcenter.admedia.com/html/spark demographic:purge >> /var/log/demographic_purge.log 2>&1
```

> Rows are purged by their `date` column (the actual stats date), not `fetched_at`.

---

## Database Migrations

Migrations live in `app/Database/Migrations/` and are run via Spark:

```bash
php spark migrate
```

There are no pending migrations in this branch. Baseline schema is provided by `migration/2026-05-13_adv_demographic_data.sql` (and the legacy `Admin` schema).

---

## Routing Notes

- **Auto-routing is disabled.** All routes are explicitly defined in `app/Config/Routes.php`. This ensures that route filters (`auth`, `adops`) cannot be bypassed by URL guessing.
- **API routes** live under the `api/` prefix and return JSON.
- **Ad Ops-only routes** are gated with the `adops` filter and include: Alerts Manager, Import/Export, and all Lists sub-modules.
- **404 handling** returns a JSON response for `api/` paths and throws `PageNotFoundException` for all other paths.

