# TIEASY production deployment (VPS / Nginx)

This document is the production authority for installing TIEASY on a VPS. The application is CodeIgniter 4 + PHP 8.2+ + MySQL 8+. It is **not** a Vercel/serverless app.

Replace `tieasy.example.com` and `/var/www/tieasy` with your hostname and install path.

## Stack requirements

- PHP **8.2 or newer** (8.3 is fine)
- Required extensions (from CI4 + this app):
  - `intl`
  - `mbstring`
  - `mysqli` (mysqlnd)
  - `openssl`
  - `json` (bundled with PHP 8)
  - `ctype` (bundled)
- Recommended: `curl`, `fileinfo`
- Composer 2
- MySQL 8+
- Nginx + PHP-FPM (primary). Apache is an alternative below.
- HTTPS certificate (Let’s Encrypt)

Confirm extensions:

```bash
php -m | grep -E 'intl|mbstring|mysqli|openssl|json|ctype'
```

## Directory layout

Install the **full project**, not only `public/`.

```
/var/www/tieasy/           # project root (not the web root)
  app/
  public/                  # Nginx/Apache document root
  vendor/
  writable/                # cache, logs, session, mailbox (dev only)
  .env                     # secrets; never web-accessible
  spark
```

Document root **must** be `/var/www/tieasy/public`.

Never expose as a URL: `app/`, `vendor/`, `writable/`, `tests/`, `.env`, `composer.json`, migrations.

## Composer (production)

```bash
cd /var/www/tieasy
composer install --no-dev --optimize-autoloader
```

Do not install PHPUnit or other require-dev packages on the VPS runtime.

## Environment file

```bash
cp env .env
php spark key:generate
```

Edit `.env` and set at least:

| Key | Production value |
| --- | --- |
| `CI_ENVIRONMENT` | `production` |
| `app.baseURL` | `https://tieasy.example.com/` (trailing slash) |
| `app.appTimezone` | IANA timezone, e.g. `UTC` or `Asia/Kolkata` |
| `app.forceGlobalSecureRequests` | `true` |
| `cookie.secure` | `true` (also forced in code when `CI_ENVIRONMENT = production`) |
| `database.default.*` | production MySQL credentials |
| `encryption.key` | generated; never commit |
| `email.protocol` | `smtp` — **never** `mailbox` in production |
| `email.fromEmail` / `fromName` | OTP From header |
| `email.SMTPHost` / `SMTPUser` / `SMTPPass` / `SMTPPort` / `SMTPCrypto` | SMTP |

`.env` is gitignored. The tracked `env` file contains placeholders only.

`email.protocol = mailbox` writes files under `writable/mailbox/` and is **rejected** when `CI_ENVIRONMENT = production`.

Application timezone is `Config\App::$appTimezone` (`app.appTimezone` in `.env`). Dashboard, recurrence, and notifications all use `App\Support\AppClock`. PHP’s default timezone is also set to this value by CodeIgniter. **Do not leave one module on a different timezone.** If the product is used in India, set `app.appTimezone = Asia/Kolkata` explicitly; the default remains `UTC` until you choose.

Tests that care about date boundaries freeze the clock in that same timezone.

## Database

```bash
mysql -u root -p -e "CREATE DATABASE tieasy CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u root -p -e "CREATE USER 'tieasy'@'127.0.0.1' IDENTIFIED BY 'choose-a-strong-password'; GRANT ALL ON tieasy.* TO 'tieasy'@'127.0.0.1'; FLUSH PRIVILEGES;"
```

Then:

```bash
cd /var/www/tieasy
php spark migrate
php spark db:seed DatabaseSeeder
```

`DatabaseSeeder` seeds **support categories only**. It does not seed Home/Office/Personal/Work task categories. Users create their own categories.

### Unique-index migrations

These indexes are added on empty tables in a fresh install. If you ever migrate a database that already has rows, check for duplicates **before** migrating and resolve them manually. Do not delete legitimate user data automatically.

```sql
-- Recurring / one-off occurrence uniqueness (NULL dates are distinct in MySQL unique indexes)
SELECT task_id, occurrence_date, COUNT(*) c
FROM task_occurrences
GROUP BY task_id, occurrence_date
HAVING c > 1;

-- Notification uniqueness. MySQL allows multiple NULLs in unique keys, so
-- rows whose occurrence_id was SET NULL after a hard delete can share
-- (user_id, NULL, kind). The generator never recreates for missing occurrences.
SELECT user_id, occurrence_id, kind, COUNT(*) c
FROM notifications
GROUP BY user_id, occurrence_id, kind
HAVING c > 1;
```

## Writable permissions

Do **not** `chmod -R 777`. Own the tree by the deploy user and give the PHP-FPM user write access to `writable/` only. On Debian/Ubuntu that user is often `www-data`; on RHEL it may be `nginx` or `apache`.

```bash
sudo chown -R deploy:www-data /var/www/tieasy
sudo find /var/www/tieasy -type d -exec chmod 755 {} \;
sudo find /var/www/tieasy -type f -exec chmod 644 {} \;
sudo chmod -R ug+rwX /var/www/tieasy/writable
sudo chmod 640 /var/www/tieasy/.env
```

Subdirectories used at runtime: `writable/cache`, `writable/logs`, `writable/session`. There is no public upload directory in V1.

File sessions are forced under `writable/session` even if `.env` tries to place them under `public/`.

## Nginx

Example file: `deploy/nginx-tieasy.conf`.

