import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 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'; /** ما يدخله السوبر-أدمن لتزويد مستأجر جديد (docs/22 — N1). */ export interface ProvisionDto { name: string; slug: string; countryPack?: string; plan?: TenantPlan; features?: Record; paymentMethods?: string[]; branding?: Record; // app_name · bundle ids · colors } @Injectable() export class TenantsService { constructor( @InjectRepository(Tenant) private readonly repo: Repository, @InjectRepository(Trip) private readonly trips: Repository, private readonly cache: CacheService, private readonly entitlements: EntitlementsService, ) {} findAll(): Promise { return this.repo.find(); } findBySlug(slug: string): Promise { return this.repo.findOne({ where: { slug } }); } private static readonly UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * يحوّل معرّف المستأجر القادم من الهيدر (slug مثل "siro" أو UUID) إلى سجل المستأجر. * التطبيقات ترسل الـ slug؛ نحوّله للـ UUID المستخدَم في tenant_id (راجع docs/06). */ async resolve(idOrSlug: string): Promise { if (!idOrSlug) return null; // Redis خط أول (docs/17 — G4): المستأجر يتغيّر نادراً جداً. return this.cache.wrap(CacheKeys.tenant(idOrSlug), TTL.tenant, async () => { const bySlug = await this.findBySlug(idOrSlug); if (bySlug) return bySlug; if (TenantsService.UUID_RE.test(idOrSlug)) { return this.repo.findOne({ where: { id: idOrSlug } }); } return null; }); } async create(data: Partial): Promise { const saved = await this.repo.save(this.repo.create(data)); await this.invalidate(saved); return saved; } /** يُبطل مفتاحَي المستأجر (بالـslug وبالـUUID) — يُنادى بعد أي تعديل عليه. */ async invalidate(tenant: Pick): Promise { await this.cache.del(CacheKeys.tenant(tenant.slug), CacheKeys.tenant(tenant.id)); await this.entitlements.invalidate(tenant.id); } /** * سياسات تشغيل المستأجر — يضبطها **المستأجر نفسه** لا السوبر-أدمن. * الدمج عميق مفتاحاً بمفتاح: ضبط سياسة الدين يجب ألّا يمحو باقي السياسات. */ async updateSettings( tenantId: string, patch: Record>, ): Promise { const t = await this.repo.findOne({ where: { id: tenantId } }); if (!t) throw new NotFoundException('Tenant not found'); const merged: Record = { ...(t.settings ?? {}) }; for (const [section, value] of Object.entries(patch)) { merged[section] = { ...(merged[section] ?? {}), ...value }; } t.settings = merged; const saved = await this.repo.save(t); await this.invalidate(saved); // وإلا قرأت الخدمة السياسة القديمة حتى ساعة return saved; } /** * تزويد مستأجر جديد بضربة واحدة (docs/22 — N1): اسم · slug · دولة · باقة · * ميزات · وسائل دفع · هوية بصرية. البديل عن `create` العاري. */ async provision(dto: ProvisionDto): Promise { if (!dto.name || !dto.slug) throw new BadRequestException('name and slug are required'); if (await this.findBySlug(dto.slug)) throw new BadRequestException('slug already taken'); const countryPack = dto.countryPack ?? 'jo'; // وسائل الدفع: ما طلبه السوبر-أدمن (مصفّى للكتالوج) أو افتراضات الدولة. const payments = dto.paymentMethods ? validPaymentMethods(dto.paymentMethods) : defaultPaymentMethods(countryPack); const tenant = this.repo.create({ name: dto.name, slug: dto.slug, countryPack, plan: dto.plan ?? 'launch', features: dto.features ?? {}, settings: { payment_methods: payments }, branding: dto.branding ?? {}, status: 'active', }); const saved = await this.repo.save(tenant); await this.invalidate(saved); return saved; } /** الهوية البصرية (docs/22 — N2): app_name · bundle ids · ألوان · مفتاح اللوغو. */ async setBranding(tenantId: string, branding: Record): Promise { const t = await this.repo.findOne({ where: { id: tenantId } }); if (!t) throw new NotFoundException('Tenant not found'); t.branding = { ...(t.branding ?? {}), ...branding }; const saved = await this.repo.save(t); await this.invalidate(saved); return saved; } /** وسائل الدفع المفعّلة (docs/22 — N1). تُصفّى للكتالوج. */ async setPaymentMethods(tenantId: string, methods: string[]): Promise { const t = await this.repo.findOne({ where: { id: tenantId } }); if (!t) throw new NotFoundException('Tenant not found'); t.settings = { ...(t.settings ?? {}), payment_methods: validPaymentMethods(methods) }; const saved = await this.repo.save(t); await this.invalidate(saved); return saved; } /** * مانيفست توليد التطبيق (docs/22 — N3): كل ما يحتاجه سكربت البناء لإنتاج * تطبيق المستأجر — الاسم · bundle IDs · اللوغو · الألوان · الميزات · الدفع. */ async appManifest(tenantId: string) { const t = await this.repo.findOne({ where: { id: tenantId } }); if (!t) throw new NotFoundException('Tenant not found'); const ent = await this.entitlements.forTenant(t.id); const b = t.branding ?? {}; return { slug: t.slug, name: t.name, app_name: b.app_name ?? t.name, bundle_id_android: b.bundle_id_android ?? null, bundle_id_ios: b.bundle_id_ios ?? null, logo_url: b.logo_key ? `/storage/${b.logo_key}` : null, colors: b.colors ?? {}, country_pack: t.countryPack, payment_methods: t.settings?.payment_methods ?? [], features: ent?.features ?? {}, limits: ent?.limits ?? {}, }; } /** ملخّص كامل للوحة السوبر-أدمن (docs/22 — N1). */ async summary(tenantId: string) { const t = await this.repo.findOne({ where: { id: tenantId } }); if (!t) throw new NotFoundException('Tenant not found'); const ent = await this.entitlements.forTenant(t.id); return { id: t.id, slug: t.slug, name: t.name, countryPack: t.countryPack, plan: t.plan, status: t.status, branding: t.branding ?? {}, payment_methods: t.settings?.payment_methods ?? [], features: ent?.features ?? {}, limits: ent?.limits ?? {}, createdAt: t.createdAt, }; } /** * النظرة الشاملة للمنصة (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', ) // `requested_at` لا `created_at`: كيان الرحلة يسمّي طابع الإنشاء بوقت // **طلب** الرحلة (docs/17 — B7). الاسم الخاطئ كان يُسقط النقطة بـ500 // فيُفرَّغ جدول المستأجرين في اللوحة كلها. .where('t.requested_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); } /** * تغيير باقة المستأجر و/أو ميزاته المشتراة منفردة (docs/19 — K3). * `features` تُدمج لا تُستبدل — وإلا محا تعديلٌ جزئي كل المشتريات السابقة. */ async updateSubscription( tenantId: string, body: { plan?: TenantPlan; features?: Record }, ): Promise { const t = await this.repo.findOne({ where: { id: tenantId } }); if (!t) throw new NotFoundException('Tenant not found'); if (body.plan) t.plan = body.plan; if (body.features) t.features = { ...(t.features ?? {}), ...body.features }; const saved = await this.repo.save(t); // بلا هذا يسري الاشتراك الجديد بعد ساعة (عمر الكاش) — أي مستأجر دفع ولم // تُفتح له ميزته، أو أُلغي اشتراكه وبقيت مفتوحة. await this.invalidate(saved); return saved; } /** * الإعداد الديناميكي الذي يجلبه تطبيق فلاتر عند الإقلاع (GET /tenant/config). * كل ما يمكن جعله ديناميكياً (نصوص، ميزات، ألوان، دفع) يأتي من هنا — راجع docs/06. */ async config(slug: string) { // عبر resolve ليستفيد من الكاش — كل تطبيق يناديها عند الإقلاع. const t = await this.resolve(slug); if (!t) return null; // الاستحقاقات المحسوبة (افتراضات الباقة + المشتريات) لا `features` الخام: // الخام يحمل التجاوزات فقط، فلو أُرجع لظهرت ميزات الباقة كأنها مطفأة. const ent = await this.entitlements.forTenant(t.id); return { slug: t.slug, name: t.name, countryPack: t.countryPack, plan: t.plan, branding: t.branding, // ⚠️ للعرض فقط — الحدّ الأمني هو FeatureGuard على السيرفر (docs/19 K5). features: ent?.features ?? {}, limits: ent?.limits ?? {}, }; } }