From d9778910bb20edafc550ba633b3b4cb0accfe679 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 15 Jun 2026 14:58:09 -0400 Subject: [PATCH] 06/15 Fix layout --- CLAUDE.md | 699 ++++++++++++++++++++++++++++++++++++++++ app/static/style.css | 12 +- app/templates/base.html | 4 +- 3 files changed, 708 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fbc0641 --- /dev/null +++ b/CLAUDE.md @@ -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/ 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/` | auth | — | +| GET/POST | `/auth/reset` | auth | — | +| GET/POST | `/auth/reset/` | auth | — | +| GET | `/lang/` | i18n | — | +| GET | `/listings` | listings | — | +| GET | `/listings/new` | listings | login | +| GET | `/listings/` | listings | — | +| GET/POST | `/listings//edit` | listings | login+owner | +| POST | `/listings//delete` | listings | login+owner | +| POST | `/listings//sold` | listings | login+owner | +| POST | `/listings//images//delete` | listings | login+owner | +| GET/POST | `/listings//contact` | messaging | login | +| POST | `/listings//favorite` | messaging | login | +| GET | `/media/` | listings | — | +| GET | `/my/listings` | listings | login | +| GET | `/my/favorites` | messaging | login | +| GET | `/messages` | messaging | login | +| GET/POST | `/messages/` | 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= +SERVER_NAME=classifieds.example.com + +# Database +DB_USER=classifieds +DB_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= +MAIL_PASSWORD= +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)._ diff --git a/app/static/style.css b/app/static/style.css index d700fd7..fdadb0c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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} @@ -127,4 +129,4 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)} .bubble.mine .bubble-meta{text-align:right} .reply-box{padding:16px} .mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px; - padding:8px 12px;margin-top:8px} + padding:8px 12px;margin-top:8px} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index caf17d9..fc38abf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -51,7 +51,7 @@ - + \ No newline at end of file