06/15 Fix layout
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
# 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.
|
||||
|
||||
**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 | APScheduler or RQ (expiry sweep, email, image processing) |
|
||||
| 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│ 443 (TLS via Certbot)
|
||||
Nginx
|
||||
├─ /static/ → app/static/ (30d cache)
|
||||
├─ /media/ → instance/media/ (7d cache, Phase 2+)
|
||||
└─ / → Gunicorn unix socket → Flask app factory
|
||||
│
|
||||
┌─────────────────────────┼──────────────┐
|
||||
MySQL 8.0 Redis Stripe
|
||||
(utf8mb4) (sessions, cache, (webhooks,
|
||||
rate-limit, RQ) billing)
|
||||
│
|
||||
SMTP relay
|
||||
(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.
|
||||
|
||||
**systemd units (in `deploy/`):**
|
||||
- `classifieds.service` — Gunicorn web process
|
||||
- `classifieds-expire.service` + `classifieds-expire.timer` — hourly expiry sweep
|
||||
|
||||
---
|
||||
|
||||
## 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 documented (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
|
||||
│ │ ├── 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
|
||||
│ │
|
||||
│ ├── blueprints/
|
||||
│ │ ├── auth/ # register, login, logout, verify-email, reset
|
||||
│ │ ├── main/ # index, /healthz
|
||||
│ │ ├── i18n/ # /lang/<code> locale switcher
|
||||
│ │ ├── listings/ # browse, detail, create, edit, delete, images
|
||||
│ │ └── messaging/ # inbox, conversation, start, favorites, /api/unread
|
||||
│ │
|
||||
│ ├── 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
|
||||
│ │
|
||||
│ ├── 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)
|
||||
│ │
|
||||
│ ├── 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
|
||||
│ │
|
||||
│ ├── static/
|
||||
│ │ └── style.css # Single CSS file (Phase 1+2+3 accumulated)
|
||||
│ │
|
||||
│ └── translations/ # Flask-Babel .po/.mo for vi + es
|
||||
│
|
||||
├── migrations/ # Alembic migration scripts (flask db migrate/upgrade)
|
||||
│
|
||||
├── 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
|
||||
│
|
||||
└── tests/
|
||||
└── test_smoke.py # 34-check integration test (SQLite + in-memory Redis)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Route Map (24 routes, Phase 1–3)
|
||||
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | main | — |
|
||||
| GET | `/healthz` | main | — |
|
||||
| GET/POST | `/auth/register` | auth | — |
|
||||
| GET/POST | `/auth/login` | auth | — |
|
||||
| GET | `/auth/logout` | auth | login |
|
||||
| GET | `/auth/verify/<token>` | auth | — |
|
||||
| GET/POST | `/auth/reset` | auth | — |
|
||||
| GET/POST | `/auth/reset/<token>` | auth | — |
|
||||
| GET | `/lang/<code>` | i18n | — |
|
||||
| GET | `/listings` | listings | — |
|
||||
| GET | `/listings/new` | listings | login |
|
||||
| GET | `/listings/<id>` | listings | — |
|
||||
| GET/POST | `/listings/<id>/edit` | listings | login+owner |
|
||||
| POST | `/listings/<id>/delete` | listings | login+owner |
|
||||
| POST | `/listings/<id>/sold` | listings | login+owner |
|
||||
| POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner |
|
||||
| GET/POST | `/listings/<id>/contact` | messaging | login |
|
||||
| POST | `/listings/<id>/favorite` | messaging | login |
|
||||
| GET | `/media/<path>` | listings | — |
|
||||
| GET | `/my/listings` | listings | login |
|
||||
| GET | `/my/favorites` | messaging | login |
|
||||
| GET | `/messages` | messaging | login |
|
||||
| GET/POST | `/messages/<id>` | messaging | login+participant |
|
||||
| GET | `/api/unread` | messaging | login |
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
- Flask-Login `@login_required` for authenticated-only routes
|
||||
- Ownership checks inline in route handlers (owner or moderator)
|
||||
|
||||
---
|
||||
|
||||
## 7. Subscription Tiers
|
||||
|
||||
Limits stored in `plans.config` JSON (admin-editable without redeploy).
|
||||
Enforcement reads from plan at runtime via `plan.limit(key, default)`.
|
||||
|
||||
| Feature | Free | Basic | Pro | Business |
|
||||
|---|---|---|---|---|
|
||||
| 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, 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+)
|
||||
|
||||
---
|
||||
|
||||
## 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 for currency.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
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]
|
||||
(seed ~42k rows from SimpleMaps/Census ZCTA for prod)
|
||||
|
||||
metros id, slug (unique), name, state, center_lat, center_lng
|
||||
(SEO landing page anchor — 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)
|
||||
```
|
||||
|
||||
### Monetization & Ops (Phase 4–6)
|
||||
```
|
||||
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
|
||||
|
||||
transactions id, user_id, type ENUM(subscription,boost,refund),
|
||||
amount_cents, currency, stripe_object_id, status,
|
||||
meta JSON, 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
|
||||
|
||||
sponsors id, name, logo_path, url, tier ENUM(directory,category),
|
||||
category_id→categories nullable, starts_at, ends_at, is_active
|
||||
|
||||
boosts id, listing_id→listings, user_id→users,
|
||||
type ENUM(featured,bump,highlight,urgent),
|
||||
expires_at, transaction_id→transactions, 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.)
|
||||
|
||||
listing_translations id, listing_id→listings, lang, title, body, cached_at
|
||||
(Phase 2 deferred: auto-translate cache)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- `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
|
||||
|
||||
### `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
|
||||
- `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
|
||||
|
||||
### `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`
|
||||
- `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
|
||||
|
||||
### `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)
|
||||
|
||||
### `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)
|
||||
|
||||
### `services/favorites.py`
|
||||
- `toggle_favorite(user_id, listing_id)` → bool (now favorited)
|
||||
- `is_favorited(user_id, listing_id)` → bool
|
||||
- `user_favorites(user_id, page, per_page)` → pagination
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
```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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- 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):
|
||||
```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 (post-and-flag model)
|
||||
|
||||
Listings go **active immediately** — no pre-approval gate by default.
|
||||
|
||||
**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
|
||||
|
||||
**Phase 6 additions (admin queue):** flag threshold N → auto-flip to `flagged`,
|
||||
keyword blocklist, `reports` table workflow, `audit_log` for all mod actions.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- ProxyFix for correct `is_secure` + client-IP behind Nginx
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
```
|
||||
FLASK_CONFIG=prod
|
||||
SECRET_KEY=<long random string>
|
||||
SERVER_NAME=classifieds.example.com
|
||||
|
||||
# Database
|
||||
DB_USER=classifieds
|
||||
DB_PASSWORD=<password>
|
||||
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
|
||||
|
||||
# i18n
|
||||
DEFAULT_LOCALE=en
|
||||
SUPPORTED_LOCALES=en,vi,es
|
||||
|
||||
# Email (Brevo SMTP relay)
|
||||
MAIL_SERVER=smtp-relay.brevo.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=<brevo login>
|
||||
MAIL_PASSWORD=<brevo smtp key>
|
||||
MAIL_FROM=no-reply@example.com
|
||||
MAIL_FROM_NAME=Classifieds
|
||||
|
||||
# Media
|
||||
MEDIA_ROOT=
|
||||
# blank = instance/media (Flask instance folder)
|
||||
|
||||
# Turnstile (leave blank in dev to bypass)
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Token lifetimes
|
||||
TOKEN_VERIFY_MAX_AGE=86400
|
||||
TOKEN_RESET_MAX_AGE=3600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 # 34 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:
|
||||
```
|
||||
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
|
||||
python seed.py --admin admin@example.com 'StrongPass123'
|
||||
flask run
|
||||
```
|
||||
|
||||
### MySQL 8.0 setup
|
||||
```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'` 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 '...'`.
|
||||
|
||||
### Production deploy
|
||||
```bash
|
||||
# 1. deploy code to /opt/classifieds (or /home/classifieds/classifieds)
|
||||
# 2. python3 -m venv venv && pip install -r requirements.txt
|
||||
# 3. cp .env.example .env && edit .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
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## 17. Coding Conventions
|
||||
|
||||
- App factory pattern. No global `app`. Extensions in `extensions.py`.
|
||||
- Blueprints per domain. **Routes thin; all business 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.
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## 18. Build Roadmap
|
||||
|
||||
| Phase | Status | Deliverable |
|
||||
|---|---|---|
|
||||
| 1 — Foundation | ✅ Done | App factory, config, auth (register/verify/login/reset), RBAC, i18n scaffold, Turnstile, seed plans |
|
||||
| 2 — Listings core | ✅ Done | Categories + field_schema, listing CRUD, image pipeline, browse/search/radius, expiry sweep |
|
||||
| 3 — Messaging + favorites | ✅ Done | Conversations, messages, read tracking, contact masking, favorites, unread badge |
|
||||
| 4 — Monetization | 🔲 Next | Stripe Billing, plans/tier enforcement, Customer Portal, à la carte boosts, webhooks |
|
||||
| 5 — Ads & sponsors | 🔲 | Internal ad server, impression/click tracking, sponsor directory, promoted search |
|
||||
| 6 — Admin backend | 🔲 | Dashboard, moderation queue, user mgmt, category/plan editors, audit log, analytics |
|
||||
| 7 — Polish | 🔲 | Metro SEO pages, sitemaps, structured data, email notifications, reviews, perf tuning |
|
||||
| 8 — JSON API | 🔲 Optional | iOS app endpoints (mirrors JQC/CitizenReady pattern) |
|
||||
|
||||
**Phase 2 deferred items:**
|
||||
- Auto-translate button → `listing_translations` cache (DeepL/Google)
|
||||
- `listing_translations` table schema already defined above
|
||||
|
||||
---
|
||||
|
||||
## 19. Known Issues / Decisions Locked
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
_End of spec. Phase 1–3 complete, 34 smoke-test checks green. Next: Phase 4 (Monetization / Stripe)._
|
||||
Reference in New Issue
Block a user