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

Terminal
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 to src/ — prefer it over deep relative paths.
  • Data access goes through the shared sql client in src/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-display for headings. No raw hex in components.
  • Server-only modules (db, secrets, anything importing Node crypto) 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:

TEXT
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, and addons/ — 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:

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:

TS
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:

TS
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:

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.

TS
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)

  1. Create the folder under the right portal: storefront src/app/(site)/…, admin src/app/admin/(panel)/…, customer src/app/customer/(app)/…, operator src/app/operator/(app)/…, or influencer src/app/influencer/(app)/….
  2. Add a page.tsx. For a data-fetching page, render a layout-shaped skeleton from @/components/ui/Skeleton while data === null — never a bare spinner or blank screen.
  3. Build the UI from the shared primitives (form fields, modals, toasts). Wrap user-facing strings in t() via useT() (client) / getServerT() (server).
  4. Use design tokens: ink-* for neutrals, brand-* for the primary blue accent, font-display for headings. Gate multi-column and fixed-px grids behind sm:/lg:/xl:, and wrap tables in overflow-x-auto.
  5. If the page needs a nav entry, add it to the relevant sidebar (admin: src/product/admin-nav.ts; portals: their inline sidebar).
  6. If adding/moving a route folder in dev makes neighboring routes 404, touch the affected page.tsx/route.ts or restart npm 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):

TS
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:

Terminal
npx tsc --noEmit -p tsconfig.json

© CreativeCape Solutions · creative-cape.com · support@creative-cape.com