Files
monica_site/docs/monica-site-design.md
2026-07-16 05:48:12 -05:00

575 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Monica Site — Architecture & Design
A Django 6.0 web platform to replace PostcardMania for a single realtor client: a public
marketing site with a captcha-protected contact form, a private portal for leads and UTM
analytics, a managed mailing list with multi-channel outreach (email, SMS, postcard), and
direct social-media automation for Facebook, Instagram, and LinkedIn.
Built to deploy the same way as the existing `company_site` app: a Docker image using `uv`,
Postgres in production, and CI-driven deploys through the `server-infra` Ansible pipeline onto
multiple active/active instances behind Nginx Proxy Manager (NPM).
> Repo: `monica_site` (mirrors the `company_site` naming convention). Single-tenant now, but
> models and app boundaries are drawn so a future `Realtor`/`Organization` foreign key can be
> added without a rewrite.
---
## 1. Goals & Non-Goals
### Goals
- Public site to market the realtor, with a contact form protected by reCAPTCHA v3.
- Private portal: lead inbox, UTM/campaign analytics, mailing-list management, campaign
composer, social scheduler.
- One mailing list of contacts. Form submissions auto-create/append contacts.
- Per-channel, per-contact consent with easy opt-out (email unsubscribe link, SMS `STOP`).
- Outreach over Email + SMS (SMTP2GO) and Postcard (pluggable provider, default Lob).
- Social posting/scheduling to Facebook, Instagram, LinkedIn via native APIs.
- Cheaper than PostcardMania + adds social automation.
### Non-Goals (initial)
- Multi-tenant SaaS (structured for it later, not built now).
- Full CRM (deal pipelines, MLS/IDX listing sync) — future phase.
- In-app billing/subscriptions.
- Building our own print/mail logistics (we integrate a provider).
---
## 2. Feature Breakdown (mapped to the request)
- **1. Public site + contact form w/ reCAPTCHA** → `public` app. Reuse the proven
`django_recaptcha` v3 setup from `company_site` (`RECAPTCHA_PUBLIC_KEY` / `RECAPTCHA_PRIVATE_KEY`,
`django_recaptcha/widget_v3.html`).
- **2. Private side: leads + UTM analytics** → `dashboard` (portal shell + auth) + `leads`
(lead records, statuses) + `analytics` (UTM capture + reporting).
- **3. Mailing list + multi-channel outreach + opt-out** → `contacts` (people, consent) +
`messaging` (channels, templates, campaigns, provider adapters). Email/SMS via SMTP2GO,
postcards via a pluggable provider interface.
- **4. Social automation (FB/IG/LinkedIn)** → `social` (connected accounts, posts, scheduling,
native connectors).
---
## 3. High-Level Architecture
```mermaid
flowchart TB
subgraph edge [Edge]
NPM["Nginx Proxy Manager 10.0.0.230 (TLS + load balance)"]
end
subgraph hosts [App hosts adama + roslin - active/active]
Web1["monica_site web (gunicorn) :PORT"]
Web2["monica_site web (gunicorn) :PORT"]
end
subgraph worker [Background - single host]
TaskWorker["django.tasks worker process"]
DispatchTick["dispatch_due management command / tick"]
end
subgraph data [Shared services 10.0.0.230]
PG[("Postgres - monica_site DB + task queue store")]
end
subgraph external [External APIs]
SMTP2GO["SMTP2GO (email + SMS)"]
Postcard["Postcard provider (Lob default)"]
Meta["Meta Graph API (FB + IG)"]
LinkedIn["LinkedIn API"]
Recaptcha["Google reCAPTCHA"]
end
Visitor(["Public visitor"]) --> NPM
Realtor(["Realtor (portal)"]) --> NPM
NPM --> Web1 & Web2
Web1 & Web2 --> PG
Web1 & Web2 --> Recaptcha
TaskWorker --> PG
DispatchTick --> PG
TaskWorker --> SMTP2GO & Postcard & Meta & LinkedIn
```
Key points:
- **Web tier is stateless** and runs on every app host (matches `company_site`: active/active
behind NPM, `SECURE_PROXY_SSL_HEADER`, `USE_X_FORWARDED_HOST`).
- **Background work uses Django 6.0 Tasks (`django.tasks`)** — define with `@task`, enqueue with
`.enqueue()`. Queue store lives in **Postgres** (same DB as the app). No Redis, no Celery.
- **Worker is a singleton**: Django's Tasks API does not run tasks itself; a separate worker
process (from a production-capable third-party backend) claims and executes queued tasks.
Exactly one worker host so scheduled sends don't duplicate.
- **Shared external Postgres** on `10.0.0.230` (same pattern as `company_site`).
---
## 4. Background jobs — Django Tasks (not Celery/Redis)
Postcard mailing, social publishing, and bulk email/SMS must run outside the request cycle.
### Decision
Use **Django 6.0's built-in Tasks framework** (`django.tasks`) as the only background API.
| Piece | Choice |
|-------|--------|
| Define / enqueue | `@task` + `.enqueue()` / `aenqueue()` |
| Dev / tests | `ImmediateBackend` (or `DummyBackend` in tests) |
| Production queue | Third-party **database-backed** Task backend (Postgres queue store) + its worker CLI |
| Deferred / scheduled | Prefer `using(run_after=...)` when the backend supports defer; otherwise poll due rows via a small `dispatch_due` management command that enqueues Tasks |
Django guarantees task definition, validation, queuing, and result handling. **Execution** is
always a separate worker process — pick a backend from the
[Django Tasks community ecosystem](https://docs.djangoproject.com/en/6.0/topics/tasks/) /
Django Packages grid that provides a durable queue + worker. Do **not** introduce Redis or
Celery for this app.
### Pattern
```python
from functools import partial
from django.db import transaction
from django.tasks import task
@task
def send_campaign_message(message_id: int) -> None:
...
# After DB writes commit — workers use a separate connection
with transaction.atomic():
message = Message.objects.create(...)
transaction.on_commit(partial(send_campaign_message.enqueue, message_id=message.pk))
```
Scheduled social/campaign work:
1. Persist `scheduled_for` on the model when the realtor schedules.
2. Either enqueue immediately with `run_after=scheduled_for` (if backend supports defer), **or**
run `manage.py dispatch_due` on a short interval (same host as the worker) to find due rows
and `.enqueue()` publish/send tasks.
3. Worker executes; updates status / `provider_message_id` / `remote_id` on success or failure.
> No Redis broker. Task queue + app data share Postgres. One less moving part vs Celery.
---
## 5. Django Apps & Responsibilities
| App | Responsibility | Key models |
|-----|----------------|------------|
| `core` | Shared base classes, mixins, utils | `TimeStampedModel` (created/last_modified, like `company_site.TimeInfoBase`) |
| `public` | Public marketing pages, contact form + reCAPTCHA, sitemap/robots/SEO | — (writes `Lead` + `Contact`) |
| `accounts` | Realtor auth, portal login, profile | uses `django.contrib.auth`; `RealtorProfile` |
| `dashboard` | Private portal shell, nav, home widgets | — (read-only aggregates) |
| `leads` | Lead capture + lifecycle (new/contacted/won/lost), notes | `Lead`, `LeadNote` |
| `contacts` | The mailing list: people + per-channel consent + opt-out | `Contact`, `ConsentRecord`, `Suppression` |
| `analytics` | UTM capture, attribution, campaign reporting | `UTMVisit`, `Attribution` |
| `messaging` | Templates, campaigns, per-recipient messages, channel adapters | `MessageTemplate`, `Campaign`, `Message`, `ProviderEvent` |
| `social` | Connected social accounts, posts, scheduling, native connectors | `SocialAccount`, `SocialPost`, `SocialPostTarget` |
### Data model sketch
```mermaid
erDiagram
Contact ||--o{ ConsentRecord : has
Contact ||--o{ Message : receives
Lead }o--|| Contact : "linked to"
Lead ||--o{ LeadNote : has
Lead }o--o| Attribution : "attributed by"
UTMVisit }o--o| Lead : "converts to"
Campaign ||--o{ Message : sends
MessageTemplate ||--o{ Campaign : "used by"
SocialPost ||--o{ SocialPostTarget : "fans out to"
SocialAccount ||--o{ SocialPostTarget : "posted via"
Contact {
uuid id
string email
string phone
string first_name
string last_name
json postal_address
string source
}
ConsentRecord {
string channel "email|sms|postcard"
bool opted_in
datetime changed_at
string reason
}
Message {
string channel
string status "queued|sent|delivered|failed|bounced"
string provider
string provider_message_id
datetime scheduled_for
}
SocialPost {
text body
json media
datetime scheduled_for
string status
}
SocialPostTarget {
string platform "facebook|instagram|linkedin"
string status
string remote_id
}
```
**Consent model (important):** consent is per `(contact, channel)`. Every send checks for an
opted-in `ConsentRecord` and no active `Suppression`. Opt-out writes an immutable audit trail
(`ConsentRecord` change + `Suppression` row). This keeps us defensible on CAN-SPAM/TCPA and lets
a contact opt out of SMS while keeping email.
---
## 6. Channel Integrations
### 6.1 Email + SMS — SMTP2GO
- **Email:** Django SMTP backend to `mail.smtp2go.com` (already the `company_site` default:
`EMAIL_HOST`, `EMAIL_PORT=2525`, TLS). Every marketing email includes an unsubscribe link
(signed token → one-click opt-out view).
- **SMS:** SMTP2GO SMS REST API. Inbound `STOP`/`UNSUBSCRIBE` handled via SMTP2GO webhook →
writes a `Suppression`. A2P 10DLC registration is a client onboarding prerequisite (call out
in Phase 3).
### 6.2 Postcards — pluggable provider (default Lob)
A small provider interface so the realtor isn't locked in and we can shop on price:
```python
class PostcardProvider(Protocol):
def send_postcard(self, *, to: PostalAddress, from_: PostalAddress,
front: Asset, back: Asset, idempotency_key: str) -> ProviderResult: ...
def get_status(self, provider_id: str) -> DeliveryStatus: ...
```
Adapters live in `messaging/providers/postcard/`. Selected via `POSTCARD_PROVIDER` env var.
| Provider | Model | ~4x6 postcard | Best for | Notes |
|----------|-------|---------------|----------|-------|
| **Lob** (default) | API-first, tiered | Free dev tier ~$0.77; ~$0.51 on $260/mo Startup | Clean API, address verification, in-transit tracking | Best DX; monthly fee only worth it at volume |
| Click2Mail | Pay-per-piece, no subscription | ~$0.350.70 | Low/occasional volume, no monthly fee | API less polished; great when volume is small |
| PostGrid | API + dashboard, subscription | Contact sales (from ~$250/mo) | Compliance-heavy, templates | Overkill unless compliance-driven |
| Stannp | Marketer-friendly, no minimums | Transparent per-piece | Non-dev fallback, EU | Good dashboard |
| USPS EDDM | Postage-only saturation | Postage only | Whole-route blasts (no list) | No per-address API; manual/bulk option |
> Recommendation: start on **Lob Developer (free) tier** to validate, keep **Click2Mail** as the
> low-volume cost option behind the same interface. Revisit once monthly volume is known — the
> abstraction makes switching a config change.
### 6.3 Social — native APIs (Facebook, Instagram, LinkedIn)
Chosen over an aggregator (Ayrshare) to avoid per-profile monthly fees, since this is one
realtor with a fixed set of accounts.
- **Facebook + Instagram:** Meta Graph API (one integration covers both). IG posting requires an
IG Business/Creator account linked to a Facebook Page. Plan for quarterly Graph API version
bumps.
- **LinkedIn:** LinkedIn API (organization + member posting). Budget **24 weeks lead time** for
app review / posting-scope approval — start this early.
- Connectors live in `social/connectors/{meta,linkedin}.py` behind a common
`SocialConnector` interface (`publish(post, target) -> remote_id`, `refresh_token()`).
- OAuth tokens stored encrypted; due `SocialPost`s are enqueued as Django Tasks
(`run_after` or `dispatch_due`); the task worker fans out to each `SocialPostTarget`
with per-platform retry.
```mermaid
sequenceDiagram
participant R as Realtor
participant D as Dashboard
participant DB as Postgres
participant T as django.tasks enqueue
participant W as Task worker
participant P as Platform API
R->>D: Compose post, pick FB/IG/LI, schedule
D->>DB: SocialPost + SocialPostTargets (status=scheduled)
D->>T: enqueue publish with run_after or via dispatch_due
T->>DB: task row in queue store
W->>DB: claim due task
W->>P: publish via connector
P-->>W: remote_id / error
W->>DB: update target status + remote_id
```
### 6.4 UTM Analytics
- Landing views capture `utm_source/medium/campaign/term/content` + referrer into `UTMVisit`
(cookie/session correlation id).
- On contact-form submit, the correlation id ties the `UTMVisit``Lead``Attribution`.
- Dashboard reports leads by source/campaign over time (Chart.js — already vendored in
`company_site` static; reuse the material-dashboard theme).
- **Reuse existing Tianji** (self-hosted analytics already run at `tianji.aimloperations.com`)
for pageview-level traffic; our `analytics` app owns *conversion* attribution that Tianji
can't tie to CRM records.
---
## 7. Proposed Folder / File Layout
Mirrors `company_site` exactly for deploy compatibility (Dockerfile at repo root, `uv`,
`settings/` package with `DJANGO_ENV` dispatch, `docker-compose.prod.yml` with **no bundled
db**, `scripts/docker-entrypoint.sh`, `.gitea/workflows`).
```
monica_site/ # repo root
├── Dockerfile # python:3.12-slim + uv (copy of company_site pattern)
├── docker-compose.yml # local dev: web + db (+ optional task worker)
├── docker-compose.prod.yml # prod: web on all hosts; task worker on one host; external PG
├── pyproject.toml # uv-managed deps (django==6.0, db task backend, ...)
├── uv.lock
├── .env.example / .env.prod.example # documented env (extends company_site's)
├── .dockerignore / .gitignore
├── .pre-commit-config.yaml
├── README.md
├── docs/
│ └── monica-site-design.md # this document
├── scripts/
│ ├── docker-entrypoint.sh # migrate + collectstatic + gunicorn (web role)
│ ├── worker-entrypoint.sh # django.tasks backend worker (+ optional dispatch_due loop)
│ └── validate-env.sh
├── .gitea/workflows/
│ ├── ci.yml # PR: uv sync + manage.py test
│ ├── unittests.yml # master: tests gate deploy
│ └── deploy.yml # calls server-infra/scripts/deploy.sh --app monica_site
└── monica_site/ # Django project dir (manage.py lives here)
├── manage.py
├── monica_site/ # project package
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py # dispatch on DJANGO_ENV (dev|beta|prod)
│ │ ├── base.py # shared (DATABASE_URL, TASKS, recaptcha, smtp2go)
│ │ ├── dev.py # ImmediateBackend for local
│ │ ├── beta.py
│ │ ├── prod.py # DEBUG off, proxy SSL header, secure cookies, DB task backend
│ │ └── logging.py
│ ├── storage.py # TolerantManifestStaticFilesStorage (reuse)
│ ├── urls.py
│ ├── asgi.py
│ └── wsgi.py
├── core/ # base models/mixins/utils
│ └── management/commands/
│ └── dispatch_due.py # poll scheduled rows → enqueue django.tasks (if needed)
├── public/ # marketing pages + contact form (+ templates/static)
├── accounts/ # realtor auth + RealtorProfile
├── dashboard/ # private portal shell
├── leads/ # Lead, LeadNote
├── contacts/ # Contact, ConsentRecord, Suppression
├── analytics/ # UTMVisit, Attribution + reports
├── messaging/ # templates, campaigns, messages, tasks
│ ├── models.py
│ ├── tasks.py # @task senders (email/sms/postcard batches)
│ ├── channels/ # email.py, sms.py, postcard.py (dispatch)
│ └── providers/
│ ├── email/smtp2go.py
│ ├── sms/smtp2go.py
│ └── postcard/{base.py,lob.py,click2mail.py,postgrid.py}
└── social/ # accounts, posts, scheduling
├── models.py
├── tasks.py # @task publish_social_target, etc.
└── connectors/{base.py,meta.py,linkedin.py}
```
Each Django app follows the standard `admin.py / apps.py / models.py / views.py / urls.py /
forms.py / migrations/ / templates/<app>/ / static/<app>/ / tests.py` layout used in
`company_site` (e.g. `public/`, `financial/`).
---
## 8. Deployment (matches server-infra pipeline)
The existing pipeline already knows how to deploy Dockerized Django apps to active/active hosts.
We plug in by mirroring `company_site` and adding one catalog entry.
### 8.1 Container & compose
- **Dockerfile**: identical pattern to `company_site``python:3.12-slim`, `uv sync --frozen
--no-dev`, copy project, `scripts/docker-entrypoint.sh` runs
`migrate → collectstatic → gunicorn monica_site.wsgi:application`.
- **`docker-compose.prod.yml`**: `web` on app hosts (external Postgres via `DATABASE_URL`;
**drop the bundled `db`**, per the server-infra checklist). Add a **`worker`** service on
exactly one host (or `docker-compose.worker.yml`) that runs the chosen django.tasks backend
worker. Optionally the same process/host loops `dispatch_due` for schedule polling if the
backend lacks `run_after` defer support.
### 8.2 server-infra wiring (repo reviewed)
- Add to `inventory/group_vars/all.yml` → `app_catalog`:
```yaml
monica_site:
type: django
repo: "{{ git_base_url }}/ai_ml_operations/monica_site.git"
default_branch: main
compose_file: docker-compose.prod.yml
web_service: web
migrate_cmd: "uv run python manage.py migrate --noinput"
```
- Assign host ports in `host_vars/<host>.yml` `host_apps` (reserve a new port pair, e.g.
`prod 8003 / beta 8013`, matching across adama+roslin so NPM can balance).
- Create control-node secret `~/Documents/secrets/monica_site/monica_site_prod.env`
(`DATABASE_URL`, `DJANGO_SECRET_KEY`, `WEB_PORT`, provider keys). Never committed;
Ansible pushes it to `/opt/apps/env/monica_site_prod.env` (mode 600) at deploy.
- Shared Postgres prereq on `10.0.0.230`: create `monica_site` DB + user (and `monica_site_beta`
for beta), like the other apps. Task queue tables live in the same DB.
### 8.3 CI/CD flow
Same as `company_site`:
1. PR → `ci.yml` runs `uv sync` + `manage.py test`.
2. Merge to `main` → `unittests.yml` (containerized tests) → on green, `deploy.yml` calls
`server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
3. `deploy-apps.yml` checks out the ref on each app host, injects `.env`, `docker compose build`,
`up -d`, runs `migrate` once.
```mermaid
flowchart LR
PR["PR to main"] --> CI["ci.yml: uv sync + tests"]
CI --> Merge["merge main"]
Merge --> UT["unittests.yml (docker tests)"]
UT -->|green| DEP["deploy.yml -> server-infra deploy.sh"]
DEP --> A["adama: web :8003"]
DEP --> R["roslin: web :8003"]
DEP --> WK["django.tasks worker (single host)"]
A & R --> NPM["NPM balances monica domain"]
```
### 8.4 New infra bits beyond company_site
- **Task worker placement**: exactly one host runs the django.tasks worker (and `dispatch_due`
if used). Document this so active/active web replicas do not each run a worker.
- **No Redis** required for background jobs — queue store is Postgres.
---
## 9. Environment Variables (extends company_site's set)
```
# Core (same as company_site)
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=...
DJANGO_ALLOWED_HOSTS=monicadomain.com,www.monicadomain.com
DATABASE_URL=postgres://monica_site:<pw>@10.0.0.230:5432/monica_site
WEB_PORT=8003
GUNICORN_WORKERS=2
# Django Tasks — prod uses a DB-backed third-party backend + worker.
# Dev defaults to ImmediateBackend in settings/dev.py (no worker needed).
# TASKS is configured in settings, not usually via env; override only if needed.
# reCAPTCHA v3 (same as company_site)
RECAPTCHA_PUBLIC_KEY=...
RECAPTCHA_PRIVATE_KEY=...
# SMTP2GO email (same defaults as company_site)
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=...
EMAIL_HOST_PASSWORD=...
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# SMTP2GO SMS
SMTP2GO_SMS_API_KEY=...
# Postcards
POSTCARD_PROVIDER=lob # lob|click2mail|postgrid
LOB_API_KEY=...
# Social (native)
META_APP_ID=...
META_APP_SECRET=...
LINKEDIN_CLIENT_ID=...
LINKEDIN_CLIENT_SECRET=...
SOCIAL_TOKEN_ENCRYPTION_KEY=...
# Analytics (reuse Tianji)
TIANJI_ENABLED=true
TIANJI_WEBSITE_ID=...
```
---
## 10. Phased Implementation Plan
### Phase 0 — Scaffolding & deploy skeleton
- Create `monica_site` Django 6.0 project via `uv`; `settings/` dispatch; Dockerfile,
compose (dev with db), entrypoints, `.gitea/workflows`, pre-commit.
- Configure `TASKS` (ImmediateBackend in `dev`, DB-backed backend in `prod`); add
`worker-entrypoint.sh` for the task worker.
- Add `core` app (`TimeStampedModel`), health check, base template.
- Wire `server-infra` `app_catalog` entry + host ports + secrets; get a "hello world" deploying
to adama+roslin behind NPM on a staging domain.
- Create Postgres DB/user on `10.0.0.230`.
- **Exit:** empty app deploys via CI to prod hosts, health check green; worker can claim a no-op task.
### Phase 1 — Public site + contact form + leads + UTM
- `public`: marketing pages (home/about/contact), `django_recaptcha` v3 contact form.
- `contacts`: `Contact` + `ConsentRecord` (default opt-in on submit, with clear notice).
- `leads`: `Lead` + statuses; form submit creates/updates `Contact` and a `Lead`.
- `analytics`: `UTMVisit` capture middleware + attribution on submit.
- `accounts` + `dashboard`: realtor login, portal shell, lead inbox, basic UTM report (Chart.js).
- **Exit:** realtor logs in, sees leads with source attribution; visitors submit safely.
### Phase 2 — Mailing list + Email/SMS outreach + opt-out
- `messaging`: `MessageTemplate`, `Campaign`, `Message`, `ProviderEvent`; `@task` senders online.
- Email via SMTP2GO with unsubscribe links; SMS via SMTP2GO SMS API.
- Opt-out views + SMS `STOP` webhook → `Suppression`; consent enforced on every send.
- Dashboard: contact list management, segment/select recipients, compose + schedule campaign,
delivery status (enqueue via `transaction.on_commit` + django.tasks).
- **Exit:** realtor sends a real email + SMS campaign to consented contacts; opt-out works.
### Phase 3 — Postcards
- `messaging/providers/postcard/` interface + Lob adapter (+ Click2Mail adapter as the
low-volume option); `POSTCARD_PROVIDER` switch.
- Postcard campaign flow: pick template/artwork, select recipients (address required + Lob
address verification), enqueue batch as Django Tasks, poll delivery status.
- Client prerequisites called out: Lob account, A2P 10DLC for SMS.
- **Exit:** realtor mails a postcard batch and sees delivery tracking.
### Phase 4 — Social automation
- `social`: `SocialAccount` OAuth connect (Meta + LinkedIn), encrypted token storage.
- Connectors for Meta (FB Page + IG Business) and LinkedIn; `SocialPost` + `SocialPostTarget`
compose/schedule; enqueue via `run_after` or `dispatch_due`; task worker fans out with retries.
- Dashboard: composer with per-platform preview, calendar/scheduled view, post status.
- **Kick off LinkedIn app review in Phase 01** given 24 week approval lead time.
- **Exit:** realtor schedules one post to all three platforms; it publishes and reports status.
### Phase 5 — Hardening & polish
- Rate limiting/backoff per provider, dead-letter handling, admin dashboards for failures.
- Analytics polish (campaign ROI, source trends), email/SMS engagement events.
- Backups for Postgres, monitoring via existing Alloy/Tianji, docs + runbook.
```mermaid
flowchart LR
P0["P0 Scaffold + deploy"] --> P1["P1 Public + leads + UTM"]
P1 --> P2["P2 Mailing list + email/SMS"]
P2 --> P3["P3 Postcards"]
P1 --> P4["P4 Social"]
P3 --> P5["P5 Harden"]
P4 --> P5
```
---
## 11. Key Risks & Decisions
- **Singleton task worker:** exactly one django.tasks worker across the active/active fleet, or
duplicate claims / double-sends are possible depending on backend locking. Pin to one host.
- **Django Tasks has no built-in production worker:** must pick and pin a third-party DB backend
+ worker early in Phase 0; built-in backends are Immediate/Dummy only.
- **Scheduling:** if the chosen backend lacks `supports_defer` / `run_after`, rely on
`dispatch_due` polling — keep the tick interval short and idempotent.
- **LinkedIn approval latency (24 wks):** start the app-review process at project kickoff.
- **SMS compliance (A2P 10DLC/TCPA):** client-side registration required before SMS launch;
consent + `STOP` handling are built in but registration is a prerequisite.
- **Postcard cost/volume:** Lob's monthly tiers only pay off at volume; the provider abstraction
lets us default to Lob's free tier and drop to Click2Mail (pay-per-piece) if volume stays low.
- **Meta Graph versioning:** budget quarterly maintenance for API version bumps.
- **Single-tenant → multi-tenant later:** every tenant-scoped model gets a nullable
`owner/realtor` FK path reserved so a later migration adds isolation without a rewrite.
---
## 12. Cost Snapshot (why it beats PostcardMania)
- **Hosting:** runs on existing adama/roslin fleet — near-zero marginal infra (Postgres only;
no Redis/Celery stack).
- **Email/SMS:** SMTP2GO usage-based (already an org account).
- **Postcards:** ~$0.350.77/4x6 at provider cost vs PostcardMania's bundled campaign packages;
no forced campaign minimums.
- **Social:** $0 API fees (native), vs PostcardMania add-ons / aggregator per-profile fees.
- **No PostcardMania platform/campaign markup**; realtor pays provider cost + our hosting.