Skip to content
Merged
58 changes: 58 additions & 0 deletions cloudflare-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,64 @@ This endpoint:
- Returns them as a sorted array
- Caches the result for 1 hour

### "My festival" API (v1)

Aggregate drink ratings and "would recommend" answers, 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 contract is defined in
`proto/` and an OpenAPI spec is 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.

Resources (the device is the record id, so a device has one record per drink):

| Method | Path | Purpose |
| -------- | ------------------------------------------------------------ | ------------------------------- |
| `PATCH` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Upsert a rating (`{value:1-5}`) |
| `GET` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Get a device's rating |
| `DELETE` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Remove a device's rating |
| `GET` | `/v1/festivals/{f}/ratingSummaries/{d}` | Aggregate for one drink |
| `GET` | `/v1/festivals/{f}/ratingSummaries?page_size=&page_token=` | Paginated list of aggregates |

The `recommendations` / `recommendationSummaries` collections mirror this with
a `{wouldRecommend: bool}` body. `PATCH` is an upsert (AIP-134 `allow_missing`);
`DELETE` takes the id in the path with no body (AIP-135) and is `NOT_FOUND` when
absent. Errors use the structured `google.rpc.Status` shape (AIP-193).

```bash
# Upsert a rating, get back the Rating resource
curl -X PATCH https://data.cambeerfestival.app/v1/festivals/cbf2025/drinks/beer-1/ratings/dev-1 \
-H 'Content-Type: application/json' -d '{"value":4}'
# -> {"name":"festivals/cbf2025/drinks/beer-1/ratings/dev-1","value":4,"updateTime":"2026-06-12T20:00:00.000Z"}

# Aggregate for one drink
curl https://data.cambeerfestival.app/v1/festivals/cbf2025/ratingSummaries/beer-1
# -> {"name":"festivals/cbf2025/ratingSummaries/beer-1","ratingCount":3,"averageRating":4.0}

# % would recommend for one drink
curl https://data.cambeerfestival.app/v1/festivals/cbf2025/recommendationSummaries/beer-1
# -> {"name":"...","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-ratings # prints the database_id
# paste the id into wrangler.toml ([[d1_databases]].database_id)
wrangler d1 migrations apply cbf-ratings # applies migrations/*.sql
```

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

### Health Check

- `/health` - Returns `{"status": "ok"}` for monitoring
Expand Down
24 changes: 24 additions & 0 deletions cloudflare-worker/migrations/0001_create_ratings_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Aggregate drink ratings (first step towards online "my festival").
--
-- One row per (bucket, festival, drink, device). The composite primary key
-- gives upsert semantics: a device re-rating a drink updates its existing row
-- rather than inserting a duplicate, so aggregate counts never inflate.
--
-- `bucket` isolates data by environment ('test' vs 'prod') so we can exercise
-- the system end to end without polluting real festival data. `user_id` is
-- reserved for the sign-in upgrade (phase 3) and stays NULL while anonymous.

