Development Guide
Code Style
We use Biome for linting and formatting. No Prettier or ESLint — Biome handles both.
pnpm lint # Check for issues
pnpm format # Auto-format all filesKey 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.tsin each module
Adding a New Route
- Create
apps/api/src/routes/my-route.ts - Define the route handler:
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' });
});- Mount it in
apps/api/src/index.ts:
import { myRoutes } from './routes/my-route.js';
app.route('/my-route', myRoutes);- 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/:
// 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:
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
pnpm test # Run all tests
pnpm test:watch # Watch mode (API)
cd packages/database && pnpm test # Database-specific testsTests 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/sharedhas zero workspace dependencies — it is the leaf package for types, schemas, constants, and errors.@activation-sys/databasehas zero workspace dependencies — it owns the Drizzle schema, migrations, and DB connection. It does not depend onshared.@activation-sys/queuedepends ondatabase(for schema imports in workers) andshared(for queue name constants, job types, retry config).@activation-sys/apidepends on all three packages. It is the only app package that directly usesshared,database, andqueue.
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.):
- Create
apps/api/src/services/my-service.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
}
}- Instantiate at the route module level — services are singletons within a route file:
// 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);
});Services may inject dependencies via constructor (see
ActivationServicewhich accepts aPluginManager), or create child services internally (seeAuthServicewhich createsOtpService,SessionService, etc. in its constructor).Always throw
AppErrorsubclasses for domain errors — theerrorHandlerMiddlewarecatches 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.
- 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:
// 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
}
}- Register the provider in
apps/api/src/plugins/index.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- 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
- Create
packages/queue/src/queues/my-queue.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');
}- Add queue name and retry config to
@activation-sys/shared(packages/shared/src/constants/andpackages/shared/src/types/).
Create a Worker
- Create
packages/queue/src/workers/my-worker.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
- Export the queue and worker from
packages/queue/src/index.ts:
// Queue
export { myQueue, addMyJob, getMyJobCounts } from './queues/my-queue.js';
// Worker
export { startMyWorker, stopMyWorker } from './workers/my-worker.js';Add
startMyWorker/stopMyWorkerto thestartAllWorkers/stopAllWorkersfunctions inpackages/queue/src/workers/index.ts.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
- Edit the schema file in
packages/database/src/schema/— add tables, columns, relations, or indexes using Drizzle schema syntax. - Generate the migration:
pnpm db:generateThis runs drizzle-kit generate scoped to the @activation-sys/database package. Generated SQL files land in packages/database/drizzle/.
- Review the generated SQL in
packages/database/drizzle/— verify that the migration produces the expected DDL (CREATE TABLE, ALTER TABLE, etc.). - Run the migration:
pnpm db:migrateThis executes packages/database/src/migrate.ts which applies all pending migrations against the direct database connection (port 5432, not the pooler on port 6543).
- Verify with live database checks, type checking, and tests:
pnpm db:verify # Verify live Supabase RLS, custom migration hashes, triggers, and Realtime
pnpm check # Turbo typecheck across all packages
pnpm test # Run all testsWarnings
- Do not use
pnpm db:pushfor 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, useALLOW_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 runpnpm db:verifyafter migration. - Use
DATABASE_URL_DIRECTfor migrations. Thedrizzle.config.tsusesDATABASE_URL_DIRECT(port 5432) when available, falling back toDATABASE_URL. This is required because Supabase's pgBouncer on port 6543 uses transaction mode, which is incompatible with DDL operations likeCREATE TABLE.
TypeScript Configuration
All packages use TypeScript strict mode with the following shared settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}Key settings explained:
| Setting | Value | Why |
|---|---|---|
target | ES2022 | Node.js 22 LTS supports all ES2022 features natively |
module | NodeNext | Required for native ESM support with .js extension imports |
moduleResolution | NodeNext | Resolves workspace packages via exports in package.json |
strict | true | Enables all strict checks: noImplicitAny, strictNullChecks, etc. |
declaration + declarationMap | true | Generates .d.ts files for cross-package type inference |
Path aliases in apps/api/tsconfig.json enable short workspace imports:
{
"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:
{
"$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:
| Setting | Value | Purpose |
|---|---|---|
organizeImports.enabled | true | Auto-sorts import statements on format |
linter.rules.recommended | true | Enables all recommended lint rules |
formatter.indentStyle | space | 2-space indentation (no tabs) |
formatter.lineWidth | 120 | Wider than Prettier default (80) to fit TypeScript type signatures |
javascript.formatter.quoteStyle | single | Single quotes for JS/TS strings |
javascript.formatter.semicolons | always | Always require semicolons |
files.ignore | *.g.dart, *.freezed.dart | Excludes Flutter generated files from linting |
Commands:
pnpm lint # biome check . — reports lint and format issues
pnpm format # biome format --write . — auto-fixes formattingTurborepo Pipeline
turbo.json defines task dependencies and caching for the monorepo build pipeline:
{
"$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:
| Task | Depends On | Cached | Notes |
|---|---|---|---|
build | ^build (all workspace deps first) | Yes | Outputs: dist/, .next/ |
dev | — | No | Persistent — runs until stopped |
typecheck | ^build | Yes | tsc --noEmit after building deps |
lint | ^build | Yes | biome check after building deps |
db:generate | — | Yes | Output: drizzle/ directory of generated SQL |
db:push | — | No | Blocked guard command |
db:push:unsafe | — | No | Local throwaway direct schema push only |
db:migrate | — | No | Applied migration, including custom SQL |
db:verify | — | No | Live 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):
// 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):
// 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/:
// 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:
- Node.js built-ins (
import crypto from 'node:crypto') - External packages (
import { Hono } from 'hono') - Workspace packages (
import { AppError } from '@activation-sys/shared') - Relative imports (
import { env } from '../config/env.js')
Frontend App Boundaries
| Workspace | Boundary | Rules |
|---|---|---|
apps/admin | Admin and B2B portal | Keep operations/company workflows here; do not mix public landing content into this app. |
apps/web-b2c | Customer web activation | Keep browser activation UX thin; derive ownership from API auth context, not client fields. |
apps/site | Public landing site | Use Vite + React for public product positioning and route users into B2C web/docs. |
apps/mobile | Native B2C mobile | Flutter 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-b2cruns@activation-sys/web-b2con port 3002.pnpm dev:siteruns@activation-sys/siteon port 3003.pnpm checkincludestypecheckfor the new frontend workspaces through Turbo.pnpm buildincludes.next/**,dist/**, and.vitepress/dist/**outputs through Turbo.