chore: أرشفة باك إند NestJS إلى backend-archive

قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل
إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com.

backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى)
+ README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs
  هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات
  الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات).

مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 04:54:35 +03:00
co-authored by Claude Opus 5
parent 75b80bd299
commit fff76c949f
307 changed files with 47 additions and 0 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
# ملاحظة: ملفات *.spec.ts تدخل السياق عمداً — الاختبارات تعمل داخل مرحلة
# builder على السيرفر (راجع docs/15). tsconfig.build.json يستثنيها من dist،
# فلا تصل صورة التشغيل.
-113
View File
@@ -1,113 +0,0 @@
# ==== Tripz backend env (شغّل: cp .env.example .env) ====
# ملاحظة: السيرفر مشترك — كل شيء معزول ببادئة tripz و Redis DB غير الافتراضي (راجع docs/14)
NODE_ENV=development
API_PORT=4010 # يُنشر على 127.0.0.1 فقط — Nginx (CloudPanel) ينهي الـTLS. راجع docs/20
DB_EXPOSE_PORT=55432 # 127.0.0.1 فقط. للوصول من الماك: نفق SSH (docs/20 §4)
# ---- 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
# ---- تشفير الحقول الحساسة at-rest (AES-256-GCM) ----
# 32 بايت بصيغة hex (64 محرف). ولّده: openssl rand -hex 32
# إلزامي للإنتاج — بدونه يُستخدم مفتاح تطوير غير آمن
ENCRYPTION_KEY=
JWT_EXPIRES=15m
JWT_REFRESH_EXPIRES=30d
# ---- Maps (انطلق / map-saas) ----
MAPS_TILES_URL=http://martin:3000
MAPS_PROVIDER=antlaq
MAPS_BASE_URL=https://map-saas.intaleqapp.com
# مفاتيح انطلق لكل دولة (ضع القيم الحقيقية في .env على السيرفر — لا تُرفع لـ git)
MAPS_API_KEY_JO=
MAPS_API_KEY_SY=
MAPS_PLACES_API_KEY=
# ---- OTP: توجيه متعدد المزوّدين حسب دولة المستأجر (docs/17 — D5) ----
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي
OTP_DEV_MODE=true
OTP_SMS_TEMPLATE=Your Tripz code is {code}
# سوريا/الأردن — Nabeh (واتساب)
NABEH_BASE_URL=https://nabeh.intaleqapp.com
NABEH_EMAIL=
NABEH_PASSWORD=
NABEH_OTP_TYPE=text
# مصر — Kazumi (SMS)، مع failover تلقائي إلى Nabeh لو فشل
SMS_USERNAME=
SMS_PASSWORD_EGYPT=
SMS_SENDER=
# ---- FCM push (ملف الـ JSON الخاص بـ Service Account) ----
GOOGLE_APPLICATION_CREDENTIALS=./firebase-service-account.json
# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ----
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
# ---- PayMob (مصر) — احتياط تطوير فقط (docs/24 — P1) ----
# الإنتاج: كل مستأجر مفاتيحه الخاصة في tenant.settings.payments.paymob
# (PATCH /payments/settings من لوحة أدمن المستأجر) — حساب تاجر مستقل لكل
# مستأجر، فمالُه يصل لحسابه هو. هذه المتغيّرات تُستعمل فقط إن كان المستأجر
# بلا إعداد خاص، لحساب تجريبي مشترك وقت التطوير.
PAYMOB_API_KEY=
PAYMOB_PUBLIC_KEY=
PAYMOB_HMAC_SECRET=
PAYMOB_INTEGRATION_ID_RIDER_CARD=
PAYMOB_INTEGRATION_ID_RIDER_WALLET=
PAYMOB_INTEGRATION_ID_DRIVER_CARD=
PAYMOB_INTEGRATION_ID_DRIVER_WALLET=
PAYMOB_IFRAME_ID=
# ---- CliQ (الأردن) — بوابة الدفع الإلكتروني ----
# الإنتاج: كل مستأجر مفاتيحه الخاصة في tenant.settings.payments.cliq
CLIQ_BASE_URL=https://api.cliq.jo
CLIQ_MERCHANT_ID=
CLIQ_API_KEY=
CLIQ_SECRET_KEY=
CLIQ_TERMINAL_ID=
# ---- SyriaTel (سوريا) — بوابة الدفع الإلكتروني ----
SYRIATEL_BASE_URL=https://e-payment.syriatel.com/api
SYRIATEL_MERCHANT_CODE=
SYRIATEL_API_KEY=
SYRIATEL_SECRET_KEY=
SYRIATEL_SERVICE_ID=
# ---- خدمة المواصلات (microservice مستقل) ----
TRANSIT_SERVICE_URL=http://transit:4020
# ---- MTN (Mobile Money) — بوابة الدفع الإلكتروني ----
MTN_BASE_URL=https://proxy.momoapi.mtn.com
MTN_API_KEY=
MTN_API_USER=
MTN_SUBSCRIPTION_KEY=
MTN_ENVIRONMENT=sandbox
# ---- L4: التسعير الديناميكي (Dynamic Pricing) ----
# ملاحظة: فاصل كرون SURGE_RECALCULATE ثابت بالكود (180 ثانية) في cron-registry.ts
# TRIP_SURGE_COOLDOWN_MS و TRIP_SURGE_SENSITIVITY يُقرأان فعلاً من SurgeService
TRIP_SURGE_COOLDOWN_MS=300000 # 5 دقائق بين تغييرين متتاليين
TRIP_SURGE_SENSITIVITY=0.3 # 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة
# ---- سرّ المنصة (لتعيين أول أدمن) — ولّده: openssl rand -hex 24 ----
PLATFORM_SECRET=
-25
View File
@@ -1,25 +0,0 @@
# ==== 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
# بوابة الجودة: الاختبارات تعمل هنا — أي على السيرفر وقت البناء، لا على الماك
# (قاعدة docs/15). تعتمد pg-mem و ioredis-mock فقط: بلا شبكة وبلا قاعدة حقيقية.
# فشل أي اختبار = فشل البناء = لا نشر لكود مكسور.
RUN npm test
# ---- 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"]
-51
View File
@@ -1,51 +0,0 @@
# 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
```
> الهجرات تعمل على النسخة المترجمة `dist/config/data-source.js` (صورة الإنتاج بلا ts-node).
> لو ما رفعت آخر نسخة بعد، شغّل مباشرة:
> `docker compose exec api node_modules/.bin/typeorm migration:run -d dist/config/data-source.js`
## فحص سريع
```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(وكيل انطلق).
-84
View File
@@ -1,84 +0,0 @@
# ==== 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:
# 127.0.0.1 فقط — كانت مكشوفة على 0.0.0.0 أي أن القاعدة كانت على
# الإنترنت مباشرة. المنفذ غير القياسي ليس أماناً، هو إخفاء.
# للوصول من الماك: نفق SSH (راجع docs/20).
- "127.0.0.1:${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`. أُزيل بقرار المالك 2026-07-18:
# **انطلق منصّة قائمة بذاتها** لها خوادمها وبلاطاتها؛ دورنا أن نرسل طلباً
# ونستقبل رداً، لا أن نستضيف خرائط. استضافتها عندنا كانت تعني قاعدة بيانات
# جغرافية ضخمة وصيانةً لا مقابل لها. (docs/25)
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:
# 127.0.0.1 فقط: لا يصل إليه إلا Nginx المحلي (CloudPanel) الذي ينهي
# الـTLS. بلا هذا يبقى http://IP:4010 مفتوحاً فيتجاوز الـTLS كله
# ويصير الشهادة زينة (docs/20).
- "127.0.0.1:${API_PORT:-4010}:4010"
volumes:
- tripz-storage:/app/storage
# اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML
# يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء).
- ../dashboards:/app/dashboards:ro
# مفتاح Firebase Admin — سرّ خارج git، يُنقل للسيرفر بـ scp (docs/15).
# غيابه لا يُسقط الإقلاع: NotificationsService يعطّل الدفع بتحذير فقط.
- ./firebase-service-account.json:/app/firebase-service-account.json:ro
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:
tripz-storage:
networks:
tripz-net:
name: tripz-net
-115
View File
@@ -1,115 +0,0 @@
{
"name": "tripz-e2e-tester",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tripz-e2e-tester",
"version": "1.0.0",
"dependencies": {
"socket.io-client": "^4.7.5"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
}
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"name": "tripz-e2e-tester",
"version": "1.0.0",
"type": "module",
"dependencies": {
"socket.io-client": "^4.7.5"
}
}
-162
View File
@@ -1,162 +0,0 @@
import readline from 'readline';
import { io } from 'socket.io-client';
const API_URL = process.env.API_URL || 'http://localhost:4010/api';
const TENANT_ID = process.env.TENANT_ID || 'siro';
async function api(method, path, body = null, token = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
'x-tenant-id': TENANT_ID,
'x-device-id': 'simulated-device-123'
}
};
if (token) options.headers['Authorization'] = `Bearer ${token}`;
if (body) options.body = JSON.stringify(body);
const res = await fetch(`${API_URL}${path}`, options);
const data = await res.json().catch(() => null);
if (!res.ok) {
console.error(`\n\x1b[31m[API Error] ${method} ${path} -> ${res.status}\x1b[0m`);
console.error(JSON.stringify(data, null, 2));
throw new Error(`API Error: ${res.status}`);
}
return data;
}
async function run() {
try {
console.log('\n==========================================');
console.log('🚀 Tripz Backend Automated E2E Simulation');
console.log('==========================================\n');
// 1. Authenticate Demo Driver
const driverPhone = '+962790000001';
console.log(`[Driver] Authenticating Demo Driver (${driverPhone})...`);
const driverAuth = await api('POST', '/auth/verify-otp', { phone: driverPhone, code: '1234' });
const driverToken = driverAuth.access_token;
console.log('\x1b[32m[Driver] Authenticated successfully!\x1b[0m\n');
// 2. Authenticate Demo Rider
const riderPhone = '+962790000002';
console.log(`[Rider] Authenticating Demo Rider (${riderPhone})...`);
const riderAuth = await api('POST', '/auth/verify-otp', { phone: riderPhone, code: '1234' });
const riderToken = riderAuth.access_token;
console.log('\x1b[32m[Rider] Authenticated successfully!\x1b[0m\n');
// 3. Setup Driver Profile & Go Online
console.log(`[Driver] Creating Driver profile...`);
await api('POST', '/drivers/apply', { service_class: 'economy', vehicle_make: 'Toyota', vehicle_model: 'Prius', vehicle_plate: '12-3456' }, driverToken);
console.log(`[Driver] Going Online...`);
await api('PATCH', '/drivers/status', { online: true }, driverToken);
console.log('\x1b[32m[Driver] Profile created and is now ONLINE.\x1b[0m\n');
// 4. Connect WebSockets
console.log(`[Sockets] Connecting Rider and Driver via WebSockets...`);
const WS_URL = API_URL.replace('/api', '');
const driverSocket = io(WS_URL, { extraHeaders: { Authorization: `Bearer ${driverToken}` } });
const riderSocket = io(WS_URL, { extraHeaders: { Authorization: `Bearer ${riderToken}` } });
let tripId = null;
// Sockets Error Logging
driverSocket.on('connect_error', (err) => console.error(`\x1b[31m[Driver WS] Error: ${err.message}\x1b[0m`));
riderSocket.on('connect_error', (err) => console.error(`\x1b[31m[Rider WS] Error: ${err.message}\x1b[0m`));
driverSocket.on('connect', () => console.log('\x1b[36m[Driver WS] Connected to Gateway\x1b[0m'));
riderSocket.on('connect', () => console.log('\x1b[36m[Rider WS] Connected to Gateway\x1b[0m'));
driverSocket.on('trip:offer', async (data) => {
console.log(`\n\x1b[35m[Driver WS] 🔔 RECEIVED OFFER for Trip ${data.tripId || data.id}!\x1b[0m`);
console.log(`Offer Payload:`, JSON.stringify(data, null, 2));
tripId = data.tripId || data.id;
try {
console.log(`[Driver] Accepting Trip...`);
await api('POST', `/trips/${tripId}/accept`, null, driverToken);
console.log(`\x1b[32m[Driver] Trip Accepted successfully!\x1b[0m`);
} catch (err) {
console.error(`\x1b[31m[Driver] Failed to accept trip!\x1b[0m`);
}
});
riderSocket.on('trip:update', (data) => {
console.log(`\x1b[33m[Rider WS] Trip status updated: ${data.status}\x1b[0m`);
});
await new Promise(r => setTimeout(r, 2000));
// 5. Driver Location Pings
let driverLat = 32.0645;
let driverLng = 36.0827;
const locationInterval = setInterval(() => {
driverLat += 0.0001;
driverSocket.emit('driver:location', { lat: driverLat, lng: driverLng, speed: 40, heading: 90 });
console.log(`[Driver WS] 📍 Location ping: ${driverLat.toFixed(5)}, ${driverLng.toFixed(5)}`);
}, 3000);
// Send first ping IMMEDIATELY
driverSocket.emit('driver:location', { lat: driverLat, lng: driverLng, speed: 40, heading: 90 });
console.log(`[Driver WS] 📍 Initial Location ping: ${driverLat.toFixed(5)}, ${driverLng.toFixed(5)}`);
// Wait for the server to process the location update into Redis
await new Promise(r => setTimeout(r, 1500));
// 6. Rider Requests Trip
console.log(`\n[Rider] Requesting Trip from Zarqa...`);
const tripRequest = await api('POST', '/trips', {
city: 'amman',
service_class: 'economy',
origin: { lat: 32.0650, lng: 36.0830 },
destination: { lat: 32.0700, lng: 36.0900 }
}, riderToken);
console.log(`\x1b[32m[Rider] Trip created: ${tripRequest.trip?.id || tripRequest.id}\x1b[0m`);
console.log(`[System] Offered to \x1b[33m${tripRequest.offeredDrivers}\x1b[0m drivers nearby.`);
// 7. Simulate Trip Flow
await new Promise(r => setTimeout(r, 10000));
if (tripId) {
try {
console.log(`\n[Driver] Heading to pickup (driver_arriving)...`);
await api('PATCH', `/trips/${tripId}/status`, { status: 'driver_arriving' }, driverToken);
await new Promise(r => setTimeout(r, 3000));
console.log(`\n[Driver] Arriving at pickup (driver_arrived)...`);
await api('PATCH', `/trips/${tripId}/status`, { status: 'driver_arrived' }, driverToken);
await new Promise(r => setTimeout(r, 3000));
console.log(`\n[Driver] Starting Trip...`);
await api('PATCH', `/trips/${tripId}/status`, { status: 'in_progress' }, driverToken);
await new Promise(r => setTimeout(r, 5000));
console.log(`\n[Driver] Completing Trip...`);
await api('PATCH', `/trips/${tripId}/status`, { status: 'completed' }, driverToken);
console.log(`\n\x1b[32m🎉 E2E Simulation Completed Successfully!\x1b[0m\n`);
} catch(e) {
console.error(`\x1b[31m[Simulation Error] ${e.message}\x1b[0m`);
}
} else {
console.log(`\n\x1b[31mFailed to receive offer!\x1b[0m`);
console.log(`[Hint] Check if offeredDrivers was 0. If it was 0, the backend didn't find the driver in Redis. If it was > 0, the backend emitted the socket event but the driver didn't receive it.\n`);
}
clearInterval(locationInterval);
driverSocket.disconnect();
riderSocket.disconnect();
process.exit(0);
} catch (error) {
console.error(`\n\x1b[31m[Fatal Error] Simulation Crashed: ${error.message}\x1b[0m\n`);
process.exit(1);
}
}
run();
-155
View File
@@ -1,155 +0,0 @@
import { io } from 'socket.io-client';
const API_URL = process.env.API_URL || 'http://localhost:4010/api';
const TENANT_ID = process.env.TENANT_ID || 'siro';
const NUM_DRIVERS = parseInt(process.env.NUM_DRIVERS || '100', 10);
const TRIP_RATE_SEC = parseInt(process.env.TRIP_RATE_SEC || '2', 10);
const DRIVER_PING_INTERVAL_MS = parseInt(process.env.DRIVER_PING_INTERVAL_MS || '3000', 10);
async function api(method, path, body = null, token = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
'x-tenant-id': TENANT_ID,
'x-device-id': 'stress-test-device'
}
};
if (token) options.headers['Authorization'] = `Bearer ${token}`;
if (body) options.body = JSON.stringify(body);
const res = await fetch(`${API_URL}${path}`, options);
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(`API Error: ${method} ${path} -> ${res.status} : ${JSON.stringify(data)}`);
}
return data;
}
let drivers = [];
let reqCount = 0;
let errors = 0;
let tripsCompleted = 0;
async function setupDriver(index) {
const phone = `+96279${String(index).padStart(6, '0')}`;
try {
// Authenticate using dev bypass
const auth = await api('POST', '/auth/verify-otp', { phone, code: '1234' });
const token = auth.access_token;
// Auto-create driver profile if missing
if (!auth.user.vehicle_plate) {
await api('POST', '/drivers/apply', {
service_class: 'economy',
vehicle_make: 'Toyota',
vehicle_model: 'Corolla',
vehicle_plate: `${index}-TEST`
}, token).catch(() => {});
}
// Go online
await api('PATCH', '/drivers/status', { online: true }, token);
const WS_URL = API_URL.replace('/api', '');
const socket = io(WS_URL, { extraHeaders: { Authorization: `Bearer ${token}` }, reconnection: true });
const driverObj = { phone, token, socket, available: true };
drivers.push(driverObj);
// Initial random location in Amman
let lat = 31.95 + (Math.random() * 0.1);
let lng = 35.91 + (Math.random() * 0.1);
// Start pinging location
setInterval(() => {
lat += 0.0001; // Move slightly
socket.emit('driver:location', { lat, lng, speed: 40, heading: 90 });
reqCount++;
}, DRIVER_PING_INTERVAL_MS);
// Listen for trip offers
socket.on('trip:offer', async (data) => {
if (!driverObj.available) return;
driverObj.available = false;
const tripId = data.tripId || data.id;
try {
await api('POST', `/trips/${tripId}/accept`, null, token);
// Simulate trip flow timeline (shortened for stress test)
setTimeout(async () => {
await api('PATCH', `/trips/${tripId}/status`, { status: 'driver_arrived' }, token).catch(()=>{});
await api('PATCH', `/trips/${tripId}/status`, { status: 'in_progress' }, token).catch(()=>{});
await api('PATCH', `/trips/${tripId}/status`, { status: 'completed' }, token).catch(()=>{});
driverObj.available = true; // Free to take next trip
tripsCompleted++;
}, 5000);
} catch (err) {
driverObj.available = true;
}
});
} catch (err) {
errors++;
}
}
async function spawnRiderAndRequestTrip(index) {
const phone = `+96278${String(index).padStart(6, '0')}`;
try {
const auth = await api('POST', '/auth/verify-otp', { phone, code: '1234' });
const token = auth.access_token;
// Pick a location roughly where drivers are spawned
const lat = 31.95 + (Math.random() * 0.1);
const lng = 35.91 + (Math.random() * 0.1);
await api('POST', '/trips', {
city: 'amman',
service_class: 'economy',
origin: { lat, lng },
destination: { lat: lat + 0.05, lng: lng + 0.05 }
}, token);
reqCount++;
} catch (err) {
errors++;
}
}
async function start() {
console.log(`\x1b[36m==================================================\x1b[0m`);
console.log(`🚀 Starting Stress Test`);
console.log(`\x1b[33mDrivers:\x1b[0m ${NUM_DRIVERS}`);
console.log(`\x1b[33mTrips/Sec:\x1b[0m ${TRIP_RATE_SEC}`);
console.log(`\x1b[33mLocation Ping Interval:\x1b[0m ${DRIVER_PING_INTERVAL_MS}ms`);
console.log(`\x1b[36m==================================================\x1b[0m\n`);
// Throttle driver creation so we don't spam the DB at startup
for (let i = 0; i < NUM_DRIVERS; i++) {
setupDriver(i);
if (i % 10 === 0) await new Promise(r => setTimeout(r, 100));
}
console.log(`[System] Finished spawning drivers. Waiting 5s for sockets...`);
await new Promise(r => setTimeout(r, 5000));
console.log(`[System] Commencing trip requests...`);
let riderIndex = 0;
setInterval(() => {
for (let i = 0; i < TRIP_RATE_SEC; i++) {
spawnRiderAndRequestTrip(riderIndex++);
}
}, 1000);
// Monitor performance locally
setInterval(() => {
const currentRps = reqCount / 5;
console.log(`\x1b[32m[Metrics]\x1b[0m Active Drivers: ${drivers.length} | API/WS Requests/sec: ${currentRps.toFixed(1)} | Trips Completed: ${tripsCompleted} | Errors: ${errors}`);
reqCount = 0;
}, 5000);
}
start();
-7
View File
@@ -1,7 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
};
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-9491
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
{
"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 -d dist/config/data-source.js",
"typeorm:dev": "typeorm-ts-node-commonjs -d src/config/data-source.ts",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"migration:generate": "npm run typeorm:dev -- migration:generate",
"lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^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",
"@socket.io/redis-adapter": "^8.3.0",
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.5",
"ioredis": "^5.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.7.5",
"typeorm": "^0.3.20",
"firebase-admin": "^12.0.0"
},
"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",
"@types/passport-jwt": "^4.0.1",
"ioredis-mock": "^8.13.1",
"jest": "^29.7.0",
"pg-mem": "^3.0.14",
"ts-jest": "^29.2.0",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}
-314
View File
@@ -1,314 +0,0 @@
// اختبار شامل متكامل لكل وظائف الباك إند (docs/17).
// يغطّي: تسجيل الدخول/الحساب · إعداد السائق (ملف + مركبة) · طلب رحلة ·
// المطابقة عبر Redis · القبول الذرّي · كل حالات الرحلة · تتبّع WebSocket ·
// التسوية المالية (محفظة) · الرصيد التشغيلي والعمولة · التقييم للطرفين ·
// نقطة «رحلة معلّقة للتقييم عند الفتح» · السحب بـOTP.
//
// يستخدم fetch المدمج. اختبار الـWebSocket اختياري: يعمل إن توفّر
// socket.io-client، ويُتخطّى بتحذير إن غاب.
//
// التشغيل (مع WebSocket):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -e WS=http://tripz-api:4010 -v /home/tripz-llc/backend/scripts:/s \
// node:22-alpine sh -c "cd /tmp && npm i socket.io-client --silent 2>/dev/null; \
// NODE_PATH=/tmp/node_modules node /s/e2e-test.mjs"
//
// التشغيل (REST فقط، بلا WebSocket):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs
//
// يتطلّب OTP_DEV_MODE=true (الرمز الثابت 1234).
const BASE = process.env.BASE || 'http://localhost:4010/api';
const WS = process.env.WS || BASE.replace(/\/api$/, '');
const TENANT = process.env.TENANT || 'siro';
const CODE = '1234';
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
const DEST = { lat: 31.9800, lng: 35.8700 };
let passed = 0;
let failed = 0;
const failures = [];
function check(name, cond, detail) {
if (cond) {
passed++;
console.log(` ✅ ${name}`);
} else {
failed++;
failures.push(name + (detail ? ` — ${detail}` : ''));
console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`);
}
}
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body, opts = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* غير JSON */ }
if (!res.ok && !opts.allowError) {
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
}
return { status: res.status, body: json, ok: res.ok };
}
async function login(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
return { token: r.body.access_token, userId: r.body.user?.id, role: r.body.user?.role };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// رقم أردني صالح: 0 + 9 خانات (10 إجمالاً). فريد عبر عدّاد + طابع زمني.
let _seq = 0;
const uniqJoPhone = () => `07${String(Date.now()).slice(-7)}${_seq++}`;
// نصّ فريد عام (رقم وطني/لوحة — لا يمرّ بتطبيع الهاتف).
const uniqStr = (p) => `${p}${String(Date.now()).slice(-6)}${_seq++}`;
// ---- اختبار WebSocket اختياري ----
async function tryWebSocket(riderToken, tripId) {
let ioClient;
try {
ioClient = (await import('socket.io-client')).io;
} catch {
console.log(' ⚠️ socket.io-client غير متوفّر — تخطّي اختبار WebSocket (راجع أمر التشغيل)');
return null;
}
return new Promise((resolve) => {
const events = { connected: false, tripUpdate: false, driverLocation: false };
const socket = ioClient(WS, { auth: { token: riderToken }, transports: ['websocket'], timeout: 5000 });
const done = () => { try { socket.close(); } catch {} resolve(events); };
socket.on('connect', () => {
events.connected = true;
socket.emit('trip:join', { tripId });
});
socket.on('trip:update', () => { events.tripUpdate = true; });
socket.on('driver:location', () => { events.driverLocation = true; });
socket.on('connect_error', () => done());
setTimeout(done, 8000); // نافذة رصد الأحداث
});
}
async function main() {
console.log(`\n🧪 اختبار Tripz الشامل على ${BASE}\n`);
// ═══ 1. تسجيل الدخول / إنشاء الحساب ═══
console.log('1) تسجيل الدخول وإنشاء الحساب (هاتف + OTP)');
const riderPhone = uniqJoPhone();
const rider = await login(riderPhone);
check('الراكب سجّل دخوله وحصل على توكن', !!rider.token);
check('حساب جديد بدور rider افتراضياً', rider.role === 'rider', `role=${rider.role}`);
// نفس الرقم بصيغة مختلفة → نفس الحساب (تطبيع D1)
const riderAgain = await api('POST', '/auth/verify-otp', null, {
phone: '0' + riderPhone.slice(1), code: CODE,
}, { allowError: true });
check('تطبيع الهاتف: نفس الرقم بصيغ مختلفة = نفس المستخدم',
riderAgain.body.user?.id === rider.userId);
const driverPhone = uniqJoPhone();
let driver = await login(driverPhone);
check('السائق سجّل دخوله', !!driver.token);
// ═══ 2. إعداد السائق (ملف + مركبة + اعتماد) ═══
console.log('\n2) إعداد السائق: تقديم + ملف + مركبة + اعتماد');
const applied = await api('POST', '/drivers/apply', driver.token, {
vehicle_make: 'Toyota', service_class: 'economy',
});
const driverId = applied.body.id;
check('تقديم السائق أنشأ سجلاً', !!driverId);
await api('PATCH', '/drivers/profile', driver.token, {
gender: 'male', national_number: uniqStr("99"), name_arabic: 'سائق تجريبي',
license_type: 'private',
});
check('تحديث ملف السائق (رقم وطني مشفّر + فهرس أعمى)', true);
const vehicle = await api('POST', '/vehicles', driver.token, {
make: 'Toyota', model: 'Corolla', year: 2020, color: 'أبيض', color_hex: '#FFFFFF',
plate: uniqStr("PL"),
});
check('إنشاء مركبة', !!vehicle.body.id);
check('أول مركبة تصير الافتراضية تلقائياً', vehicle.body.is_default === true);
await api('PATCH', `/drivers/${driverId}/approve`, driver.token, {});
// الدور صار driver بعد apply — يجب إعادة الدخول ليحمل التوكن الدور الجديد
driver = await login(driverPhone);
check('بعد الاعتماد وإعادة الدخول: الدور driver في التوكن', driver.role === 'driver', `role=${driver.role}`);
// ═══ 3. الرصيد التشغيلي (مكافأة التسجيل) ═══
console.log('\n3) الرصيد التشغيلي للسائق');
const credit0 = await api('GET', '/credit', driver.token);
check('السائق بدأ برصيد تشغيلي (مكافأة تسجيل > 0)', Number(credit0.body.balance) > 0,
`balance=${credit0.body.balance}`);
check('السائق غير محجوب', credit0.body.blocked === false);
// ═══ 4. اتصال السائق + رفع الموقع (Redis) ═══
console.log('\n4) اتصال السائق ورفع الموقع (Redis + المطابقة)');
await api('PATCH', '/drivers/status', driver.token, { online: true });
await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 0, heading: 0 });
check('السائق متصل ورفع موقعه', true);
// ═══ 5. المحفظة (شحن للتسوية لاحقاً) ═══
console.log('\n5) المحفظة');
await api('POST', '/wallet/topup', rider.token, { amount: 50 });
const wallet0 = await api('GET', '/wallet', rider.token);
check('شحن محفظة الراكب', Number(wallet0.body.balance) >= 50, `balance=${wallet0.body.balance}`);
// ═══ 6. طلب رحلة (تسعير + مطابقة + عرض) ═══
console.log('\n6) طلب رحلة (تسعير + مطابقة عبر Redis)');
const reqTrip = await api('POST', '/trips', rider.token, {
origin: ORIGIN, destination: DEST, service_class: 'economy', payment_method: 'wallet',
});
const tripId = reqTrip.body.trip?.id;
check('إنشاء الرحلة', !!tripId);
check('الرحلة بحالة searching', reqTrip.body.trip?.status === 'searching');
check('حُسبت أجرة مقفولة', reqTrip.body.trip?.quoted_fare != null,
`fare=${reqTrip.body.trip?.quoted_fare}`);
check('عُرضت على سائق قريب (مطابقة Redis نجحت)', reqTrip.body.offeredDrivers >= 1,
`offered=${reqTrip.body.offeredDrivers}`);
// السائق يرى الرحلة في المتاحة
const available = await api('GET', '/trips/available', driver.token);
check('السائق يرى الرحلة في /trips/available', Array.isArray(available.body) &&
available.body.some((t) => t.tripId === tripId || t.id === tripId));
// ═══ 7. WebSocket (يُبدأ قبل الانتقالات لرصد الأحداث) ═══
console.log('\n7) تتبّع WebSocket (اختياري)');
const wsPromise = tryWebSocket(rider.token, tripId);
await sleep(1500); // مهلة ليتّصل السوكت قبل الانتقالات، وإلا فاتته الأحداث
// ═══ 8. القبول + كل حالات الرحلة ═══
console.log('\n8) القبول وكل حالات الرحلة');
const accepted = await api('POST', `/trips/${tripId}/accept`, driver.token, {});
check('السائق قبل الرحلة', accepted.ok);
const afterAccept = await api('GET', `/trips/${tripId}`, driver.token);
check('الحالة صارت assigned', afterAccept.body.status === 'assigned', `status=${afterAccept.body.status}`);
const transitions = ['driver_arriving', 'driver_arrived', 'in_progress', 'completed'];
for (const st of transitions) {
// نبضة موقع قبل الوصول (لكشف تزوير الوصول — H/E)
if (st === 'driver_arrived') {
await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 5 });
}
const r = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: st }, { allowError: true });
check(`الانتقال إلى ${st}`, r.ok, r.ok ? '' : JSON.stringify(r.body).slice(0, 120));
await sleep(150);
}
const afterComplete = await api('GET', `/trips/${tripId}`, driver.token);
check('الرحلة مكتملة', afterComplete.body.status === 'completed');
check('طوابع دقيقة: started_at مضبوط', !!afterComplete.body.started_at);
check('السعر مفصول: price_for_passenger و price_for_driver',
afterComplete.body.price_for_passenger != null && afterComplete.body.price_for_driver != null);
// ═══ 9. العمولة خُصمت من الرصيد التشغيلي عند الإنهاء ═══
console.log('\n9) العمولة من الرصيد التشغيلي (عند الإنهاء)');
const credit1 = await api('GET', '/credit', driver.token);
const commission = Number(afterComplete.body.commission_amount ?? 0);
if (commission > 0) {
check('نقص الرصيد التشغيلي بمقدار العمولة',
Math.abs((Number(credit0.body.balance) - Number(credit1.body.balance)) - commission) < 0.01,
`${credit0.body.balance} → ${credit1.body.balance}, عمولة=${commission}`);
} else {
check('لا عمولة على هذه التعرفة (تخطّي)', true);
}
// ═══ 10. الدفع (تسوية المحفظة عند paid) ═══
console.log('\n10) الدفع وتسوية المحفظة');
const paidRes = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: 'paid' }, { allowError: true });
check('الانتقال إلى paid', paidRes.ok, paidRes.ok ? '' : JSON.stringify(paidRes.body).slice(0, 120));
await sleep(200);
const walletAfter = await api('GET', '/wallet', rider.token);
const fare = Number(afterComplete.body.price_for_passenger ?? 0);
check('خُصمت الأجرة من محفظة الراكب',
Math.abs((Number(wallet0.body.balance) - Number(walletAfter.body.balance)) - fare) < 0.01,
`${wallet0.body.balance} → ${walletAfter.body.balance}, أجرة=${fare}`);
// ═══ 11. التقييم: نقطة «رحلة معلّقة عند الفتح» ═══
console.log('\n11) التقييم (رحلة معلّقة عند الفتح + تقييم الطرفين)');
const pendingRider = await api('GET', '/trips/rating/pending', rider.token);
check('الراكب لديه رحلة معلّقة للتقييم عند الفتح', pendingRider.body.pending?.tripId === tripId,
`pending=${JSON.stringify(pendingRider.body.pending)}`);
check('دور الراكب في التقييم = rider', pendingRider.body.pending?.role === 'rider');
const pendingDriver = await api('GET', '/trips/rating/pending', driver.token);
check('السائق لديه رحلة معلّقة للتقييم', pendingDriver.body.pending?.tripId === tripId);
check('دور السائق في التقييم = driver', pendingDriver.body.pending?.role === 'driver');
await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 5, comment: 'ممتاز' });
check('الراكب قيّم السائق', true);
await api('POST', `/trips/${tripId}/rate`, driver.token, { stars: 4 });
check('السائق قيّم الراكب', true);
const pendingAfter = await api('GET', '/trips/rating/pending', rider.token);
check('بعد التقييم: لا رحلة معلّقة للراكب', pendingAfter.body.pending === null);
const dupRate = await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 3 }, { allowError: true });
check('منع التقييم المزدوج', !dupRate.ok && dupRate.status === 400);
// ═══ 11.5 حراسة النقاط الإدارية (docs/22 — N4) ═══
console.log('\n11.5) حراسة لوحة الأدمن (RolesGuard)');
const adminAsDriver = await api('GET', '/drivers/admin/list', driver.token, null, { allowError: true });
check('السائق (غير أدمن) يُرفض من /drivers/admin/list', adminAsDriver.status === 403,
`status=${adminAsDriver.status}`);
const tripsAsRider = await api('GET', '/trips/admin/list', rider.token, null, { allowError: true });
check('الراكب يُرفض من /trips/admin/list', tripsAsRider.status === 403);
const searchAsRider = await api('GET', '/admin/users/search?phone=07900000000', rider.token, null, { allowError: true });
check('الراكب يُرفض من بحث خدمة العملاء', searchAsRider.status === 403);
// ═══ 12. السحب بـOTP (تدفّق خطوتين) ═══
console.log('\n12) سحب أرباح السائق (OTP خطوتين)');
// السائق يشحن محفظة أرباحه أولاً ليكون له رصيد للسحب
await api('POST', '/wallet/topup', driver.token, { amount: 20 });
const payoutReq = await api('POST', '/payouts/request', driver.token,
{ amount: 10, channel: 'cliq' }, { allowError: true });
check('طلب السحب أرسل رمزاً بلا خصم', payoutReq.ok && payoutReq.body.otp_sent === true,
JSON.stringify(payoutReq.body).slice(0, 120));
const payoutId = payoutReq.body.payout?.id;
const devCode = payoutReq.body.dev_code;
if (payoutId && devCode) {
const walletBeforeConfirm = await api('GET', '/wallet', driver.token);
const confirm = await api('POST', `/payouts/${payoutId}/confirm`, driver.token,
{ code: String(devCode) }, { allowError: true });
check('تأكيد السحب بالرمز نجح', confirm.ok, JSON.stringify(confirm.body).slice(0, 120));
const walletAfterConfirm = await api('GET', '/wallet', driver.token);
check('حُجز مبلغ السحب من محفظة السائق عند التأكيد',
Math.abs((Number(walletBeforeConfirm.body.balance) - Number(walletAfterConfirm.body.balance)) - 10) < 0.01,
`${walletBeforeConfirm.body.balance} → ${walletAfterConfirm.body.balance}`);
} else {
check('dev_code متوفّر للتأكيد', false, 'لا يوجد dev_code — تأكّد من OTP_DEV_MODE=true');
}
// ═══ نتيجة WebSocket ═══
console.log('\n13) نتيجة WebSocket');
const ws = await wsPromise;
if (ws) {
check('اتصال WebSocket بمصادقة JWT', ws.connected);
check('استقبال أحداث trip:update أثناء الرحلة', ws.tripUpdate);
// driver:location قد لا يُبثّ إن لم تكن الحركة ذات دلالة — تحذير لا فشل
if (!ws.driverLocation) console.log(' ⚠️ لم تُرصد driver:location (قد لا تكون حركة ذات دلالة)');
}
// ═══ الخلاصة ═══
console.log('\n' + '═'.repeat(50));
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
if (failed > 0) {
console.log('\nالإخفاقات:');
for (const f of failures) console.log(` ❌ ${f}`);
}
console.log('═'.repeat(50) + '\n');
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('\n💥 توقّف الاختبار بخطأ غير متوقّع:', e.message);
console.error(e.stack?.split('\n').slice(0, 4).join('\n'));
process.exit(1);
});
-99
View File
@@ -1,99 +0,0 @@
// اختبار تحمّل: يشغّل دورة رحلة كاملة (طلب→قبول→حالات→دفع) بالتوازي ويقيس القدرة.
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات. يغطّي: تعرفة + خرائط + مطابقة GEO
// + آلة الحالة + كتابة القاعدة + بث السوكت (خادمياً).
//
// التشغيل (حاوية node مؤقتة على شبكة tripz):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/loadtest.mjs 300 30
// الوسائط: <إجمالي الرحلات> <التزامن>
const BASE = process.env.BASE || 'http://localhost:4010/api';
const TENANT = process.env.TENANT || 'siro';
const TOTAL = parseInt(process.argv[2] || '200', 10);
const CONC = parseInt(process.argv[3] || '20', 10);
const CODE = '1234';
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
const jitter = (n) => n + (Math.random() - 0.5) * 0.01;
async function api(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text().catch(() => '')}`);
return res.json().catch(() => ({}));
}
async function token(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
return r.access_token;
}
async function setupDriver(i) {
const t = await token(`0795${String(100000 + i).slice(-6)}`);
const d = await api('POST', '/drivers/apply', t, { vehicle_make: 'LoadTest', service_class: 'economy' });
await api('PATCH', `/drivers/${d.id}/approve`, t, {});
await api('PATCH', '/drivers/status', t, { online: true });
await api('POST', '/drivers/location', t, { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) });
return t;
}
async function oneTrip(riderTok, driverTok) {
const start = Date.now();
const { trip } = await api('POST', '/trips', riderTok, {
origin: { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) },
destination: { lat: jitter(ORIGIN.lat + 0.03), lng: jitter(ORIGIN.lng + 0.03) },
service_class: 'economy',
});
await api('POST', `/trips/${trip.id}/accept`, driverTok, {});
for (const s of ['driver_arriving', 'driver_arrived', 'in_progress', 'completed', 'paid']) {
await api('PATCH', `/trips/${trip.id}/status`, driverTok, { status: s });
}
return Date.now() - start;
}
async function main() {
console.log(`Setup ${CONC} drivers+riders...`);
const drivers = [], riders = [];
for (let i = 0; i < CONC; i++) {
drivers.push(await setupDriver(i));
riders.push(await token(`0796${String(100000 + i).slice(-6)}`));
}
console.log(`Running ${TOTAL} trips @ concurrency ${CONC}...`);
let done = 0, errors = 0;
const lats = [];
const t0 = Date.now();
await Promise.all(
Array.from({ length: CONC }, (_, w) =>
(async () => {
while (true) {
const idx = done++;
if (idx >= TOTAL) break;
try {
lats.push(await oneTrip(riders[w], drivers[w]));
} catch (e) {
errors++;
if (errors <= 5) console.error('ERR:', e.message);
}
}
})(),
),
);
const secs = (Date.now() - t0) / 1000;
lats.sort((a, b) => a - b);
const pct = (p) => lats[Math.min(lats.length - 1, Math.floor((p / 100) * lats.length))] || 0;
const ok = lats.length;
console.log('\n===== النتائج =====');
console.log(`رحلات ناجحة: ${ok} / ${TOTAL} · أخطاء: ${errors}`);
console.log(`الزمن: ${secs.toFixed(1)}s · الإنتاجية: ${(ok / secs).toFixed(1)} رحلة/ث (~${Math.round((ok / secs) * 86400).toLocaleString()} رحلة/يوم)`);
console.log(`زمن الرحلة الكاملة (ms): p50=${pct(50)} · p95=${pct(95)} · max=${pct(100)}`);
console.log('ملاحظة: كل رحلة = 7 نداءات API (طلب+قبول+5 حالات).');
}
main().catch((e) => { console.error(e); process.exit(1); });
-104
View File
@@ -1,104 +0,0 @@
// اختبار مسار المواقع (docs/17 — H): يثبت أن العتبات تعمل على Redis حقيقي،
// وأن سائقاً واقفاً لا يكلّف شيئاً. يستخدم fetch المدمج — بلا تبعيات.
//
// يعتمد أن POST /drivers/location يرجع { ok, significant } — فالعتبة تُتحقَّق
// عبر الـAPI الحقيقي بلا حاجة للنظر في القاعدة.
//
// التشغيل:
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/location-test.mjs
//
// بعده تحقَّق من كتابة الـworker (بعد ~6 ثوانٍ):
// SELECT count(*) FROM tripz_driver_tracks; -- يجب أن يساوي عدد «ذات الدلالة»
// SELECT loc_status, speed, loc_updated_at FROM tripz_drivers WHERE loc_updated_at IS NOT NULL;
const BASE = process.env.BASE || 'http://localhost:4010/api';
const TENANT = process.env.TENANT || 'siro';
const CODE = '1234';
const STATIONARY_PINGS = 10;
const MOVING_PINGS = 8;
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
const metersToLat = (m) => m / 111_320;
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* غير JSON */ }
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
return json;
}
async function main() {
const phone = `0797${String(Date.now()).slice(-6)}`;
const { access_token: t } = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
const driver = await api('POST', '/drivers/apply', t, {
vehicle_make: 'LocationTest',
service_class: 'economy',
});
await api('PATCH', `/drivers/${driver.id}/approve`, t, {});
await api('PATCH', '/drivers/status', t, { online: true });
console.log(`سائق: ${driver.id} (${phone}) — متصل\n`);
// 1) واقف مكانه: الأولى ذات دلالة، والباقي يجب أن تُهمَل كلها.
let stationarySignificant = 0;
for (let i = 0; i < STATIONARY_PINGS; i++) {
const r = await api('POST', '/drivers/location', t, { ...ORIGIN, speed: 0, heading: 0 });
if (r.significant) stationarySignificant++;
}
console.log(`واقف : ${STATIONARY_PINGS} نبضة → ${stationarySignificant} ذات دلالة (المتوقع 1: الأولى فقط)`);
// 2) متحرّك 30م كل نبضة: كلها يجب أن تكون ذات دلالة.
let movingSignificant = 0;
for (let i = 1; i <= MOVING_PINGS; i++) {
const r = await api('POST', '/drivers/location', t, {
lat: ORIGIN.lat + metersToLat(30 * i),
lng: ORIGIN.lng,
speed: 40,
heading: 0,
});
if (r.significant) movingSignificant++;
}
console.log(`متحرّك : ${MOVING_PINGS} نبضة × 30م → ${movingSignificant} ذات دلالة (المتوقع ${MOVING_PINGS})`);
const totalPings = STATIONARY_PINGS + MOVING_PINGS;
const totalSignificant = stationarySignificant + movingSignificant;
const saved = Math.round(((totalPings - totalSignificant) / totalPings) * 100);
console.log('');
console.log(`الإجمالي: ${totalPings} نبضة → ${totalSignificant} فقط لمست Redis/الفهرس`);
console.log(`أي أن ${saved}% من النبضات كلّفت أمر EXPIRE واحداً لا أكثر.`);
console.log('');
const checks = [
['النبضة الأولى ذات دلالة', stationarySignificant >= 1],
['الوقوف لا يُنتج نبضات ذات دلالة', stationarySignificant === 1],
['كل تحرّك 30م ذو دلالة', movingSignificant === MOVING_PINGS],
];
let failed = 0;
for (const [name, pass] of checks) {
console.log(`${pass ? '✅' : '❌'} ${name}`);
if (!pass) failed++;
}
console.log('');
console.log(`بعد ~6 ثوانٍ يجب أن يكون الـworker كتب ${totalSignificant} نقطة مسار. تحقَّق:`);
console.log(` SELECT count(*) FROM tripz_driver_tracks WHERE driver_id = '${driver.id}';`);
console.log(` SELECT loc_status, speed, loc_updated_at FROM tripz_drivers WHERE id = '${driver.id}';`);
console.log('');
console.log(failed === 0 ? '✅ العتبات تعمل.' : `❌ ${failed} محكّ فشل.`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('خطأ:', e.message);
process.exit(1);
});
-395
View File
@@ -1,395 +0,0 @@
// اختبار تسعير عبر API حقيقي — يطلب رحلة لكل فئة خدمة في كل بلد، ويقرأ
// السعر والمسافة والزمن اللذين رجعهما محرّك التعرفة الفعلي (لا محاكاة).
//
// ينشئ مستأجرَي اختبار لمصر وسوريا إن لم يكونا موجودين (الأردن = "siro"
// القائم أصلاً)، ثم يطلب 3 رحلات نموذجية (قصيرة/متوسطة/طويلة) لكل فئة
// خدمة في كل بلد، ويطبع جدول: البلد × الفئة × الرحلة → السعر.
//
// التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -e PLATFORM_SECRET="$(grep -E '^PLATFORM_SECRET=' .env | cut -d= -f2-)" \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/pricing-test.mjs
//
// يتطلّب `PLATFORM_SECRET` فقط — **لا يحتاج `OTP_DEV_MODE`**: يقرأ رمز
// التحقّق من Redis مباشرةً. الوضع التطويري ممنوع على الإنتاج بحارس في
// `main.ts`، وإضعاف ذلك الحارس لأجل اختبار مقايضة خاسرة.
import net from 'node:net';
const BASE = process.env.BASE || 'http://localhost:4010/api';
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || '';
// يقرأ رمز OTP من Redis مباشرةً بدل `OTP_DEV_MODE`. الوضع التطويري ممنوع
// على الإنتاج بحارس في `main.ts` (`OTP_DEV_MODE=true` + `NODE_ENV=production`
// = رفض الإقلاع)، وهو حارس صحيح: رمز ثابت على سيرفر متاح للإنترنت يعني أن
// معرفة رقم هاتف تكفي لدخول أي حساب. فنقرأ الرمز الحقيقي بدل إضعاف الحارس.
const REDIS_HOST = process.env.REDIS_HOST || 'tripz-redis';
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
const REDIS_DB = parseInt(process.env.REDIS_DB || '3', 10);
const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'tripz:';
if (!PLATFORM_SECRET) {
console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.');
process.exit(1);
}
// الفئات المتوقَّع أن تُسعَّر. **`fixed` ليست منها**: أُخرجت من الزرع عمداً
// (سعر ثابت بلا جدول خطوط = رقم واحد لكل الوجهات)، فالمتوقَّع منها رفض 400
// لا سعر — ويُتحقَّق من ذلك صراحةً في `REJECTED_CLASSES`.
const SERVICE_CLASSES = [
'saver', 'delivery', 'economy', 'electric', 'lady', 'comfort', 'vip', 'van',
];
/** فئات يجب أن يرفضها الخادم — الرفض هنا نجاحٌ لا فشل. */
const REJECTED_CLASSES = ['fixed'];
/**
* رحلات نموذجية داخل الزرقاء/عمّان (إحداثيات قابلة لإعادة الاستعمال في
* أي بلد — الفارق بين البلدان هو التعرفة لا الجغرافيا؛ المسافة والزمن
* يأتيان من محرّك المسارات الحقيقي `MapsService.route`، لا من افتراض).
*
* النافذة الزمنية الفعلية تُحدَّد بلحظة تشغيل السكربت فعلاً — لا حقلاً في
* الطلب (`TariffEngine.quote` يستعمل `new Date()` الحقيقي). اختبار الذروة
* مقابل النافذة العادية مغطّى بوحدات على `tariff-seed.spec.ts`؛ هنا الهدف
* إثبات أن **الـAPI الحي** يرجع سعراً معقولاً بمسافة/زمن حقيقيَّين من انطلق.
*/
const RIDES = [
{
label: 'قصيرة (داخل الزرقاء)',
origin: { lat: 32.0728, lng: 36.0876 },
destination: { lat: 32.0895, lng: 36.1046 },
},
{
label: 'متوسطة (الزرقاء → عمّان)',
origin: { lat: 32.0728, lng: 36.0876 },
destination: { lat: 31.9539, lng: 35.9106 },
},
{
label: 'طويلة (الزرقاء → المطار)',
origin: { lat: 32.0728, lng: 36.0876 },
destination: { lat: 31.7226, lng: 35.9932 },
},
];
const TENANTS = [
{ slug: 'siro', countryPack: 'jo', currency: 'JOD', create: false }, // موجود أصلاً
{ slug: 'tripz-test-eg', countryPack: 'eg', currency: 'EGP', create: true, name: 'Tripz Test EG' },
{ slug: 'tripz-test-sy', countryPack: 'sy', currency: 'SYP', create: true, name: 'Tripz Test SY' },
];
// ---- عميل Redis أدنى (RESP على TCP خام) ----
// بلا `ioredis`: حاوية `node:22-alpine` عابرة بلا `node_modules`، وتثبيت
// حزمة لكل تشغيلة تبعية شبكة لا داعي لها لأمرَين اثنين (SELECT ثم GET).
const respCmd = (...args) =>
`*${args.length}\r\n` +
args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join('');
/** يفكّ ردّاً واحداً بدءاً من `i`؛ يعيد null إن لم يكتمل بعد. */
function parseReply(s, i) {
const type = s[i];
const end = s.indexOf('\r\n', i);
if (end === -1) return null;
const head = s.slice(i + 1, end);
if (type === '+' || type === '-' || type === ':') {
return { value: head, next: end + 2, error: type === '-' };
}
if (type === '$') {
const len = parseInt(head, 10);
if (len === -1) return { value: null, next: end + 2 }; // مفتاح غير موجود
const start = end + 2;
if (s.length < start + len + 2) return null;
return { value: s.slice(start, start + len), next: start + len + 2 };
}
return null;
}
function redisGet(key) {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ host: REDIS_HOST, port: REDIS_PORT });
sock.setEncoding('utf8');
const timer = setTimeout(() => {
sock.destroy();
reject(new Error(`redis timeout (${REDIS_HOST}:${REDIS_PORT})`));
}, 5000);
sock.on('connect', () => {
sock.write(respCmd('SELECT', String(REDIS_DB)));
sock.write(respCmd('GET', key));
});
let buf = '';
sock.on('data', (chunk) => {
buf += chunk;
const first = parseReply(buf, 0); // ردّ SELECT
if (!first) return;
if (first.error) {
clearTimeout(timer);
sock.end();
return reject(new Error(`redis SELECT failed: ${first.value}`));
}
const second = parseReply(buf, first.next); // ردّ GET
if (!second) return;
clearTimeout(timer);
sock.end();
resolve(second.value);
});
sock.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
});
}
async function api(method, path, { token, tenant, body, platform } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (tenant) headers['x-tenant-id'] = tenant;
if (platform) headers['x-platform-secret'] = PLATFORM_SECRET;
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
let json = null;
try { json = await res.json(); } catch {}
return { status: res.status, body: json };
}
async function ensureTenant(t) {
if (!t.create) return;
const provisioned = await api('POST', '/admin/tenants/provision', {
platform: true,
body: {
name: t.name,
slug: t.slug,
countryPack: t.countryPack,
plan: 'launch',
},
});
if (provisioned.status >= 200 && provisioned.status < 300) {
console.log(`✅ أُنشئ مستأجر ${t.slug} (${t.countryPack})`);
} else if (/already taken|already exists|موجود/i.test(JSON.stringify(provisioned.body))) {
console.log(`ℹ️ ${t.slug} موجود أصلاً`);
} else {
console.error(`❌ فشل إنشاء ${t.slug}:`, provisioned.status, JSON.stringify(provisioned.body));
}
}
/**
* رقم دولي كامل صالح لكل بلد — **بلا "+"**، تماماً كما يخزّنه
* `PhoneService.normalize` (docs/17 — D1). الطول يجب أن يطابق
* `callingCode + nationalLength` بالضبط وإلا رُفض الرقم:
* الأردن/سوريا 962/963 + 9 أرقام محلية، مصر 20 + 10 أرقام محلية.
*/
function testPhone(countryPack, seed) {
const plans = {
jo: { code: '962', national: 9, prefix: '79' },
sy: { code: '963', national: 9, prefix: '93' },
eg: { code: '20', national: 10, prefix: '10' },
};
const plan = plans[countryPack] ?? plans.jo;
const remaining = plan.national - plan.prefix.length;
const digits = String(1_000_000_000 + seed).slice(-remaining);
return `${plan.code}${plan.prefix}${digits}`;
}
async function loginRider(tenantSlug, tenantId, countryPack, seed) {
const phone = testPhone(countryPack, seed);
// `send-otp` يخزّن الرمز في Redis **قبل** محاولة الإرسال، فحتى لو فشل
// الإرسال لرقم وهمي (503) يكون الرمز محفوظاً — وهو ما نقرأه.
let sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
// 429: `send-otp` محدود المعدّل (10 لكل 5 دقائق). تشغيلتان متتاليتان
// تصطدمان به، فننتظر بدل أن نُبلغ عن فشل ليس في النظام.
for (let wait = 0; sent.status === 429 && wait < 3; wait++) {
console.log(` ⏳ حدّ المعدّل على send-otp — انتظار 20 ثانية…`);
await new Promise((r) => setTimeout(r, 20000));
sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
}
if (sent.body?.dev_code) {
// وضع تطويري مفعّل (بيئة غير إنتاجية) — الرمز في الاستجابة مباشرةً.
return (await verify(tenantSlug, phone, sent.body.dev_code))?.access_token;
}
// المفتاح كما يبنيه `AuthService.otpKey` + `keyPrefix` من إعداد Redis،
// والمعرّف هو **UUID المستأجر** لا الـslug.
const key = `${REDIS_KEY_PREFIX}otp:${tenantId}:${phone}`;
let code;
try {
code = await redisGet(key);
} catch (e) {
console.error(` ❌ تعذّرت قراءة الرمز من Redis: ${e.message}`);
return undefined;
}
if (!code) {
console.error(
` ❌ لا رمز في Redis للمفتاح ${key} — ` +
`استجابة send-otp كانت ${sent.status} ${JSON.stringify(sent.body)}`,
);
return undefined;
}
const r = await verify(tenantSlug, phone, code);
if (!r?.access_token) {
console.error(` ❌ فشل تسجيل الدخول (${phone}): ${JSON.stringify(r)}`);
}
return r?.access_token;
}
async function verify(tenantSlug, phone, code) {
const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code } });
return r.body;
}
async function quoteFor(tenantSlug, token, serviceClass, ride) {
const res = await api('POST', '/trips', {
tenant: tenantSlug,
token,
body: {
origin: ride.origin,
destination: ride.destination,
service_class: serviceClass,
payment_method: 'cash',
},
});
return res;
}
async function main() {
console.log(`\n=== تسعير التعرفة الافتراضية — API حقيقي (${BASE}) ===`);
console.log(`وقت التشغيل: ${new Date().toISOString()} (UTC) — يحدّد أي نافذة تعرفة ستُختار فعلياً.\n`);
for (const t of TENANTS) await ensureTenant(t);
// مفتاح OTP في Redis يستعمل **UUID المستأجر** لا الـslug، فنجلب الخريطة مرة.
const all = await api('GET', '/admin/tenants', { platform: true });
const idBySlug = new Map((all.body ?? []).map((x) => [x.slug, x.id]));
if (!idBySlug.size) {
console.error(`❌ تعذّر جلب المستأجرين: ${all.status} ${JSON.stringify(all.body)}`);
process.exitCode = 1;
return;
}
const rows = [];
let rejectionsOk = true;
for (const t of TENANTS) {
console.log(`\n--- ${t.slug} (${t.countryPack} / ${t.currency}) ---`);
const tenantId = idBySlug.get(t.slug);
if (!tenantId) {
console.error(`❌ لا UUID للمستأجر ${t.slug} — تخطّي`);
continue;
}
let seed = 500 + TENANTS.indexOf(t) * 100;
const token = await loginRider(t.slug, tenantId, t.countryPack, seed++);
if (!token) {
console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`);
continue;
}
// الفئات المسحوبة يجب أن تُرفض — تسريب الـ2.5 دينار كان هنا.
for (const serviceClass of REJECTED_CLASSES) {
const res = await quoteFor(t.slug, token, serviceClass, RIDES[2]); // الأطول
if (res.status === 400) {
console.log(` ✅ ${serviceClass} مرفوضة كما يجب (400)`);
} else {
console.error(
` ❌ ${serviceClass} لم تُرفض: ${res.status} — رحلة ${RIDES[2].label} بسعر ثابت تسريب.`,
);
rejectionsOk = false;
}
}
for (const ride of RIDES) {
for (const serviceClass of SERVICE_CLASSES) {
const res = await quoteFor(t.slug, token, serviceClass, ride);
const trip = res.body?.trip;
if (!trip) {
console.error(` ❌ ${serviceClass} / ${ride.label}: ${res.status} ${JSON.stringify(res.body)}`);
continue;
}
rows.push({
tenant: t.slug,
country: t.countryPack,
currency: t.currency,
ride: ride.label,
serviceClass,
distanceKm: trip.distance_km,
durationMin: trip.duration_min,
fare: trip.quoted_fare,
tariffFound: trip.quoted_fare != null,
});
}
}
}
console.log('\n=== النتائج ===\n');
// **حارس العدم**: النسخة الأولى طبعت «الاختبار ناجح» بعد أن فشل تسجيل
// الدخول وجُمع صفر صفّ — لأن كل تحقّق كان على مجموعة فارغة فمرّ فراغاً.
// اختبار يعلن النجاح وهو لم ينفّذ شيئاً أسوأ من اختبار يفشل: الفشل يُرى.
const expected = TENANTS.length * RIDES.length * SERVICE_CLASSES.length;
if (rows.length === 0) {
console.error('❌ لم يُنفَّذ ولا طلب واحد — لا نتيجة تُقرأ. راجع أخطاء تسجيل الدخول أعلاه.');
process.exitCode = 1;
return;
}
if (rows.length < expected) {
console.error(`⚠️ نُفِّذ ${rows.length} من ${expected} طلباً — التغطية ناقصة.`);
}
const noQuote = rows.filter((r) => !r.tariffFound);
if (noQuote.length) {
console.error(`❌ ${noQuote.length} طلب بلا سعر (تعرفة مفقودة) — هذا هو العطل الذي أصلحناه، فوجوده الآن خطأ حرج:`);
for (const r of noQuote) console.error(` ${r.tenant} / ${r.serviceClass} / ${r.ride}`);
} else {
console.log(`✅ كل الطلبات (${rows.length}) رجعت سعراً — لا رحلة مجانية.`);
}
for (const t of TENANTS) {
const forTenant = rows.filter((r) => r.tenant === t.slug);
if (!forTenant.length) continue;
console.log(`\n### ${t.slug} (${t.currency}) ###`);
for (const ride of RIDES) {
console.log(`\n ${ride.label}:`);
const forRide = forTenant.filter((r) => r.ride === ride.label);
if (forRide.length) {
console.log(` مسافة: ${forRide[0].distanceKm} كم · زمن: ${forRide[0].durationMin} دقيقة`);
}
for (const cls of SERVICE_CLASSES) {
const r = forRide.find((x) => x.serviceClass === cls);
console.log(` ${cls.padEnd(10)} ${r ? r.fare + ' ' + t.currency : '—'}`);
}
}
}
console.log(`\n=== التحقق: ترتيب الفئات لا ينعكس ===\n`);
let orderOk = true;
for (const t of TENANTS) {
for (const ride of RIDES) {
const forRide = rows.filter((r) => r.tenant === t.slug && r.ride === ride.label);
const economy = forRide.find((r) => r.serviceClass === 'economy')?.fare;
const van = forRide.find((r) => r.serviceClass === 'van')?.fare;
if (economy != null && van != null && van <= economy) {
orderOk = false;
console.error(`❌ ${t.slug} / ${ride.label}: الفان (${van}) ≤ الاقتصادي (${economy})`);
}
}
}
if (orderOk) console.log('✅ الفان أغلى من الاقتصادي في كل رحلة وكل بلد.');
// المقارنة تحتاج الفئتين معاً؛ غيابهما يعني تحقّقاً لم يقع لا تحقّقاً نجح.
const comparable = rows.filter((r) => ['economy', 'van'].includes(r.serviceClass)).length;
if (comparable === 0) console.error('⚠️ لا بيانات كافية للمقارنة — التحقّق أعلاه لم يقع فعلياً.');
const bad = noQuote.length > 0 || !orderOk || rows.length < expected || !rejectionsOk;
process.exitCode = bad ? 1 : 0;
console.log(bad ? '\n❌ فشل الاختبار.' : `\n✅ الاختبار ناجح (${rows.length} طلباً).`);
}
main().catch((e) => {
console.error('خطأ غير متوقع:', e);
process.exit(1);
});
-130
View File
@@ -1,130 +0,0 @@
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
//
// ⚠️ يتطلّب أيضاً OTP_DEV_MODE=true: قسم التعليق (10) ينادي send-otp مرّتين،
// وبلا وضع التطوير تصير كل دورة اختبار رسالتَي واتساب مدفوعتين فعلياً.
//
// التشغيل:
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
// node:22-alpine node /s/provision-test.mjs
const BASE = process.env.BASE || 'http://localhost:4010/api';
const SECRET = process.env.SECRET || process.env.PLATFORM_SECRET || '';
let passed = 0, failed = 0;
const check = (n, c, d) => { c ? (passed++, console.log(` ✅ ${n}`)) : (failed++, console.log(` ❌ ${n}${d ? ` — ${d}` : ''}`)); };
async function api(method, path, body, secret = SECRET) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...(secret ? { 'x-platform-secret': secret } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {}; try { json = text ? JSON.parse(text) : {}; } catch {}
return { status: res.status, ok: res.ok, body: json };
}
async function main() {
console.log(`\n🧪 اختبار تزويد السوبر-أدمن على ${BASE}\n`);
if (!SECRET) { console.log(' ❌ لا PLATFORM_SECRET — مرّره عبر -e SECRET=…'); process.exit(1); }
// 1) الحارس: بلا سرّ يُرفض
const noSecret = await api('GET', '/admin/tenants', null, '');
check('نقاط السوبر-أدمن محميّة (بلا سرّ = 401)', noSecret.status === 401, `status=${noSecret.status}`);
const wrong = await api('GET', '/admin/tenants', null, 'wrong-secret');
check('سرّ خاطئ يُرفض', wrong.status === 401);
// 2) الكتالوج
const cat = await api('GET', '/admin/features');
check('كتالوج الميزات ووسائل الدفع متاح', Array.isArray(cat.body.features) && Array.isArray(cat.body.payment_methods));
// 3) تزويد مستأجر كامل
const slug = `test${Date.now().toString().slice(-8)}`;
const prov = await api('POST', '/admin/tenants/provision', {
name: 'مستأجر اختبار', slug, countryPack: 'eg', plan: 'brand',
features: { bots: true }, paymentMethods: ['cash', 'paymob'],
branding: { app_name: 'تطبيق الاختبار', bundle_id_android: 'com.test.rider', bundle_id_ios: 'com.test.rider' },
});
check('تزويد مستأجر جديد نجح', prov.ok, JSON.stringify(prov.body).slice(0, 120));
const tenantId = prov.body.id;
// 4) وسائل الدفع صُفّيت للكتالوج
const summary = await api('GET', `/admin/tenants/${tenantId}/summary`);
check('وسائل الدفع محفوظة', Array.isArray(summary.body.payment_methods) &&
summary.body.payment_methods.includes('paymob'), JSON.stringify(summary.body.payment_methods));
// 5) الاستحقاقات: الميزة المشتراة مفعّلة + الافتراض منع
const ent = await api('GET', `/admin/tenants/${tenantId}/entitlements`);
check('ميزة bots المشتراة مفعّلة', ent.body.features?.bots === true);
check('ميزة غير مشتراة (transit) محجوبة افتراضاً', ent.body.features?.transit === false);
// 6) المانيفست جاهز لسكربت البناء
const manifest = await api('GET', `/admin/tenants/${tenantId}/app-manifest`);
check('المانيفست يحمل bundle ID', manifest.body.bundle_id_android === 'com.test.rider');
check('المانيفست يحمل اسم التطبيق', manifest.body.app_name === 'تطبيق الاختبار');
check('المانيفست يحمل الدولة والدفع', manifest.body.country_pack === 'eg' &&
manifest.body.payment_methods.includes('paymob'));
// 7) slug مكرّر يُرفض
const dup = await api('POST', '/admin/tenants/provision', { name: 'x', slug });
check('slug مكرّر يُرفض', dup.status === 400);
// 8) تعديل وسائل الدفع (تصفية المخترع)
const setPay = await api('PATCH', `/admin/tenants/${tenantId}/payment-methods`, {
methods: ['cash', 'made_up_gateway', 'cliq'],
});
check('وسيلة مخترعة تُصفّى، الصحيحة تبقى',
setPay.body.settings?.payment_methods?.includes('cliq') &&
!setPay.body.settings?.payment_methods?.includes('made_up_gateway'));
// 9) النظرة الشاملة (docs/22 — N1b)
const ov = await api('GET', '/admin/overview?days=30');
check('النظرة الشاملة تعمل', ov.ok && typeof ov.body.totals?.tenants === 'number');
check('المستأجر الجديد يظهر فيها', (ov.body.tenants || []).some((t) => t.slug === slug));
check('أرقام المال أعداد لا نصوص', typeof (ov.body.totals?.gmv) === 'number' &&
typeof (ov.body.totals?.revenue) === 'number');
// P5: الإيراد صار من دفتر الإيراد، وعمولة الرحلات حقل منفصل عنه.
check('الإيراد وعمولة الرحلات حقلان منفصلان',
typeof (ov.body.totals?.trip_commission) === 'number');
const badWindow = await api('GET', '/admin/overview?days=abc');
check('نافذة غير صالحة تسقط للافتراضي لا تنكسر', badWindow.ok && badWindow.body.window_days === 30);
// 10) التعليق يسري فعلياً (docs/22 — N1a) — الحقل كان زخرفة بلا فرض
const badStatus = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'whatever' });
check('حالة مخترعة تُرفض', badStatus.status === 400);
const susp = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'suspended' });
check('تعليق المستأجر نجح', susp.ok && susp.body.status === 'suspended');
// الإثبات الحقيقي: الدخول نفسه يُقطع — لا مجرّد تغيّر الحقل.
const otpWhileSuspended = await fetch(`${BASE}/auth/send-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
body: JSON.stringify({ phone: '01000000000' }),
});
check('المستأجر المعلَّق لا يستطيع حتى طلب OTP (401)', otpWhileSuspended.status === 401,
`status=${otpWhileSuspended.status}`);
const back = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'active' });
check('إعادة التفعيل تعمل', back.ok && back.body.status === 'active');
const otpAfter = await fetch(`${BASE}/auth/send-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
body: JSON.stringify({ phone: '01000000000' }),
});
// نتحقّق من 2xx تحديداً لا من «ليس 401»: لو صادف حدّ الطلبات (429) لمرّ
// الاختبار وهو لا يثبت شيئاً عن إبطال الكاش.
check('بعد إعادة التفعيل يعود الدخول فوراً (الكاش أُبطل)',
otpAfter.status >= 200 && otpAfter.status < 300, `status=${otpAfter.status}`);
console.log('\n' + '═'.repeat(48));
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
console.log('═'.repeat(48) + '\n');
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => { console.error('💥', e.message); process.exit(1); });
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env bash
#
# دورة تحقّق واحدة: افحص البيئة → أقلِع → **انتظر الصحة فعلاً** → اختبر.
#
# سبب وجوده: تسلسل `up -d --build && restart && <اختبار>` كان يشغّل الاختبار
# بينما الـapi ساقطة، فيظهر الفشل كـ`EAI_AGAIN` في الاختبار — أي في المكان
# الخطأ تماماً. ضاعت دورتان كاملتان على تشخيص خاطئ لهذا السبب وحده.
# البوابة هنا تمنع تكراره: لا اختبار قبل ردّ /health الفعلي.
#
# التشغيل من backend/: bash scripts/verify-and-test.sh
# بلا اختبار (نشر فقط): bash scripts/verify-and-test.sh --deploy-only
# مع إعادة بناء: bash scripts/verify-and-test.sh --build
set -uo pipefail
BUILD=0
DEPLOY_ONLY=0
for arg in "$@"; do
case "$arg" in
--build) BUILD=1 ;;
--deploy-only) DEPLOY_ONLY=1 ;;
*) echo "خيار غير معروف: $arg"; exit 2 ;;
esac
done
cd "$(dirname "$0")/.." || exit 1
ENV_FILE=".env"
FAILED=0
hr() { printf '%s\n' "────────────────────────────────────────────────"; }
# ── 1) فحص البيئة قبل أي إقلاع ───────────────────────────────────────
hr; echo "1) فحص البيئة"
if [[ ! -f "$ENV_FILE" ]]; then
echo "❌ لا يوجد $ENV_FILE"; exit 1
fi
NODE_ENV_VAL=$(grep -E '^NODE_ENV=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'"'\r ' || true)
OTP_DEV_VAL=$(grep -E '^OTP_DEV_MODE=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'"'\r ' || true)
echo " NODE_ENV=${NODE_ENV_VAL:-<غير مضبوط>} OTP_DEV_MODE=${OTP_DEV_VAL:-<غير مضبوط>}"
# التعارض القاتل: الحارس في main.ts ينهي العملية (exit 1) لا يحذّر فقط،
# فالحاوية تدخل حلقة إعادة تشغيل ويظهر العطل لاحقاً كفشل شبكة مضلِّل.
if [[ "$NODE_ENV_VAL" == "production" && "$OTP_DEV_VAL" == "true" ]]; then
echo " ⚠️ تعارض قاتل: OTP_DEV_MODE=true مع NODE_ENV=production → الـapi لن تقلع."
echo " ↪︎ أطفئ OTP_DEV_MODE تلقائياً (الاختبار لا يحتاجه — يقرأ الرمز من Redis)."
sed -i.bak 's/^OTP_DEV_MODE=.*/OTP_DEV_MODE=false/' "$ENV_FILE"
echo " ✅ OTP_DEV_MODE=false (نسخة احتياطية: ${ENV_FILE}.bak)"
fi
if ! grep -qE '^PLATFORM_SECRET=.+' "$ENV_FILE"; then
echo " ❌ PLATFORM_SECRET غير مضبوط — نقاط السوبر-أدمن مغلقة والاختبار يحتاجها."
exit 1
fi
# ── 2) الإقلاع ───────────────────────────────────────────────────────
hr; echo "2) الإقلاع"
if [[ "$BUILD" == "1" ]]; then
echo " بناء (الاختبارات تعمل داخل مرحلة البناء وتحجب النشر عند الفشل)…"
docker compose up -d --build || { echo "❌ فشل البناء"; exit 1; }
else
docker compose up -d || { echo "❌ فشل الإقلاع"; exit 1; }
fi
# ── 3) بوابة الصحة — لا اختبار قبلها ─────────────────────────────────
hr; echo "3) انتظار /health"
HEALTH_OK=0
for i in $(seq 1 30); do
STATUS=$(docker compose ps --format '{{.Name}} {{.Status}}' | grep '^tripz-api' || true)
# الحاوية في حلقة إعادة تشغيل = خطأ إقلاع، والانتظار لن يصلحه.
if [[ "$STATUS" == *Restarting* ]]; then
echo " ❌ tripz-api في حلقة إعادة تشغيل — خطأ إقلاع، لا مشكلة شبكة."
hr; echo "سبب السقوط (آخر أخطاء):"
docker compose logs api --tail=200 2>/dev/null | grep -E 'ERROR|⛔|Error:' | tail -15
exit 1
fi
if docker run --rm --network tripz-net curlimages/curl:latest \
-fsS --max-time 3 http://tripz-api:4010/api/health >/dev/null 2>&1; then
HEALTH_OK=1
echo " ✅ الـapi تستجيب (بعد ${i} محاولة)"
break
fi
sleep 2
done
if [[ "$HEALTH_OK" != "1" ]]; then
echo " ❌ لم تستجب /health خلال 60 ثانية."
hr; docker compose ps
hr; docker compose logs api --tail=40
exit 1
fi
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
if [[ "$DEPLOY_ONLY" == "1" ]]; then
hr; echo "✅ النشر سليم (بلا اختبار)."; exit 0
fi
# ── 4) إبطال كاش التعرفة ─────────────────────────────────────────────
# `getActive` تكاشي في Redis 900 ثانية، و**Redis حاوية منفصلة لا تتأثر
# بإعادة تشغيل الـapi**. تطبيق `tariffs.sql` مباشرةً على القاعدة يتجاوز
# التطبيق فلا يُبطل شيئاً — فبقي النظام يخدم أسعاراً قديمة عبر دورتَي نشر
# كاملتين وهو يعلن النجاح. المسح هنا يجعل الطزاجة مضمونة بالبناء لا
# بالتذكّر.
hr; echo "4) إبطال كاش التعرفة"
KEYS=$(docker compose exec -T redis redis-cli -n "${REDIS_DB:-3}" --scan --pattern 'tripz:tariff:*' 2>/dev/null | tr -d '\r')
if [[ -n "$KEYS" ]]; then
COUNT=$(printf '%s\n' "$KEYS" | grep -c . || true)
printf '%s\n' "$KEYS" | xargs -r docker compose exec -T redis redis-cli -n "${REDIS_DB:-3}" DEL >/dev/null
echo " ✅ مُسح $COUNT مفتاح تعرفة"
else
echo " ℹ️ لا مفاتيح تعرفة مكاشية"
fi
# ── 5) اختبار التسعير ────────────────────────────────────────────────
hr; echo "5) اختبار التسعير"
SECRET=$(grep -E '^PLATFORM_SECRET=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'"'\r ')
docker run --rm --network tripz-net \
-e BASE=http://tripz-api:4010/api \
-e PLATFORM_SECRET="$SECRET" \
-v "$(pwd)/scripts:/s" \
node:22-alpine node /s/pricing-test.mjs
FAILED=$?
hr
if [[ "$FAILED" == "0" ]]; then echo "✅ تمّت الدورة بنجاح."; else echo "❌ فشل الاختبار (رمز $FAILED)."; fi
exit $FAILED
-122
View File
@@ -1,122 +0,0 @@
// اختبار تسابق المحفظة (docs/17 — I1): يثبت أن المال لا يُفقد ولا يُخلق تحت التزامن.
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات.
//
// لماذا سكربت منفصل: pg-mem في اختبارات jest أحادي الخيط فيثبت صحة الـSQL فقط،
// أما التزامن الحقيقي على نفس الصف فيحتاج Postgres حقيقياً.
//
// التشغيل (حاوية node مؤقتة على شبكة tripz):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/wallet-race-test.mjs 100 5
// الوسائط: <عدد محاولات الخصم المتزامنة> <مبلغ كل خصم>
const BASE = process.env.BASE || 'http://localhost:4010/api';
const TENANT = process.env.TENANT || 'siro';
const ATTEMPTS = parseInt(process.argv[2] || '100', 10);
const AMOUNT = parseFloat(process.argv[3] || '5');
const CODE = '1234';
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* نص غير JSON */ }
return { ok: res.ok, status: res.status, body: json };
}
async function token(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
if (!r.ok) throw new Error(`auth failed: ${r.status} ${JSON.stringify(r.body)}`);
return r.body.access_token;
}
async function main() {
// مبلغ يكفي نصف المحاولات فقط — النصف الآخر *يجب* أن يُرفض.
const expectedSuccesses = Math.floor(ATTEMPTS / 2);
const funded = expectedSuccesses * AMOUNT;
const phone = `0796${String(Date.now()).slice(-6)}`;
const t = await token(phone);
await api('POST', '/wallet/topup', t, { amount: funded });
const before = await api('GET', '/wallet', t);
const startBalance = Number(before.body.balance);
console.log(`المحفظة: ${phone} | الرصيد الابتدائي = ${startBalance}`);
if (startBalance !== funded) {
console.log(`⚠️ الشحن لم يطابق المتوقع (${funded}) — أوقفت الاختبار.`);
process.exit(1);
}
// تدفّق السحب صار خطوتين (docs/17 — I4): الطلب يرسل رمزاً ولا يخصم شيئاً،
// والخصم يقع عند التأكيد. فالسباق هنا على `confirm` لا على `request`.
// يعتمد dev_code الذي ترجعه النقطة في وضع التطوير (OTP_DEV_MODE=true).
console.log(`تحضير ${ATTEMPTS} طلب سحب (بلا خصم)…`);
const requested = await Promise.all(
Array.from({ length: ATTEMPTS }, () =>
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch(() => null),
),
);
const pending = requested.filter((r) => r?.ok && r.body?.payout?.id && r.body?.dev_code);
if (pending.length !== ATTEMPTS) {
console.log(`⚠️ حُضّر ${pending.length}/${ATTEMPTS} فقط.`);
if (pending.length === 0) {
console.log('لا شيء لتأكيده — تأكّد أن OTP_DEV_MODE=true (يُرجع dev_code).');
process.exit(1);
}
}
// فحص وسيط: الطلب وحده يجب ألّا يمسّ الرصيد إطلاقاً.
const mid = await api('GET', '/wallet', t);
const midBalance = Number(mid.body.balance);
console.log(`إطلاق ${pending.length} تأكيد متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
const t0 = Date.now();
const results = await Promise.all(
pending.map((r) =>
api('POST', `/payouts/${r.body.payout.id}/confirm`, t, {
code: String(r.body.dev_code),
}).catch((e) => ({ ok: false, status: 0, body: { message: String(e) } })),
),
);
const elapsed = Date.now() - t0;
const ok = results.filter((r) => r.ok).length;
const rejected = results.filter((r) => !r.ok).length;
const after = await api('GET', '/wallet', t);
const endBalance = Number(after.body.balance);
const spent = startBalance - endBalance;
console.log('');
console.log(`نجح : ${ok} (المتوقع ${expectedSuccesses})`);
console.log(`رُفض : ${rejected}`);
console.log(`الرصيد : ${startBalance} → ${endBalance} (خُصم ${spent})`);
console.log(`الزمن : ${elapsed}ms`);
console.log('');
const checks = [
['الطلب وحده لا يمسّ الرصيد (لا مال قبل إثبات الهوية)', midBalance === startBalance],
['الرصيد لم يصبح سالباً', endBalance >= 0],
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
];
let failed = 0;
for (const [name, pass] of checks) {
console.log(`${pass ? '✅' : '❌'} ${name}`);
if (!pass) failed++;
}
console.log('');
console.log(failed === 0 ? '✅ لا سباق: المحفظة ذرّية.' : `❌ ${failed} محكّ فشل — يوجد سباق.`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('خطأ:', e.message);
process.exit(1);
});
-136
View File
@@ -1,136 +0,0 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import configuration from './config/configuration';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { RedisModule } from './common/redis/redis.module';
import { I18nModule } from './common/i18n/i18n.module';
import { CacheModule } from './common/cache/cache.module';
import { EntitlementsModule } from './common/entitlements/entitlements.module';
import { PlatformModule } from './common/platform/platform.module';
import { AuditModule } from './common/audit/audit.module';
import { SigningModule } from './common/signing/signing.module';
import { PhoneModule } from './common/phone/phone.module';
import { DeviceModule } from './common/device/device.module';
import { SeedModule } from './common/seed/seed.module';
import { HealthModule } from './modules/health/health.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { MatchingModule } from './modules/matching/matching.module';
import { LocationsModule } from './modules/locations/locations.module';
import { TariffModule } from './modules/tariff/tariff.module';
import { MapsModule } from './modules/maps/maps.module';
import { TripsModule } from './modules/trips/trips.module';
import { RealtimeModule } from './realtime/realtime.module';
import { FraudModule } from './modules/fraud/fraud.module';
import { ChatModule } from './modules/chat/chat.module';
import { RatingsModule } from './modules/ratings/ratings.module';
import { NabehModule } from './integrations/nabeh/nabeh.module';
import { OtpModule } from './integrations/otp/otp.module';
import { RideTypesModule } from './modules/ride-types/ride-types.module';
import { DispatchModule } from './modules/dispatch/dispatch.module';
import { WalletModule } from './modules/wallet/wallet.module';
import { TenantWalletModule } from './modules/tenant-wallet/tenant-wallet.module';
import { CreditModule } from './modules/credit/credit.module';
import { RewardsModule } from './modules/rewards/rewards.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { StorageModule } from './common/storage/storage.module';
import { DocumentsModule } from './modules/documents/documents.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { GeminiModule } from './integrations/gemini/gemini.module';
import { BillingModule } from './modules/billing/billing.module';
import { GeofenceModule } from './modules/geofence/geofence.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { CronModule } from './common/cron/cron.module';
import { MarketingModule } from './modules/marketing/marketing.module';
import { TransitModule } from './modules/transit/transit.module';
import { BotModule } from './modules/bots/bot.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'),
// بركة اتصالات أكبر — يرفع سقف التزامن تحت الحمل العالي
extra: { max: parseInt(process.env.DB_POOL_MAX ?? '30', 10) },
// بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14
entityPrefix: cfg.get<string>('db.tablePrefix'),
synchronize: cfg.get<boolean>('db.synchronize'),
autoLoadEntities: true,
}),
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]),
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
I18nModule, // عالمي — ترجمة نصوص الإشعارات
CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة)
EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard
PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret)
AuditModule, // عالمي — سجل التدقيق المالي
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
DeviceModule, // عالمي — ربط الجلسة بالجهاز
NabehModule, // عالمي — إرسال OTP واتساب (سوريا/الأردن)
OtpModule, // عالمي — توجيه OTP متعدد المزوّدين حسب الدولة + failover
NotificationsModule, // عالمي — FCM
StorageModule, // عالمي — تخزين ملفات الوثائق
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
GeofenceModule,
HealthModule,
TenantsModule,
UsersModule,
AuthModule,
MapsModule,
MatchingModule,
LocationsModule,
TariffModule,
RideTypesModule,
DriversModule,
RealtimeModule,
FraudModule,
WalletModule,
TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24)
CreditModule,
RewardsModule, // الإحالات والكوبونات (المجموعة L)
PaymentsModule,
BillingModule,
TripsModule,
DispatchModule,
ChatModule,
RatingsModule,
DocumentsModule,
VehiclesModule,
SeedModule,
CatalogModule,
CronModule, // O5 — المهام الدورية الموحّدة على BullMQ
MarketingModule, // O3 — محرك التسويق التلقائي
TransitModule, // O2 — نظام المواصلات
BotModule, // O4 — بوتات التواصل الاجتماعي
],
providers: [
// ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
// بلا أي حارس يطبّقه، فتحديد 120/60s لم يكن ساري المفعول إطلاقاً على أي
// نقطة. APP_GUARD يُفعّله عالمياً على كل نقطة تلقائياً (docs/17 — D4).
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');
}
}
@@ -1,56 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* سجل التدقيق المالي (docs/17 — I7؛ مكافئ `admin_audit_log` عند سيرو).
* الجدول: tripz_audit_log. **append-only** — لا تحديث ولا حذف.
*
* يجيب عن سؤال واحد عند كل نزاع: **من فعل ماذا ومتى ومن أين؟**
*/
@Entity('audit_log')
@Index(['tenant_id', 'created_at'])
@Index(['tenant_id', 'subject_type', 'subject_id'])
export class AuditLog {
@PrimaryGeneratedColumn('increment')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
/** من نفّذ الفعل — قد يكون السائق نفسه أو أدمن أو `null` للنظام. */
@Column({ type: 'uuid', nullable: true })
actor_user_id: string | null;
@Column({ type: 'varchar', nullable: true })
actor_role: string | null;
/** payout.request · payout.confirm · payout.complete · payout.fail · credit.topup … */
@Column()
action: string;
@Column({ type: 'varchar', nullable: true })
subject_type: string | null; // payout | credit | wallet
@Column({ type: 'varchar', nullable: true })
subject_id: string | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
amount: number | null;
@Column({ type: 'varchar', nullable: true })
currency: string | null;
@Column({ type: 'jsonb', default: {} })
meta: Record<string, any>;
@Column({ type: 'varchar', nullable: true })
ip: string | null;
@CreateDateColumn()
created_at: Date;
}
-12
View File
@@ -1,12 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from './audit-log.entity';
import { AuditService } from './audit.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
-58
View File
@@ -1,58 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from './audit-log.entity';
export interface AuditEntry {
tenantId: string;
action: string;
actorUserId?: string | null;
actorRole?: string | null;
subjectType?: string;
subjectId?: string;
amount?: number | null;
currency?: string | null;
meta?: Record<string, any>;
ip?: string | null;
}
/** سجل التدقيق المالي (docs/17 — I7). */
@Injectable()
export class AuditService {
private readonly logger = new Logger('Audit');
constructor(@InjectRepository(AuditLog) private readonly repo: Repository<AuditLog>) {}
/**
* يسجّل فعلاً مالياً.
*
* **لا يرمي أبداً**: فشل التدقيق يجب ألّا يُسقط عمليةً ماليةً نجحت — وإلا
* صار السجل نقطة فشل بدل أن يكون شبكة أمان. الفشل يُسجَّل في اللوغ ليُرى.
*/
async record(entry: AuditEntry): Promise<void> {
try {
await this.repo.insert({
tenant_id: entry.tenantId,
actor_user_id: entry.actorUserId ?? null,
actor_role: entry.actorRole ?? null,
action: entry.action,
subject_type: entry.subjectType ?? null,
subject_id: entry.subjectId ?? null,
amount: entry.amount ?? null,
currency: entry.currency ?? null,
meta: entry.meta ?? {},
ip: entry.ip ?? null,
});
} catch (e: any) {
this.logger.error(`audit write failed [${entry.action}]: ${e?.message}`);
}
}
/** سجلّ موضوع بعينه — للتحقيق في نزاع. */
forSubject(tenantId: string, subjectType: string, subjectId: string) {
return this.repo.find({
where: { tenant_id: tenantId, subject_type: subjectType, subject_id: subjectId },
order: { created_at: 'ASC' },
});
}
}
-9
View File
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { CacheService } from './cache.service';
@Global()
@Module({
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
-71
View File
@@ -1,71 +0,0 @@
import RedisMock from 'ioredis-mock';
import { CacheService, CacheKeys } from './cache.service';
describe('CacheService', () => {
let redis: any;
let cache: CacheService;
// ioredis-mock يشارك مخزناً واحداً بين النسخ ذات الإعدادات نفسها —
// بلا flushall تتسرّب مفاتيح اختبار إلى الذي يليه.
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
cache = new CacheService(redis);
});
it('يخزّن ويقرأ قيمة مركّبة', async () => {
await cache.set('k', { a: 1, b: ['x'] }, 60);
expect(await cache.get<{ a: number; b: string[] }>('k')).toEqual({ a: 1, b: ['x'] });
});
it('يرجع null لمفتاح غائب', async () => {
expect(await cache.get('nope')).toBeNull();
});
it('wrap: يحمّل من المصدر مرة واحدة ثم يخدم من الكاش', async () => {
const loader = jest.fn().mockResolvedValue({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(loader).toHaveBeenCalledTimes(1);
});
it('wrap: لا يخزّن null — الغياب المؤقّت لا يُثبَّت', async () => {
const loader = jest.fn().mockResolvedValue(null);
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(loader).toHaveBeenCalledTimes(2);
});
it('del يُبطل المفتاح فيعود المصدر', async () => {
const loader = jest.fn().mockResolvedValue('v1');
await cache.wrap('k', 60, loader);
await cache.del('k');
await cache.wrap('k', 60, loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('mget يقرأ عدة مفاتيح ويعطي null للغائب', async () => {
await cache.set('a', 1, 60);
await cache.set('c', 3, 60);
expect(await cache.mget<number>(['a', 'b', 'c'])).toEqual([1, null, 3]);
});
it('فشل Redis لا يُسقط الطلب — يرجع للمصدر', async () => {
const broken = {
get: jest.fn().mockRejectedValue(new Error('redis down')),
set: jest.fn().mockRejectedValue(new Error('redis down')),
} as any;
const c = new CacheService(broken);
const loader = jest.fn().mockResolvedValue('from-db');
expect(await c.wrap('k', 60, loader)).toBe('from-db');
expect(loader).toHaveBeenCalledTimes(1);
});
it('المفاتيح معزولة بالمستأجر', () => {
expect(CacheKeys.userLang('t1', 'u1')).not.toBe(CacheKeys.userLang('t2', 'u1'));
expect(CacheKeys.tariff('t1', 'amman', 'economy')).toBe('tariff:t1:amman:economy');
});
});
-103
View File
@@ -1,103 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS } from '../redis/redis.module';
/** أعمار الكاش بالثواني — مجمّعة هنا ليسهل ضبطها (docs/17 — G). */
export const TTL = {
/** المستأجر يتغيّر نادراً جداً. */
tenant: 3600,
/** لغة المستخدم — يرفعها فلاتر عند الفتح. */
userLang: 24 * 3600,
/** توكنات FCM للجهاز. */
deviceTokens: 6 * 3600,
/** التعرفة و ride-types — شبه ثابتة، وتُبطَل صراحةً عند تعديل الأدمن. */
catalog: 900,
/** نتائج الخرائط — عناوين ومسارات لا تتغيّر عملياً. */
maps: 24 * 3600,
} as const;
/**
* كاش-جانبي (cache-aside) فوق Redis — «الريدز خط أول، القاعدة خط احتياط»
* (docs/17 — G).
*
* مبدأ صارم: **فشل Redis لا يُسقط الطلب**. أي خطأ هنا يُسجَّل ويُرجَع للقاعدة،
* لأن الكاش تحسين أداء لا مصدر حقيقة.
*/
@Injectable()
export class CacheService {
private readonly logger = new Logger('Cache');
constructor(@Inject(REDIS) private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
try {
const raw = await this.redis.get(key);
return raw == null ? null : (JSON.parse(raw) as T);
} catch (e: any) {
this.logger.warn(`get ${key} failed: ${e?.message}`);
return null;
}
}
async set(key: string, value: unknown, ttlSec: number): Promise<void> {
try {
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSec);
} catch (e: any) {
this.logger.warn(`set ${key} failed: ${e?.message}`);
}
}
async del(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.redis.del(...keys);
} catch (e: any) {
this.logger.warn(`del failed: ${e?.message}`);
}
}
/**
* يقرأ من الكاش، وعند الغياب يحمّل من المصدر ويخزّن.
* `null` من المصدر لا يُخزَّن — لئلا يُثبَّت غياب مؤقّت لمدة TTL كاملة.
*/
async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T | null>): Promise<T | null> {
const hit = await this.get<T>(key);
if (hit !== null) return hit;
const fresh = await loader();
if (fresh !== null && fresh !== undefined) await this.set(key, fresh, ttlSec);
return fresh;
}
/** قراءة عدة مفاتيح بجولة واحدة — للبث الجماعي (docs/17 — G3). */
async mget<T>(keys: string[]): Promise<(T | null)[]> {
if (keys.length === 0) return [];
try {
const raws = await this.redis.mget(...keys);
return raws.map((raw) => {
if (raw == null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
});
} catch (e: any) {
this.logger.warn(`mget failed: ${e?.message}`);
return keys.map(() => null);
}
}
}
/** مفاتيح الكاش — مجمّعة لتفادي التضارب ولتسهيل الإبطال. */
export const CacheKeys = {
tenant: (slugOrId: string) => `tenant:${slugOrId}`,
userLang: (tenantId: string, userId: string) => `user:lang:${tenantId}:${userId}`,
deviceTokens: (tenantId: string, userId: string) => `user:fcm:${tenantId}:${userId}`,
tariff: (tenantId: string, city: string, serviceClass: string) =>
`tariff:${tenantId}:${city}:${serviceClass}`,
rideTypes: (tenantId: string) => `ridetypes:${tenantId}`,
mapsRoute: (from: string, to: string) => `maps:route:${from}:${to}`,
mapsReverse: (lat: string, lng: string) => `maps:rev:${lat}:${lng}`,
mapsGeocode: (country: string, q: string) => `maps:geo:${country}:${q}`,
};
-40
View File
@@ -1,40 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
/**
* اسم الطابور الوحيد — كل المهام الدورية تدخل هنا.
* البادئة `tripz_` تأتي من `QUEUE_PREFIX` في الإعدادات.
*/
export const CRON_QUEUE = 'tripz_cron';
/**
* وحدة BullMQ عالمية — تنشئ طابوراً واحداً لكل المهام الدورية.
*
* النمط: `@Global` مثل `RedisModule` — تُصدَّر وتُستهلك في أي وحدة.
* الطابور يقرأ اتصال Redis من `REDIS_CLIENT` (نفس DB 3 + البادئة tripz:).
*/
@Global()
@Module({
providers: [
{
provide: CRON_QUEUE,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Queue(CRON_QUEUE, {
connection: {
host: cfg.get<string>('redis.host'),
port: cfg.get<number>('redis.port'),
db: cfg.get<number>('redis.db'),
},
prefix: cfg.get<string>('queue.prefix'),
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
},
}),
},
],
exports: [CRON_QUEUE],
})
export class BullModule {}
@@ -1,80 +0,0 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlatformGuard } from '../platform/platform.guard';
import { JobExecution } from './job-execution.entity';
import { CronOrchestratorService } from './cron-orchestrator.service';
import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry';
/**
* لوحة المهام الدورية — السوبر-أدن (O5).
*
* تُظهر: آخر نجاح/فشل لكل مهمة + مدة التنفيذ + السجل التاريخي.
* لا مهمة صامتة — كل ما في الجدول هو ما حدث فعلاً.
*/
@ApiTags('cron')
@Controller()
export class CronAdminController {
constructor(
private readonly orchestrator: CronOrchestratorService,
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
) {}
/** إحصائيات كل المهام + آخر تنفيذ. */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/cron/stats')
stats() {
return this.orchestrator.getStats();
}
/** سجل التنفيذ التاريخي (مع التصفية حسب اسم المهمة). */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/cron/executions')
async listExecutions(
@Query('job_name') jobName?: string,
@Query('limit') limit?: string,
) {
const take = Math.min(parseInt(limit ?? '50', 10) || 50, 200);
const where: any = {};
if (jobName) where.job_name = jobName;
return this.execRepo.find({ where, order: { started_at: 'DESC' }, take });
}
/** آخر 10 تنفيذات لكل مهمة (ملخص سريع). */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/cron/summary')
async summary() {
const jobs = Object.keys(CRON_SCHEDULES) as CronJobName[];
const result: Record<string, {
label: string;
everyMs: number;
recent: Array<{ status: string; started_at: Date; duration_ms: number | null; items_processed: number; error_message: string | null }>;
}> = {};
for (const name of jobs) {
const recent = await this.execRepo.find({
where: { job_name: name },
order: { started_at: 'DESC' },
take: 10,
});
result[name] = {
label: CRON_JOB_LABELS[name],
everyMs: CRON_SCHEDULES[name].everyMs,
recent: recent.map((e) => ({
status: e.status,
started_at: e.started_at,
duration_ms: e.duration_ms,
items_processed: e.items_processed,
error_message: e.error_message,
})),
};
}
return result;
}
}
@@ -1,111 +0,0 @@
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Queue } from 'bullmq';
import { Repository } from 'typeorm';
import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry';
import { CRON_QUEUE } from './bull.module';
import { JobExecution } from './job-execution.entity';
/**
* منسّق المهام الدورية (O5).
*
* يعمل في عملية **الـAPI** فقط (ليس الـworker) لأنه يملك حقن كل الخدمات.
* مسؤولياته:
* 1. تسجيل المهام المتكررة (repeatable jobs) عند بدء التشغيل
* 2. قفل ضد التشغيل المزدوج (BullMQ concurrency = 1)
* 3. تعطيل المهام عند الإغلاق
*
* لا تُسجَّل المهام هنا — `CronWorker` هو من يprocessها ويكتب السجل.
*/
@Injectable()
export class CronOrchestratorService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger('CronOrchestrator');
private readonly registeredJobs = new Map<CronJobName, string>();
constructor(
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
@Inject(CRON_QUEUE)
private readonly queue: Queue,
) {}
async onModuleInit(): Promise<void> {
const runningJobs = await this.execRepo.find({
where: { status: 'running' },
order: { started_at: 'DESC' },
});
if (runningJobs.length > 0) {
this.logger.warn(
`found ${runningJobs.length} orphaned running job(s) — marking as timeout`,
);
for (const job of runningJobs) {
job.status = 'timeout';
job.finished_at = new Date();
job.error_message = 'Orphaned: server restarted while job was running';
await this.execRepo.save(job);
}
}
for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) {
await this.queue.upsertJobScheduler(
name,
{ every: schedule.everyMs },
{
name,
data: { name },
opts: {
removeOnComplete: 100,
removeOnFail: 50,
},
},
);
this.registeredJobs.set(name, name);
this.logger.log(`registered: ${name} (every ${schedule.everyMs / 1000}s)`);
}
this.logger.log(`${this.registeredJobs.size} cron job(s) registered`);
}
async onModuleDestroy(): Promise<void> {
for (const [name] of this.registeredJobs) {
await this.queue.removeJobScheduler(name);
}
this.logger.log(`removed ${this.registeredJobs.size} repeatable job(s)`);
}
async getStats() {
const stats: Array<{
name: string;
label: string;
everyMs: number;
lastExecution?: {
status: string;
started_at: Date;
duration_ms: number | null;
error_message: string | null;
};
}> = [];
for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) {
const last = await this.execRepo.findOne({
where: { job_name: name },
order: { started_at: 'DESC' },
});
stats.push({
name,
label: CRON_JOB_LABELS[name] ?? name,
everyMs: schedule.everyMs,
lastExecution: last
? {
status: last.status,
started_at: last.started_at,
duration_ms: last.duration_ms,
error_message: last.error_message,
}
: undefined,
});
}
return stats;
}
}
-51
View File
@@ -1,51 +0,0 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Queue, Worker, Job } from 'bullmq';
import { Repository } from 'typeorm';
import { JobExecution } from './job-execution.entity';
/**
* أسماء المهام الدورية — مصدر الحقيقة الوحيد.
* لا سلاسل نصية مبعثرة في الكود.
*/
export const CRON_JOBS = {
SCHEDULED_TRIPS_SWEEP: 'cron:scheduled_trips_sweep',
SEARCH_TIMEOUT_SWEEP: 'cron:search_timeout_sweep',
REWARDS_SWEEP: 'cron:rewards_sweep',
SURGE_RECALCULATE: 'cron:surge_recalculate',
RE_ENGAGEMENT: 'cron:re_engagement',
TRANSIT_APPROACHING_ALERTS: 'cron:transit_approaching_alerts',
TRANSIT_SYNC_MEMBERS: 'cron:transit_sync_members',
BOT_SOCIAL_TASKS: 'cron:bot_social_tasks',
} as const;
export type CronJobName = (typeof CRON_JOBS)[keyof typeof CRON_JOBS];
/**
* جدولة كل مهمة — كل مهمة لها فاصلها وحدّها الأقصى.
*/
export const CRON_SCHEDULES: Record<CronJobName, { everyMs: number; timeoutMs: number }> = {
[CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: { everyMs: 30_000, timeoutMs: 15_000 },
[CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 },
[CRON_JOBS.REWARDS_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 },
[CRON_JOBS.SURGE_RECALCULATE]: { everyMs: 180_000, timeoutMs: 60_000 },
[CRON_JOBS.RE_ENGAGEMENT]: { everyMs: 86_400_000, timeoutMs: 120_000 },
[CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: { everyMs: 60_000, timeoutMs: 30_000 },
[CRON_JOBS.TRANSIT_SYNC_MEMBERS]: { everyMs: 86_400_000, timeoutMs: 120_000 },
[CRON_JOBS.BOT_SOCIAL_TASKS]: { everyMs: 900_000, timeoutMs: 60_000 },
};
/**
* أسماء المهام الودية (للقسم).
*/
export const CRON_JOB_LABELS: Record<CronJobName, string> = {
[CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: 'Release scheduled trips',
[CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: 'Expire stuck searching trips',
[CRON_JOBS.REWARDS_SWEEP]: 'Pay out qualified referrals',
[CRON_JOBS.SURGE_RECALCULATE]: 'Recalculate surge multipliers',
[CRON_JOBS.RE_ENGAGEMENT]: 'Re-engagement notifications',
[CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: 'Transit approaching station alerts',
[CRON_JOBS.TRANSIT_SYNC_MEMBERS]: 'Sync transit member groups',
[CRON_JOBS.BOT_SOCIAL_TASKS]: 'Execute social media bot tasks',
};
@@ -1,176 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Worker, Job } from 'bullmq';
import { Repository } from 'typeorm';
import { CRON_JOBS, CronJobName } from './cron-registry';
import { CRON_QUEUE } from './bull.module';
import { JobExecution } from './job-execution.entity';
import { TripsService } from '../../modules/trips/trips.service';
import { ReferralsService } from '../../modules/rewards/referrals.service';
import { SurgeService } from '../../modules/tariff/surge.service';
import { MarketingService } from '../../modules/marketing/marketing.service';
import { BotService } from '../../modules/bots/bot.service';
/**
* معالج المهام الدورية (O5).
*
* يعمل في عملية **الـAPI** (ليس الـworker) لأنه يحتاج حقن الخدمات.
* كل مهمة تكتب سجل تنفيذ في `job_executions`.
*
* قفل ضد التشغيل المزدوج: `concurrency: 1` في الـWorker.
*/
@Injectable()
export class CronWorkerService {
private readonly logger = new Logger('CronWorker');
private worker?: Worker;
constructor(
@InjectRepository(JobExecution)
private readonly execRepo: Repository<JobExecution>,
private readonly config: ConfigService,
private readonly tripsService: TripsService,
private readonly referralsService: ReferralsService,
private readonly surgeService: SurgeService,
private readonly marketingService: MarketingService,
private readonly botService: BotService,
) {}
start(): void {
const connection = {
host: this.config.get<string>('redis.host'),
port: this.config.get<number>('redis.port'),
db: this.config.get<number>('redis.db'),
};
this.worker = new Worker(
CRON_QUEUE,
async (job: Job) => {
const name = job.name as CronJobName;
const execution = await this.startExecution(name);
try {
let itemsProcessed = 0;
switch (name) {
case CRON_JOBS.SCHEDULED_TRIPS_SWEEP:
itemsProcessed = await this.tripsService.releaseDueScheduled();
break;
case CRON_JOBS.SEARCH_TIMEOUT_SWEEP: {
const timeoutMs = this.config.get<number>('trips.searchTimeoutMs') ?? 900_000;
itemsProcessed = await this.tripsService.expireStuckSearches(timeoutMs);
break;
}
case CRON_JOBS.REWARDS_SWEEP:
itemsProcessed = await this.referralsService.sweep();
break;
case CRON_JOBS.SURGE_RECALCULATE: {
const result = await this.surgeService.recalculateAll();
itemsProcessed = result.updated + result.unchanged;
break;
}
case CRON_JOBS.RE_ENGAGEMENT: {
const campaigns = await this.marketingService.findDueReEngagement('*', 3);
let totalSent = 0;
for (const campaign of campaigns) {
const inactiveUserIds = await this.marketingService.findInactiveUserIds(
campaign.tenant_id,
3,
);
if (inactiveUserIds.length > 0) {
const result = await this.marketingService.runCampaign(campaign.id, inactiveUserIds);
totalSent += result.sent;
}
}
itemsProcessed = totalSent;
break;
}
case CRON_JOBS.TRANSIT_APPROACHING_ALERTS: {
this.logger.debug('transit approaching alerts: delegated to transit microservice');
itemsProcessed = 1;
break;
}
case CRON_JOBS.TRANSIT_SYNC_MEMBERS: {
this.logger.debug('transit sync members: delegated to transit microservice');
itemsProcessed = 1;
break;
}
case CRON_JOBS.BOT_SOCIAL_TASKS: {
const dueTasks = await this.botService.getDueTasks();
for (const task of dueTasks) {
await this.botService.updateStatus(task.id, 'running');
try {
await this.botService.updateStatus(task.id, 'completed', {
message: 'Task queued for execution',
});
} catch (e: any) {
await this.botService.updateStatus(task.id, 'failed');
}
}
itemsProcessed = dueTasks.length;
break;
}
default:
throw new Error(`Unknown cron job: ${name}`);
}
await this.finishExecution(execution, 'success', itemsProcessed);
if (itemsProcessed > 0) {
this.logger.log(`${name}: processed ${itemsProcessed} item(s)`);
}
} catch (err: any) {
await this.finishExecution(execution, 'failed', 0, err?.message);
this.logger.error(`${name} failed: ${err?.message}`);
throw err;
}
},
{
connection,
concurrency: 1,
prefix: this.config.get<string>('queue.prefix'),
},
);
this.worker.on('ready', () => this.logger.log('cron worker ready'));
this.worker.on('error', (err) => this.logger.error(`cron worker error: ${err.message}`));
}
async stop(): Promise<void> {
if (this.worker) {
await this.worker.close();
this.worker = undefined;
}
}
private async startExecution(jobName: string): Promise<JobExecution> {
const execution = this.execRepo.create({
job_name: jobName,
status: 'running',
started_at: new Date(),
});
return this.execRepo.save(execution);
}
private async finishExecution(
execution: JobExecution,
status: 'success' | 'failed',
itemsProcessed: number,
errorMessage?: string,
): Promise<void> {
const now = new Date();
execution.status = status;
execution.finished_at = now;
execution.duration_ms = now.getTime() - execution.started_at.getTime();
execution.items_processed = itemsProcessed;
execution.error_message = errorMessage ?? null;
await this.execRepo.save(execution);
}
}
-50
View File
@@ -1,50 +0,0 @@
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JobExecution } from './job-execution.entity';
import { BullModule } from './bull.module';
import { CronOrchestratorService } from './cron-orchestrator.service';
import { CronWorkerService } from './cron-worker.service';
import { CronAdminController } from './cron-admin.controller';
import { TripsModule } from '../../modules/trips/trips.module';
import { RewardsModule } from '../../modules/rewards/rewards.module';
import { TariffModule } from '../../modules/tariff/tariff.module';
import { MarketingModule } from '../../modules/marketing/marketing.module';
import { BotModule } from '../../modules/bots/bot.module';
/**
* وحدة المهام الدورية الموحّدة (O5).
*
* تسجّل كل مهمة دورية كـrepeatable job في BullMQ، وتعالجها في عملية واحدة
* مع `concurrency: 1` لمنع التشغيل المزدوج. كل تنفيذ يُسجَّل في
* `job_executions` — لا مهمة صامتة.
*
* لا تُستهلك في الـworker — الـworker يبقى مستقلاً لمفرّغ المواقع (5 ثوانٍ).
*/
@Module({
imports: [
TypeOrmModule.forFeature([JobExecution]),
BullModule,
TripsModule,
RewardsModule,
TariffModule,
MarketingModule,
BotModule,
],
controllers: [CronAdminController],
providers: [CronOrchestratorService, CronWorkerService],
exports: [CronOrchestratorService],
})
export class CronModule implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly orchestrator: CronOrchestratorService,
private readonly worker: CronWorkerService,
) {}
onModuleInit(): void {
this.worker.start();
}
async onModuleDestroy(): Promise<void> {
await this.worker.stop();
}
}
@@ -1,58 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* سجل تنفيذ المهام الدورية (O5).
*
* الجدول: `tripz_job_executions`.
*
* كل صف = تنفيذ واحد لأي مهمة كانت (sweeper, bot, report...).
* الهدف: إظهار آخر نجاح/فشل لكل مهمة + مدة التنفيذ + سبب الفشل.
* لا مهمة صامتة — كل تنفيذ يُسجَّل هنا.
*/
@Entity('job_executions')
@Index(['job_name', 'started_at'])
export class JobExecution {
@PrimaryGeneratedColumn('uuid')
id: string;
/** اسم المهمة (cron:scheduled_trips_sweep, cron:surge_recalculate...). */
@Column()
job_name: string;
/** حالة التنفيذ. */
@Column({ type: 'varchar', length: 20 })
status: 'running' | 'success' | 'failed' | 'timeout';
/** لحظة بدء التنفيذ الفعلي (لا وقت إضاف الطور). */
@Column({ type: 'timestamptz' })
started_at: Date;
/** لحظة انتهاء التنفيذ. */
@Column({ type: 'timestamptz', nullable: true })
finished_at: Date | null;
/** مدة التنفيذ بالميلي ثانية. */
@Column({ type: 'int', nullable: true })
duration_ms: number;
/** عدد العناصر التي عالجتها المهمة (مثلاً: عدد الرحلات التي انتهت). */
@Column({ type: 'int', default: 0 })
items_processed: number;
/** رسالة خطأ (فقط عند الفشل). */
@Column({ type: 'text', nullable: true })
error_message: string | null;
/** معرّف المستأجر (إن كانت المهمة خاصة بمستأجر). */
@Column({ type: 'uuid', nullable: true })
tenant_id: string | null;
@CreateDateColumn()
created_at: Date;
}
-63
View File
@@ -1,63 +0,0 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
} from 'crypto';
/**
* تشفير الحقول الحساسة عند الراحة (at-rest) بـ AES-256-GCM مع IV عشوائي لكل قيمة
* (تشفير موثَّق authenticated — يمنع العبث). صيغة التخزين: v1:base64(iv|tag|ct).
* IV عشوائي 96-بت لكل عملية — لا IV ثابت إطلاقاً (راجع docs/16).
*
* للحقول القابلة للبحث بالتساوي (مثل الهاتف): تشفير GCM عشوائي لا يسمح بالمطابقة،
* فنستخدم blindIndex (HMAC حتمي) كعمود بحث منفصل — مخطّط لاحقاً.
*/
const VERSION = 'v1';
function getKey(): Buffer {
const raw = process.env.ENCRYPTION_KEY || '';
if (/^[0-9a-f]{64}$/i.test(raw)) return Buffer.from(raw, 'hex'); // 32 بايت hex
if (!raw) return createHash('sha256').update('tripz-dev-insecure-key').digest();
return createHash('sha256').update(raw).digest(); // أي سر → 32 بايت
}
export function encrypt(plain: string | null | undefined): string | null {
if (plain === null || plain === undefined) return plain as any;
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${VERSION}:${Buffer.concat([iv, tag, ct]).toString('base64')}`;
}
export function decrypt(value: string | null | undefined): string | null {
if (value === null || value === undefined) return value as any;
if (typeof value !== 'string' || !value.startsWith(`${VERSION}:`)) {
return value as any; // نص قديم غير مشفّر — تسامح أثناء الانتقال
}
try {
const raw = Buffer.from(value.slice(VERSION.length + 1), 'base64');
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const ct = raw.subarray(28);
const d = createDecipheriv('aes-256-gcm', getKey(), iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
} catch {
return value as any; // لا نُسقط القراءة عند قيمة تالفة/قديمة
}
}
/** فهرس أعمى حتمي للحقول القابلة للبحث (مثل الهاتف) — HMAC-SHA256. */
export function blindIndex(value: string): string {
const key = process.env.ENCRYPTION_KEY || 'tripz-dev-insecure-key';
return createHmac('sha256', key).update(value.trim().toLowerCase()).digest('hex');
}
/** محوّل TypeORM: يشفّر عند الكتابة ويفكّ عند القراءة تلقائياً. */
export const EncryptedTransformer = {
to: (v: string | null) => encrypt(v),
from: (v: string | null) => decrypt(v),
};
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { DeviceService } from './device.service';
@Global()
@Module({
providers: [DeviceService],
exports: [DeviceService],
})
export class DeviceModule {}
@@ -1,16 +0,0 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
/**
* ربط الجلسة بالجهاز (docs/17 — D2؛ نمط سيرو).
*
* فلاتر يولّد بصمة جهاز محلية (device_info_plus) ويرسلها **مرة عند الدخول**
* ثم في ترويسة `x-device-id` مع كل طلب لاحق. البصمة الخام لا تُخزَّن — فقط
* بصمتها (hash) داخل التوكن، بنفس منطق عدم تخزين أسرار خام.
*/
@Injectable()
export class DeviceService {
hash(rawDeviceId: string): string {
return createHash('sha256').update(rawDeviceId ?? '').digest('hex');
}
}
@@ -1,17 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { EntitlementsService } from './entitlements.service';
import { FeatureGuard } from './feature.guard';
/**
* عالمي: `FeatureGuard` يُستعمل في وحدات كثيرة، وإجبار كلٍّ منها على استيراد
* الوحدة يعني نقطةً منسيّة يوماً ما — والنقطة المنسيّة ميزةٌ مجانية للجميع.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
providers: [EntitlementsService, FeatureGuard],
exports: [EntitlementsService, FeatureGuard],
})
export class EntitlementsModule {}
@@ -1,55 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { CacheService, TTL } from '../cache/cache.service';
import { Entitlements, Feature, resolveEntitlements } from './features';
const key = (tenantId: string) => `entitlements:${tenantId}`;
/**
* استحقاقات المستأجر — Redis خط أول والقاعدة احتياط (docs/19 — K1).
* تُقرأ على كل نقطة محميّة، لذا لا يجوز أن تكون استعلام قاعدة في كل طلب.
*/
@Injectable()
export class EntitlementsService {
private readonly logger = new Logger('Entitlements');
constructor(
@InjectRepository(Tenant) private readonly tenants: Repository<Tenant>,
private readonly cache: CacheService,
) {}
async forTenant(tenantId: string): Promise<Entitlements | null> {
return this.cache.wrap<Entitlements>(key(tenantId), TTL.tenant, async () => {
const t = await this.tenants.findOne({
where: { id: tenantId },
select: { id: true, plan: true, features: true, status: true },
});
if (!t) return null;
return resolveEntitlements(t.plan, t.features ?? {});
});
}
/**
* هل يملك المستأجر هذه الميزة؟
* **مستأجر مجهول = ممنوع** — لا يُفترض السماح عند غياب المعلومة.
*/
async has(tenantId: string, feature: Feature): Promise<boolean> {
const e = await this.forTenant(tenantId);
return e?.features?.[feature] === true;
}
/** حدّ عددي؛ `undefined`/`null` = بلا حدّ. */
async limit(tenantId: string, name: 'drivers_max' | 'cities_max'): Promise<number | null> {
const e = await this.forTenant(tenantId);
const v = e?.limits?.[name];
return typeof v === 'number' ? v : null;
}
/** يُنادى بعد أي تغيير على الباقة/الميزات — وإلا سرى التغيير بعد ساعة. */
async invalidate(tenantId: string): Promise<void> {
await this.cache.del(key(tenantId));
this.logger.debug(`entitlements invalidated for ${tenantId}`);
}
}
@@ -1,70 +0,0 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { FeatureGuard } from './feature.guard';
import { EntitlementsService } from './entitlements.service';
const TENANT_WITH = 'tenant-with-bots';
const TENANT_WITHOUT = 'tenant-without-bots';
/** سياق تنفيذ مزيّف — `user` هو ما يحقنه JwtAuthGuard من التوكن الموقَّع. */
function ctx(user: any, headers: Record<string, string> = {}): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ user, headers }) }),
getHandler: () => ({}),
getClass: () => ({}),
} as any;
}
describe('FeatureGuard — الحدّ الأمني الحقيقي (docs/19 K1/K6)', () => {
let guard: FeatureGuard;
let reflector: Reflector;
let entitlements: EntitlementsService;
beforeEach(() => {
reflector = { getAllAndOverride: jest.fn() } as any;
entitlements = {
has: jest.fn(async (tenantId: string) => tenantId === TENANT_WITH),
} as any;
guard = new FeatureGuard(reflector, entitlements);
});
it('نقطة بلا @RequiresFeature تمرّ', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined);
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).resolves.toBe(true);
expect(entitlements.has).not.toHaveBeenCalled();
});
it('مستأجر يملك الميزة يمرّ', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITH }))).resolves.toBe(true);
});
it('مستأجر لا يملكها يُرفض بـ403', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow(
ForbiddenException,
);
});
it('الرسالة ثابتة ولا تكشف شيئاً عن مستأجرين آخرين', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow(
'feature_not_in_plan',
);
});
it('بلا مستخدم موثَّق = منع (الافتراض ليس السماح)', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
await expect(guard.canActivate(ctx(undefined))).rejects.toThrow(ForbiddenException);
});
it('لا يمكن تزوير المستأجر بترويسة — الحارس يقرأ التوكن وحده', async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots');
// المهاجم يدّعي أنه المستأجر المالك للميزة عبر الترويسة…
const forged = ctx({ tenantId: TENANT_WITHOUT }, { 'x-tenant-id': TENANT_WITH });
await expect(guard.canActivate(forged)).rejects.toThrow(ForbiddenException);
// …والحارس سأل عن المستأجر القادم من التوكن لا من الترويسة.
expect(entitlements.has).toHaveBeenCalledWith(TENANT_WITHOUT, 'bots');
});
});
@@ -1,51 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { EntitlementsService } from './entitlements.service';
import { Feature } from './features';
export const FEATURE_KEY = 'required_feature';
/**
* يحرس نقطةً بميزة مشتراة (docs/19 — K1).
*
* **هذا هو الحدّ الأمني الحقيقي.** علم الميزة في التطبيق قرار *عرض* فقط؛ من
* يفكّك التطبيق ويفعّل الأعلام يرى الشاشة ثم يصطدم بـ403 من هنا.
*
* يجب أن يُستعمل **بعد** `JwtAuthGuard` — يعتمد على `tenant_id` القادم من
* التوكن الموقَّع، لا من ترويسة يكتبها العميل.
*/
export const RequiresFeature = (feature: Feature) => SetMetadata(FEATURE_KEY, feature);
@Injectable()
export class FeatureGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly entitlements: EntitlementsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const feature = this.reflector.getAllAndOverride<Feature>(FEATURE_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!feature) return true; // النقطة غير محروسة بميزة
const { user } = context.switchToHttp().getRequest();
const tenantId = user?.tenantId;
// بلا مستأجر موثَّق لا نستطيع التحقق — والافتراض هو المنع.
if (!tenantId) throw new ForbiddenException('feature_not_in_plan');
if (!(await this.entitlements.has(tenantId, feature))) {
// رسالة ثابتة يعرضها التطبيق بلطف («هذه الميزة غير مشمولة باشتراكك»)،
// ولا تكشف للمهاجم أي ميزات يملكها المستأجرون الآخرون.
throw new ForbiddenException('feature_not_in_plan');
}
return true;
}
}
@@ -1,55 +0,0 @@
import { FEATURES, resolveEntitlements } from './features';
describe('resolveEntitlements — كتالوج الاستحقاقات (docs/19 K2)', () => {
it('الافتراض هو المنع: كل ميزة خارج الباقة محجوبة', () => {
const e = resolveEntitlements('launch');
expect(e.features.bots).toBe(false);
expect(e.features.market_intel).toBe(false);
expect(e.features.dispatch).toBe(false);
});
it('كل ميزة في الكتالوج لها قيمة صريحة — لا undefined يُقرأ كسماح', () => {
const e = resolveEntitlements('launch');
for (const f of FEATURES) {
expect(typeof e.features[f]).toBe('boolean');
}
});
it('الباقة تعطي افتراضاتها', () => {
expect(resolveEntitlements('launch').features.wallet).toBe(true);
expect(resolveEntitlements('fleet').features.dispatch).toBe(true);
expect(resolveEntitlements('brand').features.dispatch).toBe(false);
});
it('السيادة تشمل كل شيء — قرار مقصود (docs/19 §6)', () => {
const e = resolveEntitlements('sovereign');
for (const f of FEATURES) expect(e.features[f]).toBe(true);
});
it('ميزة مشتراة منفردة فوق الباقة', () => {
const e = resolveEntitlements('brand', { bots: true });
expect(e.features.bots).toBe(true);
expect(e.features.wallet).toBe(true); // افتراض الباقة باقٍ
});
it('سحب ميزة من الباقة صراحةً', () => {
expect(resolveEntitlements('fleet', { dispatch: false }).features.dispatch).toBe(false);
});
it('مفاتيح مجهولة تُتجاهل — لا تفتح شيئاً', () => {
const e = resolveEntitlements('launch', { not_a_feature: true } as any);
expect((e.features as any).not_a_feature).toBeUndefined();
expect(e.features.bots).toBe(false);
});
it('الحدود العددية من الباقة، وتُتجاوز صراحةً', () => {
expect(resolveEntitlements('launch').limits.drivers_max).toBe(50);
expect(resolveEntitlements('launch', { limits: { drivers_max: 200 } }).limits.drivers_max).toBe(200);
});
it('باقة مجهولة تسقط لأضيق باقة لا لأوسعها', () => {
const e = resolveEntitlements('nonsense' as any);
expect(e.features.bots).toBe(false);
expect(e.limits.drivers_max).toBe(50);
});
});
@@ -1,98 +0,0 @@
import { TenantPlan } from '../../database/entities/tenant.entity';
/**
* كتالوج الميزات القابلة للبيع (docs/19 — K2).
* الميزة تُشترى منفردة فوق الباقة، فـ`plan` يعطي الافتراضات و`tenants.features`
* هي الحقيقة النهائية.
*/
export const FEATURES = [
'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب
'wallet', // محفظة الراكب/السائق + التسوية
'payments', // بوابات الدفع والشحن
'chat', // دردشة داخل الرحلة
'calls', // مكالمات WebRTC
'ride_types', // أنواع رحلات مخصّصة للمستأجر
'market_intel', // الاستخبار السوقي
'bots', // البوتات
'ads', // الدعاية
'transit', // نظام المواصلات
'api_access', // API + Webhooks للمستأجر
'driver_tiers', // مستويات السائقين (برونزي/فضي/ذهبي/بلاتيني)
'marketing_engine', // محرك التسويق التلقائي
'dynamic_pricing', // التسعير الديناميكي (مضاعف الطلب)
'geofence', // السياج الجغرافي — مناطق التسعير والتنبيهات
'negotiator', // المفاوض الآلي — تفاوض السعر تلقائياً
'driver_assurance', // تأمين السائق — تغطية تأمينية
'coupons', // كوبونات الخصم
] as const;
export type Feature = (typeof FEATURES)[number];
/** الحدود العددية — تُفرض على السيرفر أيضاً (docs/19 — K4). */
export interface Limits {
drivers_max?: number;
cities_max?: number;
}
export interface Entitlements {
features: Record<Feature, boolean>;
limits: Limits;
}
/** `null` = بلا حدّ. */
const UNLIMITED = null;
/**
* افتراضات كل باقة (docs/05 — انطلاقة/علامة/أسطول+/سيادة).
* ما لا يُذكر هنا = **ممنوع**؛ الافتراض هو المنع لا السماح.
*/
const PLAN_DEFAULTS: Record<TenantPlan, { features: Feature[]; limits: Limits }> = {
launch: {
features: ['wallet', 'chat', 'ride_types'],
limits: { drivers_max: 50, cities_max: 1 },
},
brand: {
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'driver_tiers'],
limits: { drivers_max: 500, cities_max: 3 },
},
fleet: {
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'dispatch', 'api_access'],
limits: { drivers_max: 5000, cities_max: 20 },
},
// السيادة: كل شيء مشمول — قرار مقصود (docs/19 §6). المستأجر يشغّل النسخة على
// خوادمه، فلا يوجد حارس يحرس ضدّه؛ الحجب هناك وهمٌ لا حماية. الحدّ تعاقدي.
sovereign: {
features: [...FEATURES],
limits: { drivers_max: UNLIMITED as any, cities_max: UNLIMITED as any },
},
};
/** خريطة «كل الميزات ممنوعة» — نقطة البداية دائماً (default-deny). */
function noFeatures(): Record<Feature, boolean> {
return Object.fromEntries(FEATURES.map((f) => [f, false])) as Record<Feature, boolean>;
}
/**
* يحسب استحقاقات المستأجر: افتراضات الباقة، ثم تجاوزات `tenants.features`
* الصريحة (شراء ميزة منفردة أو سحبها).
*
* **مفتاح غير موجود = ممنوع.** قائمة بيضاء لا سوداء — ميزة جديدة تُضاف للكتالوج
* تبقى محجوبة عن الجميع حتى تُمنح صراحةً، لا العكس.
*/
export function resolveEntitlements(
plan: TenantPlan,
overrides: Record<string, any> = {},
): Entitlements {
const base = PLAN_DEFAULTS[plan] ?? PLAN_DEFAULTS.launch;
const features = noFeatures();
for (const f of base.features) features[f] = true;
// تجاوزات صريحة — تقبل المنح والسحب معاً.
for (const f of FEATURES) {
if (typeof overrides?.[f] === 'boolean') features[f] = overrides[f];
}
const limits: Limits = { ...base.limits, ...(overrides?.limits ?? {}) };
return { features, limits };
}
@@ -1,29 +0,0 @@
/**
* كتالوج بوابات الدفع المتاحة (docs/22 — N) والدول التي تخدمها.
* السوبر-أدمن يفعّل ما يناسب دولة المستأجر عند التزويد.
*/
export const PAYMENT_METHODS = [
{ code: 'cash', name_ar: 'نقداً', countries: ['jo', 'sy', 'eg'] },
{ code: 'wallet', name_ar: 'المحفظة', countries: ['jo', 'sy', 'eg'] },
{ code: 'cliq', name_ar: 'كليك', countries: ['jo'] },
{ code: 'zaincash', name_ar: 'زين كاش', countries: ['jo'] },
{ code: 'paymob', name_ar: 'باي موب', countries: ['eg'] },
{ code: 'mtn', name_ar: 'MTN', countries: ['sy'] },
{ code: 'syriatel', name_ar: 'سيرياتيل كاش', countries: ['sy'] },
{ code: 'shamcash', name_ar: 'شام كاش', countries: ['sy'] },
] as const;
export type PaymentMethodCode = (typeof PAYMENT_METHODS)[number]['code'];
/** الوسائل الافتراضية لدولة (نقد + محفظة + بوابات الدولة) — نقطة بداية التزويد. */
export function defaultPaymentMethods(countryPack: string): string[] {
return PAYMENT_METHODS.filter((m) => (m.countries as readonly string[]).includes(countryPack)).map(
(m) => m.code,
);
}
/** يتحقق أن الوسائل المطلوبة كلها في الكتالوج (لا وسيلة مخترعة). */
export function validPaymentMethods(codes: string[]): string[] {
const known = new Set<string>(PAYMENT_METHODS.map((m) => m.code));
return (codes ?? []).filter((c) => known.has(c));
}
-9
View File
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}
@@ -1,32 +0,0 @@
import { I18nService } from './i18n.service';
describe('I18nService', () => {
const i18n = new I18nService();
it('يترجم للعربية افتراضياً ويستبدل المتغيّرات', () => {
const m = i18n.t(null, 'trip.assigned', { driverName: 'أحمد' });
expect(m.title).toBe('تم قبول رحلتك');
expect(m.body).toBe('سائقك أحمد في الطريق إليك');
});
it('يحترم لغة المستخدم الإنجليزية', () => {
const m = i18n.t('en', 'trip.driver_arrived');
expect(m.title).toBe('Driver has arrived');
});
it('يطبّع وسوم اللغة المركّبة (ar-JO, EN_us)', () => {
expect(i18n.normalize('ar-JO')).toBe('ar');
expect(i18n.normalize('EN_us')).toBe('en');
expect(i18n.normalize('fr')).toBe('ar'); // غير مدعومة → الافتراضية
expect(i18n.normalize(undefined)).toBe('ar');
});
it('يترك المتغيّر كما هو إذا لم تُمرَّر قيمته', () => {
const m = i18n.t('ar', 'trip.completed', { currency: 'JOD' });
expect(m.body).toBe('وصلت إلى وجهتك. الأجرة {fare} JOD');
});
it('لا يرمي عند مفتاح مفقود', () => {
expect(() => i18n.t('ar', 'does.not.exist')).not.toThrow();
});
});
-34
View File
@@ -1,34 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { CATALOGS, DEFAULT_LANG, Lang, Message, SUPPORTED_LANGS } from './messages';
/** ترجمة نصوص الإشعارات (docs/17 — A2). العربية هي الافتراض. */
@Injectable()
export class I18nService {
private readonly logger = new Logger('I18n');
/** يطبّع أي وسم لغة (ar-JO, AR, en_US) إلى لغة مدعومة، وإلا الافتراضية. */
normalize(lang?: string | null): Lang {
const base = (lang ?? '').toLowerCase().split(/[-_]/)[0];
return (SUPPORTED_LANGS as string[]).includes(base) ? (base as Lang) : DEFAULT_LANG;
}
/** يترجم مفتاحاً مع استبدال المتغيّرات. المفتاح المفقود يرجع كما هو (لا يكسر الإرسال). */
t(lang: string | null | undefined, key: string, params: Record<string, any> = {}): Message {
const l = this.normalize(lang);
const msg = CATALOGS[l][key] ?? CATALOGS[DEFAULT_LANG][key];
if (!msg) {
this.logger.warn(`missing i18n key "${key}"`);
return { title: key, body: '' };
}
return {
title: this.interpolate(msg.title, params),
body: this.interpolate(msg.body, params),
};
}
private interpolate(tpl: string, params: Record<string, any>): string {
return tpl.replace(/\{(\w+)\}/g, (match, name) =>
params[name] == null ? match : String(params[name]),
);
}
}
-51
View File
@@ -1,51 +0,0 @@
/**
* نصوص الإشعارات لكل لغة. المفتاح يُشتق من نوع الحدث (راجع docs/17 — A2).
* كل مدخل: عنوان + نص. المتغيّرات بصيغة {name}.
*/
export type Lang = 'ar' | 'en';
export const DEFAULT_LANG: Lang = 'ar';
export const SUPPORTED_LANGS: Lang[] = ['ar', 'en'];
export interface Message {
title: string;
body: string;
}
type Catalog = Record<string, Message>;
const ar: Catalog = {
'trip.assigned': { title: 'تم قبول رحلتك', body: 'سائقك {driverName} في الطريق إليك' },
'trip.driver_arriving': { title: 'السائق في الطريق', body: 'سائقك متوجّه إلى نقطة الانطلاق' },
'trip.driver_arrived': { title: 'وصل السائق', body: 'سائقك ينتظرك في نقطة الانطلاق' },
'trip.in_progress': { title: 'بدأت الرحلة', body: 'رحلتك جارية الآن — رحلة موفقة' },
'trip.completed': { title: 'انتهت الرحلة', body: 'وصلت إلى وجهتك. الأجرة {fare} {currency}' },
'trip.paid': { title: 'تم الدفع', body: 'تم استلام مبلغ {fare} {currency}' },
'trip.cancelled': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة' },
'trip.cancelled_fee': { title: 'أُلغيت الرحلة', body: 'تم إلغاء الرحلة ورسم الإلغاء {fee} {currency}' },
'trip.no_drivers': { title: 'لا يوجد سائقون', body: 'لم نجد سائقاً متاحاً قريباً منك. حاول مجدداً' },
'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' },
'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' },
'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' },
'trip.audio_recording_started': { title: 'بدء التسجيل', body: 'تم بدء تسجيل الرحلة صوتياً لضمان سلامتكما' },
'chat.message': { title: 'رسالة جديدة', body: '{preview}' },
};
const en: Catalog = {
'trip.assigned': { title: 'Your ride is accepted', body: 'Your driver {driverName} is on the way' },
'trip.driver_arriving': { title: 'Driver on the way', body: 'Your driver is heading to the pickup point' },
'trip.driver_arrived': { title: 'Driver has arrived', body: 'Your driver is waiting at the pickup point' },
'trip.in_progress': { title: 'Trip started', body: 'Your trip is now in progress — have a good ride' },
'trip.completed': { title: 'Trip finished', body: 'You have arrived. Fare {fare} {currency}' },
'trip.paid': { title: 'Payment received', body: 'Payment of {fare} {currency} received' },
'trip.cancelled': { title: 'Trip cancelled', body: 'The trip was cancelled' },
'trip.cancelled_fee': { title: 'Trip cancelled', body: 'The trip was cancelled with a {fee} {currency} cancellation fee' },
'trip.no_drivers': { title: 'No drivers', body: 'No driver available nearby. Please try again' },
'trip.expired': { title: 'Request expired', body: 'The ride request expired without being accepted' },
'trip.offer': { title: 'New ride request', body: 'Rider {distanceKm} km away — fare {fare} {currency}' },
'trip.offer_taken': { title: 'Ride no longer available', body: 'Another driver accepted the request' },
'trip.audio_recording_started': { title: 'Recording started', body: 'Trip audio recording has started for your safety' },
'chat.message': { title: 'New message', body: '{preview}' },
};
export const CATALOGS: Record<Lang, Catalog> = { ar, en };
-9
View File
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PhoneService } from './phone.service';
@Global()
@Module({
providers: [PhoneService],
exports: [PhoneService],
})
export class PhoneModule {}
@@ -1,84 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { PhoneService } from './phone.service';
describe('PhoneService — تطبيع أرقام الهاتف (docs/17 D1)', () => {
let phones: PhoneService;
beforeEach(() => {
phones = new PhoneService();
});
describe('الأردن (jo)', () => {
it('الصيغة المحلية بالصفر', () => {
expect(phones.normalize('0790000000', 'jo')).toBe('962790000000');
});
it('الصيغة الدولية بالفعل', () => {
expect(phones.normalize('962790000000', 'jo')).toBe('962790000000');
});
it('بادئة + دولية', () => {
expect(phones.normalize('+962790000000', 'jo')).toBe('962790000000');
});
it('بادئة 00 بدل +', () => {
expect(phones.normalize('00962790000000', 'jo')).toBe('962790000000');
});
it('فراغات وشرطات لا تؤثر', () => {
expect(phones.normalize('+962 79-000-0000', 'jo')).toBe('962790000000');
});
it('محلي بلا صفر بادئ', () => {
expect(phones.normalize('790000000', 'jo')).toBe('962790000000');
});
});
describe('مصر (eg) — لبس المالك: "01" يُظنّ مفتاحاً وهو ليس كذلك', () => {
it('الصيغة المحلية بالصفر (11 رقماً) تُحسب بمفتاح 20 الصحيح لا "2"', () => {
const r = phones.normalize('01012345678', 'eg');
expect(r).toBe('201012345678');
expect(r.startsWith('20')).toBe(true); // لا "2" فقط
});
it('الصيغة الدولية بالفعل', () => {
expect(phones.normalize('201012345678', 'eg')).toBe('201012345678');
});
it('بادئة +', () => {
expect(phones.normalize('+201012345678', 'eg')).toBe('201012345678');
});
});
describe('سوريا (sy)', () => {
it('الصيغة المحلية بالصفر', () => {
expect(phones.normalize('0944000000', 'sy')).toBe('963944000000');
});
});
describe('نفس الرقم الحقيقي بصيغ مختلفة → مفتاح واحد فقط', () => {
it('لا يتعدد الحساب لنفس الرقم', () => {
const variants = ['0790000000', '962790000000', '+962790000000', '00962790000000'];
const results = new Set(variants.map((v) => phones.normalize(v, 'jo')));
expect(results.size).toBe(1);
});
});
describe('مدخلات غير صالحة', () => {
it('طول خاطئ يُرفض لا يُخمَّن', () => {
expect(() => phones.normalize('12345', 'jo')).toThrow(BadRequestException);
});
it('نص فارغ يُرفض', () => {
expect(() => phones.normalize('', 'jo')).toThrow(BadRequestException);
});
it('حروف بلا أرقام تُرفض', () => {
expect(() => phones.normalize('abc-def', 'jo')).toThrow(BadRequestException);
});
});
it('دولة غير معروفة تسقط لخطة افتراضية بلا انهيار', () => {
expect(() => phones.normalize('0790000000', 'xx')).not.toThrow();
});
});
-76
View File
@@ -1,76 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
/**
* قاعدة ترقيم كل دولة (docs/17 — D1).
* `trunkPrefix`: الصفر المحلي الذي يُكتب فيُحذف قبل إضافة مفتاح الدولة.
* `nationalLength`: طول الرقم المحلي **بعد** حذف الصفر.
*/
interface DialPlan {
callingCode: string; // بلا +
nationalLength: number;
}
const DIAL_PLANS: Record<string, DialPlan> = {
// 07XXXXXXXX (10 أرقام بالصفر) → مفتاح 962 + 7XXXXXXXX (9 أرقام)
jo: { callingCode: '962', nationalLength: 9 },
// 09XXXXXXXX (10 أرقام بالصفر) → مفتاح 963 + 9XXXXXXXX (9 أرقام)
sy: { callingCode: '963', nationalLength: 9 },
// 01XXXXXXXXX (11 رقماً بالصفر) → مفتاح 20 + 1XXXXXXXXX (10 أرقام).
// هذا بالضبط اللبس الذي ذكره المالك: الناس تظن المفتاح "2" لا "20"، لأن
// الرقم المحلي نفسه يبدأ بـ1 فيبدو للوهلة الأولى أن "01" بأكملها مفتاح.
eg: { callingCode: '20', nationalLength: 10 },
};
/**
* تطبيع رقم الهاتف لصيغة دولية قانونية موحّدة (docs/17 — D1).
*
* **بلا هذا**: نفس الرقم الحقيقي يُكتب "0790000000" مرة و"962790000000"
* مرة و"+962790000000" مرة ثالثة — فيصير له 3 حسابات مختلفة. المفتاح
* المخزَّن في القاعدة يجب أن يكون **دالة واحدة فقط** لكل رقم حقيقي.
*/
@Injectable()
export class PhoneService {
/**
* يرجع الصيغة الدولية بلا "+" (مثل "962790000000") — هذا هو المفتاح
* القانوني الوحيد المخزَّن والمُقارَن به في كل مكان.
*/
normalize(raw: string, countryPack: string): string {
const plan = DIAL_PLANS[countryPack] ?? DIAL_PLANS.jo;
const digits = PhoneService.stripToDigits(raw);
if (!digits) throw new BadRequestException('invalid phone number');
// "00" بادئة دولية بديلة عن "+" — إزالتها تعيدنا لحالة "مفتاح كامل".
const noIntlPrefix = digits.startsWith('00') ? digits.slice(2) : digits;
// 1) مكتوب بمفتاح الدولة كاملاً (مع أو بلا +/00 — أُزيلا أعلاه).
if (
noIntlPrefix.startsWith(plan.callingCode) &&
noIntlPrefix.length === plan.callingCode.length + plan.nationalLength
) {
return noIntlPrefix;
}
// 2) الصيغة المحلية المعتادة: صفر بادئ ثم الرقم المحلي.
if (noIntlPrefix.startsWith('0') && noIntlPrefix.length === plan.nationalLength + 1) {
return plan.callingCode + noIntlPrefix.slice(1);
}
// 3) رقم محلي بلا صفر بادئ (بعض المستخدمين يحذفونه بأنفسهم).
if (noIntlPrefix.length === plan.nationalLength) {
return plan.callingCode + noIntlPrefix;
}
throw new BadRequestException(
`invalid phone number for country "${countryPack}"`,
);
}
/** للعرض/الإرسال فقط — Nabeh والتطبيق يريدان الصيغة الدولية بلا +. */
toWhatsApp(normalized: string): string {
return normalized;
}
private static stripToDigits(raw: string): string {
return (raw ?? '').replace(/[^\d]/g, '');
}
}
@@ -1,49 +0,0 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { PlatformGuard } from './platform.guard';
const SECRET = 'super-secret-value';
function ctx(headers: Record<string, any> = {}): ExecutionContext {
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as any;
}
const guardWith = (secret?: string) =>
new PlatformGuard({ get: () => secret } as any);
describe('PlatformGuard — حارس السوبر-أدمن', () => {
it('السرّ الصحيح يمرّ', () => {
expect(guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': SECRET }))).toBe(true);
});
it('سرّ خاطئ يُرفض', () => {
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'nope' }))).toThrow(
UnauthorizedException,
);
});
it('بلا ترويسة يُرفض', () => {
expect(() => guardWith(SECRET).canActivate(ctx())).toThrow(UnauthorizedException);
});
it('سرّ غير مضبوط = منع الجميع، لا فتح الباب', () => {
// الفخّ الخطير: إعداد ناقص يفتح نقاط المنصة للعالم.
expect(() => guardWith(undefined).canActivate(ctx({ 'x-platform-secret': 'anything' }))).toThrow(
UnauthorizedException,
);
expect(() => guardWith('').canActivate(ctx({ 'x-platform-secret': '' }))).toThrow(
UnauthorizedException,
);
});
it('لا ينهار على ترويسة غير نصّية', () => {
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': ['a', 'b'] }))).toThrow(
UnauthorizedException,
);
});
it('طول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط تطابق الطول)', () => {
expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'short' }))).toThrow(
UnauthorizedException,
);
});
});
@@ -1,50 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'crypto';
/**
* حارس السوبر-أدمن (مالك المنصة) — يحرس `admin/*` و`platform/*`.
*
* السوبر-أدمن يدير **كل** المستأجرين، فهو خارج نموذج أدوار المستأجر تماماً
* (rider/driver/dispatcher/admin): أدمن المستأجر «سيرو» يجب ألّا يرقّي اشتراكه
* بنفسه. لذلك المصادقة بسرّ المنصة لا بـJWT مستأجر.
*
* مؤقّت حتى يوجد نموذج مستخدم منصة حقيقي؛ يوثَّق في docs/19.
*/
@Injectable()
export class PlatformGuard implements CanActivate {
private readonly logger = new Logger('PlatformGuard');
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const provided = req.headers?.['x-platform-secret'];
const expected = this.config.get<string>('platform.secret');
// سرّ غير مضبوط = **منع الجميع**، لا سماح للجميع. تركه فارغاً في الإنتاج
// خطأ تشغيلي شائع، وفتحُ الباب عنده أسوأ من إغلاقه.
if (!expected) {
this.logger.error('PLATFORM_SECRET is not set — refusing all platform access');
throw new UnauthorizedException('platform access disabled');
}
if (typeof provided !== 'string' || !PlatformGuard.safeEqual(provided, expected)) {
throw new UnauthorizedException('invalid platform secret');
}
return true;
}
/** مقارنة ثابتة الزمن — المقارنة العادية تسرّب السرّ حرفاً حرفاً بالتوقيت. */
private static safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
}
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PlatformGuard } from './platform.guard';
@Global()
@Module({
providers: [PlatformGuard],
exports: [PlatformGuard],
})
export class PlatformModule {}
-29
View File
@@ -1,29 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
export const REDIS = 'REDIS_CLIENT';
/**
* عميل Redis عالمي — DB رقم 3 وبادئة مفاتيح tripz: للعزل على السيرفر المشترك
* (راجع docs/14). يُستخدم لتخزين OTP والحضور والمطابقة لاحقاً.
*/
@Global()
@Module({
providers: [
{
provide: REDIS,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Redis({
host: cfg.get<string>('redis.host'),
port: cfg.get<number>('redis.port'),
db: cfg.get<number>('redis.db'),
keyPrefix: cfg.get<string>('redis.keyPrefix'),
maxRetriesPerRequest: null,
}),
},
],
exports: [REDIS],
})
export class RedisModule {}
-11
View File
@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { SeedService } from './seed.service';
import { TenantsModule } from '../../modules/tenants/tenants.module';
import { TariffModule } from '../../modules/tariff/tariff.module';
import { RideTypesModule } from '../../modules/ride-types/ride-types.module';
@Module({
imports: [TenantsModule, TariffModule, RideTypesModule],
providers: [SeedService],
})
export class SeedModule {}
-94
View File
@@ -1,94 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { TenantsService } from '../../modules/tenants/tenants.service';
import { TariffService } from '../../modules/tariff/tariff.service';
import { RideTypesService } from '../../modules/ride-types/ride-types.service';
import {
buildDefinition,
countryTariff,
SEEDED_CLASSES,
} from '../../modules/tariff/default-tariffs';
/**
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
* حتى لا نعيد إنشاءهما يدوياً بعد كل `docker compose down -v`.
*/
@Injectable()
export class SeedService implements OnModuleInit {
private readonly logger = new Logger('Seed');
constructor(
private readonly tenants: TenantsService,
private readonly tariff: TariffService,
private readonly rideTypes: RideTypesService,
) {}
async onModuleInit() {
try {
await this.run();
} catch (e: any) {
// على قاعدة جديدة قد تسبق onModuleInit تشغيلَ الهجرات — لا نُسقط الإقلاع.
this.logger.warn(`seed skipped (run migrations then restart api): ${e?.message ?? e}`);
}
}
private async run() {
let tenant = await this.tenants.findBySlug('siro');
if (!tenant) {
tenant = await this.tenants.create({
name: 'Siro (Demo — Tenant Zero)',
slug: 'siro',
countryPack: 'jo',
// سيرو = المشترك الأول بالباقة الكاملة (قرار المالك)، وسيادةً تعني كل
// الميزات بلا حدود عددية (docs/19 §6). عملياً أيضاً: أي باقة أدنى كانت
// ستحجب dispatch وتُسقط سكربتات التحقق بـ403 وحدّ السائقين.
plan: 'sovereign',
});
this.logger.log(`seeded demo tenant "siro" (${tenant.id})`);
}
// تعرفة الانطلاق **لكل فئة خدمة** من مصدر الحقيقة الواحد
// (`default-tariffs.ts`)، لا الاقتصادي وحده: طلب فئة بلا تعرفة كان
// يمرّ بلا سعر ثم يُسوّى بصفر — رحلة مجانية وعمولة صفر.
// البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة
// التي يبحث بها `trips.service` فعلاً.
const country = countryTariff(tenant.countryPack);
let created = 0;
let skipped = 0;
for (const serviceClass of SEEDED_CLASSES) {
const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass);
if (existing) {
skipped++;
continue;
}
created++;
await this.tariff.create({
tenant_id: tenant.id,
city: 'default',
service_class: serviceClass,
version: 1,
active: true,
definition: buildDefinition(country, serviceClass),
});
}
// العدد الحقيقي لا ثابت: الرسالة السابقة طبعت 9 دائماً حتى حين تُتخطّى
// كل الفئات، فلم تكن تُميّز «زُرعت» من «كانت موجودة» — وهو الفرق الوحيد
// الذي يُقرأ من هذا السطر أصلاً.
this.logger.log(
`tariffs for "${tenant.slug}" (${country.currency}): ${created} seeded, ${skipped} already active`,
);
// أنواع الرحلات الافتراضية
const types: any[] = [
{ code: 'economy', name_ar: 'اقتصادي', name_en: 'Economy', vehicle_kind: 'car', sort: 1 },
{ code: 'comfort', name_ar: 'مريح', name_en: 'Comfort', vehicle_kind: 'car', sort: 2 },
{ code: 'electric', name_ar: 'كهربائية', name_en: 'Electric', vehicle_kind: 'car', sort: 3 },
{ code: 'family_van', name_ar: 'باص عائلي', name_en: 'Family Van', vehicle_kind: 'van', sort: 4 },
{ code: 'women', name_ar: 'سيدات', name_en: 'Women', vehicle_kind: 'car', women_only: true, sort: 5 },
{ code: 'scooter', name_ar: 'سكوتر توصيل', name_en: 'Scooter', vehicle_kind: 'scooter', round_trip_supported: false, sort: 6 },
];
for (const t of types) {
await this.rideTypes.ensure({ ...t, tenant_id: tenant.id });
}
this.logger.log('seeded ride types for "siro"');
}
}
@@ -1,70 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SigningService } from './signing.service';
import { AuditService } from '../audit/audit.service';
/**
* يحرس العمليات المالية بتوقيع HMAC (docs/17 — I6).
*
* **مطفأ افتراضياً** (`PAYMENTS_REQUIRE_SIGNATURE=false`): التوقيع يحتاج فلاتر
* أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. يُفعَّل بمتغيّر بيئة فور جاهزية
* التطبيق — بلا نشر كود.
*
* ما يقدّمه فعلاً: الـAPI يعمل على **http** بلا TLS، فمن يلتقط الشبكة يسرق
* التوكن. التوقيع بمفتاح جلسة (ليس في التوكن ولا في الـAPK) يجعل التوكن
* المسروق وحده غير كافٍ لتوقيع سحب، ويمنع تبديل المبلغ في الطريق.
* **هذا ترقيع لغياب TLS لا بديل عنه** — راجع docs/17.
*/
@Injectable()
export class SignatureGuard implements CanActivate {
private readonly logger = new Logger('SignatureGuard');
constructor(
private readonly signing: SigningService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.config.get<boolean>('payments.requireSignature')) return true;
const req = context.switchToHttp().getRequest();
const user = req.user;
if (!user?.userId || !user?.tenantId) throw new UnauthorizedException('unsigned_request');
const signature = req.headers?.['x-signature'];
const timestamp = req.headers?.['x-timestamp'];
if (typeof signature !== 'string' || typeof timestamp !== 'string') {
throw new UnauthorizedException('unsigned_request');
}
const reason = await this.signing.verify(
user.tenantId,
user.userId,
signature,
timestamp,
req.method,
req.originalUrl?.split('?')[0] ?? req.url,
// الجسم الخام كما وصل؛ إعادة تسلسله قد تُغيّر ترتيب المفاتيح فيفشل توقيع سليم.
req.rawBody?.toString() ?? JSON.stringify(req.body ?? {}),
);
if (reason) {
await this.audit.record({
tenantId: user.tenantId,
actorUserId: user.userId,
action: 'signature.rejected',
ip: req.ip ?? null,
meta: { reason, path: req.url },
});
// رسالة واحدة لكل الأسباب — لا نُعلّم المهاجم أين أخطأ.
throw new UnauthorizedException('unsigned_request');
}
return true;
}
}
@@ -1,10 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { SigningService } from './signing.service';
import { SignatureGuard } from './signature.guard';
@Global()
@Module({
providers: [SigningService, SignatureGuard],
exports: [SigningService, SignatureGuard],
})
export class SigningModule {}
@@ -1,119 +0,0 @@
import RedisMock from 'ioredis-mock';
import { SigningService } from './signing.service';
const TENANT = 't1';
const USER = 'u1';
const BODY = '{"amount":50,"channel":"cliq"}';
describe('SigningService — توقيع العمليات المالية (docs/17 I6)', () => {
let redis: any;
let signing: SigningService;
const sign = (secret: string, ts: string, method = 'POST', path = '/api/payouts/request', body = BODY) =>
SigningService.sign(secret, SigningService.payload(ts, method, path, body));
const now = () => String(Math.floor(Date.now() / 1000));
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
signing = new SigningService(redis);
});
it('توقيع صحيح يمرّ', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
const sig = sign(secret, ts);
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', BODY)).toBeNull();
});
it('تبديل المبلغ في الطريق يُكشف', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
const sig = sign(secret, ts); // وُقّع على 50
const tampered = '{"amount":5000,"channel":"cliq"}';
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/request', tampered)).toBe(
'bad_signature',
);
});
it('توقيع نقطةٍ لا يصلح لنقطة أخرى', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
const sig = sign(secret, ts, 'POST', '/api/payouts/request');
expect(await signing.verify(TENANT, USER, sig, ts, 'POST', '/api/payouts/x/confirm', BODY)).toBe(
'bad_signature',
);
});
it('طابع زمني قديم يُرفض (منع إعادة البثّ)', async () => {
const secret = await signing.issue(TENANT, USER);
const old = String(Math.floor(Date.now() / 1000) - 3600);
const sig = sign(secret, old);
expect(await signing.verify(TENANT, USER, sig, old, 'POST', '/api/payouts/request', BODY)).toBe(
'stale_timestamp',
);
});
it('طابع زمني غير رقمي يُرفض بلا انهيار', async () => {
const secret = await signing.issue(TENANT, USER);
expect(await signing.verify(TENANT, USER, sign(secret, 'x'), 'x', 'POST', '/p', BODY)).toBe(
'bad_timestamp',
);
});
it('مفتاح مستخدم آخر لا يوقّع نيابةً عنه', async () => {
const secret = await signing.issue(TENANT, USER);
await signing.issue(TENANT, 'attacker');
const ts = now();
const sig = sign(secret, ts);
expect(await signing.verify(TENANT, 'attacker', sig, ts, 'POST', '/api/payouts/request', BODY)).toBe(
'bad_signature',
);
});
it('المستأجرون معزولون', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
expect(await signing.verify('t2', USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
'no_signing_key',
);
});
it('بلا مفتاح (لم يسجّل دخوله بعد) يُرفض لا يمرّ', async () => {
expect(await signing.verify(TENANT, 'ghost', 'a'.repeat(64), now(), 'POST', '/p', BODY)).toBe(
'no_signing_key',
);
});
it('دخول جديد يُبطل مفتاح الجلسة السابقة', async () => {
const first = await signing.issue(TENANT, USER);
const second = await signing.issue(TENANT, USER);
expect(first).not.toBe(second);
const ts = now();
expect(await signing.verify(TENANT, USER, sign(first, ts), ts, 'POST', '/p', BODY)).toBe(
'bad_signature',
);
});
it('السحب يُبطل التوقيع', async () => {
const secret = await signing.issue(TENANT, USER);
await signing.revoke(TENANT, USER);
const ts = now();
expect(await signing.verify(TENANT, USER, sign(secret, ts), ts, 'POST', '/p', BODY)).toBe(
'no_signing_key',
);
});
it('توقيع بطول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط الطول)', async () => {
const secret = await signing.issue(TENANT, USER);
const ts = now();
expect(await signing.verify(TENANT, USER, 'short', ts, 'POST', '/p', BODY)).toBe('bad_signature');
});
});
@@ -1,79 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import Redis from 'ioredis';
import { REDIS } from '../redis/redis.module';
/** عمر مفتاح التوقيع — يطابق عمر توكن التحديث. */
const KEY_TTL_SEC = 30 * 24 * 3600;
/** نافذة انحراف الساعة المسموحة للطابع الزمني. */
export const SIGNATURE_SKEW_SEC = 300;
/**
* توقيع العمليات المالية (docs/17 — I6/D3).
*
* مفتاح **لكل جلسة** يُولَّد على السيرفر عند الدخول — لا سرّ ثابت في التطبيق
* (السرّ الثابت يُستخرج بالهندسة العكسية فيصير التوقيع بلا معنى).
*/
@Injectable()
export class SigningService {
constructor(@Inject(REDIS) private readonly redis: Redis) {}
private key(tenantId: string, userId: string) {
return `sign:${tenantId}:${userId}`;
}
/** يُصدر مفتاحاً جديداً — الدخول من جهاز جديد يُبطل توقيع السابق. */
async issue(tenantId: string, userId: string): Promise<string> {
const secret = randomBytes(32).toString('hex');
await this.redis.set(this.key(tenantId, userId), secret, 'EX', KEY_TTL_SEC);
return secret;
}
async revoke(tenantId: string, userId: string): Promise<void> {
await this.redis.del(this.key(tenantId, userId));
}
/**
* النصّ الموقَّع: `timestamp.METHOD.path.body`.
* تضمين المسار والطريقة يمنع إعادة استعمال توقيعٍ صالح على نقطة أخرى؛
* والجسم يمنع تبديل المبلغ في الطريق (الـAPI على http حالياً).
*/
static payload(timestamp: string, method: string, path: string, rawBody: string): string {
return `${timestamp}.${method.toUpperCase()}.${path}.${rawBody ?? ''}`;
}
static sign(secret: string, payload: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
/**
* يتحقق من التوقيع. يرجع سبب الفشل نصّاً (أو `null` عند النجاح) ليُسجَّل
* في التدقيق — لا ليُعرض للمهاجم.
*/
async verify(
tenantId: string,
userId: string,
signature: string,
timestamp: string,
method: string,
path: string,
rawBody: string,
): Promise<string | null> {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return 'bad_timestamp';
// نافذة ضيقة = حماية من إعادة البثّ (replay) بلا تخزين حالة.
if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_SKEW_SEC) return 'stale_timestamp';
const secret = await this.redis.get(this.key(tenantId, userId));
if (!secret) return 'no_signing_key'; // لم يُسجّل دخوله بعد هذه الميزة
const expected = SigningService.sign(secret, SigningService.payload(timestamp, method, path, rawBody));
return SigningService.safeEqual(signature, expected) ? null : 'bad_signature';
}
/** مقارنة ثابتة الزمن — العادية تسرّب التوقيع الصحيح بالتوقيت. */
private static safeEqual(a: string, b: string): boolean {
if (typeof a !== 'string' || a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
}
@@ -1,65 +0,0 @@
import { readImageMeta, validateLogo, LOGO_MIN_SIDE } from './image-meta';
/** يبني ترويسة PNG صالحة بأبعاد محدّدة (لا نحتاج بقية الملف للقراءة). */
function png(width: number, height: number): Buffer {
const buf = Buffer.alloc(24);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
buf.write('IHDR', 12, 'ascii');
buf.writeUInt32BE(width, 16);
buf.writeUInt32BE(height, 20);
return buf;
}
/** JPEG بجزء APP0 قبل SOF0 — يثبت أننا نتنقّل بالأطوال لا بموضع ثابت. */
function jpeg(width: number, height: number): Buffer {
const app0 = Buffer.alloc(4 + 14);
app0.writeUInt16BE(0xffe0, 0);
app0.writeUInt16BE(16, 2); // طول APP0
const sof = Buffer.alloc(11);
sof.writeUInt16BE(0xffc0, 0);
sof.writeUInt16BE(8, 2);
sof.writeUInt8(8, 4);
sof.writeUInt16BE(height, 5);
sof.writeUInt16BE(width, 7);
return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof]);
}
describe('readImageMeta — قراءة الأبعاد بلا اعتمادية', () => {
it('يقرأ أبعاد PNG', () => {
expect(readImageMeta(png(1024, 1024))).toEqual({ format: 'png', width: 1024, height: 1024 });
});
it('يقرأ أبعاد JPEG متجاوزاً الأجزاء التي تسبق SOF', () => {
expect(readImageMeta(jpeg(800, 600))).toEqual({ format: 'jpeg', width: 800, height: 600 });
});
it('يرفض ما ليس صورة', () => {
expect(readImageMeta(Buffer.from('هذا ملف نصي لا صورة'))).toBeNull();
});
it('لا يدور بلا نهاية على JPEG بطول معطوب', () => {
// طول = 0 كان سيُبقي المؤشّر مكانه إلى الأبد.
const bad = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]), Buffer.alloc(40)]);
expect(readImageMeta(bad)).toBeNull();
});
});
describe('validateLogo — بوابة جودة الأيقونة (docs/22 N2)', () => {
it('يقبل لوغو مربّعاً كبيراً', () => {
expect(validateLogo(png(1024, 1024))).toBeNull();
});
it('يرفض الصغير — الأيقونة ستخرج مشوّشة', () => {
expect(validateLogo(png(LOGO_MIN_SIDE - 1, LOGO_MIN_SIDE - 1))).toContain('صغير');
});
it('يرفض المستطيل — الأيقونة ستُمطّ', () => {
expect(validateLogo(png(1024, 512))).toContain('غير مربّع');
});
it('يتسامح مع انحراف بسيط عن التربيع', () => {
expect(validateLogo(png(1024, 1000))).toBeNull();
});
});
-73
View File
@@ -1,73 +0,0 @@
/**
* قراءة أبعاد الصورة ونوعها من ترويسة الملف — بلا أي اعتمادية (docs/22 — N2).
*
* لم نُدخل `sharp`: توليد الأيقونات نفسه شغل `flutter_launcher_icons` في
* سكربت N3، والباك إند لا يحتاج إلا **التحقّق** قبل القبول. إدخال مكتبة
* صور أصيلة (libvips) في صورة alpine ثمنٌ كبير مقابل قراءة ٢٤ بايت.
*
* السبب في وجود هذا أصلاً: لوغو صغير أو غير مربّع يُنتج أيقونة ممطوطة أو
* مشوّشة في كل تطبيقات المستأجر — ويُكتشف بعد البناء والنشر لا قبله.
*/
export interface ImageMeta {
format: 'png' | 'jpeg';
width: number;
height: number;
}
/** يعيد null إن لم يكن الملف PNG أو JPEG صالحاً. */
export function readImageMeta(buf: Buffer): ImageMeta | null {
return readPng(buf) ?? readJpeg(buf);
}
function readPng(buf: Buffer): ImageMeta | null {
// توقيع PNG ثم قطعة IHDR: العرض والارتفاع عند 16 و20 (big-endian).
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (buf.length < 24 || !buf.subarray(0, 8).equals(SIG)) return null;
if (buf.subarray(12, 16).toString('ascii') !== 'IHDR') return null;
return { format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
function readJpeg(buf: Buffer): ImageMeta | null {
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
// JPEG سلسلة أجزاء متغيّرة الطول؛ الأبعاد في جزء SOF ولا موضع ثابت لها،
// فنتنقّل بالأطوال المعلنة حتى نصل SOFn.
let i = 2;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) return null; // خرجنا عن المحاذاة = ملف تالف
const marker = buf[i + 1];
const len = buf.readUInt16BE(i + 2);
if (len < 2) return null; // طول معطوب: بلا هذا الفحص تصير الحلقة لانهائية
// SOF0..SOF15 عدا DHT(c4) وJPG(c8) وDAC(cc) — هذه ليست أجزاء إطار.
const isSof = marker >= 0xc0 && marker <= 0xcf &&
marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (isSof) {
return { format: 'jpeg', height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
}
i += 2 + len;
}
return null;
}
/** الحدّ الأدنى لأيقونة تطبيق: أندرويد يحتاج 512 لمتجر Play، وiOS 1024. */
export const LOGO_MIN_SIDE = 512;
/**
* يتحقّق من صلاحية اللوغو كمصدر للأيقونات ويعيد رسالة الخطأ أو null.
* نسمح بانحراف بسيط عن التربيع (٥٪) لأن التصدير اليدوي نادراً ما يكون مضبوطاً
* بالبكسل، لكن شعاراً مستطيلاً فعلاً يجب أن يُرفض لا أن يُمطّ.
*/
export function validateLogo(buf: Buffer): string | null {
const meta = readImageMeta(buf);
if (!meta) return 'الملف ليس PNG أو JPEG صالحاً';
if (meta.width < LOGO_MIN_SIDE || meta.height < LOGO_MIN_SIDE) {
return `اللوغو صغير: ${meta.width}×${meta.height} — الحدّ الأدنى ${LOGO_MIN_SIDE}×${LOGO_MIN_SIDE}`;
}
const ratio = meta.width / meta.height;
if (ratio < 0.95 || ratio > 1.05) {
return `اللوغو غير مربّع: ${meta.width}×${meta.height} — الأيقونة ستُمطّ`;
}
return null;
}
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}
@@ -1,46 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { promises as fs } from 'fs';
import { join, dirname, extname } from 'path';
import { randomUUID } from 'crypto';
/**
* مُحوّل تخزين الملفات. محلياً الآن (قرص/فوليوم)، وقابل للتحويل لسيرفر تخزين
* داخل البلد (السيادة الرقمية) بتغيير STORAGE_BASE_URL + STORAGE_DIR (docs/16، docs/06).
* الملفات لكل مستأجر معزولة تحت مجلده.
*/
@Injectable()
export class StorageService {
constructor(private readonly config: ConfigService) {}
private get dir(): string {
return this.config.get<string>('storage.dir') ?? '/app/storage';
}
private get baseUrl(): string {
return this.config.get<string>('storage.baseUrl') ?? '';
}
/** يحفظ ملفاً ويعيد مفتاحه (مسار نسبي معزول بالمستأجر). */
async save(
tenantId: string,
scope: string,
buffer: Buffer,
originalName = '',
): Promise<string> {
const ext = extname(originalName) || '.bin';
const key = `${tenantId}/${scope}/${randomUUID()}${ext}`;
const full = join(this.dir, key);
await fs.mkdir(dirname(full), { recursive: true });
await fs.writeFile(full, buffer);
return key;
}
/** رابط الوصول (يشير لسيرفر التوطين حين يُضبط STORAGE_BASE_URL). */
url(key: string): string {
return this.baseUrl ? `${this.baseUrl}/${key}` : `/storage/${key}`;
}
async read(key: string): Promise<Buffer> {
return fs.readFile(join(this.dir, key));
}
}
@@ -1,17 +0,0 @@
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;
}
@@ -1,10 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { currentTenantId } from './tenant.context';
/**
* @Tenant() — يحقن معرّف المستأجر الحالي في معاملات المتحكّم.
*/
export const Tenant = createParamDecorator(
(_data: unknown, _ctx: ExecutionContext): string | undefined =>
currentTenantId(),
);
@@ -1,20 +0,0 @@
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());
}
}
@@ -1,30 +0,0 @@
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}`);
}),
);
}
}
-143
View File
@@ -1,143 +0,0 @@
/**
* إعداد مركزي يقرأ متغيرات البيئة مع افتراضات العزل (راجع 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',
},
auth: {
/**
* وضع تطوير: رمز OTP ثابت `1234` يُطبع باللوغ بلا مناداة مزوّد (docs/07).
*
* **يفشل مُغلقاً عمداً**: لا يُفتح إلا بـ`OTP_DEV_MODE=true` حرفياً. كان
* `!== 'false'` أي أن غياب المتغيّر — أو خطأً مطبعياً فيه، أو `OTP_DEV_MODE=0`
* (ونصّ `'0'` ليس `'false'`) — يترك الإنتاج برمز ثابت يفتح **كل حساب في
* المنصة**. الافتراض الآمن أن نرسل رسالة حقيقية، لا أن نفتح الباب.
*/
otpDevMode: process.env.OTP_DEV_MODE === 'true',
otpTtl: parseInt(process.env.OTP_TTL ?? '300', 10),
otpLength: parseInt(process.env.OTP_LENGTH ?? '4', 10),
// ربط الجلسة بالجهاز (docs/17 — D2). مطفأ حتى يرسل فلاتر x-device-id؛
// تفعيله قبل ذلك يقطع كل طلب مصادَق (نفس نمط PAYMENTS_REQUIRE_SIGNATURE).
requireDeviceBinding: process.env.AUTH_REQUIRE_DEVICE_BINDING === 'true',
},
maps: {
// بلاطات انطلق تأتي من خوادم انطلق نفسها — لا نستضيفها (docs/25).
tilesUrl: process.env.MAPS_TILES_URL ?? 'https://maps.intaleqapp.com',
provider: process.env.MAPS_PROVIDER ?? 'antlaq',
// خرائط انطلق (map-saas). مفتاح افتراضي + مفاتيح لكل دولة.
baseUrl: process.env.MAPS_BASE_URL ?? 'https://map-saas.intaleqapp.com',
apiKey: process.env.MAPS_API_KEY ?? '',
apiKeyByCountry: {
jo: process.env.MAPS_API_KEY_JO ?? process.env.MAPS_API_KEY ?? '',
sy: process.env.MAPS_API_KEY_SY ?? process.env.MAPS_API_KEY ?? '',
} as Record<string, string>,
placesApiKey: process.env.MAPS_PLACES_API_KEY ?? process.env.MAPS_API_KEY ?? '',
},
// Nabeh — إرسال OTP عبر واتساب.
// توجيه OTP متعدد المزوّدين حسب الدولة (docs/17 — D5).
otp: {
// نصّ رسالة SMS (Kazumi/مصر). {code} يُستبدل بالرمز.
smsTemplate: process.env.OTP_SMS_TEMPLATE ?? 'Your Tripz code is {code}',
kazumi: {
username: process.env.SMS_USERNAME ?? '',
password: process.env.SMS_PASSWORD_EGYPT ?? '',
sender: process.env.SMS_SENDER ?? '',
},
},
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5).
trips: {
scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10),
// R1 — كم يفحص رحلات searching عالقة (10 ثوانٍ كافية: لا ضغط على القاعدة).
searchTimeoutSweepMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_SWEEP_MS ?? '10000', 10),
// R1 — المهلة قبل إنهاء رحلة searching كـno_drivers (15 دقيقة كسيرو).
searchTimeoutMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_MS ?? '900000', 10),
// L4 — التسعير الديناميكي: كل كم يعيد حساب المضاعفات (3 دقائق افتراضياً).
surgeSweepMs: parseInt(process.env.TRIP_SURGE_SWEEP_MS ?? '180000', 10),
// L4 — الحدّ الأدنى بين تغييرين متتاليين للمضاعف (5 دقائق).
surgeCooldownMs: parseInt(process.env.TRIP_SURGE_COOLDOWN_MS ?? '300000', 10),
// L4 — حسّاسية الزيادة: 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة.
surgeSensitivity: parseFloat(process.env.TRIP_SURGE_SENSITIVITY ?? '0.3'),
},
// المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (docs/17 — L5).
// أبطأ من كنس الرحلات عمداً: هذا شبكة أمان لا مسار أساسي.
rewards: {
sweepMs: parseInt(process.env.REWARDS_SWEEP_MS ?? '60000', 10),
},
nabeh: {
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
email: process.env.NABEH_EMAIL ?? '',
password: process.env.NABEH_PASSWORD ?? '',
otpType: process.env.NABEH_OTP_TYPE ?? 'text',
},
// ملاحظة: خرائط الاتصال الدولية صارت في common/phone/phone.service.ts
// (docs/17 — D1) — مصدر واحد للحقيقة بدل نسختين قد تتباعدان.
// Gemini (رؤية) — قراءة الوثائق ومطابقة الوجه.
gemini: {
apiKey: process.env.GEMINI_API_KEY ?? '',
model: process.env.GEMINI_MODEL ?? 'gemini-2.0-flash',
},
// سرّ المنصة (super-admin) — لتعيين أول أدمن قبل وجود أدمن.
platform: {
secret: process.env.PLATFORM_SECRET ?? '',
},
// تخزين ملفات الوثائق. baseUrl فارغ = محلي؛ يُضبط لسيرفر التوطين لاحقاً (docs/06).
storage: {
dir: process.env.STORAGE_DIR ?? '/app/storage',
baseUrl: process.env.STORAGE_BASE_URL ?? '',
},
// خدمة المواصلات (microservice مستقل)
transit: {
serviceUrl: process.env.TRANSIT_SERVICE_URL ?? 'http://transit:4020',
},
// المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6).
// مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب.
payments: {
requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true',
},
// إشعارات FCM (ملف JSON لحزمة firebase-admin).
fcm: {
credentialsPath: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '',
},
});
-22
View File
@@ -1,22 +0,0 @@
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}'],
});
@@ -1,59 +0,0 @@
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>;
// استحقاقات مشتراة (ماذا يملك) — يحكمها السوبر-أدمن. راجع docs/19.
@Column({ type: 'jsonb', default: {} })
features: Record<string, any>;
/**
* سياسات تشغيل المستأجر (كيف يدير عمله) — يحكمها **المستأجر نفسه**، لا
* السوبر-أدمن. منفصلة عن `features` عمداً: تلك «ماذا اشتريت»، وهذه «كيف
* تشتغل». راجع docs/18 §5.2 (سياسة الدين).
*/
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ default: 'active' })
status: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -1,32 +0,0 @@
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`);
}
}
@@ -1,29 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جدول المستخدمين tripz_users + فهرس فريد (tenant_id, phone).
*/
export class InitUsers1721232000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_users (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
phone varchar NOT NULL,
name varchar,
role varchar NOT NULL DEFAULT 'rider',
status varchar NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone"
ON tripz_users (tenant_id, phone)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_users`);
}
}
@@ -1,87 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جداول P1: drivers, tariffs, trips, trip_events (كلها ببادئة tripz_).
*/
export class InitP11721300000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
// drivers
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_drivers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
service_class varchar NOT NULL DEFAULT 'economy',
vehicle_make varchar, vehicle_model varchar,
vehicle_plate varchar, vehicle_color varchar,
docs jsonb NOT NULL DEFAULT '{}',
verification_status varchar NOT NULL DEFAULT 'pending',
is_online boolean NOT NULL DEFAULT false,
rating numeric(3,2) NOT NULL DEFAULT 5,
last_lat double precision, last_lng double precision,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_user" ON tripz_drivers (tenant_id, user_id)`);
// tariffs
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_tariffs (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
city varchar NOT NULL,
service_class varchar NOT NULL,
definition jsonb NOT NULL,
version int NOT NULL DEFAULT 1,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_tariffs_lookup" ON tripz_tariffs (tenant_id, city, service_class, active)`);
// trips
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trips (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
rider_id uuid NOT NULL,
driver_id uuid,
service_class varchar NOT NULL DEFAULT 'economy',
city varchar,
origin_lat double precision NOT NULL, origin_lng double precision NOT NULL,
dest_lat double precision NOT NULL, dest_lng double precision NOT NULL,
status varchar NOT NULL DEFAULT 'searching',
distance_km numeric(8,3), duration_min numeric(8,1),
tariff_id uuid, tariff_version int,
quoted_fare numeric(12,3), final_fare numeric(12,3),
currency varchar,
payment_method varchar NOT NULL DEFAULT 'cash',
requested_at timestamptz NOT NULL DEFAULT now(),
assigned_at timestamptz, completed_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_status" ON tripz_trips (tenant_id, status)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_rider" ON tripz_trips (tenant_id, rider_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_driver" ON tripz_trips (tenant_id, driver_id)`);
// trip_events
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trip_events (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
from_status varchar,
to_status varchar NOT NULL,
source varchar NOT NULL DEFAULT 'system',
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_events_trip" ON tripz_trip_events (tenant_id, trip_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_trip_events`);
await q.query(`DROP TABLE IF EXISTS tripz_trips`);
await q.query(`DROP TABLE IF EXISTS tripz_tariffs`);
await q.query(`DROP TABLE IF EXISTS tripz_drivers`);
}
}
@@ -1,59 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* P1b: chat_messages, ratings, fraud_flags + أعمدة إلغاء على trips.
*/
export class InitP1b1721400000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancelled_by varchar`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancel_fee numeric(12,3)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_chat_messages (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
sender_id uuid NOT NULL,
sender_role varchar NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_chat_trip" ON tripz_chat_messages (tenant_id, trip_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_ratings (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
by_user_id uuid NOT NULL,
by_role varchar NOT NULL,
target_driver_id uuid,
stars int NOT NULL,
comment text,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_trip" ON tripz_ratings (tenant_id, trip_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_driver" ON tripz_ratings (tenant_id, target_driver_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_fraud_flags (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
subject_type varchar NOT NULL,
subject_id uuid NOT NULL,
trip_id uuid,
reason varchar NOT NULL,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_fraud_subject" ON tripz_fraud_flags (tenant_id, subject_type, subject_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_fraud_flags`);
await q.query(`DROP TABLE IF EXISTS tripz_ratings`);
await q.query(`DROP TABLE IF EXISTS tripz_chat_messages`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancel_fee`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancelled_by`);
}
}
@@ -1,71 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* P2: ride_types, wallets, wallet_txns, device_tokens + trips.is_round_trip.
*/
export class InitP21721500000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_round_trip boolean NOT NULL DEFAULT false`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_ride_types (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
code varchar NOT NULL,
name_ar varchar NOT NULL,
name_en varchar,
vehicle_kind varchar NOT NULL DEFAULT 'car',
women_only boolean NOT NULL DEFAULT false,
round_trip_supported boolean NOT NULL DEFAULT true,
icon varchar,
sort int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_ride_types_code" ON tripz_ride_types (tenant_id, code)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_wallets (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
balance numeric(12,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_wallets_user" ON tripz_wallets (tenant_id, user_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_wallet_txns (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
wallet_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
type varchar NOT NULL,
reason varchar NOT NULL,
ref varchar,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_wallet_txns_wallet" ON tripz_wallet_txns (tenant_id, wallet_id)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_device_tokens (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
token varchar NOT NULL UNIQUE,
platform varchar NOT NULL DEFAULT 'android',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_device_tokens_user" ON tripz_device_tokens (tenant_id, user_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_device_tokens`);
await q.query(`DROP TABLE IF EXISTS tripz_wallet_txns`);
await q.query(`DROP TABLE IF EXISTS tripz_wallets`);
await q.query(`DROP TABLE IF EXISTS tripz_ride_types`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_round_trip`);
}
}
@@ -1,50 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جداول الدفع والسحب (schema الدفع منطقياً — بادئة tripz_pay_، قابلة للفصل).
*/
export class InitPayments1721600000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_pay_payments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
trip_id uuid,
purpose varchar NOT NULL DEFAULT 'topup',
provider varchar NOT NULL,
method varchar,
amount numeric(12,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
status varchar NOT NULL DEFAULT 'pending',
tx_ref varchar,
redirect_url varchar,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_user" ON tripz_pay_payments (tenant_id, user_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payments_status" ON tripz_pay_payments (tenant_id, status)`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_pay_payouts (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_user_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
channel varchar NOT NULL,
destination jsonb NOT NULL DEFAULT '{}',
status varchar NOT NULL DEFAULT 'requested',
ref varchar,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_payouts_driver" ON tripz_pay_payouts (tenant_id, driver_user_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_pay_payouts`);
await q.query(`DROP TABLE IF EXISTS tripz_pay_payments`);
}
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** وثائق السائق. الجدول: tripz_driver_documents. */
export class InitDocuments1721700000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_documents (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
type varchar NOT NULL,
file_key varchar NOT NULL,
number varchar,
expiry_date date,
status varchar NOT NULL DEFAULT 'pending',
review_note varchar,
reviewed_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_driver" ON tripz_driver_documents (tenant_id, driver_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_status" ON tripz_driver_documents (tenant_id, status)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_driver_documents`);
}
}
@@ -1,18 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** حقول إضافية للوثائق: الوجه، اسم الحامل، تاريخ الإصدار، حقول إضافية. */
export class DocumentsFields1721750000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS side varchar NOT NULL DEFAULT 'single'`);
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS holder_name varchar`);
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS issue_date date`);
await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS extra jsonb NOT NULL DEFAULT '{}'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS extra`);
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS issue_date`);
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS holder_name`);
await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS side`);
}
}
@@ -1,12 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** لغة المستخدم — تُستخدم لترجمة الإشعارات (docs/17 — A2). */
export class UserLanguage1721800000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS language varchar NOT NULL DEFAULT 'ar'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS language`);
}
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* دفتر المحفظة (docs/17 — I1):
* - `balance_after` لتدقيق كل حركة ومطابقتها بالرصيد.
* - قيد `balance >= 0` — شبكة أمان أخيرة على مستوى القاعدة: أي مسار كتابة
* يتجاوز الخصم الذرّي سيفشل بدل أن يُنتج رصيداً سالباً بصمت.
*/
export class WalletLedger1721810000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_wallet_txns ADD COLUMN IF NOT EXISTS balance_after numeric(12,3)`,
);
await q.query(`
DO $$ BEGIN
ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_wallets DROP CONSTRAINT IF EXISTS tripz_wallets_balance_non_negative`,
);
await q.query(`ALTER TABLE tripz_wallet_txns DROP COLUMN IF EXISTS balance_after`);
}
}
@@ -1,25 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* تقييم الطرفين (docs/17 — G6):
* - `ratings.target_user_id` — الراكب المُقيَّم عندما يقيّمه السائق. بدونه كان
* تقييم السائق للراكب يُخزَّن بلا هدف فلا يُجمَّع أبداً.
* - `users.rating` — التقييم الظاهر للراكب، يُكتب مرة واحدة يومياً.
*/
export class RatingsBothParties1721820000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_ratings ADD COLUMN IF NOT EXISTS target_user_id uuid`);
await q.query(
`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_tenant_target_user" ON tripz_ratings (tenant_id, target_user_id)`,
);
await q.query(
`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS rating numeric(3,2) NOT NULL DEFAULT 5`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS rating`);
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_ratings_tenant_target_user"`);
await q.query(`ALTER TABLE tripz_ratings DROP COLUMN IF EXISTS target_user_id`);
}
}
@@ -1,41 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* المواقع (docs/17 — H3/H4):
* - أعمدة لقطة الموقع على `drivers` — يكتبها الـworker دورياً، لا كل نبضة.
* - `driver_tracks` — نقاط المسار التاريخية، إدراج مجمّع من الـworker.
*/
export class DriverLocations1721830000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS heading real`);
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS speed real`);
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_status varchar`);
await q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS loc_updated_at timestamptz`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_tracks (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
lat double precision NOT NULL,
lng double precision NOT NULL,
heading real,
speed real,
status varchar,
recorded_at timestamptz NOT NULL
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time"
ON tripz_driver_tracks (tenant_id, driver_id, recorded_at)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_driver_tracks`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_updated_at`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS loc_status`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS speed`);
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS heading`);
}
}
@@ -1,52 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* نموذج الرحلة (docs/17 — B2/B3/B6/B7):
* - فصل السعر: ما يدفعه الراكب مقابل ما يقبضه السائق + العمولة.
* - الانتظار: الدقائق ورسمها.
* - مشوار وصول السائق للراكب: مسافته وزمنه ورسمه.
* - طوابع دقيقة: انطلاق نحو الراكب · الوصول · بدء الرحلة.
*/
export class TripPricingAndTimestamps1721840000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('price_for_passenger', 'numeric(12,3)');
await add('price_for_driver', 'numeric(12,3)');
await add('commission_amount', 'numeric(12,3)');
await add('commission_rate', 'numeric(5,2)');
await add('waiting_min', 'numeric(6,1)');
await add('waiting_charge', 'numeric(12,3)');
await add('pickup_distance_km', 'numeric(8,3)');
await add('pickup_duration_min', 'numeric(8,1)');
await add('pickup_charge', 'numeric(12,3)');
await add('driver_going_at', 'timestamptz');
await add('arrived_at', 'timestamptz');
await add('started_at', 'timestamptz');
// رحلات قائمة: الراكب دفع = السائق قبض (لم تكن هناك عمولة أصلاً).
await q.query(`
UPDATE tripz_trips
SET price_for_passenger = COALESCE(price_for_passenger, final_fare, quoted_fare),
price_for_driver = COALESCE(price_for_driver, final_fare, quoted_fare)
WHERE quoted_fare IS NOT NULL OR final_fare IS NOT NULL
`);
}
public async down(q: QueryRunner): Promise<void> {
const drop = (col: string) =>
q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS ${col}`);
for (const c of [
'started_at', 'arrived_at', 'driver_going_at',
'pickup_charge', 'pickup_duration_min', 'pickup_distance_km',
'waiting_charge', 'waiting_min',
'commission_rate', 'commission_amount', 'price_for_driver', 'price_for_passenger',
]) {
await drop(c);
}
}
}
@@ -1,59 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الرصيد التشغيلي للسائق (docs/18 — المجموعة J).
* عمولة مدفوعة سلفاً، منفصلة تماماً عن محفظة أرباحه.
*
* **بلا قيد `balance >= 0`** — بخلاف `tripz_wallets`: الدين مسموح عمداً
* (قرار المالك: الرحلة لا تُقطع أبداً ولو صار الرصيد سالباً).
*/
export class DriverCredit1721850000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_credits (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
balance numeric(12,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_driver_credits_tenant_driver"
ON tripz_driver_credits (tenant_id, driver_id)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_credit_txns (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
type varchar NOT NULL,
balance_after numeric(12,3),
trip_id uuid,
ref varchar,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_driver_time"
ON tripz_credit_txns (tenant_id, driver_id, created_at)
`);
// مكافأة التسجيل مرة واحدة لكل سائق — تُفرض على القاعدة لا بفحص تطبيقي،
// فتسجيلان متزامنان لا يمنحانها مرتين (docs/18 — J6).
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_signup_once"
ON tripz_credit_txns (tenant_id, driver_id)
WHERE type = 'signup_bonus'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_credit_txns`);
await q.query(`DROP TABLE IF EXISTS tripz_driver_credits`);
}
}
@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* سياسات تشغيل المستأجر (docs/18 §5.2): سياسة الدين ومكافأة التسجيل لكل عملة.
*
* منفصلة عن `features` عمداً: تلك استحقاقات مشتراة يحكمها السوبر-أدمن، وهذه
* سياسات يحكمها المستأجر نفسه.
*/
export class TenantSettings1721860000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_tenants ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_tenants DROP COLUMN IF EXISTS settings`);
}
}
@@ -1,63 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* أمان السحب + سجل التدقيق (docs/17 — I4/I5/I7).
* - أعمدة إثبات هوية السحب على `pay_payouts`.
* - `audit_log` — من فعل ماذا ومتى ومن أين (append-only).
*/
export class PayoutSecurityAndAudit1721870000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_pay_payouts ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('otp_verified_at', 'timestamptz');
await add('biometric_method', 'varchar');
await add('biometric_at', 'timestamptz');
await add('device_id', 'varchar');
await add('request_ip', 'varchar');
// الافتراض الجديد للطلبات الجديدة: لا شيء يتحرك قبل التأكيد.
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'pending_otp'`);
// السحوبات القائمة أُنشئت قبل وجود OTP وقد حُجز مالها فعلاً — تُعتبر
// مُتحقَّقة، وإلا رفض `complete` صرفها إلى الأبد.
await q.query(`
UPDATE tripz_pay_payouts
SET otp_verified_at = COALESCE(otp_verified_at, created_at)
WHERE status IN ('requested', 'processing', 'paid')
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_audit_log (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
actor_user_id uuid,
actor_role varchar,
action varchar NOT NULL,
subject_type varchar,
subject_id varchar,
amount numeric(12,3),
currency varchar,
meta jsonb NOT NULL DEFAULT '{}',
ip varchar,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_tenant_time"
ON tripz_audit_log (tenant_id, created_at)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_audit_log_subject"
ON tripz_audit_log (tenant_id, subject_type, subject_id)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_audit_log`);
await q.query(`ALTER TABLE tripz_pay_payouts ALTER COLUMN status SET DEFAULT 'requested'`);
for (const c of ['request_ip', 'device_id', 'biometric_at', 'biometric_method', 'otp_verified_at']) {
await q.query(`ALTER TABLE tripz_pay_payouts DROP COLUMN IF EXISTS ${c}`);
}
}
}
@@ -1,50 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* تطبيع أرقام الهاتف المخزَّنة مسبقاً (docs/17 — D1).
*
* قبل هذه الهجرة كانت `sendOtp`/`verifyOtp` تخزّنان الرقم **كما كُتب حرفياً**
* — فحسابات اختبار الحمل (`0797…`) وأي دخول سابق مخزَّنة بصيغة محلية لا
* دولية. تطبّق هذه الهجرة نفس منطق `PhoneService` بـSQL خام لكل مستأجر حسب
* `country_pack`، وتتخطّى أي صفٍّ يتعارض ناتجه مع صفٍّ آخر (لا تكسر القيد
* الفريد `(tenant_id, phone)`؛ التعارض النادر يُترك يدوياً).
*/
export class NormalizePhones1721880000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
// jo/sy: صفر محلي + 9 أرقام → مفتاح الدولة + 9 أرقام.
await q.query(`
UPDATE tripz_users u
SET phone = t.country_pack_cc || substring(u.phone from 2)
FROM (
SELECT id, CASE country_pack WHEN 'jo' THEN '962' WHEN 'sy' THEN '963' END AS country_pack_cc
FROM tripz_tenants WHERE country_pack IN ('jo', 'sy')
) t
WHERE u.tenant_id = t.id
AND u.phone ~ '^0[0-9]{9}$'
AND NOT EXISTS (
SELECT 1 FROM tripz_users u2
WHERE u2.tenant_id = u.tenant_id
AND u2.phone = t.country_pack_cc || substring(u.phone from 2)
)
`);
// eg: صفر محلي + 10 أرقام → "20" + 10 أرقام.
await q.query(`
UPDATE tripz_users u
SET phone = '20' || substring(u.phone from 2)
FROM tripz_tenants t
WHERE u.tenant_id = t.id
AND t.country_pack = 'eg'
AND u.phone ~ '^0[0-9]{10}$'
AND NOT EXISTS (
SELECT 1 FROM tripz_users u2
WHERE u2.tenant_id = u.tenant_id AND u2.phone = '20' || substring(u.phone from 2)
)
`);
}
public async down(): Promise<void> {
// لا رجوع — لا سبيل لمعرفة الصيغة الأصلية بعد التطبيع (وقد تكون كانت
// بصيغ مختلفة أصلاً). التطبيع أُحادي الاتجاه بطبيعته.
}
}
@@ -1,26 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* إشارة المكالمة على الرحلة (docs/17 — E1/E2؛ مكافئ isDriverCallPassenger
* عند سيرو). تُخزَّن دائماً على الرحلة نفسها — لا في Redis فقط — لأنها
* حقيقة تفيد التحقيق حتى بعد زوال حالة الرحلة من الكاش.
*/
export class TripCallSignal1721890000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS driver_called_passenger boolean NOT NULL DEFAULT false`,
);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_at timestamptz`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS last_call_by varchar`);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS call_count integer NOT NULL DEFAULT 0`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS call_count`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_by`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS last_call_at`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS driver_called_passenger`);
}
}
@@ -1,56 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { encrypt, blindIndex } from '../../common/crypto/crypto.util';
/**
* تشفير الهاتف at-rest + فهرس أعمى للبحث (docs/16، docs/17 — D1 إضافة).
*
* **لماذا فهرس منفصل**: `phone` بعد التشفير غير قابل للبحث بالمساواة إطلاقاً
* — AES-256-GCM يستعمل IV عشوائياً لكل عملية، فنفس الرقم الحقيقي ينتج شيفرة
* مختلفة كل مرة. القيد الفريد ونقاط `WHERE phone = …` (تسجيل الدخول بأكمله)
* ينتقلان إلى `phone_bidx` — بصمة حتمية (HMAC) لا تكشف الرقم لكنها تكرّر
* نفسها لنفس الرقم دائماً.
*
* تُشفَّر الصفوف القائمة في نفس الهجرة (لا تُترك نصّاً صافياً) — الحماية
* التلقائية عبر `decrypt()` المتسامح كانت ستُبقيها صافية للأبد بلا هذا.
*/
export class EncryptPhone1721900000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_users ADD COLUMN IF NOT EXISTS phone_bidx varchar`);
const rows: { id: string; phone: string }[] = await q.query(
`SELECT id, phone FROM tripz_users`,
);
for (const row of rows) {
// تسامح إعادة التشغيل الجزئي: لا نُشفّر قيمة مشفّرة أصلاً مرة أخرى.
const alreadyEncrypted = row.phone?.startsWith('v1:');
const canonicalPhone = alreadyEncrypted ? null : row.phone;
const bidx = blindIndex(canonicalPhone ?? row.phone);
if (alreadyEncrypted) {
await q.query(`UPDATE tripz_users SET phone_bidx = $1 WHERE id = $2`, [bidx, row.id]);
} else {
await q.query(`UPDATE tripz_users SET phone = $1, phone_bidx = $2 WHERE id = $3`, [
encrypt(row.phone),
bidx,
row.id,
]);
}
}
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx SET NOT NULL`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone"`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
ON tripz_users (tenant_id, phone_bidx)
`);
}
public async down(q: QueryRunner): Promise<void> {
// لا رجوع لفكّ التشفير جماعياً هنا عمداً — decrypt() يبقى متاحاً عند
// القراءة عبر الـORM؛ التراجع عن هذه الهجرة يعني قبول عدم قابلية البحث
// إن أُعيد phone فهرساً فريداً وحده، فلا نعيد بناء ذلك القيد تلقائياً.
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
await q.query(`ALTER TABLE tripz_users ALTER COLUMN phone_bidx DROP NOT NULL`);
await q.query(`ALTER TABLE tripz_users DROP COLUMN IF EXISTS phone_bidx`);
}
}
@@ -1,76 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* بيانات السائق والمركبة (docs/17 — المجموعة C):
* - جدول `vehicles` (مكافئ CarRegistration عند سيرو) — سائق قد يملك أكثر من مركبة.
* - حقول الملف الشخصي للسائق: الرقم الوطني (مشفّر + فهرس أعمى)، الرخصة،
* ai_data/user_input، سبب الرفض.
*/
export class VehiclesAndDriverProfile1721910000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_vehicles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
plate varchar,
vin varchar,
make varchar,
model varchar,
year integer,
color varchar,
color_hex varchar,
fuel varchar,
owner varchar,
registration_expiry date,
category varchar,
is_default boolean NOT NULL DEFAULT false,
status varchar NOT NULL DEFAULT 'pending',
ai_data jsonb NOT NULL DEFAULT '{}',
user_input jsonb NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(
`CREATE INDEX IF NOT EXISTS "IDX_tripz_vehicles_tenant_driver" ON tripz_vehicles (tenant_id, driver_id)`,
);
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('gender', 'varchar');
await add('national_number', 'varchar');
await add('national_number_bidx', 'varchar');
await add('name_arabic', 'varchar');
await add('birthdate', 'date');
await add('address', 'varchar');
await add('license_type', 'varchar');
await add('license_categories', 'varchar');
await add('license_issue', 'date');
await add('license_expiry', 'date');
await add('rejected_reason', 'varchar');
await add(`ai_data`, `jsonb NOT NULL DEFAULT '{}'`);
await add(`user_input`, `jsonb NOT NULL DEFAULT '{}'`);
// فهرس فريد على الرقم الوطني لكل مستأجر (عبر الفهرس الأعمى، لا العمود المشفّر
// — العشوائي لا يصلح للتفرّد). جزئي: يتجاهل السائقين بلا رقم وطني بعد.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_national"
ON tripz_drivers (tenant_id, national_number_bidx)
WHERE national_number_bidx IS NOT NULL
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_drivers_tenant_national"`);
for (const c of [
'user_input', 'ai_data', 'rejected_reason', 'license_expiry', 'license_issue',
'license_categories', 'license_type', 'address', 'birthdate', 'name_arabic',
'national_number_bidx', 'national_number', 'gender',
]) {
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS ${c}`);
}
await q.query(`DROP TABLE IF EXISTS tripz_vehicles`);
}
}
@@ -1,34 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* شريحة B الثانية (docs/17 — B4/B5/B8):
* - نقاط التوقف (`stops` jsonb).
* - الحجز المسبق (`scheduled_at` + حالة `scheduled`).
* - مطابقة الوجهة (`is_destination_match` + `destination_match_discount`).
*/
export class TripStopsSchedulingMatch1721920000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS stops jsonb NOT NULL DEFAULT '[]'`);
await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS scheduled_at timestamptz`);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_destination_match boolean NOT NULL DEFAULT false`,
);
await q.query(
`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS destination_match_discount numeric(12,3)`,
);
// الكنس يبحث عن scheduled المستحقة زمنياً — فهرس جزئي عليها فقط.
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_scheduled_due"
ON tripz_trips (scheduled_at)
WHERE status = 'scheduled'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trips_scheduled_due"`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS destination_match_discount`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_destination_match`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS scheduled_at`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS stops`);
}
}
@@ -1,47 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* دفترا مال المستأجر (docs/24 — P0): الإيراد والأمانات.
*
* **جدولان منفصلان لا جدول واحد بعمود نوع**: استعلامُ سحبٍ أو تقريرٍ ينسى شرط
* النوع كان سيخلط مال المستأجر بأمانات الركّاب. مع جدولين لا يوجد صفٌّ خطأ
* ليُقرأ أصلاً.
*
* الفهرس الفريد على (tenant_id, ref) هو **حارس منع التسوية المزدوجة**: رسالة
* تأكيد من كليك/شام كاش قد تصل مرتين، فيرفض الثانية. القيم الفارغة متعدّدة
* مسموحة في postgres، فالحركات بلا مرجع خارجي لا تتصادم.
*/
export class TenantWalletLedgers1721930000000 implements MigrationInterface {
private readonly tables = ['tripz_tenant_revenue_ledger', 'tripz_tenant_pending_ledger'];
public async up(q: QueryRunner): Promise<void> {
for (const t of this.tables) {
await q.query(`
CREATE TABLE IF NOT EXISTS ${t} (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
amount numeric(14,3) NOT NULL,
currency varchar NOT NULL DEFAULT 'JOD',
reason varchar NOT NULL,
ref varchar,
meta jsonb NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_${t}_tenant_created"
ON ${t} (tenant_id, created_at)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_${t}_tenant_ref"
ON ${t} (tenant_id, ref)
`);
}
}
public async down(q: QueryRunner): Promise<void> {
for (const t of this.tables) {
await q.query(`DROP TABLE IF EXISTS ${t}`);
}
}
}
@@ -1,47 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* سجلّ رسائل المزوّدين الخام (docs/24 §5 — P2): تسوية كليك/شام كاش بلا API.
*
* الفهرس الفريد على (tenant_id, fingerprint) هو حارس التسوية المزدوجة على
* مستوى الرسالة: الجهاز قد يعيد الإرسال بعد انقطاع شبكة، فتصل الرسالة ذاتها
* مرتين — وبلا هذا الفهرس تُسوَّى الفاتورة مرتين ويُشحن الرصيد ضعفين.
*/
export class RawSmsSettlement1721940000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_pay_raw_sms (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
provider varchar NOT NULL,
sender varchar,
body text NOT NULL,
fingerprint varchar NOT NULL,
device_id varchar,
status varchar NOT NULL DEFAULT 'received',
parsed jsonb NOT NULL DEFAULT '{}',
payment_id uuid,
note varchar,
sent_at timestamptz,
received_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_pay_raw_sms_fingerprint"
ON tripz_pay_raw_sms (tenant_id, fingerprint)
`);
// طابور المراجعة يُقرأ بالحالة، والتقارير بالزمن.
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_raw_sms_status"
ON tripz_pay_raw_sms (tenant_id, status)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_pay_raw_sms_received"
ON tripz_pay_raw_sms (tenant_id, received_at)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_pay_raw_sms`);
}
}
@@ -1,169 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الإحالات والكوبونات (المجموعة L — قرار المالك 2026-07-18).
*
* مكافأة الراكب كوبون لا رصيد محفظة: محفظة الراكب لا تُسحب أبداً (قانون
* ثابت)، فإنزال مكافأة تسويقية فيها يخلق التزاماً نقدياً بلا مقابل.
* والحدود هنا مفروضة على القاعدة لا بفحص تطبيقي وحده — الفحص التطبيقي
* يسقط تحت التزامن، والقيد لا يسقط.
*/
export class ReferralsAndCoupons1721950000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_coupons (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
code varchar NOT NULL,
user_id uuid,
discount_type varchar NOT NULL DEFAULT 'percent',
discount_value numeric(12,3) NOT NULL,
max_discount numeric(12,3),
min_fare numeric(12,3),
currency varchar NOT NULL DEFAULT 'JOD',
valid_from TIMESTAMP,
valid_until TIMESTAMP,
max_redemptions int,
max_per_user int NOT NULL DEFAULT 1,
redeemed_count int NOT NULL DEFAULT 0,
status varchar NOT NULL DEFAULT 'active',
source varchar NOT NULL DEFAULT 'campaign',
ref varchar,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
-- خصم سالب كان سيرفع الأجرة؛ ونسبة فوق 100 تجعلها سالبة.
CONSTRAINT chk_tripz_coupons_value CHECK (
discount_value >= 0
AND (discount_type <> 'percent' OR discount_value <= 100)
),
CONSTRAINT chk_tripz_coupons_cap CHECK (max_discount IS NULL OR max_discount >= 0)
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_code"
ON tripz_coupons (tenant_id, code)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_user"
ON tripz_coupons (tenant_id, user_id)
`);
// كوبون واحد لكل سبب منح — يمنع مكافأة إحالة مزدوجة إذا التقى الصرف
// الفوري بالماسح على نفس الإحالة.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_ref_once"
ON tripz_coupons (tenant_id, ref)
WHERE ref IS NOT NULL
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_coupon_redemptions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
coupon_id uuid NOT NULL,
user_id uuid NOT NULL,
trip_id uuid NOT NULL,
discount_amount numeric(12,3) NOT NULL,
fare_before numeric(12,3),
currency varchar NOT NULL DEFAULT 'JOD',
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
// كوبون واحد لكل رحلة — لا تكديس أكواد على أجرة واحدة.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_trip_once"
ON tripz_coupon_redemptions (tenant_id, trip_id)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_coupon"
ON tripz_coupon_redemptions (tenant_id, coupon_id)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_user"
ON tripz_coupon_redemptions (tenant_id, user_id)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_referral_codes (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
code varchar NOT NULL,
role varchar NOT NULL,
status varchar NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_code"
ON tripz_referral_codes (tenant_id, code)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_user"
ON tripz_referral_codes (tenant_id, user_id)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_referrals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
referrer_user_id uuid NOT NULL,
referee_user_id uuid NOT NULL,
referrer_role varchar NOT NULL,
referee_role varchar NOT NULL,
code varchar NOT NULL,
status varchar NOT NULL DEFAULT 'pending',
qualifying_trips int NOT NULL DEFAULT 0,
qualified_trip_id uuid,
qualified_at TIMESTAMP,
paid_at TIMESTAMP,
rejected_reason varchar,
currency varchar NOT NULL DEFAULT 'JOD',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
-- إحالة النفس: أوضح صور الاحتيال وأرخصها منعاً.
CONSTRAINT chk_tripz_referrals_not_self CHECK (referrer_user_id <> referee_user_id)
)
`);
// الحارس الأساسي: الشخص يُحال **مرة واحدة** لا مرة لكل داعٍ.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referrals_referee_once"
ON tripz_referrals (tenant_id, referee_user_id)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_referrer"
ON tripz_referrals (tenant_id, referrer_user_id)
`);
// الماسح يقرأ بالحالة كل دورة — بلا فهرس يمسح الجدول كله مع نموّه.
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_status"
ON tripz_referrals (tenant_id, status)
`);
// أثر الكوبون على الرحلة — نصاً لا مفتاحاً أجنبياً: الرحلة سجلّ تاريخي،
// وحذف حملة قديمة يجب ألّا يمحو سبب الخصم من رحلة مضت.
await q.query(`
ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_code varchar
`);
await q.query(`
ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_discount numeric(12,3)
`);
// مكافأة الإحالة مرة واحدة لكل سائق لكل إحالة — نفس حارس J6 على القاعدة.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_referral_once"
ON tripz_credit_txns (tenant_id, ref)
WHERE type = 'referral_bonus'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_credit_txns_referral_once"`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_discount`);
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_code`);
await q.query(`DROP TABLE IF EXISTS tripz_referrals`);
await q.query(`DROP TABLE IF EXISTS tripz_referral_codes`);
await q.query(`DROP TABLE IF EXISTS tripz_coupon_redemptions`);
await q.query(`DROP TABLE IF EXISTS tripz_coupons`);
}
}
@@ -1,38 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* المسافة المقاسة مقابل المقدَّرة (docs/17 — B12).
*
* الأجرة تُحسب على ما قُطع فعلاً من نبضات GPS لا على تقدير وقت الطلب:
* تحويلة حقيقية يقبضها السائق، وجولة مخترَعة يقصّها السقف. الأعمدة الثلاثة
* تُخزَّن دائماً — حتى حين لا يُحاسَب عليها — لأن النسبة هي ما يُقرأ لاحقاً
* لكشف النمط: سائق نسبته 1.6 في كل رحلاته ليس ضحية ازدحام.
*/
export class ActualDistanceBilling1721960000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
ALTER TABLE tripz_trips
ADD COLUMN IF NOT EXISTS actual_distance_km numeric(10,3),
ADD COLUMN IF NOT EXISTS billed_distance_km numeric(10,3),
ADD COLUMN IF NOT EXISTS distance_ratio numeric(6,3)
`);
// الاستعلام الحاكم في التسوية: نبضات سائق بين لحظتين. بلا هذا الفهرس
// يمسح الجدول كاملاً — وهو أسرع الجداول نموّاً عندنا (نبضة كل ثوانٍ لكل
// سائق متصل)، فالمسح يزداد كلفةً كل يوم.
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time"
ON tripz_driver_tracks (tenant_id, driver_id, recorded_at)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_driver_tracks_driver_time"`);
await q.query(`
ALTER TABLE tripz_trips
DROP COLUMN IF EXISTS distance_ratio,
DROP COLUMN IF EXISTS billed_distance_km,
DROP COLUMN IF EXISTS actual_distance_km
`);
}
}
@@ -1,219 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الجداول السبعة الجديدة + أعمدة الشرائح على tripz_drivers.
*
* الجداول:
* - tripz_job_executions — سجل المهام الدورية (O5)
* - tripz_surge_states — حالة التسعير الديناميكي (L4)
* - tripz_bot_tasks — مهام بوتات التواصل (O4)
* - tripz_feature_catalog — كتالوج الميزات القابلة للبيع (N6)
* - tripz_ride_type_catalog — كتالوج أنواع الرحلات العالمي (L2)
* - tripz_marketing_campaigns — حملات التسويق
* - tripz_trip_audio — تسجيلات صوتية للرحلات (R2)
*
* أعمدة إضافية على tripz_drivers: tier, tier_score, total_trips, acceptance_rate.
*/
export class AddNewTablesAndDriverColumns1721970000000
implements MigrationInterface
{
public async up(q: QueryRunner): Promise<void> {
// ─── 1. job_executions ────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_job_executions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_name varchar NOT NULL,
status varchar(20) NOT NULL,
started_at timestamptz NOT NULL,
finished_at timestamptz,
duration_ms int,
items_processed int NOT NULL DEFAULT 0,
error_message text,
tenant_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_job_executions_job_started"
ON tripz_job_executions (job_name, started_at)
`);
// ─── 2. surge_states ──────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_surge_states (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
city varchar NOT NULL,
service_class varchar NOT NULL,
multiplier numeric(5,2) NOT NULL DEFAULT 1.0,
max_multiplier numeric(5,2) NOT NULL DEFAULT 2.0,
min_multiplier numeric(5,2) NOT NULL DEFAULT 1.0,
demand_count int NOT NULL DEFAULT 0,
supply_count int NOT NULL DEFAULT 0,
last_sample_at timestamptz,
last_changed_at timestamptz,
manual_override boolean NOT NULL DEFAULT false,
manual_note varchar,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_surge_states_tenant_city_class"
ON tripz_surge_states (tenant_id, city, service_class)
`);
// ─── 3. bot_tasks ─────────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_bot_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
platform varchar NOT NULL,
task_type varchar NOT NULL,
status varchar NOT NULL DEFAULT 'pending',
config jsonb NOT NULL DEFAULT '{}',
result jsonb NOT NULL DEFAULT '{}',
items_processed int NOT NULL DEFAULT 0,
items_failed int NOT NULL DEFAULT 0,
error_message text,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
// ─── 4. feature_catalog ───────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_feature_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key varchar(64) NOT NULL,
name_ar varchar NOT NULL,
name_en varchar NOT NULL,
category varchar NOT NULL DEFAULT 'add_on',
monthly_price numeric(10,3) NOT NULL DEFAULT 0,
setup_fee numeric(10,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
description_ar text,
sort_order int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_feature_catalog_key"
ON tripz_feature_catalog (key)
`);
// ─── 5. ride_type_catalog ─────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_ride_type_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar NOT NULL,
name_ar varchar NOT NULL,
name_en varchar,
vehicle_kind varchar NOT NULL DEFAULT 'car',
women_only boolean NOT NULL DEFAULT false,
round_trip_supported boolean NOT NULL DEFAULT true,
requires_weight boolean NOT NULL DEFAULT false,
max_seats int NOT NULL DEFAULT 4,
icon varchar,
sort int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_ride_type_catalog_code"
ON tripz_ride_type_catalog (code)
`);
// ─── 6. marketing_campaigns ───────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_marketing_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
name varchar NOT NULL,
type varchar NOT NULL,
status varchar NOT NULL DEFAULT 'draft',
title text,
body text,
data jsonb NOT NULL DEFAULT '{}',
targeting jsonb NOT NULL DEFAULT '{}',
sent_count int NOT NULL DEFAULT 0,
open_count int NOT NULL DEFAULT 0,
click_count int NOT NULL DEFAULT 0,
scheduled_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
// ─── 7. trip_audio ────────────────────────────────────────────
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trip_audio (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
uploaded_by varchar NOT NULL,
file_key varchar NOT NULL,
file_ext varchar NOT NULL DEFAULT 'm4a',
duration_sec int,
file_size bigint,
destination_name varchar,
origin_lat double precision,
origin_lng double precision,
dest_lat double precision,
dest_lng double precision,
trip_status_at_upload varchar,
uploaded_at timestamptz NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_tenant_trip"
ON tripz_trip_audio (tenant_id, trip_id)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_trip"
ON tripz_trip_audio (trip_id)
`);
// ─── 8. أعمدة الشرائح على tripz_drivers ──────────────────────
await q.query(`
ALTER TABLE tripz_drivers
ADD COLUMN IF NOT EXISTS tier varchar NOT NULL DEFAULT 'bronze',
ADD COLUMN IF NOT EXISTS tier_score int NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_trips int NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS acceptance_rate numeric(5,2) NOT NULL DEFAULT 100
`);
}
public async down(q: QueryRunner): Promise<void> {
// إزالة أعمدة الشرائح
await q.query(`
ALTER TABLE tripz_drivers
DROP COLUMN IF EXISTS acceptance_rate,
DROP COLUMN IF EXISTS total_trips,
DROP COLUMN IF EXISTS tier_score,
DROP COLUMN IF EXISTS tier
`);
// حذف الجداول (الترتيب العكسي للعلاقات)
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_trip"`);
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_tenant_trip"`);
await q.query(`DROP TABLE IF EXISTS tripz_trip_audio`);
await q.query(`DROP TABLE IF EXISTS tripz_marketing_campaigns`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_ride_type_catalog_code"`);
await q.query(`DROP TABLE IF EXISTS tripz_ride_type_catalog`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_feature_catalog_key"`);
await q.query(`DROP TABLE IF EXISTS tripz_feature_catalog`);
await q.query(`DROP TABLE IF EXISTS tripz_bot_tasks`);
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_surge_states_tenant_city_class"`);
await q.query(`DROP TABLE IF EXISTS tripz_surge_states`);
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_job_executions_job_started"`);
await q.query(`DROP TABLE IF EXISTS tripz_job_executions`);
}
}
@@ -1,31 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* فصل هوية الراكب عن السائق (قرار المالك 2026-07-20 — الخيار ب).
*
* قبل: القيد الفريد `(tenant_id, phone_bidx)` يعني مستخدماً واحداً لكل رقم في
* كل مستأجر — فتسجيل الرقم كسائق (`setRole('driver')`) يقلب سجلّه للأبد ويكسر
* وضع الراكب لنفس الرقم. بعد: الرقم الواحد يملك سجلّاً للراكب وآخر للسائق
* (ومحفظتين منفصلتين تلقائياً لأنها مربوطة بـ `user_id`).
*
* التغيير: القيد الفريد يصبح `(tenant_id, phone_bidx, role)`. لا فقدان بيانات —
* السجلات القائمة تبقى كما هي؛ الجديد فقط أنه يُسمح بسجلٍّ ثانٍ بدور مختلف.
*/
export class SeparateRiderDriverIdentity1721980000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx"`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx_role"
ON tripz_users (tenant_id, phone_bidx, role)
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_users_tenant_phone_bidx_role"`);
// العودة قد تفشل لو وُجد رقم بدورين — لا يمكن استعادة قيد أضيق مع بيانات تخالفه.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone_bidx"
ON tripz_users (tenant_id, phone_bidx)
`);
}
}
@@ -1,60 +0,0 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import {
buildDefinition,
COUNTRY_TARIFFS,
SEEDED_CLASSES,
} from '../../modules/tariff/default-tariffs';
/**
* يولّد `tariffs.sql` لتطبيقها على قاعدة **قائمة** — للمستأجرين الموجودين
* قبل أن يصير الزرع تلقائياً.
*
* التشغيل: `npx ts-node src/database/seeds/generate-tariff-sql.ts`
*
* مستأجر جديد لا يحتاج هذا الملف إطلاقاً: `SeedService` يزرع له التعرفة من
* **نفس** `default-tariffs.ts` عند الإقلاع. هذا المولّد للحاق بالقائم فقط،
* ولذلك يقرأ من مصدر الحقيقة نفسه — نسختان من أرقام التسعير كانتا ستتباعدان.
*
* الناتج **آمن للتكرار**: كل تشغيل يطفئ الفعّالة ويُدرج `max(version)+1`،
* فلا يبقى صفّان فعّالان لنفس (مستأجر، مدينة، فئة) — و`getActive` ترتّب
* `version DESC` فكان الاختيار بينهما غير محدَّد.
*/
function generateSql(): string {
let sql = `-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد.\n`;
sql += `-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً.\n`;
sql += `-- المصدر: src/modules/tariff/default-tariffs.ts\n`;
sql += `-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى.\n\n`;
sql += `BEGIN;\n\n`;
for (const country of COUNTRY_TARIFFS) {
sql += `-- ============ ${country.countryPack.toUpperCase()} (${country.currency}) ============\n`;
for (const serviceClass of SEEDED_CLASSES) {
const definition = buildDefinition(country, serviceClass);
const defJson = JSON.stringify(definition).replace(/'/g, "''");
// يطال **كل** مستأجر في هذا البلد لا سيرو وحده: تعرفة الانطلاق عامة،
// وربطها بمستأجر واحد كان يترك كل مستأجر آخر بلا تسعير.
sql += `-- ${serviceClass}\n`;
sql += `UPDATE tripz_tariffs SET active = false\n`;
sql += ` WHERE city = 'default' AND service_class = '${serviceClass}' AND active = true\n`;
sql += ` AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = '${country.countryPack}');\n`;
sql += `INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)\n`;
sql += `SELECT uuid_generate_v4(), t.id, 'default', '${serviceClass}',\n`;
sql += ` COALESCE((SELECT MAX(version) FROM tripz_tariffs x\n`;
sql += ` WHERE x.tenant_id = t.id AND x.city = 'default'\n`;
sql += ` AND x.service_class = '${serviceClass}'), 0) + 1,\n`;
sql += ` true, '${defJson}'::jsonb\n`;
sql += ` FROM tripz_tenants t WHERE t.country_pack = '${country.countryPack}';\n\n`;
}
}
sql += `COMMIT;\n`;
return sql;
}
const out = join(__dirname, 'tariffs.sql');
writeFileSync(out, generateSql());
// eslint-disable-next-line no-console
console.log(`✅ generated ${out}`);

Some files were not shown because too many files have changed in this diff Show More