القاعدة (docs/19): علم الميزة في التطبيق قرار عرض لا حدّ أمني. من يفكّك التطبيق ويفعّل الأعلام يرى الشاشة ثم يصطدم بـ403 من FeatureGuard. - K1: FeatureGuard + @RequiresFeature — tenant_id من التوكن الموقَّع لا من ترويسة. الوحدة عالمية عمداً: إجبار كل وحدة على استيرادها = نقطة منسيّة يوماً ما، والمنسيّة ميزة مجانية للجميع - K2: كتالوج الميزات + افتراضات الباقات. الافتراض هو المنع (قائمة بيضاء): ميزة جديدة تبقى محجوبة حتى تُمنح صراحةً - K3: GET /admin/features · GET /admin/tenants/:id/entitlements · PATCH /admin/tenants/:id/subscription — يدمج features لا يستبدلها (استبدالها كان سيمحو المشتريات السابقة) ويُبطل الكاش فوراً - K4: drivers_max عند apply — عند الإنشاء فقط، فسائق قائم لا يُطرد بتغيير باقة - K5: /tenant/config يرجع الاستحقاقات المحسوبة لا features الخام (الخام تجاوزات فقط فكان سيُظهر ميزات الباقة مطفأة) - K6: اختبار أن الترويسة لا تزوّر المستأجر + أن الافتراض منع لا سماح - K7: السيادة = كل الميزات — المستأجر يملك السيرفر فالحجب هناك وهم لا حماية محروس: dispatch · chat · payments/charge. المدفوعات لكل نقطة لا للصنف — الـwebhook بلا JWT فحارس الصنف كان سيمنعه. seed: المستأجر التجريبي siro صار sovereign (قرار المالك: أول مشترك بالباقة الكاملة) — وأيضاً يمنع حجب dispatch وحدّ 500 سائق من إسقاط سكربتات التحقق. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
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<Tenant>,
|
|
private readonly cache: CacheService,
|
|
private readonly entitlements: EntitlementsService,
|
|
) {}
|
|
|
|
findAll(): Promise<Tenant[]> {
|
|
return this.repo.find();
|
|
}
|
|
|
|
findBySlug(slug: string): Promise<Tenant | null> {
|
|
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<Tenant | null> {
|
|
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<Tenant>): Promise<Tenant> {
|
|
const saved = await this.repo.save(this.repo.create(data));
|
|
await this.invalidate(saved);
|
|
return saved;
|
|
}
|
|
|
|
/** يُبطل مفتاحَي المستأجر (بالـslug وبالـUUID) — يُنادى بعد أي تعديل عليه. */
|
|
async invalidate(tenant: Pick<Tenant, 'id' | 'slug'>): Promise<void> {
|
|
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<string, any> },
|
|
): Promise<Tenant> {
|
|
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 ?? {},
|
|
};
|
|
}
|
|
}
|