إصلاح انحدار أدخلتُه: /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>
333 lines
14 KiB
TypeScript
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 ?? {},
|
|
};
|
|
}
|
|
}
|