feat: N1 — خدمة اللوحات + فرض تعليق المستأجر + النظرة الشاملة

- اللوحات الثلاث تُخدم من نفس أصل الـAPI (/panel/{superadmin,admin,service})
  عبر useStaticAssets + bind mount، فترث TLS القائم بلا دومين ولا CORS.
- تعليق المستأجر كان زخرفة: tenant.status يُكتب ويُعرض بلا أي فرض. الآن
  يُفرض في JwtStrategy (نقطة واحدة) + AuthService.resolveTenant، مع إبطال
  الكاش ليسري فوراً، وتمرير عند تعذّر القراءة حتى لا تسقط المنصة كلها.
- GET /admin/overview: رحلات · GMV · إيراد المنصة لكل مستأجر باستعلام
  واحد مجمَّع (لا N+1).
- توثيق قرار طبقات التطبيق (const flags + tree-shaking · أصيل موحّد
  لـShorebird · طبقتا الإعداد) في docs/22 §1.5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-18 12:35:20 +03:00
co-authored by Claude Fable 5
parent 5cad6adbde
commit 3ab74a13a8
12 changed files with 343 additions and 15 deletions
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
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';
@@ -22,6 +23,8 @@ 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,
) {}
@@ -181,6 +184,99 @@ export class TenantsService {
};
}
/**
* النظرة الشاملة للمنصة (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',
)
.where('t.created_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);