# Vertex WiFi — Deployment Guide

> Deliverable 13 of 13. Getting this into production, and the things that will bite
> you if you skip them.

---

## 1. Requirements

| Component | Minimum | Notes |
| --- | --- | --- |
| PHP | 8.2 | With `zip` **enabled** — Composer cannot extract packages without it |
| PHP extensions | `pdo_mysql`, `mbstring`, `openssl`, `tokenizer`, `xml`, `ctype`, `json`, `bcmath`, `fileinfo`, `curl`, `zip`, `gd` | `bcmath` is not optional: all money arithmetic uses it |
| MySQL / MariaDB | MySQL 8.0 / MariaDB 10.4 | `utf8mb4` / `utf8mb4_unicode_ci` |
| Node | 20 LTS | Build-time only; the server runs static files |
| Web server | nginx or Apache | |
| Process supervisor | supervisor or systemd | For the queue worker |
| Cron | system crontab | For the scheduler |

On Windows/XAMPP, `zip` is usually commented out. Uncomment `extension=zip` in
`php.ini` and restart, then confirm:

```bash
php -m | grep -i zip
```

---

## 2. Backend

```bash
cd /var/www/vertex/backend

composer install --no-dev --optimize-autoloader
cp .env.example .env
php artisan key:generate
```

### 2.1 Configure `.env`

```dotenv
APP_NAME="Vertex WiFi"
APP_ENV=production
APP_DEBUG=false                      # never true in production
APP_URL=https://api.vertexwifi.ph

# PH business dates. See §7 — this is not cosmetic.
APP_TIMEZONE=Asia/Manila

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=vertex_wifi
DB_USERNAME=vertex
DB_PASSWORD=<strong password>

# Where the SPA is served from — CORS and Sanctum both read this.
FRONTEND_URL=https://app.vertexwifi.ph
CORS_ALLOWED_ORIGINS=https://app.vertexwifi.ph
SANCTUM_STATEFUL_DOMAINS=app.vertexwifi.ph
SANCTUM_TOKEN_EXPIRATION=720          # minutes

QUEUE_CONNECTION=database
CACHE_STORE=database
SESSION_DRIVER=database

LOG_CHANNEL=stack
LOG_STACK=daily
LOG_LEVEL=warning                     # `debug` fills a disk fast

# Start on `log` and switch to a real driver only after a successful test send.
SMS_ENABLED=true
SMS_DRIVER=log
SMS_REMINDER_DAYS_BEFORE=3

# Seeded account passwords — set these so the defaults are never used.
SEED_SUPERADMIN_EMAIL=you@yourdomain.ph
SEED_SUPERADMIN_PASSWORD=<strong password>
SEED_ADMIN_EMAIL=admin@yourdomain.ph
SEED_ADMIN_PASSWORD=<strong password>
SEED_STAFF_EMAIL=staff@yourdomain.ph
SEED_STAFF_PASSWORD=<strong password>
```

### 2.2 Database

```bash
mysql -u root -p -e "CREATE DATABASE vertex_wifi CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

php artisan migrate --force
php artisan db:seed --force          # roles, users, plans, SMS templates, settings
```

All five core seeders are **idempotent** — safe to re-run on every deploy. They
sync the permission catalogue from `App\Enums\Permission` and the role matrix from
`App\Enums\UserRole`, so adding a permission is: edit the enum, redeploy, re-seed.

`UserSeeder` uses `firstOrCreate`, so re-running never resets a password an admin
has already changed.

Do **not** run `DemoDataSeeder` in production — it creates 16 fictional subscribers.

### 2.3 Cache the config

```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
```

> Re-run these after **any** `.env` change. A cached config ignores `.env`
> entirely, which is the single most common "why isn't my setting applied?" in
> Laravel deployment.

### 2.4 Permissions

```bash
chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
```

---

## 3. Frontend

```bash
cd /var/www/vertex/frontend

# Point the SPA at the API. Baked in at build time, so it must be set before
# `npm run build`, not after.
echo 'VITE_API_URL=https://api.vertexwifi.ph' > .env.production

npm ci
npm run build            # → dist/
```

