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:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
test
|
||||
**/*.spec.ts
|
||||
@@ -0,0 +1,32 @@
|
||||
# ==== Tripz backend env (شغّل: cp .env.example .env) ====
|
||||
# ملاحظة: السيرفر مشترك — كل شيء معزول ببادئة tripz و Redis DB غير الافتراضي (راجع docs/14)
|
||||
|
||||
NODE_ENV=development
|
||||
API_PORT=4010 # منفذ غير شائع لتفادي التصادم على السيرفر المشترك
|
||||
|
||||
# ---- PostgreSQL (قاعدة مستقلة + بادئة جداول) ----
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=tripz
|
||||
DB_USER=tripz
|
||||
DB_PASSWORD=change_me_strong
|
||||
DB_TABLE_PREFIX=tripz_
|
||||
DB_SYNC=false # لا synchronize في الإنتاج — هجرات فقط
|
||||
|
||||
# ---- Redis (DB رقم 3 غير الافتراضي 0 + بادئة مفاتيح) ----
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=3
|
||||
REDIS_KEY_PREFIX=tripz:
|
||||
|
||||
# ---- BullMQ ----
|
||||
QUEUE_PREFIX=tripz_
|
||||
|
||||
# ---- Auth ----
|
||||
JWT_SECRET=change_me_jwt_secret
|
||||
JWT_EXPIRES=15m
|
||||
JWT_REFRESH_EXPIRES=30d
|
||||
|
||||
# ---- Maps (انطلق) ----
|
||||
MAPS_TILES_URL=http://martin:3000
|
||||
MAPS_PROVIDER=antlaq
|
||||
@@ -0,0 +1,21 @@
|
||||
# ==== Tripz backend — multi-stage Docker build ====
|
||||
# البناء والتشغيل كله داخل Docker (لا تثبيت محلي على الماك)
|
||||
|
||||
# ---- builder ----
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- runtime ----
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 4010
|
||||
# نقطة الدخول الافتراضية: الـ API. الـ worker يُشغَّل بأمر مختلف من compose.
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Tripz Backend — NestJS (Docker فقط)
|
||||
|
||||
> **لا تثبيت محلي على الماك.** الكود يُكتب محلياً، والبناء والتشغيل والهجرات **كلها داخل Docker على السيرفر**.
|
||||
|
||||
## التشغيل على السيرفر
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env # ثم عدّل كلمات المرور والأسرار
|
||||
docker compose up -d --build # يبني ويشغّل: postgres/postgis, redis, martin, api, worker
|
||||
```
|
||||
|
||||
## الهجرات (داخل حاوية الـ api)
|
||||
```bash
|
||||
docker compose exec api npm run migration:run
|
||||
```
|
||||
|
||||
## فحص سريع
|
||||
```bash
|
||||
curl http://<server>:4010/api/health # {"status":"ok",...}
|
||||
# توثيق Swagger: http://<server>:4010/api/docs
|
||||
```
|
||||
|
||||
## العزل على السيرفر المشترك (راجع docs/14)
|
||||
- Postgres: قاعدة `tripz`، بادئة جداول `tripz_` (مثال: `tripz_tenants`).
|
||||
- Redis: **DB رقم 3** غير الافتراضي + بادئة مفاتيح `tripz:`.
|
||||
- منافذ مضيف غير قياسية (API 4010، Postgres 55432) لتفادي التصادم.
|
||||
- أسماء حاويات/شبكة/فوليوم ببادئة `tripz-`.
|
||||
|
||||
## البنية الحالية (P0)
|
||||
```
|
||||
src/
|
||||
├── main.ts # bootstrap API (+ Swagger)
|
||||
├── worker.ts # نقطة دخول BullMQ (هيكل)
|
||||
├── app.module.ts # ConfigModule + TypeORM(entityPrefix) + Throttler + TenantMiddleware
|
||||
├── config/ # configuration.ts + data-source.ts (هجرات)
|
||||
├── common/
|
||||
│ ├── tenant/ # context (AsyncLocalStorage) + middleware + @Tenant()
|
||||
│ └── usage/ # UsageInterceptor (أساس الفوترة)
|
||||
├── database/
|
||||
│ ├── entities/ # tenant.entity.ts
|
||||
│ └── migrations/ # InitTenants
|
||||
└── modules/
|
||||
├── health/ # GET /api/health
|
||||
└── tenants/ # GET /api/tenant/config/:slug ، admin/tenants
|
||||
```
|
||||
|
||||
## التالي (P1)
|
||||
users · auth(OTP) · drivers · trips(★ آلة حالة) · matching(Redis GEO) · tariff · realtime(Socket.IO) · maps(وكيل انطلق).
|
||||
@@ -0,0 +1,74 @@
|
||||
# ==== Tripz stack — كل شيء معزول ببادئة tripz على السيرفر المشترك ====
|
||||
# التشغيل على السيرفر: docker compose up -d --build
|
||||
# أسماء الحاويات/الشبكة/الفوليوم كلها ببادئة tripz لتفادي التصادم.
|
||||
|
||||
name: tripz
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
container_name: tripz-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-tripz}
|
||||
POSTGRES_USER: ${DB_USER:-tripz}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me_strong}
|
||||
volumes:
|
||||
- tripz-pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_EXPOSE_PORT:-55432}:5432" # منفذ مضيف غير قياسي (عزل)
|
||||
networks: [tripz-net]
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tripz-redis
|
||||
restart: unless-stopped
|
||||
# ملاحظة: نعزل منطقياً بـ DB index 3 + keyPrefix، لا بنسخة redis منفصلة.
|
||||
command: ["redis-server", "--save", "60", "1"]
|
||||
volumes:
|
||||
- tripz-redisdata:/data
|
||||
networks: [tripz-net]
|
||||
|
||||
martin:
|
||||
image: ghcr.io/maplibre/martin:latest
|
||||
container_name: tripz-martin
|
||||
restart: unless-stopped
|
||||
# خادم بلاطات انطلق (يُهيّأ لاحقاً بمصدر البيانات). موجود من الآن للربط.
|
||||
environment:
|
||||
DATABASE_URL: postgres://${DB_USER:-tripz}:${DB_PASSWORD:-change_me_strong}@postgres:5432/${DB_NAME:-tripz}
|
||||
depends_on: [postgres]
|
||||
networks: [tripz-net]
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: tripz-api
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
REDIS_HOST: redis
|
||||
command: ["node", "dist/main.js"]
|
||||
depends_on: [postgres, redis]
|
||||
ports:
|
||||
- "${API_PORT:-4010}:4010"
|
||||
networks: [tripz-net]
|
||||
|
||||
worker:
|
||||
build: .
|
||||
container_name: tripz-worker
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
REDIS_HOST: redis
|
||||
command: ["node", "dist/worker.js"]
|
||||
depends_on: [postgres, redis]
|
||||
networks: [tripz-net]
|
||||
|
||||
volumes:
|
||||
tripz-pgdata:
|
||||
tripz-redisdata:
|
||||
|
||||
networks:
|
||||
tripz-net:
|
||||
name: tripz-net
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "tripz-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Tripz — multi-tenant ride-hailing platform API (NestJS)",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"worker": "node dist/worker.js",
|
||||
"typeorm": "typeorm-ts-node-commonjs -d src/config/data-source.ts",
|
||||
"migration:generate": "npm run typeorm -- migration:generate",
|
||||
"migration:run": "npm run typeorm -- migration:run",
|
||||
"migration:revert": "npm run typeorm -- migration:revert",
|
||||
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.2.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"ioredis": "^5.4.1",
|
||||
"pg": "^8.12.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.7.5",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user