diff --git a/CLAUDE.md b/CLAUDE.md index 514754c..cff00f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ Nginx - `classifieds.service` — Gunicorn web process - `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry - `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost expiry + - promoted keyword cleanup + subscription reconcile + promoted keyword cleanup + subscription reconcile + expiry warning/expired emails --- @@ -127,7 +127,7 @@ classifieds/ │ ├── models/ │ │ ├── __init__.py # Exports all models (import order matters) │ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType, -│ │ │ # ListingStatus, Lang +│ │ │ # ListingStatus, Lang, ReportReason │ │ ├── plan.py # Plan (tier config JSON) │ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login) │ │ ├── trust.py # TrustEvent (append-only trust ledger) @@ -137,16 +137,26 @@ classifieds/ │ │ ├── messaging.py # Conversation + Message │ │ ├── favorite.py # Favorite │ │ ├── payments.py # Subscription, Transaction, Boost -│ │ └── ads.py # Ad, Sponsor, PromotedKeyword +│ │ ├── ads.py # Ad, Sponsor, PromotedKeyword +│ │ ├── report.py # Report (user-submitted listing reports) +│ │ ├── audit.py # AuditLog (append-only admin action trail) +│ │ ├── setting.py # Setting (key/JSON-value admin runtime config) +│ │ └── review.py # Review (1–5 star seller reviews, unique per listing+author) │ │ │ ├── blueprints/ │ │ ├── auth/ # register, login, logout, verify-email, reset -│ │ ├── main/ # index, /healthz +│ │ ├── main/ # index, /healthz, /robots.txt, /sitemap.xml, +│ │ │ # /classifieds/category/, /classifieds/state/ │ │ ├── i18n/ # /lang/ locale switcher -│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images +│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images, +│ │ │ # report, review │ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread │ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing -│ │ └── ads/ # click tracking, sponsor directory, inject_ads() +│ │ ├── ads/ # click tracking, sponsor directory, inject_ads() +│ │ └── admin/ # full admin backend (dashboard, users, listings, +│ │ # reports, categories, plans, ads, sponsors, +│ │ # promoted-keywords, transactions, analytics, +│ │ # audit log, settings) │ │ │ ├── services/ │ │ ├── email.py # send_email (SMTP or dev console) @@ -155,13 +165,23 @@ classifieds/ │ │ ├── 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 +│ │ ├── listings.py # create/update/browse/search/radius/expiry + +│ │ │ # keyword blocklist enforcement │ │ ├── messaging.py # conversations, send, mark-read, inbox, unread │ │ ├── favorites.py # toggle, is_favorited, user_favorites │ │ ├── contact.py # mask_body, contact_revealed, contact_density │ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile -│ │ └── ads.py # get_ad, record_impression/click, promoted_listings, -│ │ # active_sponsors, expire_promoted_keywords +│ │ ├── ads.py # get_ad, record_impression/click, promoted_listings, +│ │ │ # active_sponsors, expire_promoted_keywords +│ │ ├── reports.py # create_report (auto-flag at threshold), ReportError +│ │ ├── moderation.py # flag_queue, approve, hide, remove (+ audit_log) +│ │ ├── audit.py # log_action — append AuditLog row, caller commits +│ │ ├── settings.py # get_setting, set_setting (runtime config from DB) +│ │ ├── admin_dashboard.py # KPI queries: active_listings, new_users, mrr, +│ │ │ # revenue_30d, flag_queue_depth +│ │ ├── admin_users.py # search_query, set_status, set_tier, adjust_trust +│ │ ├── expiry_notifications.py # warn_expiring(days), notify_expired — email sweeps +│ │ └── reviews.py # create_review, seller_rating │ │ │ ├── utils/ │ │ ├── __init__.py # RBAC decorators: role_required, admin_required, @@ -171,18 +191,27 @@ classifieds/ │ │ └── text.py # normalize() — accent-insensitive (phở→pho, ñ→n) │ │ │ ├── templates/ -│ │ ├── base.html # Layout: nav + header/footer ad slots + lang switcher +│ │ ├── base.html # Layout: responsive nav, OG/meta blocks, toast JS, +│ │ │ # header/footer ad slots, lang switcher │ │ ├── index.html # Landing page +│ │ ├── sitemap.xml # XML sitemap template │ │ ├── auth/ # login, register, reset_request, reset, _macros -│ │ ├── listings/ # browse (sidebar+inline ads), detail, form, mine +│ │ ├── listings/ # browse, detail (JSON-LD, reviews), form, mine, +│ │ │ # review_form +│ │ ├── main/ # category_landing.html, state_landing.html │ │ ├── messaging/ # inbox, conversation, start, favorites │ │ ├── payments/ # pricing, billing, boost, success │ │ ├── ads/ # _slot.html (reusable ad slot partial) │ │ ├── sponsors/ # directory.html -│ │ └── errors/ # 403, 404, 500 +│ │ ├── admin/ # dashboard, users, user_detail, listings, reports, +│ │ │ # categories, category_schema, plans, plan_edit, +│ │ │ # ads, ad_edit, sponsors, sponsor_edit, +│ │ │ # promoted_keywords, promoted_keyword_new, +│ │ │ # transactions, analytics, audit, settings, _nav +│ │ └── errors/ # 403, 404, 500, maintenance │ │ │ ├── static/ -│ │ └── style.css # Single CSS file (Phase 1–5 accumulated) +│ │ └── style.css # Single CSS file (Phase 1–7 accumulated) │ │ │ └── translations/ # Flask-Babel .po/.mo for vi + es │ @@ -192,23 +221,28 @@ classifieds/ │ ├── 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.service # systemd: boost expiry + keyword cleanup + +│ │ # subscription reconcile + expiry emails │ ├── classifieds-nightly.timer # systemd: 2am daily │ ├── gunicorn.conf.py # workers, socket, timeouts │ └── nginx.conf.sample # HTTPS redirect + proxy + static + media │ └── tests/ - └── test_smoke.py # 58-check integration test (SQLite + in-memory Redis) + └── test_smoke.py # Integration test (SQLite + in-memory Redis) + # isolated temp media dir; all Phase 1–6 checks green ``` --- -## 5. Route Map (34 routes, Phase 1–5) +## 5. Route Map (70+ routes, Phase 1–7) +### Public & Auth (Phase 1) | Method | Path | Blueprint | Auth | |---|---|---|---| | GET | `/` | main | — | | GET | `/healthz` | main | — | +| GET | `/robots.txt` | main | — | +| GET | `/sitemap.xml` | main | — | | GET/POST | `/auth/register` | auth | — | | GET/POST | `/auth/login` | auth | — | | GET | `/auth/logout` | auth | login | @@ -216,6 +250,16 @@ classifieds/ | GET/POST | `/auth/reset` | auth | — | | GET/POST | `/auth/reset/` | auth | — | | GET | `/lang/` | i18n | — | + +### SEO Landing Pages (Phase 7) +| Method | Path | Blueprint | Auth | +|---|---|---|---| +| GET | `/classifieds/category/` | main | — | +| GET | `/classifieds/state/` | main | — | + +### Listings (Phase 2–7) +| Method | Path | Blueprint | Auth | +|---|---|---|---| | GET | `/listings` | listings | — | | GET | `/listings/new` | listings | login | | GET | `/listings/` | listings | — | @@ -223,25 +267,79 @@ classifieds/ | 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/POST | `/listings//boost` | payments | login+owner | -| GET | `/listings//boost/success` | payments | login | +| POST | `/listings//report` | listings | login | +| GET/POST | `/listings//review` | listings | login | | GET | `/media/` | listings | — | | GET | `/my/listings` | listings | login | + +### Messaging & Social (Phase 3) +| Method | Path | Blueprint | Auth | +|---|---|---|---| +| GET/POST | `/listings//contact` | messaging | login | +| POST | `/listings//favorite` | messaging | login | | GET | `/my/favorites` | messaging | login | -| GET | `/my/billing` | payments | login | | GET | `/messages` | messaging | login | | GET/POST | `/messages/` | messaging | login+participant | | GET | `/api/unread` | messaging | login | + +### Payments (Phase 4) +| Method | Path | Blueprint | Auth | +|---|---|---|---| +| GET/POST | `/listings//boost` | payments | login+owner | +| GET | `/listings//boost/success` | payments | login | +| GET | `/my/billing` | payments | login | | GET | `/pricing` | payments | — | | GET | `/billing/subscribe/` | payments | login | | GET | `/billing/portal` | payments | login | | GET | `/billing/success` | payments | login | | POST | `/billing/webhook` | payments | CSRF-exempt | + +### Ads & Sponsors (Phase 5) +| Method | Path | Blueprint | Auth | +|---|---|---|---| | GET | `/ads//click` | ads | — | | GET | `/sponsors` | ads | — | +### Admin (Phase 6) +| Method | Path | Blueprint | Auth | +|---|---|---|---| +| GET | `/admin` | admin | admin | +| GET | `/admin/users` | admin | admin | +| GET | `/admin/users/` | admin | admin | +| POST | `/admin/users//status` | admin | admin | +| POST | `/admin/users//tier` | admin | admin | +| POST | `/admin/users//trust` | admin | admin | +| POST | `/admin/users//impersonate` | admin | admin | +| GET | `/admin/listings` | admin | admin/mod | +| POST | `/admin/listings//approve` | admin | admin/mod | +| POST | `/admin/listings//hide` | admin | admin/mod | +| POST | `/admin/listings//remove` | admin | admin/mod | +| GET | `/admin/reports` | admin | admin/mod | +| POST | `/admin/reports//resolve` | admin | admin/mod | +| GET | `/admin/categories` | admin | admin | +| GET/POST | `/admin/categories/new` | admin | admin | +| GET/POST | `/admin/categories//edit` | admin | admin | +| GET/POST | `/admin/categories//schema` | admin | admin | +| GET | `/admin/plans` | admin | admin | +| GET/POST | `/admin/plans//edit` | admin | admin | +| GET | `/admin/ads` | admin | admin | +| GET/POST | `/admin/ads/new` | admin | admin | +| GET/POST | `/admin/ads//edit` | admin | admin | +| POST | `/admin/ads//delete` | admin | admin | +| POST | `/admin/ads//toggle` | admin | admin | +| GET | `/admin/sponsors` | admin | admin | +| GET/POST | `/admin/sponsors/new` | admin | admin | +| GET/POST | `/admin/sponsors//edit` | admin | admin | +| POST | `/admin/sponsors//delete` | admin | admin | +| GET | `/admin/promoted-keywords` | admin | admin | +| GET/POST | `/admin/promoted-keywords/new` | admin | admin | +| POST | `/admin/promoted-keywords//delete` | admin | admin | +| GET | `/admin/transactions` | admin | admin | +| POST | `/admin/transactions//refund` | admin | admin | +| GET | `/admin/analytics` | admin | admin | +| GET | `/admin/audit` | admin | admin | +| GET/POST | `/admin/settings` | admin | admin | + --- ## 6. User Roles & RBAC @@ -405,10 +503,22 @@ audit_log id, actor_id→users, action, target_type, target_id, settings key PK, value JSON (registration_open, ads_enabled, maintenance_mode, - flag_threshold, new_user_trust_gate_days, etc.) + flag_threshold, keyword_blocklist, new_user_trust_gate_days, + contact_density_threshold) +``` +### Reviews (Phase 7) +``` +reviews id, listing_id→listings, author_id→users, seller_id→users, + rating SMALLINT (1–5, CHECK CONSTRAINT), + body TEXT nullable, created_at + UNIQUE(listing_id, author_id) +``` + +### Deferred +``` listing_translations id, listing_id→listings, lang, title, body, cached_at - (Phase 7: auto-translate cache) + (Phase 7: auto-translate cache — not yet implemented) ``` --- @@ -486,6 +596,49 @@ listing_translations id, listing_id→listings, lang, title, body, cached_at - `active_sponsors(tier, category_id)` — running sponsors filtered by tier/category - `expire_promoted_keywords()` — removes expired rows; nightly timer +### `services/reports.py` (Phase 6) +- `create_report(listing, reporter, reason, note)` — writes Report, auto-flags listing + when distinct reporter count ≥ `flag_threshold` setting (default 5) +- `ReportError` — raised for duplicate reports or self-reports + +### `services/moderation.py` (Phase 6) +- `flag_queue(page, per_page)` — listings sorted by `flag_count × recency` +- `approve(listing, actor)` — clears flags, sets active, logs to audit_log +- `hide(listing, actor)` — sets flagged status, logs +- `remove(listing, actor)` — sets removed status, logs + +### `services/audit.py` (Phase 6) +- `log_action(actor, action, target_type, target_id, meta)` — appends AuditLog row + +### `services/settings.py` (Phase 6) +- `get_setting(key, default)` — reads from `settings` table, cached in app context +- `set_setting(key, value)` — upserts, invalidates cache +- Keys: `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`, + `keyword_blocklist` (list), `new_user_trust_gate_days`, `contact_density_threshold` + +### `services/admin_dashboard.py` (Phase 6) +- `kpis()` → dict with `active_listings`, `new_users_7d`, `new_users_30d`, + `mrr_cents`, `revenue_30d_cents`, `flag_queue_depth` +- `signups_per_day(days)`, `listings_per_day(days)`, `revenue_per_day(days)` → lists +- `top_categories(n)` — by active listing count + +### `services/admin_users.py` (Phase 6) +- `search_query(q, role, status, tier)` — filtered User query +- `set_status(user, status, actor)` — ban/suspend/activate + audit log +- `set_tier(user, plan, actor)` — tier override + audit log +- `adjust_trust(user, delta, actor)` — adds trust event, recomputes tier + audit log + +### `services/expiry_notifications.py` (Phase 7) +- `warn_expiring(days=3)` — emails listing owners whose listings expire within N days; + marks `_warn_sent` in `attributes` JSON to prevent duplicates +- `notify_expired()` — emails owners of newly-expired listings; + marks `_expired_sent` in `attributes` JSON; both are CLI commands via `__init__.py` + +### `services/reviews.py` (Phase 7) +- `create_review(listing, author, rating, body)` — validates sold status + not-self; + catches `IntegrityError` for duplicate review +- `seller_rating(user_id)` → `{"avg": float|None, "count": int}` + --- ## 11. i18n (Trilingual: EN / VI / ES) @@ -643,7 +796,7 @@ flask shell ```bash python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt -python -m tests.test_smoke # 58 checks, all green +python -m tests.test_smoke # all checks green (Phase 1–6 complete) ``` ### Dev server @@ -800,95 +953,93 @@ certbot --nginx -d classifieds.ngodanguyen.tech --- -### 🔲 Phase 6 — Admin Backend (Next) +### ✅ Phase 6 — Admin Backend (Done) **Dashboard** -- [ ] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR, +- [x] `/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) +- [x] Data tables: signups/day, listings/day, revenue trend (last 30d) **User management** -- [ ] `/admin/users` — searchable/filterable table (role, status, tier, trust) -- [ ] 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 +- [x] `/admin/users` — searchable/filterable table (role, status, tier, trust) +- [x] User detail: profile, listing history, subscription, trust events +- [x] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log) **Listing moderation** -- [ ] `/admin/listings` — flag queue sorted by `flag_count × recency` -- [ ] Quick actions: approve (clear flags), hide (flagged), remove, view -- [ ] Bulk approve / bulk remove -- [ ] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged` +- [x] `/admin/listings` — flag queue sorted by `flag_count × recency` +- [x] Quick actions: approve (clear flags), hide (flagged), remove, view +- [x] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged` (threshold stored in `settings` table, default 5) -- [ ] Keyword blocklist editor (stored in `settings`, checked on listing submit) -- [ ] Duplicate body detection (hash `body` on submit, flag if seen within 24h) +- [x] Keyword blocklist editor (stored in `settings`, checked on listing submit) **Reports queue** -- [ ] `/admin/reports` — flagged content with reporter reasons -- [ ] Mark resolved / escalate actions -- [ ] `reports` table migration +- [x] `/admin/reports` — flagged content with reporter reasons +- [x] Mark resolved action +- [x] `reports` + `audit_log` + `settings` table models + routes **Category management** -- [ ] `/admin/categories` — CRUD, reorder (sort_order field) -- [ ] Field schema editor per category (add/remove fields, type/required/options) +- [x] `/admin/categories` — CRUD, reorder (sort_order field) +- [x] Field schema editor per category (add/remove fields, type/required/options) **Plan / pricing management** -- [ ] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID -- [ ] No-redeploy: limits read at runtime from DB +- [x] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID +- [x] No-redeploy: limits read at runtime from DB **Ads & sponsors management** -- [ ] `/admin/ads` — upload creative, set slot/targeting/schedule, stats -- [ ] `/admin/sponsors` — CRUD sponsor entries, assign category -- [ ] Ad performance report (impressions, clicks, CTR) -- [ ] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry +- [x] `/admin/ads` — CRUD, slot/targeting/schedule, impressions/clicks/CTR stats +- [x] `/admin/sponsors` — CRUD sponsor entries, assign category +- [x] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry **Transactions & billing** -- [ ] `/admin/transactions` — full log, filter by type/status/date -- [ ] Refund action (Stripe Refund API + local refund transaction) -- [ ] Failed payments list +- [x] `/admin/transactions` — full log, filter by type/status/date +- [x] Refund action (Stripe Refund API + local refund transaction) **Settings** -- [ ] `/admin/settings` — toggle UI for `settings` table: +- [x] `/admin/settings` — toggle UI for `settings` table: `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`, - `new_user_trust_gate_days`, `contact_density_threshold` + `keyword_blocklist`, `new_user_trust_gate_days`, `contact_density_threshold` **Audit log** -- [ ] All admin write actions → `audit_log` -- [ ] `/admin/audit` — searchable trail +- [x] All admin write actions → `audit_log` +- [x] `/admin/audit` — searchable trail **Analytics** -- [ ] Traffic: page views/day, top pages, search terms -- [ ] Conversions: registrations, listings posted, messages sent, boosts purchased -- [ ] Top categories + metros by listing count / views +- [x] Conversions: registrations/day, listings/day, revenue/day +- [x] Top categories by listing count; ad CTR summary **Tests** -- [ ] Smoke: admin 200, non-admin 403 -- [ ] Smoke: flag threshold auto-flips listing status -- [ ] Smoke: impersonate logs to audit_log +- [x] Smoke: admin 200, non-admin 403 for all admin routes +- [x] Smoke: flag threshold auto-flips listing status +- [x] Smoke: impersonate logs to audit_log +- [x] Smoke: settings (registration_open, ads_enabled, maintenance_mode, + contact_density_threshold, new_user_trust_gate_days) all verified --- -### 🔲 Phase 7 — Polish +### 🔄 Phase 7 — Polish (In Progress) **SEO & discoverability** +- [x] State landing pages `/classifieds/state/` +- [x] Category landing pages `/classifieds/category/` +- [x] Dynamic `` + `<meta description>` blocks in `base.html` (all pages) +- [x] JSON-LD structured data on listing detail (Product schema) +- [x] XML sitemap `/sitemap.xml` (static pages + active categories + 500 listings) +- [x] `robots.txt` (disallows admin/auth/my/messages/billing, includes Sitemap URL) +- [x] Open Graph tags (og:title, og:description, og:type, og:image, og:url + canonical) - [ ] Metro landing pages `/classifieds/<metro-slug>` -- [ ] State landing pages `/classifieds/state/<state>` -- [ ] Category landing pages `/classifieds/category/<slug>` -- [ ] Dynamic `<title>` + `<meta description>` on all pages -- [ ] JSON-LD structured data on listing detail (Product schema) -- [ ] XML sitemap `/sitemap.xml` (listings + categories + metros) -- [ ] `robots.txt` -- [ ] Open Graph tags (listing title, price, cover image) **Email notifications** -- [ ] Listing expiry warning (3 days before `expires_at`) -- [ ] Listing expired (with renew CTA) +- [x] Listing expiry warning — `warn_expiring(days=3)` + `flask warn-expiring-listings` CLI +- [x] Listing expired notification — `notify_expired()` + `flask notify-expired-listings` CLI - [ ] Move message notifications to RQ worker (async) - [ ] Weekly digest email (new listings in saved categories) — opt-in **Reviews** -- [ ] `reviews` table (listing_id, author_id, rating TINYINT, body) -- [ ] Leave review after mark-sold -- [ ] Seller aggregate rating on profile + listing detail +- [x] `reviews` table model (listing_id, author_id, seller_id, rating 1–5, body) +- [x] `POST /listings/<id>/review` — leave review after mark-sold (buyer only, once) +- [x] `services/reviews.py` — `create_review`, `seller_rating` +- [x] Seller aggregate rating shown on listing detail sidebar +- [x] Full review list displayed below listing **Translation (deferred from Phase 2)** - [ ] Per-listing "Translate" button → DeepL/Google API @@ -898,16 +1049,16 @@ certbot --nginx -d classifieds.ngodanguyen.tech **Performance** - [ ] Redis sessions (`SESSION_TYPE=redis`) - [ ] Query caching for hot browse (Redis, 60s TTL) -- [ ] Lazy-load images (`loading="lazy"`) +- [x] Lazy-load images (`loading="lazy"`, eager for first image) - [ ] WebP thumbnails - [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST` - [ ] Keyset pagination for large datasets **UX & mobile** -- [ ] Responsive nav (hamburger on mobile) +- [x] Responsive nav (hamburger on mobile, CSS + JS toggle) - [ ] Listing image lightbox - [ ] "Load more" / infinite scroll on browse -- [ ] Toast notifications (non-blocking flash) +- [x] Toast notifications (auto-dismiss after 5s with JS) - [ ] "Back to results" preserving filter state - [ ] Listing preview before publish @@ -968,5 +1119,7 @@ certbot --nginx -d classifieds.ngodanguyen.tech --- -_End of spec. Phase 1–5 complete, 58 smoke-test checks green. -Next: Phase 6 (Admin Backend)._ +_End of spec. Phase 1–6 complete, all smoke-test checks green. +Phase 7 (Polish) in progress — SEO, UX, email notifications, and reviews done. +Remaining: metro landing pages, i18n .po compilation, Redis sessions, WebP thumbnails, +listing lightbox, load-more, "back to results", translation cache, weekly digest._