From d77aabdb8c75a7a4956e08c0c570b7660ebeff50 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Wed, 29 Jul 2026 20:50:50 +0330 Subject: [PATCH] fix(commercial): stabilize admin updates and API access --- frontend/app/api/core/[...path]/route.ts | 65 +++++++++++++++++ frontend/lib/api-client.ts | 5 +- frontend/lib/api.ts | 9 ++- frontend/modules/commercial/adapters/admin.ts | 73 ++++++++++++++----- frontend/modules/commercial/adapters/http.ts | 9 ++- .../modules/commercial/admin/resources.ts | 54 +++++++++----- .../commercial/components/BundleCard.tsx | 20 +++-- .../commercial/features/admin-registry.tsx | 60 ++++++++++++--- 8 files changed, 230 insertions(+), 65 deletions(-) create mode 100644 frontend/app/api/core/[...path]/route.ts diff --git a/frontend/app/api/core/[...path]/route.ts b/frontend/app/api/core/[...path]/route.ts new file mode 100644 index 0000000..6a12b00 --- /dev/null +++ b/frontend/app/api/core/[...path]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; + +const UPSTREAM = + process.env.CORE_SERVICE_URL || + process.env.NEXT_PUBLIC_API_BASE_URL || + process.env.NEXT_PUBLIC_BACKEND_URL || + "http://core-service:8000"; + +async function proxy(request: NextRequest, pathSegments: string[]) { + const path = pathSegments.join("/"); + const url = new URL(request.url); + const target = `${UPSTREAM.replace(/\/$/, "")}/${path}${url.search}`; + const headers = new Headers(); + for (const name of ["authorization", "content-type", "x-tenant-id", "accept", "idempotency-key"]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.arrayBuffer(); + try { + const response = await fetch(target, { + method: request.method, + headers, + body, + cache: "no-store", + }); + const responseHeaders = new Headers(); + const contentType = response.headers.get("content-type"); + if (contentType) responseHeaders.set("content-type", contentType); + return new NextResponse(await response.arrayBuffer(), { + status: response.status, + headers: responseHeaders, + }); + } catch { + return NextResponse.json( + { + error: { + code: "core_proxy_unavailable", + message: "ارتباط با سرویس اصلی برقرار نشد. لطفاً دوباره تلاش کنید.", + }, + }, + { status: 502 } + ); + } +} + +type Context = { params: Promise<{ path: string[] }> }; + +export async function GET(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} +export async function POST(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} +export async function PATCH(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} +export async function PUT(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} +export async function DELETE(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} diff --git a/frontend/lib/api-client.ts b/frontend/lib/api-client.ts index 0489469..cf32a26 100644 --- a/frontend/lib/api-client.ts +++ b/frontend/lib/api-client.ts @@ -5,8 +5,9 @@ import { getValidAccessToken } from "./auth"; -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; +const SERVER_API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; +const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core"; export class ApiError extends Error { constructor( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 3f2720f..645a2b6 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -5,10 +5,11 @@ import { getValidAccessToken } from "@/lib/auth"; -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_URL || - "http://localhost:8000"; +const SERVER_API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL || + process.env.NEXT_PUBLIC_BACKEND_URL || + "http://localhost:8000"; +const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core"; export class ApiError extends Error { constructor( diff --git a/frontend/modules/commercial/adapters/admin.ts b/frontend/modules/commercial/adapters/admin.ts index 1aee61a..9f6f64d 100644 --- a/frontend/modules/commercial/adapters/admin.ts +++ b/frontend/modules/commercial/adapters/admin.ts @@ -2,9 +2,8 @@ import type { AdapterResult } from "../types"; import { commercialDelete, commercialGet, - commercialPatch, - commercialPost, - commercialPut, + commercialPatch, + commercialPost, extractItems, failResult, listResult, @@ -13,14 +12,56 @@ import type { CommercialAdminResourceDef } from "../admin/resources"; export type RegistryRecord = Record; -export async function listAdminRegistry( - resource: CommercialAdminResourceDef -): Promise> { - const res = await commercialGet(resource.listPath); - if (!res.ok) { - return failResult(res.availability, res.message, []); - } - const items = extractItems(res.data, resource.envelopeKeys); +export async function listAdminRegistry( + resource: CommercialAdminResourceDef, + tenantId?: string | null +): Promise> { + if (resource.requiresTenant && !tenantId) { + return { + availability: "empty", + data: [], + source: "admin.tenant-selection", + message: "برای مشاهده این بخش، ابتدا یک فضای کاری انتخاب کنید.", + }; + } + if (resource.key === "entitlements") { + return { + availability: "empty", + data: [], + source: "admin.entitlements", + message: "سطوح دسترسی در صفحه اشتراک و محصولات هر فضای کاری نمایش داده می‌شود.", + }; + } + + const query = tenantId ? `tenant_id=${encodeURIComponent(tenantId)}` : ""; + const path = + resource.key === "subscriptions" + ? `/api/v1/commercial/dashboard?${query}` + : resource.key === "activation-logs" + ? `/api/v1/commercial/activation?${query}` + : `${resource.listPath}${query ? `?${query}` : ""}`; + const res = await commercialGet(path); + if (!res.ok) { + return failResult(res.availability, res.message, []); + } + if (resource.key === "subscriptions") { + const subscription = (res.data as { subscription?: RegistryRecord | null }).subscription; + return listResult( + subscription ? [subscription] : [], + "admin.subscriptions", + "اشتراکی ثبت نشده است." + ); + } + if (resource.key === "activation-logs") { + const pipeline = (res.data as { pipeline?: RegistryRecord | null }).pipeline; + const item = pipeline || (res.data as RegistryRecord); + return listResult( + item && Object.keys(item).length ? [item] : [], + "admin.activation", + "فعال‌سازی ثبت نشده است." + ); + } + const items = extractItems(res.data, resource.envelopeKeys); return listResult(items, `admin.${resource.key}`, "موردی در رجیستری نیست"); } @@ -38,12 +79,10 @@ export async function updateAdminRegistryItem( code: string, body: RegistryRecord ): Promise<{ ok: true; data: RegistryRecord } | { ok: false; message: string }> { - const path = `${resource.listPath}/${encodeURIComponent(code)}`; - const patch = await commercialPatch(path, body); - if (patch.ok) return { ok: true, data: patch.data }; - const put = await commercialPut(path, body); - if (put.ok) return { ok: true, data: put.data }; - return { ok: false, message: patch.message || put.message }; + const path = `${resource.listPath}/${encodeURIComponent(code)}`; + const patch = await commercialPatch(path, body); + if (patch.ok) return { ok: true, data: patch.data }; + return { ok: false, message: patch.message }; } export async function deleteAdminRegistryItem( diff --git a/frontend/modules/commercial/adapters/http.ts b/frontend/modules/commercial/adapters/http.ts index b20b385..0a5bc8f 100644 --- a/frontend/modules/commercial/adapters/http.ts +++ b/frontend/modules/commercial/adapters/http.ts @@ -7,10 +7,11 @@ import { getValidAccessToken } from "@/lib/auth"; import type { AdapterResult, RegistryAvailability } from "../types"; -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_URL || - "http://localhost:8000"; +const SERVER_API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL || + process.env.NEXT_PUBLIC_BACKEND_URL || + "http://localhost:8000"; +const API_BASE = typeof window === "undefined" ? SERVER_API_BASE : "/api/core"; async function authHeaders(): Promise { const headers = new Headers({ "Content-Type": "application/json" }); diff --git a/frontend/modules/commercial/admin/resources.ts b/frontend/modules/commercial/admin/resources.ts index 4eecc27..809b041 100644 --- a/frontend/modules/commercial/admin/resources.ts +++ b/frontend/modules/commercial/admin/resources.ts @@ -46,8 +46,10 @@ export interface CommercialAdminResourceDef { /** Item code/id field for display + PATCH/DELETE */ codeField: string; nameField: string; - /** Envelope keys for list extraction */ - envelopeKeys: string[]; + /** Envelope keys for list extraction */ + envelopeKeys: string[]; + requiresTenant?: boolean; + readOnly?: boolean; group: | "catalog" | "commerce" @@ -333,8 +335,8 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ envelopeKeys: ["items", "support_levels"], group: "ops", }, - { - key: "subscriptions", + { + key: "subscriptions", label: "اشتراک‌ها", description: "Tenant subscriptions", listPath: "/api/v1/commercial/subscriptions", @@ -342,10 +344,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ codeField: "id", nameField: "tenant_id", envelopeKeys: ["items", "subscriptions"], - group: "tenant", + group: "tenant", + requiresTenant: true, + readOnly: true, }, - { - key: "licenses", + { + key: "licenses", label: "لایسنس‌ها", description: "Licenses", listPath: "/api/v1/commercial/licenses", @@ -353,10 +357,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ codeField: "license_code", nameField: "display_name", envelopeKeys: ["items", "licenses"], - group: "tenant", + group: "tenant", + requiresTenant: true, + readOnly: true, }, - { - key: "entitlements", + { + key: "entitlements", label: "سطوح دسترسی", description: "Entitlement projections", listPath: "/api/v1/commercial/entitlements", @@ -364,10 +370,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ codeField: "entitlement_code", nameField: "display_name", envelopeKeys: ["items", "entitlements"], - group: "tenant", + group: "tenant", + requiresTenant: true, + readOnly: true, }, - { - key: "activation-logs", + { + key: "activation-logs", label: "لاگ فعال‌سازی", description: "Activation pipelines", listPath: "/api/v1/commercial/activation-logs", @@ -375,10 +383,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ codeField: "correlation_id", nameField: "tenant_id", envelopeKeys: ["items", "pipelines", "activation_logs"], - group: "tenant", + group: "tenant", + requiresTenant: true, + readOnly: true, }, - { - key: "invoices", + { + key: "invoices", label: "فاکتورها", description: "Commercial invoices (published read models)", listPath: "/api/v1/commercial/invoices", @@ -386,10 +396,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ codeField: "invoice_code", nameField: "display_name", envelopeKeys: ["items", "invoices"], - group: "tenant", + group: "tenant", + requiresTenant: true, + readOnly: true, }, - { - key: "transactions", + { + key: "transactions", label: "تراکنش‌ها", description: "Payment history read models (no Payment edits)", listPath: "/api/v1/commercial/transactions", @@ -397,7 +409,9 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [ codeField: "transaction_code", nameField: "display_name", envelopeKeys: ["items", "transactions"], - group: "tenant", + group: "tenant", + requiresTenant: true, + readOnly: true, }, ]; diff --git a/frontend/modules/commercial/components/BundleCard.tsx b/frontend/modules/commercial/components/BundleCard.tsx index 8cbfef0..3bf3c7e 100644 --- a/frontend/modules/commercial/components/BundleCard.tsx +++ b/frontend/modules/commercial/components/BundleCard.tsx @@ -26,9 +26,12 @@ export function BundleCard({ const prices = (bundle.pricing_refs || []) .map((ref) => pricingItems.find((item) => item.pricing_item_code === ref)) .filter((item): item is CommercialPricingItem => Boolean(item)); - const productNames = (bundle.product_codes || []).map( - (code) => products.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code) - ); + const presentedProducts = (bundle.product_codes || []).map((code, index) => ({ + key: `${code}-${index}`, + name: + products.find((product) => product.product_code === code)?.display_name || + humanizeCommercialCode(code), + })); const badge = bundle.recommendation_badge ? enumLabel(bundle.recommendation_badge) : recommendationScore != null @@ -70,13 +73,16 @@ export function BundleCard({ ) : null} - {productNames.length ? ( + {presentedProducts.length ? (

محصولات این بسته

- {productNames.map((name) => ( - - {name} + {presentedProducts.map((product) => ( + + {product.name} ))}
diff --git a/frontend/modules/commercial/features/admin-registry.tsx b/frontend/modules/commercial/features/admin-registry.tsx index 05c0b92..8f2ccea 100644 --- a/frontend/modules/commercial/features/admin-registry.tsx +++ b/frontend/modules/commercial/features/admin-registry.tsx @@ -3,6 +3,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls"; +import { useMe } from "@/hooks/useMe"; import type { CommercialAdminResourceDef } from "../admin/resources"; import { createAdminRegistryItem, @@ -41,6 +42,11 @@ function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): return code == null ? "" : String(code); } +function recordTargetId(item: RegistryRecord, resource: CommercialAdminResourceDef): string { + const target = item.registry_object_id ?? item.id ?? item[resource.codeField]; + return target == null ? "" : String(target); +} + function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord { const seed: RegistryRecord = { [resource.codeField]: "", @@ -195,6 +201,7 @@ function CommercialRecordForm({ } export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) { + const { me } = useMe(); const [result, setResult] = useState | null>(null); const [error, setError] = useState(""); const [msg, setMsg] = useState(""); @@ -212,13 +219,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe const load = useCallback(async () => { setLoading(true); setError(""); - const response = await listAdminRegistry(resource); + const response = await listAdminRegistry(resource, me?.current_tenant_id); setResult(response); if (["unavailable", "permission_denied", "error"].includes(response.availability)) { setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید.")); } setLoading(false); - }, [resource]); + }, [me?.current_tenant_id, resource]); useEffect(() => { void load(); }, [load]); @@ -267,10 +274,35 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe const submitEdit = async (event: FormEvent) => { event.preventDefault(); if (!editItem) return; - const code = recordCode(editItem, resource); - if (!code) return setError("شناسه این مورد پیدا نشد. صفحه را تازه‌سازی کنید."); + const targetId = recordTargetId(editItem, resource); + if (!targetId) return setError("شناسه این مورد پیدا نشد. صفحه را تازه‌سازی کنید."); + const changedFields = Object.fromEntries( + Object.entries(formBody).filter( + ([key, value]) => + ![ + "registry_object_id", + "id", + "kind", + "slug", + "stable_code", + "created_at", + "updated_at", + "created_by", + "updated_by", + "published_at", + "archived_at", + "is_deleted", + "clone_of_id", + ].includes(key) && !Object.is(value, editItem[key]) + ) + ); + if (!Object.keys(changedFields).length) { + setEditItem(null); + setMsg("تغییری برای ذخیره وجود نداشت."); + return; + } setBusy(true); - const response = await updateAdminRegistryItem(resource, code, formBody); + const response = await updateAdminRegistryItem(resource, targetId, changedFields); if (!response.ok) setError(friendlyMessage(response.message, "ذخیره تغییرات انجام نشد. دوباره تلاش کنید.")); else { setEditItem(null); @@ -284,9 +316,9 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return; setBusy(true); for (const item of targets) { - const code = recordCode(item, resource); - if (code) { - const response = await deleteAdminRegistryItem(resource, code); + const targetId = recordTargetId(item, resource); + if (targetId) { + const response = await deleteAdminRegistryItem(resource, targetId); if (!response.ok) { setError(friendlyMessage(response.message, "حذف بعضی موارد انجام نشد.")); break; @@ -303,7 +335,7 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
} + action={
{!resource.readOnly ? : null}
} /> {error ? {error} : null} {msg ? {msg} : null} @@ -318,7 +350,7 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe - {selected.size ? : null} + {selected.size && !resource.readOnly ? : null}
@@ -339,7 +371,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe setSelected((current) => { const next = new Set(current); next.has(code) ? next.delete(code) : next.add(code); return next; })} /> {recordLabel(item, resource)} {enumLabel(status)} -
+ + {resource.readOnly ? ( + نمایش فقط‌خواندنی + ) : ( +
+ )} + ); })}