CREATE TABLE IF NOT EXISTS ratings (
bucket TEXT NOT NULL,
festival_id TEXT NOT NULL,
drink_id TEXT NOT NULL,
device_id TEXT NOT NULL,
user_id TEXT,
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
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_ratings_aggregate
ON ratings (bucket, festival_id, drink_id);
20 changes: 20 additions & 0 deletions cloudflare-worker/migrations/0002_create_recommendations_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- "Would recommend" — a yes/no signal per (bucket, festival, drink, device),
-- separate from the star rating so we can surface a "% would recommend".
--
-- Shares the same upsert/bucket model as the ratings table. `recommend` is
-- stored as 0/1 because SQLite has no native boolean. `user_id` is reserved
-- for the sign-in upgrade and stays NULL while anonymous.

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

CREATE INDEX IF NOT EXISTS idx_recommendations_aggregate
ON recommendations (bucket, festival_id, drink_id);
66 changes: 66 additions & 0 deletions cloudflare-worker/ratings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Ratings resource family for the /v1 API (AIP resource-oriented).
*
* GET /v1/festivals/{f}/drinks/{d}/ratings/{device} get my rating
* PATCH /v1/festivals/{f}/drinks/{d}/ratings/{device} upsert my rating
* DELETE /v1/festivals/{f}/drinks/{d}/ratings/{device} remove my rating
* GET /v1/festivals/{f}/ratingSummaries/{d} aggregate for a drink
* GET /v1/festivals/{f}/ratingSummaries list (paginated)
*
* Backed by the `ratings` table in D1. Writes are local-first on the client.
*/

import { handleResourceFamily, rfc3339 } from "./shared.js";

function round1(value) {
return Math.round(value * 10) / 10;
}

export const RATINGS_FAMILY = {
table: "ratings",
valueColumn: "rating",
writeCollection: "ratings",
summaryCollection: "ratingSummaries",

/** Validate the Rating body { value: 1..5 }. */
parseValue(body) {
if (body === null || typeof body !== "object") {
return {
ok: false,
reason: "INVALID_BODY",
message: "Body must be a JSON object",
};
}
const { value } = body;
if (!Number.isInteger(value) || value < 1 || value > 5) {
return {
ok: false,
reason: "RATING_VALUE_OUT_OF_RANGE",
message: "value must be an integer between 1 and 5",
};
}
return { ok: true, columnValue: value };
},

/** Serialize a Rating resource from a DB row { value, updated_at }. */
serializeResource(name, row) {
return { name, value: row.value, updateTime: rfc3339(row.updated_at) };
},

// Aggregate columns selected for summary single + list queries.
summaryColumns: "COUNT(*) AS agg_count, AVG(rating) AS agg_average",

/** Build RatingSummary fields from an aggregate row. */
summaryFields(row) {
const count = row.agg_count || 0;
return {
ratingCount: count,
averageRating: count ? round1(row.agg_average) : 0,
};
},
};

/** Route a ratings request, or null if the path is not a ratings path. */
export function handleRatings(request, url, env, corsHeaders) {
return handleResourceFamily(request, url, env, corsHeaders, RATINGS_FAMILY);
}
78 changes: 78 additions & 0 deletions cloudflare-worker/recommendations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Recommendations resource family for the /v1 API (AIP resource-oriented).
*
* GET /v1/festivals/{f}/drinks/{d}/recommendations/{device} get my answer
* PATCH /v1/festivals/{f}/drinks/{d}/recommendations/{device} upsert my answer
* DELETE /v1/festivals/{f}/drinks/{d}/recommendations/{device} remove my answer
* GET /v1/festivals/{f}/recommendationSummaries/{d} aggregate for a drink
* GET /v1/festivals/{f}/recommendationSummaries list (paginated)
*
* A yes/no signal separate from the star rating, surfacing a "% would
* recommend". Backed by the `recommendations` table (`recommend` stored as 0/1).
*/

import { handleResourceFamily, rfc3339 } from "./shared.js";

function round2(value) {
return Math.round(value * 100) / 100;
}

export const RECOMMENDATIONS_FAMILY = {
table: "recommendations",
valueColumn: "recommend",
writeCollection: "recommendations",
summaryCollection: "recommendationSummaries",

/** Validate the Recommendation body { wouldRecommend: bool }. */
parseValue(body) {
if (body === null || typeof body !== "object") {
return {
ok: false,
reason: "INVALID_BODY",
message: "Body must be a JSON object",
};
}
const { wouldRecommend } = body;
if (typeof wouldRecommend !== "boolean") {
return {
ok: false,
reason: "RECOMMENDATION_VALUE_INVALID",
message: "wouldRecommend must be a boolean",
};
}
return { ok: true, columnValue: wouldRecommend ? 1 : 0 };
},

/** Serialize a Recommendation resource from a DB row { value, updated_at }. */
serializeResource(name, row) {
return {
name,
wouldRecommend: Boolean(row.value),
updateTime: rfc3339(row.updated_at),
};
},

summaryColumns: "COUNT(*) AS agg_count, SUM(recommend) AS agg_yes",

/** Build RecommendationSummary fields from an aggregate row. */
summaryFields(row) {
const count = row.agg_count || 0;
const yes = row.agg_yes != null ? row.agg_yes : 0;
return {
responseCount: count,
recommendCount: yes,
recommendRate: count ? round2(yes / count) : 0,
};
},
};

/** Route a recommendations request, or null if not a recommendations path. */
export function handleRecommendations(request, url, env, corsHeaders) {
return handleResourceFamily(
request,
url,
env,
corsHeaders,
RECOMMENDATIONS_FAMILY,
);
}
Loading