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:
co-authored by
Claude Opus 5
parent
75b80bd299
commit
fff76c949f
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
# ملاحظة: ملفات *.spec.ts تدخل السياق عمداً — الاختبارات تعمل داخل مرحلة
|
||||
# builder على السيرفر (راجع docs/15). tsconfig.build.json يستثنيها من dist،
|
||||
# فلا تصل صورة التشغيل.
|
||||
@@ -0,0 +1,113 @@
|
||||
# ==== 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=
|
||||
@@ -0,0 +1,25 @@
|
||||
# ==== 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"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# ⚠️ باك إند مؤرشف — NestJS. لا يُبنى ولا يُنشر.
|
||||
|
||||
أُرشف بقرار المالك **2026-07-27**. كان اسمه `backend/`.
|
||||
|
||||
## لماذا
|
||||
|
||||
الباك إند المعتمد للإنتاج صار **باك إند سيرو PHP** (يُنقل إلى هذا المستودع
|
||||
كنسخة جديدة باسم **انطلق** على `api.intaleqapp.com`). السبب المعلن: هذا
|
||||
الباك إند مكتمل تقنياً لكن **بلا تطبيقات تعمل عليه** — وبناء تطبيقات له من
|
||||
الصفر أطول من نقل تطبيقات سيرو المجرّبة ميدانياً مع باك إندها.
|
||||
|
||||
يُقرأ مع: [docs/34](../docs/34-siro-backend-multitenancy.md) (سيرفر مستقل لكل
|
||||
مستأجر) · [docs/30](../docs/30-siro-port-plan.md) (خطة النقل السابقة).
|
||||
|
||||
## لماذا لم يُحذف — اقرأ هذا قبل أي `rm`
|
||||
|
||||
**`scripts/e2e-test.mjs` هو أدق توثيق سلوكي نملكه لدورة الرحلة كاملة**
|
||||
(دخول → سائق → رحلة → حالات → WebSocket → دفع → عمولة → تقييم → payout)
|
||||
بالأشكال الفعلية للطلب والرد. عند بناء أي وحدة ناقصة في باك إند PHP
|
||||
(المحفظتان · الاستحقاقات · محرّك التسعير الكامل · الوحدات المدفوعة) فهذا
|
||||
الملف مرجع «ماذا يجب أن يحدث»، لا الكود.
|
||||
|
||||
كذلك 38 controller هنا تمثّل عقد الـAPI الذي بُنيت عليه:
|
||||
- `apps/rider` و`apps/driver` الحاليان (إعادة بناء Cubit — تُؤرشف أيضاً عند
|
||||
وصول تطبيقات سيرو)
|
||||
- `dashboards/admin-web` و`service-web` و`superadmin-web` (نماذج HTML أوّلية)
|
||||
|
||||
## قواعد
|
||||
|
||||
- ❌ لا تُبنى صورته ولا تُشغّل حاويته. `docker-compose.yml` بداخله للتأريخ فقط.
|
||||
- ❌ لا تُوجَّه إليه أي حركة إنتاج.
|
||||
- ✅ يُقرأ كمرجع عقد وسلوك.
|
||||
- المسارات في `docs/*.md` التي تذكر `backend/` تُقرأ على أنها `backend-archive/`
|
||||
— لم تُعدَّل عمداً لأنها سجلّ تاريخي لقرارات وقتها.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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(وكيل انطلق).
|
||||
@@ -0,0 +1,84 @@
|
||||
# ==== 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
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "tripz-e2e-tester",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"socket.io-client": "^4.7.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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();
|
||||
@@ -0,0 +1,155 @@
|
||||
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();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "tripz-7883a",
|
||||
"private_key_id": "ed26bbec475d75c258960f8ab45632e44b915630",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDjzervI3IS5DvB\nLb6xHph1Au7H/0QFNwAQOw4Z0sh8idAKlxugl51UnqKy8RLReoavUX1PYnXsK58Z\nqdoUMi0KyHk37fvhPSNpzFENr0oziNs80fqM7R7QVFo2X5ZkzpbtjvGxNeOlEDAu\n9Pw7K1LU0rBr3Rl/7UEl+8aj/hp0/q60i0P9sa3+cENIR+Ka9520eguk2raCi30g\n53/cj9P4tNd3EaVp/loNuoOh7ZXXf/zg/UjaHPSzXWho2VeRHktbh3rZQY9krczK\nnlppReK7Ir12NY7AMIdhucmtCmdHbXtkuRn1Z4Lgn3+/w/e7QotrW6MIkoit2ckD\nTWaNtUknAgMBAAECggEAOqCbtnjeWltPv5tIyOexX+fl0SXyeQ8bHpI8QkwbmeWT\n263hB9cNQCJlh/0AFVZdxFAKGnyiEap2U1IB1JHHQs+uVsUSPnkQHKHfOFDlHVc6\n6S+3Nj6uX/pdj0HRgFckw7W2oR9X23rLk8IsCgRP2ILysYfhEKncSuy9xiXW5EXN\nFPa2OSucE/OCQh0IbctMf75HMHhmJ4+CKpL6c9B9DWg4AjV2P9u5roNTwFCEWJt1\nOrBs09TMcoOtpBpejJY8kgjfjWLin8tkx1EYFh0RQuJxV3ePRmYT2ahwl1PQO/zK\n9XDYGQ3wLLtxrYHYM/RKtyY5AqGLldjbbiQUjeyLyQKBgQD3dhM4CiHkeev7PGD2\nIZwSkm6HVOVNocGAyH8naxZMYO1y4AkhHEpMJfuDkWCk8CpFd6j2mFoyFjXcv/oU\ngTRT9d1qz3x66ACsaCJW0xW8NQRDRsFRx0A9zMl2ASH+EeozXB1YS19EkY+JqQgw\nQvO8+QWWBKxC5joJqyOI1Swu2QKBgQDrqjSjZkHCxSS1uoNa9Rurgc506wYDNXEh\nU0hnJ2Y5qbcV3sudm6aB4HnG4wzlmBnZaHBqYtL9JpbpcLp9rDhrTrk9RbVyTqQe\nA4R8HLVgnlUdXZbRTsuZL9lKWSDfTAldYrNVwAJpW9NECUciCFrY/RgwvxqzpcFR\nJusWPHI3/wKBgBwGVTrdeuUjIaR8PAbj24cG6lmNNcLK9N0e17ehGw9fUsTVtnL3\nfzqMeqV1y1oikMb5PDthGaXeeg8b9rhnwYNYrtCv2ssYhyr/SBRJJ4b/AhKUasRW\nKmJVVON2UWEyqjFS10fukUV0rqrKZ9D/ZBccpJNOyCtzY1NA/G88pRpRAoGAJWlr\n2dTN88GgRklJuRLYRa3hvKXi2neNciDqiZWzoMvUiOBJqsee81dTfcNltL0kvWLb\nFciCfjW2F3ER6gmBG2fv+E4ss+zQGQOt8JYOJTx9fp/w5WGA6Nyzvh3QHiLQCcc1\neRN2TouJSm++nyR8Z54mTPbJomNaASwQfgMyjrkCgYBLHbkfhEhB/j/f41B2L4Pf\nwpZTkJEqwfQ3hfWs2fn7yHVfo6aYJ612U26Z+jfcxBVngT2XxkQitxzqtAzx3HVA\nUHHV/kzU5K0Yb21QdJSCgmC23J9vR0XklTKui4bwa9LwOBby58HmVYmoVEIAXMh0\ncs/gQvkdGnFHSN0+eGud2w==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-fbsvc@tripz-7883a.iam.gserviceaccount.com",
|
||||
"client_id": "117765975970130733259",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40tripz-7883a.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+9491
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// اختبار شامل متكامل لكل وظائف الباك إند (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);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// اختبار تحمّل: يشغّل دورة رحلة كاملة (طلب→قبول→حالات→دفع) بالتوازي ويقيس القدرة.
|
||||
// يستخدم 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); });
|
||||
@@ -0,0 +1,104 @@
|
||||
// اختبار مسار المواقع (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);
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
// اختبار تسعير عبر 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);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// اختبار طبقة التزويد (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); });
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,122 @@
|
||||
// اختبار تسابق المحفظة (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);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
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('*');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,58 @@
|
||||
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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CacheService],
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
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
@@ -0,0 +1,103 @@
|
||||
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}`,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DeviceService } from './device.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DeviceService],
|
||||
exports: [DeviceService],
|
||||
})
|
||||
export class DeviceModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,55 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* كتالوج بوابات الدفع المتاحة (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));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [I18nService],
|
||||
exports: [I18nService],
|
||||
})
|
||||
export class I18nModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* نصوص الإشعارات لكل لغة. المفتاح يُشتق من نوع الحدث (راجع 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 };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PhoneService } from './phone.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PhoneService],
|
||||
exports: [PhoneService],
|
||||
})
|
||||
export class PhoneModule {}
|
||||
@@ -0,0 +1,84 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PlatformGuard } from './platform.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PlatformGuard],
|
||||
exports: [PlatformGuard],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,11 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,94 @@
|
||||
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"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,119 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* قراءة أبعاد الصورة ونوعها من ترويسة الملف — بلا أي اعتمادية (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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
export interface TenantStore {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* سياق المستأجر لكل طلب — يُملأ من TenantMiddleware ويُقرأ في المستودعات
|
||||
* لفرض النطاق على tenant_id تلقائياً (راجع docs/06).
|
||||
*/
|
||||
export const tenantContext = new AsyncLocalStorage<TenantStore>();
|
||||
|
||||
export function currentTenantId(): string | undefined {
|
||||
return tenantContext.getStore()?.tenantId;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { currentTenantId } from './tenant.context';
|
||||
|
||||
/**
|
||||
* @Tenant() — يحقن معرّف المستأجر الحالي في معاملات المتحكّم.
|
||||
*/
|
||||
export const Tenant = createParamDecorator(
|
||||
(_data: unknown, _ctx: ExecutionContext): string | undefined =>
|
||||
currentTenantId(),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { tenantContext } from './tenant.context';
|
||||
|
||||
/**
|
||||
* يستخرج معرّف المستأجر من الترويسة (أو الـ subdomain لاحقاً) ويضعه في السياق
|
||||
* لبقية دورة حياة الطلب. لا استعلام يمر بلا tenantId (راجع docs/06).
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
use(req: Request, _res: Response, next: NextFunction) {
|
||||
const headerTenant =
|
||||
(req.headers['x-tenant-id'] as string) ||
|
||||
(req.headers['x-tenant'] as string) ||
|
||||
'';
|
||||
|
||||
// السوبر-آدمن قد لا يحمل مستأجراً محدداً — يُعالَج بحارس منفصل لاحقاً.
|
||||
tenantContext.run({ tenantId: headerTenant }, () => next());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { currentTenantId } from '../tenant/tenant.context';
|
||||
|
||||
/**
|
||||
* يقيس الاستخدام لكل مستأجر (أساس الفوترة الشهرية — راجع docs/05).
|
||||
* حالياً يسجّل فقط؛ لاحقاً يكتب في جدول usage / يدفع لطابور BullMQ.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsageInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('Usage');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const tenantId = currentTenantId() ?? 'none';
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
// TODO(P1): عدّ الرحلات المكتملة و GMV بدل تسجيل كل طلب.
|
||||
this.logger.debug(`tenant=${tenantId} ${req.method} ${req.url}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* إعداد مركزي يقرأ متغيرات البيئة مع افتراضات العزل (راجع 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 ?? '',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
loadEnv();
|
||||
|
||||
/**
|
||||
* مصدر بيانات TypeORM — يُستخدم للهجرات و runtime.
|
||||
* entityPrefix يضمن أن كل جداول Tripz تبدأ بـ tripz_ لعزلها على السيرفر المشترك.
|
||||
*/
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST ?? 'postgres',
|
||||
port: parseInt(process.env.DB_PORT ?? '5432', 10),
|
||||
database: process.env.DB_NAME ?? 'tripz',
|
||||
username: process.env.DB_USER ?? 'tripz',
|
||||
password: process.env.DB_PASSWORD ?? 'change_me_strong',
|
||||
entityPrefix: process.env.DB_TABLE_PREFIX ?? 'tripz_',
|
||||
synchronize: false,
|
||||
entities: [__dirname + '/../database/entities/*.entity.{ts,js}'],
|
||||
migrations: [__dirname + '/../database/migrations/*.{ts,js}'],
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* أول هجرة: جدول المستأجرين + تفعيل PostGIS.
|
||||
* اسم الجدول الفعلي tripz_tenants (البادئة تُضاف عبر entityPrefix، لكن الهجرة
|
||||
* تكتب الاسم صراحةً لأنها SQL خام).
|
||||
*/
|
||||
export class InitTenants1721145600000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`CREATE EXTENSION IF NOT EXISTS postgis`);
|
||||
await q.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_tenants (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name varchar NOT NULL,
|
||||
slug varchar NOT NULL UNIQUE,
|
||||
country_pack varchar NOT NULL DEFAULT 'jo',
|
||||
plan varchar NOT NULL DEFAULT 'launch',
|
||||
mode varchar NOT NULL DEFAULT 'shared',
|
||||
branding jsonb NOT NULL DEFAULT '{}',
|
||||
features jsonb NOT NULL DEFAULT '{}',
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_tenants`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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> {
|
||||
// لا رجوع — لا سبيل لمعرفة الصيغة الأصلية بعد التطبيع (وقد تكون كانت
|
||||
// بصيغ مختلفة أصلاً). التطبيع أُحادي الاتجاه بطبيعته.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user