feat: المجموعة K — استحقاقات الاشتراك مفروضة على السيرفر
القاعدة (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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d9b0faab2d
commit
aae01fc4ee
@@ -7,6 +7,7 @@ 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 { SeedModule } from './common/seed/seed.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TenantsModule } from './modules/tenants/tenants.module';
|
||||
@@ -60,6 +61,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
|
||||
RedisModule, // عالمي — عميل Redis للمطابقة و OTP
|
||||
I18nModule, // عالمي — ترجمة نصوص الإشعارات
|
||||
CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة)
|
||||
EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard
|
||||
NabehModule, // عالمي — إرسال OTP واتساب
|
||||
NotificationsModule, // عالمي — FCM
|
||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||
|
||||
@@ -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,91 @@
|
||||
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 للمستأجر
|
||||
] 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'],
|
||||
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 };
|
||||
}
|
||||
@@ -33,7 +33,10 @@ export class SeedService implements OnModuleInit {
|
||||
name: 'Siro (Demo — Tenant Zero)',
|
||||
slug: 'siro',
|
||||
countryPack: 'jo',
|
||||
plan: 'brand',
|
||||
// سيرو = المشترك الأول بالباقة الكاملة (قرار المالك)، وسيادةً تعني كل
|
||||
// الميزات بلا حدود عددية (docs/19 §6). عملياً أيضاً: أي باقة أدنى كانت
|
||||
// ستحجب dispatch وتُسقط سكربتات التحقق بـ403 وحدّ السائقين.
|
||||
plan: 'sovereign',
|
||||
});
|
||||
this.logger.log(`seeded demo tenant "siro" (${tenant.id})`);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { ChatService } from './chat.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
|
||||
@ApiTags('chat')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, FeatureGuard)
|
||||
@RequiresFeature('chat')
|
||||
@Controller('trips')
|
||||
export class ChatController {
|
||||
constructor(private readonly chat: ChatService) {}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
|
||||
@ApiTags('dispatch')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard)
|
||||
@Roles('dispatcher', 'admin')
|
||||
@RequiresFeature('dispatch')
|
||||
@Controller('dispatch')
|
||||
export class DispatchController {
|
||||
constructor(private readonly dispatch: DispatchService) {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Driver } from './entities/driver.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { DriverLocationService } from '../locations/driver-location.service';
|
||||
import { DriverCreditService } from '../credit/driver-credit.service';
|
||||
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
||||
|
||||
@Injectable()
|
||||
export class DriversService {
|
||||
@@ -16,6 +17,7 @@ export class DriversService {
|
||||
private readonly users: UsersService,
|
||||
private readonly locations: DriverLocationService,
|
||||
private readonly credit: DriverCreditService,
|
||||
private readonly entitlements: EntitlementsService,
|
||||
) {}
|
||||
|
||||
findByUser(tenantId: string, userId: string): Promise<Driver | null> {
|
||||
@@ -33,6 +35,15 @@ export class DriversService {
|
||||
data: Partial<Driver>,
|
||||
): Promise<Driver> {
|
||||
let driver = await this.findByUser(tenantId, userId);
|
||||
if (!driver) {
|
||||
// حدّ الباقة يُفرض على السيرفر (docs/19 — K4): السائق رقم 501 يُرفض
|
||||
// مهما قال التطبيق. يُفحص عند الإنشاء فقط — سائق قائم لا يُطرد بتغيير باقة.
|
||||
const max = await this.entitlements.limit(tenantId, 'drivers_max');
|
||||
if (max != null) {
|
||||
const count = await this.repo.count({ where: { tenant_id: tenantId } });
|
||||
if (count >= max) throw new ForbiddenException('drivers_limit_reached');
|
||||
}
|
||||
}
|
||||
if (!driver) {
|
||||
driver = this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
|
||||
@@ -3,14 +3,20 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
|
||||
/**
|
||||
* الحراسة هنا **لكل نقطة** لا على مستوى الصنف: الـwebhook يناديه مزوّد الدفع
|
||||
* بلا JWT، فحارس الميزة على الصنف كان سيمنعه (بلا مستأجر = منع).
|
||||
*/
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
export class PaymentsController {
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, FeatureGuard)
|
||||
@RequiresFeature('payments')
|
||||
@Post('charge')
|
||||
charge(@CurrentUser() user: AuthUser, @Body() body: any) {
|
||||
return this.payments.charge(user.tenantId, {
|
||||
|
||||
@@ -1,27 +1,60 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import { FEATURES } from '../../common/entitlements/features';
|
||||
|
||||
@ApiTags('tenants')
|
||||
@Controller()
|
||||
export class TenantsController {
|
||||
constructor(private readonly tenants: TenantsService) {}
|
||||
|
||||
// للتطبيق: إعداد المستأجر الحالي عند الإقلاع.
|
||||
/**
|
||||
* إعداد المستأجر عند إقلاع التطبيق.
|
||||
*
|
||||
* ⚠️ **`features` هنا للعرض فقط — وليست حدّاً أمنياً** (docs/19 — K5).
|
||||
* التطبيق يستعملها ليقرّر **ماذا يُظهر**. من يفكّك التطبيق ويفعّل كل الأعلام
|
||||
* يرى الشاشات ثم يصطدم بـ403 من `FeatureGuard` على السيرفر. لا تُبنَ أي
|
||||
* حماية على هذه الاستجابة.
|
||||
*/
|
||||
@Get('tenant/config/:slug')
|
||||
config(@Param('slug') slug: string) {
|
||||
return this.tenants.config(slug);
|
||||
}
|
||||
|
||||
// للسوبر-آدمن: إدارة كل المستأجرين (يُحمى بحارس دور لاحقاً).
|
||||
// ---- سوبر-أدمن (docs/19 — K3). يُحمى بحارس دور المنصة لاحقاً ----
|
||||
|
||||
@Get('admin/tenants')
|
||||
list() {
|
||||
return this.tenants.findAll();
|
||||
}
|
||||
|
||||
/** كتالوج الميزات القابلة للبيع — تعرضه لوحة السوبر-أدمن. */
|
||||
@Get('admin/features')
|
||||
features() {
|
||||
return { features: FEATURES };
|
||||
}
|
||||
|
||||
@Post('admin/tenants')
|
||||
create(@Body() body: Partial<Tenant>) {
|
||||
return this.tenants.create(body);
|
||||
}
|
||||
|
||||
/** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */
|
||||
@Get('admin/tenants/:id/entitlements')
|
||||
entitlements(@Param('id') id: string) {
|
||||
return this.tenants.entitlementsOf(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* تغيير الاشتراك و/أو الميزات المشتراة منفردة.
|
||||
* يُبطل الكاش فوراً فيسري خلال ثوانٍ **بلا تحديث تطبيق**.
|
||||
*/
|
||||
@Patch('admin/tenants/:id/subscription')
|
||||
updateSubscription(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { plan?: Tenant['plan']; features?: Record<string, any> },
|
||||
) {
|
||||
return this.tenants.updateSubscription(id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
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 {
|
||||
@@ -10,6 +11,7 @@ export class TenantsService {
|
||||
@InjectRepository(Tenant)
|
||||
private readonly repo: Repository<Tenant>,
|
||||
private readonly cache: CacheService,
|
||||
private readonly entitlements: EntitlementsService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<Tenant[]> {
|
||||
@@ -49,6 +51,33 @@ export class TenantsService {
|
||||
/** يُبطل مفتاحَي المستأجر (بالـ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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,13 +88,19 @@ export class TenantsService {
|
||||
// عبر 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,
|
||||
features: t.features,
|
||||
// ⚠️ للعرض فقط — الحدّ الأمني هو FeatureGuard على السيرفر (docs/19 K5).
|
||||
features: ent?.features ?? {},
|
||||
limits: ent?.limits ?? {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user