Skip to content

Development Guide

Code Style

We use Biome for linting and formatting. No Prettier or ESLint — Biome handles both.

bash
pnpm lint          # Check for issues
pnpm format        # Auto-format all files

Key conventions:

  • TypeScript strict mode everywhere
  • Zod schemas for all runtime validation (never trust raw input)
  • Bilingual error messages: messageEn + messageAr
  • No TypeScript enum — use string union types
  • Barrel exports via index.ts in each module

Adding a New Route

  1. Create apps/api/src/routes/my-route.ts
  2. Define the route handler:
ts
import { Hono } from 'hono';
import { AppError } from '@activation-sys/shared';

export const myRoutes = new Hono();

myRoutes.get('/', async (c) => {
  // Implementation
  return c.json({ data: 'ok' });
});
  1. Mount it in apps/api/src/index.ts:
ts
import { myRoutes } from './routes/my-route.js';
app.route('/my-route', myRoutes);
  1. Add the route to the OpenAPI spec in apps/api/src/openapi/spec.ts

Adding a New Zod Schema

Schemas live in packages/shared/src/schemas/:

ts
// packages/shared/src/schemas/my-thing.ts
import { z } from 'zod';

export const createMyThingSchema = z.object({
  name: z.string().min(1).max(255),
  value: z.number().positive(),
});

Export from packages/shared/src/schemas/index.ts and packages/shared/src/index.ts.

Error Handling

All errors extend AppError:

ts
import { AppError } from '@activation-sys/shared';

// Bilingual error with code
throw new AppError(404, 'NOT_FOUND', 'Resource not found', 'المورد غير موجود');

// Or use specific error classes
import { ValidationError, AuthError, NotFoundError } from '@activation-sys/shared';
throw new NotFoundError('User not found', 'المستخدم غير موجود');

Errors are auto-converted to bilingual JSON by errorHandlerMiddleware based on Accept-Language header.

Testing

bash
pnpm test              # Run all tests
pnpm test:watch        # Watch mode (API)
cd packages/database && pnpm test   # Database-specific tests

Tests use Vitest. Each package has its own vitest.config.ts.

Monorepo Package Dependencies

The project is a pnpm workspace monorepo with the following package dependency graph:

@activation-sys/api
├── @activation-sys/database
├── @activation-sys/queue
│   ├── @activation-sys/database
│   └── @activation-sys/shared
└── @activation-sys/shared

@activation-sys/database  (standalone — drizzle-orm, postgres, @supabase/supabase-js)
@activation-sys/shared    (standalone — zod)

Dependency rules:

  • @activation-sys/shared has zero workspace dependencies — it is the leaf package for types, schemas, constants, and errors.
  • @activation-sys/database has zero workspace dependencies — it owns the Drizzle schema, migrations, and DB connection. It does not depend on shared.
  • @activation-sys/queue depends on database (for schema imports in workers) and shared (for queue name constants, job types, retry config).
  • @activation-sys/api depends on all three packages. It is the only app package that directly uses shared, database, and queue.

Important: No circular dependencies are allowed. If shared needs a type from database, extract it into shared instead.

Adding a New Service

Services in apps/api/src/services/ encapsulate business logic and are consumed by route handlers. Follow the class-based pattern used by existing services (AuthService, PaymentService, ActivationService, etc.):

  1. Create apps/api/src/services/my-service.ts:
ts
import { AppError } from '@activation-sys/shared';

export class MyService {
  async myMethod(param: string): Promise<MyResult> {
    // Validate input with Zod schema if needed
    // Call database via @activation-sys/database
    // Call plugins or queues as needed
    // Return typed result
  }
}
  1. Instantiate at the route module level — services are singletons within a route file:
ts
// apps/api/src/routes/my-route.ts
import { MyService } from '../services/my-service.js';

const myService = new MyService();

myRoutes.post('/', async (c) => {
  const result = await myService.myMethod(c.req.valid('json').param);
  return c.json(result);
});
  1. Services may inject dependencies via constructor (see ActivationService which accepts a PluginManager), or create child services internally (see AuthService which creates OtpService, SessionService, etc. in its constructor).

  2. Always throw AppError subclasses for domain errors — the errorHandlerMiddleware catches and formats them as bilingual JSON responses.

Adding a New Plugin

