# Deploying Property Management Network on DigitalOcean App Platform

This app runs as a single Next.js 16 (standalone) container. On App Platform it needs:

- a **Managed PostgreSQL** database,
- **DigitalOcean Spaces** (S3-compatible) for uploads — App Platform containers are
  **ephemeral**, so local-disk storage would be wiped on every deploy (the app already
  uses Spaces; see `SPACES_*` env vars),
- **DigitalOcean Functions** for the two daily cron jobs (App Platform has no native cron),
- a few third-party keys/credentials (Stripe, OpenAI, SMTP/SMTP2GO, Cloudflare Turnstile).

Because the source repo lives on self-hosted **Gitea** (which App Platform can't pull),
the app is deployed as a **pre-built image from DigitalOcean Container Registry (DOCR)**.

---

## 0. Prerequisites

- `doctl` installed and authenticated (`doctl auth init`)
- A DOCR registry: `doctl registry create <your-registry>` (once)
- A Managed PostgreSQL cluster (see step 1)
- The Space `property-management-network` in `nyc3` with CDN enabled (already set up)

### Optional: manage this app with the DigitalOcean MCP server

For natural-language management of App Platform, the database cluster, and Spaces
from Claude Code (deploy status, logs, env vars), register the DigitalOcean MCP
server. It's a **management convenience only** — deploys still go through DOCR +
`doctl`, migrations through `scripts/migrate-prod.mjs`, and cron through DO Functions.

1. Create a **scoped** DO API token (console → **API → Tokens**) limited to the
   resources used here: App Platform (read + write), Databases (read), Spaces
   (read). Do **not** use a full-access token.
2. Register it locally — the token is stored in `~/.claude.json`, never in the repo:
   ```bash
   claude mcp add digitalocean --scope local \
     -e DIGITALOCEAN_API_TOKEN=<scoped-token> \
     -- npx -y @digitalocean/mcp --services apps,databases,spaces
   ```
3. Reconnect the Claude Code session (`/mcp`); `claude mcp list` should then show
   `digitalocean` connected. Remove anytime with `claude mcp remove digitalocean`.

---

## 1. Database

Create a **Managed PostgreSQL** cluster and a database (e.g. `propertymanagementnetwork`).

**Privileges:** the migrator and the app both run DDL (create the `drizzle` schema +
tables, and apply migrations on boot). A database owned by `doadmin` does **not** grant
DDL to a scoped user automatically. Either:

- use the **`doadmin`** user in `DATABASE_URL`, **or**
- grant your scoped user the needed rights (run once as `doadmin`):
  ```sql
  GRANT ALL ON DATABASE propertymanagementnetwork TO propertymanagementnetworksuser;
  \c propertymanagementnetwork
  GRANT ALL ON SCHEMA public TO propertymanagementnetworksuser;
  ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT ALL ON TABLES TO propertymanagementnetworksuser;
  ```

**Host & port:** use the **private** host (`private-...db.ondigitalocean.com`) in the
app's `DATABASE_URL` — the app runs inside DO's network, so it's faster and not publicly
exposed. Use the **public** host only for one-off admin/migration from your laptop. Use
the **direct port `25060`** (not the `25061` connection pool) so `migrate-on-boot` and its
advisory locks work correctly.

**Trusted Sources:** on the DB cluster → **Settings → Trusted Sources**, add the App
Platform app (and, temporarily, your laptop's IP for the initial migration). Otherwise the
cluster's firewall refuses connections.

**SSL (verified TLS — recommended):** DO's DB cert isn't in the system trust store, so
verification against the system CAs fails. Use verify-full instead: **omit `?sslmode=...`
from `DATABASE_URL`**, keep `DATABASE_SSL=require`, and set `DATABASE_CA` to the cluster's
CA cert — DB cluster → **Connection Details → Download CA certificate**, then paste the PEM
contents as the `DATABASE_CA` secret. With `require` and no (or an invalid) CA the app
**refuses to connect** rather than run unverified — that's intended. `DATABASE_SSL=no-verify`
(encrypted but unverified) exists only as an emergency fallback; do not use it in production.

---

## 2. Build and push the image to DOCR

`NEXT_PUBLIC_*` values are inlined into the browser bundle at **build time**, so pass
them as `--build-arg`. Use the URL you'll actually serve on (your custom domain, or the
`*.ondigitalocean.app` URL once known):

```bash
doctl registry login   # auth Docker to DOCR

REG=registry.digitalocean.com/<your-registry>
docker build \
  --build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
  --build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
  --build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
  --build-arg NEXT_PUBLIC_SENTRY_DSN=<your-sentry-dsn> \
  --build-arg SENTRY_AUTH_TOKEN=<optional-for-source-maps> \
  -t $REG/property-management-network:latest .

docker push $REG/property-management-network:latest
```

> **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg`
> (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The
> server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert
> until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is
> optional and only uploads source maps for readable stack traces.

> First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the
> `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`.

---

## 3. Create the app

```bash
doctl apps create --spec .do/app.yaml
```

Then set every `type: SECRET` value (App → Settings → Environment Variables), or edit
`.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`,
`BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
`OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`,
`SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden):
`QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET`
(e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring —
plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP
(SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without
`SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime.

Migrations do **not** run on boot (`RUN_MIGRATIONS_ON_START=false`) — the app user has no
DDL rights by design. Apply schema changes out-of-band as **doadmin** before/after
deploying, from a machine allowed by the DB's Trusted Sources:

```bash
DATABASE_URL="postgresql://doadmin:<pw>@<public-host>:25060/propertymanagementnetwork" \
  DATABASE_SSL=no-verify node scripts/migrate.mjs
```

The migrator is idempotent (only pending migrations run). The current schema (0000–0002)
is already applied to production. Redeploys reuse the same image tag — App Platform pulls
the new `:latest` on push (`deploy_on_push`).

---

## 4. Cron — DigitalOcean Functions

The two jobs are triggered by DO Functions schedulers hitting the app's protected
endpoints. Create `functions/.env` (gitignored):

```
APP_BASE_URL=https://<your-domain>
CRON_SECRET=<same value as the app's CRON_SECRET>
```

Deploy:

```bash
doctl serverless install        # once
doctl serverless connect        # once, pick/create a namespace
doctl serverless deploy functions --env functions/.env
```

This registers `cron/run` with two scheduler triggers: `daily` at `0 9 * * *` and
`late-fees` at `0 8 * * *` (UTC). Verify in **Functions → Triggers**.

---

## 5. Stripe webhook

Point a Stripe webhook at `https://<your-domain>/api/stripe/webhook` and put its signing
secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`,
`customer.subscription.created/updated/deleted`, `invoice.payment_failed`,
`payment_intent.succeeded`.

---

## 6. Google OAuth (optional)

Set `GOOGLE_CLIENT_ID/SECRET` and add `https://<your-domain>/api/auth/callback/google`
as an authorized redirect URI.

---

## Post-deploy checklist

- [ ] `https://<domain>/api/health` returns `{"status":"ok",...}`
- [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*` + Turnstile)
- [ ] Upload a document; confirm the object appears in the Space and serves via the CDN
- [ ] Trigger the `daily` function manually (`doctl serverless functions invoke cron/run -p job:daily`) → 200
- [ ] Stripe webhook delivers successfully
