From 897788a9d8b12010a1077079ed7f12d5823dc838 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 15 Jun 2026 18:06:47 -0400 Subject: [PATCH] 06/15 Update document --- CLAUDE.md | 996 ++++++++++++++++++++++++++---------------------------- 1 file changed, 478 insertions(+), 518 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b2d89c..514754c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ 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. @@ -69,6 +71,7 @@ requests==2.32.3 PyMySQL==1.1.1 gunicorn==23.0.0 Pillow==10.4.0 +stripe==10.12.0 ``` --- @@ -77,10 +80,10 @@ Pillow==10.4.0 ``` Internet - │ 443 (TLS via Certbot) + │ 443 (TLS via Certbot / Let's Encrypt) Nginx ├─ /static/ → app/static/ (30d cache) - ├─ /media/ → instance/media/ (7d cache, Phase 2+) + ├─ /media/ → instance/media/ (7d cache) └─ / → Gunicorn unix socket → Flask app factory │ ┌─────────────────────────┼──────────────┐ @@ -92,12 +95,14 @@ Nginx (Brevo) ``` -**ProxyFix** is applied in the app factory (`x_for=1, x_proto=1, x_host=1`) so -`request.is_secure`, secure cookies, and rate-limit IP are all correct behind Nginx. +**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 expiry sweep +- `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry +- `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost expiry + + promoted keyword cleanup + subscription reconcile --- @@ -105,89 +110,100 @@ Nginx ``` classifieds/ -├── wsgi.py # WSGI entry point for Gunicorn + flask CLI -├── seed.py # Seed plans, categories, zip_geo; --admin flag -├── babel.cfg # pybabel extraction config +├── 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 documented (no inline comments) +├── .env.example # All env vars (no inline # comments) ├── .gitignore -├── CLAUDE.md # This file -├── README.md # Setup, run, deploy instructions +├── 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 +│ ├── __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 -│ │ ├── 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 +│ │ ├── __init__.py # Exports all models (import order matters) +│ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType, +│ │ │ # ListingStatus, Lang +│ │ ├── 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 │ │ │ ├── blueprints/ -│ │ ├── auth/ # register, login, logout, verify-email, reset -│ │ ├── main/ # index, /healthz -│ │ ├── i18n/ # /lang/ locale switcher -│ │ ├── listings/ # browse, detail, create, edit, delete, images -│ │ └── messaging/ # inbox, conversation, start, favorites, /api/unread +│ │ ├── auth/ # register, login, logout, verify-email, reset +│ │ ├── main/ # index, /healthz +│ │ ├── i18n/ # /lang/ locale switcher +│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images +│ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread +│ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing +│ │ └── ads/ # click tracking, sponsor directory, inject_ads() │ │ │ ├── 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 -│ │ ├── messaging.py # conversations, send, mark-read, inbox, unread -│ │ ├── favorites.py # toggle, is_favorited, user_favorites -│ │ └── contact.py # mask_body, contact_revealed, contact_density +│ │ ├── 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 +│ │ ├── 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 │ │ │ ├── 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) +│ │ ├── __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) │ │ │ ├── templates/ -│ │ ├── base.html # Layout: nav (Browse/Post/My listings/Saved/ -│ │ │ # Messages+badge/Sign out), lang switcher, flashes -│ │ ├── index.html # Landing page -│ │ ├── auth/ # login, register, reset_request, reset, _macros -│ │ ├── listings/ # browse, detail, form, mine -│ │ ├── messaging/ # inbox, conversation, start, favorites -│ │ └── errors/ # 403, 404, 500 +│ │ ├── base.html # Layout: nav + header/footer ad slots + lang switcher +│ │ ├── index.html # Landing page +│ │ ├── auth/ # login, register, reset_request, reset, _macros +│ │ ├── listings/ # browse (sidebar+inline ads), detail, form, mine +│ │ ├── messaging/ # inbox, conversation, start, favorites +│ │ ├── payments/ # pricing, billing, boost, success +│ │ ├── ads/ # _slot.html (reusable ad slot partial) +│ │ ├── sponsors/ # directory.html +│ │ └── errors/ # 403, 404, 500 │ │ │ ├── static/ -│ │ └── style.css # Single CSS file (Phase 1+2+3 accumulated) +│ │ └── style.css # Single CSS file (Phase 1–5 accumulated) │ │ -│ └── translations/ # Flask-Babel .po/.mo for vi + es +│ └── translations/ # Flask-Babel .po/.mo for vi + es │ -├── migrations/ # Alembic migration scripts (flask db migrate/upgrade) +├── migrations/ # Alembic migration scripts │ ├── deploy/ -│ ├── classifieds.service # systemd: Gunicorn web -│ ├── classifieds-expire.service # systemd: oneshot expiry sweep -│ ├── classifieds-expire.timer # systemd: hourly timer -│ ├── gunicorn.conf.py # workers, socket, timeouts -│ └── nginx.conf.sample # HTTPS redirect + proxy + static + media blocks +│ ├── 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 + reconcile +│ ├── classifieds-nightly.timer # systemd: 2am daily +│ ├── gunicorn.conf.py # workers, socket, timeouts +│ └── nginx.conf.sample # HTTPS redirect + proxy + static + media │ └── tests/ - └── test_smoke.py # 34-check integration test (SQLite + in-memory Redis) + └── test_smoke.py # 58-check integration test (SQLite + in-memory Redis) ``` --- -## 5. Route Map (24 routes, Phase 1–3) +## 5. Route Map (34 routes, Phase 1–5) | Method | Path | Blueprint | Auth | |---|---|---|---| @@ -209,12 +225,22 @@ classifieds/ | POST | `/listings//images//delete` | listings | login+owner | | GET/POST | `/listings//contact` | messaging | login | | POST | `/listings//favorite` | messaging | login | +| GET/POST | `/listings//boost` | payments | login+owner | +| GET | `/listings//boost/success` | payments | login | | GET | `/media/` | listings | — | | GET | `/my/listings` | listings | login | | GET | `/my/favorites` | messaging | login | +| GET | `/my/billing` | payments | login | | GET | `/messages` | messaging | login | | GET/POST | `/messages/` | messaging | login+participant | | GET | `/api/unread` | messaging | 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 | +| GET | `/ads//click` | ads | — | +| GET | `/sponsors` | ads | — | --- @@ -228,18 +254,15 @@ classifieds/ | **admin** | Full control | RBAC enforced via decorators in `app/utils/__init__.py`: -- `@role_required('admin')`, `@admin_required`, `@moderator_required` -- Flask-Login `@login_required` for authenticated-only routes -- Ownership checks inline in route handlers (owner or moderator) +`@role_required('admin')`, `@admin_required`, `@moderator_required` --- ## 7. Subscription Tiers -Limits stored in `plans.config` JSON (admin-editable without redeploy). -Enforcement reads from plan at runtime via `plan.limit(key, default)`. +Limits stored in `plans.config` JSON — editable in admin without redeploy. -| Feature | Free | Basic | Pro | Business | +| Feature | Free | Basic ($9.99) | Pro ($24.99) | Business ($59.99) | |---|---|---|---|---| | Active listings | 3 | 15 | 50 | Unlimited | | Listing life (days) | 14 | 30 | 60 | 90 | @@ -260,11 +283,12 @@ 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, bump, highlight, urgent — 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 results (Phase 5+) +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 --- @@ -272,109 +296,119 @@ Plan slugs: `free`, `basic`, `pro`, `business`. Seeded by `seed.py`. **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 for currency. +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 +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 +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 +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, - FK wired Phase 5), is_active, created_at, updated_at +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) +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 +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] - (seed ~42k rows from SimpleMaps/Census ZCTA for prod) +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 page anchor — Phase 7) +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) +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 +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) +favorites id, user_id→users, listing_id→listings, created_at + UNIQUE(user_id, listing_id) ``` -### Monetization & Ops (Phase 4–6) +### Ads & Sponsors ``` -subscriptions id, user_id→users, plan_id→plans, stripe_customer_id, - stripe_sub_id, status ENUM(active,past_due,canceled,trialing), - current_period_end, cancel_at_period_end BOOL, - created_at, updated_at +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 -transactions id, user_id, type ENUM(subscription,boost,refund), - amount_cents, currency, stripe_object_id, status, - meta JSON, created_at +sponsors id, name, logo_path, url, tagline, + tier ENUM(directory,category), + category_id→categories nullable, + starts_at, ends_at, is_active, created_at -ads id, advertiser_name, slot ENUM(header,sidebar,inline,footer), - creative_path, target_url, lang nullable, geo_state nullable, - starts_at, ends_at, impressions, clicks, is_active, created_at +promoted_keywords id, keyword(80) [indexed], listing_id→listings, + priority INT, expires_at [indexed], created_at + UNIQUE(keyword, listing_id) +``` -sponsors id, name, logo_path, url, tier ENUM(directory,category), - category_id→categories nullable, starts_at, ends_at, is_active +### 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) -boosts id, listing_id→listings, user_id→users, - type ENUM(featured,bump,highlight,urgent), - expires_at, transaction_id→transactions, created_at +audit_log id, actor_id→users, action, target_type, target_id, + meta JSON, created_at -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 - (site-wide toggles: registration_open, ads_enabled, - maintenance_mode, flag_threshold, etc.) +settings key PK, value JSON + (registration_open, ads_enabled, maintenance_mode, + flag_threshold, new_user_trust_gate_days, etc.) listing_translations id, listing_id→listings, lang, title, body, cached_at - (Phase 2 deferred: auto-translate cache) + (Phase 7: auto-translate cache) ``` --- @@ -382,100 +416,111 @@ listing_translations id, listing_id→listings, lang, title, body, cached_at ## 10. Key Services (implemented) ### `services/listings.py` -- `create_listing(user, category, ...)` — tier-enforced active-listing cap, ZIP geocode, - accent-norm `title_norm`, hot-column denormalization, `expires_at` from plan life +- `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(...)` — SQLAlchemy query with category/state/price/condition/job_type filters - and accent-insensitive keyword on `title_norm LIKE` -- `search_with_radius(base_query, lat, lng, radius_mi)` — bounding-box SQL prefilter - + exact haversine refine, returns `[(listing, distance_mi), ...]` -- `expire_due_listings()` — flips active listings past `expires_at` to expired; called by - `flask expire-listings` CLI + hourly systemd timer -- `can_create(user)`, `active_count(user)`, `image_cap(user)` — tier enforcement helpers +- `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)` — offline lookup from `zip_geo` table → `(lat, lng, city, state, metro)` -- `haversine_mi(lat1, lng1, lat2, lng2)` — exact distance +- `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)` — validates against - category `field_schema`, coerces types (text/number/select/bool), enforces required -- `hot_values(cleaned)` → dict of `attr_*` column values for denormalization +- `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)` — validates MIME, re-encodes - to JPEG (strips all EXIF), resizes to 1600px max, generates 400px thumbnail, - randomized filename → returns unsaved `ListingImage` +- `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 seller self-message -- `send_message(conv, sender, body)` — validates participant, heuristic contact-density - check (≥3 signals → warning logged), saves `Message`, updates `last_message_at`, - sends notification email to recipient -- `mark_conversation_read(conv, reader)` — marks all other-party messages read -- `inbox(user, page, per_page)` — paginated conversations (buyer or seller), newest first -- `total_unread(user)` — sum of unread across all conversations; injected into every - template via context processor for nav badge +- `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)` — replaces phones/emails/URLs with `[hidden]` when `reveal=False` -- `contact_revealed(user)` — True only when `email_verified AND trust_tier >= trusted` -- `contact_density(text)` — count of contact signals (used for auto-flag heuristic) +- `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 `TrustEvent`, updates `trust_score`, - recomputes `trust_tier` (new=0, basic=5, trusted=20, verified=50) +- `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 (now favorited) +- `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 + --- ## 11. i18n (Trilingual: EN / VI / ES) -Two separate layers — never conflate: - -**A. UI chrome** — Flask-Babel. All user-facing strings wrapped in `_()` / -`lazy_gettext()`. Locale resolution: `session['lang']` → `user.locale` → -`Accept-Language` header → default `en`. +**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 -# translate .po files ... pybabel compile -d app/translations ``` **B. User content** — stored as-is, `listings.lang` declared. -Phase 2 deferred: auto-translate button → DeepL/Google → `listing_translations` cache. +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" ``` -Vietnamese `đ/Đ` handled explicitly (NFKD decomposition misses it). Shadow column -`title_norm` stores the result; search queries normalize the input the same way. +`đ/Đ` 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 rows (Westminster CA, Garden Grove CA, San Jose CA, Houston TX, - San Antonio TX, Miami FL, Hawthorne CA, Falls Church VA). Replace with full - SimpleMaps/Census ZCTA (~42k rows) before production. -- On listing save: ZIP → lat/lng stored on listing. -- Browse: state filter (exact), keyword, price, hot-field filters. +- **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. -- UI: near-me (browser geolocation) + manual ZIP + radius selector. - `metros` table wired; SEO landing pages built in Phase 7. -**MySQL spatial upgrade** (optional, post-Phase-2): +**MySQL spatial upgrade (optional):** ```sql ALTER TABLE listings ADD COLUMN geo POINT GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED, @@ -484,56 +529,49 @@ ALTER TABLE listings ADD COLUMN geo POINT --- -## 13. Anti-Abuse (post-and-flag model) +## 13. Anti-Abuse -Listings go **active immediately** — no pre-approval gate by default. +Listings go **active immediately** (post-and-flag model). **Status lifecycle:** `active` → `flagged` / `sold` / `expired` / `removed` -Controls in place (Phase 1–3): -- **Trust tier** — gates contact-info reveal, links in messages visible only to trusted+ -- **Rate limits** — Flask-Limiter + Redis: register (10/hr), login (20/hr), post (30/hr), - message start (20/hr) -- **Contact density heuristic** — ≥3 phone/email/URL signals in a message body → logged warning -- **Turnstile CAPTCHA** on register + post (bypassed in dev when keys blank) -- **Duplicate email** blocked at register +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 additions (admin queue):** flag threshold N → auto-flip to `flagged`, -keyword blocklist, `reports` table workflow, `audit_log` for all mod actions. +Phase 6: flag threshold → auto-flip, keyword blocklist, reports queue, audit_log. --- ## 14. Security -- Argon2 password hashing (`argon2-cffi`) -- CSRF on all forms (Flask-WTF) -- RBAC decorators on every protected route -- Rate limiting (Flask-Limiter + Redis) on auth + post endpoints -- Image upload: MIME sniff, Pillow re-encode (strips EXIF/metadata), max 8MB, - max 1600px, randomized filenames -- Cloudflare Turnstile CAPTCHA on register + post -- Contact masking: phone/email/URL hidden for low-trust users -- Signed time-limited tokens for email verify + password reset (itsdangerous) +- 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 +- Stripe webhook signature verification on every event - Secrets via `.env` only — never committed -- HTTPS-only in prod; secure + httponly + samesite=Lax cookies -- `SESSION_COOKIE_SECURE=True` in ProdConfig - -**Stripe (Phase 4):** -- Webhook signature verification on every event -- Stripe = source of truth; local DB mirrors via webhooks + nightly reconcile +- HTTPS only in prod; `SESSION_COOKIE_SECURE=True` in ProdConfig --- ## 15. Environment Variables (`.env`) -All comments must be on their own lines — no inline `# comments` after values -(python-dotenv does NOT strip them, causing `int()` parse errors). +**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.example.com +SERVER_NAME=classifieds.ngodanguyen.tech # Database DB_USER=classifieds @@ -542,7 +580,6 @@ DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=classifieds DATABASE_URL= -# leave DATABASE_URL blank; URI is built from DB_* parts # Redis REDIS_URL=redis://127.0.0.1:6379/0 @@ -551,26 +588,51 @@ REDIS_URL=redis://127.0.0.1:6379/0 DEFAULT_LOCALE=en SUPPORTED_LOCALES=en,vi,es -# Email (Brevo SMTP relay) +# Email (Brevo) MAIL_SERVER=smtp-relay.brevo.com MAIL_PORT=587 MAIL_USE_TLS=true MAIL_USERNAME= MAIL_PASSWORD= -MAIL_FROM=no-reply@example.com +MAIL_FROM=no-reply@classifieds.ngodanguyen.tech MAIL_FROM_NAME=Classifieds # Media MEDIA_ROOT= -# blank = instance/media (Flask instance folder) -# Turnstile (leave blank in dev to bypass) +# Turnstile TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= -# Token lifetimes +# 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() ``` --- @@ -581,25 +643,23 @@ TOKEN_RESET_MAX_AGE=3600 ```bash python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt -python -m tests.test_smoke # 34 checks, all green +python -m tests.test_smoke # 58 checks, all green ``` -### Dev server (absolute SQLite path — important) -Flask-SQLAlchemy resolves `sqlite:///dev.db` against `instance/`, not CWD. -Use an absolute path to avoid ghost-schema issues: +### 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 schema" -flask db upgrade +flask db init && flask db migrate -m "initial" && flask db upgrade python seed.py --admin admin@example.com 'StrongPass123' flask run ``` -### MySQL 8.0 setup +### 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'; @@ -608,277 +668,200 @@ 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'` are required — MySQL treats them -as different accounts. PyMySQL connects TCP (127.0.0.1) but MySQL may -resolve to localhost. - -If you get `caching_sha2_password` auth errors: -```bash -pip install cryptography -``` -Or create user with `IDENTIFIED WITH mysql_native_password BY '...'`. +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 /opt/classifieds (or /home/classifieds/classifieds) +# 1. Deploy code to server # 2. python3 -m venv venv && pip install -r requirements.txt -# 3. cp .env.example .env && edit .env (no inline comments!) +# 3. cp .env.example .env && nano .env (no inline comments!) # 4. flask db upgrade && python seed.py -# 5. cp deploy/classifieds.service /etc/systemd/system/ -# 6. cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/ -# 7. systemctl enable --now classifieds classifieds-expire.timer -# 8. adapt deploy/nginx.conf.sample → /etc/nginx/sites-available/classifieds -# 9. ln -s /etc/nginx/sites-available/classifieds /etc/nginx/sites-enabled/ -# 10. nginx -t && systemctl reload nginx -# 11. certbot --nginx -d classifieds.example.com +# 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 common issues:** -- Config in `sites-available` but NOT symlinked to `sites-enabled` → requests - fall through to default_server → wrong page served -- `curl -H "Host: classifieds.example.com" http://127.0.0.1/ -I` tests Nginx - config independently of DNS -- DNS A record must point to the server's public IP before Let's Encrypt works +### 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: copy to `style.v2.css` and update `base.html` link --- ## 17. Coding Conventions - App factory pattern. No global `app`. Extensions in `extensions.py`. -- Blueprints per domain. **Routes thin; all business logic in `services/`.** +- Blueprints per domain. **Routes thin; all logic in `services/`.** - SQLAlchemy models, Alembic migrations for every schema change. - Never hand-edit prod schema. Never use `db.create_all()` in production. -- All datetimes stored UTC. Localize only at render. + Never `db.create_all()` in production. +- All datetimes UTC in DB. Localize only at render. - Money: integer cents everywhere. Never `float` for currency. -- Enums: DB `ENUM` columns + Python `str, enum.Enum` classes in `models/enums.py`. -- `db.session.get(Model, pk)` — not the legacy `Model.query.get(pk)` (SA 2.0 deprecated). -- Validate `listings.attributes` against category `field_schema` server-side on every - create/update. Never trust client-submitted attribute values. -- i18n: wrap ALL user-facing UI strings in `_()`. No hardcoded English in templates. -- Env-driven config. No secrets in code or VCS. -- CSRF token on every state-changing form (Flask-WTF handles automatically). - For inline POST forms without a WTForms object, use `{{ csrf_token() }}`. -- `merge_query(**overrides)` context helper for pagination links (not a custom Jinja filter). +- `db.session.get(Model, pk)` not `Model.query.get(pk)` (SA 2.0 deprecated). +- 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 settings +- [x] App factory, Dev/Prod config, env-driven, ProxyFix - [x] Extensions: SQLAlchemy, Migrate, LoginManager, CSRF, Babel, Limiter -- [x] ProxyFix for Nginx X-Forwarded headers - [x] `users`, `plans`, `trust_events` schema + Alembic migrations -- [x] Argon2 password hashing, itsdangerous signed tokens -- [x] Auth flows: register, email verify (+5 trust), login, logout, password reset -- [x] Rate limiting on auth endpoints (Flask-Limiter + Redis) +- [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), locale switcher `/lang/` -- [x] Accent-insensitive normalizer `utils/text.py` (phở→pho, ñ→n) -- [x] Cloudflare Turnstile CAPTCHA hook (dev bypass when keys blank) +- [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` model (self-referential subcategories, `field_schema` JSON) -- [x] `listings` + `listing_images` models (title_norm, lat/lng, hot columns, status, expiry) -- [x] `zip_geo` + `metros` models +- [x] `categories`, `listings`, `listing_images`, `zip_geo`, `metros` models - [x] 6 categories + subcategories seeded with field schemas -- [x] 10 sample ZIP rows seeded (Westminster, Houston, San Jose, Miami, Falls Church…) -- [x] Field schema validation service (text/number/select/bool, required, coerce) +- [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 routes: create, edit, delete, mark-sold -- [x] Tier enforcement: active-listing cap, image cap, listing life from plan config -- [x] Image pipeline: MIME validate, Pillow re-encode to JPEG, strip EXIF, thumbnail, random filenames -- [x] Browse + filter: category, state, price range, condition, keyword +- [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 prefilter + exact haversine refine -- [x] View counter on listing detail -- [x] My-listings page with active/cap display -- [x] `flask expire-listings` CLI + systemd timer (hourly sweep) -- [x] Dev media serving route `/media/` -- [x] Smoke test: +6 checks (geocode, tier limit, attr validation, search, radius, image, expiry) - ---- +- [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` model (unique per listing+buyer, seller auto-set) -- [x] `messages` model (append-only, read_at tracking) -- [x] `favorites` model (unique user+listing) -- [x] Messaging service: get-or-create conversation, send, mark-read, inbox, total-unread -- [x] Self-message block (seller cannot contact own listing) -- [x] Contact density heuristic (≥3 phone/email/URL signals → warning log) -- [x] Contact masking service: mask phone/email/URL for low-trust users -- [x] Trust-gated contact reveal (email_verified AND trust_tier ≥ trusted) -- [x] Notification email to recipient on every new message -- [x] Favorites service: toggle, is_favorited, paginated user favorites -- [x] Inbox route with unread badge per conversation -- [x] Conversation thread route (GET masked, POST send reply) -- [x] Start-conversation from listing detail -- [x] Toggle-favorite endpoint (AJAX-capable + form fallback) -- [x] My-favorites page (saved listings grid) +- [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] Unread count injected into every page via context processor (nav badge) -- [x] Nav: Messages + Saved links added -- [x] Listing detail: Contact seller + ♥ Save listing buttons for non-owners -- [x] Smoke test: +15 checks (conversation, messages, unread, masking, favorites, routes) +- [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 4 — Monetization / Stripe (Next) - -**Models** -- [ ] `subscriptions` table (stripe_customer_id, stripe_sub_id, status, period_end, cancel_at_period_end) -- [ ] `transactions` table (type: subscription/boost/refund, amount_cents, stripe_object_id) -- [ ] `boosts` table (listing_id, type: featured/bump/highlight/urgent, expires_at) -- [ ] Alembic migration for all three tables - -**Stripe setup** -- [ ] `stripe` pip package added to requirements.txt -- [ ] Stripe keys in `.env` (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`) -- [ ] Create Products + Prices in Stripe dashboard for Basic/Pro/Business plans -- [ ] Map `stripe_price_id` in `plans` table (via seed or admin) -- [ ] Create boost Products in Stripe (featured, bump, highlight, urgent) + prices - -**Subscriptions** -- [ ] `payments` blueprint: `/billing/subscribe/` → Stripe Checkout Session -- [ ] Redirect to Stripe-hosted checkout, success/cancel return URLs -- [ ] Stripe Customer Portal route `/billing/portal` (self-serve upgrade/cancel/update card) -- [ ] Webhook endpoint `/billing/webhook` — verify signature on every event -- [ ] Handle `checkout.session.completed` → create/update `subscriptions` row, set `users.role=subscriber`, assign `tier_id` -- [ ] Handle `customer.subscription.updated` → sync status, period_end, plan change -- [ ] Handle `customer.subscription.deleted` → downgrade to free plan, clear tier -- [ ] Handle `invoice.payment_failed` → flip subscription status to `past_due`, email user -- [ ] Nightly reconcile job: compare local subscription status vs Stripe API (catch missed webhooks) -- [ ] Stripe Tax enabled on checkout (auto US sales tax) - -**À la carte boosts (free + paid users)** -- [ ] Boost purchase route `/listings//boost` → Stripe Payment Intent (one-off) -- [ ] Boost type selector UI (featured / bump / highlight / urgent + price display) -- [ ] On payment success: write `boosts` row with `expires_at`, flip `listing.is_featured` if featured boost -- [ ] Boost expiry sweep added to `expire-listings` worker (clear expired boosts) -- [ ] Browse query: order featured (boosted) listings first - -**Tier enforcement upgrades** -- [ ] Enforce `featured_per_month` limit from plan config (count active featured boosts) -- [ ] Enforce `scheduled_posting` — gate date-picker on plan check -- [ ] Enforce `ad_free` — suppress ads for subscriber role -- [ ] Upgrade prompt shown when free user hits any limit - -**UI** -- [ ] Pricing page `/pricing` showing plan comparison table -- [ ] Account billing page `/my/billing` (current plan, next renewal, manage button → portal) -- [ ] Boost buttons on listing detail (owner-only) and my-listings page -- [ ] Payment success / cancel flash pages - -**Tests** -- [ ] Smoke test: Stripe webhook handler with test payload (mock signature) -- [ ] Smoke test: boost creation writes correct `boosts` row -- [ ] Smoke test: subscription downgrade clears tier - ---- - -### 🔲 Phase 5 — Ads & Sponsors - -**Models** -- [ ] `ads` table (slot, creative_path, target_url, lang, geo_state, schedule, impressions, clicks) -- [ ] `sponsors` table (tier: directory/category, category_id, schedule) -- [ ] Alembic migration - -**Ad server** -- [ ] Ad service: `get_ad_for_slot(slot, lang, state)` — picks active ad matching targeting, returns one -- [ ] Impression tracking: increment `ads.impressions` on serve (async-safe, batched to Redis then flush) -- [ ] Click tracking: redirect endpoint `/ads//click` → increment `ads.clicks` → redirect to `target_url` -- [ ] Ad slots in templates: header banner, sidebar (browse page), inline (every 6th listing card), footer -- [ ] Ads suppressed for `role=subscriber` (check `plan.limit('ad_free')`) -- [ ] Ad creative upload (admin): validate image, Pillow re-encode, store in `/media/ads/` - -**Sponsors** -- [ ] Sponsor directory page `/sponsors` -- [ ] Category sponsorship: "Jobs powered by X" banner on category browse -- [ ] Sponsor logo in nav or footer (tier: directory) -- [ ] Sponsor admin CRUD - -**Promoted search** -- [ ] `promoted_keywords` table (keyword, listing_id, expires_at, priority) -- [ ] Browse query: prepend promoted listings matching keyword before organic results -- [ ] Admin UI to assign promoted keyword slots - -**Tests** -- [ ] Smoke: ad served for correct slot/lang/state targeting -- [ ] Smoke: click redirect increments counter -- [ ] Smoke: subscriber sees no ads - ---- - -### 🔲 Phase 6 — Admin Backend +### 🔲 Phase 6 — Admin Backend (Next) **Dashboard** -- [ ] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR, ad revenue, flag-queue depth) -- [ ] Mini charts: signups/day, listings/day (last 30d), revenue trend +- [ ] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR, + ad revenue, flag-queue depth) +- [ ] Mini charts: signups/day, listings/day, revenue trend (last 30d) **User management** - [ ] `/admin/users` — searchable/filterable table (role, status, tier, trust) -- [ ] User detail page: profile, listing history, subscription, trust events -- [ ] Actions: ban/suspend/activate, tier override, trust score adjust, impersonate (logs to audit_log) +- [ ] User detail: profile, listing history, subscription, trust events +- [ ] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log) - [ ] Bulk actions: ban selected, send email to selected **Listing moderation** - [ ] `/admin/listings` — flag queue sorted by `flag_count × recency` -- [ ] Quick actions per listing: approve (clear flags), hide (flagged), remove, view +- [ ] Quick actions: approve (clear flags), hide (flagged), remove, view - [ ] Bulk approve / bulk remove -- [ ] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged` (setting in `settings` table) +- [ ] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged` + (threshold stored in `settings` table, default 5) - [ ] Keyword blocklist editor (stored in `settings`, checked on listing submit) -- [ ] Duplicate body detection (hash `body` on submit, reject/flag if seen within 24h) +- [ ] Duplicate body detection (hash `body` on submit, flag if seen within 24h) **Reports queue** - [ ] `/admin/reports` — flagged content with reporter reasons - [ ] Mark resolved / escalate actions -- [ ] `reports` table: unique per reporter+listing, reason enum +- [ ] `reports` table migration **Category management** -- [ ] `/admin/categories` — CRUD, reorder (drag or sort_order field) -- [ ] Field schema editor per category (JSON form builder: add/remove fields, set type/required/options) -- [ ] Preview of field schema as it would appear on listing form +- [ ] `/admin/categories` — CRUD, reorder (sort_order field) +- [ ] Field schema editor per category (add/remove fields, type/required/options) **Plan / pricing management** -- [ ] `/admin/plans` — edit plan config JSON (limits), name, price, Stripe price ID, toggle active +- [ ] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID - [ ] No-redeploy: limits read at runtime from DB **Ads & sponsors management** -- [ ] `/admin/ads` — upload creative, set slot/targeting/schedule, view impression/click stats +- [ ] `/admin/ads` — upload creative, set slot/targeting/schedule, stats - [ ] `/admin/sponsors` — CRUD sponsor entries, assign category -- [ ] Ad performance report (impressions, clicks, CTR per ad) +- [ ] Ad performance report (impressions, clicks, CTR) +- [ ] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry **Transactions & billing** -- [ ] `/admin/transactions` — full log with filter by type/status/date -- [ ] Refund action (calls Stripe Refund API, writes refund transaction row) -- [ ] Failed payments list with retry action +- [ ] `/admin/transactions` — full log, filter by type/status/date +- [ ] Refund action (Stripe Refund API + local refund transaction) +- [ ] Failed payments list **Settings** -- [ ] `/admin/settings` — toggle UI for all `settings` table keys: - - `registration_open` (bool) - - `ads_enabled` (bool) - - `maintenance_mode` (bool) - - `flag_threshold` (int, default 5) - - `new_user_trust_gate_days` (int) - - `contact_density_threshold` (int) +- [ ] `/admin/settings` — toggle UI for `settings` table: + `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`, + `new_user_trust_gate_days`, `contact_density_threshold` **Audit log** -- [ ] All admin write actions write to `audit_log` (actor, action, target, meta JSON) -- [ ] `/admin/audit` — searchable audit trail +- [ ] All admin write actions → `audit_log` +- [ ] `/admin/audit` — searchable trail **Analytics** -- [ ] Traffic: page views/day, top pages, search terms used +- [ ] Traffic: page views/day, top pages, search terms - [ ] Conversions: registrations, listings posted, messages sent, boosts purchased -- [ ] Top categories by listing count and by view count -- [ ] Top metros by listing count (Phase 7 SEO planning) +- [ ] Top categories + metros by listing count / views **Tests** -- [ ] Smoke: admin dashboard 200, non-admin gets 403 +- [ ] Smoke: admin 200, non-admin 403 - [ ] Smoke: flag threshold auto-flips listing status - [ ] Smoke: impersonate logs to audit_log @@ -887,110 +870,82 @@ Or create user with `IDENTIFIED WITH mysql_native_password BY '...'`. ### 🔲 Phase 7 — Polish **SEO & discoverability** -- [ ] Metro landing pages `/classifieds/` (e.g. `/classifieds/orange-county`) +- [ ] Metro landing pages `/classifieds/` - [ ] State landing pages `/classifieds/state/` - [ ] Category landing pages `/classifieds/category/` -- [ ] Dynamic `` and `<meta description>` on all pages +- [ ] Dynamic `<title>` + `<meta description>` on all pages - [ ] JSON-LD structured data on listing detail (Product schema) -- [ ] XML sitemap (`/sitemap.xml`) — listings + categories + metros, auto-updated +- [ ] XML sitemap `/sitemap.xml` (listings + categories + metros) - [ ] `robots.txt` -- [ ] Open Graph tags (listing title, price, cover image) for social sharing +- [ ] Open Graph tags (listing title, price, cover image) **Email notifications** -- [ ] Welcome email on registration (with verify link already sent — add branding) -- [ ] Listing expiry warning email (3 days before `expires_at`) -- [ ] Listing expired email (with renew CTA) -- [ ] Message received email (already inline — move to RQ worker for async) -- [ ] Payment failed email -- [ ] Weekly digest email (new listings in saved categories/metro) — opt-in +- [ ] Listing expiry warning (3 days before `expires_at`) +- [ ] Listing expired (with renew CTA) +- [ ] Move message notifications to RQ worker (async) +- [ ] Weekly digest email (new listings in saved categories) — opt-in **Reviews** -- [ ] `reviews` table (listing_id, author_id, rating TINYINT, body, created_at) -- [ ] Leave review on completed transaction (mark-sold triggers prompt) +- [ ] `reviews` table (listing_id, author_id, rating TINYINT, body) +- [ ] Leave review after mark-sold - [ ] Seller aggregate rating on profile + listing detail -- [ ] Moderation: flag/remove abusive reviews **Translation (deferred from Phase 2)** -- [ ] Per-listing "Translate" button → DeepL/Google Translate API -- [ ] Cache result in `listing_translations` (pay once per listing+lang) -- [ ] Language filter on browse ("Show VI listings only") +- [ ] Per-listing "Translate" button → DeepL/Google API +- [ ] Cache in `listing_translations` +- [ ] Language filter on browse **Performance** -- [ ] Switch sessions to Redis (`SESSION_TYPE=redis`) -- [ ] Query-level caching for hot browse queries (Redis, 60s TTL) -- [ ] Lazy-load listing images (native `loading="lazy"`) -- [ ] Serve WebP thumbnails (Pillow WebP encode alongside JPEG) -- [ ] Add MySQL `FULLTEXT(title, body)` index + switch keyword search to `MATCH ... AGAINST` -- [ ] Pagination `LIMIT/OFFSET` → keyset pagination for large datasets +- [ ] Redis sessions (`SESSION_TYPE=redis`) +- [ ] Query caching for hot browse (Redis, 60s TTL) +- [ ] Lazy-load images (`loading="lazy"`) +- [ ] WebP thumbnails +- [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST` +- [ ] Keyset pagination for large datasets **UX & mobile** -- [ ] Responsive nav (hamburger menu on mobile) -- [ ] Listing detail image gallery with lightbox -- [ ] Infinite scroll OR "Load more" on browse (AJAX pagination) -- [ ] Toast notifications (non-blocking flash messages) -- [ ] "Back to results" link on listing detail (preserve filter state) +- [ ] Responsive nav (hamburger on mobile) +- [ ] Listing image lightbox +- [ ] "Load more" / infinite scroll on browse +- [ ] Toast notifications (non-blocking flash) +- [ ] "Back to results" preserving filter state - [ ] Listing preview before publish **i18n completion** -- [ ] Extract all `_()` strings to `.pot` file -- [ ] Translate all strings to Vietnamese (`vi`) -- [ ] Translate all strings to Spanish (`es`) -- [ ] Compile `.mo` files, test all three locales end-to-end - -**Tests** -- [ ] Smoke: sitemap returns valid XML with listing URLs -- [ ] Smoke: OG tags present on listing detail -- [ ] Smoke: translation cached correctly in `listing_translations` +- [ ] Extract all `_()` strings to `.pot` +- [ ] Translate VI + ES `.po` files +- [ ] Compile `.mo`, test all three locales --- ### 🔲 Phase 8 — JSON API (Optional, for iOS app) -**Design** -- [ ] RESTful JSON API under `/api/v1/` -- [ ] JWT authentication (separate from session cookies) -- [ ] API versioning strategy documented - -**Endpoints** -- [ ] `POST /api/v1/auth/register` -- [ ] `POST /api/v1/auth/login` → returns JWT -- [ ] `GET /api/v1/listings` (browse + all filters, returns paginated JSON) -- [ ] `GET /api/v1/listings/<id>` -- [ ] `POST /api/v1/listings` (create, multipart for images) -- [ ] `PUT /api/v1/listings/<id>` (edit) -- [ ] `DELETE /api/v1/listings/<id>` -- [ ] `GET /api/v1/messages` (inbox) -- [ ] `GET /api/v1/messages/<conv_id>` -- [ ] `POST /api/v1/messages/<conv_id>` (send) +- [ ] 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` -- [ ] `GET /api/v1/zip/<zip>` (geocode lookup) -- [ ] `GET /api/v1/me` (profile + tier info) - -**Infrastructure** -- [ ] JWT library added (`PyJWT` or `flask-jwt-extended`) -- [ ] Rate limiting on all API endpoints (stricter than web) -- [ ] API error responses: consistent `{"error": "...", "code": "..."}` JSON shape -- [ ] CORS headers for iOS app origin -- [ ] API smoke tests (separate from web smoke test) +- [ ] `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 (from earlier phases) +### 🔲 Deferred Items -- [ ] **Auto-translate** — per-listing translate button → DeepL/Google → `listing_translations` cache (moved to Phase 7) -- [ ] **Full ZIP dataset** — replace 10-row sample with SimpleMaps US ZIP or Census ZCTA (~42k rows) -- [ ] **MySQL spatial upgrade** — `POINT` generated column + `SPATIAL INDEX` + `ST_Distance_Sphere` (optional perf upgrade) -- [ ] **MySQL FULLTEXT upgrade** — `FULLTEXT(title,body)` index + `MATCH ... AGAINST` (Phase 7) -- [ ] **MySQL generated hot columns** — `GENERATED ALWAYS AS (JSON_EXTRACT(...))` replacing app-maintained `attr_*` columns (optional) -- [ ] **RQ worker** — replace inline email sends + APScheduler with proper RQ queue + worker process -- [ ] **Redis sessions** — switch from filesystem sessions to Redis-backed (`SESSION_TYPE=redis`) -- [ ] **Bulk CSV listing upload** — Business tier feature (parse CSV, validate, batch-create listings) -- [ ] **Scheduled posting** — Pro/Business: set `publish_at` datetime, worker flips to active -- [ ] **Auto-bump/renew** — Pro (weekly) / Business (daily): worker resets `bump_at`, extends `expires_at` -- [ ] **Storefront/profile page** — Pro/Business: public seller page `/seller/<username>` with all active listings -- [ ] **Custom URL** — Business: vanity slug for storefront (e.g. `/shop/alices-cleaning`) -- [ ] **Job-seeker posts** — reverse job listings ("I'm looking for work") under Jobs category +- [ ] **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 --- @@ -998,15 +953,20 @@ Or create user with `IDENTIFIED WITH mysql_native_password BY '...'`. | Item | Decision | |---|---| -| DB portability | SQLite for tests (with `BigInteger().with_variant(Integer,"sqlite")` + `autoincrement=True`), MySQL 8.0 for prod. MariaDB NOT supported. | -| Spatial search | lat/lng + bounding box + haversine for now. MySQL `POINT` + `SPATIAL INDEX` upgrade SQL documented in README. | -| FULLTEXT search | `title_norm LIKE` for now. MySQL `FULLTEXT(title,body)` + `MATCH ... AGAINST` upgrade documented in README. | -| Hot filter columns | App-maintained `attr_*` indexed columns. MySQL `GENERATED ALWAYS` upgrade documented in README. | -| ZIP dataset | 10-row sample seeded. Replace with SimpleMaps US ZIP or Census ZCTA (~42k rows) before prod. | -| Workers | No RQ/Celery yet. Expiry sweep via systemd timer. Message notifications inline. Phase 4+ may add RQ for async email. | -| Session storage | Flask default (filesystem/cookie). Phase 4: switch to Redis sessions for scale. | -| inline `.env` comments | MUST NOT have `# comments` on the same line as values — python-dotenv does not strip them, causing `int()` ValueError. Comments go on their own lines. | +| 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: copy to `style.vN.css` and update `base.html` link. | --- -_End of spec. Phase 1–3 complete, 34 smoke-test checks green. Phases 4–8 fully detailed as to-do lists above. Next: Phase 4 (Monetization / Stripe)._ +_End of spec. Phase 1–5 complete, 58 smoke-test checks green. +Next: Phase 6 (Admin Backend)._