first commit: منصة Tripz — خطط كاملة + سكافولد باك إند NestJS/Docker

- docs/00-15: دراسة، بنية، محرك تعرفة، تسعير، نموذج استئجار، تكاملات، بيانات، realtime، خطة، devops، لاندنج، مخاطر، اصطلاحات سيرفر، تدفق نشر
- backend/: NestJS 11 على Docker (health + tenants + عزل tenant_id + بادئة tripz_ + Redis DB 3)
- apps/rider, apps/driver, dashboards/admin-web, dashboards/superadmin-web (هياكل)
- sync-to-server.sh + .gitignore

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:22:06 +03:00
co-authored by Claude Opus 4.8
commit 95fea546f5
49 changed files with 1881 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import configuration from './config/configuration';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { HealthModule } from './modules/health/health.module';
import { TenantsModule } from './modules/tenants/tenants.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
type: 'postgres',
host: cfg.get<string>('db.host'),
port: cfg.get<number>('db.port'),
database: cfg.get<string>('db.name'),
username: cfg.get<string>('db.user'),
password: cfg.get<string>('db.password'),
// بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14
entityPrefix: cfg.get<string>('db.tablePrefix'),
synchronize: cfg.get<boolean>('db.synchronize'),
autoLoadEntities: true,
}),
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]),
HealthModule,
TenantsModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface TenantStore {
tenantId: string;
userId?: string;
role?: string;
}
/**
* سياق المستأجر لكل طلب — يُملأ من TenantMiddleware ويُقرأ في المستودعات
* لفرض النطاق على tenant_id تلقائياً (راجع docs/06).
*/
export const tenantContext = new AsyncLocalStorage<TenantStore>();
export function currentTenantId(): string | undefined {
return tenantContext.getStore()?.tenantId;
}
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { currentTenantId } from './tenant.context';
/**
* @Tenant() — يحقن معرّف المستأجر الحالي في معاملات المتحكّم.
*/
export const Tenant = createParamDecorator(
(_data: unknown, _ctx: ExecutionContext): string | undefined =>
currentTenantId(),
);
@@ -0,0 +1,20 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { tenantContext } from './tenant.context';
/**
* يستخرج معرّف المستأجر من الترويسة (أو الـ subdomain لاحقاً) ويضعه في السياق
* لبقية دورة حياة الطلب. لا استعلام يمر بلا tenantId (راجع docs/06).
*/
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const headerTenant =
(req.headers['x-tenant-id'] as string) ||
(req.headers['x-tenant'] as string) ||
'';
// السوبر-آدمن قد لا يحمل مستأجراً محدداً — يُعالَج بحارس منفصل لاحقاً.
tenantContext.run({ tenantId: headerTenant }, () => next());
}
}
@@ -0,0 +1,30 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { currentTenantId } from '../tenant/tenant.context';
/**
* يقيس الاستخدام لكل مستأجر (أساس الفوترة الشهرية — راجع docs/05).
* حالياً يسجّل فقط؛ لاحقاً يكتب في جدول usage / يدفع لطابور BullMQ.
*/
@Injectable()
export class UsageInterceptor implements NestInterceptor {
private readonly logger = new Logger('Usage');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const tenantId = currentTenantId() ?? 'none';
return next.handle().pipe(
tap(() => {
// TODO(P1): عدّ الرحلات المكتملة و GMV بدل تسجيل كل طلب.
this.logger.debug(`tenant=${tenantId} ${req.method} ${req.url}`);
}),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* إعداد مركزي يقرأ متغيرات البيئة مع افتراضات العزل (راجع docs/14).
*/
export default () => ({
env: process.env.NODE_ENV ?? 'development',
apiPort: parseInt(process.env.API_PORT ?? '4010', 10),
db: {
host: process.env.DB_HOST ?? 'postgres',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
name: process.env.DB_NAME ?? 'tripz',
user: process.env.DB_USER ?? 'tripz',
password: process.env.DB_PASSWORD ?? 'change_me_strong',
// بادئة الجداول لعزل Tripz عن باقي البرامج على نفس السيرفر
tablePrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
synchronize: process.env.DB_SYNC === 'true',
},
redis: {
host: process.env.REDIS_HOST ?? 'redis',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
// DB رقم غير الافتراضي 0 لعزل Tripz عن باقي البرامج
db: parseInt(process.env.REDIS_DB ?? '3', 10),
keyPrefix: process.env.REDIS_KEY_PREFIX ?? 'tripz:',
},
queue: {
prefix: process.env.QUEUE_PREFIX ?? 'tripz_',
},
jwt: {
secret: process.env.JWT_SECRET ?? 'change_me_jwt_secret',
expires: process.env.JWT_EXPIRES ?? '15m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES ?? '30d',
},
maps: {
tilesUrl: process.env.MAPS_TILES_URL ?? 'http://martin:3000',
provider: process.env.MAPS_PROVIDER ?? 'antlaq',
},
});
+22
View File
@@ -0,0 +1,22 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { config as loadEnv } from 'dotenv';
loadEnv();
/**
* مصدر بيانات TypeORM — يُستخدم للهجرات و runtime.
* entityPrefix يضمن أن كل جداول Tripz تبدأ بـ tripz_ لعزلها على السيرفر المشترك.
*/
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'postgres',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
database: process.env.DB_NAME ?? 'tripz',
username: process.env.DB_USER ?? 'tripz',
password: process.env.DB_PASSWORD ?? 'change_me_strong',
entityPrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
synchronize: false,
entities: [__dirname + '/../database/entities/*.entity.{ts,js}'],
migrations: [__dirname + '/../database/migrations/*.{ts,js}'],
});
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type TenantMode = 'shared' | 'sovereign';
export type TenantPlan = 'launch' | 'brand' | 'fleet' | 'sovereign';
/**
* المستأجر = مكتب تكسي / أسطول / مشغّل. الجدول الفعلي: tripz_tenants
* (البادئة من entityPrefix). راجع docs/06 و docs/08.
*/
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
slug: string;
@Column({ name: 'country_pack', default: 'jo' })
countryPack: string;
@Column({ type: 'varchar', default: 'launch' })
plan: TenantPlan;
@Column({ type: 'varchar', default: 'shared' })
mode: TenantMode;
@Column({ type: 'jsonb', default: {} })
branding: Record<string, any>;
@Column({ type: 'jsonb', default: {} })
features: Record<string, any>;
@Column({ default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* أول هجرة: جدول المستأجرين + تفعيل PostGIS.
* اسم الجدول الفعلي tripz_tenants (البادئة تُضاف عبر entityPrefix، لكن الهجرة
* تكتب الاسم صراحةً لأنها SQL خام).
*/
export class InitTenants1721145600000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`CREATE EXTENSION IF NOT EXISTS postgis`);
await q.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_tenants (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name varchar NOT NULL,
slug varchar NOT NULL UNIQUE,
country_pack varchar NOT NULL DEFAULT 'jo',
plan varchar NOT NULL DEFAULT 'launch',
mode varchar NOT NULL DEFAULT 'shared',
branding jsonb NOT NULL DEFAULT '{}',
features jsonb NOT NULL DEFAULT '{}',
status varchar NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_tenants`);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const cfg = app.get(ConfigService);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
app.enableCors();
const swagger = new DocumentBuilder()
.setTitle('Tripz API')
.setDescription('منصة نقل ذكي متعددة المستأجرين')
.setVersion('0.1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, swagger));
const port = cfg.get<number>('apiPort') ?? 4010;
await app.listen(port, '0.0.0.0');
Logger.log(`Tripz API on :${port} (docs at /api/docs)`, 'Bootstrap');
}
bootstrap();
@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'tripz-api',
time: new Date().toISOString(),
};
}
}
@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({ controllers: [HealthController] })
export class HealthModule {}
@@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantsService } from './tenants.service';
import { Tenant } from '../../database/entities/tenant.entity';
@ApiTags('tenants')
@Controller()
export class TenantsController {
constructor(private readonly tenants: TenantsService) {}
// للتطبيق: إعداد المستأجر الحالي عند الإقلاع.
@Get('tenant/config/:slug')
config(@Param('slug') slug: string) {
return this.tenants.config(slug);
}
// للسوبر-آدمن: إدارة كل المستأجرين (يُحمى بحارس دور لاحقاً).
@Get('admin/tenants')
list() {
return this.tenants.findAll();
}
@Post('admin/tenants')
create(@Body() body: Partial<Tenant>) {
return this.tenants.create(body);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { TenantsService } from './tenants.service';
import { TenantsController } from './tenants.controller';
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
@Injectable()
export class TenantsService {
constructor(
@InjectRepository(Tenant)
private readonly repo: Repository<Tenant>,
) {}
findAll(): Promise<Tenant[]> {
return this.repo.find();
}
findBySlug(slug: string): Promise<Tenant | null> {
return this.repo.findOne({ where: { slug } });
}
create(data: Partial<Tenant>): Promise<Tenant> {
return this.repo.save(this.repo.create(data));
}
/**
* الإعداد الديناميكي الذي يجلبه تطبيق فلاتر عند الإقلاع (GET /tenant/config).
* كل ما يمكن جعله ديناميكياً (نصوص، ميزات، ألوان، دفع) يأتي من هنا — راجع docs/06.
*/
async config(slug: string) {
const t = await this.findBySlug(slug);
if (!t) return null;
return {
slug: t.slug,
name: t.name,
countryPack: t.countryPack,
plan: t.plan,
branding: t.branding,
features: t.features,
};
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
/**
* نقطة دخول الـ worker (BullMQ) — مهام غير متزامنة: انتهاء صلاحية العروض،
* الإشعارات، التسويات، تجميع usage للفوترة (راجع docs/09).
* حالياً هيكل فقط؛ المعالِجات تُضاف في P1.
*/
async function bootstrap() {
const log = new Logger('Worker');
const prefix = process.env.QUEUE_PREFIX ?? 'tripz_';
const redisDb = process.env.REDIS_DB ?? '3';
log.log(`Tripz worker up. queuePrefix=${prefix} redisDb=${redisDb}`);
// TODO(P1): سجّل معالِجات BullMQ هنا.
setInterval(() => void 0, 1 << 30);
}
bootstrap();