```bash
sudo cp /var/www/tieasy/deploy/nginx-tieasy.conf /etc/nginx/sites-available/tieasy
sudo nano /etc/nginx/sites-available/tieasy   # set server_name and php-fpm socket
sudo ln -s /etc/nginx/sites-available/tieasy /etc/nginx/sites-enabled/tieasy
sudo nginx -t && sudo systemctl reload nginx
```

Adjust `fastcgi_pass` to the socket for your PHP version (`php8.2-fpm.sock` or `php8.3-fpm.sock`).

## Apache (alternative)

DocumentRoot `/var/www/tieasy/public`. Enable `mod_rewrite` and `AllowOverride All` (or copy the rewrite rules) so `public/.htaccess` can route to `index.php`. Hidden files are denied there. Nginx remains the primary example.

```
<VirtualHost *:80>
    ServerName tieasy.example.com
    DocumentRoot /var/www/tieasy/public
    <Directory /var/www/tieasy/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>
```

Then obtain HTTPS and redirect HTTP → HTTPS.

## HTTPS (required)

Persistent login sets the `Secure` cookie flag in production. HTTP production will not keep users signed in.

```bash
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d tieasy.example.com
```

Checklist:

1. DNS A/AAAA records point at the VPS
2. `app.baseURL` uses `https://` and a trailing slash
3. `app.forceGlobalSecureRequests = true`
4. Certificate auto-renews (`certbot renew --dry-run`)

## Cron

V1 notification generation is **daily**. Timed (date+time) reminders are created on the **next cron run after they become due**. They are not guaranteed at the exact minute. That is accepted V1 behavior; do not add a minutely cron unless product requirements change.

Order matters: extend occurrences first, then generate notifications.

```cron
15 0 * * * cd /var/www/tieasy && /usr/bin/php spark tasks:generate-occurrences >/dev/null 2>&1 && /usr/bin/php spark tasks:notifications >/dev/null 2>&1
```

Use the real `php` binary path (`which php`). Commands take a non-blocking file lock under `writable/cache/` so overlapping runs skip. Unique database indexes also prevent duplicate occurrences and duplicate due notifications.

Idempotent: re-running both commands on a quiet day creates zero extra rows.

## SMTP smoke test (no OTP in logs)

1. Set production SMTP values in `.env`
2. `CI_ENVIRONMENT` must not be `testing`
3. Sign in with a real mailbox you control
4. Confirm the message arrives
5. Confirm `writable/logs/` has no raw 6-digit code and no SMTP password
6. Confirm `writable/mailbox/` is unused in production

## Test environment (not on the VPS runtime)

Automated tests use `CI_ENVIRONMENT = testing`, database group `tests` (`tieasy_test` by default), CSRF disabled only in that environment, and an in-memory/array mailer or mailbox. They cannot use the production database group because `Config\Database` forces `defaultGroup = tests` while testing.

```bash
mysql -h 127.0.0.1 -u your_test_db_user -p -e "CREATE DATABASE IF NOT EXISTS tieasy_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
composer install
composer test
```

## Backup (before every production migrate)

Take a MySQL dump. TIEASY does not include a backup product.

```bash
mysqldump -u your_db_user -p tieasy > /root/backups/tieasy-$(date +%F).sql
```

## Update procedure

1. Maintenance window if users would hit a half-deployed tree
2. Backup the database
3. Deploy the new code (git pull or rsync of the project, excluding `.env` and `writable/`)
4. `composer install --no-dev --optimize-autoloader`
5. `php spark migrate`
6. Recheck `writable/` ownership
7. `php spark cache:clear` if you use file cache
8. Reload PHP-FPM (`sudo systemctl reload php8.2-fpm`)
9. Smoke-test (below)
10. Re-enable traffic

Do not run destructive rollback SQL from this document. Restore from the dump you took in step 2 if a migrate fails.

## Smoke tests after deploy

- `https://tieasy.example.com/` → Sign In
- Unauthenticated `/dashboard`, `/analytics`, `/notifications`, `/support`, `/settings` → Sign In
- Send OTP over SMTP; verify 6 boxes; land on onboarding (new) or Dashboard (returning)
- Close the browser, reopen, still signed in until Logout
- Logout, then browser Back does not show Dashboard HTML
- Create a category and a task; Dashboard Today/Pending/All Categories
- Search, Analytics charts, Notifications bell, Contact Support submit
- `php spark tasks:generate-occurrences` then `php spark tasks:notifications` exit 0

## Route summary

Public (guest; signed-in users are redirected away):

- `GET /` → Sign In or restore session
- `GET /sign-in`
- `POST /auth/send-otp`, `GET|POST /auth/verify-otp`, `POST /auth/resend-otp`

Authenticated (`AuthFilter`; onboarding required except logout/onboarding):

- `GET /logout`
- `GET|POST /profile/onboarding`
- `GET /dashboard`, `POST /dashboard/filter`
- `GET /tasks/search`, `GET /tasks/priority/{1-4}`
- `GET|POST /tasks/create`, `GET|POST /tasks/{id}/edit`
- `POST /tasks/occurrences/{id}/complete|delete|delete-future`
- `POST /tasks/{id}/delete`
- `GET|POST /categories` and category update/delete POSTs
- `GET /analytics`
- `GET /notifications`, mark-read POSTs
- `GET|POST /support`
- `GET /settings`, `POST /settings/profile`

There are no GET delete routes, no auto-routing, and no debug/hot-reload routes in production (`CI_DEBUG` is false).

## Foreign keys (intentional)

- Deleting a user cascades related rows
- Soft-deleted tasks keep occurrences until the task row is removed
- Notifications keep history when a task/occurrence is hard-deleted (`SET NULL` on `task_id` / `occurrence_id`)
- Support requests snapshot name/email/phone; later profile edits do not rewrite history
