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:
co-authored by
Claude Fable 5
parent
5cad6adbde
commit
3ab74a13a8
@@ -59,6 +59,9 @@ services:
|
|||||||
- "127.0.0.1:${API_PORT:-4010}:4010"
|
- "127.0.0.1:${API_PORT:-4010}:4010"
|
||||||
volumes:
|
volumes:
|
||||||
- tripz-storage:/app/storage
|
- tripz-storage:/app/storage
|
||||||
|
# اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML
|
||||||
|
# يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء).
|
||||||
|
- ../dashboards:/app/dashboards:ro
|
||||||
networks: [tripz-net]
|
networks: [tripz-net]
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
|
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
|
||||||
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
|
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
|
||||||
//
|
//
|
||||||
|
// ⚠️ يتطلّب أيضاً OTP_DEV_MODE=true: قسم التعليق (10) ينادي send-otp مرّتين،
|
||||||
|
// وبلا وضع التطوير تصير كل دورة اختبار رسالتَي واتساب مدفوعتين فعلياً.
|
||||||
|
//
|
||||||
// التشغيل:
|
// التشغيل:
|
||||||
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||||||
// -e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
|
// -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('cliq') &&
|
||||||
!setPay.body.settings?.payment_methods?.includes('made_up_gateway'));
|
!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('\n' + '═'.repeat(48));
|
||||||
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
|
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
|
||||||
console.log('═'.repeat(48) + '\n');
|
console.log('═'.repeat(48) + '\n');
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { RedisIoAdapter } from './realtime/redis-io.adapter';
|
import { RedisIoAdapter } from './realtime/redis-io.adapter';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// rawBody: التوقيع يُحسب على الجسم **كما وصل** (docs/17 — I6). إعادة تسلسل
|
// rawBody: التوقيع يُحسب على الجسم **كما وصل** (docs/17 — I6). إعادة تسلسل
|
||||||
@@ -25,6 +27,29 @@ async function bootstrap() {
|
|||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
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(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({ whitelist: true, transform: true }),
|
new ValidationPipe({ whitelist: true, transform: true }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export class AuthService {
|
|||||||
private async resolveTenant(slugOrId: string): Promise<Tenant> {
|
private async resolveTenant(slugOrId: string): Promise<Tenant> {
|
||||||
const tenant = await this.tenantsService.resolve(slugOrId);
|
const tenant = await this.tenantsService.resolve(slugOrId);
|
||||||
if (!tenant) throw new UnauthorizedException('Unknown tenant');
|
if (!tenant) throw new UnauthorizedException('Unknown tenant');
|
||||||
|
// المستأجر المعلَّق يُمنع من الباب (docs/22 — N1): هذه النقطة تخنق
|
||||||
|
// `sendOtp` و`verifyOtp` معاً، فلا يُرسَل رمز أصلاً لمستأجر موقوف.
|
||||||
|
// الطلبات المصادَقة القائمة يقطعها `JwtStrategy` بالتوازي.
|
||||||
|
if (tenant.status !== 'active') throw new UnauthorizedException('tenant_suspended');
|
||||||
return tenant;
|
return tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ function reqWith(deviceHeader?: string) {
|
|||||||
return { headers: deviceHeader !== undefined ? { 'x-device-id': deviceHeader } : {} };
|
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) };
|
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)', () => {
|
describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () => {
|
||||||
@@ -67,3 +70,25 @@ describe('JwtStrategy — ربط الجلسة بالجهاز (docs/17 D2)', () =
|
|||||||
await expect(strategy.validate(reqWith(rawHash), payload)).rejects.toThrow(UnauthorizedException);
|
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 { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DeviceService } from '../../../common/device/device.service';
|
import { DeviceService } from '../../../common/device/device.service';
|
||||||
|
import { TenantsService } from '../../tenants/tenants.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly device: DeviceService,
|
private readonly device: DeviceService,
|
||||||
|
private readonly tenants: TenantsService,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
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 {
|
return {
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
phone: payload.phone,
|
phone: payload.phone,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Res,
|
Res,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -73,6 +74,19 @@ export class TenantsController {
|
|||||||
return this.tenants.findAll();
|
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')
|
@ApiSecurity('x-platform-secret')
|
||||||
@UseGuards(PlatformGuard)
|
@UseGuards(PlatformGuard)
|
||||||
@@ -146,6 +160,17 @@ export class TenantsController {
|
|||||||
return this.tenants.entitlementsOf(id);
|
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Tenant } from '../../database/entities/tenant.entity';
|
import { Tenant } from '../../database/entities/tenant.entity';
|
||||||
|
import { Trip } from '../trips/entities/trip.entity';
|
||||||
import { TenantsService } from './tenants.service';
|
import { TenantsService } from './tenants.service';
|
||||||
import { TenantsController } from './tenants.controller';
|
import { TenantsController } from './tenants.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Tenant])],
|
// Trip هنا للقراءة فقط — تقرير GMV للسوبر-أدمن (docs/22 — N1).
|
||||||
|
imports: [TypeOrmModule.forFeature([Tenant, Trip])],
|
||||||
controllers: [TenantsController],
|
controllers: [TenantsController],
|
||||||
providers: [TenantsService],
|
providers: [TenantsService],
|
||||||
exports: [TenantsService],
|
exports: [TenantsService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Tenant, TenantPlan } from '../../database/entities/tenant.entity';
|
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 { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||||
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
||||||
import { defaultPaymentMethods, validPaymentMethods } from '../../common/entitlements/payment-methods';
|
import { defaultPaymentMethods, validPaymentMethods } from '../../common/entitlements/payment-methods';
|
||||||
@@ -22,6 +23,8 @@ export class TenantsService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Tenant)
|
@InjectRepository(Tenant)
|
||||||
private readonly repo: Repository<Tenant>,
|
private readonly repo: Repository<Tenant>,
|
||||||
|
@InjectRepository(Trip)
|
||||||
|
private readonly trips: Repository<Trip>,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
private readonly entitlements: EntitlementsService,
|
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). */
|
/** الاستحقاقات الفعّالة — نفس ما يقرأه `FeatureGuard` (docs/19 — K3). */
|
||||||
entitlementsOf(tenantId: string) {
|
entitlementsOf(tenantId: string) {
|
||||||
return this.entitlements.forTenant(tenantId);
|
return this.entitlements.forTenant(tenantId);
|
||||||
|
|||||||
@@ -48,12 +48,22 @@
|
|||||||
<button class="ghost" id="logoutBtn" style="display:none">تسجيل خروج</button>
|
<button class="ghost" id="logoutBtn" style="display:none">تسجيل خروج</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
|
<!-- النظرة الشاملة -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>النظرة الشاملة
|
||||||
|
<select id="ovDays" style="width:auto; display:inline-block; margin-right:10px">
|
||||||
|
<option value="7">٧ أيام</option><option value="30" selected>٣٠ يوماً</option><option value="90">٩٠ يوماً</option>
|
||||||
|
</select>
|
||||||
|
</h2>
|
||||||
|
<div id="ovTotals" class="muted">…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- المستأجرون -->
|
<!-- المستأجرون -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>المستأجرون</h2>
|
<h2>المستأجرون</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th></th></tr></thead>
|
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th>رحلات</th><th>GMV</th><th>إيراد المنصة</th><th></th></tr></thead>
|
||||||
<tbody id="tenantRows"><tr><td colspan="6" class="muted">…</td></tr></tbody>
|
<tbody id="tenantRows"><tr><td colspan="9" class="muted">…</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,24 +155,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// اسم المستأجر يدخله بشر ويُحقن في innerHTML — بلا تهريب يصير اسمٌ فيه
|
||||||
|
// <script> ثغرة XSS في أخطر لوحة عندنا.
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
const money = (n) => Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||||
|
|
||||||
async function loadTenants() {
|
async function loadTenants() {
|
||||||
const rows = document.getElementById('tenantRows');
|
const rows = document.getElementById('tenantRows');
|
||||||
|
const days = document.getElementById('ovDays').value;
|
||||||
try {
|
try {
|
||||||
const tenants = await api('GET', '/admin/tenants');
|
const ov = await api('GET', `/admin/overview?days=${days}`);
|
||||||
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="6" class="muted">لا مستأجرين بعد</td></tr>';
|
const t0 = ov.totals || {};
|
||||||
|
document.getElementById('ovTotals').innerHTML =
|
||||||
|
`مستأجرون: <b>${t0.tenants ?? 0}</b> (فعّال: ${t0.active_tenants ?? 0}) ·
|
||||||
|
رحلات: <b>${t0.trips ?? 0}</b> (مكتملة: ${t0.completed_trips ?? 0}) ·
|
||||||
|
GMV: <b>${money(t0.gmv)}</b> · إيراد المنصة: <b>${money(t0.revenue)}</b>`;
|
||||||
|
|
||||||
|
const tenants = ov.tenants || [];
|
||||||
|
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="9" class="muted">لا مستأجرين بعد</td></tr>';
|
||||||
for (const t of tenants) {
|
for (const t of tenants) {
|
||||||
|
const suspended = t.status !== 'active';
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `<td>${t.name}</td><td class="muted">${t.slug}</td><td>${t.countryPack||t.country_pack||'-'}</td>
|
tr.innerHTML = `<td>${esc(t.name)}</td><td class="muted">${esc(t.slug)}</td><td>${esc(t.countryPack || '-')}</td>
|
||||||
<td>${t.plan}</td><td><span class="badge active">${t.status}</span></td>
|
<td>${esc(t.plan)}</td>
|
||||||
<td><button class="ghost" data-id="${t.id}">مانيفست</button></td>`;
|
<td><span class="badge ${suspended ? '' : 'active'}">${esc(t.status)}</span></td>
|
||||||
tr.querySelector('button').onclick = async () => {
|
<td>${t.completed_trips}/${t.trips}</td><td>${money(t.gmv)}</td><td>${money(t.revenue)}</td>
|
||||||
|
<td><button class="ghost" data-act="manifest">مانيفست</button>
|
||||||
|
<button class="ghost" data-act="status">${suspended ? 'تفعيل' : 'تعليق'}</button></td>`;
|
||||||
|
|
||||||
|
tr.querySelector('[data-act=manifest]').onclick = async () => {
|
||||||
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
|
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
|
||||||
alert(JSON.stringify(m, null, 2));
|
alert(JSON.stringify(m, null, 2));
|
||||||
};
|
};
|
||||||
|
tr.querySelector('[data-act=status]').onclick = async () => {
|
||||||
|
// التعليق يقطع الخدمة عن مستأجر حيّ فوراً — لذلك تأكيد صريح.
|
||||||
|
const next = suspended ? 'active' : 'suspended';
|
||||||
|
if (!suspended && !confirm(`تعليق «${t.name}»؟ سيتوقّف تطبيقه وسائقوه وركّابه فوراً.`)) return;
|
||||||
|
try {
|
||||||
|
await api('PATCH', `/admin/tenants/${t.id}/status`, { status: next });
|
||||||
|
toast(next === 'active' ? 'أُعيد التفعيل ✅' : 'عُلّق المستأجر ⛔');
|
||||||
|
loadTenants();
|
||||||
|
} catch (e) { toast(e.message, false); }
|
||||||
|
};
|
||||||
rows.appendChild(tr);
|
rows.appendChild(tr);
|
||||||
}
|
}
|
||||||
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="6" class="muted">${e.message}</td></tr>`; }
|
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="9" class="muted">${esc(e.message)}</td></tr>`; }
|
||||||
}
|
}
|
||||||
|
document.getElementById('ovDays').onchange = loadTenants;
|
||||||
|
|
||||||
document.getElementById('provisionBtn').onclick = async () => {
|
document.getElementById('provisionBtn').onclick = async () => {
|
||||||
const btn = document.getElementById('provisionBtn'); btn.disabled = true;
|
const btn = document.getElementById('provisionBtn'); btn.disabled = true;
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
- مجلد Tripz المخصّص: **`/home/tripz-llc`** (منفصل عن مواقع CloudPanel لتفادي التصادم).
|
- مجلد Tripz المخصّص: **`/home/tripz-llc`** (منفصل عن مواقع CloudPanel لتفادي التصادم).
|
||||||
- Git الخاص: `https://git.intaleqapp.com/Hamza/tripz-llc.git`.
|
- Git الخاص: `https://git.intaleqapp.com/Hamza/tripz-llc.git`.
|
||||||
|
|
||||||
|
## روابط اللوحات الثلاث 🔗
|
||||||
|
تُخدم من **نفس أصل الـAPI** (لهذا تكتب `API = '/api'`)، فترث TLS القائم بلا دومين ولا شهادة ثانية:
|
||||||
|
|
||||||
|
| اللوحة | الرابط | الدخول |
|
||||||
|
|---|---|---|
|
||||||
|
| السوبر-أدمن | `https://tripz-api.intaleqapp.com/panel/superadmin/` | `PLATFORM_SECRET` من `.env` |
|
||||||
|
| أدمن المستأجر | `https://tripz-api.intaleqapp.com/panel/admin/?tenant=siro` | هاتف + OTP بدور admin |
|
||||||
|
| خدمة العملاء | `https://tripz-api.intaleqapp.com/panel/service/?tenant=siro` | هاتف + OTP بدور admin/dispatcher |
|
||||||
|
|
||||||
|
- **الشرطة المائلة الأخيرة مهمة** (`/panel/admin/` لا `/panel/admin`).
|
||||||
|
- `?tenant=<slug>` يحدّد المستأجر للوحتَي الأدمن والخدمة؛ الافتراضي `siro`.
|
||||||
|
- اللوحات bind من `../dashboards` — تعديل HTML يظهر بتحديث الصفحة **بلا إعادة بناء** (`git pull` يكفي).
|
||||||
|
- صفحة السوبر-أدمن عامة عمداً؛ الحماية كلها على السيرفر (PlatformGuard). إخفاء المسار ليس أماناً.
|
||||||
|
|
||||||
## الطريقة المعتمدة (الأنظف): Git
|
## الطريقة المعتمدة (الأنظف): Git
|
||||||
```
|
```
|
||||||
# على الماك (مرة واحدة) — إعداد الريبو والدفع
|
# على الماك (مرة واحدة) — إعداد الريبو والدفع
|
||||||
|
|||||||
@@ -57,6 +57,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 1.5 طبقات التطبيق (lite / pro / max) — قرار المالك 2026-07-18 🔒
|
||||||
|
**المطلوب**: تُولَّد تطبيقات بـ«نسخ» متفاوتة الميزات، ولا يستطيع من يفكّك نسخة أدنى رؤية ميزات النسخ الأعلى.
|
||||||
|
|
||||||
|
**القرار: نسخة = بناء مُولَّد، لا fork للكود.** مستودع واحد؛ الطبقة تُشتق من باقة المستأجر عبر مانيفست N3.
|
||||||
|
> ثلاث نسخ منسوخة يدوياً = كل إصلاح باغ ×٣ وتباعد خلال شهرين. الطبقة صفة **للمستأجر** لا قائمةَ متجرٍ إضافية (كل مستأجر له bundle ID خاصه أصلاً). و`lite/pro/max` ليست مفهوماً جديداً — هي presets فوق كتالوج §1.3.
|
||||||
|
|
||||||
|
**1) أعلام `const` لكل ميزة — لا Map وقت التشغيل.**
|
||||||
|
بناء AOT يعمل tree-shaking: كود محروس بعلم `const` قيمته `false` **يُحذف من الـbinary فيزيائياً**. هذا ما يحقّق مناعة التفكيك. علم مستقل لكل ميزة (`FF_NEGOTIATOR`…)؛ لو قرأناها من Map وقت التشغيل بقي الكود كاملاً في الملف وضاع الغرض.
|
||||||
|
|
||||||
|
**2) الطبقة الأصيلة موحّدة عبر كل الطبقات (شرط Shorebird).**
|
||||||
|
Shorebird يرقّع Dart فقط. بتوحيد Kotlin/الصلاحيات/NDK تصير ترقية مستأجر من lite إلى pro **patch فورياً بلا مراجعة متجر**؛ ولو اختلف الأصيل صار كل ترقية إصداراً جديداً وانتظار مراجعة — أي نخسر أهم ميزة تشغيلية عندنا.
|
||||||
|
|
||||||
|
**3) طبقتا إعداد، لكلٍّ دورة حياة:**
|
||||||
|
| الطبقة | متى تُحسم | تحوي | التغيير يسري بـ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BuildConfig` مولَّد (`const`) | وقت البناء (N3) | هوية المستأجر · BASE_URL · ألوان · أصول · **أعلام الميزات** | إصدار/patch |
|
||||||
|
| مانيفست ريموت (`GET /tenant/config`) | كل إقلاع | تشغيل/إطفاء ضمن المبنيّ · وسائل الدفع · ترتيبات · وضع صيانة | فوراً |
|
||||||
|
|
||||||
|
**القاعدة الحاكمة**: `الفعّال = المبني في الـbinary ∩ المانيفست ∩ استحقاقات السيرفر`.
|
||||||
|
|
||||||
|
**4) الحدّ الأمني يبقى السيرفر.** التجريد من الـbinary دفاعٌ إضافي وحجمٌ أصغر وUX بلا أزرار مقفلة — **لا بديل عن `FeatureGuard`** (docs/19 — K5). أثقل ملكية فكرية (تسعير · مطابقة · surge) سيرفرية أصلاً ولا تظهر في التطبيق.
|
||||||
|
|
||||||
|
**5) تطبيقاتنا المنشورة** (rider/driver بمعرّفات [[tripz-mobile-store-identity]]) تبقى بناء **max كاملاً** — لا نفتّت قوائم منشورة.
|
||||||
|
|
||||||
|
**البنية المقابلة في فلاتر (Q5)**: feature-first — كل ميزة موديول مستقل يسجّل نفسه في DI والراوتر **فقط إذا علمه مفعّل**، فيكون الإقفال من نقطة واحدة لا `if` مبعثرة في الشاشات.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. المجموعات القادمة (تكملةً لـA–K)
|
## 2. المجموعات القادمة (تكملةً لـA–K)
|
||||||
|
|
||||||
### المجموعة L — إكمال نموذج التسعير (B9/B10/B11 + الديناميكي)
|
### المجموعة L — إكمال نموذج التسعير (B9/B10/B11 + الديناميكي)
|
||||||
@@ -78,9 +106,12 @@
|
|||||||
### المجموعة N — طبقة الـSaaS: السوبر-أدمن وتوليد التطبيق ⭐ (الهدف الأساسي) — 🔵 قيد التنفيذ
|
### المجموعة N — طبقة الـSaaS: السوبر-أدمن وتوليد التطبيق ⭐ (الهدف الأساسي) — 🔵 قيد التنفيذ
|
||||||
| # | البند | الحالة |
|
| # | البند | الحالة |
|
||||||
|---|-------|--------|
|
|---|-------|--------|
|
||||||
| N1 | **لوحة سوبر-أدمن (ويب)**: إنشاء مستأجر · اسم/لوغو/دولة/دفع/باقة/ميزات · مراقبة. | 🔵 الباك إند ✅ (`provision` · `summary` · `app-manifest` · `payment-methods` · `branding` خلف PlatformGuard؛ كتالوج `payment-methods.ts`) + واجهة ويب مكتفية ذاتياً (`dashboards/superadmin-web/index.html`). الباقي: لوحة أغنى (تعديل/تعطيل/GMV). |
|
| N1 | **لوحة سوبر-أدمن (ويب)**: إنشاء مستأجر · اسم/لوغو/دولة/دفع/باقة/ميزات · مراقبة. | ✅ الباك إند (`provision` · `summary` · `app-manifest` · `payment-methods` · `branding` · **`overview`** · **`status`** خلف PlatformGuard) + `dashboards/superadmin-web/index.html` (نظرة شاملة + تعليق/تفعيل). التصميم مؤجَّل عمداً لآخر المشوار. |
|
||||||
|
| N1a | **تعليق المستأجر يسري فعلياً** (2026-07-18). | ✅ كان `tenant.status` يُكتب ويُعرض **بلا أي فرض** — أي أن «تعطيل المستأجر» زخرفة والمستأجر غير الدافع يظل يشتغل. الفرض الآن في `JwtStrategy` (نقطة واحدة، نفس حجّة ربط الجهاز D2) + `AuthService.resolveTenant` (يخنق `sendOtp` و`verifyOtp` معاً). القراءة مكيَّشة (Redis أولاً) والإبطال يجعل التعليق فورياً؛ وتعذّر القراءة **يمرّر** لا يقطع المنصة كلها (G). |
|
||||||
|
| N1b | **النظرة الشاملة**: `GET /admin/overview?days=` — رحلات · GMV · **إيراد المنصة (العمولة)** لكل مستأجر. | ✅ استعلام واحد مجمَّع لكل المستأجرين (لا N+1). `revenue` = مجموع العمولة وهو الرقم الذي يهمّ عند التسعير، لا الـGMV. |
|
||||||
|
| N1c | **خدمة اللوحات الثلاث على الإنترنت**. | ✅ تُخدم من **نفس أصل الـAPI** عبر `useStaticAssets`: `/panel/superadmin` · `/panel/admin` · `/panel/service` — بلا CORS وبلا دومين/شهادة ثانية، وترث TLS القائم (docs/20). المجلد مربوط bind من compose فتعديل HTML يظهر بتحديث الصفحة بلا إعادة بناء. |
|
||||||
| N2 | **رفع اللوغو + توليد الأيقونات/splash**. | 🔵 الباك إند ✅ (`POST /admin/tenants/:id/logo` + `GET /tenant/logo/:slug` **عام لأصل الهوية فقط** — لا يخدم مجلد التخزين كاملاً حتى لا تُسرَّب صور الوثائق). توليد الأيقونات نفسه في N3. |
|
| N2 | **رفع اللوغو + توليد الأيقونات/splash**. | 🔵 الباك إند ✅ (`POST /admin/tenants/:id/logo` + `GET /tenant/logo/:slug` **عام لأصل الهوية فقط** — لا يخدم مجلد التخزين كاملاً حتى لا تُسرَّب صور الوثائق). توليد الأيقونات نفسه في N3. |
|
||||||
| N3 | **سكربت توليد التطبيق الواحد**: bundle ID · أيقونات/splash من اللوغو · FCM · **إضافات Kotlin/C++ الأصيلة** (overlay · method channels · NDK) · بناء · **Shorebird**. | 🔵 `scripts/generate-tenant-app.sh` — يجلب المانيفست ويضبط الهيكل؛ خطوات فلاتر موسومة [Q] تُوصَل عند بناء المجموعة Q (المشروع غير موجود بعد). |
|
| N3 | **سكربت توليد التطبيق الواحد**: bundle ID · أيقونات/splash من اللوغو · FCM · **إضافات Kotlin/C++ الأصيلة** (overlay · method channels · NDK) · بناء · **Shorebird**. | 🔵 `scripts/generate-tenant-app.sh` — يجلب المانيفست ويضبط الهيكل؛ خطوات فلاتر موسومة [Q] تُوصَل عند بناء المجموعة Q (المشروع غير موجود بعد). **يجب أن يولّد `BuildConfig` بأعلام `const` حسب §1.5** — هذا ما يجعل الطبقات حقيقية لا تجميلية. |
|
||||||
| N4 | **لوحة أدمن المستأجر (ويب)**: سائقون (اعتماد) · رحلات · مراجعة وثائق · سحوبات (تحويل/فشل). | ✅ `dashboards/admin-web/index.html` + نقاط سرد إدارية (`/drivers/admin/list` · `/trips/admin/list` · `/payouts/admin/list` خلف RolesGuard admin/dispatcher). التعرفة والتقارير تُضاف مع L. |
|
| N4 | **لوحة أدمن المستأجر (ويب)**: سائقون (اعتماد) · رحلات · مراجعة وثائق · سحوبات (تحويل/فشل). | ✅ `dashboards/admin-web/index.html` + نقاط سرد إدارية (`/drivers/admin/list` · `/trips/admin/list` · `/payouts/admin/list` خلف RolesGuard admin/dispatcher). التعرفة والتقارير تُضاف مع L. |
|
||||||
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم برقمه (فهرس أعمى) · رحلاته · تفاصيل رحلة بالمعرّف. | ✅ `dashboards/service-web/index.html` + `GET /admin/users/search` · `/trips/admin/list?rider=`. الشكاوى تحتاج وحدة complaints (لا توجد بعد — بند لاحق). |
|
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم برقمه (فهرس أعمى) · رحلاته · تفاصيل رحلة بالمعرّف. | ✅ `dashboards/service-web/index.html` + `GET /admin/users/search` · `/trips/admin/list?rider=`. الشكاوى تحتاج وحدة complaints (لا توجد بعد — بند لاحق). |
|
||||||
|
|
||||||
@@ -114,7 +145,7 @@
|
|||||||
| Q2 | **الراكب**: صفحة الطلب · اختيار الوجهة/التوقفات · widgets الرحلة · تتبّع السائق حياً · الدفع · التقييم (شاشة إجبارية عند وجود رحلة معلّقة). |
|
| Q2 | **الراكب**: صفحة الطلب · اختيار الوجهة/التوقفات · widgets الرحلة · تتبّع السائق حياً · الدفع · التقييم (شاشة إجبارية عند وجود رحلة معلّقة). |
|
||||||
| Q3 | **السائق**: overlay العرض · قبول · widgets الرحلة · **متحكّم الموقع** (تسجيل 3ث/رفع دفعات/واعٍ للبطارية — نمط سيرو) · حيوية الوجه · الرصيد/الشحن. |
|
| Q3 | **السائق**: overlay العرض · قبول · widgets الرحلة · **متحكّم الموقع** (تسجيل 3ث/رفع دفعات/واعٍ للبطارية — نمط سيرو) · حيوية الوجه · الرصيد/الشحن. |
|
||||||
| Q4 | **الخرائط المتقدّمة**: خريطة حرارية · خريطة تنبّؤية (من O1). |
|
| Q4 | **الخرائط المتقدّمة**: خريطة حرارية · خريطة تنبّؤية (من O1). |
|
||||||
| Q5 | **البنية التحتية للتطبيق**: طبقة الشبكة (توقيع HMAC · x-device-id) · country pack ديناميكي · flavors. |
|
| Q5 | **البنية التحتية للتطبيق**: طبقة الشبكة (توقيع HMAC · x-device-id) · country pack ديناميكي · flavors · **طبقتا الإعداد وأعلام `const` حسب §1.5** · موديولات feature-first تُسجَّل حسب العلم. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user