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:
Hamza-Ayed
2026-07-18 03:39:02 +03:00
co-authored by Claude Opus 4.8
parent 4fc79181ff
commit c93be38622
8 changed files with 614 additions and 14 deletions
@@ -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)
+106 -1
View File
@@ -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);