Developer Guide
This guide explains how to work in the Tours codebase the right way. Tours is a single Next.js 16 (App Router) application written in TypeScript and React 19, talking directly to PostgreSQL via a tagged sql template (raw parameterized SQL — no ORM), with assets on Cloudflare R2. 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 a tagged sql template (raw parameterized 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 + lucide-react |
| Images | sharp (resize/convert to WebP) |
Domain vocabulary vs. database names
Use tour vocabulary in code and UI; the underlying DB table/column names were kept from the original build for stability. Keep this mapping in mind when reading SQL:
| Domain term (UI/code) | Meaning | DB table / column |
|---|---|---|
| tour | a bookable experience | listings |
| operator | seller/host who lists tours | hosts (host_id) |
| departure | a dated option of a tour | units (unit_id) |
| traveler / customer | the buyer | customers / guest_name |
| booking | a reservation | bookings |
Use tour vocabulary everywhere the user sees it; use the real table/column names only inside sql queries.
Local Setup
npm install
cp .env.example .env # then fill in DATABASE_URL, JWT_SECRET, etc.
npm run db:init # bootstrap the schema (core + tours/bookings)
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. A first request to a freshly-added or edited route can take several seconds while Turbopack compiles on demand — that is not a hang.
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/roles/settings + tours, bookings, operators) |
db:seed:core |
Seed core data |
db:reset |
Reset the database to the template state |
gen:openapi |
Regenerate the OpenAPI spec from the route handlers |
addons:bundle |
Regenerate the add-on aggregator indexes |
addons:catalog |
Regenerate docs/add-ons/catalog.json |
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 admin API route checks permissions with
requirePermission(action, resource)before doing work; customer/operator routes resolve the caller first (getCustomerId()/getOperatorId()) and scope every query to the owner — no IDOR. - 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. - Reuse the primitives — modals from
@/components/ui/modal, toasts from@/components/ui/Toast, form fields from@/components/form, loading skeletons from@/components/ui/Skeleton. Do not hand-roll them. - Design tokens only:
ink-*for text/borders/surfaces,brand-*for the primary blue accent,font-displayfor headings. No raw hex in components. - Server-only modules (
db,secrets, anything importing Nodecrypto) must never be imported into client components.
The core / product Layer Split
Tours 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, monetization, and add-ons
src/lib/ Shared utilities (db, auth, secrets, schema bootstrap, helpers)
src/app/ Next.js routes (storefront, /admin, /customer, /operator, /influencer, /api)
src/core/holds the technical machinery: the channels registry and provider types (channels/), runtime adapters (adapters/), and the feature-plugin system (plugins/).src/product/holds what makes this product itself:product.ts(PRODUCT_ID = "tours"), the data-driven admin sidebar (admin-nav.ts), the tours/booking domain (booking/,master/), checkout/payout config, andaddons/— the built-in add-ons. The customer, operator, and influencer portals define their own sidebars inline in their layout folders.
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 = ${tourId}`;
// rawSql helper for parameterized `$1` array 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. Never string-concatenate user input into SQL or build dynamic table/column names from input.
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:
let operatorsReady = false;
export async function ensureOperators(): Promise<void> {
if (operatorsReady) return;
await ensureCustomers(); // dependencies first
await sql`
CREATE TABLE IF NOT EXISTS hosts (
id SERIAL PRIMARY KEY,
customer_id INT UNIQUE NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending',
revenue_share_pct NUMERIC DEFAULT 70,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`;
operatorsReady = true;
}
The tours and booking tables are provisioned the same way by ensureBookingSchema() (src/product/booking/schema.ts) and ensureMasterSchema() (src/product/master/schema.ts). The boolean flag makes the call cheap after the first run; CREATE TABLE IF NOT EXISTS and ALTER TABLE ... ADD COLUMN IF NOT EXISTS make it safe to call repeatedly and on older databases.
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 admin 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 (VENDOR_ADMIN, SUPERADMIN) bypass checks; everyone else is matched against their role's permissions array of "<resource>:<action>" strings. Customer-facing routes resolve the current customer via getCustomerId() (src/lib/customer-auth.ts); operator routes use getOperatorId() (src/lib/operators.ts).
Adding an API Route
API routes live under src/app/api/v1/**/route.ts and export GET / POST / PUT / DELETE handlers. Follow this order: auth/permission → ensure schema → validate → scoped query → mask secrets → respond.
import { NextRequest } from "next/server";
import sql from "@/lib/db";
import { getOperatorId } from "@/lib/operators";
import { ensureBookingSchema } from "@/product/booking/schema";
import { ok, err, serverErr } from "@/lib/api-helpers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function PUT(req: NextRequest) {
try {
const operatorId = await getOperatorId(); // 1) AUTH FIRST
if (!operatorId) return err("unauthorized", 401);
await ensureBookingSchema(); // 2) ENSURE SCHEMA
const b = await req.json();
const name = String(b.name ?? "").trim().slice(0, 160); // 3) VALIDATE + COERCE
if (!name) return err("A tour name is required.");
const price = Math.max(0, Math.round(Number(b.price) || 0)); // money = cents
const [row] = await sql` // 4) SCOPE TO OWNER — no IDOR
UPDATE listings SET name = ${name}, price = ${price}
WHERE id = ${Number(b.id)} AND host_id = ${operatorId}
RETURNING *`;
if (!row) return err("Not found", 404);
return ok(row);
} catch (e) { return serverErr(e); }
}
src/lib/api-helpers.ts provides ok(), err(message, status), and serverErr(e) for consistent JSON responses. When you add a public page powered by a server-only function, also expose a matching GET /api/v1/... (paginated lists return { data, meta }; detail endpoints return { data }), then re-run npm run gen:openapi so the admin-gated API reference stays current.
Adding a Page or Route (UI)
- Create the folder under the right portal: storefront
src/app/(site)/…, adminsrc/app/admin/(panel)/…, customersrc/app/customer/(app)/…, operatorsrc/app/operator/(app)/…, or influencersrc/app/influencer/(app)/…. - Add a
page.tsx. For a data-fetching page, render a layout-shaped skeleton from@/components/ui/Skeletonwhiledata === null— never a bare spinner or blank screen. - Build the UI from the shared primitives (form fields, modals, toasts). Wrap user-facing strings in
t()viauseT()(client) /getServerT()(server). - Use design tokens:
ink-*for neutrals,brand-*for the primary blue accent,font-displayfor headings. Gate multi-column and fixed-px grids behindsm:/lg:/xl:, and wrap tables inoverflow-x-auto. - If the page needs a nav entry, add it to the relevant sidebar (admin:
src/product/admin-nav.ts; portals: their inline sidebar). - If adding/moving a route folder in dev makes neighboring routes 404,
touchthe affectedpage.tsx/route.tsor restartnpm run dev— that is a Turbopack stale-route quirk, not a code bug.
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 |
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) |
Provider credentials (Cloudflare R2, payment gateways, email, etc.) are not kept in .env — admins enter them in Settings → Integrations and they are stored encrypted in the integration_connections table.
Verify Your Work
After any edit, run the type checker and don't claim done until it passes:
npx tsc --noEmit -p tsconfig.json
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com