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
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