Add online rating service design plan - #210
Conversation
Cloudflare Workers + D1 backend with Firebase Anonymous Auth for cross-device identity. Covers API design, Flutter integration, offline sync strategy, and phased implementation plan. https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
- Three environments: dev (PRs), staging (main), production (tags) - Each with separate D1 database and Worker deployment - Phase 0: add tests to existing data proxy Worker (CORS, parsing) - Vitest + Miniflare testing pattern for both Workers - Flutter unit/widget/E2E test coverage for rating features - CI pipeline with test gates before each deployment stage https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
- Custom domain: yes, ratings.cambeerfestival.app for all environments - Min ratings: server-configurable threshold, 0 for testing, tunable for prod - Archival: read-only after festival end date, block new writes - Distribution bar chart: defer to v2 https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
Type 2 decision - start simple with copy in each Worker, refactor to shared module if maintenance burden emerges. https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
Identifies 14 risks across high/medium/low severity: - JWT verification complexity (spike candidate) - Aggregate table consistency (needs recompute safety net) - Firebase Auth web persistence limitations - Missing EnvironmentService.isStaging() method - Festival end-date source undefined for ratings Worker - Batch endpoint caching problem - Rate limiting won't work in-memory - Existing local ratings migration gap - Bundle size impact of firebase_auth on web Includes 5 pre-Phase-1 actions to reduce risk. https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
Existing local ratings are for past festivals. Online ratings will launch with a new festival so everyone starts fresh. No migration needed. https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
Rate limiting: launch with DB constraint only (UNIQUE per user/drink), upgrade to D1 rate-limit table if abuse observed. Batch endpoint: aggregates only (no per-user data), fully cacheable with 60s TTL. Client uses local ratings. Add /mine in Phase 4. Two pre-Phase-1 items remain: JWT spike and festival end-date source. https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
- Festival metadata: embed festivals.json at build time (same as data proxy) - Drink ID validation: trust client, don't validate for launch - Update decision summary table with all resolved decisions - Update write path to validate festivalId against embedded data - Clean up anti-abuse description (DB constraint only, no rate limiting) - Only remaining pre-Phase-1 item: JWT verification spike https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa
There was a problem hiding this comment.
Pull request overview
Adds a detailed design document for introducing an online, cross-device rating system to the Cambridge Beer Festival app, centered on a Cloudflare Workers + D1 backend and Firebase Anonymous Auth for identity.
Changes:
- Introduces an end-to-end architecture and API design for a ratings service (single-drink + batch endpoints).
- Proposes Flutter-side integration (services/models/provider updates) including offline-first sync behavior.
- Outlines deployment environments, migrations, and a phased implementation/testing plan.
| } else if (EnvironmentService.isStaging()) { | ||
| return 'https://ratings-staging.cambeerfestival.app'; | ||
| } else { | ||
| return 'https://ratings-dev.cambeerfestival.app'; | ||
| } |
There was a problem hiding this comment.
The example ratingsApiBaseUrl routing uses EnvironmentService.isStaging(), but EnvironmentService currently only exposes isProduction() and getEnvironmentName() (no isStaging). Update this snippet to match the real API (or explicitly call out that isStaging() must be added before implementing).
| } else if (EnvironmentService.isStaging()) { | |
| return 'https://ratings-staging.cambeerfestival.app'; | |
| } else { | |
| return 'https://ratings-dev.cambeerfestival.app'; | |
| } | |
| } | |
| final envName = EnvironmentService.getEnvironmentName(); | |
| if (envName == 'staging') { | |
| return 'https://ratings-staging.cambeerfestival.app'; | |
| } | |
| // Default to dev / local | |
| return 'https://ratings-dev.cambeerfestival.app'; |
| /// Submit or update a rating, returns updated aggregates | ||
| Future<DrinkRatingResult> ratedrink(String festivalId, String drinkId, int rating); | ||
|
|
There was a problem hiding this comment.
OnlineRatingService method name ratedrink doesn’t follow Dart camelCase / naming conventions used elsewhere (e.g., setRating, getRating). Consider renaming to something like rateDrink / setDrinkRating for consistency and readability.
| /// Queue a rating for sync (called when offline or as fire-and-forget) | ||
| Future<void> queueRating(String festivalId, String drinkId, int rating); | ||
|
|
There was a problem hiding this comment.
RatingSyncService.queueRating is defined here as queueRating(String festivalId, String drinkId, int rating), but later the plan uses null rating to represent deletes. Consider updating the design to support deletes explicitly (e.g., separate queueRemoveRating or a queued action type that can represent remove).
| /// Queue a rating for sync (called when offline or as fire-and-forget) | |
| Future<void> queueRating(String festivalId, String drinkId, int rating); | |
| /// Queue a rating set/update for sync (called when offline or as fire-and-forget) | |
| Future<void> queueRating(String festivalId, String drinkId, int rating); | |
| /// Queue removal of a rating (user cleared their rating) | |
| Future<void> queueRemoveRating(String festivalId, String drinkId); |
| notifyListeners(); // UI updates immediately | ||
|
|
||
| // 2. Sync to server in background | ||
| _ratingSyncService.queueRating(currentFestival.id, drink.id, rating); |
There was a problem hiding this comment.
In the BeerProvider.setRating example, _ratingSyncService.queueRating(...) is called with rating where the variable is int? (null means remove). This won’t match a queueRating(..., int rating) signature—either update the sync API to accept null/removals, or call a dedicated remove-queue method when rating == null.
| _ratingSyncService.queueRating(currentFestival.id, drink.id, rating); | |
| if (rating == null) { | |
| _ratingSyncService.queueRemoveRating(currentFestival.id, drink.id); | |
| } else { | |
| _ratingSyncService.queueRating(currentFestival.id, drink.id, rating); | |
| } |
| Future<String> getIdToken() async { | ||
| final user = _auth.currentUser; | ||
| if (user == null) throw StateError('Not authenticated'); | ||
| return await user.getIdToken() ?? ''; |
There was a problem hiding this comment.
AuthService.getIdToken() shows return await user.getIdToken() ?? '';, but User.getIdToken() returns a non-null Future<String> (so ?? '' is misleading). Returning an empty string on failure would also cause opaque 401s downstream; prefer throwing if token retrieval fails (or forcing a refresh when needed).
| return await user.getIdToken() ?? ''; | |
| return await user.getIdToken(); |
|
|
||
| ## 10. Resolved Questions | ||
|
|
||
| 1. **Custom domain?** Yes — `ratings.cambeerfestival.app` (+ `ratings-staging`, `ratings-dev`). Simplifies CORS since `*.cambeerfestival.app` patterns are already handled. |
There was a problem hiding this comment.
The plan states the custom domain "simplifies CORS since *.cambeerfestival.app patterns are already handled", but the existing Worker’s CORS logic uses an explicit allowlist (e.g., https://cambeerfestival.app, https://staging.cambeerfestival.app) rather than a *.cambeerfestival.app wildcard. Update this wording so it doesn’t imply wildcard support that isn’t present today.
| 1. **Custom domain?** Yes — `ratings.cambeerfestival.app` (+ `ratings-staging`, `ratings-dev`). Simplifies CORS since `*.cambeerfestival.app` patterns are already handled. | |
| 1. **Custom domain?** Yes — `ratings.cambeerfestival.app` (+ `ratings-staging`, `ratings-dev`). Simplifies CORS since it can reuse the existing explicit cambeerfestival.app origin allowlist used by the Worker. |
| | Anti-abuse | DB UNIQUE constraint (one rating per user per drink) | Sufficient for festival scale; upgrade to rate-limit table if needed | | ||
| | Batch endpoint | Aggregates only (no per-user data) | Fully cacheable; client uses local ratings for "my rating" | | ||
| | Festival metadata | Embed `festivals.json` at build time (same as data proxy) | Single source of truth for IDs, end dates, validation | | ||
| | Drink ID validation | Trust client (don't validate) | No incentive to fabricate; orphan rows are harmless | |
There was a problem hiding this comment.
Decision summary says "Trust client (don't validate)" for drink IDs. Even if you don’t validate against the festival drink list, it’s still worth specifying basic constraints (max length/charset) to prevent malicious clients from creating unlimited distinct drink_ids that bloat D1 and batch responses.
| | Drink ID validation | Trust client (don't validate) | No incentive to fabricate; orphan rows are harmless | | |
| | Drink ID validation | Basic format/length validation only | Constrain charset + max length to avoid D1/batch bloat; no lookup against festival drink list | |
| 4. Ratings follow the user across devices | ||
|
|
||
| --- | ||
|
|
There was a problem hiding this comment.
Section numbering jumps from "## 6" to "## 8" (missing section 7). Consider renumbering (or adding a placeholder heading) so future references don’t drift as the plan changes.
| ## 7. Future Work (placeholder) | |
| _This section is reserved for future expansion of the rating service design plan._ |
| | # | Action | Effort | Status | | ||
| |---|--------|--------|--------| |
There was a problem hiding this comment.
The final summary table appears to have a duplicated header block (the | # | Action | Effort | Status | rows repeat twice). Removing the duplicate will make the "What to do before starting Phase 1" section easier to read.
| | # | Action | Effort | Status | | |
| |---|--------|--------|--------| |
Cloudflare Workers + D1 backend with Firebase Anonymous Auth
for cross-device identity. Covers API design, Flutter integration,
offline sync strategy, and phased implementation plan.
https://claude.ai/code/session_01SusUN4s9TQ4AqeoyoGAGYa