06/18 Update document

This commit is contained in:
2026-06-18 09:44:32 -04:00
parent d54e5e2127
commit ae544af6e1
+232 -79
View File
@@ -102,7 +102,7 @@ Nginx
- `classifieds.service` — Gunicorn web process - `classifieds.service` — Gunicorn web process
- `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry - `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry
- `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost 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/ │ ├── models/
│ │ ├── __init__.py # Exports all models (import order matters) │ │ ├── __init__.py # Exports all models (import order matters)
│ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType, │ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType,
│ │ │ # ListingStatus, Lang │ │ │ # ListingStatus, Lang, ReportReason
│ │ ├── plan.py # Plan (tier config JSON) │ │ ├── plan.py # Plan (tier config JSON)
│ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login) │ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login)
│ │ ├── trust.py # TrustEvent (append-only trust ledger) │ │ ├── trust.py # TrustEvent (append-only trust ledger)
@@ -137,16 +137,26 @@ classifieds/
│ │ ├── messaging.py # Conversation + Message │ │ ├── messaging.py # Conversation + Message
│ │ ├── favorite.py # Favorite │ │ ├── favorite.py # Favorite
│ │ ├── payments.py # Subscription, Transaction, Boost │ │ ├── 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 (15 star seller reviews, unique per listing+author)
│ │ │ │
│ ├── blueprints/ │ ├── blueprints/
│ │ ├── auth/ # register, login, logout, verify-email, reset │ │ ├── auth/ # register, login, logout, verify-email, reset
│ │ ├── main/ # index, /healthz │ │ ├── main/ # index, /healthz, /robots.txt, /sitemap.xml,
│ │ │ # /classifieds/category/<slug>, /classifieds/state/<state>
│ │ ├── i18n/ # /lang/<code> locale switcher │ │ ├── i18n/ # /lang/<code> 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 │ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread
│ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing │ │ ├── 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/ │ ├── services/
│ │ ├── email.py # send_email (SMTP or dev console) │ │ ├── email.py # send_email (SMTP or dev console)
@@ -155,13 +165,23 @@ classifieds/
│ │ ├── geo.py # geocode_zip, haversine_mi, bounding_box │ │ ├── geo.py # geocode_zip, haversine_mi, bounding_box
│ │ ├── field_schema.py # validate_attributes, hot_values │ │ ├── field_schema.py # validate_attributes, hot_values
│ │ ├── images.py # process_upload, delete_image_files │ │ ├── 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 │ │ ├── messaging.py # conversations, send, mark-read, inbox, unread
│ │ ├── favorites.py # toggle, is_favorited, user_favorites │ │ ├── favorites.py # toggle, is_favorited, user_favorites
│ │ ├── contact.py # mask_body, contact_revealed, contact_density │ │ ├── contact.py # mask_body, contact_revealed, contact_density
│ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile │ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile
│ │ ── ads.py # get_ad, record_impression/click, promoted_listings, │ │ ── ads.py # get_ad, record_impression/click, promoted_listings,
│ │ # active_sponsors, expire_promoted_keywords │ │ # 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/ │ ├── utils/
│ │ ├── __init__.py # RBAC decorators: role_required, admin_required, │ │ ├── __init__.py # RBAC decorators: role_required, admin_required,
@@ -171,18 +191,27 @@ classifieds/
│ │ └── text.py # normalize() — accent-insensitive (phở→pho, ñ→n) │ │ └── text.py # normalize() — accent-insensitive (phở→pho, ñ→n)
│ │ │ │
│ ├── templates/ │ ├── 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 │ │ ├── index.html # Landing page
│ │ ├── sitemap.xml # XML sitemap template
│ │ ├── auth/ # login, register, reset_request, reset, _macros │ │ ├── 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 │ │ ├── messaging/ # inbox, conversation, start, favorites
│ │ ├── payments/ # pricing, billing, boost, success │ │ ├── payments/ # pricing, billing, boost, success
│ │ ├── ads/ # _slot.html (reusable ad slot partial) │ │ ├── ads/ # _slot.html (reusable ad slot partial)
│ │ ├── sponsors/ # directory.html │ │ ├── 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/ │ ├── static/
│ │ └── style.css # Single CSS file (Phase 15 accumulated) │ │ └── style.css # Single CSS file (Phase 17 accumulated)
│ │ │ │
│ └── translations/ # Flask-Babel .po/.mo for vi + es │ └── translations/ # Flask-Babel .po/.mo for vi + es
@@ -192,23 +221,28 @@ classifieds/
│ ├── classifieds.service # systemd: Gunicorn web │ ├── classifieds.service # systemd: Gunicorn web
│ ├── classifieds-expire.service # systemd: oneshot listing expiry │ ├── classifieds-expire.service # systemd: oneshot listing expiry
│ ├── classifieds-expire.timer # systemd: hourly │ ├── 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 │ ├── classifieds-nightly.timer # systemd: 2am daily
│ ├── gunicorn.conf.py # workers, socket, timeouts │ ├── gunicorn.conf.py # workers, socket, timeouts
│ └── nginx.conf.sample # HTTPS redirect + proxy + static + media │ └── nginx.conf.sample # HTTPS redirect + proxy + static + media
└── tests/ └── 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 16 checks green
``` ```
--- ---
## 5. Route Map (34 routes, Phase 15) ## 5. Route Map (70+ routes, Phase 17)
### Public & Auth (Phase 1)
| Method | Path | Blueprint | Auth | | Method | Path | Blueprint | Auth |
|---|---|---|---| |---|---|---|---|
| GET | `/` | main | — | | GET | `/` | main | — |
| GET | `/healthz` | main | — | | GET | `/healthz` | main | — |
| GET | `/robots.txt` | main | — |
| GET | `/sitemap.xml` | main | — |
| GET/POST | `/auth/register` | auth | — | | GET/POST | `/auth/register` | auth | — |
| GET/POST | `/auth/login` | auth | — | | GET/POST | `/auth/login` | auth | — |
| GET | `/auth/logout` | auth | login | | GET | `/auth/logout` | auth | login |
@@ -216,6 +250,16 @@ classifieds/
| GET/POST | `/auth/reset` | auth | — | | GET/POST | `/auth/reset` | auth | — |
| GET/POST | `/auth/reset/<token>` | auth | — | | GET/POST | `/auth/reset/<token>` | auth | — |
| GET | `/lang/<code>` | i18n | — | | GET | `/lang/<code>` | i18n | — |
### SEO Landing Pages (Phase 7)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/classifieds/category/<slug>` | main | — |
| GET | `/classifieds/state/<state>` | main | — |
### Listings (Phase 27)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/listings` | listings | — | | GET | `/listings` | listings | — |
| GET | `/listings/new` | listings | login | | GET | `/listings/new` | listings | login |
| GET | `/listings/<id>` | listings | — | | GET | `/listings/<id>` | listings | — |
@@ -223,25 +267,79 @@ classifieds/
| POST | `/listings/<id>/delete` | listings | login+owner | | POST | `/listings/<id>/delete` | listings | login+owner |
| POST | `/listings/<id>/sold` | listings | login+owner | | POST | `/listings/<id>/sold` | listings | login+owner |
| POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner | | POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner |
| GET/POST | `/listings/<id>/contact` | messaging | login | | POST | `/listings/<id>/report` | listings | login |
| POST | `/listings/<id>/favorite` | messaging | login | | GET/POST | `/listings/<id>/review` | listings | login |
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
| GET | `/listings/<id>/boost/success` | payments | login |
| GET | `/media/<path>` | listings | — | | GET | `/media/<path>` | listings | — |
| GET | `/my/listings` | listings | login | | GET | `/my/listings` | listings | login |
### Messaging & Social (Phase 3)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET/POST | `/listings/<id>/contact` | messaging | login |
| POST | `/listings/<id>/favorite` | messaging | login |
| GET | `/my/favorites` | messaging | login | | GET | `/my/favorites` | messaging | login |
| GET | `/my/billing` | payments | login |
| GET | `/messages` | messaging | login | | GET | `/messages` | messaging | login |
| GET/POST | `/messages/<id>` | messaging | login+participant | | GET/POST | `/messages/<id>` | messaging | login+participant |
| GET | `/api/unread` | messaging | login | | GET | `/api/unread` | messaging | login |
### Payments (Phase 4)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
| GET | `/listings/<id>/boost/success` | payments | login |
| GET | `/my/billing` | payments | login |
| GET | `/pricing` | payments | — | | GET | `/pricing` | payments | — |
| GET | `/billing/subscribe/<slug>` | payments | login | | GET | `/billing/subscribe/<slug>` | payments | login |
| GET | `/billing/portal` | payments | login | | GET | `/billing/portal` | payments | login |
| GET | `/billing/success` | payments | login | | GET | `/billing/success` | payments | login |
| POST | `/billing/webhook` | payments | CSRF-exempt | | POST | `/billing/webhook` | payments | CSRF-exempt |
### Ads & Sponsors (Phase 5)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/ads/<id>/click` | ads | — | | GET | `/ads/<id>/click` | ads | — |
| GET | `/sponsors` | ads | — | | GET | `/sponsors` | ads | — |
### Admin (Phase 6)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/admin` | admin | admin |
| GET | `/admin/users` | admin | admin |
| GET | `/admin/users/<id>` | admin | admin |
| POST | `/admin/users/<id>/status` | admin | admin |
| POST | `/admin/users/<id>/tier` | admin | admin |
| POST | `/admin/users/<id>/trust` | admin | admin |
| POST | `/admin/users/<id>/impersonate` | admin | admin |
| GET | `/admin/listings` | admin | admin/mod |
| POST | `/admin/listings/<id>/approve` | admin | admin/mod |
| POST | `/admin/listings/<id>/hide` | admin | admin/mod |
| POST | `/admin/listings/<id>/remove` | admin | admin/mod |
| GET | `/admin/reports` | admin | admin/mod |
| POST | `/admin/reports/<id>/resolve` | admin | admin/mod |
| GET | `/admin/categories` | admin | admin |
| GET/POST | `/admin/categories/new` | admin | admin |
| GET/POST | `/admin/categories/<id>/edit` | admin | admin |
| GET/POST | `/admin/categories/<id>/schema` | admin | admin |
| GET | `/admin/plans` | admin | admin |
| GET/POST | `/admin/plans/<id>/edit` | admin | admin |
| GET | `/admin/ads` | admin | admin |
| GET/POST | `/admin/ads/new` | admin | admin |
| GET/POST | `/admin/ads/<id>/edit` | admin | admin |
| POST | `/admin/ads/<id>/delete` | admin | admin |
| POST | `/admin/ads/<id>/toggle` | admin | admin |
| GET | `/admin/sponsors` | admin | admin |
| GET/POST | `/admin/sponsors/new` | admin | admin |
| GET/POST | `/admin/sponsors/<id>/edit` | admin | admin |
| POST | `/admin/sponsors/<id>/delete` | admin | admin |
| GET | `/admin/promoted-keywords` | admin | admin |
| GET/POST | `/admin/promoted-keywords/new` | admin | admin |
| POST | `/admin/promoted-keywords/<id>/delete` | admin | admin |
| GET | `/admin/transactions` | admin | admin |
| POST | `/admin/transactions/<id>/refund` | admin | admin |
| GET | `/admin/analytics` | admin | admin |
| GET | `/admin/audit` | admin | admin |
| GET/POST | `/admin/settings` | admin | admin |
--- ---
## 6. User Roles & RBAC ## 6. User Roles & RBAC
@@ -405,10 +503,22 @@ audit_log id, actor_id→users, action, target_type, target_id,
settings key PK, value JSON settings key PK, value JSON
(registration_open, ads_enabled, maintenance_mode, (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 (15, CHECK CONSTRAINT),
body TEXT nullable, created_at
UNIQUE(listing_id, author_id)
```
### Deferred
```
listing_translations id, listing_id→listings, lang, title, body, cached_at 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 - `active_sponsors(tier, category_id)` — running sponsors filtered by tier/category
- `expire_promoted_keywords()` — removes expired rows; nightly timer - `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) ## 11. i18n (Trilingual: EN / VI / ES)
@@ -643,7 +796,7 @@ flask shell
```bash ```bash
python3 -m venv venv && source venv/bin/activate python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python -m tests.test_smoke # 58 checks, all green python -m tests.test_smoke # all checks green (Phase 16 complete)
``` ```
### Dev server ### Dev server
@@ -800,95 +953,93 @@ certbot --nginx -d classifieds.ngodanguyen.tech
--- ---
### 🔲 Phase 6 — Admin Backend (Next) ### Phase 6 — Admin Backend (Done)
**Dashboard** **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) 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** **User management**
- [ ] `/admin/users` — searchable/filterable table (role, status, tier, trust) - [x] `/admin/users` — searchable/filterable table (role, status, tier, trust)
- [ ] User detail: profile, listing history, subscription, trust events - [x] User detail: profile, listing history, subscription, trust events
- [ ] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log) - [x] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log)
- [ ] Bulk actions: ban selected, send email to selected
**Listing moderation** **Listing moderation**
- [ ] `/admin/listings` — flag queue sorted by `flag_count × recency` - [x] `/admin/listings` — flag queue sorted by `flag_count × recency`
- [ ] Quick actions: approve (clear flags), hide (flagged), remove, view - [x] Quick actions: approve (clear flags), hide (flagged), remove, view
- [ ] Bulk approve / bulk remove - [x] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
- [ ] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
(threshold stored in `settings` table, default 5) (threshold stored in `settings` table, default 5)
- [ ] Keyword blocklist editor (stored in `settings`, checked on listing submit) - [x] Keyword blocklist editor (stored in `settings`, checked on listing submit)
- [ ] Duplicate body detection (hash `body` on submit, flag if seen within 24h)
**Reports queue** **Reports queue**
- [ ] `/admin/reports` — flagged content with reporter reasons - [x] `/admin/reports` — flagged content with reporter reasons
- [ ] Mark resolved / escalate actions - [x] Mark resolved action
- [ ] `reports` table migration - [x] `reports` + `audit_log` + `settings` table models + routes
**Category management** **Category management**
- [ ] `/admin/categories` — CRUD, reorder (sort_order field) - [x] `/admin/categories` — CRUD, reorder (sort_order field)
- [ ] Field schema editor per category (add/remove fields, type/required/options) - [x] Field schema editor per category (add/remove fields, type/required/options)
**Plan / pricing management** **Plan / pricing management**
- [ ] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID - [x] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID
- [ ] No-redeploy: limits read at runtime from DB - [x] No-redeploy: limits read at runtime from DB
**Ads & sponsors management** **Ads & sponsors management**
- [ ] `/admin/ads`upload creative, set slot/targeting/schedule, stats - [x] `/admin/ads`CRUD, slot/targeting/schedule, impressions/clicks/CTR stats
- [ ] `/admin/sponsors` — CRUD sponsor entries, assign category - [x] `/admin/sponsors` — CRUD sponsor entries, assign category
- [ ] Ad performance report (impressions, clicks, CTR) - [x] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
- [ ] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
**Transactions & billing** **Transactions & billing**
- [ ] `/admin/transactions` — full log, filter by type/status/date - [x] `/admin/transactions` — full log, filter by type/status/date
- [ ] Refund action (Stripe Refund API + local refund transaction) - [x] Refund action (Stripe Refund API + local refund transaction)
- [ ] Failed payments list
**Settings** **Settings**
- [ ] `/admin/settings` — toggle UI for `settings` table: - [x] `/admin/settings` — toggle UI for `settings` table:
`registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`, `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** **Audit log**
- [ ] All admin write actions → `audit_log` - [x] All admin write actions → `audit_log`
- [ ] `/admin/audit` — searchable trail - [x] `/admin/audit` — searchable trail
**Analytics** **Analytics**
- [ ] Traffic: page views/day, top pages, search terms - [x] Conversions: registrations/day, listings/day, revenue/day
- [ ] Conversions: registrations, listings posted, messages sent, boosts purchased - [x] Top categories by listing count; ad CTR summary
- [ ] Top categories + metros by listing count / views
**Tests** **Tests**
- [ ] Smoke: admin 200, non-admin 403 - [x] Smoke: admin 200, non-admin 403 for all admin routes
- [ ] Smoke: flag threshold auto-flips listing status - [x] Smoke: flag threshold auto-flips listing status
- [ ] Smoke: impersonate logs to audit_log - [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** **SEO & discoverability**
- [x] State landing pages `/classifieds/state/<state>`
- [x] Category landing pages `/classifieds/category/<slug>`
- [x] Dynamic `<title>` + `<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>` - [ ] 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** **Email notifications**
- [ ] Listing expiry warning (3 days before `expires_at`) - [x] Listing expiry warning `warn_expiring(days=3)` + `flask warn-expiring-listings` CLI
- [ ] Listing expired (with renew CTA) - [x] Listing expired notification — `notify_expired()` + `flask notify-expired-listings` CLI
- [ ] Move message notifications to RQ worker (async) - [ ] Move message notifications to RQ worker (async)
- [ ] Weekly digest email (new listings in saved categories) — opt-in - [ ] Weekly digest email (new listings in saved categories) — opt-in
**Reviews** **Reviews**
- [ ] `reviews` table (listing_id, author_id, rating TINYINT, body) - [x] `reviews` table model (listing_id, author_id, seller_id, rating 15, body)
- [ ] Leave review after mark-sold - [x] `POST /listings/<id>/review` — leave review after mark-sold (buyer only, once)
- [ ] Seller aggregate rating on profile + listing detail - [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)** **Translation (deferred from Phase 2)**
- [ ] Per-listing "Translate" button → DeepL/Google API - [ ] Per-listing "Translate" button → DeepL/Google API
@@ -898,16 +1049,16 @@ certbot --nginx -d classifieds.ngodanguyen.tech
**Performance** **Performance**
- [ ] Redis sessions (`SESSION_TYPE=redis`) - [ ] Redis sessions (`SESSION_TYPE=redis`)
- [ ] Query caching for hot browse (Redis, 60s TTL) - [ ] 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 - [ ] WebP thumbnails
- [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST` - [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST`
- [ ] Keyset pagination for large datasets - [ ] Keyset pagination for large datasets
**UX & mobile** **UX & mobile**
- [ ] Responsive nav (hamburger on mobile) - [x] Responsive nav (hamburger on mobile, CSS + JS toggle)
- [ ] Listing image lightbox - [ ] Listing image lightbox
- [ ] "Load more" / infinite scroll on browse - [ ] "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 - [ ] "Back to results" preserving filter state
- [ ] Listing preview before publish - [ ] Listing preview before publish
@@ -968,5 +1119,7 @@ certbot --nginx -d classifieds.ngodanguyen.tech
--- ---
_End of spec. Phase 15 complete, 58 smoke-test checks green. _End of spec. Phase 16 complete, all smoke-test checks green.
Next: Phase 6 (Admin Backend)._ 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._