The plugin system supports pluggable providers for SMS, email, payment, and telecom operators. Each provider type has an interface and a manager class.

  1. Create the provider file in apps/api/src/plugins/ implementing the relevant interface:
  • SMS: Implement SmsProvider (providerId, name, sendOtp(), healthCheck())
  • Email: Implement EmailProvider (providerId, name, sendOtp(), healthCheck())
  • Payment: Implement PaymentProvider (name, createPaymentIntent())
  • STC Pay: Implement StcPayProvider (initiateStcPay())
  • Operator: Implement OperatorPlugin (operatorId, name, getPackages(), validateICCID(), initiateActivation(), checkActivationStatus(), verifyIdentity(), checkVerificationStatus(), healthCheck())

Example for a new SMS provider:

ts
// apps/api/src/plugins/my-sms-provider.ts
import type { SmsProvider } from './sms-plugin.js';
import type { SendOtpResult } from '@activation-sys/shared';

export class MySmsProvider implements SmsProvider {
  readonly providerId = 'myprovider';
  readonly name = { ar: 'مزود الرسائل', en: 'My SMS Provider' };

  async sendOtp(phone: string, code: string): Promise<SendOtpResult> {
    // Call provider API
  }

  async healthCheck(): Promise<boolean> {
    // Verify API reachability
  }
}
  1. Register the provider in apps/api/src/plugins/index.ts:
ts
// Import your provider
import { MySmsProvider } from './my-sms-provider.js';

// Register in the appropriate manager singleton
export const smsProviderManager = new SmsProviderManager();
smsProviderManager.register(new MockSmsProvider());
smsProviderManager.register(new MySmsProvider()); // Add this
  1. Add environment variables in apps/api/src/config/env.ts:
ts
// Add to envSchema
SMS_PROVIDER: z.enum(['mock', 'twilio', 'taqnyat', 'myprovider']).default('mock'),
MYPROVIDER_API_KEY: z.string().optional(),

// Add provider-specific refinement in refineProviderEnv()
if (parsed.SMS_PROVIDER === 'myprovider') {
  if (!parsed.MYPROVIDER_API_KEY) {
    throw new Error('MYPROVIDER_API_KEY is required when SMS_PROVIDER=myprovider');
  }
}
  1. Add provider registration logic in the registerProductionSmsProvider() function (or equivalent for other provider types), mapping SMS_PROVIDER env var to the correct provider instantiation.

Adding a New Queue Worker

The queue infrastructure lives in packages/queue/ with three named queues: activation, payment, and notification.

Create a Queue

  1. Create packages/queue/src/queues/my-queue.ts:
ts
import { Queue } from 'bullmq';
import { QUEUE_NAMES, RETRY_CONFIG } from '@activation-sys/shared';
import type { MyJob } from '@activation-sys/shared';
import { createConnection, getQueuePrefix } from '../config/redis.js';

export const myQueue = new Queue<MyJob>(QUEUE_NAMES.myQueue, {
  connection: createConnection(),
  prefix: getQueuePrefix(QUEUE_NAMES.myQueue),
  defaultJobOptions: {
    attempts: RETRY_CONFIG.myQueue.attempts,
    backoff: { type: 'exponential', delay: 1000 },
    removeOnComplete: 100,
    removeOnFail: { age: 86400, count: 1000 },
  },
});

export async function addMyJob(data: MyJob): Promise<string | undefined> {
  const job = await myQueue.add(data.type, data);
  return job.id;
}

export async function getMyJobCounts() {
  return myQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
}
  1. Add queue name and retry config to @activation-sys/shared (packages/shared/src/constants/ and packages/shared/src/types/).

Create a Worker

  1. Create packages/queue/src/workers/my-worker.ts:
ts
import { Worker, type Job } from 'bullmq';
import pino from 'pino';
import { QUEUE_NAMES } from '@activation-sys/shared';
import type { MyJob } from '@activation-sys/shared';
import { createConnection, getQueuePrefix } from '../config/redis.js';

const logger = pino({ name: 'my-worker' });
let worker: Worker<MyJob> | null = null;

export function startMyWorker(): Worker<MyJob> {
  if (worker) return worker;

  worker = new Worker<MyJob>(
    QUEUE_NAMES.myQueue,
    async (job: Job<MyJob>) => {
      logger.info({ jobId: job.id, type: job.data.type }, 'Processing job');
      // Process the job — use db from ../config/database.js
      return { success: true };
    },
    {
      connection: createConnection(),
      prefix: getQueuePrefix(QUEUE_NAMES.myQueue),
      concurrency: 10,
    }
  );

  worker.on('failed', (job, err) => {
    logger.error({ jobId: job?.id, error: err.message }, 'Job failed');
  });

  worker.on('completed', (job) => {
    logger.info({ jobId: job.id }, 'Job completed');
  });

  logger.info('My worker started');
  return worker;
}

export async function stopMyWorker(): Promise<void> {
  if (worker) {
    await worker.close();
    worker = null;
  }
}

