06/15 Fix layout

This commit is contained in:
2026-06-15 14:58:09 -04:00
parent b0f983259a
commit d9778910bb
3 changed files with 708 additions and 7 deletions
+699
View File
@@ -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 13)
| 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 46)
```
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 13):
- **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 13 complete, 34 smoke-test checks green. Next: Phase 4 (Monetization / Stripe)._
+6 -4
View File
@@ -25,7 +25,7 @@ a:hover{text-decoration:underline}
.btn:hover{background:var(--brand-d);text-decoration:none}
.btn-lg{padding:12px 22px;font-size:17px}
main.wrap{padding-top:24px;padding-bottom:48px}
main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
.hero{text-align:center;padding:48px 0}
.hero h1{font-size:32px;margin:0 0 8px}
.hero p{color:var(--muted);margin:0 0 24px}
@@ -61,10 +61,12 @@ main.wrap{padding-top:24px;padding-bottom:48px}
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-wide{max-width:640px;margin:0 auto}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;width:100%}
.browse .filters{min-width:0}
.browse .results{min-width:0;width:100%}
.filters h3{margin-top:0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,220px));gap:14px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg);width:100%;max-width:220px}
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
+1 -1
View File
@@ -51,7 +51,7 @@
</main>
<footer class="site-footer">
<div class="wrap">© Classifieds — Phase 1 scaffold</div>
<div class="wrap">© {{ 2025 }} Classifieds</div>
</footer>
</body>
</html>