This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Programmerbar is a Norwegian student bar/social organization management platform built as a monorepo. The application manages member registration, event/shift assignments, beer/product inventory with tiered pricing, volunteer applications, and admin workflows.
This is a Turborepo monorepo with three workspaces:
- programmerbar-web: Main SvelteKit application (frontend + backend)
- programmerbar-cms: Sanity Studio (headless CMS)
- programmerbar-email-templates: React Email templates (shared package)
pnpm install # Install all dependencies
pnpm dev # Start all workspaces in dev mode
# - Web: http://localhost:5173
# - Sanity: http://localhost:3333pnpm build # Build all workspaces
pnpm preview # Build and preview with Wrangler
cd programmerbar-web && pnpm deploy # Deploy to Cloudflare Workerspnpm lint # Lint all workspaces
pnpm lint:fix # Fix linting issues
pnpm check # Type-check all workspaces (svelte-check)
pnpm format # Format code with Prettier
pnpm format:check # Check formatting without writingpnpm db:generate # Generate migration files from schema changes
pnpm db:migrate:local # Apply migrations to local D1 database
pnpm db:migrate # Apply migrations to production D1 databaseImportant: When you modify database schemas in src/lib/db/schemas/, always run pnpm db:generate to create migration files before applying them.
# In programmerbar-web/
pnpm test:unit # Run Vitest unit tests
pnpm test:integration # Run Playwright e2e tests# In programmerbar-web/
pnpm dlx tsx ./scripts/add-invitation.ts "<email>" # Create user invitation
pnpm dlx tsx ./scripts/seed.ts # Seed local database
pnpm dlx tsx ./scripts/reset.ts # Reset local database
pnpm typegen # Generate Wrangler types
pnpm client:generate # Generate API client# In programmerbar-cms/
pnpm extract # Extract Sanity schema
pnpm typegen # Generate TypeScript types from schema
pnpm deploy # Deploy Sanity StudioFrontend & Backend (Same SvelteKit app):
- SvelteKit 2.46 with Vite 7
- Cloudflare Workers (via @sveltejs/adapter-cloudflare)
- Tailwind CSS 4.1 + Bits UI component library
- Svelte 5 (Runes syntax)
Data Layer:
- Cloudflare D1 (serverless SQLite)
- Drizzle ORM 0.44 with relational queries
- Database migrations via drizzle-kit
- Snake_case conversion enabled in Drizzle config
Authentication:
- Lucia 3.2 (session-based auth)
- Feide OAuth2 provider (Norwegian federated identity service)
- Cookie-based sessions stored in D1
- Session validation in
hooks.server.ts
Content Management:
- Two separate Sanity instances:
- Main Sanity: products, producers, product types
- Echo Sanity: events/happenings (external event system integration)
- GROQ queries for content fetching
- Image handling via @sanity/image-url
Email:
- Cloudflare Email (transactional email service)
- React Email for templates (in shared workspace)
- Shift emails include .ics calendar attachments
Infrastructure (Cloudflare):
- Workers: Serverless runtime
- D1: SQLite database
- R2: Object storage (product images)
- KV: Key-value store (IP bans, status cache)
The application uses dependency injection via hooks.server.ts. Every request handler receives initialized services in event.locals:
event.locals.db; // Drizzle ORM instance
event.locals.auth; // Lucia auth instance
event.locals.eventService; // Event CRUD operations
event.locals.userService; // User CRUD operations
event.locals.shiftService; // Shift management
event.locals.productService; // Product management
// ... and moreServices are class-based (e.g., UserService, EventService) with private #db field and public methods. Each service focuses on one domain.
Location: src/lib/services/
Key tables (all in src/lib/db/schemas/):
user- Members with Feide ID, role (board/normal), training statussession- Lucia session storageevent- Internal events with optional slug for public listingshift- Volunteer shifts within eventsuser_shift- Join table (user assignments to shifts)product- Beverages with three price tiers (ordinaryPrice,studentPrice,internalPrice) + creditsproducer- Breweries/distilleriesproduct_type- Beer categoriesgroup- User groups/teamsusers_groups- Join table (user memberships)notification- User notificationspending_application- Volunteer applicationsreferral- Referral system trackingclaimed_credit- Beer credit claimsinvitation- User invitations
Schema changes workflow:
- Edit schema files in
src/lib/db/schemas/ - Run
pnpm db:generateto create migration - Run
pnpm db:migrate:localto apply locally - Test changes
- Run
pnpm db:migratefor production
Public Routes src/routes/(app)/:
/- Homepage/meny- Beer/product menu/arrangement- Event listings (from Sanity)/arrangement/[slug]- Event details/logg-inn- Login (Feide OAuth redirect)/bli-frivollig- Volunteer signup form/kontakt-oss- Contact form
Protected Routes src/routes/(portal)/portal/:
/portal- User dashboard (requires auth)/portal/profil- User profile & settings/portal/admin/*- Board-only admin panel (requiresuser.role === 'board')/portal/admin/soknader- Pending applications/portal/admin/historikk- Statistics/history/portal/admin/bruker- User management/portal/admin/cms- Product/producer/type management
API Routes src/routes/(app)/:
/slack-command- Slack integration webhook/booking- Event booking endpoint
Route protection is implemented in src/hooks.server.ts with HTTP 307 redirects.
- User clicks login → redirects to
/logg-inn - Feide OAuth redirect → user authorizes
- Callback receives
sub(unique ID) + email from Feide UserServicelooks up byfeideIdor creates new user- Lucia creates session in
sessiontable - Session cookie set → user redirected to
/portal
On every request:
hooks.server.tsextracts session cookie- Validates with Lucia (queries D1
sessiontable) - Populates
event.locals.userandevent.locals.session - Protects routes based on auth status and role
Files:
src/hooks.server.ts- Request lifecycle & DIsrc/lib/auth/lucia.ts- Lucia auth factorysrc/lib/auth/feide.ts- Feide OAuth2 providersrc/routes/(app)/logg-inn/+server.ts- Login endpointsrc/routes/(app)/logg-inn/callback/+server.ts- OAuth callback
Two separate Sanity projects:
-
Main Sanity (
sanityClient):- Products, producers, product types
- Configured in
src/lib/api/sanity/client.ts - Schema in
programmerbar-cms/schemaTypes/
-
Echo Sanity (
echoSanityClient):- Events/happenings from external system
- Separate project ID and dataset
Usage:
// Fetch products from main Sanity
const products = await sanityClient.fetch(groq`*[_type == "product"]`);
// Fetch events from Echo Sanity
const events = await echoSanityClient.fetch(groq`*[_type == "happening"]`);
// Generate image URLs
const imageUrl = imageUrlBuilder.image(product.image).width(400).url();Files:
src/lib/api/sanity/client.ts- Client setupsrc/lib/api/sanity/events.ts- Event fetchingsrc/lib/api/sanity/products.ts- Product fetchingsrc/lib/api/sanity/image.ts- Image URL generation
Email templates are React components in the @programmerbar/email-templates workspace:
ContactUsEmail.tsx- Contact form notificationsInvitationEmail.tsx- User invitationsNewShiftEmail.tsx- Shift assignments (includes .ics attachment)VolunteerRequestEmail.tsx- Volunteer applications
Sending emails:
const html = render(<InvitationEmail email={email} />)
await event.locals.emailService.sendEmail({
from: 'Programmerbar <no-reply@programmer.bar>',
to: email,
subject: 'Invitation',
html
})File: src/lib/services/email.service.tsx
Access Cloudflare resources via event.platform?.env:
event.platform.env.DB; // D1 database
event.platform.env.BUCKET; // R2 bucket (images)
event.platform.env.STATUS_KV; // KV namespace (caching)
event.platform.env.FEIDE_CLIENT_ID; // OAuth credentialsConfiguration: programmerbar-web/wrangler.jsonc
- Database: Use
snake_casein SQL (handled by Drizzle configcasing: 'snake_case') - TypeScript: Use
camelCasefor variables,PascalCasefor types/classes - Services: Name as
domain.service.ts(e.g.,user.service.ts,event.service.ts) - Components: Use
.svelteextension,PascalCasefor component names
- Services accept
Databasetype from$lib/db/drizzle - Use Drizzle's
InferSelectModel<typeof table>for type inference - SvelteKit auto-generates
$typesfor routes (don't import manually)
Use Zod schemas for all form validation:
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
// In +page.server.ts actions:
const result = schema.safeParse(formData);Local development: Copy .env.example to .env in programmerbar-web/
Production: Set via Wrangler secrets or environment variables in Cloudflare dashboard
Required variables:
FEIDE_CLIENT_ID,FEIDE_CLIENT_SECRET- OAuth credentialsPUBLIC_SANITY_PROJECT_ID,PUBLIC_SANITY_DATASET- Main SanityPUBLIC_ECHO_SANITY_PROJECT_ID,PUBLIC_ECHO_SANITY_DATASET- Echo Sanity
- Create schema file in
src/lib/db/schemas/your-table.ts - Export from
src/lib/db/schemas/index.ts - Run
pnpm db:generateto create migration - Run
pnpm db:migrate:localto apply locally - Create corresponding service in
src/lib/services/your-domain.service.ts - Initialize service in
src/hooks.server.ts
Public route:
- Create
src/routes/(app)/your-path/+page.svelte - Add server logic in
+page.server.tsif needed - No auth required by default
Protected route:
- Create
src/routes/(portal)/portal/your-path/+page.svelte - Add server logic in
+page.server.ts - Access user via
const { user } = await parent()
Admin-only route:
- Create
src/routes/(portal)/portal/admin/your-path/+page.svelte - Check
user.role === 'board'in+page.server.tsor component - Redirect if unauthorized
cd programmerbar-email-templates
pnpm dev # Starts React Email dev server on http://localhost:3000Preview and iterate on email templates in the browser.
# Unit test (Vitest)
pnpm test:unit -- path/to/test.test.ts
# E2E test (Playwright)
pnpm test:integration -- tests/your-test.spec.ts| Purpose | File Path |
|---|---|
| Request lifecycle & DI | src/hooks.server.ts |
| Database factory | src/lib/db/drizzle.ts |
| Database schemas | src/lib/db/schemas/ |
| Services | src/lib/services/ |
| Lucia auth setup | src/lib/auth/lucia.ts |
| Feide OAuth | src/lib/auth/feide.ts |
| Sanity integration | src/lib/api/sanity/ |
| Email templates | ../programmerbar-email-templates/templates/ |
| Cloudflare config | wrangler.jsonc |
| Drizzle config | drizzle.config.ts |
Products have three price tiers:
ordinaryPrice- Regular customersstudentPrice- Discounted for studentsinternalPrice- Cost for board members
Products also have a credits field (1-5 rating).
normal- Regular memberboard- Board member (admin access)
Check role in routes:
if (user.role !== "board") {
redirect(307, "/portal");
}Users have training-related fields:
trainingCompleted- BooleantrainingCompletedDate- Timestamp
Managed via UserService.
- Board creates event with shifts in
/portal/admin - Board assigns users to shifts (
user_shiftjoin table) ShiftServicesends email viaEmailService- Email includes .ics file for calendar import
- User receives shift details + calendar invite
Users can refer others. Tracked in referral table with referrerId and referredId.