Files
classifieds/CLAUDE.md
T
2026-07-13 17:07:30 -04:00

1146 lines
51 KiB
Markdown
Raw 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.
# CLAUDE.md — Classifieds Marketplace
> Master spec and working agreement for an AI coding assistant.
> Read this fully before writing any code. Follow conventions exactly.
> Ask before deviating from schema, architecture, or naming.
---
## 1. Project Overview
National US classifieds marketplace targeting Vietnamese and Hispanic communities,
with full trilingual UI (English / Vietnamese / Spanish) and location-based filtering.
**Live site:** https://classifieds.ngodanguyen.tech
**Mission:** Connect people with needs — buy, sell, request, hire, and find services
across their local community.
**Six listing categories:**
1. For Sale — goods, items
2. Wanted / Requesting — reverse listings ("looking for X")
3. Jobs — employer posts + optional job-seeker posts
4. Services — offering labor/skills
5. Supplies — bulk/wholesale, B2B
6. Community — events, announcements, freebies
Each category has subcategories and a JSON-driven `field_schema`
(admin-editable, no redeploy required).
---
## 2. Tech Stack (fixed — do not substitute)
| Layer | Technology |
|---|---|
| Language | Python 3.10+ |
| Web framework | Flask (app factory + blueprints) |
| Database | **MySQL 8.0**`utf8mb4` / `utf8mb4_unicode_ci`. NOT MariaDB. |
| ORM + migrations | SQLAlchemy 2.x + Alembic (Flask-Migrate) |
| Cache / queue / rate-limit | Redis |
| WSGI | Gunicorn (unix socket) |
| Reverse proxy / static | Nginx |
| Process manager | systemd |
| OS | Ubuntu 22.04 Server |
| Background jobs | systemd timers (expiry/boost/keyword sweeps). RQ planned — not yet installed |
| Payments | Stripe (Billing + Payment Intents + Tax + Customer Portal) |
| Email | SMTP relay via Brevo (smart relay, existing infra) |
| i18n | Flask-Babel |
| Forms / CSRF | Flask-WTF |
| Password hashing | Argon2 (`argon2-cffi`) |
| Image processing | Pillow (re-encode, strip EXIF, thumbnail) |
| CAPTCHA | Cloudflare Turnstile (bypassed in dev when keys blank) |
**Current `requirements.txt`:**
```
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Babel==4.0.0
Flask-Limiter==3.8.0
SQLAlchemy==2.0.34
alembic==1.13.2
argon2-cffi==23.1.0
email-validator==2.2.0
itsdangerous==2.2.0
python-dotenv==1.0.1
redis==5.0.8
requests==2.32.3
PyMySQL==1.1.1
gunicorn==23.0.0
Pillow==10.4.0
stripe==10.12.0
```
---
## 3. Architecture
```
Internet
│ 443 (TLS via Certbot / Let's Encrypt)
Nginx
├─ /static/ → app/static/ (30d cache)
├─ /media/ → instance/media/ (7d cache)
└─ / → Gunicorn unix socket → Flask app factory
┌─────────────────────────┼──────────────┐
MySQL 8.0 Redis Stripe
(utf8mb4) (sessions, cache, (webhooks,
rate-limit, RQ) billing)
SMTP relay
(Brevo)
```
**ProxyFix** applied in app factory (`x_for=1, x_proto=1, x_host=1`) so
`request.is_secure`, secure cookies, and rate-limit IP are correct behind Nginx.
**systemd units (in `deploy/`):**
- `classifieds.service` — Gunicorn web process
- `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry
- `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost expiry +
promoted keyword cleanup + subscription reconcile + expiry warning/expired emails
---
## 4. Project File Structure
```
classifieds/
├── wsgi.py # WSGI entry point for Gunicorn + flask CLI
├── seed.py # Seed plans, categories, zip_geo; --admin flag
├── babel.cfg # pybabel extraction config
├── requirements.txt
├── .env.example # All env vars (no inline # comments)
├── .gitignore
├── CLAUDE.md # This file
├── README.md # Setup, run, deploy instructions
├── app/
│ ├── __init__.py # App factory: create_app()
│ ├── config.py # BaseConfig / DevConfig / ProdConfig
│ ├── extensions.py # db, migrate, login_manager, csrf, babel, limiter
│ │
│ ├── models/
│ │ ├── __init__.py # Exports all models (import order matters)
│ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType,
│ │ │ # ListingStatus, Lang, ReportReason
│ │ ├── plan.py # Plan (tier config JSON)
│ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login)
│ │ ├── trust.py # TrustEvent (append-only trust ledger)
│ │ ├── category.py # Category (self-ref subcategories, field_schema)
│ │ ├── listing.py # Listing + ListingImage
│ │ ├── geo.py # ZipGeo + Metro
│ │ ├── messaging.py # Conversation + Message
│ │ ├── favorite.py # Favorite
│ │ ├── payments.py # Subscription, Transaction, Boost
│ │ ├── ads.py # Ad, Sponsor, PromotedKeyword
│ │ ├── report.py # Report (user-submitted listing reports)
│ │ ├── audit.py # AuditLog (append-only admin action trail)
│ │ ├── setting.py # Setting (key/JSON-value admin runtime config)
│ │ └── review.py # Review (15 star seller reviews, unique per listing+author)
│ │
│ ├── blueprints/
│ │ ├── auth/ # register, login, logout, verify-email, reset
│ │ ├── main/ # index, /healthz, /robots.txt, /sitemap.xml,
│ │ │ # /classifieds/category/<slug>, /classifieds/state/<state>
│ │ ├── i18n/ # /lang/<code> locale switcher
│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images,
│ │ │ # report, review
│ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread
│ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing
│ │ ├── ads/ # click tracking, sponsor directory, inject_ads()
│ │ └── admin/ # full admin backend (dashboard, users, listings,
│ │ # reports, categories, plans, ads, sponsors,
│ │ # promoted-keywords, transactions, analytics,
│ │ # audit log, settings)
│ │
│ ├── services/
│ │ ├── email.py # send_email (SMTP or dev console)
│ │ ├── turnstile.py # Cloudflare Turnstile verify
│ │ ├── trust.py # record_event, trust tier computation
│ │ ├── geo.py # geocode_zip, haversine_mi, bounding_box
│ │ ├── field_schema.py # validate_attributes, hot_values
│ │ ├── images.py # process_upload, delete_image_files
│ │ ├── listings.py # create/update/browse/search/radius/expiry +
│ │ │ # keyword blocklist enforcement
│ │ ├── messaging.py # conversations, send, mark-read, inbox, unread
│ │ ├── favorites.py # toggle, is_favorited, user_favorites
│ │ ├── contact.py # mask_body, contact_revealed, contact_density
│ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile
│ │ ├── ads.py # get_ad, record_impression/click, promoted_listings,
│ │ │ # active_sponsors, expire_promoted_keywords
│ │ ├── reports.py # create_report (auto-flag at threshold), ReportError
│ │ ├── moderation.py # flag_queue, approve, hide, remove (+ audit_log)
│ │ ├── audit.py # log_action — append AuditLog row, caller commits
│ │ ├── settings.py # get_setting, set_setting (runtime config from DB)
│ │ ├── admin_dashboard.py # KPI queries: active_listings, new_users, mrr,
│ │ │ # revenue_30d, flag_queue_depth
│ │ ├── admin_users.py # search_query, set_status, set_tier, adjust_trust
│ │ ├── expiry_notifications.py # warn_expiring(days), notify_expired — email sweeps
│ │ └── reviews.py # create_review, seller_rating
│ │
│ ├── utils/
│ │ ├── __init__.py # RBAC decorators: role_required, admin_required,
│ │ │ # moderator_required
│ │ ├── security.py # hash_password, verify_password, generate_token,
│ │ │ # read_token (itsdangerous)
│ │ ├── text.py # normalize() — accent-insensitive (phở→pho, ñ→n)
│ │ └── time.py # utcnow() — naive-UTC helper (replaces datetime.utcnow)
│ │
│ ├── templates/
│ │ ├── base.html # Layout: responsive nav, OG/meta blocks, toast JS,
│ │ │ # header/footer ad slots, lang switcher
│ │ ├── index.html # Landing page
│ │ ├── sitemap.xml # XML sitemap template
│ │ ├── auth/ # login, register, reset_request, reset, _macros
│ │ ├── listings/ # browse, detail (JSON-LD, reviews), form, mine,
│ │ │ # review_form
│ │ ├── main/ # category_landing.html, state_landing.html
│ │ ├── messaging/ # inbox, conversation, start, favorites
│ │ ├── payments/ # pricing, billing, boost, success
│ │ ├── ads/ # _slot.html (reusable ad slot partial)
│ │ ├── sponsors/ # directory.html
│ │ ├── admin/ # dashboard, users, user_detail, listings, reports,
│ │ │ # categories, category_schema, plans, plan_edit,
│ │ │ # ads, ad_edit, sponsors, sponsor_edit,
│ │ │ # promoted_keywords, promoted_keyword_new,
│ │ │ # transactions, analytics, audit, settings, _nav
│ │ └── errors/ # 403, 404, 500, maintenance
│ │
│ ├── static/
│ │ └── style.css # Single CSS file (Phase 17 accumulated)
│ │
│ └── translations/ # Flask-Babel .po/.mo for vi + es
├── migrations/ # Alembic migration scripts
├── deploy/
│ ├── classifieds.service # systemd: Gunicorn web
│ ├── classifieds-expire.service # systemd: oneshot listing expiry
│ ├── classifieds-expire.timer # systemd: hourly
│ ├── classifieds-nightly.service # systemd: boost expiry + keyword cleanup +
│ │ # subscription reconcile + expiry emails
│ ├── classifieds-nightly.timer # systemd: 2am daily
│ ├── gunicorn.conf.py # workers, socket, timeouts
│ └── nginx.conf.sample # HTTPS redirect + proxy + static + media
└── tests/
└── test_smoke.py # Integration test (SQLite + in-memory Redis)
# isolated temp media dir; all Phase 16 checks green
```
---
## 5. Route Map (70+ routes, Phase 17)
### Public & Auth (Phase 1)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/` | main | — |
| GET | `/healthz` | main | — |
| GET | `/robots.txt` | main | — |
| GET | `/sitemap.xml` | main | — |
| GET/POST | `/auth/register` | auth | — |
| GET/POST | `/auth/login` | auth | — |
| GET | `/auth/logout` | auth | login |
| GET | `/auth/verify/<token>` | auth | — |
| GET/POST | `/auth/reset` | auth | — |
| GET/POST | `/auth/reset/<token>` | auth | — |
| GET | `/lang/<code>` | i18n | — |
### SEO Landing Pages (Phase 7)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/classifieds/category/<slug>` | main | — |
| GET | `/classifieds/state/<state>` | main | — |
### Listings (Phase 27)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/listings` | listings | — |
| GET | `/listings/new` | listings | login |
| GET | `/listings/<id>` | listings | — |
| GET/POST | `/listings/<id>/edit` | listings | login+owner |
| POST | `/listings/<id>/delete` | listings | login+owner |
| POST | `/listings/<id>/sold` | listings | login+owner |
| POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner |
| POST | `/listings/<id>/report` | listings | login |
| GET/POST | `/listings/<id>/review` | listings | login |
| GET | `/media/<path>` | listings | — |
| GET | `/my/listings` | listings | login |
### Messaging & Social (Phase 3)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET/POST | `/listings/<id>/contact` | messaging | login |
| POST | `/listings/<id>/favorite` | messaging | login |
| GET | `/my/favorites` | messaging | login |
| GET | `/messages` | messaging | login |
| GET/POST | `/messages/<id>` | messaging | login+participant |
| GET | `/api/unread` | messaging | login |
### Payments (Phase 4)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
| GET | `/listings/<id>/boost/success` | payments | login |
| GET | `/my/billing` | payments | login |
| GET | `/pricing` | payments | — |
| GET | `/billing/subscribe/<slug>` | payments | login |
| GET | `/billing/portal` | payments | login |
| GET | `/billing/success` | payments | login |
| POST | `/billing/webhook` | payments | CSRF-exempt |
### Ads & Sponsors (Phase 5)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/ads/<id>/click` | ads | — |
| GET | `/sponsors` | ads | — |
### Admin (Phase 6)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/admin` | admin | admin |
| GET | `/admin/users` | admin | admin |
| GET | `/admin/users/<id>` | admin | admin |
| POST | `/admin/users/<id>/status` | admin | admin |
| POST | `/admin/users/<id>/tier` | admin | admin |
| POST | `/admin/users/<id>/trust` | admin | admin |
| POST | `/admin/users/<id>/impersonate` | admin | admin |
| GET | `/admin/listings` | admin | admin/mod |
| POST | `/admin/listings/<id>/approve` | admin | admin/mod |
| POST | `/admin/listings/<id>/hide` | admin | admin/mod |
| POST | `/admin/listings/<id>/remove` | admin | admin/mod |
| GET | `/admin/reports` | admin | admin/mod |
| POST | `/admin/reports/<id>/resolve` | admin | admin/mod |
| GET | `/admin/categories` | admin | admin |
| GET/POST | `/admin/categories/new` | admin | admin |
| GET/POST | `/admin/categories/<id>/edit` | admin | admin |
| GET/POST | `/admin/categories/<id>/schema` | admin | admin |
| GET | `/admin/plans` | admin | admin |
| GET/POST | `/admin/plans/<id>/edit` | admin | admin |
| GET | `/admin/ads` | admin | admin |
| GET/POST | `/admin/ads/new` | admin | admin |
| GET/POST | `/admin/ads/<id>/edit` | admin | admin |
| POST | `/admin/ads/<id>/delete` | admin | admin |
| POST | `/admin/ads/<id>/toggle` | admin | admin |
| GET | `/admin/sponsors` | admin | admin |
| GET/POST | `/admin/sponsors/new` | admin | admin |
| GET/POST | `/admin/sponsors/<id>/edit` | admin | admin |
| POST | `/admin/sponsors/<id>/delete` | admin | admin |
| GET | `/admin/promoted-keywords` | admin | admin |
| GET/POST | `/admin/promoted-keywords/new` | admin | admin |
| POST | `/admin/promoted-keywords/<id>/delete` | admin | admin |
| GET | `/admin/transactions` | admin | admin |
| POST | `/admin/transactions/<id>/refund` | admin | admin |
| GET | `/admin/analytics` | admin | admin |
| GET | `/admin/audit` | admin | admin |
| GET/POST | `/admin/settings` | admin | admin |
---
## 6. User Roles & RBAC
| Role | Capabilities |
|---|---|
| **free** | Register, post (limited), message, buy boosts, sees ads |
| **subscriber** | Tier features (Basic/Pro/Business), ad-free |
| **moderator** | Flag queue, hide/remove listings, ban users — no billing |
| **admin** | Full control |
RBAC enforced via decorators in `app/utils/__init__.py`:
`@role_required('admin')`, `@admin_required`, `@moderator_required`
---
## 7. Subscription Tiers
Limits stored in `plans.config` JSON — editable in admin without redeploy.
| Feature | Free | Basic ($9.99) | Pro ($24.99) | Business ($59.99) |
|---|---|---|---|---|
| Active listings | 3 | 15 | 50 | Unlimited |
| Listing life (days) | 14 | 30 | 60 | 90 |
| Images / listing | 3 | 8 | 15 | 25 |
| Featured slots / mo | 0 | 1 | 5 | 20 |
| Auto-bump | — | — | Weekly | Daily |
| Analytics | — | Basic | Full | Full + export |
| Storefront page | — | — | ✓ | ✓ + custom URL |
| Verified badge | — | — | ✓ | ✓ |
| Ad-free | — | ✓ | ✓ | ✓ |
| Scheduled posting | — | — | ✓ | ✓ |
| Bulk CSV upload | — | — | — | ✓ |
| Priority support | — | — | — | ✓ |
Plan slugs: `free`, `basic`, `pro`, `business`. Seeded by `seed.py`.
---
## 8. Monetization Streams
1. **Subscriptions** — recurring via Stripe Billing ✅ Phase 4
2. **À la carte boosts** — featured ($4.99/7d), bump ($1.99/3d), highlight ($2.99/7d),
urgent ($1.99/7d) — Stripe Payment Intents ✅ Phase 4
3. **Banner ads** — internal ad server, hidden for subscribers ✅ Phase 5
4. **Sponsors** — directory + category sponsorship ✅ Phase 5
5. **Promoted search** — keyword-pinned listing results ✅ Phase 5
---
## 9. Database Schema
**All tables:** MySQL 8.0, `utf8mb4`, UTC datetimes, `BIGINT` PKs with
`BigInteger().with_variant(Integer, "sqlite"), autoincrement=True` for SQLite
test compatibility. **Money: integer cents always. Never floats.**
### Identity & Billing
```
users id, email (unique), password_hash, display_name,
role ENUM(free,subscriber,moderator,admin),
status ENUM(active,suspended,banned),
locale(5), tier_id→plans, trust_score INT, trust_tier ENUM,
verified BOOL, email_verified BOOL, last_login_at,
created_at, updated_at
plans id, slug (unique), name, price_monthly_cents, stripe_price_id,
config JSON, is_active, sort_order, created_at, updated_at
trust_events id, user_id→users, type ENUM(account_age,listing_survived,
flag_received,verified_email,payment), delta INT, created_at
subscriptions id, user_id→users (unique), plan_id→plans,
stripe_customer_id, stripe_sub_id (unique),
status ENUM(active,past_due,canceled,trialing),
current_period_end, cancel_at_period_end BOOL,
created_at, updated_at
transactions id, user_id→users, type ENUM(subscription,boost,refund),
amount_cents, currency, stripe_object_id, status,
meta JSON, created_at
boosts id, listing_id→listings, user_id→users,
type ENUM(featured,bump,highlight,urgent),
expires_at [indexed], transaction_id→transactions, created_at
```
### Catalog
```
categories id, slug (unique), name, parent_id→categories (nullable),
field_schema JSON, icon, sort_order, sponsor_id (nullable),
is_active, created_at, updated_at
listings id, user_id→users, category_id→categories,
title(140), title_norm(140) [indexed, accent-stripped],
body TEXT, lang ENUM(en,vi,es), price_cents INT nullable,
zip(12), city(80), state(2), lat FLOAT, lng FLOAT,
attributes JSON,
attr_condition(40) [indexed], attr_job_type(40) [indexed],
attr_salary_min INT [indexed], attr_salary_max INT [indexed],
status ENUM(active,flagged,sold,expired,removed),
is_featured BOOL, bump_at DATETIME, flag_count INT,
view_count INT, expires_at DATETIME [indexed],
created_at, updated_at
Indexes: ix_listing_browse(status,expires_at),
ix_listing_sort(is_featured,bump_at)
listing_images id, listing_id→listings, path(255), thumb_path(255),
sort_order, width, height, created_at
```
### Geo
```
zip_geo zip PK, city, state [indexed], lat, lng, metro [indexed]
(~42k rows from SimpleMaps/Census ZCTA for prod;
10-row sample seeded for dev)
metros id, slug (unique), name, state, center_lat, center_lng
(SEO landing pages — Phase 7)
```
### Messaging & Social
```
conversations id, listing_id→listings, buyer_id→users, seller_id→users,
last_message_at [indexed], created_at
UNIQUE(listing_id, buyer_id)
messages id, conversation_id→conversations, sender_id→users,
body TEXT, read_at DATETIME nullable, created_at
favorites id, user_id→users, listing_id→listings, created_at
UNIQUE(user_id, listing_id)
```
### Ads & Sponsors
```
ads id, advertiser_name, slot ENUM(header,sidebar,inline,footer),
creative_path, target_url, alt_text,
lang nullable, geo_state nullable,
starts_at, ends_at, impressions INT, clicks INT,
is_active, created_at, updated_at
sponsors id, name, logo_path, url, tagline,
tier ENUM(directory,category),
category_id→categories nullable,
starts_at, ends_at, is_active, created_at
promoted_keywords id, keyword(80) [indexed], listing_id→listings,
priority INT, expires_at [indexed], created_at
UNIQUE(keyword, listing_id)
```
### Admin & Ops (Phase 6)
```
reports id, listing_id→listings, reporter_id→users,
reason ENUM(spam,scam,offensive,duplicate,miscategorized,other),
note, created_at
UNIQUE(reporter_id, listing_id)
audit_log id, actor_id→users, action, target_type, target_id,
meta JSON, created_at
settings key PK, value JSON
(registration_open, ads_enabled, maintenance_mode,
flag_threshold, keyword_blocklist, new_user_trust_gate_days,
contact_density_threshold)
```
### Reviews (Phase 7)
```
reviews id, listing_id→listings, author_id→users, seller_id→users,
rating SMALLINT (15, CHECK CONSTRAINT),
body TEXT nullable, created_at
UNIQUE(listing_id, author_id)
```
### Deferred
```
listing_translations id, listing_id→listings, lang, title, body, cached_at
(Phase 7: auto-translate cache — not yet implemented)
```
---
## 10. Key Services (implemented)
### `services/listings.py`
- `create_listing(user, category, ...)` — tier-enforced cap, ZIP geocode, accent-norm
`title_norm`, hot-column denorm, `expires_at` from plan life
- `update_listing(listing, category, ...)` — same validations, in-place update
- `browse_query(...)` — filters: category, state, price, condition, keyword (`title_norm LIKE`)
- `search_with_radius(base_query, lat, lng, radius_mi)` — bbox prefilter + haversine refine
- `expire_due_listings()` — flips past-due active → expired; hourly systemd timer
- `can_create(user)`, `active_count(user)`, `image_cap(user)` — tier helpers
### `services/geo.py`
- `geocode_zip(zip)``(lat, lng, city, state, metro)` from `zip_geo` table
- `haversine_mi(lat1, lng1, lat2, lng2)` — exact distance in miles
- `bounding_box(lat, lng, radius_mi)``(min_lat, max_lat, min_lng, max_lng)`
### `services/field_schema.py`
- `validate_attributes(category, raw_dict)``(cleaned, errors)`
- `hot_values(cleaned)``attr_*` column dict for denormalization
### `services/images.py`
- `process_upload(file_storage, listing_id, sort_order)` — MIME validate, Pillow
re-encode JPEG, strip EXIF, 1600px max, 400px thumb, random filename
- `delete_image_files(image)` — removes full + thumb from disk
### `services/messaging.py`
- `get_or_create_conversation(listing, buyer)` — idempotent, blocks self-message
- `send_message(conv, sender, body)` — validates, contact-density heuristic,
saves, updates `last_message_at`, sends notification email
- `mark_conversation_read(conv, reader)`
- `inbox(user, page, per_page)` — paginated, newest first
- `total_unread(user)` — injected into every template via context processor
### `services/contact.py`
- `mask_body(text, reveal)` — hides phone/email/URL for low-trust users
- `contact_revealed(user)` — True when `email_verified AND trust_tier >= trusted`
- `contact_density(text)` — count of contact signals (auto-flag heuristic)
### `services/trust.py`
- `record_event(user, event_type, delta)` — appends event, updates score + tier
(new=0, basic=5, trusted=20, verified=50)
### `services/favorites.py`
- `toggle_favorite(user_id, listing_id)` → bool
- `is_favorited(user_id, listing_id)` → bool
- `user_favorites(user_id, page, per_page)` → pagination
### `services/billing.py`
- `stripe_enabled()` — guard; app runs without keys in dev
- `get_or_create_customer(user)` → Stripe customer ID
- `create_subscription_checkout(user, plan, success_url, cancel_url)` → Stripe URL
- `create_customer_portal(user, return_url)` → Stripe URL
- `create_boost_checkout(user, listing, boost_type, success_url, cancel_url)` → URL
- `activate_boost(user_id, listing_id, boost_type, payment_intent_id, amount_cents)`
— idempotent on `payment_intent_id`; writes Transaction + Boost; applies listing effects
- `sync_subscription(user_id, stripe_sub_obj)` — upserts Subscription, upgrades role/tier
- `downgrade_to_free(user_id)` — reverts role + tier, marks subscription canceled
- `handle_webhook(payload, sig_header)` — verifies signature, dispatches to handlers
- `expire_boosts()` — clears expired boosts, reverts `is_featured`; nightly timer
- `reconcile_subscriptions()` — compares local vs Stripe; fixes mismatches; nightly timer
**Webhook events handled:** `checkout.session.completed` (subscription + boost),
`customer.subscription.updated/created/deleted`, `invoice.payment_failed`
### `services/ads.py`
- `get_ad(slot, lang, state)` — targeted → untargeted → any fallback chain
- `record_impression(ad_id)` — increments `ads.impressions`
- `record_click(ad_id)` — increments `ads.clicks`
- `promoted_listings(keyword)` — accent-insensitive keyword match, priority-ordered,
live listings only; prepended to browse results
- `active_sponsors(tier, category_id)` — running sponsors filtered by tier/category
- `expire_promoted_keywords()` — removes expired rows; nightly timer
### `services/reports.py` (Phase 6)
- `create_report(listing, reporter, reason, note)` — writes Report, auto-flags listing
when distinct reporter count ≥ `flag_threshold` setting (default 5)
- `ReportError` — raised for duplicate reports or self-reports
### `services/moderation.py` (Phase 6)
- `flag_queue(page, per_page)` — listings sorted by `flag_count × recency`
- `approve(listing, actor)` — clears flags, sets active, logs to audit_log
- `hide(listing, actor)` — sets flagged status, logs
- `remove(listing, actor)` — sets removed status, logs
### `services/audit.py` (Phase 6)
- `log_action(actor, action, target_type, target_id, meta)` — appends AuditLog row
### `services/settings.py` (Phase 6)
- `get_setting(key, default)` — reads from `settings` table, cached in app context
- `set_setting(key, value)` — upserts, invalidates cache
- Keys: `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`,
`keyword_blocklist` (list), `new_user_trust_gate_days`, `contact_density_threshold`
### `services/admin_dashboard.py` (Phase 6)
- `kpis()` → dict with `active_listings`, `new_users_7d`, `new_users_30d`,
`mrr_cents`, `revenue_30d_cents`, `flag_queue_depth`
- `signups_per_day(days)`, `listings_per_day(days)`, `revenue_per_day(days)` → lists
- `top_categories(n)` — by active listing count
### `services/admin_users.py` (Phase 6)
- `search_query(q, role, status, tier)` — filtered User query
- `set_status(user, status, actor)` — ban/suspend/activate + audit log
- `set_tier(user, plan, actor)` — tier override + audit log
- `adjust_trust(user, delta, actor)` — adds trust event, recomputes tier + audit log
### `services/expiry_notifications.py` (Phase 7)
- `warn_expiring(days=3)` — emails listing owners whose listings expire within N days;
marks `_warn_sent` in `attributes` JSON to prevent duplicates
- `notify_expired()` — emails owners of newly-expired listings;
marks `_expired_sent` in `attributes` JSON; both are CLI commands via `__init__.py`
### `services/reviews.py` (Phase 7)
- `create_review(listing, author, rating, body)` — validates sold status + not-self;
catches `IntegrityError` for duplicate review
- `seller_rating(user_id)``{"avg": float|None, "count": int}`
---
## 11. i18n (Trilingual: EN / VI / ES)
**A. UI chrome** — Flask-Babel. All strings in `_()` / `lazy_gettext()`.
Locale order: `session['lang']``user.locale``Accept-Language``en`.
```bash
pybabel extract -F babel.cfg -o messages.pot .
pybabel init -i messages.pot -d app/translations -l vi
pybabel init -i messages.pot -d app/translations -l es
pybabel compile -d app/translations
```
**B. User content** — stored as-is, `listings.lang` declared.
Phase 7: auto-translate button → `listing_translations` cache.
**Accent-insensitive search** (`utils/text.py`):
```python
normalize("Phở Bò Đặc Biệt") == "pho bo dac biet"
normalize("Ñandú Jalapeño") == "nandu jalapeno"
```
`đ/Đ` handled explicitly. Shadow column `title_norm` + promoted keyword matching
both use this normalizer.
---
## 12. Geography & Location
- `zip_geo` table: offline US ZIP → lat/lng/city/state/metro.
- **Seed sample:** 10 VN/Hispanic hub ZIPs. Replace with full SimpleMaps/Census
ZCTA (~42k rows) before production.
- Radius search: bounding-box SQL prefilter → haversine exact refine.
- `metros` table wired; SEO landing pages built in Phase 7.
**MySQL spatial upgrade (optional):**
```sql
ALTER TABLE listings ADD COLUMN geo POINT
GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
ADD SPATIAL INDEX spx_listings_geo (geo);
```
---
## 13. Anti-Abuse
Listings go **active immediately** (post-and-flag model).
**Status lifecycle:** `active``flagged` / `sold` / `expired` / `removed`
Active controls (Phase 15):
- Trust tier gates contact-info reveal (email_verified AND tier ≥ trusted)
- Rate limits: register 10/hr, login 20/hr, post 30/hr, message 20/hr
- Contact density heuristic: ≥3 signals in message body → warning log
- Turnstile CAPTCHA on register + post (dev bypass when keys blank)
- Duplicate email blocked at register
Phase 6: flag threshold → auto-flip, keyword blocklist, reports queue, audit_log.
---
## 14. Security
- Argon2 password hashing
- CSRF on all forms (Flask-WTF); `@csrf.exempt` only on `/billing/webhook`
- RBAC decorators on all protected routes
- Rate limiting (Flask-Limiter + Redis)
- Image upload: MIME sniff, Pillow re-encode, 8MB cap, 1600px max, random filenames
- Cloudflare Turnstile CAPTCHA
- Contact masking for low-trust users
- Signed time-limited tokens for email verify + password reset
- ProxyFix for correct `is_secure` + client-IP behind Nginx
- Mid-session ban enforcement: `enforce_active_account` before_request hook logs
out any authenticated user whose `status != active` on their next request
(Flask-Login only checks `is_active` at login time)
- Stripe webhook signature verification on every event
- Secrets via `.env` only — never committed
- HTTPS only in prod; `SESSION_COOKIE_SECURE=True` in ProdConfig
---
## 15. Environment Variables (`.env`)
**CRITICAL:** No inline `# comments` after values — python-dotenv does NOT strip
them, causing `int()` parse errors. Comments must be on their own lines.
```
FLASK_CONFIG=prod
SECRET_KEY=<long random string>
SERVER_NAME=classifieds.ngodanguyen.tech
# Database
DB_USER=classifieds
DB_PASSWORD=<password>
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=classifieds
DATABASE_URL=
# Redis
REDIS_URL=redis://127.0.0.1:6379/0
# i18n
DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,vi,es
# Email (Brevo)
MAIL_SERVER=smtp-relay.brevo.com
MAIL_PORT=587
MAIL_USE_TLS=true
MAIL_USERNAME=<brevo login>
MAIL_PASSWORD=<brevo smtp key>
MAIL_FROM=no-reply@classifieds.ngodanguyen.tech
MAIL_FROM_NAME=Classifieds
# Media
MEDIA_ROOT=
# Turnstile
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# Token lifetimes (seconds — no inline comments!)
TOKEN_VERIFY_MAX_AGE=86400
TOKEN_RESET_MAX_AGE=3600
# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
```
### Getting Stripe keys
- **Secret + Publishable keys:** https://dashboard.stripe.com/apikeys
- **Webhook secret:** https://dashboard.stripe.com/webhooks
→ Add endpoint → URL: `https://classifieds.ngodanguyen.tech/billing/webhook`
→ Events: `checkout.session.completed`, `customer.subscription.created`,
`customer.subscription.updated`, `customer.subscription.deleted`,
`invoice.payment_failed` → Reveal signing secret → copy `whsec_...`
- **Test mode:** use `stripe listen --forward-to .../billing/webhook` (Stripe CLI)
for local dev; prints a temporary `whsec_...` to stdout
### Activating Stripe plans
After creating Products + Prices in the Stripe dashboard, map `stripe_price_id`:
```bash
flask shell
>>> from app.models.plan import Plan; from app.extensions import db
>>> Plan.query.filter_by(slug='basic').first().stripe_price_id = 'price_xxx'
>>> Plan.query.filter_by(slug='pro').first().stripe_price_id = 'price_yyy'
>>> Plan.query.filter_by(slug='business').first().stripe_price_id = 'price_zzz'
>>> db.session.commit()
```
---
## 16. Setup & Run
### Smoke test (no MySQL/Redis needed)
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python -m tests.test_smoke # all checks green (Phase 16 complete)
```
### Dev server
Use absolute SQLite path — Flask-SQLAlchemy resolves relative paths against
`instance/`, not CWD. Stale `instance/dev.db` causes ghost-schema issues.
```
DATABASE_URL=sqlite:////tmp/classifieds_dev.db
```
```bash
export FLASK_APP=wsgi:app
flask db init && flask db migrate -m "initial" && flask db upgrade
python seed.py --admin admin@example.com 'StrongPass123'
flask run
```
### MySQL 8.0 — both host variants required
```sql
CREATE DATABASE classifieds CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'classifieds'@'localhost' IDENTIFIED BY 'password';
CREATE USER 'classifieds'@'127.0.0.1' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON classifieds.* TO 'classifieds'@'localhost';
GRANT ALL PRIVILEGES ON classifieds.* TO 'classifieds'@'127.0.0.1';
FLUSH PRIVILEGES;
```
Both `@'localhost'` and `@'127.0.0.1'` required — MySQL treats them as different
accounts. If `caching_sha2_password` errors: `pip install cryptography`.
### Production deploy
```bash
# 1. Deploy code to server
# 2. python3 -m venv venv && pip install -r requirements.txt
# 3. cp .env.example .env && nano .env (no inline comments!)
# 4. flask db upgrade && python seed.py
# 4b. Compile translations (committed .mo travel with the repo, but recompile
# after any .po change): pybabel compile -d app/translations
# 5. Copy systemd units:
cp deploy/classifieds.service /etc/systemd/system/
cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/
cp deploy/classifieds-nightly.{service,timer} /etc/systemd/system/
systemctl enable --now classifieds classifieds-expire.timer classifieds-nightly.timer
# 6. Configure Nginx:
ln -s /etc/nginx/sites-available/classifieds /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
# 7. TLS:
certbot --nginx -d classifieds.ngodanguyen.tech
```
### Nginx troubleshooting
- Config must be symlinked to `sites-enabled/` (not just `sites-available/`)
- `curl -H "Host: classifieds.ngodanguyen.tech" http://127.0.0.1/ -I` — tests
Nginx config independently of DNS
- DNS A record must point to server's public IP
- `curl -4 ifconfig.me` vs `dig +short classifieds.ngodanguyen.tech` must match
- CSS cache busting: bump the `?v=N` query string on the `style.css` link in
`base.html` (single `style.css` file; the old `style.vN.css` copy approach was
dropped)
---
## 17. Coding Conventions
- App factory pattern. No global `app`. Extensions in `extensions.py`.
- Blueprints per domain. **Routes thin; all logic in `services/`.**
- SQLAlchemy models, Alembic migrations for every schema change.
Never `db.create_all()` in production.
- All datetimes UTC in DB. Localize only at render.
- Money: integer cents everywhere. Never `float` for currency.
- `db.session.get(Model, pk)` not `Model.query.get(pk)` (SA 2.0 deprecated).
- UTC now: use `utcnow()` from `app/utils/time.py` — never `datetime.utcnow()`
(deprecated on Python 3.12+). The helper returns a naive UTC datetime to match
the naive `DateTime` columns; do not mix in tz-aware datetimes.
- Enums: DB `ENUM` columns + Python `str, enum.Enum` in `models/enums.py`.
- Validate `listings.attributes` against `field_schema` server-side on every save.
- i18n: all user-facing strings in `_()`. No hardcoded English in templates.
- CSRF on every state-changing form; inline POST forms use `{{ csrf_token() }}`.
- `merge_query(**overrides)` context helper for pagination links.
- `inject_ads()` runs on every request via context processor; errors suppressed
(ads must never break page renders).
- Stripe webhook handler is CSRF-exempt; always verify signature before processing.
---
## 18. Build Roadmap & To-Do List
### ✅ Phase 1 — Foundation (Done)
- [x] App factory, Dev/Prod config, env-driven, ProxyFix
- [x] Extensions: SQLAlchemy, Migrate, LoginManager, CSRF, Babel, Limiter
- [x] `users`, `plans`, `trust_events` schema + Alembic migrations
- [x] Argon2 hashing, itsdangerous signed tokens
- [x] Auth: register, email verify (+5 trust), login, logout, password reset
- [x] Rate limiting on auth endpoints
- [x] RBAC decorators: `role_required`, `admin_required`, `moderator_required`
- [x] Trilingual i18n scaffold (EN/VI/ES), `/lang/<code>` locale switcher
- [x] Accent-insensitive normalizer (phở→pho, ñ→n)
- [x] Cloudflare Turnstile CAPTCHA hook
- [x] `seed.py`: 4 plans with JSON limit configs + `--admin` flag
- [x] systemd + Gunicorn + Nginx deploy files
- [x] Smoke test: 14 checks
### ✅ Phase 2 — Listings Core (Done)
- [x] `categories`, `listings`, `listing_images`, `zip_geo`, `metros` models
- [x] 6 categories + subcategories seeded with field schemas
- [x] 10 sample ZIP rows (Westminster, Houston, San Jose, Miami, Falls Church…)
- [x] Field schema validation (text/number/select/bool, required, coerce)
- [x] Hot-column denormalization (attr_condition, attr_job_type, attr_salary_*)
- [x] Listing CRUD: create, edit, delete, mark-sold, my-listings
- [x] Tier enforcement: active-listing cap, image cap, listing life from plan
- [x] Image pipeline: MIME check, Pillow re-encode JPEG, strip EXIF, thumbnail
- [x] Browse + filters: category, state, price, condition, keyword
- [x] Accent-insensitive keyword search via `title_norm LIKE`
- [x] Radius search: bounding-box SQL + haversine refine
- [x] `flask expire-listings` CLI + hourly systemd timer
- [x] Smoke test: +6 checks
### ✅ Phase 3 — Messaging + Favorites (Done)
- [x] `conversations`, `messages`, `favorites` models
- [x] Messaging: get-or-create, send, mark-read, inbox, total-unread
- [x] Self-message block; contact density heuristic
- [x] Contact masking (phone/email/URL hidden for low-trust users)
- [x] Trust-gated reveal (email_verified AND trust_tier ≥ trusted)
- [x] Notification email on every new message
- [x] Favorites: toggle, paginated list
- [x] Unread badge in nav via context processor
- [x] Contact seller + ♥ Save buttons on listing detail
- [x] `/api/unread` JSON endpoint
- [x] Smoke test: +15 checks
### ✅ Phase 4 — Monetization / Stripe (Done)
- [x] `subscriptions`, `transactions`, `boosts` models
- [x] `stripe==10.12.0` added to requirements
- [x] Stripe config: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`
- [x] `billing.py` service: customer, checkout, portal, boost, sync, downgrade, webhooks
- [x] Subscription checkout → Stripe Billing (Checkout Session + Tax)
- [x] Customer Portal (self-serve upgrade/cancel/update card)
- [x] À la carte boost checkout (Payment Intents, idempotent on payment_intent_id)
- [x] Webhook handler: 4 event types, signature-verified, CSRF-exempt
- [x] `activate_boost()` — writes Transaction + Boost, applies listing effects
- [x] `sync_subscription()` — upserts local DB, upgrades user role/tier
- [x] `downgrade_to_free()` — reverts role + tier on cancellation
- [x] `expire_boosts()` + `reconcile_subscriptions()` — nightly systemd timer
- [x] Pricing page, billing dashboard, boost selector UI
- [x] Boost + Upgrade buttons on listing detail + my-listings
- [x] Smoke test: +12 checks
### ✅ Phase 5 — Ads & Sponsors (Done)
- [x] `ads`, `sponsors`, `promoted_keywords` models
- [x] `ads.py` service: `get_ad` (targeted→untargeted fallback), impression/click tracking
- [x] `promoted_listings(keyword)` — accent-insensitive, live only, priority-ordered
- [x] `active_sponsors(tier, category_id)` — running sponsors
- [x] `expire_promoted_keywords()` — nightly timer
- [x] Ad slots in templates: header, sidebar (browse), inline (every 6th card), footer
- [x] Ads suppressed for subscribers (`plan.limit('ad_free')`)
- [x] Promoted listings prepended to browse results with "Promoted" badge
- [x] Click tracking redirect `/ads/<id>/click`
- [x] Sponsor directory `/sponsors`
- [x] `inject_ads()` context processor on every request
- [x] `ads/_slot.html` reusable partial
- [x] Sponsors link in footer nav
- [x] `flask expire-promoted-keywords` CLI added to nightly systemd unit
- [x] Smoke test: +14 checks (58 total)
---
### ✅ Phase 6 — Admin Backend (Done)
**Dashboard**
- [x] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR,
ad revenue, flag-queue depth)
- [x] Data tables: signups/day, listings/day, revenue trend (last 30d)
**User management**
- [x] `/admin/users` — searchable/filterable table (role, status, tier, trust)
- [x] User detail: profile, listing history, subscription, trust events
- [x] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log)
**Listing moderation**
- [x] `/admin/listings` — flag queue sorted by `flag_count × recency`
- [x] Quick actions: approve (clear flags), hide (flagged), remove, view
- [x] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
(threshold stored in `settings` table, default 5)
- [x] Keyword blocklist editor (stored in `settings`, checked on listing submit)
**Reports queue**
- [x] `/admin/reports` — flagged content with reporter reasons
- [x] Mark resolved action
- [x] `reports` + `audit_log` + `settings` table models + routes
**Category management**
- [x] `/admin/categories` — CRUD, reorder (sort_order field)
- [x] Field schema editor per category (add/remove fields, type/required/options)
**Plan / pricing management**
- [x] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID
- [x] No-redeploy: limits read at runtime from DB
**Ads & sponsors management**
- [x] `/admin/ads` — CRUD, slot/targeting/schedule, impressions/clicks/CTR stats
- [x] `/admin/sponsors` — CRUD sponsor entries, assign category
- [x] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
**Transactions & billing**
- [x] `/admin/transactions` — full log, filter by type/status/date
- [x] Refund action (Stripe Refund API + local refund transaction)
**Settings**
- [x] `/admin/settings` — toggle UI for `settings` table:
`registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`,
`keyword_blocklist`, `new_user_trust_gate_days`, `contact_density_threshold`
**Audit log**
- [x] All admin write actions → `audit_log`
- [x] `/admin/audit` — searchable trail
**Analytics**
- [x] Conversions: registrations/day, listings/day, revenue/day
- [x] Top categories by listing count; ad CTR summary
**Tests**
- [x] Smoke: admin 200, non-admin 403 for all admin routes
- [x] Smoke: flag threshold auto-flips listing status
- [x] Smoke: impersonate logs to audit_log
- [x] Smoke: settings (registration_open, ads_enabled, maintenance_mode,
contact_density_threshold, new_user_trust_gate_days) all verified
---
### 🔄 Phase 7 — Polish (In Progress)
**SEO & discoverability**
- [x] State landing pages `/classifieds/state/<state>`
- [x] Category landing pages `/classifieds/category/<slug>`
- [x] Dynamic `<title>` + `<meta description>` blocks in `base.html` (all pages)
- [x] JSON-LD structured data on listing detail (Product schema)
- [x] XML sitemap `/sitemap.xml` (static pages + active categories + 500 listings)
- [x] `robots.txt` (disallows admin/auth/my/messages/billing, includes Sitemap URL)
- [x] Open Graph tags (og:title, og:description, og:type, og:image, og:url + canonical)
- [ ] Metro landing pages `/classifieds/<metro-slug>`
**Email notifications**
- [x] Listing expiry warning — `warn_expiring(days=3)` + `flask warn-expiring-listings` CLI
- [x] Listing expired notification — `notify_expired()` + `flask notify-expired-listings` CLI
- [ ] Move message notifications to RQ worker (async)
- [ ] Weekly digest email (new listings in saved categories) — opt-in
**Reviews**
- [x] `reviews` table model (listing_id, author_id, seller_id, rating 15, body)
- [x] `POST /listings/<id>/review` — leave review after mark-sold (buyer only, once)
- [x] `services/reviews.py``create_review`, `seller_rating`
- [x] Seller aggregate rating shown on listing detail sidebar
- [x] Full review list displayed below listing
**Translation (deferred from Phase 2)**
- [ ] Per-listing "Translate" button → DeepL/Google API
- [ ] Cache in `listing_translations`
- [ ] Language filter on browse
**Performance**
- [ ] Redis sessions (`SESSION_TYPE=redis`)
- [ ] Query caching for hot browse (Redis, 60s TTL)
- [x] Lazy-load images (`loading="lazy"`, eager for first image)
- [ ] WebP thumbnails
- [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST`
- [ ] Keyset pagination for large datasets
**UX & mobile**
- [x] Responsive nav (hamburger on mobile, CSS + JS toggle)
- [ ] Listing image lightbox
- [ ] "Load more" / infinite scroll on browse
- [x] Toast notifications (auto-dismiss after 5s with JS)
- [ ] "Back to results" preserving filter state
- [ ] Listing preview before publish
**i18n completion**
- [x] Translate VI + ES `.po` files (75-string Phase 12 catalog: auth + listings chrome)
- [x] Compile `.mo` for vi + es; verified loadable via gettext (placeholders + plural-forms intact)
- [x] `.mo` files un-gitignored so they deploy (see Known Issues)
- [ ] Re-extract `.pot` to capture Phase 37 strings (messaging/payments/admin) —
needs `pybabel extract` (Babel not currently installed); then translate + recompile
- [ ] Test all three locales end-to-end in the running app
---
### 🔲 Phase 8 — JSON API (Optional, for iOS app)
- [ ] JWT auth (`flask-jwt-extended`), `/api/v1/auth/login` + `/register`
- [ ] `GET/POST /api/v1/listings` (browse + create)
- [ ] `GET/PUT/DELETE /api/v1/listings/<id>`
- [ ] `GET/POST /api/v1/messages/<conv_id>`
- [ ] `POST /api/v1/listings/<id>/favorite`
- [ ] `GET /api/v1/categories`, `/api/v1/zip/<zip>`, `/api/v1/me`
- [ ] Consistent `{"error":"...", "code":"..."}` JSON error shape
- [ ] CORS headers, stricter rate limits
- [ ] API smoke tests (separate suite)
---
### 🔲 Deferred Items
- [ ] **Full ZIP dataset** — replace 10-row sample with SimpleMaps/Census ZCTA (~42k rows)
- [ ] **MySQL spatial**`POINT` column + `SPATIAL INDEX` + `ST_Distance_Sphere`
- [ ] **MySQL FULLTEXT**`FULLTEXT(title,body)` + `MATCH ... AGAINST` (Phase 7)
- [ ] **MySQL generated columns**`GENERATED ALWAYS AS (JSON_EXTRACT(...))` for `attr_*`
- [ ] **RQ worker** — replace inline email + APScheduler with proper RQ queue
- [ ] **Redis sessions**`SESSION_TYPE=redis` (Phase 7)
- [ ] **Bulk CSV upload** — Business tier: parse, validate, batch-create listings
- [ ] **Scheduled posting** — Pro/Business: `publish_at` datetime, worker flips to active
- [ ] **Auto-bump/renew** — Pro (weekly) / Business (daily): worker extends `bump_at`/`expires_at`
- [ ] **Storefront page** — Pro/Business: `/seller/<username>` with all active listings
- [ ] **Custom URL** — Business: vanity slug (e.g. `/shop/alices-cleaning`)
- [ ] **Job-seeker posts** — reverse listings under Jobs category
---
## 19. Known Issues / Decisions Locked
| Item | Decision |
|---|---|
| DB portability | SQLite for tests (`BigInteger().with_variant(Integer,"sqlite")` + `autoincrement=True`), MySQL 8.0 for prod. MariaDB NOT supported. |
| Spatial search | lat/lng + bounding box + haversine. MySQL `POINT` + `SPATIAL INDEX` upgrade documented above. |
| FULLTEXT search | `title_norm LIKE` for now. MySQL `FULLTEXT` upgrade in Phase 7. |
| Hot filter columns | App-maintained `attr_*` indexed columns. MySQL `GENERATED ALWAYS` upgrade optional. |
| ZIP dataset | 10-row sample seeded. Replace with full dataset (~42k rows) before prod. |
| Workers | No RQ yet. Expiry + boost + keyword sweeps via systemd timers. Message notifications inline. RQ in Phase 7. |
| Session storage | Flask default. Switch to Redis sessions in Phase 7. |
| Ad impression tracking | Direct DB increment per request. Phase 7: batch to Redis, flush periodically. |
| `.env` inline comments | MUST NOT use `# comments` after values — python-dotenv does not strip them → `int()` ValueError on startup. |
| Stripe webhook | `/billing/webhook` is `@csrf.exempt`. Always verify `Stripe-Signature` header first. |
| MySQL user grants | Must create BOTH `@'localhost'` AND `@'127.0.0.1'` — MySQL treats them as different accounts. |
| CSS cache busting | Nginx serves static with 30d expires. To bust: bump the `?v=N` query string on the `style.css` link in `base.html`. |
| Translations (.mo) | `*.mo` is gitignored **except** `app/translations/**` (negated) so compiled catalogs deploy with the code — the no-build-step deploy has no compile phase. Recompile with `pybabel compile -d app/translations` after editing any `.po`. |
| Python / datetime | Target 3.113.12 for prod parity. `datetime.utcnow()` is deprecated on 3.12+ — use `utcnow()` from `app/utils/time.py`. |
---
_End of spec. Phase 16 complete. Phase 7 (Polish) in progress — SEO, UX, email
notifications, reviews, and VI/ES translation compilation done (existing catalog).
Remaining: metro landing pages, i18n re-extraction for Phase 37 strings, Redis
sessions, WebP thumbnails, listing lightbox, load-more, "back to results",
translation cache, weekly digest.
Note: run the smoke suite in a venv matching `requirements.txt` (Flask 3.0.3 /
Python 3.113.12); the ambient Python 3.14 env lacks `flask_login` and drifts from
the pins._