Deploy `dist/` as static files. It is a single-page app, so **every unmatched path
must fall back to `index.html`** or a refresh on `/customers/12` returns a 404.

---

## 4. Web server

### 4.1 nginx — API

```nginx
server {
    listen 443 ssl http2;
    server_name api.vertexwifi.ph;

    root /var/www/vertex/backend/public;
    index index.php;

    ssl_certificate     /etc/letsencrypt/live/api.vertexwifi.ph/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.vertexwifi.ph/privkey.pem;

    add_header X-Frame-Options            "SAMEORIGIN"        always;
    add_header X-Content-Type-Options     "nosniff"           always;
    add_header Referrer-Policy            "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security  "max-age=31536000"  always;

    client_max_body_size 12M;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;

        # Report exports over a large subscriber book can exceed the default.
        fastcgi_read_timeout 120;
    }

    # Never serve dotfiles — .env lives one level up but this is cheap insurance.
    location ~ /\.(?!well-known) { deny all; }
}
```

### 4.2 nginx — SPA

```nginx
server {
    listen 443 ssl http2;
    server_name app.vertexwifi.ph;

    root /var/www/vertex/frontend/dist;
    index index.html;

    ssl_certificate     /etc/letsencrypt/live/app.vertexwifi.ph/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.vertexwifi.ph/privkey.pem;

    # Hashed filenames, so these can be cached hard.
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # index.html must NOT be cached, or users keep loading a stale bundle
    # referencing assets that no longer exist.
    location = /index.html {
        add_header Cache-Control "no-cache, must-revalidate";
    }

    # SPA fallback.
    location / {
        try_files $uri $uri/ /index.html;
    }
}
```

### 4.3 Apache (SPA fallback)

If you must use Apache, `frontend/dist/.htaccess`:

```apache
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /
    RewriteRule ^index\.html$ - [L]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule . /index.html [L]
</IfModule>
```

---

## 5. The scheduler — this is what sends the reminders

One cron entry runs Laravel's scheduler, which then dispatches everything defined
in `bootstrap/app.php`:

```cron
* * * * * cd /var/www/vertex/backend && php artisan schedule:run >> /dev/null 2>&1
```

What it drives:

| Time | Command | Purpose |
| --- | --- | --- |
| 00:01 | `vertex:generate-invoices` | Issue invoices due inside the lead window; promote past-due to overdue |
| 00:05 | `vertex:send-due-reminders` | **The spec's requirement** — queue an SMS for every invoice due in exactly 3 days |
| 01:00 | `vertex:suspend-overdue` | Suspend accounts past the grace period (**off unless enabled in Settings**) |
| every 30 min | `vertex:check-sms-gateway` | Cache gateway health for the dashboard |
| Sun 02:00 | `vertex:prune-activity-logs` | Audit-log retention |

Order matters: invoices are generated before reminders, because you cannot text
somebody about a bill that does not exist yet.

All are guarded with `withoutOverlapping()` and `onOneServer()`. **Keep
`onOneServer()` if you ever run more than one app server** — without it every
subscriber gets texted once per server.

Verify:

```bash
php artisan schedule:list
```

### Testing the pipeline without waiting for midnight

```bash
# What would go out, without queueing anything
php artisan vertex:send-due-reminders --dry-run

# Pretend it is a specific date
php artisan vertex:send-due-reminders --date=2026-03-13

# Then drain the queue
php artisan queue:work --stop-when-empty
```

Re-running is safe: each invoice carries a `reminder_sent` flag, so a second run
sends nothing.

---

## 6. The queue worker — nothing sends without it

SMS is dispatched to a queue. If no worker runs, messages sit in **Queued**
forever and the SMS screen will show them piling up.

`/etc/supervisor/conf.d/vertex-worker.conf`:

```ini
[program:vertex-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/vertex/backend/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/vertex-worker.log
stopwaitsecs=3600
```

```bash
supervisorctl reread && supervisorctl update && supervisorctl start vertex-worker:*
```

`--max-time=3600` recycles the process hourly, which stops a long-lived worker
holding stale config after a deploy. **After every deploy:**

