Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm check
- run: pnpm build:cloudflare
- name: Apply D1 migrations
run: pnpm db:migrate:remote
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,94 @@ Pushes to `main`, including merged PRs, deploy to `internal.ctcusc.com` after ch
Same-repo PRs build and deploy to `pr-<number>.internal.ctcusc.com` independently of CI checks. Find the preview in the PR's deployment status, updated bot comment, or **Preview** workflow summary. Closing the PR deletes its preview and marks its deployments inactive.

Production and previews share four GitHub Actions secrets: `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, `SITE_PASSWORD` (12+ characters), and `SESSION_SECRET` (32+ characters). The first production deployment registers the custom domain for production and previews. Previews use `workers.dev` until the custom domain is active.

## Project data starter

D1 is bound as `DB` to `ctc-internal` (`dded22aa-c431-40e7-bae4-3d62f0540c16`).
This starter supplies storage and basic reads/writes. Developers implement the
short-code generation, redirect behavior, QR rendering, and attendance UI/workflows.
There is no public redirect or unauthenticated attendance endpoint.

### Database setup

```sh
pnpm db:migrate:local
pnpm dev
```

Local development uses a local D1 database, not production. To create the tables
in the existing remote database, authenticate Wrangler to its owning Cloudflare
account (set `CLOUDFLARE_ACCOUNT_ID` if necessary), then run:

```sh
pnpm db:migrate:remote
```

The production deployment workflow applies migrations before deploying, using the
existing Cloudflare account/token secrets. That token needs D1 edit access.
PR previews intentionally have no D1 binding; add a separate preview database and
apply its migrations before testing data features there. Do not bind previews to
production attendance data.

### Tables

- `short_links`: `code` (primary key), `url`, optional `title`, `created_at`.
A code must be supplied by the developer's app. QR codes can encode the eventual
short URL; image generation and storage are left to the project.
- `attendance_events`: `id` (UUID), `name`, `starts_at` (UTC), optional `location`,
`created_at`.
- `attendance_check_ins`: `event_id` (foreign key), `email`, `name`, `checked_in_at`.
One row per event/email. Repeating a write updates the name and preserves the
original timestamp. Email is normalized to lowercase.

### Endpoints

All endpoints require the existing club login cookie. They are intended for
same-origin pages in this app. All signed-in members share access; this is not
individual identity verification or per-role authorization.

| Method | Path | Read/write |
| ------ | ---------------------------- | ------------------------------------- |
| GET | `/api/links` | List saved links |
| GET | `/api/links/:code` | Read one link |
| POST | `/api/links` | Save `{ code, url, title? }` |
| GET | `/api/events` | List events |
| GET | `/api/events/:id` | Read one event |
| POST | `/api/events` | Save `{ name, starts_at, location? }` |
| GET | `/api/events/:id/attendance` | List check-ins |
| POST | `/api/events/:id/attendance` | Save `{ name, email }` |

List endpoints accept `?limit=50&offset=0` (maximum limit 100). Responses wrap
results in `links`, `link`, `events`, `event`, `attendance`, or `checkIn`.
Dates are ISO 8601 strings. Send writes as `application/json` (maximum 16 KiB).
Errors return `{ error: "..." }`: 400 invalid input, 401 sign-in required,
403 cross-origin write, 404 missing record, 409 duplicate code,
413 oversized body, 415 incorrect content type, or 503 unavailable database.

Example from a signed-in page:

```ts
const response = await fetch("/api/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "workshop", url: "https://example.com" }),
});
if (!response.ok) throw new Error("Could not save link");
const { link } = await response.json();
```

Server components and server actions can call the same authenticated helpers:

```ts
import { createLink, getLink, listLinks } from "@/server/data/links";
import {
createEvent,
getEvent,
listEvents,
checkIn,
listAttendance,
} from "@/server/data/attendance";
```

Keep these imports server-side. Validate project-specific rules in your own code;
the starter validates basic fields and uses parameterized SQL.
17 changes: 11 additions & 6 deletions cloudflare-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
interface CloudflareEnv {
LOGIN_RATE_LIMITER: {
limit(options: { key: string }): Promise<{ success: boolean }>;
};
SITE_PASSWORD: string;
SESSION_SECRET: string;
import type { Database } from "./src/server/data/db";

declare global {
interface CloudflareEnv {
DB: Database;
LOGIN_RATE_LIMITER: {
limit(options: { key: string }): Promise<{ success: boolean }>;
};
SITE_PASSWORD: string;
SESSION_SECRET: string;
}
}
8 changes: 2 additions & 6 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { FlatCompat } from "@eslint/eslintrc";
import nextVitals from "eslint-config-next/core-web-vitals";
import tseslint from "typescript-eslint";

const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});

export default tseslint.config(
{
ignores: [".next/**", ".open-next/**", ".wrangler/**", "next-env.d.ts"],
},
...compat.extends("next/core-web-vitals"),
...nextVitals,
{
files: ["**/*.ts", "**/*.tsx"],
extends: [
Expand Down
25 changes: 25 additions & 0 deletions migrations/0001_links_and_attendance.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TABLE short_links (
code TEXT PRIMARY KEY NOT NULL,
url TEXT NOT NULL,
title TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX short_links_created_at ON short_links(created_at DESC, code);

CREATE TABLE attendance_events (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
starts_at TEXT NOT NULL,
location TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX attendance_events_starts_at ON attendance_events(starts_at DESC, id);

CREATE TABLE attendance_check_ins (
event_id TEXT NOT NULL REFERENCES attendance_events(id),
email TEXT NOT NULL COLLATE NOCASE,
name TEXT NOT NULL,
checked_in_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (event_id, email)
);
CREATE INDEX attendance_check_ins_time ON attendance_check_ins(event_id, checked_in_at, email);
4 changes: 3 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import "./src/env.js";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";

initOpenNextCloudflareForDev();

const isPreviewBuild = process.env.PREVIEW_BUILD === "true";

/** @type {import("next").NextConfig} */
const config = {
poweredByHeader: false,
eslint: { ignoreDuringBuilds: isPreviewBuild },
typescript: { ignoreBuildErrors: isPreviewBuild },
async headers() {
return [
Expand Down
14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"build": "next build --webpack",
"build:cloudflare": "opennextjs-cloudflare build",
"preview": "pnpm build:cloudflare && opennextjs-cloudflare preview",
"deploy": "pnpm build:cloudflare && opennextjs-cloudflare deploy",
Expand All @@ -15,16 +15,19 @@
"test": "vitest run",
"format:check": "prettier --check .",
"format:write": "prettier --write .",
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test"
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test",
"db:migrate:local": "wrangler d1 migrations apply ctc-internal --local",
"db:migrate:remote": "wrangler d1 migrations apply ctc-internal --remote",
"test:worker": "node scripts/smoke-worker.js"
},
"dependencies": {
"@opennextjs/cloudflare": "1.20.6",
"@opennextjs/cloudflare": "1.20.13",
"@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"jose": "^6.2.12",
"lucide-react": "^1.51.0",
"next": "^15.2.3",
"next": "^16.4.2",
"radix-ui": "^1.6.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand All @@ -34,13 +37,12 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"eslint-config-next": "^16.4.2",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
Expand Down
Loading
Loading