Developer Guide
This guide explains how to work in the PubCrawl codebase the right way. PubCrawl is a single Next.js 16 (App Router) application written in TypeScript and React 19, talking directly to PostgreSQL over @neondatabase/serverless (raw parameterized SQL — no ORM), with assets on Cloudflare R2. It is a pub crawl & nightlife tour booking marketplace: hosts (guides) list crawls, each crawl runs scheduled departures with a fixed number of spots, and customers book spots and pay a deposit or the full amount. Follow the conventions below to keep the product consistent and resale-ready.
Tech Stack
| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 |
| Language | TypeScript 5 |
| Database | PostgreSQL via @neondatabase/serverless (raw SQL) |
| Auth | Custom JWT (jose / jsonwebtoken) + bcryptjs, HttpOnly cookie |
| Secrets | AES-256-GCM (Node crypto), key from SECRET_KEY |
| Storage | Cloudflare R2 via @aws-sdk/client-s3 |
| Styling | Tailwind CSS 3 + lucide-react |
| Images | sharp (resize/convert to WebP) |
Local Setup
npm install
cp .env.example .env # then fill in DATABASE_URL, JWT_SECRET, etc.
npm run db:init # bootstrap auth + core + marketplace schema
npm run dev # start the dev server (Turbopack)
The dev server runs at http://localhost:3000. On localhost the license is bypassed, so the admin panel opens without activation.
npm scripts
| Script | Purpose |
|---|---|
dev |
Start the Next.js dev server (Turbopack) |
build |
Production build |
start |
Run the production server |
lint |
ESLint |
type-check |
TypeScript check (no emit) |
db:init |
Initialize the full schema (auth + core/vendor tables) |
db:seed:core |
Seed core data |
db:seed:master |
Seed master data (experience types, cuisines, skill levels, …) |
db:seed:hero |
Seed the hero-slider slides |
db:reset |
Reset the database to the template state |
addons:bundle |
Regenerate the add-on aggregator indexes (npm run addons:bundle) |
addons:catalog |
Regenerate the add-on catalog JSON |
gen:openapi |
Regenerate the OpenAPI spec from the route handlers |
Project Conventions
- TypeScript everywhere; the
@/path alias maps tosrc/— prefer it over deep relative paths. - Data access goes through the shared
sqlclient insrc/lib/db.ts(raw parameterized SQL). No ORM. - Every API route checks permissions with
requirePermission(action, resource)before doing work. - Tables are created lazily by idempotent
ensure*()functions called at the top of a route — there is no migration runner. - Secrets are encrypted with
encryptSecret()before storage and masked in API responses; never return a plaintext key. - Server-only modules (
db,secrets, anything importing Nodecrypto) must never be imported into client components.
The core / product Layer Split
PubCrawl is built to power many products from one architecture, so code is split into two layers:
src/core/ Reusable integration infrastructure (channels, adapters, plugins)
src/product/ Product-specific identity, navigation, booking engine, and add-ons
src/lib/ Shared utilities (db, auth, secrets, schema bootstrap, helpers)
src/app/ Next.js routes (storefront (site), /admin, /customer, /host, /influencer, /api)
src/core/holds the technical machinery: the channels registry and provider types (channels/), runtime adapters (adapters/), and the feature-plugin system (plugins/types.ts,plugins/state.ts,plugins/registry.ts,plugins/hooks.ts).src/product/holds what makes this product itself:product.ts(PRODUCT_ID = "pubcrawl"),experience-types.ts(theclass/tour/diningworlds that share one booking engine — PubCrawl rides theclass-shaped seat/spot mechanic), the data-drivenadmin-nav.ts/admin-routes.ts, thebooking/engine (schema, create, cancel, settle),master/data, checkout/payout config, andaddons/— the built-in integration add-ons.
Add-ons register into core through generated aggregator indexes (ADDON_CHANNELS, ADDON_FEATURES, server/client adapters). See the Add-on and Plugin Development guides.
Data Access (raw SQL + ensure-schema)
The single database client lives in src/lib/db.ts:
import sql, { rawSql } from "@/lib/db";
// Tagged-template form (preferred) — values are parameterized, not interpolated.
const rows = await sql`SELECT * FROM listings WHERE id = ${listingId}`;
// Method form for dynamic SQL.
const r = await sql.query("SELECT * FROM listings WHERE id = $1", [listingId]);
// rawSql helper for parameterized non-template queries.
const list = await rawSql("SELECT * FROM listings WHERE status = $1", [
"published",
]);
type Row = Record<string, unknown> is also exported. The client initializes lazily on first use so next build doesn't fail when DATABASE_URL is absent.
The ensure-schema pattern
There is no migration tool. Each table is created by an idempotent, guarded ensure*() function that's called at the start of any route that touches it. Example from src/lib/hosts.ts:
let ready = false;
export async function ensureHosts(): Promise<void> {
if (ready) return;
await ensureCustomers(); // dependencies first
await sql`
CREATE TABLE IF NOT EXISTS hosts (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL UNIQUE REFERENCES customers(id) ON DELETE CASCADE,
business_name VARCHAR(200) NOT NULL DEFAULT '',
slug VARCHAR(220) UNIQUE NOT NULL,
commission_override NUMERIC(5,2), -- NULL = use platform default / plan
status VARCHAR(20) DEFAULT 'active', -- pending | active | suspended
min_payout_cents INTEGER DEFAULT 5000,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`;
ready = true;
}
The boolean flag makes the call cheap after the first run; CREATE TABLE IF NOT EXISTS and ADD COLUMN IF NOT EXISTS make it safe to call repeatedly and on older databases. The booking engine's ensureBookingSchema() (src/product/booking/schema.ts) provisions coupons, bookings, and the related tables the same way. The auth schema (src/lib/auth-schema.ts) follows the same pattern and seeds the first admin user from ADMIN_EMAIL / ADMIN_PASSWORD.
Auth Helpers & Sessions
JWTs are signed and verified in src/lib/auth.ts using jose (HS256) with the secret from JWT_SECRET, stored in the HttpOnly cookie admin_token:
export async function signToken(payload: JWTPayload): Promise<string>; // 7d default
export async function verifyToken(token: string): Promise<JWTPayload | null>;
export async function getSession(): Promise<JWTPayload | null>; // reads the cookie
Authorization is enforced per route by requirePermission() in src/lib/require-permission.ts:
export type PermAction = "read" | "create" | "update" | "delete";
export async function requirePermission(
action: PermAction,
resource: string,
): Promise<Response | null>; // returns a 403 Response, or null when allowed
Super roles (e.g. VENDOR_ADMIN, SUPERADMIN) bypass checks; everyone else is matched against their role's permissions array of "<resource>:<action>" strings (e.g. listings:read, bookings:update). Authorized mutations also emit an admin.action core event so an audit plugin can log them — without ever blocking the route. Customer-facing routes resolve the current customer via getCustomerId() (src/lib/customer-auth.ts); host routes use getHostId() and influencer routes their own auth helper.
Adding an API Route
API routes live under src/app/api/... and export GET / POST / PUT / DELETE handlers. Follow this order: permission → ensure schema → query → mask secrets → respond.
import { NextRequest } from "next/server";
import sql from "@/lib/db";
import { requirePermission } from "@/lib/require-permission";
import { ensureBookingSchema } from "@/product/booking/schema";
import { ok, serverErr } from "@/lib/api-helpers";
export async function GET() {
const denied = await requirePermission("read", "bookings");
if (denied) return denied;
try {
await ensureBookingSchema();
const rows = await sql`SELECT * FROM bookings ORDER BY created_at DESC`;
return ok(rows);
} catch (e) {
return serverErr(e);
}
}
src/lib/api-helpers.ts provides ok(), err(message, status), and serverErr(e) for consistent JSON responses.
Secrets / Encryption
src/lib/secrets.ts provides AES-256-GCM helpers. The key is the SHA-256 hash of SECRET_KEY (falling back to JWT_SECRET):
encryptSecret(plain: string): string; // "enc:v1:" + base64(iv || tag || ciphertext)
decryptSecret(value: string): string;
encryptJson(obj: unknown): string;
decryptJson<T>(value: unknown): T;
When a settings field holds a secret (an API key, a payment secret), encrypt it on write and return a mask (••••••••) on read so plaintext never leaves the server.
Environment Variables
| Variable | Purpose |
|---|---|
DATABASE_URL |
Neon PostgreSQL connection string (required) |
JWT_SECRET |
HMAC secret for signing JWTs (required) |
JWT_EXPIRES_IN |
Token lifetime (default 7d) |
SECRET_KEY |
Source for the AES-256-GCM key (falls back to JWT_SECRET) |
NEXT_PUBLIC_SITE_URL |
Public base URL |
ADMIN_EMAIL / ADMIN_PASSWORD |
First-run admin bootstrap |
NODE_ENV |
production enables secure cookies |
LICENSE_SERVER_URL / LICENSE_PUBLIC_KEY |
License verification (optional overrides) |
NOTIFY_EMAIL / NOTIFY_EMAIL sender |
Notification sender |
Provider credentials (Cloudflare R2, Stripe, SendGrid, etc.) are not kept in .env — admins enter them in Settings → Integrations and they are stored encrypted in the database.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com