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 { TenantsService } from './tenants.service';
|
||||
import { TenantsService, ProvisionDto } from './tenants.service';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
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';
|
||||
|
||||
@ApiTags('tenants')
|
||||
@Controller()
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* لوغو المستأجر — **عام** (يظهر في 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`). حرجٌ أن تكون خارج أدوار
|
||||
// المستأجر: بلا هذا الحارس يستطيع أدمن أي مستأجر ترقية اشتراكه بنفسه —
|
||||
@@ -35,12 +73,12 @@ export class TenantsController {
|
||||
return this.tenants.findAll();
|
||||
}
|
||||
|
||||
/** كتالوج الميزات القابلة للبيع — تعرضه لوحة السوبر-أدمن. */
|
||||
/** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/features')
|
||||
features() {
|
||||
return { features: FEATURES };
|
||||
return { features: FEATURES, payment_methods: PAYMENT_METHODS };
|
||||
}
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@@ -50,6 +88,56 @@ export class TenantsController {
|
||||
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')
|
||||
@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 { Repository } from 'typeorm';
|
||||
import { Tenant, TenantPlan } from '../../database/entities/tenant.entity';
|
||||
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
||||
import { defaultPaymentMethods, validPaymentMethods } from '../../common/entitlements/payment-methods';
|
||||
|
||||
/** ما يدخله السوبر-أدمن لتزويد مستأجر جديد (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()
|
||||
export class TenantsService {
|
||||
@@ -76,6 +88,99 @@ export class TenantsService {
|
||||
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). */
|
||||
entitlementsOf(tenantId: string) {
|
||||
return this.entitlements.forTenant(tenantId);
|
||||
|
||||
Reference in New Issue
Block a user