diff --git a/deploy/sync-replica-data.sh b/deploy/sync-replica-data.sh index 07715fc..99b6dcb 100644 --- a/deploy/sync-replica-data.sh +++ b/deploy/sync-replica-data.sh @@ -16,7 +16,7 @@ CONFIG_FILE="${REPLICAS_ENV_FILE:-$API_DIR/deploy/replicas.env}" MODE="${1:---repair-if-needed}" # Tables that must match publisher for public API (order: parents before children). -SYNC_TABLES=(setting_templates tenants settings) +SYNC_TABLES=(setting_templates tenants settings plugin_catalog_items plugin_catalog_prices plugin_purchase_requests) APP_DB_USER="${REPLICA_APP_DB_USER:-montime}" case "$MODE" in diff --git a/prisma/migrations/20260908110000_plugin_store_catalog_tables/migration.sql b/prisma/migrations/20260908110000_plugin_store_catalog_tables/migration.sql new file mode 100644 index 0000000..b1efaf7 --- /dev/null +++ b/prisma/migrations/20260908110000_plugin_store_catalog_tables/migration.sql @@ -0,0 +1,89 @@ +-- Plugin store tables were added to schema.prisma without a CREATE TABLE +-- migration (publisher likely used db push, or never created them at all). +-- Replica ALTER of images/receipt_url then fails. IF NOT EXISTS is safe +-- on publisher if tables already exist. + +DO $$ BEGIN + CREATE TYPE "PluginPurchaseStatus" AS ENUM ('pending', 'approved', 'rejected', 'cancelled'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS "plugin_catalog_items" ( + "id" TEXT NOT NULL, + "setting_key" VARCHAR(50) NOT NULL, + "title_fa" TEXT NOT NULL, + "description" TEXT, + "images" JSONB, + "in_store" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "plugin_catalog_items_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_catalog_items_setting_key_key" + ON "plugin_catalog_items"("setting_key"); + +CREATE TABLE IF NOT EXISTS "plugin_catalog_prices" ( + "id" TEXT NOT NULL, + "catalog_item_id" TEXT NOT NULL, + "duration_days" INTEGER NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "original_price_rials" INTEGER NOT NULL DEFAULT 0, + "final_price_rials" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "plugin_catalog_prices_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_catalog_prices_catalog_item_id_duration_days_key" + ON "plugin_catalog_prices"("catalog_item_id", "duration_days"); + +CREATE TABLE IF NOT EXISTS "plugin_purchase_requests" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "catalog_item_id" TEXT NOT NULL, + "duration_days" INTEGER NOT NULL, + "original_price_rials" INTEGER NOT NULL, + "final_price_rials" INTEGER NOT NULL, + "status" "PluginPurchaseStatus" NOT NULL DEFAULT 'pending', + "auto_cut_on_expiry" BOOLEAN, + "plugin_subscription_id" TEXT, + "receipt_url" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "decided_at" TIMESTAMP(3), + CONSTRAINT "plugin_purchase_requests_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "plugin_purchase_requests_tenant_id_status_idx" + ON "plugin_purchase_requests"("tenant_id", "status"); +CREATE INDEX IF NOT EXISTS "plugin_purchase_requests_catalog_item_id_idx" + ON "plugin_purchase_requests"("catalog_item_id"); +CREATE INDEX IF NOT EXISTS "plugin_purchase_requests_created_at_idx" + ON "plugin_purchase_requests"("created_at"); + +DO $$ BEGIN + ALTER TABLE "plugin_catalog_prices" + ADD CONSTRAINT "plugin_catalog_prices_catalog_item_id_fkey" + FOREIGN KEY ("catalog_item_id") REFERENCES "plugin_catalog_items"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "plugin_purchase_requests" + ADD CONSTRAINT "plugin_purchase_requests_tenant_id_fkey" + FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "plugin_purchase_requests" + ADD CONSTRAINT "plugin_purchase_requests_catalog_item_id_fkey" + FOREIGN KEY ("catalog_item_id") REFERENCES "plugin_catalog_items"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/src/plugins/plugin-store.service.ts b/src/plugins/plugin-store.service.ts index c7a2343..b1849d1 100644 --- a/src/plugins/plugin-store.service.ts +++ b/src/plugins/plugin-store.service.ts @@ -7,6 +7,7 @@ import { NotFoundException, OnModuleDestroy, OnModuleInit, + ServiceUnavailableException, } from '@nestjs/common'; import { PluginCatalogItem, @@ -271,14 +272,14 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { create: { catalogItemId: id, durationDays: price.durationDays, - enabled: price.enabled, - originalPriceRials: price.originalPriceRials, - finalPriceRials: price.finalPriceRials, + enabled: Boolean(price.enabled), + originalPriceRials: Math.trunc(Number(price.originalPriceRials) || 0), + finalPriceRials: Math.trunc(Number(price.finalPriceRials) || 0), }, update: { - enabled: price.enabled, - originalPriceRials: price.originalPriceRials, - finalPriceRials: price.finalPriceRials, + enabled: Boolean(price.enabled), + originalPriceRials: Math.trunc(Number(price.originalPriceRials) || 0), + finalPriceRials: Math.trunc(Number(price.finalPriceRials) || 0), }, }); } @@ -491,33 +492,53 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { return await this.buildRemoteStore(tenantId); } catch (error: any) { if (error instanceof ForbiddenException) throw error; - this.logger.warn(`remoteStore skipped: ${error?.message || error}`); - return { plugin_store_enabled: true, items: [] }; + this.logger.warn(`remoteStore failed: ${error?.message || error}`); + throw new ServiceUnavailableException( + error?.message || 'فروشگاه افزونه در سوپرادمین در دسترس نیست', + ); } } + private isCatalogPriceEnabled(price: Pick | { enabled?: unknown }) { + const flag = (price as { enabled?: unknown }).enabled; + return flag === true || flag === 1 || flag === 'true' || flag === 't'; + } + private async buildRemoteStore(tenantId: string) { + await this.seedCatalog(); const items = await this.prisma.pluginCatalogItem.findMany({ where: { inStore: true }, orderBy: { titleFa: 'asc' }, include: { prices: { orderBy: { durationDays: 'asc' } } }, }); - const subs = await this.prisma.pluginSubscription.findMany({ - where: { tenantId, settingKey: { not: null } }, - }); - const pending = await this.prisma.pluginPurchaseRequest.findMany({ - where: { tenantId, status: 'pending' }, - select: { catalogItemId: true }, - }); + let subs: Array<{ + settingKey: string | null; + expiresAt: Date | null; + isActive: boolean; + }> = []; + let pending: Array<{ catalogItemId: string }> = []; + try { + subs = await this.prisma.pluginSubscription.findMany({ + where: { tenantId, settingKey: { not: null } }, + select: { settingKey: true, expiresAt: true, isActive: true }, + }); + } catch (error: any) { + this.logger.warn(`remoteStore subs skipped: ${error?.message || error}`); + } + try { + pending = await this.prisma.pluginPurchaseRequest.findMany({ + where: { tenantId, status: 'pending' }, + select: { catalogItemId: true }, + }); + } catch (error: any) { + this.logger.warn(`remoteStore pending skipped: ${error?.message || error}`); + } const pendingIds = new Set(pending.map((p) => p.catalogItemId)); const now = Date.now(); - - return { - plugin_store_enabled: true, - items: items - .map((item) => { - const enabledPrices = item.prices.filter((p) => p.enabled); - if (!enabledPrices.length) return null; + const mapped = items + .map((item) => { + const enabledPrices = item.prices.filter((p) => this.isCatalogPriceEnabled(p)); + if (!enabledPrices.length) return null; const sub = subs .filter((s) => s.settingKey === item.settingKey) .sort((a, b) => (b.expiresAt?.getTime() || 0) - (a.expiresAt?.getTime() || 0))[0]; @@ -553,7 +574,15 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { pendingRequest: hasPending, }; }) - .filter(Boolean), + .filter(Boolean); + + this.logger.log( + `remoteStore tenant=${tenantId} catalog=${items.length} listed=${mapped.length}`, + ); + + return { + plugin_store_enabled: true, + items: mapped, }; }