diff --git a/backend/scripts/e2e-test.mjs b/backend/scripts/e2e-test.mjs index a8619e9..2f0bfe4 100644 --- a/backend/scripts/e2e-test.mjs +++ b/backend/scripts/e2e-test.mjs @@ -253,6 +253,16 @@ async function main() { const dupRate = await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 3 }, { allowError: true }); check('منع التقييم المزدوج', !dupRate.ok && dupRate.status === 400); + // ═══ 11.5 حراسة النقاط الإدارية (docs/22 — N4) ═══ + console.log('\n11.5) حراسة لوحة الأدمن (RolesGuard)'); + const adminAsDriver = await api('GET', '/drivers/admin/list', driver.token, null, { allowError: true }); + check('السائق (غير أدمن) يُرفض من /drivers/admin/list', adminAsDriver.status === 403, + `status=${adminAsDriver.status}`); + const tripsAsRider = await api('GET', '/trips/admin/list', rider.token, null, { allowError: true }); + check('الراكب يُرفض من /trips/admin/list', tripsAsRider.status === 403); + const searchAsRider = await api('GET', '/admin/users/search?phone=07900000000', rider.token, null, { allowError: true }); + check('الراكب يُرفض من بحث خدمة العملاء', searchAsRider.status === 403); + // ═══ 12. السحب بـOTP (تدفّق خطوتين) ═══ console.log('\n12) سحب أرباح السائق (OTP خطوتين)'); // السائق يشحن محفظة أرباحه أولاً ليكون له رصيد للسحب diff --git a/backend/src/modules/drivers/drivers.controller.ts b/backend/src/modules/drivers/drivers.controller.ts index 4957251..8879113 100644 --- a/backend/src/modules/drivers/drivers.controller.ts +++ b/backend/src/modules/drivers/drivers.controller.ts @@ -1,7 +1,9 @@ -import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { DriversService } from './drivers.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; @ApiTags('drivers') @@ -11,6 +13,14 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator export class DriversController { constructor(private readonly drivers: DriversService) {} + /** سرد سائقي المستأجر — للوحة الأدمن (docs/22 — N4). */ + @UseGuards(RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/list') + adminList(@CurrentUser() user: AuthUser, @Query('verification') verification?: string) { + return this.drivers.listByTenant(user.tenantId, verification); + } + @Post('apply') apply(@CurrentUser() user: AuthUser, @Body() body: any) { return this.drivers.apply(user.tenantId, user.userId, body); diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index b0d3452..d0eb870 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -102,6 +102,13 @@ export class DriversService { }); } + /** سرد سائقي المستأجر للوحة الأدمن (docs/22 — N4). */ + listByTenant(tenantId: string, verification?: string): Promise { + const where: any = { tenant_id: tenantId }; + if (verification) where.verification_status = verification; + return this.repo.find({ where, order: { created_at: 'DESC' }, take: 200 }); + } + async setRating(tenantId: string, driverId: string, rating: number): Promise { await this.repo.update( { tenant_id: tenantId, id: driverId }, diff --git a/backend/src/modules/payments/payouts.controller.ts b/backend/src/modules/payments/payouts.controller.ts index 586db30..9b74087 100644 --- a/backend/src/modules/payments/payouts.controller.ts +++ b/backend/src/modules/payments/payouts.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { PayoutsService, RequestContext } from './payouts.service'; @@ -69,6 +69,14 @@ export class PayoutsController { return this.payouts.listMine(user.tenantId, user.userId); } + /** سرد سحوبات المستأجر — للأدمن، بترشيح الحالة (docs/22 — N4). */ + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/list') + adminList(@CurrentUser() user: AuthUser, @Query('status') status?: string) { + return this.payouts.listByTenant(user.tenantId, status); + } + // الأدمن يؤكّد/يفشل التحويل @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'dispatcher') diff --git a/backend/src/modules/payments/payouts.service.ts b/backend/src/modules/payments/payouts.service.ts index 4429fff..43bc290 100644 --- a/backend/src/modules/payments/payouts.service.ts +++ b/backend/src/modules/payments/payouts.service.ts @@ -174,6 +174,13 @@ export class PayoutsService { }); } + /** سرد سحوبات المستأجر للأدمن (docs/22 — N4)، بترشيح الحالة. */ + listByTenant(tenantId: string, status?: string) { + const where: any = { tenant_id: tenantId }; + if (status) where.status = status; + return this.repo.find({ where, order: { created_at: 'DESC' }, take: 100 }); + } + /** الأدمن يؤكّد أن المبلغ حُوِّل خارجياً. */ async complete( tenantId: string, diff --git a/backend/src/modules/trips/trips.controller.ts b/backend/src/modules/trips/trips.controller.ts index 6fa584d..a876753 100644 --- a/backend/src/modules/trips/trips.controller.ts +++ b/backend/src/modules/trips/trips.controller.ts @@ -1,8 +1,10 @@ -import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TripsService, RequestTripDto } from './trips.service'; import { TripStatus } from './entities/trip.entity'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; @ApiTags('trips') @@ -12,6 +14,18 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator export class TripsController { constructor(private readonly trips: TripsService) {} + /** سرد رحلات المستأجر — للوحة الأدمن، بترشيح الحالة (docs/22 — N4). */ + @UseGuards(RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/list') + adminList( + @CurrentUser() user: AuthUser, + @Query('status') status?: string, + @Query('rider') rider?: string, + ) { + return this.trips.listByTenant(user.tenantId, status, rider); + } + // الراكب يطلب رحلة @Post() request(@CurrentUser() user: AuthUser, @Body() body: RequestTripDto) { diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index d8dadc9..516e35d 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -115,6 +115,14 @@ export class TripsService { return qb.orderBy('t.completed_at', 'DESC').take(limit).getMany(); } + /** سرد رحلات المستأجر للوحة الأدمن، بترشيح الحالة أو الراكب (docs/22 — N4/N5). */ + listByTenant(tenantId: string, status?: string, riderId?: string): Promise { + const where: any = { tenant_id: tenantId }; + if (status) where.status = status; + if (riderId) where.rider_id = riderId; // خدمة العملاء: رحلات مستخدم بعينه + return this.trips.find({ where, order: { requested_at: 'DESC' }, take: 100 }); + } + listForDriver(tenantId: string, driverId: string): Promise { return this.trips.find({ where: { tenant_id: tenantId, driver_id: driverId }, diff --git a/backend/src/modules/users/admin-users.controller.ts b/backend/src/modules/users/admin-users.controller.ts index 479c12b..c986020 100644 --- a/backend/src/modules/users/admin-users.controller.ts +++ b/backend/src/modules/users/admin-users.controller.ts @@ -2,10 +2,12 @@ import { BadRequestException, Body, Controller, + Get, NotFoundException, Param, Patch, Post, + Query, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'; @@ -29,6 +31,20 @@ export class AdminUsersController { private readonly phones: PhoneService, ) {} + /** بحث خدمة العملاء عن مستخدم برقم هاتفه (docs/22 — N5). يُطبَّع ثم يُبحث + * بالفهرس الأعمى — لا يُعرض الرقم الخام. نطاقه المستأجر من التوكن. */ + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/users/search') + async search(@CurrentUser() user: AuthUser, @Query('phone') phone: string) { + if (!phone) throw new BadRequestException('phone is required'); + const tenant = await this.tenants.resolve(user.tenantId); + const canonical = this.phones.normalize(phone, tenant?.countryPack ?? 'jo'); + const u = await this.users.findByPhone(user.tenantId, canonical); + return u ? { id: u.id, phone: u.phone, role: u.role, rating: u.rating, status: u.status } : null; + } + // أدمن **المستأجر** يعيّن دور مستخدم داخل مستأجره هو — هذه ليست نقطة منصة، // ونطاقها مضمون بـ user.tenantId القادم من التوكن. @ApiBearerAuth() diff --git a/dashboards/admin-web/index.html b/dashboards/admin-web/index.html new file mode 100644 index 0000000..5312ee9 --- /dev/null +++ b/dashboards/admin-web/index.html @@ -0,0 +1,164 @@ + + + + + + Tripz — لوحة الأدمن + + + + +
+

