Skip to content

Commit 71f184f

Browse files
authored
Merge pull request #397 from DataScience-GT/dev
Sync: dev to main
2 parents 5cd49d4 + f2cfde7 commit 71f184f

69 files changed

Lines changed: 3603 additions & 2514 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apphosting.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ env:
7171
# Cloud Run region, so reads and writes stay in-region.
7272
- variable: RESUME_BUCKET
7373
value: dsgt-resumes
74-
# Cloud Storage bucket holding bootcamp handouts — slides, notebooks,
74+
# Cloud Storage bucket holding bootcamp workshop ZIPs — notebooks,
7575
# datasets. Same runtime service account, same objectAdmin grant, already
7676
# applied. Separate from the resume bucket on purpose: those are documents
7777
# no member may read, these are files every enrolled member may.

‎docs/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Members looking for a club-language overview should start at [Club project](./cl
1616
| [Security](./operations/security.md) | Auth gates, rate limits, CSP, input scrubbing |
1717
| [Testing](./operations/testing.md) | Vitest, Playwright, and what each suite protects |
1818
| [Resume book](./resume-book.md) | Member uploads, the two staff views, limits, and why files skip tRPC |
19-
| [Bootcamp materials](./bootcamp-materials.md) | Session handouts: who can download one, why the links are proxied, file limits |
19+
| [Bootcamp materials](./bootcamp-materials.md) | Weekly workshop ZIPs: who can download one, publishing, session dates, storage |
2020
| [Glossary](./glossary.md) | Club vs hackathon vocabulary |
2121

2222
## Packages

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Year-round DSGT operations. **Not** scoped to a hackathon row.
4242
- Membership (`member`, `membership_history`) — one paid year per person, defined by start/end dates
4343
- Club events and QR check-in (`event`, `event_check_in`)
4444
- Bootcamp sessions are club events with `bootcamp_week` + `bootcamp_term`
45-
- Bootcamp handouts (`bootcamp_material`) hang off those events; the bytes are in Cloud Storage and downloads are gated on the caller's term — see [Bootcamp materials](./bootcamp-materials.md)
45+
- Bootcamp workshop material (`bootcamp_workshop`, one row per week) joins those events on `(term, week)`; the ZIPs are in Cloud Storage and downloads are gated on the workshop's own term — see [Bootcamp materials](./bootcamp-materials.md)
4646
- Initiatives (`initiative`, `initiative_application`) led by `project_leader`
4747
- Stripe payments and account linking
4848

‎docs/bootcamp-materials.md‎

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,53 @@
11
# Bootcamp materials
22

3-
Staff attach files to a bootcamp session — slides, notebooks, datasets. Everyone enrolled in that bootcamp downloads them from `/club/bootcamp`; nobody else can, including members who did not buy it.
3+
Each week of a bootcamp has one workshop row: a title, a materials ZIP, a solution ZIP and a recording link. Staff manage the rows at `/admin/bootcamp` and publish each one when it's ready. Members download from `/club/bootcamp`. Nobody else can download, including members who didn't buy the bootcamp.
44

5-
Sessions themselves are ordinary events carrying `bootcamp_week` and `bootcamp_term`; see [Architecture](./architecture.md). Nothing here adds a second events stack.
5+
Sessions themselves are ordinary events carrying `bootcamp_week` and `bootcamp_term`; see [Architecture](./architecture.md). A workshop row finds its session by `(term, week)`. There is no foreign key, so deleting a session never deletes the notebooks.
66

77
## Where things are
88

99
| Piece | Path |
1010
| --- | --- |
11-
| Table (metadata only) | `packages/db/src/schemas/bootcamp.ts` (`bootcamp_material`) |
12-
| Read API | `packages/api/src/routers/bootcamp.ts` (`myProgress`, `attendance`) |
13-
| Upload | `sites/mainweb/app/(portal)/api/bootcamp/materials/route.ts` |
14-
| Serve / remove one file | `sites/mainweb/app/(portal)/api/bootcamp/materials/[id]/route.ts` |
15-
| File rules (types, size, names) | `sites/mainweb/lib/bootcamp-materials.ts` |
16-
| Access gate | `sites/mainweb/lib/bootcamp-access.ts` |
11+
| Table (metadata only) | `packages/db/src/schemas/bootcamp.ts` (`bootcamp_workshop`) |
12+
| API | `packages/api/src/routers/bootcamp.ts` (`workshops`, `adminWorkshops`, `createWorkshop`, `updateWorkshop`, `upsertSession`, `setPublished`) |
13+
| Upload / serve / clear one ZIP | `sites/mainweb/app/(portal)/api/bootcamp/materials/[workshopId]/[kind]/route.ts` |
14+
| Delete a workshop | `sites/mainweb/app/(portal)/api/bootcamp/materials/[workshopId]/route.ts` |
15+
| ZIP rules (signature, cap, names) | `sites/mainweb/lib/bootcamp-file.ts` |
16+
| Download rule | `sites/mainweb/lib/bootcamp-route-rules.ts` |
17+
| Caller lookup | `sites/mainweb/lib/bootcamp-access.ts` |
1718
| Bucket client | `sites/mainweb/lib/bootcamp-storage.ts` |
18-
| Both UIs | `sites/mainweb/components/portal/BootcampMaterials.tsx` |
19-
| Member page | `sites/mainweb/app/(portal)/club/bootcamp/page.tsx` |
20-
| Staff page | `sites/mainweb/app/(portal)/admin/bootcamp/page.tsx` (`/admin/bootcamp`) |
19+
| Member table / staff modal | `sites/mainweb/components/portal/BootcampMaterialsTable.tsx`, `BootcampWorkshopModal.tsx` |
2120

22-
## Who can read a file
21+
## Who can download a file
2322

24-
The download route answers for one person and one file:
23+
- Staff can download anything, drafts and past terms included.
24+
- A member can download a **published** file when their `member.bootcamp_term` equals the **workshop's term**, not the current term. Someone who bought the fall bootcamp keeps the fall notebooks in January. They don't get the spring ones.
25+
- Everyone else gets **404, not 403**, so a guessed workshop id can't confirm that a draft exists.
2526

26-
- Staff, always.
27-
- Anyone whose `member.bootcamp_term` equals the **term of the session the file hangs off** — not the current term. Somebody who bought the fall bootcamp keeps the fall notebooks in January. They do not get the spring ones, because their enrolment never said spring.
28-
- Everyone else gets **404, not 403**. Whether a file exists is itself worth nothing to someone who may not read it.
27+
Publishing is its own procedure (`setPublished`), separate from saving. That keeps a row hidden until its uploads have landed, and a failed upload can't leave it visible.
2928

30-
A file is attached to the session event rather than to a week number, which is what makes that check one lookup: the term comes with the row.
29+
## Session dates
3130

32-
## Storage
33-
34-
Files live in `gs://dsgt-bootcamp` under `sessions/<eventId>/<materialId>.<ext>`. Postgres holds the name, size, content type and object key.
35-
36-
A second bucket rather than a prefix inside `dsgt-resumes`: resumes are documents no member may ever read and handouts are files every enrolled member may, and that difference is worth a bucket boundary rather than a path check.
37-
38-
The object is named after the row id, never the uploaded filename. Two weeks can both hand out `slides.pdf`, and a name that arrived from a form has no business being a path. The extension rides along so a `gcloud storage cp` out of the bucket still produces a usable file.
31+
The modal's date and room fields call `upsertSession`, but only when the officer changed one of them. So saving a TBA workshop never touches an event. Term and week come from the workshop row, never from the clock, so editing a past cohort can't reach the current one.
3932

40-
Write order matches the resume path and for the same reason. Upload writes the object **before** the row — a row pointing at nothing is a download that 404s on a file the cohort was told to expect, while an object with no row costs a few cents. Delete reverses it.
33+
- **Setting a date** creates the week's event, or reschedules the existing one. The QR code is minted on insert only, so printed signs keep working. The event's title is left alone on reschedule.
34+
- **Clearing a date** detaches the event (clears `bootcamp_week`/`bootcamp_term`) rather than deleting it, because deleting an event destroys its check-ins.
35+
- A workshop's **week is fixed** once it's created, because moving it would leave its session behind. To renumber a workshop, delete it and create it again.
4136

42-
Credentials are Application Default Credentials; the App Hosting runtime service account has `roles/storage.objectAdmin` on the bucket (see `apphosting.yaml`). Locally, `gcloud auth application-default login`. With `BOOTCAMP_BUCKET` unset, uploads and downloads return 503 with a message rather than failing obscurely.
43-
44-
## Downloads are proxied, not signed
45-
46-
The bucket keeps uniform bucket-level access and public access prevention **enforced**. Every byte is streamed through the Next server, so a copied link is worth nothing to someone who is not signed in, and a member who leaves the bootcamp stops being able to use one they saved.
47-
48-
A signed URL would be cheaper — the bytes would never touch the server — but it is a bearer token for the file that keeps working until it expires, and it would need `roles/iam.serviceAccountTokenCreator` on the runtime service account on top of that. If the traffic ever justifies it, that is the trade being made, not a free upgrade.
37+
## Storage
4938

50-
Streamed rather than buffered: 25MB times a class opening the slides at once is more than an instance has. The cost is that a read failing after the headers are out arrives as a truncated download rather than an error, which is why that failure is logged server-side.
39+
ZIPs live in `gs://dsgt-bootcamp` under `bootcamp/<workshopId>/<kind>.zip`. Postgres holds the object key, file name and size. Objects are keyed by the workshop id, which never changes. A key based on `(term, week)` would let one row's upload overwrite another row's file.
5140

52-
## Limits
41+
Uploads bypass tRPC and stream straight into the bucket. `uploadProcedure` caps at 2MB and superjson base64-encodes the body. The first four bytes are held until the ZIP signature checks out, and the stream stops at 20MB, which leaves headroom under Cloud Run's 32MiB request limit.
5342

54-
| Rule | Value | Why |
55-
| --- | --- | --- |
56-
| Per file | 25MB | A deck with screenshots is 5-15MB; the ceiling is the download path, not storage |
57-
| Kinds | `pdf` `ipynb` `py` `csv` `json` `md` `txt` `zip` `pptx` `xlsx` `docx` `png` `jpg` `jpeg` | Allowlist. The entry **is** the `content-type` served back, so an unlisted kind has none and is refused |
58-
| Uploads | 30 per officer per hour | Token bucket, same limiter as everything else |
43+
Downloads are proxied, never redirected to a signed URL, which would leave the origin and the auth check behind. The bucket keeps uniform bucket-level access and public access prevention **enforced**. The App Hosting runtime service account has `roles/storage.objectAdmin` on it.
5944

60-
`.html` and `.svg` are absent on purpose: both run script from our own origin. The download being `content-disposition: attachment` with `x-content-type-options: nosniff` is the second lock, not the only one.
45+
Credentials are Application Default Credentials. Locally, run `gcloud auth application-default login`. With `BOOTCAMP_BUCKET` unset, the file routes return 503.
6146

62-
Uploaded filenames are sanitised before they reach a header or a disk: path separators, control characters and leading dots come out, the extension stays, because the extension is what picks the content type.
47+
## Known limitation
6348

64-
## Adding files
49+
An upload and a removal of the same file can race, because the object write and the row update can't be one transaction. With a handful of officers, the impact is low.
6550

66-
`/admin/bootcamp` lists this term's sessions under **Handouts**, each with its files and an **Add file** control. Uploads go straight to the route handler rather than through tRPC — superjson base64s the whole body, which no slide deck survives.
51+
## Legacy table
6752

68-
There is no total-storage ceiling and no cleanup job. Deleting a session deletes its rows (`on delete cascade`) but not its objects; a term's worth is a few hundred megabytes, and a sweep that deletes from the bucket on a cascade is a sweep that can delete the wrong thing.
53+
`bootcamp_material` (#390's per-event handouts) is still declared in the schema, but nothing reads or writes it. Deploys run `drizzle-kit push`, and removing the declaration would make push stop at a DROP TABLE prompt. Drop the table in a separate change.

‎docs/operations/environment.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC proced
5656
| Variable | Default / notes |
5757
| --- | --- |
5858
| `RESUME_BUCKET` | Cloud Storage bucket holding resume PDFs (App Hosting sets `dsgt-resumes`). Unset means uploads return 503 rather than failing obscurely. Credentials are ADC — the runtime service account needs `roles/storage.objectAdmin`. See [Resume book](../resume-book.md) |
59-
| `BOOTCAMP_BUCKET` | Cloud Storage bucket holding bootcamp handouts (App Hosting sets `dsgt-bootcamp`). Unset means uploads and downloads return 503. Same ADC and same `roles/storage.objectAdmin` as above. Downloads are proxied and gated on the caller's bootcamp term, so the bucket keeps public access prevention on |
59+
| `BOOTCAMP_BUCKET` | Cloud Storage bucket holding bootcamp workshop ZIPs (App Hosting sets `dsgt-bootcamp`). Unset means uploads and downloads return 503. Same ADC and same `roles/storage.objectAdmin` as above. Downloads are proxied and gated on the caller's bootcamp term, so the bucket keeps public access prevention on |
6060

6161
## Security / proxy
6262

‎docs/operations/testing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Tests use `src/test/create-mock-context.ts` and `.internal-tests/_db-tx-mock.ts`
4343

4444
## Mainweb
4545

46-
`sites/mainweb/lib/*.test.ts` — phone formatting, hackathon slugs, `safe-callback`, bootcamp handout file rules (`bootcamp-materials.test.ts`), the handout access gate (`bootcamp-access.test.ts`, where the null-term trap is held down) and the upload and download handlers (`bootcamp-material-routes.test.ts` — the handlers live under `app/`, which the root script does not glob).
46+
`sites/mainweb/lib/*.test.ts` — phone formatting, hackathon slugs, `safe-callback`, bootcamp ZIP rules (`bootcamp-file.test.ts`), the download gate (`bootcamp-route-rules.test.ts`, where the null-term trap is held down) and the upload and download handlers (`bootcamp-routes.test.ts` — the handlers live under `app/`, which the root script does not glob).
4747

4848
## Hacklytics
4949

‎docs/packages/db.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts
3131
| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` |
3232
| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
3333
| `events.ts` | `event`, `event_check_in` |
34-
| `bootcamp.ts` | `bootcamp_material` (metadata; the files are in Cloud Storage) |
34+
| `bootcamp.ts` | `bootcamp_workshop` (metadata; the ZIPs are in Cloud Storage). `bootcamp_material` is legacy and unused |
3535
| `stripe.ts` | `stripe_payment`, `user_account_link` |
3636
| `security.ts` | `audit_logs` (+ `security_severity` enum) |
3737
| `settings.ts` | `system_settings` (single row, `id = 'default'`) |

‎docs/sites/mainweb.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p
5050
| `/admin/attendees` | Attendee tools |
5151
| `/admin/judging` | Judging admin (sync submissions + assign judges live here) |
5252
| `/admin/initiatives` | Initiative / proposal review |
53-
| `/admin/bootcamp` | Bootcamp attendance grid, and the handouts attached to each session |
53+
| `/admin/bootcamp` | Bootcamp attendance grid and the weekly workshop table |
5454
| `/admin/staff` | Admin users |
5555
| `/admin/analytics` | Overview |
5656
| `/admin/audit` | Audit log |
@@ -65,8 +65,8 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p
6565
| `/api/auth/[...nextauth]` | NextAuth handlers |
6666
| `/api/auth/verify-email` | Email-code verification |
6767
| `/api/webhooks/stripe` | Stripe webhooks |
68-
| `/api/bootcamp/materials` | Attach a file to a bootcamp session (`POST`, staff) |
69-
| `/api/bootcamp/materials/[id]` | Download one handout (`GET`, gated on the caller's bootcamp term) or remove it (`DELETE`, staff) |
68+
| `/api/bootcamp/materials/[workshopId]` | Delete a workshop and its ZIPs (`DELETE`, staff) |
69+
| `/api/bootcamp/materials/[workshopId]/[kind]` | Upload (`POST`, staff), download (`GET`, gated on the workshop's term and publish state) or clear (`DELETE`, staff) the `materials` or `solution` ZIP |
7070

7171
## Client data
7272

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
"test": "vitest run packages/api packages/db sites/mainweb/lib"
1616
},
1717
"dependencies": {
18-
"next": "16.3.0",
18+
"next": "16.3.3",
1919
"typescript": "^6.0.2"
2020
},
2121
"devDependencies": {
2222
"turbo": "^2.9.14",
23-
"vitest": "^4.1.8"
23+
"vitest": "^4.1.11"
2424
},
2525
"pnpm": {
2626
"overrides": {

‎packages/api/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"drizzle-orm": "0.45.2",
3636
"image-size": "2.0.2",
3737
"prom-client": "15.1.3",
38-
"sanitize-html": "2.17.4",
38+
"sanitize-html": "2.17.7",
3939
"stripe": "^22.0.0",
4040
"superjson": "2.2.3",
4141
"zod": "3.25.53"
@@ -44,6 +44,6 @@
4444
"@query/tsconfig": "workspace:*",
4545
"@types/sanitize-html": "^2.16.0",
4646
"typescript": "6.0.2",
47-
"vitest": "^4.1.8"
47+
"vitest": "^4.1.11"
4848
}
4949
}

0 commit comments

Comments
 (0)