Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
outputs:
app: ${{ steps.filter.outputs.app }}
functions: ${{ steps.filter.outputs.functions }}
proto: ${{ steps.filter.outputs.proto }}
steps:
- name: Checkout
uses: actions/checkout@v6
Expand All @@ -46,6 +47,8 @@ jobs:
- 'mise.toml'
functions:
- 'functions/**'
proto:
- 'proto/**'

# Check formatting before expensive jobs
fmt:
Expand Down Expand Up @@ -96,6 +99,30 @@ jobs:
mapfile -t SH_FILES < <(find . -name '*.sh' -not -path './.git/*' -not -path './.mise/*' -not -path '*/node_modules/*' | sort)
[[ ${#SH_FILES[@]} -gt 0 ]] && shfmt -d -i 0 -ci "${SH_FILES[@]}"

# Lint proto files and check for breaking changes against main.
# buf breaking runs on PRs only (bufbuild/buf-action skips it on push to main
# where the change is already merged). FILE stability level (configured in
# proto/buf.yaml) is appropriate for v1alpha; switch to WIRE_JSON_COMPATIBLE
# when the API graduates to v1.
proto:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch' ||
needs.changes.outputs.proto == 'true'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # buf breaking needs full history to compare against main

- uses: bufbuild/buf-action@v1
with:
input: proto
push: false
pr_comment: false
breaking_against: "https://github.com/${{ github.repository }}.git#branch=main,subdir=proto"

# Analyze code in parallel with tests so builds can start sooner
analyze:
needs: changes
Expand Down
65 changes: 65 additions & 0 deletions cloudflare-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,71 @@ This endpoint:
- Returns them as a sorted array
- Caches the result for 1 hour

### "My festival" API (v1alpha)

Personal drink reviews and shared aggregates, backed by D1 (SQLite). The first
step towards an online "my festival". The API is resource-oriented following
[Google's AIPs](https://google.aip.dev) — the proto contract is in `proto/`
and an OpenAPI spec can be generated from it (see `proto/README.md`).

Writes are local-first on the client; the server holds the shared aggregate.
Every row and query is scoped by a `bucket` (`test` or `prod`, derived from the
request origin; only `https://cambeerfestival.app` → `prod`) so test traffic
never mixes with production data. A `RATINGS_BUCKET` worker var can pin it.
Comment on lines +95 to +105

The **Review** is a singleton per (caller, drink). Caller identity comes from
the `X-Device-Id` request header (anonymous phase); the device ID never appears
in resource names, so the sign-in upgrade is transparent to clients.

| Method | Path | Purpose |
| -------- | ---------------------------------------------------------------- | -------------------------------- |
| `PATCH` | `/v1alpha/festivals/{f}/drinks/{d}/review` | Upsert review (`starRating` and/or `wouldRecommend`) |
| `GET` | `/v1alpha/festivals/{f}/drinks/{d}/review` | Get caller's review |
| `DELETE` | `/v1alpha/festivals/{f}/drinks/{d}/review` | Remove caller's review |
| `GET` | `/v1alpha/festivals/{f}/reviews` | List caller's reviews at festival |
| `GET` | `/v1alpha/festivals/{f}/reviewSummaries/{d}` | Aggregate for one drink |
| `GET` | `/v1alpha/festivals/{f}/reviewSummaries?page_size=&page_token=` | Paginated list of aggregates |

`PATCH` body: `{ starRating?: 1-5, wouldRecommend?: bool, updateMask?: "starRating,wouldRecommend" }`.
Both signals are independently optional; use `updateMask` to update one without
clearing the other. `DELETE` is `NOT_FOUND` when the review is absent (AIP-135).
Errors use the structured `google.rpc.Status` shape (AIP-193).

```bash
# Upsert a review (star rating + recommendation)
curl -X PATCH https://data.cambeerfestival.app/v1alpha/festivals/cbf2025/drinks/beer-1/review \
-H 'Content-Type: application/json' \
-H 'X-Device-Id: my-device-id' \
-d '{"starRating":4,"wouldRecommend":true}'
# -> {"name":"festivals/cbf2025/drinks/beer-1/review","starRating":4,"wouldRecommend":true,"updateTime":"..."}

# Update only the star rating (keep wouldRecommend as-is)
curl -X PATCH https://data.cambeerfestival.app/v1alpha/festivals/cbf2025/drinks/beer-1/review \
-H 'Content-Type: application/json' \
-H 'X-Device-Id: my-device-id' \
-d '{"starRating":5,"updateMask":"starRating"}'

# Aggregate for one drink
curl https://data.cambeerfestival.app/v1alpha/festivals/cbf2025/reviewSummaries/beer-1
# -> {"name":"...","ratingCount":3,"averageRating":4.0,"responseCount":2,"recommendCount":1,"recommendRate":0.5}
```

#### D1 provisioning (one-time, before first deploy)

The `database_id` in `wrangler.toml` is a placeholder. Local `wrangler dev` and
the vitest test pool use a simulated local D1 and ignore it, so the full test
suite runs with no real database. Before deploying:

```bash
cd cloudflare-worker
wrangler d1 create cbf-myfestival # prints the database_id
# paste the id into wrangler.toml ([[d1_databases]].database_id)
wrangler d1 migrations apply cbf-myfestival # applies migrations/*.sql
```

The deploy `CLOUDFLARE_API_TOKEN` must include **D1: Edit** in addition to
Workers Scripts: Edit. To wipe test data: `DELETE FROM reviews WHERE bucket='test'`.

### Health Check

- `/health` - Returns `{"status": "ok"}` for monitoring
Expand Down
28 changes: 28 additions & 0 deletions cloudflare-worker/migrations/0001_create_reviews_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Unified review table for the /v1alpha "my festival" API.
--
-- One row per (bucket, festival, drink, device). The composite primary key
-- gives upsert semantics so re-reviewing never inflates counts.
--
-- star_rating and recommend are independently nullable: a caller can set
-- a star rating without answering the recommendation question, or vice versa.
--
-- `bucket` isolates data by environment ('test' vs 'prod'), so test traffic
-- never mixes with production data. A `RATINGS_BUCKET` worker var can pin it.
-- `user_id` is reserved for the sign-in upgrade (phase 3) and stays NULL
-- while the API is anonymous.

CREATE TABLE IF NOT EXISTS reviews (
bucket TEXT NOT NULL,
festival_id TEXT NOT NULL,
drink_id TEXT NOT NULL,
device_id TEXT NOT NULL,
user_id TEXT,
star_rating INTEGER CHECK (star_rating BETWEEN 1 AND 5),
recommend INTEGER CHECK (recommend IN (0, 1)),
updated_at INTEGER NOT NULL,
PRIMARY KEY (bucket, festival_id, drink_id, device_id)
);

-- Aggregate reads always filter by (bucket, festival_id) and group by drink_id.
CREATE INDEX IF NOT EXISTS idx_reviews_aggregate
ON reviews (bucket, festival_id, drink_id);
16 changes: 12 additions & 4 deletions cloudflare-worker/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion cloudflare-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"pretest": "cp ../data/festivals.json ./festivals.json",
"test": "vitest run"
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.16.13",
"@cloudflare/workers-types": "^4.20260613.1",
"openapi-typescript": "^7.13.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8",
"wrangler": "^4.88.0"
}
Expand Down
Loading
Loading