```bash
php artisan queue:restart
```

Workers boot the framework once and keep it in memory — without this they keep
running the previous release's code.

> `numprocs=2` is deliberate. More workers send faster, but a phone gateway is
> rate-limited by the carrier and a burst can get the SIM flagged as spam. The
> `sms.dispatch_delay_ms` setting spaces messages out; two workers is plenty.

---

## 7. Timezone — read this

`config/app.php` in this project reads `APP_TIMEZONE`, because Laravel 12 ships it
hardcoded to `'UTC'`.

Almost every date here is a **business date in the operator's timezone**: "bills
due today", "due in 3 days", the midnight sweep, and the `before_or_equal:today`
rule on a payment date. The Philippines is UTC+8, so between midnight and 08:00
local the PH date is already a day ahead of UTC.

On a UTC server with this misconfigured:

- A cashier recording a payment at 7am is told *"a payment cannot be dated in the
  future."*
- The midnight scheduler runs against the wrong day's due dates.

Set `APP_TIMEZONE=Asia/Manila` and confirm:

```bash
php artisan tinker --execute="echo config('app.timezone'), ' ', now()->toDateString();"
```

Set the **database** and **OS** to the same zone, or store UTC in both and let the
app convert — just do not mix.

---

## 8. First-run hardening

Work through this before the system is reachable from the internet.

- [ ] `APP_DEBUG=false` and `APP_ENV=production`. Debug mode returns stack traces
      and config values to the browser.
- [ ] `APP_KEY` generated, and **backed up**. Losing it makes every encrypted
      PPPoE password and SMS API key unreadable.
- [ ] All three seeded passwords changed. The defaults are in this repository.
- [ ] Delete or deactivate the seeded `Staff`/`Admin` demo accounts if unused.
- [ ] Database user has only what it needs — no `GRANT ALL`, no `SUPER`.
- [ ] HTTPS on both hosts; HTTP redirects to it. Bearer tokens over plain HTTP are
      readable in transit.
- [ ] `CORS_ALLOWED_ORIGINS` names your SPA origin and is **not** `*`.
- [ ] `SANCTUM_TOKEN_EXPIRATION` set (default 720 min). Sanctum's own default is
      "never expire".
- [ ] `.env` is `chmod 600` and outside the web root.
- [ ] `php artisan config:cache` run after the final `.env` edit.
- [ ] Queue worker running under supervisor; cron entry installed.
- [ ] `SMS_DRIVER=log` until a test send succeeds — then switch.
- [ ] Backups scheduled (§10) and a restore actually tested.
- [ ] Confirm `storage/` is not web-accessible: `curl https://api.../storage/logs/laravel.log`
      must not return a log.

---

## 9. SMS gateway

Configure from **Settings → SMS Gateway** in the UI rather than `.env` — settings
are read at runtime, so switching gateways or rotating a key needs no redeploy.
Secrets are encrypted at rest and never returned by the API.

### Android phone as the gateway (recommended, no per-message cost)

1. Install an SMS-gateway app on a spare handset with an unlimited-texts SIM
   (tested against `capcom6/android-sms-gateway`).
2. Give the phone a **static IP** on the office LAN — a DHCP lease change silently
   breaks every reminder.
3. Set **Gateway URL** to `http://<ip>:<port>`, and paste the app's API key (or
   username/password if it uses Basic auth).
4. Set **SMS Driver** to `android`.
5. Send a test from **SMS → Test & Diagnostics**. It runs synchronously, so you get
   the real gateway result immediately.
6. Keep the handset on a charger. The dashboard shows its battery level so a dying
   phone is visible before it stops sending.

Hosted alternatives — Semaphore (cheapest for PH numbers), Twilio, Vonage — need
only their credentials in the same screen.

Full detail: [06-SMS-AUTOMATION.md](06-SMS-AUTOMATION.md).

---

## 10. Backups