Export from Package

  1. Export the queue and worker from packages/queue/src/index.ts:
ts
// Queue
export { myQueue, addMyJob, getMyJobCounts } from './queues/my-queue.js';

// Worker
export { startMyWorker, stopMyWorker } from './workers/my-worker.js';
  1. Add startMyWorker / stopMyWorker to the startAllWorkers / stopAllWorkers functions in packages/queue/src/workers/index.ts.

  2. Call addMyJob() from the API route or service that enqueues the work (e.g., after creating a database record).

Database Migration Workflow

Schema files live in packages/database/src/schema/ (e.g., activation.ts, auth.ts, payment.ts, catalog.ts, b2b.ts, audit.ts, support.ts).

Step-by-step

  1. Edit the schema file in packages/database/src/schema/ — add tables, columns, relations, or indexes using Drizzle schema syntax.
  2. Generate the migration:
bash
pnpm db:generate

This runs drizzle-kit generate scoped to the @activation-sys/database package. Generated SQL files land in packages/database/drizzle/.

  1. Review the generated SQL in packages/database/drizzle/ — verify that the migration produces the expected DDL (CREATE TABLE, ALTER TABLE, etc.).
  2. Run the migration:
bash
pnpm db:migrate

This executes packages/database/src/migrate.ts which applies all pending migrations against the direct database connection (port 5432, not the pooler on port 6543).

  1. Verify with live database checks, type checking, and tests:
bash
pnpm db:verify   # Verify live Supabase RLS, custom migration hashes, triggers, and Realtime
pnpm check      # Turbo typecheck across all packages
pnpm test       # Run all tests

Warnings

  • Do not use pnpm db:push for real databases. The command is blocked by default because direct pushes bypass migration history, custom SQL, RLS policy installation, and Realtime publication setup. For local throwaway prototyping only, use ALLOW_DB_PUSH=1 pnpm db:push:unsafe.
  • RLS policies are not emitted by Drizzle Kit. If your migration adds a new table with user data, add the RLS SQL to a new custom migration under packages/database/drizzle/custom/ and run pnpm db:verify after migration.
  • Use DATABASE_URL_DIRECT for migrations. The drizzle.config.ts uses DATABASE_URL_DIRECT (port 5432) when available, falling back to DATABASE_URL. This is required because Supabase's pgBouncer on port 6543 uses transaction mode, which is incompatible with DDL operations like CREATE TABLE.

TypeScript Configuration

All packages use TypeScript strict mode with the following shared settings:

jsonc
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Key settings explained:

SettingValueWhy
targetES2022Node.js 22 LTS supports all ES2022 features natively
moduleNodeNextRequired for native ESM support with .js extension imports
moduleResolutionNodeNextResolves workspace packages via exports in package.json
stricttrueEnables all strict checks: noImplicitAny, strictNullChecks, etc.
declaration + declarationMaptrueGenerates .d.ts files for cross-package type inference

Path aliases in apps/api/tsconfig.json enable short workspace imports:

json
{
  "paths": {
    "@/*": ["./src/*"],
    "@activation-sys/shared": ["../../packages/shared/src"],
    "@activation-sys/shared/*": ["../../packages/shared/src/*"],
    "@activation-sys/database": ["../../packages/database/src"],
    "@activation-sys/database/*": ["../../packages/database/src/*"],
    "@activation-sys/queue": ["../../packages/queue/src"],
    "@activation-sys/queue/*": ["../../packages/queue/src/*"]
  }
}

