# 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 (Insights API)

Demographic breakdown data (gender / age group) is fetched **live** from the Insights API on every request that needs it. There is no persistent cache; a per-request in-memory memoisation map ensures that multiple lookups for the same (advertiser, dimension, date-range) inside a single request only issue one round-trip.

### How it works

1. `DemographicDataService::ensureLoaded()` checks its in-memory map for the current request.
2. On a miss, it fetches from the Insights API:
   - Looks up the advertiser's API key from `api_keys`.
   - Fetches active campaigns for the advertiser.
   - Calls the Insights API **in parallel** for all campaigns using `curl_multi`.
   - Normalises values (e.g. `"65 or more"` → `"65+"`, unmapped genders → `"other"`).
   - Distributes real spend/conversions from `adv_clicks_*` tables proportionally by impression share.
3. Rows are held in memory (`$memoRows`) and served for any subsequent lookup during the same request.

### History

Earlier revisions persisted responses to an `Admin.adv_demographic_data` table with a 24 h TTL. That table was never provisioned in production and the strategy has been retired — no DB writes, no purge command, no migration. If persistent caching becomes necessary later, the intended layer is an out-of-process store such as Redis, not a MySQL table.

### Trade-off to be aware of

Because every request re-hits the Insights API for the demographic filter, page-load latency on the Breakdown page and on Campaign Performance with gender/age filters is bounded by the parallel API call. The `curl_multi` fan-out keeps this at roughly one round-trip regardless of campaign count, but external API availability is now on the request-critical path. If the API is degraded, demographic-filtered views degrade with it — the rest of the page continues to render from local DB data.

---

## 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.

**Endpoints:** Two routed URLs, same handler, same response:

- `GET /generate-reports/email/<adv>/<freq>/<sent>?pass=<secret>` — canonical legacy-shape path form used by the `admediacrons/adcenter_reports.php` wrapper. Path segments mirror the CI3 contract byte-for-byte.
- `GET /reports/cron?pass=<secret>&adv=<advId>&freq=<freq>&sent=<sent>` — query-string form. Useful for ad-hoc curl testing and for the index-run mode.

**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** (canonical) | `GET /generate-reports/email/<adv>/<freq>/<sent>?pass=<secret>` | Frequency-aware run. `<adv>` = 0 for all advertisers. `<sent>` = `0` for no resume ids (the crontab value). An underscore-joined list skips those ids only when `emailed_at` is today. Same-day repeats are blocked by `adv_reports.emailed_at`. Respects configured time-of-day, day-of-week, and day-of-month gates. Mirrors legacy `Generate_Reports::email()`. |
| **Email run** (alias)     | `GET /reports/cron?pass=<secret>&adv=<advId>&freq=<once\|daily\|weekly\|monthly>&sent=<sent>` | Query-string variant of the same email run. |

**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 runs — canonical legacy-shape path form
5 10 * * *   curl -s "https://adcenter.admedia.com/generate-reports/email/0/daily/0?pass=YOUR_SECRET"   >> /var/log/reports_cron.log 2>&1
5 9  * * 4   curl -s "https://adcenter.admedia.com/generate-reports/email/0/weekly/0?pass=YOUR_SECRET"  >> /var/log/reports_cron.log 2>&1
5 11 16 * *  curl -s "https://adcenter.admedia.com/generate-reports/email/0/monthly/0?pass=YOUR_SECRET" >> /var/log/reports_cron.log 2>&1
```

> Replace `YOUR_SECRET` with the value of `REPORTS_CRON_SECRET`. In practice the crontab lives on the `admediacrons` host and calls the `adcenter_reports.php` wrapper (which handles locking + retry state) rather than curling this URL directly.

#### 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) — path-form
curl 'http://dev.host/.../public/?/generate-reports/email/0/weekly/0?pass=SECRET&force=1'

# Correct (dev) — query-string alias
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: `/generate-reports/email/0/daily/0?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.

---

## 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 the legacy `Admin` database.

---

## 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.

