# 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 (1–5 star seller reviews, unique per listing+author) │ │ │ ├── blueprints/ │ │ ├── auth/ # register, login, logout, verify-email, reset │ │ ├── main/ # index, /healthz, /robots.txt, /sitemap.xml, │ │ │ # /classifieds/category/, /classifieds/state/ │ │ ├── i18n/ # /lang/ 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 1–7 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 1–6 checks green ``` --- ## 5. Route Map (70+ routes, Phase 1–7) ### 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/` | auth | — | | GET/POST | `/auth/reset` | auth | — | | GET/POST | `/auth/reset/` | auth | — | | GET | `/lang/` | i18n | — | ### SEO Landing Pages (Phase 7) | Method | Path | Blueprint | Auth | |---|---|---|---| | GET | `/classifieds/category/` | main | — | | GET | `/classifieds/state/` | main | — | ### Listings (Phase 2–7) | Method | Path | Blueprint | Auth | |---|---|---|---| | GET | `/listings` | listings | — | | GET | `/listings/new` | listings | login | | GET | `/listings/` | listings | — | | GET/POST | `/listings//edit` | listings | login+owner | | POST | `/listings//delete` | listings | login+owner | | POST | `/listings//sold` | listings | login+owner | | POST | `/listings//images//delete` | listings | login+owner | | POST | `/listings//report` | listings | login | | GET/POST | `/listings//review` | listings | login | | GET | `/media/` | listings | — | | GET | `/my/listings` | listings | login | ### Messaging & Social (Phase 3) | Method | Path | Blueprint | Auth | |---|---|---|---| | GET/POST | `/listings//contact` | messaging | login | | POST | `/listings//favorite` | messaging | login | | GET | `/my/favorites` | messaging | login | | GET | `/messages` | messaging | login | | GET/POST | `/messages/` | messaging | login+participant | | GET | `/api/unread` | messaging | login | ### Payments (Phase 4) | Method | Path | Blueprint | Auth | |---|---|---|---| | GET/POST | `/listings//boost` | payments | login+owner | | GET | `/listings//boost/success` | payments | login | | GET | `/my/billing` | payments | login | | GET | `/pricing` | payments | — | | GET | `/billing/subscribe/` | 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//click` | ads | — | | GET | `/sponsors` | ads | — | ### Admin (Phase 6) | Method | Path | Blueprint | Auth | |---|---|---|---| | GET | `/admin` | admin | admin | | GET | `/admin/users` | admin | admin | | GET | `/admin/users/` | admin | admin | | POST | `/admin/users//status` | admin | admin | | POST | `/admin/users//tier` | admin | admin | | POST | `/admin/users//trust` | admin | admin | | POST | `/admin/users//impersonate` | admin | admin | | GET | `/admin/listings` | admin | admin/mod | | POST | `/admin/listings//approve` | admin | admin/mod | | POST | `/admin/listings//hide` | admin | admin/mod | | POST | `/admin/listings//remove` | admin | admin/mod | | GET | `/admin/reports` | admin | admin/mod | | POST | `/admin/reports//resolve` | admin | admin/mod | | GET | `/admin/categories` | admin | admin | | GET/POST | `/admin/categories/new` | admin | admin | | GET/POST | `/admin/categories//edit` | admin | admin | | GET/POST | `/admin/categories//schema` | admin | admin | | GET | `/admin/plans` | admin | admin | | GET/POST | `/admin/plans//edit` | admin | admin | | GET | `/admin/ads` | admin | admin | | GET/POST | `/admin/ads/new` | admin | admin | | GET/POST | `/admin/ads//edit` | admin | admin | | POST | `/admin/ads//delete` | admin | admin | | POST | `/admin/ads//toggle` | admin | admin | | GET | `/admin/sponsors` | admin | admin | | GET/POST | `/admin/sponsors/new` | admin | admin | | GET/POST | `/admin/sponsors//edit` | admin | admin | | POST | `/admin/sponsors//delete` | admin | admin | | GET | `/admin/promoted-keywords` | admin | admin | | GET/POST | `/admin/promoted-keywords/new` | admin | admin | | POST | `/admin/promoted-keywords//delete` | admin | admin | | GET | `/admin/transactions` | admin | admin | | POST | `/admin/transactions//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 (1–5, 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 1–5): - 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= SERVER_NAME=classifieds.ngodanguyen.tech # Database DB_USER=classifieds DB_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= MAIL_PASSWORD= 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 1–6 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/` 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//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/` - [x] Category landing pages `/classifieds/category/` - [x] Dynamic `` + `<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 1–5, 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 1–2 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 3–7 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.11–3.12 for prod parity. `datetime.utcnow()` is deprecated on 3.12+ — use `utcnow()` from `app/utils/time.py`. | --- _End of spec. Phase 1–6 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 3–7 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.11–3.12); the ambient Python 3.14 env lacks `flask_login` and drifts from the pins._