From 3ab74a13a8a9680222874d2c8ada95043544ab55 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 18 Jul 2026 12:35:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20N1=20=E2=80=94=20=D8=AE=D8=AF=D9=85?= =?UTF-8?q?=D8=A9=20=D8=A7=D9=84=D9=84=D9=88=D8=AD=D8=A7=D8=AA=20+=20?= =?UTF-8?q?=D9=81=D8=B1=D8=B6=20=D8=AA=D8=B9=D9=84=D9=8A=D9=82=20=D8=A7?= =?UTF-8?q?=D9=84=D9=85=D8=B3=D8=AA=D8=A3=D8=AC=D8=B1=20+=20=D8=A7=D9=84?= =?UTF-8?q?=D9=86=D8=B8=D8=B1=D8=A9=20=D8=A7=D9=84=D8=B4=D8=A7=D9=85=D9=84?= =?UTF-8?q?=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - اللوحات الثلاث تُخدم من نفس أصل الـAPI (/panel/{superadmin,admin,service}) عبر useStaticAssets + bind mount، فترث TLS القائم بلا دومين ولا CORS. - تعليق المستأجر كان زخرفة: tenant.status يُكتب ويُعرض بلا أي فرض. الآن يُفرض في JwtStrategy (نقطة واحدة) + AuthService.resolveTenant، مع إبطال الكاش ليسري فوراً، وتمرير عند تعذّر القراءة حتى لا تسقط المنصة كلها. - GET /admin/overview: رحلات · GMV · إيراد المنصة لكل مستأجر باستعلام واحد مجمَّع (لا N+1). - توثيق قرار طبقات التطبيق (const flags + tree-shaking · أصيل موحّد لـShorebird · طبقتا الإعداد) في docs/22 §1.5. Co-Authored-By: Claude Fable 5 --- backend/docker-compose.yml | 3 + backend/scripts/provision-test.mjs | 41 ++++++++ backend/src/main.ts | 25 +++++ backend/src/modules/auth/auth.service.ts | 4 + .../auth/strategies/jwt.strategy.spec.ts | 29 +++++- .../modules/auth/strategies/jwt.strategy.ts | 22 +++++ .../src/modules/tenants/tenants.controller.ts | 25 +++++ backend/src/modules/tenants/tenants.module.ts | 4 +- .../src/modules/tenants/tenants.service.ts | 96 +++++++++++++++++++ dashboards/superadmin-web/index.html | 58 +++++++++-- docs/15-deploy-flow.md | 14 +++ docs/22-full-product-roadmap.md | 37 ++++++- 12 files changed, 343 insertions(+), 15 deletions(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e08dd1d..8f3c07c 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -59,6 +59,9 @@ services: - "127.0.0.1:${API_PORT:-4010}:4010" volumes: - tripz-storage:/app/storage + # اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML + # يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء). + - ../dashboards:/app/dashboards:ro networks: [tripz-net] worker: diff --git a/backend/scripts/provision-test.mjs b/backend/scripts/provision-test.mjs index ef46c9d..16136a6 100644 --- a/backend/scripts/provision-test.mjs +++ b/backend/scripts/provision-test.mjs @@ -1,6 +1,9 @@ // اختبار طبقة التزويد (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 \ @@ -77,6 +80,44 @@ async function main() { 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'); + 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'); diff --git a/backend/src/main.ts b/backend/src/main.ts index 46e5393..1360b8a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,6 +6,8 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { RedisIoAdapter } from './realtime/redis-io.adapter'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; async function bootstrap() { // rawBody: التوقيع يُحسب على الجسم **كما وصل** (docs/17 — I6). إعادة تسلسل @@ -25,6 +27,29 @@ async function bootstrap() { app.set('trust proxy', 1); app.setGlobalPrefix('api'); + + // ---- اللوحات الثلاث: تُخدم من **نفس أصل الـAPI** (docs/22 — N1) ---- + // لهذا السبب كودها يكتب `API = '/api'`: نفس الأصل يعني بلا CORS وبلا + // شهادة ثانية وبلا دومين ثانٍ — ويرث TLS القائم (docs/20) تلقائياً. + // المجلد مربوط bind من compose، فتعديل HTML يظهر بتحديث الصفحة بلا إعادة بناء. + // + // ⚠️ لوحة السوبر-أدمن ملف HTML عام عمداً: الحماية كلها على السيرفر + // (PlatformGuard + `x-platform-secret`). الصفحة نفسها لا تملك شيئاً تسرّبه — + // تطلب السرّ ثم تنادي نقاطاً محروسة. إخفاء المسار ليس أماناً. + const panels: Record = { + '/panel/superadmin': 'superadmin-web', + '/panel/admin': 'admin-web', + '/panel/service': 'service-web', + }; + for (const [route, dir] of Object.entries(panels)) { + // داخل الحاوية: /app/dashboards. على الماك (تشغيل من backend/): ../dashboards. + const path = [join(__dirname, '..', 'dashboards', dir), join(process.cwd(), '..', 'dashboards', dir)].find( + existsSync, + ); + if (path) app.useStaticAssets(path, { prefix: route }); + else Logger.warn(`لوحة ${dir} غير موجودة — لن تُخدم على ${route}`, 'Panels'); + } + app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true }), ); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index a0c16d1..da6233f 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -37,6 +37,10 @@ export class AuthService { private async resolveTenant(slugOrId: string): Promise { const tenant = await this.tenantsService.resolve(slugOrId); if (!tenant) throw new UnauthorizedException('Unknown tenant'); + // المستأجر المعلَّق يُمنع من الباب (docs/22 — N1): هذه النقطة تخنق + // `sendOtp` و`verifyOtp` معاً، فلا يُرسَل رمز أصلاً لمستأجر موقوف. + // الطلبات المصادَقة القائمة يقطعها `JwtStrategy` بالتوازي. + if (tenant.status !== 'active') throw new UnauthorizedException('tenant_suspended'); return tenant; } diff --git a/backend/src/modules/auth/strategies/jwt.strategy.spec.ts b/backend/src/modules/auth/strategies/jwt.strategy.spec.ts index a360c21..aceac40 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.spec.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.spec.ts @@ -8,9 +8,12 @@ function reqWith(deviceHeader?: string) { return { headers: deviceHeader !== undefined ? { 'x-device-id': deviceHeader } : {} }; } -function strategyWith(requireDeviceBinding: boolean) { +/** مستأجر وهمي فعّال — الحالة الطبيعية لكل اختبارات ربط الجهاز. */ +const tenantsStub = (status = 'active') => ({ resolve: async () => ({ status }) }); + +function strategyWith(requireDeviceBinding: boolean, tenants: any = tenantsStub()) { const config = { get: (k: string) => (k === 'auth.requireDeviceBinding' ? requireDeviceBinding : undefined) }; - return new JwtStrategy(config as any, new DeviceService()); + return new JwtStrategy(config as any, new DeviceService(), tenants); } describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () => { @@ -67,3 +70,25 @@ describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () = await expect(strategy.validate(reqWith(rawHash), payload)).rejects.toThrow(UnauthorizedException); }); }); + +describe('JwtStrategy — تعليق المستأجر (docs/22 N1)', () => { + it('مستأجر فعّال يمرّ', async () => { + const strategy = strategyWith(false, tenantsStub('active')); + const user = await strategy.validate(reqWith(undefined), PAYLOAD); + expect(user.tenantId).toBe('t1'); + }); + + it('مستأجر معلَّق يُرفض — التوكن الصالح لا ينفع بعد التعليق', async () => { + const strategy = strategyWith(false, tenantsStub('suspended')); + await expect(strategy.validate(reqWith(undefined), PAYLOAD)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('تعذّر قراءة المستأجر لا يقطع الخدمة — نمرّر بدل إسقاط المنصة كلها', async () => { + const failing = { resolve: async () => { throw new Error('redis + db down'); } }; + const strategy = strategyWith(false, failing); + const user = await strategy.validate(reqWith(undefined), PAYLOAD); + expect(user.userId).toBe('u1'); + }); +}); diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index d0540dc..e10d73b 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -3,12 +3,14 @@ import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DeviceService } from '../../../common/device/device.service'; +import { TenantsService } from '../../tenants/tenants.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private readonly config: ConfigService, private readonly device: DeviceService, + private readonly tenants: TenantsService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -40,6 +42,26 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } } + // تعليق المستأجر يسري فوراً (docs/22 — N1). هنا لا كحارس منفصل، لنفس + // سبب ربط الجهاز أعلاه: نقطة واحدة منسيّة تعني مستأجراً معلَّقاً ما زال + // يشتغل. القراءة من Redis أولاً (`resolve` مكيَّش، عمره ساعة) فالكلفة + // لا تُذكر على كل طلب، والإبطال عند التعليق يجعله فورياً. + // + // فشل Redis **يجب ألّا يفشل الطلب** (docs/17 — G): `resolve` يسقط إلى + // القاعدة تلقائياً. أما إن تعذّرت القراءة كلياً فنمرّر بدل قطع الخدمة عن + // كل المستأجرين — تعليق مستأجر واحد أهون من انقطاع المنصة. + if (payload.tenant_id) { + let tenant: { status?: string } | null = null; + try { + tenant = await this.tenants.resolve(payload.tenant_id); + } catch { + tenant = null; + } + if (tenant && tenant.status !== 'active') { + throw new UnauthorizedException('tenant_suspended'); + } + } + return { userId: payload.sub, phone: payload.phone, diff --git a/backend/src/modules/tenants/tenants.controller.ts b/backend/src/modules/tenants/tenants.controller.ts index c3c9bae..52c3590 100644 --- a/backend/src/modules/tenants/tenants.controller.ts +++ b/backend/src/modules/tenants/tenants.controller.ts @@ -5,6 +5,7 @@ import { Param, Patch, Post, + Query, Res, UploadedFile, UseGuards, @@ -73,6 +74,19 @@ export class TenantsController { return this.tenants.findAll(); } + /** + * النظرة الشاملة: كل المستأجرين + رحلاتهم وGMV وإيراد المنصة (docs/22 — N1). + * `?days=30` نافذة القياس. + */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Get('admin/overview') + overview(@Query('days') days?: string) { + const n = Number(days); + // نافذة غير صالحة (نص أو سالب) تعني تاريخاً معطوباً واستعلاماً بلا معنى. + return this.tenants.platformOverview(Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30); + } + /** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */ @ApiSecurity('x-platform-secret') @UseGuards(PlatformGuard) @@ -146,6 +160,17 @@ export class TenantsController { return this.tenants.entitlementsOf(id); } + /** + * تعليق/تفعيل مستأجر — يسري فوراً على كل توكناته (docs/22 — N1). + * `{ "status": "suspended" }` أو `{ "status": "active" }`. + */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) + @Patch('admin/tenants/:id/status') + setStatus(@Param('id') id: string, @Body('status') status: string) { + return this.tenants.setStatus(id, status); + } + /** * تغيير الاشتراك و/أو الميزات المشتراة منفردة. * يُبطل الكاش فوراً فيسري خلال ثوانٍ **بلا تحديث تطبيق**. diff --git a/backend/src/modules/tenants/tenants.module.ts b/backend/src/modules/tenants/tenants.module.ts index ed2f6e8..2f47545 100644 --- a/backend/src/modules/tenants/tenants.module.ts +++ b/backend/src/modules/tenants/tenants.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Tenant } from '../../database/entities/tenant.entity'; +import { Trip } from '../trips/entities/trip.entity'; import { TenantsService } from './tenants.service'; import { TenantsController } from './tenants.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Tenant])], + // Trip هنا للقراءة فقط — تقرير GMV للسوبر-أدمن (docs/22 — N1). + imports: [TypeOrmModule.forFeature([Tenant, Trip])], controllers: [TenantsController], providers: [TenantsService], exports: [TenantsService], diff --git a/backend/src/modules/tenants/tenants.service.ts b/backend/src/modules/tenants/tenants.service.ts index 383c9ff..2dc6f7d 100644 --- a/backend/src/modules/tenants/tenants.service.ts +++ b/backend/src/modules/tenants/tenants.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Tenant, TenantPlan } from '../../database/entities/tenant.entity'; +import { Trip } from '../trips/entities/trip.entity'; import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service'; import { EntitlementsService } from '../../common/entitlements/entitlements.service'; import { defaultPaymentMethods, validPaymentMethods } from '../../common/entitlements/payment-methods'; @@ -22,6 +23,8 @@ export class TenantsService { constructor( @InjectRepository(Tenant) private readonly repo: Repository, + @InjectRepository(Trip) + private readonly trips: Repository, private readonly cache: CacheService, private readonly entitlements: EntitlementsService, ) {} @@ -181,6 +184,99 @@ export class TenantsService { }; } + /** + * النظرة الشاملة للمنصة (docs/22 — N1): كل المستأجرين + حجمهم خلال نافذة. + * + * استعلام **واحد مجمَّع** لكل المستأجرين، لا استعلام لكل مستأجر: مع عشرات + * المستأجرين يصير الثاني N+1 يُبطئ اللوحة الأهم عندنا. + * + * `gmv` = مجموع الأجرة النهائية للرحلات المكتملة/المدفوعة (حجم الأعمال). + * `revenue` = مجموع العمولة — **إيراد المنصة الفعلي**، وهو الرقم الذي يهم + * عند التسعير لا الـGMV. + */ + async platformOverview(days = 30) { + const since = new Date(Date.now() - days * 86400_000); + const tenants = await this.repo.find(); + + const rows = await this.trips + .createQueryBuilder('t') + .select('t.tenant_id', 'tenant_id') + .addSelect('COUNT(*)', 'trips') + .addSelect( + "COUNT(*) FILTER (WHERE t.status IN ('completed','paid'))", + 'completed_trips', + ) + .addSelect( + "COALESCE(SUM(t.final_fare) FILTER (WHERE t.status IN ('completed','paid')), 0)", + 'gmv', + ) + .addSelect( + "COALESCE(SUM(t.commission_amount) FILTER (WHERE t.status IN ('completed','paid')), 0)", + 'revenue', + ) + .where('t.created_at >= :since', { since }) + .groupBy('t.tenant_id') + .getRawMany(); + + const byTenant = new Map(rows.map((r: any) => [r.tenant_id, r])); + const perTenant = tenants.map((t) => { + const r: any = byTenant.get(t.id) ?? {}; + return { + id: t.id, + slug: t.slug, + name: t.name, + plan: t.plan, + status: t.status, + countryPack: t.countryPack, + createdAt: t.createdAt, + // أعمدة numeric تعود نصوصاً من postgres — بلا Number يصير الجمع في + // اللوحة لصقَ نصوص ("10"+"5"="105") لا حساباً. + trips: Number(r.trips ?? 0), + completed_trips: Number(r.completed_trips ?? 0), + gmv: Number(r.gmv ?? 0), + revenue: Number(r.revenue ?? 0), + }; + }); + + const sum = (k: 'trips' | 'completed_trips' | 'gmv' | 'revenue') => + perTenant.reduce((a, t) => a + t[k], 0); + + return { + window_days: days, + totals: { + tenants: tenants.length, + active_tenants: tenants.filter((t) => t.status === 'active').length, + trips: sum('trips'), + completed_trips: sum('completed_trips'), + gmv: sum('gmv'), + revenue: sum('revenue'), + }, + tenants: perTenant.sort((a, b) => b.gmv - a.gmv), + }; + } + + /** + * تعليق/إعادة تفعيل مستأجر (docs/22 — N1). + * + * التعليق **يقطع الخدمة فوراً**: `JwtStrategy` يرفض كل توكن لمستأجر غير + * `active`، فلا حاجة لانتظار انتهاء التوكنات. كان الحقل يُكتب ويُعرض بلا + * أي فرض — أي أن «تعطيل المستأجر» كان زخرفة، والمستأجر غير الدافع يظل + * يشتغل كأن شيئاً لم يكن. + */ + async setStatus(tenantId: string, status: string): Promise { + const allowed = ['active', 'suspended']; + if (!allowed.includes(status)) { + throw new BadRequestException(`status must be one of: ${allowed.join(', ')}`); + } + const t = await this.repo.findOne({ where: { id: tenantId } }); + if (!t) throw new NotFoundException('Tenant not found'); + t.status = status; + const saved = await this.repo.save(t); + // حرج: بلا الإبطال يظل المستأجر المعلَّق يعمل حتى ساعة كاملة (عمر الكاش). + await this.invalidate(saved); + return saved; + } + /** الاستحقاقات الفعّالة — نفس ما يقرأه `FeatureGuard` (docs/19 — K3). */ entitlementsOf(tenantId: string) { return this.entitlements.forTenant(tenantId); diff --git a/dashboards/superadmin-web/index.html b/dashboards/superadmin-web/index.html index c191881..56a031f 100644 --- a/dashboards/superadmin-web/index.html +++ b/dashboards/superadmin-web/index.html @@ -48,12 +48,22 @@
+ +
+

النظرة الشاملة + +

+
…
+
+

المستأجرون

- - + +
الاسمslugالدولةالباقةالحالة
…
الاسمslugالدولةالباقةالحالةرحلاتGMVإيراد المنصة
…
@@ -145,24 +155,54 @@ } } + // اسم المستأجر يدخله بشر ويُحقن في innerHTML — بلا تهريب يصير اسمٌ فيه + //