feat: N1d/N2/N3 + قانون فلاتر وإعادة ضبط تطبيق السائق

الباك إند:
- تعيين أول أدمن صار يُنشئ المستخدم إن لم يوجد — كان يشترط دخولاً سابقاً،
  وهي بيضة ودجاجة تمنع الدخول إلى لوحة أي مستأجر جديد أصلاً.
- OTP_DEV_MODE كان يفشل مفتوحاً (`!== 'false'`): غياب المتغيّر أو خطأ مطبعي
  يترك الإنتاج برمز ثابت يفتح كل حساب، ويحرس السحب المالي كذلك. صار
  `=== 'true'` في الإعداد وفي قارئَيه، مع تحذير عند الإقلاع.
- N2: التحقّق من اللوغو عند الرفع (PNG/JPEG · 512+ · مربّع · سقف 5MB) عبر
  قراءة الترويسة بلا اعتمادية — بدل اكتشاف أيقونة ممطوطة بعد النشر.

N3: السكربت يولّد build_config.dart بأعلام const (طبقات docs/22 §1.5)،
ويضبط bundle IDs واسم التطبيق والأيقونات/splash، و--build يشغّل Shorebird.

فلاتر:
- docs/23: قانون مُلزِم للتطبيقين (البنية · Cubit · طبقة الشبكة · الأعلام).
- السائق: أُزيل Dart القديم (GetX) مع الإبقاء على الأصيل والإضافات وشهادات
  التوقيع وShorebird وFirebase الخاص به، وأُعيد هيكلته مطابقاً للراكب.
- طبقة الشبكة ترسل x-device-id وتجدّد التوكن عند 401 مرة واحدة فقط.
- الحزم موحّدة بين التطبيقين، والـAPI https حصراً في الاثنين.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-18 13:06:46 +03:00
co-authored by Claude Fable 5
parent 3ab74a13a8
commit de4bbd00ac
271 changed files with 1333 additions and 81068 deletions
+3 -1
View File
@@ -44,8 +44,10 @@ export class AuthService {
return tenant;
}
// `=== true` لا `!== false`: قيمة مفقودة يجب أن تعني إرسالاً حقيقياً، لا
// رمزاً ثابتاً يفتح كل الحسابات (نفس منطق الإعداد في configuration.ts).
private get devMode(): boolean {
return this.config.get<boolean>('auth.otpDevMode') !== false;
return this.config.get<boolean>('auth.otpDevMode') === true;
}
private otpKey(tenantId: string, phone: string): string {
@@ -63,8 +63,10 @@ export class PayoutsService {
return `payout:otp:${payoutId}`;
}
// `=== true` لا `!== false`: هنا الرمز يحرس **سحب المال**؛ قيمة مفقودة كانت
// تعني رمزاً ثابتاً يكفي لسحب رصيد أي سائق.
private get devMode(): boolean {
return this.config.get<boolean>('auth.otpDevMode') !== false;
return this.config.get<boolean>('auth.otpDevMode') === true;
}
/**
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -19,6 +20,7 @@ import { FEATURES } from '../../common/entitlements/features';
import { PAYMENT_METHODS } from '../../common/entitlements/payment-methods';
import { PlatformGuard } from '../../common/platform/platform.guard';
import { StorageService } from '../../common/storage/storage.service';
import { readImageMeta, validateLogo } from '../../common/storage/image-meta';
@ApiTags('tenants')
@Controller()
@@ -142,14 +144,28 @@ export class TenantsController {
return this.tenants.setPaymentMethods(id, methods);
}
/** رفع لوغو المستأجر (docs/22 — N2) → يُخزَّن ويُربط في الهوية البصرية. */
/**
* رفع لوغو المستأجر (docs/22 — N2) → يُخزَّن ويُربط في الهوية البصرية.
*
* يُتحقَّق منه هنا لا عند البناء: لوغو صغير أو مستطيل يُنتج أيقونة ممطوطة
* في **كل** تطبيقات المستأجر، ولا يُكتشف إلا بعد بناءٍ ونشرٍ كاملين.
* الحدّ 5MB يمنع رفعاً غير محدود يملأ قرص السيرفر (وهو مزدحم أصلاً).
*/
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Post('admin/tenants/:id/logo')
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async logo(@Param('id') id: string, @UploadedFile() file: any) {
const key = await this.storage.save(id, 'branding', file.buffer, file.originalname);
return this.tenants.setBranding(id, { logo_key: key });
if (!file?.buffer) throw new BadRequestException('لا ملف مرفوع (الحقل: file)');
const problem = validateLogo(file.buffer);
if (problem) throw new BadRequestException(problem);
const meta = readImageMeta(file.buffer)!;
// نوحّد الامتداد من المحتوى لا من الاسم: `logo.png` قد يكون JPEG فعلياً،
// فتُرسَل ترويسة Content-Type خاطئة من نقطة اللوغو العامة.
const key = await this.storage.save(id, 'branding', file.buffer, `logo.${meta.format === 'png' ? 'png' : 'jpg'}`);
const tenant = await this.tenants.setBranding(id, { logo_key: key });
return { ...tenant, logo: { ...meta, url: `/api/tenant/logo/${tenant.slug}` } };
}
/** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */
@@ -57,20 +57,33 @@ export class AdminUsersController {
return this.users.findById(user.tenantId, id);
}
// تمهيد: تعيين أول أدمن قبل وجود أدمن — سوبر-أدمن عبر PlatformGuard.
/**
* تمهيد: تعيين أول أدمن قبل وجود أدمن — سوبر-أدمن عبر PlatformGuard.
*
* **يُنشئ المستخدم إن لم يكن موجوداً** (docs/22 — N1d). كان يشترط أن يكون
* صاحب الرقم سجّل دخوله مرة أولاً، وهذه بيضة ودجاجة: المستأجر المزوَّد
* حديثاً بلا مستخدمين إطلاقاً، فلا سبيل لصنع أول أدمن — أي أن اللوحة
* المسلَّمة للمستأجر لا يمكن الدخول إليها أصلاً.
*
* الإنشاء هنا آمن: النقطة خلف سرّ المنصة، والحساب المُنشأ بلا كلمة مرور
* (المصادقة هاتف+OTP) فلا يملكه أحد إلا صاحب الرقم فعلياً.
*/
@ApiSecurity('x-platform-secret')
@UseGuards(PlatformGuard)
@Post('platform/users/role')
async bootstrap(@Body() body: { tenantSlug: string; phone: string; role: string }) {
if (!ROLES.includes(body.role)) throw new BadRequestException('invalid role');
if (!body.phone) throw new BadRequestException('phone is required');
const tenant = await this.tenants.resolve(body.tenantSlug);
if (!tenant) throw new NotFoundException('unknown tenant');
// المستخدم مخزَّن برقمه المطبَّع (docs/17 — D1) — بحثٌ بالرقم الخام
// يفشل لو كتبه الأدمن بصيغة مختلفة عن التي دخل بها المستخدم أول مرة.
const canonical = this.phones.normalize(body.phone, tenant.countryPack);
const u = await this.users.findByPhone(tenant.id, canonical);
if (!u) throw new NotFoundException('user not found (must log in once first)');
await this.users.setRole(tenant.id, u.id, body.role);
return this.users.findById(tenant.id, u.id);
const existing = await this.users.findByPhone(tenant.id, canonical);
const u = existing ?? (await this.users.create(tenant.id, canonical, body.role));
if (existing) await this.users.setRole(tenant.id, existing.id, body.role);
return { ...(await this.users.findById(tenant.id, u.id)), created: !existing };
}
}