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
+3
View File
@@ -59,6 +59,9 @@ services:
- "127.0.0.1:${API_PORT:-4010}:4010"
volumes:
- tripz-storage:/app/storage
# اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML
# يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء).
- ../dashboards:/app/dashboards:ro
networks: [tripz-net]
worker:
+41
View File
@@ -1,6 +1,9 @@
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
//
// ⚠️ يتطلّب أيضاً OTP_DEV_MODE=true: قسم التعليق (10) ينادي send-otp مرّتين،
// وبلا وضع التطوير تصير كل دورة اختبار رسالتَي واتساب مدفوعتين فعلياً.
//
// التشغيل:
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
@@ -77,6 +80,44 @@ async function main() {
setPay.body.settings?.payment_methods?.includes('cliq') &&
!setPay.body.settings?.payment_methods?.includes('made_up_gateway'));
// 9) النظرة الشاملة (docs/22 — N1b)
const ov = await api('GET', '/admin/overview?days=30');
check('النظرة الشاملة تعمل', ov.ok && typeof ov.body.totals?.tenants === 'number');
check('المستأجر الجديد يظهر فيها', (ov.body.tenants || []).some((t) => t.slug === slug));
check('أرقام المال أعداد لا نصوص', typeof (ov.body.totals?.gmv) === 'number' &&
typeof (ov.body.totals?.revenue) === 'number');
const badWindow = await api('GET', '/admin/overview?days=abc');
check('نافذة غير صالحة تسقط للافتراضي لا تنكسر', badWindow.ok && badWindow.body.window_days === 30);
// 10) التعليق يسري فعلياً (docs/22 — N1a) — الحقل كان زخرفة بلا فرض
const badStatus = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'whatever' });
check('حالة مخترعة تُرفض', badStatus.status === 400);
const susp = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'suspended' });
check('تعليق المستأجر نجح', susp.ok && susp.body.status === 'suspended');
// الإثبات الحقيقي: الدخول نفسه يُقطع — لا مجرّد تغيّر الحقل.
const otpWhileSuspended = await fetch(`${BASE}/auth/send-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
body: JSON.stringify({ phone: '01000000000' }),
});
check('المستأجر المعلَّق لا يستطيع حتى طلب OTP (401)', otpWhileSuspended.status === 401,
`status=${otpWhileSuspended.status}`);
const back = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'active' });
check('إعادة التفعيل تعمل', back.ok && back.body.status === 'active');
const otpAfter = await fetch(`${BASE}/auth/send-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
body: JSON.stringify({ phone: '01000000000' }),
});
// نتحقّق من 2xx تحديداً لا من «ليس 401»: لو صادف حدّ الطلبات (429) لمرّ
// الاختبار وهو لا يثبت شيئاً عن إبطال الكاش.
check('بعد إعادة التفعيل يعود الدخول فوراً (الكاش أُبطل)',
otpAfter.status >= 200 && otpAfter.status < 300, `status=${otpAfter.status}`);
console.log('\n' + '═'.repeat(48));
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
console.log('═'.repeat(48) + '\n');
+25
View File
@@ -6,6 +6,8 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { RedisIoAdapter } from './realtime/redis-io.adapter';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
async function bootstrap() {
// rawBody: التوقيع يُحسب على الجسم **كما وصل** (docs/17 — I6). إعادة تسلسل
@@ -25,6 +27,29 @@ async function bootstrap() {
app.set('trust proxy', 1);
app.setGlobalPrefix('api');
// ---- اللوحات الثلاث: تُخدم من **نفس أصل الـAPI** (docs/22 — N1) ----
// لهذا السبب كودها يكتب `API = '/api'`: نفس الأصل يعني بلا CORS وبلا
// شهادة ثانية وبلا دومين ثانٍ — ويرث TLS القائم (docs/20) تلقائياً.
// المجلد مربوط bind من compose، فتعديل HTML يظهر بتحديث الصفحة بلا إعادة بناء.
//
// ⚠️ لوحة السوبر-أدمن ملف HTML عام عمداً: الحماية كلها على السيرفر
// (PlatformGuard + `x-platform-secret`). الصفحة نفسها لا تملك شيئاً تسرّبه —
// تطلب السرّ ثم تنادي نقاطاً محروسة. إخفاء المسار ليس أماناً.
const panels: Record<string, string> = {
'/panel/superadmin': 'superadmin-web',
'/panel/admin': 'admin-web',
'/panel/service': 'service-web',
};
for (const [route, dir] of Object.entries(panels)) {
// داخل الحاوية: /app/dashboards. على الماك (تشغيل من backend/): ../dashboards.
const path = [join(__dirname, '..', 'dashboards', dir), join(process.cwd(), '..', 'dashboards', dir)].find(
existsSync,
);
if (path) app.useStaticAssets(path, { prefix: route });
else Logger.warn(`لوحة ${dir} غير موجودة — لن تُخدم على ${route}`, 'Panels');
}
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
+4
View File
@@ -37,6 +37,10 @@ export class AuthService {
private async resolveTenant(slugOrId: string): Promise<Tenant> {
const tenant = await this.tenantsService.resolve(slugOrId);
if (!tenant) throw new UnauthorizedException('Unknown tenant');
// المستأجر المعلَّق يُمنع من الباب (docs/22 — N1): هذه النقطة تخنق
// `sendOtp` و`verifyOtp` معاً، فلا يُرسَل رمز أصلاً لمستأجر موقوف.
// الطلبات المصادَقة القائمة يقطعها `JwtStrategy` بالتوازي.
if (tenant.status !== 'active') throw new UnauthorizedException('tenant_suspended');
return tenant;
}
@@ -8,9 +8,12 @@ function reqWith(deviceHeader?: string) {
return { headers: deviceHeader !== undefined ? { 'x-device-id': deviceHeader } : {} };
}
function strategyWith(requireDeviceBinding: boolean) {
/** مستأجر وهمي فعّال — الحالة الطبيعية لكل اختبارات ربط الجهاز. */
const tenantsStub = (status = 'active') => ({ resolve: async () => ({ status }) });
function strategyWith(requireDeviceBinding: boolean, tenants: any = tenantsStub()) {
const config = { get: (k: string) => (k === 'auth.requireDeviceBinding' ? requireDeviceBinding : undefined) };
return new JwtStrategy(config as any, new DeviceService());
return new JwtStrategy(config as any, new DeviceService(), tenants);
}
describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () => {
@@ -67,3 +70,25 @@ describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () =
await expect(strategy.validate(reqWith(rawHash), payload)).rejects.toThrow(UnauthorizedException);
});
});
describe('JwtStrategy — تعليق المستأجر (docs/22 N1)', () => {
it('مستأجر فعّال يمرّ', async () => {
const strategy = strategyWith(false, tenantsStub('active'));
const user = await strategy.validate(reqWith(undefined), PAYLOAD);
expect(user.tenantId).toBe('t1');
});
it('مستأجر معلَّق يُرفض — التوكن الصالح لا ينفع بعد التعليق', async () => {
const strategy = strategyWith(false, tenantsStub('suspended'));
await expect(strategy.validate(reqWith(undefined), PAYLOAD)).rejects.toThrow(
UnauthorizedException,
);
});
it('تعذّر قراءة المستأجر لا يقطع الخدمة — نمرّر بدل إسقاط المنصة كلها', async () => {
const failing = { resolve: async () => { throw new Error('redis + db down'); } };
const strategy = strategyWith(false, failing);
const user = await strategy.validate(reqWith(undefined), PAYLOAD);
expect(user.userId).toBe('u1');
});
});
@@ -3,12 +3,14 @@ import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeviceService } from '../../../common/device/device.service';
import { TenantsService } from '../../tenants/tenants.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly config: ConfigService,
private readonly device: DeviceService,
private readonly tenants: TenantsService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -40,6 +42,26 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
}
// تعليق المستأجر يسري فوراً (docs/22 — N1). هنا لا كحارس منفصل، لنفس
// سبب ربط الجهاز أعلاه: نقطة واحدة منسيّة تعني مستأجراً معلَّقاً ما زال
// يشتغل. القراءة من Redis أولاً (`resolve` مكيَّش، عمره ساعة) فالكلفة
// لا تُذكر على كل طلب، والإبطال عند التعليق يجعله فورياً.
//
// فشل Redis **يجب ألّا يفشل الطلب** (docs/17 — G): `resolve` يسقط إلى
// القاعدة تلقائياً. أما إن تعذّرت القراءة كلياً فنمرّر بدل قطع الخدمة عن
// كل المستأجرين — تعليق مستأجر واحد أهون من انقطاع المنصة.
if (payload.tenant_id) {
let tenant: { status?: string } | null = null;
try {
tenant = await this.tenants.resolve(payload.tenant_id);
} catch {
tenant = null;
}
if (tenant && tenant.status !== 'active') {
throw new UnauthorizedException('tenant_suspended');
}
}
return {
userId: payload.sub,
phone: payload.phone,
@@ -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);