import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Tenant, TenantPlan } from '../../database/entities/tenant.entity'; import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service'; import { EntitlementsService } from '../../common/entitlements/entitlements.service'; @Injectable() export class TenantsService { constructor( @InjectRepository(Tenant) private readonly repo: 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); } /** الاستحقاقات الفعّالة — نفس ما يقرأه `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 ?? {}, }; } }