feat: المجموعة N (طبقة الـSaaS) — تزويد المستأجر + مانيفست التطبيق + لوحة سوبر-أدمن
الهدف الأساسي: السوبر-أدمن ينشئ مستأجراً كاملاً (اسم/لوغو/دولة/دفع/باقة/ ميزات/bundle IDs) ويولّد تطبيقه. الباك إند (خلف PlatformGuard): - POST /admin/tenants/provision — تزويد بضربة واحدة - GET /admin/tenants/:id/summary + /app-manifest (يستهلكه سكربت البناء) - PATCH .../branding + .../payment-methods - POST /admin/tenants/:id/logo (رفع) + GET /tenant/logo/:slug عام **يخدم أصل الهوية فقط لا مجلد التخزين كاملاً** — وإلا سُرّبت صور وثائق الهوية الحساسة بمفاتيحها - كتالوج payment-methods.ts (cash/wallet/cliq/zaincash/paymob/mtn/ syriatel/shamcash حسب الدولة) + تصفية المخترع - GET /admin/features يرجع الميزات ووسائل الدفع للكتالوج الواجهة: dashboards/superadmin-web/index.html — SPA مكتفية ذاتياً (vanilla JS، بلا خطوة بناء)، سرّ المنصة في sessionStorage، RTL. تُخدَم كملف ثابت. سكربت التوليد: scripts/generate-tenant-app.sh — يجلب المانيفست ويضبط الهيكل؛ خطوات فلاتر موسومة [Q] تُوصَل عند بناء مشروع فلاتر. اختبار: provision-test.mjs (يثبت الحارس + التزويد + تصفية الدفع + الاستحقاقات + المانيفست). لا هجرة — settings/branding jsonb موجودان. القرار: اللوحات ويب (لا فلاتر) — تحديث فوري، سطح مكتب، معيار الصناعة. البدء بـN (طبقة الـSaaS) قرار المالك. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4fc79181ff
commit
c93be38622
@@ -0,0 +1,86 @@
|
|||||||
|
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
|
||||||
|
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
|
||||||
|
//
|
||||||
|
// التشغيل:
|
||||||
|
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||||||
|
// -e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
|
||||||
|
// node:22-alpine node /s/provision-test.mjs
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||||
|
const SECRET = process.env.SECRET || process.env.PLATFORM_SECRET || '';
|
||||||
|
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
const check = (n, c, d) => { c ? (passed++, console.log(` ✅ ${n}`)) : (failed++, console.log(` ❌ ${n}${d ? ` — ${d}` : ''}`)); };
|
||||||
|
|
||||||
|
async function api(method, path, body, secret = SECRET) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(secret ? { 'x-platform-secret': secret } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
let json = {}; try { json = text ? JSON.parse(text) : {}; } catch {}
|
||||||
|
return { status: res.status, ok: res.ok, body: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🧪 اختبار تزويد السوبر-أدمن على ${BASE}\n`);
|
||||||
|
if (!SECRET) { console.log(' ❌ لا PLATFORM_SECRET — مرّره عبر -e SECRET=…'); process.exit(1); }
|
||||||
|
|
||||||
|
// 1) الحارس: بلا سرّ يُرفض
|
||||||
|
const noSecret = await api('GET', '/admin/tenants', null, '');
|
||||||
|
check('نقاط السوبر-أدمن محميّة (بلا سرّ = 401)', noSecret.status === 401, `status=${noSecret.status}`);
|
||||||
|
|
||||||
|
const wrong = await api('GET', '/admin/tenants', null, 'wrong-secret');
|
||||||
|
check('سرّ خاطئ يُرفض', wrong.status === 401);
|
||||||
|
|
||||||
|
// 2) الكتالوج
|
||||||
|
const cat = await api('GET', '/admin/features');
|
||||||
|
check('كتالوج الميزات ووسائل الدفع متاح', Array.isArray(cat.body.features) && Array.isArray(cat.body.payment_methods));
|
||||||
|
|
||||||
|
// 3) تزويد مستأجر كامل
|
||||||
|
const slug = `test${Date.now().toString().slice(-8)}`;
|
||||||
|
const prov = await api('POST', '/admin/tenants/provision', {
|
||||||
|
name: 'مستأجر اختبار', slug, countryPack: 'eg', plan: 'brand',
|
||||||
|
features: { bots: true }, paymentMethods: ['cash', 'paymob'],
|
||||||
|
branding: { app_name: 'تطبيق الاختبار', bundle_id_android: 'com.test.rider', bundle_id_ios: 'com.test.rider' },
|
||||||
|
});
|
||||||
|
check('تزويد مستأجر جديد نجح', prov.ok, JSON.stringify(prov.body).slice(0, 120));
|
||||||
|
const tenantId = prov.body.id;
|
||||||
|
|
||||||
|
// 4) وسائل الدفع صُفّيت للكتالوج
|
||||||
|
const summary = await api('GET', `/admin/tenants/${tenantId}/summary`);
|
||||||
|
check('وسائل الدفع محفوظة', Array.isArray(summary.body.payment_methods) &&
|
||||||
|
summary.body.payment_methods.includes('paymob'), JSON.stringify(summary.body.payment_methods));
|
||||||
|
|
||||||
|
// 5) الاستحقاقات: الميزة المشتراة مفعّلة + الافتراض منع
|
||||||
|
const ent = await api('GET', `/admin/tenants/${tenantId}/entitlements`);
|
||||||
|
check('ميزة bots المشتراة مفعّلة', ent.body.features?.bots === true);
|
||||||
|
check('ميزة غير مشتراة (transit) محجوبة افتراضاً', ent.body.features?.transit === false);
|
||||||
|
|
||||||
|
// 6) المانيفست جاهز لسكربت البناء
|
||||||
|
const manifest = await api('GET', `/admin/tenants/${tenantId}/app-manifest`);
|
||||||
|
check('المانيفست يحمل bundle ID', manifest.body.bundle_id_android === 'com.test.rider');
|
||||||
|
check('المانيفست يحمل اسم التطبيق', manifest.body.app_name === 'تطبيق الاختبار');
|
||||||
|
check('المانيفست يحمل الدولة والدفع', manifest.body.country_pack === 'eg' &&
|
||||||
|
manifest.body.payment_methods.includes('paymob'));
|
||||||
|
|
||||||
|
// 7) slug مكرّر يُرفض
|
||||||
|
const dup = await api('POST', '/admin/tenants/provision', { name: 'x', slug });
|
||||||
|
check('slug مكرّر يُرفض', dup.status === 400);
|
||||||
|
|
||||||
|
// 8) تعديل وسائل الدفع (تصفية المخترع)
|
||||||
|
const setPay = await api('PATCH', `/admin/tenants/${tenantId}/payment-methods`, {
|
||||||
|
methods: ['cash', 'made_up_gateway', 'cliq'],
|
||||||
|
});
|
||||||
|
check('وسيلة مخترعة تُصفّى، الصحيحة تبقى',
|
||||||
|
setPay.body.settings?.payment_methods?.includes('cliq') &&
|
||||||
|
!setPay.body.settings?.payment_methods?.includes('made_up_gateway'));
|
||||||
|
|
||||||
|
console.log('\n' + '═'.repeat(48));
|
||||||
|
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
|
||||||
|
console.log('═'.repeat(48) + '\n');
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error('💥', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* كتالوج بوابات الدفع المتاحة (docs/22 — N) والدول التي تخدمها.
|
||||||
|
* السوبر-أدمن يفعّل ما يناسب دولة المستأجر عند التزويد.
|
||||||
|
*/
|
||||||
|
export const PAYMENT_METHODS = [
|
||||||
|
{ code: 'cash', name_ar: 'نقداً', countries: ['jo', 'sy', 'eg'] },
|
||||||
|
{ code: 'wallet', name_ar: 'المحفظة', countries: ['jo', 'sy', 'eg'] },
|
||||||
|
{ code: 'cliq', name_ar: 'كليك', countries: ['jo'] },
|
||||||
|
{ code: 'zaincash', name_ar: 'زين كاش', countries: ['jo'] },
|
||||||
|
{ code: 'paymob', name_ar: 'باي موب', countries: ['eg'] },
|
||||||
|
{ code: 'mtn', name_ar: 'MTN', countries: ['sy'] },
|
||||||
|
{ code: 'syriatel', name_ar: 'سيرياتيل كاش', countries: ['sy'] },
|
||||||
|
{ code: 'shamcash', name_ar: 'شام كاش', countries: ['sy'] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PaymentMethodCode = (typeof PAYMENT_METHODS)[number]['code'];
|
||||||
|
|
||||||
|
/** الوسائل الافتراضية لدولة (نقد + محفظة + بوابات الدولة) — نقطة بداية التزويد. */
|
||||||
|
export function defaultPaymentMethods(countryPack: string): string[] {
|
||||||
|
return PAYMENT_METHODS.filter((m) => (m.countries as readonly string[]).includes(countryPack)).map(
|
||||||
|
(m) => m.code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** يتحقق أن الوسائل المطلوبة كلها في الكتالوج (لا وسيلة مخترعة). */
|
||||||
|
export function validPaymentMethods(codes: string[]): string[] {
|
||||||
|
const known = new Set<string>(PAYMENT_METHODS.map((m) => m.code));
|
||||||
|
return (codes ?? []).filter((c) => known.has(c));
|
||||||
|
}
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags, ApiSecurity } from '@nestjs/swagger';
|
import { ApiTags, ApiSecurity } from '@nestjs/swagger';
|
||||||
import { TenantsService } from './tenants.service';
|
import { TenantsService, ProvisionDto } from './tenants.service';
|
||||||
import { Tenant } from '../../database/entities/tenant.entity';
|
import { Tenant } from '../../database/entities/tenant.entity';
|
||||||
import { FEATURES } from '../../common/entitlements/features';
|
import { FEATURES } from '../../common/entitlements/features';
|
||||||
|
import { PAYMENT_METHODS } from '../../common/entitlements/payment-methods';
|
||||||
import { PlatformGuard } from '../../common/platform/platform.guard';
|
import { PlatformGuard } from '../../common/platform/platform.guard';
|
||||||
|
import { StorageService } from '../../common/storage/storage.service';
|
||||||
|
|
||||||
@ApiTags('tenants')
|
@ApiTags('tenants')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class TenantsController {
|
export class TenantsController {
|
||||||
constructor(private readonly tenants: TenantsService) {}
|
constructor(
|
||||||
|
private readonly tenants: TenantsService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* إعداد المستأجر عند إقلاع التطبيق.
|
* إعداد المستأجر عند إقلاع التطبيق.
|
||||||
@@ -23,6 +40,27 @@ export class TenantsController {
|
|||||||
return this.tenants.config(slug);
|
return this.tenants.config(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لوغو المستأجر — **عام** (يظهر في splash التطبيق ولوحة السوبر-أدمن).
|
||||||
|
* يخدم أصل الهوية البصرية **فقط** عبر `branding.logo_key` — لا يخدم مجلد
|
||||||
|
* التخزين كاملاً (فيه صور وثائق الهوية الحساسة). لذلك نقطة مخصّصة لا
|
||||||
|
* `useStaticAssets` على كل `/storage`.
|
||||||
|
*/
|
||||||
|
@Get('tenant/logo/:slug')
|
||||||
|
async logoOf(@Param('slug') slug: string, @Res() res: any) {
|
||||||
|
const t = await this.tenants.findBySlug(slug);
|
||||||
|
const key = t?.branding?.logo_key;
|
||||||
|
if (!key) return res.status(404).json({ message: 'no logo' });
|
||||||
|
try {
|
||||||
|
const buf = await this.storage.read(key);
|
||||||
|
res.setHeader('Content-Type', key.endsWith('.png') ? 'image/png' : 'image/jpeg');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
return res.send(buf);
|
||||||
|
} catch {
|
||||||
|
return res.status(404).json({ message: 'logo not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- سوبر-أدمن: مالك المنصة يدير كل المستأجرين ----
|
// ---- سوبر-أدمن: مالك المنصة يدير كل المستأجرين ----
|
||||||
// كلها خلف PlatformGuard (`x-platform-secret`). حرجٌ أن تكون خارج أدوار
|
// كلها خلف PlatformGuard (`x-platform-secret`). حرجٌ أن تكون خارج أدوار
|
||||||
// المستأجر: بلا هذا الحارس يستطيع أدمن أي مستأجر ترقية اشتراكه بنفسه —
|
// المستأجر: بلا هذا الحارس يستطيع أدمن أي مستأجر ترقية اشتراكه بنفسه —
|
||||||
@@ -35,12 +73,12 @@ export class TenantsController {
|
|||||||
return this.tenants.findAll();
|
return this.tenants.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** كتالوج الميزات القابلة للبيع — تعرضه لوحة السوبر-أدمن. */
|
/** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */
|
||||||
@ApiSecurity('x-platform-secret')
|
@ApiSecurity('x-platform-secret')
|
||||||
@UseGuards(PlatformGuard)
|
@UseGuards(PlatformGuard)
|
||||||
@Get('admin/features')
|
@Get('admin/features')
|
||||||
features() {
|
features() {
|
||||||
return { features: FEATURES };
|
return { features: FEATURES, payment_methods: PAYMENT_METHODS };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiSecurity('x-platform-secret')
|
@ApiSecurity('x-platform-secret')
|
||||||
@@ -50,6 +88,56 @@ export class TenantsController {
|
|||||||
return this.tenants.create(body);
|
return this.tenants.create(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** تزويد مستأجر كامل بضربة واحدة (docs/22 — N1). */
|
||||||
|
@ApiSecurity('x-platform-secret')
|
||||||
|
@UseGuards(PlatformGuard)
|
||||||
|
@Post('admin/tenants/provision')
|
||||||
|
provision(@Body() body: ProvisionDto) {
|
||||||
|
return this.tenants.provision(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ملخّص كامل لمستأجر — للوحة السوبر-أدمن. */
|
||||||
|
@ApiSecurity('x-platform-secret')
|
||||||
|
@UseGuards(PlatformGuard)
|
||||||
|
@Get('admin/tenants/:id/summary')
|
||||||
|
summary(@Param('id') id: string) {
|
||||||
|
return this.tenants.summary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مانيفست توليد التطبيق — يستهلكه سكربت البناء (docs/22 — N3). */
|
||||||
|
@ApiSecurity('x-platform-secret')
|
||||||
|
@UseGuards(PlatformGuard)
|
||||||
|
@Get('admin/tenants/:id/app-manifest')
|
||||||
|
appManifest(@Param('id') id: string) {
|
||||||
|
return this.tenants.appManifest(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** الهوية البصرية (اسم التطبيق · bundle IDs · ألوان). */
|
||||||
|
@ApiSecurity('x-platform-secret')
|
||||||
|
@UseGuards(PlatformGuard)
|
||||||
|
@Patch('admin/tenants/:id/branding')
|
||||||
|
branding(@Param('id') id: string, @Body() body: Record<string, any>) {
|
||||||
|
return this.tenants.setBranding(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وسائل الدفع المفعّلة للمستأجر. */
|
||||||
|
@ApiSecurity('x-platform-secret')
|
||||||
|
@UseGuards(PlatformGuard)
|
||||||
|
@Patch('admin/tenants/:id/payment-methods')
|
||||||
|
paymentMethods(@Param('id') id: string, @Body('methods') methods: string[]) {
|
||||||
|
return this.tenants.setPaymentMethods(id, methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** رفع لوغو المستأجر (docs/22 — N2) → يُخزَّن ويُربط في الهوية البصرية. */
|
||||||
|
@ApiSecurity('x-platform-secret')
|
||||||
|
@UseGuards(PlatformGuard)
|
||||||
|
@Post('admin/tenants/:id/logo')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
/** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */
|
/** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */
|
||||||
@ApiSecurity('x-platform-secret')
|
@ApiSecurity('x-platform-secret')
|
||||||
@UseGuards(PlatformGuard)
|
@UseGuards(PlatformGuard)
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
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 { 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';
|
||||||
|
|
||||||
|
/** ما يدخله السوبر-أدمن لتزويد مستأجر جديد (docs/22 — N1). */
|
||||||
|
export interface ProvisionDto {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
countryPack?: string;
|
||||||
|
plan?: TenantPlan;
|
||||||
|
features?: Record<string, any>;
|
||||||
|
paymentMethods?: string[];
|
||||||
|
branding?: Record<string, any>; // app_name · bundle ids · colors
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantsService {
|
export class TenantsService {
|
||||||
@@ -76,6 +88,99 @@ export class TenantsService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تزويد مستأجر جديد بضربة واحدة (docs/22 — N1): اسم · slug · دولة · باقة ·
|
||||||
|
* ميزات · وسائل دفع · هوية بصرية. البديل عن `create` العاري.
|
||||||
|
*/
|
||||||
|
async provision(dto: ProvisionDto): Promise<Tenant> {
|
||||||
|
if (!dto.name || !dto.slug) throw new BadRequestException('name and slug are required');
|
||||||
|
if (await this.findBySlug(dto.slug)) throw new BadRequestException('slug already taken');
|
||||||
|
|
||||||
|
const countryPack = dto.countryPack ?? 'jo';
|
||||||
|
// وسائل الدفع: ما طلبه السوبر-أدمن (مصفّى للكتالوج) أو افتراضات الدولة.
|
||||||
|
const payments = dto.paymentMethods
|
||||||
|
? validPaymentMethods(dto.paymentMethods)
|
||||||
|
: defaultPaymentMethods(countryPack);
|
||||||
|
|
||||||
|
const tenant = this.repo.create({
|
||||||
|
name: dto.name,
|
||||||
|
slug: dto.slug,
|
||||||
|
countryPack,
|
||||||
|
plan: dto.plan ?? 'launch',
|
||||||
|
features: dto.features ?? {},
|
||||||
|
settings: { payment_methods: payments },
|
||||||
|
branding: dto.branding ?? {},
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(tenant);
|
||||||
|
await this.invalidate(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** الهوية البصرية (docs/22 — N2): app_name · bundle ids · ألوان · مفتاح اللوغو. */
|
||||||
|
async setBranding(tenantId: string, branding: Record<string, any>): Promise<Tenant> {
|
||||||
|
const t = await this.repo.findOne({ where: { id: tenantId } });
|
||||||
|
if (!t) throw new NotFoundException('Tenant not found');
|
||||||
|
t.branding = { ...(t.branding ?? {}), ...branding };
|
||||||
|
const saved = await this.repo.save(t);
|
||||||
|
await this.invalidate(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وسائل الدفع المفعّلة (docs/22 — N1). تُصفّى للكتالوج. */
|
||||||
|
async setPaymentMethods(tenantId: string, methods: string[]): Promise<Tenant> {
|
||||||
|
const t = await this.repo.findOne({ where: { id: tenantId } });
|
||||||
|
if (!t) throw new NotFoundException('Tenant not found');
|
||||||
|
t.settings = { ...(t.settings ?? {}), payment_methods: validPaymentMethods(methods) };
|
||||||
|
const saved = await this.repo.save(t);
|
||||||
|
await this.invalidate(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مانيفست توليد التطبيق (docs/22 — N3): كل ما يحتاجه سكربت البناء لإنتاج
|
||||||
|
* تطبيق المستأجر — الاسم · bundle IDs · اللوغو · الألوان · الميزات · الدفع.
|
||||||
|
*/
|
||||||
|
async appManifest(tenantId: string) {
|
||||||
|
const t = await this.repo.findOne({ where: { id: tenantId } });
|
||||||
|
if (!t) throw new NotFoundException('Tenant not found');
|
||||||
|
const ent = await this.entitlements.forTenant(t.id);
|
||||||
|
const b = t.branding ?? {};
|
||||||
|
return {
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
app_name: b.app_name ?? t.name,
|
||||||
|
bundle_id_android: b.bundle_id_android ?? null,
|
||||||
|
bundle_id_ios: b.bundle_id_ios ?? null,
|
||||||
|
logo_url: b.logo_key ? `/storage/${b.logo_key}` : null,
|
||||||
|
colors: b.colors ?? {},
|
||||||
|
country_pack: t.countryPack,
|
||||||
|
payment_methods: t.settings?.payment_methods ?? [],
|
||||||
|
features: ent?.features ?? {},
|
||||||
|
limits: ent?.limits ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ملخّص كامل للوحة السوبر-أدمن (docs/22 — N1). */
|
||||||
|
async summary(tenantId: string) {
|
||||||
|
const t = await this.repo.findOne({ where: { id: tenantId } });
|
||||||
|
if (!t) throw new NotFoundException('Tenant not found');
|
||||||
|
const ent = await this.entitlements.forTenant(t.id);
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
countryPack: t.countryPack,
|
||||||
|
plan: t.plan,
|
||||||
|
status: t.status,
|
||||||
|
branding: t.branding ?? {},
|
||||||
|
payment_methods: t.settings?.payment_methods ?? [],
|
||||||
|
features: ent?.features ?? {},
|
||||||
|
limits: ent?.limits ?? {},
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** الاستحقاقات الفعّالة — نفس ما يقرأه `FeatureGuard` (docs/19 — K3). */
|
/** الاستحقاقات الفعّالة — نفس ما يقرأه `FeatureGuard` (docs/19 — K3). */
|
||||||
entitlementsOf(tenantId: string) {
|
entitlementsOf(tenantId: string) {
|
||||||
return this.entitlements.forTenant(tenantId);
|
return this.entitlements.forTenant(tenantId);
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Tripz — لوحة السوبر-أدمن</title>
|
||||||
|
<!--
|
||||||
|
لوحة مالك المنصة (docs/22 — N1). SPA مكتفية ذاتياً بلا خطوة بناء:
|
||||||
|
Vanilla JS + fetch. تُخدَم كملف ثابت خلف نفس نطاق الـTLS (Nginx location).
|
||||||
|
المصادقة: سرّ المنصة (x-platform-secret) يُدخَل مرة ويُحفظ في sessionStorage.
|
||||||
|
كل الحماية على السيرفر (PlatformGuard) — هذه واجهة فقط.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0f1420; --card:#1a2130; --line:#2a3346; --fg:#e6ebf5; --mut:#8b97ad; --acc:#4f8cff; --ok:#2ecc71; --bad:#e74c3c; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family:system-ui,'Segoe UI',Tahoma,sans-serif; background:var(--bg); color:var(--fg); }
|
||||||
|
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
|
||||||
|
header h1 { font-size:18px; margin:0; }
|
||||||
|
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
|
||||||
|
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin-bottom:20px; }
|
||||||
|
.card h2 { margin:0 0 16px; font-size:15px; color:var(--mut); font-weight:600; }
|
||||||
|
label { display:block; font-size:13px; color:var(--mut); margin:10px 0 4px; }
|
||||||
|
input, select { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg); font-size:14px; }
|
||||||
|
button { background:var(--acc); color:#fff; border:0; border-radius:8px; padding:10px 18px; font-size:14px; cursor:pointer; }
|
||||||
|
button.ghost { background:transparent; border:1px solid var(--line); color:var(--fg); }
|
||||||
|
button:disabled { opacity:.5; cursor:default; }
|
||||||
|
.row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
||||||
|
.chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
|
||||||
|
.chip { padding:6px 12px; border:1px solid var(--line); border-radius:20px; font-size:13px; cursor:pointer; user-select:none; }
|
||||||
|
.chip.on { background:var(--acc); border-color:var(--acc); }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th, td { text-align:right; padding:10px; border-bottom:1px solid var(--line); font-size:14px; }
|
||||||
|
th { color:var(--mut); font-weight:600; font-size:12px; }
|
||||||
|
.badge { padding:2px 9px; border-radius:6px; font-size:12px; }
|
||||||
|
.badge.active { background:rgba(46,204,113,.15); color:var(--ok); }
|
||||||
|
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:12px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .2s; }
|
||||||
|
.toast.ok { background:var(--ok); } .toast.bad { background:var(--bad); } .toast.show { opacity:1; }
|
||||||
|
.muted { color:var(--mut); font-size:13px; }
|
||||||
|
dialog { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:460px; width:90%; }
|
||||||
|
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🚕 Tripz — لوحة السوبر-أدمن</h1>
|
||||||
|
<span class="muted" id="apiLabel"></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="ghost" id="logoutBtn" style="display:none">تسجيل خروج</button>
|
||||||
|
</header>
|
||||||
|
<div class="wrap">
|
||||||
|
<!-- المستأجرون -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>المستأجرون</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th></th></tr></thead>
|
||||||
|
<tbody id="tenantRows"><tr><td colspan="6" class="muted">…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- تزويد مستأجر جديد -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>تزويد مستأجر جديد</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>الاسم</label><input id="pName" placeholder="Siro" /></div>
|
||||||
|
<div><label>slug (معرّف)</label><input id="pSlug" placeholder="siro" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>الدولة</label>
|
||||||
|
<select id="pCountry"><option value="jo">الأردن</option><option value="sy">سوريا</option><option value="eg">مصر</option></select>
|
||||||
|
</div>
|
||||||
|
<div><label>الباقة</label>
|
||||||
|
<select id="pPlan"><option value="launch">انطلاقة</option><option value="brand">علامة</option><option value="fleet">أسطول+</option><option value="sovereign">سيادة</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>اسم التطبيق</label><input id="pAppName" placeholder="سيرو" />
|
||||||
|
<div class="row">
|
||||||
|
<div><label>bundle ID (أندرويد)</label><input id="pBidA" placeholder="com.siro.rider" /></div>
|
||||||
|
<div><label>bundle ID (iOS)</label><input id="pBidI" placeholder="com.siro.rider" /></div>
|
||||||
|
</div>
|
||||||
|
<label>وسائل الدفع</label><div class="chips" id="payChips"></div>
|
||||||
|
<label>الميزات المدفوعة المفعّلة</label><div class="chips" id="featChips"></div>
|
||||||
|
<div style="margin-top:18px"><button id="provisionBtn">تزويد المستأجر</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<!-- نافذة السرّ -->
|
||||||
|
<dialog id="authDlg">
|
||||||
|
<h2 style="margin-top:0">سرّ المنصة</h2>
|
||||||
|
<p class="muted">أدخل <code>PLATFORM_SECRET</code> للوصول. يُحفظ في هذه الجلسة فقط.</p>
|
||||||
|
<input id="secretInput" type="password" placeholder="platform secret" />
|
||||||
|
<div style="margin-top:16px; text-align:left"><button id="secretOk">دخول</button></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = (location.origin.includes('localhost') ? 'http://localhost:4010/api' : '/api');
|
||||||
|
document.getElementById('apiLabel').textContent = API;
|
||||||
|
let SECRET = sessionStorage.getItem('tripz_platform_secret') || '';
|
||||||
|
let CATALOG = { features: [], payment_methods: [] };
|
||||||
|
const selectedPay = new Set(), selectedFeat = new Set();
|
||||||
|
|
||||||
|
const toast = (msg, ok = true) => {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg; t.className = `toast show ${ok ? 'ok' : 'bad'}`;
|
||||||
|
setTimeout(() => (t.className = 'toast'), 2600);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const res = await fetch(API + path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-platform-secret': SECRET },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) { openAuth(); throw new Error('unauthorized'); }
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuth() { document.getElementById('authDlg').showModal(); }
|
||||||
|
document.getElementById('secretOk').onclick = () => {
|
||||||
|
SECRET = document.getElementById('secretInput').value.trim();
|
||||||
|
sessionStorage.setItem('tripz_platform_secret', SECRET);
|
||||||
|
document.getElementById('authDlg').close();
|
||||||
|
boot();
|
||||||
|
};
|
||||||
|
document.getElementById('logoutBtn').onclick = () => {
|
||||||
|
sessionStorage.removeItem('tripz_platform_secret'); location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderChips(el, items, selected, labelKey = 'name_ar', codeKey = 'code') {
|
||||||
|
el.innerHTML = '';
|
||||||
|
for (const it of items) {
|
||||||
|
const code = typeof it === 'string' ? it : it[codeKey];
|
||||||
|
const label = typeof it === 'string' ? it : (it[labelKey] || code);
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'chip' + (selected.has(code) ? ' on' : '');
|
||||||
|
chip.textContent = label;
|
||||||
|
chip.onclick = () => {
|
||||||
|
selected.has(code) ? selected.delete(code) : selected.add(code);
|
||||||
|
chip.classList.toggle('on');
|
||||||
|
};
|
||||||
|
el.appendChild(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
const rows = document.getElementById('tenantRows');
|
||||||
|
try {
|
||||||
|
const tenants = await api('GET', '/admin/tenants');
|
||||||
|
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="6" class="muted">لا مستأجرين بعد</td></tr>';
|
||||||
|
for (const t of tenants) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${t.name}</td><td class="muted">${t.slug}</td><td>${t.countryPack||t.country_pack||'-'}</td>
|
||||||
|
<td>${t.plan}</td><td><span class="badge active">${t.status}</span></td>
|
||||||
|
<td><button class="ghost" data-id="${t.id}">مانيفست</button></td>`;
|
||||||
|
tr.querySelector('button').onclick = async () => {
|
||||||
|
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
|
||||||
|
alert(JSON.stringify(m, null, 2));
|
||||||
|
};
|
||||||
|
rows.appendChild(tr);
|
||||||
|
}
|
||||||
|
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="6" class="muted">${e.message}</td></tr>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('provisionBtn').onclick = async () => {
|
||||||
|
const btn = document.getElementById('provisionBtn'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const feats = {}; for (const f of selectedFeat) feats[f] = true;
|
||||||
|
await api('POST', '/admin/tenants/provision', {
|
||||||
|
name: document.getElementById('pName').value.trim(),
|
||||||
|
slug: document.getElementById('pSlug').value.trim(),
|
||||||
|
countryPack: document.getElementById('pCountry').value,
|
||||||
|
plan: document.getElementById('pPlan').value,
|
||||||
|
features: feats,
|
||||||
|
paymentMethods: [...selectedPay],
|
||||||
|
branding: {
|
||||||
|
app_name: document.getElementById('pAppName').value.trim(),
|
||||||
|
bundle_id_android: document.getElementById('pBidA').value.trim() || null,
|
||||||
|
bundle_id_ios: document.getElementById('pBidI').value.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast('تمّ تزويد المستأجر ✅');
|
||||||
|
loadTenants();
|
||||||
|
} catch (e) { toast(e.message, false); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
if (!SECRET) { openAuth(); return; }
|
||||||
|
document.getElementById('logoutBtn').style.display = '';
|
||||||
|
try {
|
||||||
|
CATALOG = await api('GET', '/admin/features');
|
||||||
|
renderChips(document.getElementById('payChips'), CATALOG.payment_methods || [], selectedPay);
|
||||||
|
renderChips(document.getElementById('featChips'),
|
||||||
|
(CATALOG.features || []).map((f) => ({ code: f, name_ar: f })), selectedFeat);
|
||||||
|
loadTenants();
|
||||||
|
} catch (e) { /* openAuth already fired on 401 */ }
|
||||||
|
}
|
||||||
|
boot();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -86,3 +86,11 @@ docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
|||||||
-v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs
|
-v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs
|
||||||
# يتطلّب OTP_DEV_MODE=true.
|
# يتطلّب OTP_DEV_MODE=true.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
# تزويد السوبر-أدمن (docs/22 — N): يثبت إنشاء مستأجر + المانيفست + الحارس
|
||||||
|
docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||||||
|
-e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
|
||||||
|
node:22-alpine node /s/provision-test.mjs
|
||||||
|
# (بدّل $PLATFORM_SECRET بقيمته الفعلية من .env)
|
||||||
|
```
|
||||||
|
|||||||
@@ -75,14 +75,16 @@
|
|||||||
| M3 | **السياج الجغرافي** (من `Admin/geofence`): مناطق مسموح/ممنوع · تسعير حسب المنطقة · حظر دخول. |
|
| M3 | **السياج الجغرافي** (من `Admin/geofence`): مناطق مسموح/ممنوع · تسعير حسب المنطقة · حظر دخول. |
|
||||||
| M4 | **تأمين السائق** (`driver_assurance`). |
|
| M4 | **تأمين السائق** (`driver_assurance`). |
|
||||||
|
|
||||||
### المجموعة N — طبقة الـSaaS: السوبر-أدمن وتوليد التطبيق ⭐ (الهدف الأساسي)
|
### المجموعة N — طبقة الـSaaS: السوبر-أدمن وتوليد التطبيق ⭐ (الهدف الأساسي) — 🔵 قيد التنفيذ
|
||||||
| # | البند |
|
| # | البند | الحالة |
|
||||||
|---|-------|
|
|---|-------|--------|
|
||||||
| N1 | **لوحة سوبر-أدمن (ويب)**: إنشاء مستأجر · اسم/لوغو/دولة/دفع/باقة/ميزات · تعديل الاشتراك · مراقبة الأسطول. (الباك إند K جاهز؛ نبني الواجهة.) |
|
| N1 | **لوحة سوبر-أدمن (ويب)**: إنشاء مستأجر · اسم/لوغو/دولة/دفع/باقة/ميزات · مراقبة. | 🔵 الباك إند ✅ (`provision` · `summary` · `app-manifest` · `payment-methods` · `branding` خلف PlatformGuard؛ كتالوج `payment-methods.ts`) + واجهة ويب مكتفية ذاتياً (`dashboards/superadmin-web/index.html`). الباقي: لوحة أغنى (تعديل/تعطيل/GMV). |
|
||||||
| N2 | **رفع اللوغو + توليد الأيقونات/splash** تلقائياً لكل مستأجر. |
|
| N2 | **رفع اللوغو + توليد الأيقونات/splash**. | 🔵 الباك إند ✅ (`POST /admin/tenants/:id/logo` + `GET /tenant/logo/:slug` **عام لأصل الهوية فقط** — لا يخدم مجلد التخزين كاملاً حتى لا تُسرَّب صور الوثائق). توليد الأيقونات نفسه في N3. |
|
||||||
| N3 | **سكربت توليد التطبيق الواحد**: يضبط bundle ID · يبدّل الأيقونات/الـsplash من اللوغو · يحقن FCM · **يربط إضافات Kotlin/C++ الأصيلة** (overlay · method channels · NDK · root) المبنية على bundle ID · يبني · **يسجّل ويدفع عبر Shorebird**. |
|
| N3 | **سكربت توليد التطبيق الواحد**: bundle ID · أيقونات/splash من اللوغو · FCM · **إضافات Kotlin/C++ الأصيلة** (overlay · method channels · NDK) · بناء · **Shorebird**. | 🔵 `scripts/generate-tenant-app.sh` — يجلب المانيفست ويضبط الهيكل؛ خطوات فلاتر موسومة [Q] تُوصَل عند بناء المجموعة Q (المشروع غير موجود بعد). |
|
||||||
| N4 | **لوحة أدمن المستأجر (ويب)**: dispatch · سائقون · رحلات · تعرفة · تقارير · مراجعة وثائق. |
|
| N4 | **لوحة أدمن المستأجر (ويب)**: dispatch · سائقون · رحلات · تعرفة · تقارير · مراجعة وثائق. | ⏳ التالي |
|
||||||
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم · شكاوى · تدخّل. |
|
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم · شكاوى · تدخّل. | ⏳ |
|
||||||
|
|
||||||
|
**اختبار**: `backend/scripts/provision-test.mjs` (يتطلّب `PLATFORM_SECRET`) — يثبت الحارس، التزويد، تصفية الدفع، الاستحقاقات، والمانيفست.
|
||||||
|
|
||||||
### المجموعة O — التحليلات والمواصلات والتسويق
|
### المجموعة O — التحليلات والمواصلات والتسويق
|
||||||
| # | البند |
|
| # | البند |
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==========================================================================
|
||||||
|
# توليد تطبيق مستأجر واحد من مانيفست الباك إند (docs/22 — N3).
|
||||||
|
#
|
||||||
|
# سكربت واحد يأخذ slug المستأجر → يجلب مانيفسته → يضبط bundle ID والأيقونات
|
||||||
|
# والـsplash من اللوغو، يحقن FCM، يربط إضافات Kotlin/C++ الأصيلة (overlay ·
|
||||||
|
# method channels · NDK) المبنية على bundle ID، يبني، ويسجّل/يدفع عبر Shorebird.
|
||||||
|
#
|
||||||
|
# الاستعمال:
|
||||||
|
# PLATFORM_SECRET=xxx API=https://tripz-api.intaleqapp.com/api \
|
||||||
|
# ./scripts/generate-tenant-app.sh <slug> <rider|driver>
|
||||||
|
#
|
||||||
|
# ⚠️ الخطوات الموسومة [Q] تعتمد على مشروع فلاتر (المجموعة Q) الذي لم يُبنَ بعد.
|
||||||
|
# البنية والعقد (المانيفست) جاهزان الآن؛ تُوصَل هذه الخطوات عند وصول Q.
|
||||||
|
# ==========================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SLUG="${1:?الاستعمال: generate-tenant-app.sh <slug> <rider|driver>}"
|
||||||
|
APP_KIND="${2:-rider}" # rider | driver
|
||||||
|
API="${API:?اضبط API=https://tripz-api.intaleqapp.com/api}"
|
||||||
|
SECRET="${PLATFORM_SECRET:?اضبط PLATFORM_SECRET}"
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
APP_DIR="$ROOT/apps/$APP_KIND" # [Q] مشروع فلاتر لكل نوع
|
||||||
|
WORK="$ROOT/.build/$SLUG-$APP_KIND"
|
||||||
|
|
||||||
|
echo "▶ توليد تطبيق [$APP_KIND] للمستأجر [$SLUG]"
|
||||||
|
|
||||||
|
# ── 1. جلب المانيفست من الباك إند (جاهز الآن) ──────────────────────────
|
||||||
|
echo " • جلب المانيفست…"
|
||||||
|
# نحصل على tenant id من القائمة ثم المانيفست
|
||||||
|
TENANT_ID="$(curl -fsSL -H "x-platform-secret: $SECRET" "$API/admin/tenants" \
|
||||||
|
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const t=JSON.parse(s).find(x=>x.slug==='$SLUG');if(!t){console.error('مستأجر غير موجود: $SLUG');process.exit(1)}process.stdout.write(t.id)})")"
|
||||||
|
|
||||||
|
MANIFEST="$(curl -fsSL -H "x-platform-secret: $SECRET" "$API/admin/tenants/$TENANT_ID/app-manifest")"
|
||||||
|
echo "$MANIFEST" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const m=JSON.parse(s);console.log(' الاسم:',m.app_name,'| bundleAndroid:',m.bundle_id_android,'| دولة:',m.country_pack)})"
|
||||||
|
|
||||||
|
val() { echo "$MANIFEST" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{process.stdout.write(String(JSON.parse(s).$1 ?? ''))})"; }
|
||||||
|
APP_NAME="$(val app_name)"
|
||||||
|
BID_ANDROID="$(val bundle_id_android)"
|
||||||
|
BID_IOS="$(val bundle_id_ios)"
|
||||||
|
COUNTRY="$(val country_pack)"
|
||||||
|
|
||||||
|
[ -z "$BID_ANDROID" ] && { echo " ✗ bundle_id_android غير مضبوط في المانيفست — اضبطه من لوحة السوبر-أدمن أولاً"; exit 1; }
|
||||||
|
|
||||||
|
# ── 2. تحضير نسخة عمل من مشروع فلاتر ───────────────────────────────────
|
||||||
|
if [ ! -d "$APP_DIR" ]; then
|
||||||
|
echo " ⚠️ [Q] مشروع فلاتر $APP_DIR غير موجود بعد — تخطّي خطوات البناء."
|
||||||
|
echo " المانيفست جاهز؛ تُوصَل خطوات فلاتر عند بناء المجموعة Q."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"; mkdir -p "$(dirname "$WORK")"; cp -r "$APP_DIR" "$WORK"
|
||||||
|
|
||||||
|
# ── 3. ضبط bundle ID (أندرويد + iOS) ───────────────────────────────────
|
||||||
|
echo " • ضبط bundle ID…"
|
||||||
|
# [Q] applicationId في build.gradle، و PRODUCT_BUNDLE_IDENTIFIER في iOS.
|
||||||
|
# الإضافات الأصيلة (overlay/NDK/method-channel) مبنية على bundle ID —
|
||||||
|
# تُحدَّث مراجعها هنا. راجع apps/*/android و apps/*/ios.
|
||||||
|
# sed -i "s/applicationId \".*\"/applicationId \"$BID_ANDROID\"/" "$WORK/android/app/build.gradle"
|
||||||
|
|
||||||
|
# ── 4. الأيقونات و splash من اللوغو ────────────────────────────────────
|
||||||
|
echo " • توليد الأيقونات و splash من اللوغو…"
|
||||||
|
curl -fsSL "$API/tenant/logo/$SLUG" -o "$WORK/assets/logo.png" 2>/dev/null || echo " (لا لوغو مرفوع — يُستعمل الافتراضي)"
|
||||||
|
# [Q] flutter_launcher_icons + flutter_native_splash من assets/logo.png
|
||||||
|
|
||||||
|
# ── 5. حقن FCM + إعداد المستأجر ────────────────────────────────────────
|
||||||
|
echo " • حقن إعداد المستأجر ($COUNTRY)…"
|
||||||
|
# [Q] نسخ google-services.json / GoogleService-Info.plist الخاص بالمستأجر.
|
||||||
|
# BASE_URL و TENANT عبر --dart-define عند البناء.
|
||||||
|
|
||||||
|
# ── 6. البناء + Shorebird ──────────────────────────────────────────────
|
||||||
|
echo " • البناء وتسجيل Shorebird…"
|
||||||
|
# [Q] أول مرة: shorebird init (يسجّل app_id لهذا bundle ID في حساب Shorebird).
|
||||||
|
# التحديثات: shorebird release / shorebird patch.
|
||||||
|
# ( cd "$WORK" && shorebird release android \
|
||||||
|
# --dart-define=BASE_URL=${API} --dart-define=TENANT=$SLUG )
|
||||||
|
|
||||||
|
echo "✅ اكتمل توليد [$APP_KIND] لـ[$SLUG] (الخطوات الموسومة [Q] معلّقة حتى مشروع فلاتر)."
|
||||||
Reference in New Issue
Block a user