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- Add environment variables in
apps/api/src/config/env.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');
}
}- Add provider registration logic in the
registerProductionSmsProvider()function (or equivalent for other provider types), mappingSMS_PROVIDERenv 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
- 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.