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
@@ -5,6 +5,7 @@ import {
Param,
Patch,
Post,
Query,
Res,
UploadedFile,
UseGuards,
@@ -73,6 +74,19 @@ export class TenantsController {
return this.tenants.findAll();
}
/**
* النظرة الشاملة: كل المستأجرين + رحلاتهم وGMV وإيراد المنصة (docs/22 — N1).
* `?days=30` نافذة القياس.
*/
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Get('admin/overview')
overview(@Query('days') days?: string) {
const n = Number(days);
// نافذة غير صالحة (نص أو سالب) تعني تاريخاً معطوباً واستعلاماً بلا معنى.
return this.tenants.platformOverview(Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30);
}
/** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@@ -146,6 +160,17 @@ export class TenantsController {
return this.tenants.entitlementsOf(id);
}
/**
* تعليق/تفعيل مستأجر — يسري فوراً على كل توكناته (docs/22 — N1).
* `{ "status": "suspended" }` أو `{ "status": "active" }`.
*/
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Patch('admin/tenants/:id/status')
setStatus(@Param('id') id: string, @Body('status') status: string) {
return this.tenants.setStatus(id, status);
}
/**
* تغيير الاشتراك و/أو الميزات المشتراة منفردة.
* يُبطل الكاش فوراً فيسري خلال ثوانٍ **بلا تحديث تطبيق**.
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../../database/entities/tenant.entity';
import { Trip } from '../trips/entities/trip.entity';
import { TenantsService } from './tenants.service';
import { TenantsController } from './tenants.controller';
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
// Trip هنا للقراءة فقط — تقرير GMV للسوبر-أدمن (docs/22 — N1).
imports: [TypeOrmModule.forFeature([Tenant, Trip])],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
@@ -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);