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. Register it in the provider catalog and registry as described in Service providers. Its keys are entered in the admin portal, not in environment variables.

Adding a New Queue Worker ​

The queue infrastructure lives in packages/queue/ with seven named queues: activation, payment, store-fulfillment, notification-email, notification-sms, notification-push, and notification-orchestration. Email, SMS and push have independent delivery limits and retries. Orchestration dispatches batch execution, handoff reconciliation, and callback-normalization hooks without a provider rate limiter.

Adding a Notification Producer ​

For a notification event, use notificationOrchestrationService.trigger() from apps/api/src/services/notification-orchestration-service.ts, not a new queue or direct channel enqueue. The event registry in packages/shared/src/schemas/notification-events.ts owns category, supported channels, template key, and parameter validation. Bilingual templates live in apps/api/src/notifications/templates/ and render through renderer.ts.

Derive recipients from trusted domain records. The company audience means active users with an admin membership in that company, not everyone with a B2B role or every company member. Choose a stable occurrence dedupe key so retries find the same notification. A genuinely new occurrence needs a new key.

The trigger persists notification and delivery rows together before enqueueing. It always includes in-app, omits disabled push, and records blocked external channels as suppressed. Queue payloads contain only delivery ID, notification ID, and channel. Keep addresses, rendered content, and eSIM credentials out of Redis. The renderer loads sensitive values only when needed, and injected send functions preserve attachments.

Operational persistence failures make the trigger throw after logging ids only, so a caller does not count a partially written occurrence as collected. The same dedupe key retries those rows on a later run. fullyEnqueued stays a separate flag for rows that committed but whose enqueue did not complete. Invalid event parameters, unsupported event channels, and an empty explicit audience reject before that catch. A best-effort domain caller must catch the rejection and log it without failing its business operation. The acceptance audit is not evidence of provider delivery.

The API wires activation completion, successful payment, admin invitations, company approval, contract readiness, and eSIM QR delivery to this trigger. Do not assume a registered template has an automatic producer. OTP, magic-link sends, and the email plugin test action retain direct delivery.

startup-workers.ts supplies rendering, dispatch, locale, batch execution, reconciliation, and callback dependencies. Calling startAllWorkers() without notification dependencies does not start notification workers. Register any new dependency in the API startup wrapper as well as the package helper, and keep partial-start rollback and shutdown coverage. See worker lifecycle for owned connection cleanup and notification verification for coverage.

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/
dev—NoPersistent — runs until stopped
typecheck^buildYestsc --noEmit after building deps
lint^buildYesbiome check after building deps
db:generate—YesOutput: drizzle/ directory of generated SQL
db:push—NoBlocked guard command
db:push:unsafe—NoLocal throwaway direct schema push only
db:migrate—NoApplied migration, including custom SQL
db:verify—NoLive 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