```bash
#!/usr/bin/env bash
# /usr/local/bin/vertex-backup.sh
set -euo pipefail

STAMP=$(date +%Y%m%d-%H%M)
DEST=/var/backups/vertex
mkdir -p "$DEST"

mysqldump --single-transaction --quick --routines \
  -u vertex -p"$DB_PASSWORD" vertex_wifi | gzip > "$DEST/db-$STAMP.sql.gz"

# APP_KEY lives here. Without it the database backup is only half a backup:
# encrypted PPPoE passwords and gateway keys cannot be decrypted.
cp /var/www/vertex/backend/.env "$DEST/env-$STAMP"

find "$DEST" -type f -mtime +30 -delete
```

```cron
30 2 * * * /usr/local/bin/vertex-backup.sh >> /var/log/vertex-backup.log 2>&1
```

`--single-transaction` keeps the dump consistent without locking the tables, so
nightly backups do not collide with the midnight billing run.

**Test a restore.** An untested backup is a hope, not a backup.

---

## 11. Deploy script

```bash
#!/usr/bin/env bash
set -euo pipefail

cd /var/www/vertex

php backend/artisan down --render=errors::503 --retry=60

git pull origin main

# --- backend ---
cd backend
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan db:seed --force              # idempotent: syncs roles/permissions

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# Workers hold the old code in memory until told otherwise.
php artisan queue:restart

# --- frontend ---
cd ../frontend
npm ci
npm run build

cd ..
php backend/artisan up
```

---

## 12. Monitoring

| Check | How |
| --- | --- |
| App up | `GET /up` — Laravel's health endpoint |
| Queue not backing up | `SELECT COUNT(*) FROM jobs;` — a rising count means no worker |
| Failed jobs | `SELECT COUNT(*) FROM failed_jobs;` |
| SMS failures | `SELECT COUNT(*) FROM sms_logs WHERE status='failed' AND created_at > NOW() - INTERVAL 1 DAY;` |
| Gateway alive | `php artisan vertex:check-sms-gateway` — exits non-zero when offline, so it can drive an alert |
| Scheduler ran | `storage/logs/scheduler-reminders.log` should gain a line each night |
| Disk | `storage/logs` with `LOG_LEVEL=debug` fills a small VPS quickly |

---

## 13. Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Messages stuck on **Queued** | No queue worker. `supervisorctl status vertex-worker:*` |
| Reminders never sent | Cron missing. `php artisan schedule:list`, then check `storage/logs/scheduler-reminders.log` |
| "A payment cannot be dated in the future" at 7am | `APP_TIMEZONE` not set — see §7 |
| `.env` change has no effect | `php artisan config:cache` needs re-running |
| CORS errors in the browser console | `CORS_ALLOWED_ORIGINS` does not list the SPA origin exactly, scheme included |
| 404 on refreshing `/customers/12` | SPA fallback missing from the web server config (§4) |
| 401 immediately after signing in | System clocks skewed between app and DB, or `SANCTUM_TOKEN_EXPIRATION` set absurdly low |
| "Too Many Attempts" during normal use | Expected only under real load; limits are per-concern in `RateLimitServiceProvider` |
| Composer "zip extension missing" | Enable `extension=zip` in `php.ini` |
| PDF exports show boxes instead of ₱ | The Blade templates set `DejaVu Sans` explicitly; do not override the font |
| Encrypted settings read as empty after a restore | `APP_KEY` differs from the one that encrypted them — restore the backed-up `.env` |

---

## 14. Scaling notes

Beyond a few thousand subscribers:

- **Redis** for cache and queue (`CACHE_STORE=redis`, `QUEUE_CONNECTION=redis`).
  The database queue is fine to start and one less service to run.
- **Keep `onOneServer()`** on every scheduled command once you add a second app
  server, or reminders double-send.
- **Read replica** for reports. `ReportService` only reads, so it can be pointed at
  a replica without touching anything else.
- **Raise `vertex:prune-activity-logs` frequency.** `activity_logs` grows on every
  write and is the first table to become unmanageable.
- **Index review.** The indexes in [01-DATABASE-ERD.md §4](01-DATABASE-ERD.md) cover
  the shipped queries; re-check with `EXPLAIN` as your data shape changes.