🚕 Tripz — لوحة الأدمن

+ + + +
+ + + +
+ + +

دخول الأدمن

+

هاتف + رمز تحقّق (OTP).

+ + +
+ + +
+
+ + + + diff --git a/dashboards/service-web/index.html b/dashboards/service-web/index.html new file mode 100644 index 0000000..ef25406 --- /dev/null +++ b/dashboards/service-web/index.html @@ -0,0 +1,128 @@ + + + + + + Tripz — خدمة العملاء + + + + +
+

🎧 Tripz — خدمة العملاء

+ + + +
+ + +
+ +

دخول خدمة العملاء

+

هاتف + رمز تحقّق.

+ + +
+
+ + + + diff --git a/docs/22-full-product-roadmap.md b/docs/22-full-product-roadmap.md index 14ada85..ea412a5 100644 --- a/docs/22-full-product-roadmap.md +++ b/docs/22-full-product-roadmap.md @@ -81,8 +81,11 @@ | N1 | **لوحة سوبر-أدمن (ويب)**: إنشاء مستأجر · اسم/لوغو/دولة/دفع/باقة/ميزات · مراقبة. | 🔵 الباك إند ✅ (`provision` · `summary` · `app-manifest` · `payment-methods` · `branding` خلف PlatformGuard؛ كتالوج `payment-methods.ts`) + واجهة ويب مكتفية ذاتياً (`dashboards/superadmin-web/index.html`). الباقي: لوحة أغنى (تعديل/تعطيل/GMV). | | 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 (المشروع غير موجود بعد). | -| N4 | **لوحة أدمن المستأجر (ويب)**: dispatch · سائقون · رحلات · تعرفة · تقارير · مراجعة وثائق. | ⏳ التالي | -| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم · شكاوى · تدخّل. | ⏳ | +| 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 (لا توجد بعد — بند لاحق). | + +**مصادقة اللوحتين**: هاتف + OTP → JWT بدور admin/dispatcher (نفس تدفّق الموبايل). كل الحماية على السيرفر (RolesGuard) — الواجهة عرض فقط. الوصول للوحة معيَّن بـ`?tenant=`. +**اختبار الحراسة**: E2E يثبت أن السائق/الراكب (غير أدمن) يُرفضان من كل النقاط الإدارية (403). **اختبار**: `backend/scripts/provision-test.mjs` (يتطلّب `PLATFORM_SECRET`) — يثبت الحارس، التزويد، تصفية الدفع، الاستحقاقات، والمانيفست.