The @/* alias resolves to apps/api/src/ for internal imports within the API app. The @activation-sys/* aliases resolve to workspace package source directories using relative paths.

Biome Configuration

Biome replaces ESLint + Prettier with a single tool for both linting and formatting. Configuration lives in the monorepo root biome.json:

json
{
  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
  "organizeImports": { "enabled": true },
  "linter": {
    "enabled": true,
    "rules": { "recommended": true }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 120
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "always"
    }
  },
  "files": {
    "ignore": ["node_modules", "dist", ".next", ".dart_tool", "build", "*.g.dart", "*.freezed.dart"]
  }
}

Key settings:

SettingValuePurpose
organizeImports.enabledtrueAuto-sorts import statements on format
linter.rules.recommendedtrueEnables all recommended lint rules
formatter.indentStylespace2-space indentation (no tabs)
formatter.lineWidth120Wider than Prettier default (80) to fit TypeScript type signatures
javascript.formatter.quoteStylesingleSingle quotes for JS/TS strings
javascript.formatter.semicolonsalwaysAlways require semicolons
files.ignore*.g.dart, *.freezed.dartExcludes Flutter generated files from linting

Commands:

bash
pnpm lint      # biome check . — reports lint and format issues
pnpm format    # biome format --write . — auto-fixes formatting

Turborepo Pipeline

turbo.json defines task dependencies and caching for the monorepo build pipeline:

json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "docs/.vitepress/dist/**"]
    },
    "dev": {
      "persistent": true,
      "cache": false
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "db:generate": {
      "outputs": ["drizzle/**"]
    },
    "db:push": {
      "cache": false
    },
    "db:push:unsafe": {
      "cache": false
    },
    "db:migrate": {
      "cache": false
    },
    "db:verify": {
      "cache": false
    }
  },
  "globalEnv": ["NODE_ENV"]
}

Task details:

TaskDepends OnCachedNotes
build^build (all workspace deps first)YesOutputs: dist/, .next/
devNoPersistent — runs until stopped
typecheck^buildYestsc --noEmit after building deps
lint^buildYesbiome check after building deps
db:generateYesOutput: drizzle/ directory of generated SQL
db:pushNoBlocked guard command
db:push:unsafeNoLocal throwaway direct schema push only
db:migrateNoApplied migration, including custom SQL
db:verifyNoLive Supabase RLS/custom migration/Realtime verification

The ^build dependency syntax means "build all workspace dependencies first." For example, @activation-sys/api's typecheck will not run until @activation-sys/shared, @activation-sys/database, and @activation-sys/queue have all completed their build tasks.

db:generate, db:push, db:push:unsafe, db:migrate, and db:verify are scoped to @activation-sys/database via --filter in the root package.json scripts (for example, turbo run db:generate --filter=@activation-sys/database).

Import Path Patterns

Workspace Package Imports

Import from workspace packages using the @activation-sys/* namespace. These are resolved via paths in tsconfig.json (development) and exports in each package's package.json (build time):

ts
// Types, schemas, constants, and errors
import { AppError, NotFoundError } from '@activation-sys/shared';
import type { ActivationJob, LoginResponse } from '@activation-sys/shared';
import { QUEUE_NAMES, RETRY_CONFIG } from '@activation-sys/shared/constants';
import { createActivationSchema } from '@activation-sys/shared/schemas';

// Database schema and connection
import { users, activations, orders } from '@activation-sys/database';
import { db } from '@activation-sys/database';  // Note: DB connection is in apps/api/src/config/database.ts

// Queue infrastructure
import { addActivationJob, startActivationWorker } from '@activation-sys/queue';

The @activation-sys/shared package supports deep imports via its exports map:

  • @activation-sys/shared — main barrel (errors, types, schemas, constants)
  • @activation-sys/shared/constants — queue names, retry config
  • @activation-sys/shared/types — shared type definitions
  • @activation-sys/shared/schemas — Zod schemas
  • @activation-sys/shared/errors — error classes

Relative Imports Within a Package

Within the same package, use relative imports with .js extension (required for NodeNext module resolution):

ts
// Within apps/api/src/
import { env } from '../config/env.js';
import { pluginManager } from '../plugins/index.js';
import { AuthService } from './auth-service.js';

Path Aliases (API only)

The @/* alias in the API app resolves to apps/api/src/:

ts
// Equivalent to '../../config/env.js' from a deeply nested file
import { env } from '@/config/env.js';

Import Ordering

Biome's organizeImports auto-sorts imports on format. The convention is:

  1. Node.js built-ins (import crypto from 'node:crypto')
  2. External packages (import { Hono } from 'hono')
  3. Workspace packages (import { AppError } from '@activation-sys/shared')
  4. Relative imports (import { env } from '../config/env.js')

Frontend App Boundaries

WorkspaceBoundaryRules
apps/adminAdmin and B2B portalKeep operations/company workflows here; do not mix public landing content into this app.
apps/web-b2cCustomer web activationKeep browser activation UX thin; derive ownership from API auth context, not client fields.
apps/sitePublic landing siteUse Vite + React for public product positioning and route users into B2C web/docs.
apps/mobileNative B2C mobileFlutter app remains the primary native end-user surface once Dart sources are added.

All user-visible frontend copy must have Arabic and English parity. Prefer local content modules per app until a shared i18n package is introduced.

Frontend commands

  • pnpm dev:web-b2c runs @activation-sys/web-b2c on port 3002.
  • pnpm dev:site runs @activation-sys/site on port 3003.
  • pnpm check includes typecheck for the new frontend workspaces through Turbo.
  • pnpm build includes .next/**, dist/**, and .vitepress/dist/** outputs through Turbo.

Internal documentation - Activation System