Files
tripz-llc/backend/src/modules/tenants/tenants.service.ts
T
Hamza-AyedandClaude Fable 5 4ca932dd1f fix: عمود overview + قرارات المالك (الخرائط · فلسفة المال · تنظيف المستودع)
إصلاح انحدار أدخلتُه: /admin/overview كان يستعلم عن `t.created_at` وهو غير
موجود في كيان الرحلة (الاسم `requested_at`)، فترجع النقطة 500 ويُفرَّغ جدول
المستأجرين في لوحة السوبر-أدمن. فحص الأنواع لم يمسكه لأن استعلامات
QueryBuilder نصوص، والمحكّ الذي كان سيمسكه لم يُشغَّل.

قرارات المالك 2026-07-18:
- الخرائط: `intaleq_maps` حصراً (SDK انطلق على MapLibre). تصحيح قرار سابق
  خاطئ: flutter_map + latlong2 طبقة منافسة تعطي نوعَي LatLng متضاربين.
  السبب ليس عمل جوجل في سوريا — بل قِدَم بياناتها في المنطقة.
- docs/24 جديد: فلسفة المال — فصل الإيراد (شحن السائق · رسوم العمليات) عن
  الأمانة (شحن الراكب)، بمحفظتين لا بحقل حالة، ودفتر مضاف فقط.
- P موسّعة (بوابات · تسوية بالرسائل لكليك/شام كاش · رسوم بالدولة · تقارير)
  و O5 (ترتيب المهام المجدولة)، وتأجيل Android Auto/CarPlay.
- الحزم: نسخ سيرو الأحدث + live_activities لشاشة القفل في iOS.

تنظيف: إخراج 463 ملف بناء NDK من التتبّع، وإخراج شهادات التوقيع ومفتاح
App Store من المستودع (تُولَّد من جديد عند الحاجة) مع تحديث .gitignore.
الملفات باقية على القرص — أُزيلت من الفهرس فقط.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:37:26 +03:00

333 lines
14 KiB
TypeScript

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<string, any>;
paymentMethods?: string[];
branding?: Record<string, any>; // app_name · bundle ids · colors
}
@Injectable()
export class TenantsService {
constructor(
@InjectRepository(Tenant)
private readonly repo: Repository<Tenant>,
@InjectRepository(Trip)
private readonly trips: Repository<Trip>,
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);
}
/**
* سياسات تشغيل المستأجر — يضبطها **المستأجر نفسه** لا السوبر-أدمن.
* الدمج عميق مفتاحاً بمفتاح: ضبط سياسة الدين يجب ألّا يمحو باقي السياسات.
*/
async updateSettings(
tenantId: string,
patch: Record<string, Record<string, any>>,
): Promise<Tenant> {
const t = await this.repo.findOne({ where: { id: tenantId } });
if (!t) throw new NotFoundException('Tenant not found');
const merged: Record<string, any> = { ...(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<Tenant> {
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<string, any>): Promise<Tenant> {
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<Tenant> {
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<Tenant> {
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<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 ?? {},
};
}
}