203 lines
7.1 KiB
TypeScript
203 lines
7.1 KiB
TypeScript
import { api } from "@/lib/api";
|
|
import type {
|
|
AdapterResult,
|
|
CommercialProduct,
|
|
CommercialWorkspaceSnapshot,
|
|
SetupChecklistItem,
|
|
TrialState,
|
|
} from "../types";
|
|
import { resolveProductLaunchUrl } from "../types";
|
|
import { evaluateDomainPolicy } from "./policies";
|
|
import { loadCommercialUsage } from "./usage";
|
|
import { commercialGet, commercialPost } from "./http";
|
|
|
|
function computeTrial(sub: {
|
|
status: string;
|
|
trial_ends_at: string | null;
|
|
} | null): TrialState | null {
|
|
if (!sub) return null;
|
|
const active = sub.status === "trialing";
|
|
let days_remaining: number | null = null;
|
|
if (sub.trial_ends_at) {
|
|
const end = new Date(sub.trial_ends_at).getTime();
|
|
days_remaining = Math.max(0, Math.ceil((end - Date.now()) / 86400000));
|
|
}
|
|
return { active, days_remaining, ends_at: sub.trial_ends_at, quotas: [] };
|
|
}
|
|
|
|
/**
|
|
* Entitlement check — prefers commercial entitlement API; never local capability lists.
|
|
* Safe deny when unavailable.
|
|
*/
|
|
export async function checkFeatureAccess(
|
|
tenantId: string,
|
|
featureKey: string
|
|
): Promise<import("../types").EntitlementCheckResult> {
|
|
const commercial = await commercialPost<{
|
|
has_access?: boolean;
|
|
allowed?: boolean;
|
|
reason?: string | null;
|
|
required_plan?: string | null;
|
|
required_capability?: string | null;
|
|
required_bundle?: string | null;
|
|
}>("/api/v1/commercial/entitlements/check", {
|
|
tenant_id: tenantId,
|
|
feature_key: featureKey,
|
|
capability_code: featureKey,
|
|
});
|
|
if (commercial.ok) {
|
|
const allowed = Boolean(commercial.data.has_access ?? commercial.data.allowed);
|
|
return {
|
|
has_access: allowed,
|
|
reason: commercial.data.reason ?? null,
|
|
required_plan: commercial.data.required_plan ?? null,
|
|
required_capability: commercial.data.required_capability ?? null,
|
|
required_bundle: commercial.data.required_bundle ?? null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const r = await api.subscriptions.checkFeature(tenantId, featureKey);
|
|
return { has_access: r.has_access, reason: r.reason };
|
|
} catch {
|
|
return {
|
|
has_access: false,
|
|
reason: commercial.message || "بررسی entitlement در دسترس نیست",
|
|
unavailable: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function loadWorkspaceSnapshot(): Promise<
|
|
AdapterResult<CommercialWorkspaceSnapshot | null>
|
|
> {
|
|
try {
|
|
const tenantCtx = await api.tenantContext.current();
|
|
let subscription: Awaited<ReturnType<typeof api.subscriptions.get>> | null = null;
|
|
try {
|
|
subscription = await api.subscriptions.get(tenantCtx.tenant.id);
|
|
} catch {
|
|
subscription = null;
|
|
}
|
|
|
|
const status = subscription?.status ?? tenantCtx.subscription_status;
|
|
const trial = computeTrial(subscription);
|
|
const usage = await loadCommercialUsage(tenantCtx.tenant.id);
|
|
if (trial && usage.availability === "ready") {
|
|
trial.quotas = usage.data;
|
|
}
|
|
|
|
const domainEval = await evaluateDomainPolicy({
|
|
tenant_id: tenantCtx.tenant.id,
|
|
subscription_status: status,
|
|
});
|
|
|
|
let pinned: string[] = [];
|
|
let recent: string[] = [];
|
|
let quick: CommercialWorkspaceSnapshot["quick_actions"] = [];
|
|
const dash = await commercialGet<{
|
|
pinned_product_codes?: string[];
|
|
recent_product_codes?: string[];
|
|
quick_actions?: CommercialWorkspaceSnapshot["quick_actions"];
|
|
locked_features?: CommercialWorkspaceSnapshot["locked_features"];
|
|
workspace_health?: string | null;
|
|
checklist_completion_percent?: number | null;
|
|
product_codes?: string[];
|
|
capability_codes?: string[];
|
|
bundle_code?: string | null;
|
|
}>(`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantCtx.tenant.id)}`);
|
|
if (dash.ok) {
|
|
pinned = dash.data.pinned_product_codes ?? [];
|
|
recent = dash.data.recent_product_codes ?? [];
|
|
quick = dash.data.quick_actions ?? [];
|
|
}
|
|
|
|
const domainData = domainEval.data;
|
|
const params = domainData.parameters || {};
|
|
|
|
const snapshot: CommercialWorkspaceSnapshot = {
|
|
tenant_id: tenantCtx.tenant.id,
|
|
tenant_name: tenantCtx.tenant.name,
|
|
slug: tenantCtx.tenant.slug,
|
|
status: tenantCtx.tenant.status,
|
|
plan_name: tenantCtx.plan_name,
|
|
subscription_status: status,
|
|
onboarding_completed: tenantCtx.tenant.onboarding_completed,
|
|
primary_domain: tenantCtx.primary_domain,
|
|
domains: tenantCtx.domains.map((d) => ({
|
|
id: d.id,
|
|
domain: d.domain,
|
|
domain_type: d.domain_type,
|
|
is_verified: d.is_verified,
|
|
})),
|
|
role: tenantCtx.role,
|
|
trial,
|
|
custom_domain_unlocked: Boolean(domainData.custom_domain_allowed),
|
|
domain_policy: {
|
|
...domainData,
|
|
ssl_ready:
|
|
domainData.ssl_ready ??
|
|
(typeof params.ssl_ready === "boolean" ? params.ssl_ready : undefined),
|
|
dns_instructions:
|
|
domainData.dns_instructions ??
|
|
(Array.isArray(params.dns_instructions)
|
|
? (params.dns_instructions as NonNullable<
|
|
CommercialWorkspaceSnapshot["domain_policy"]
|
|
>["dns_instructions"])
|
|
: undefined),
|
|
verification_state:
|
|
domainData.verification_state ??
|
|
(typeof params.verification_state === "string" ? params.verification_state : null),
|
|
},
|
|
pinned_product_codes: pinned,
|
|
recent_product_codes: recent,
|
|
quick_actions: quick,
|
|
product_codes: dash.ok ? dash.data.product_codes ?? [] : [],
|
|
capability_codes: dash.ok ? dash.data.capability_codes ?? [] : [],
|
|
bundle_code: dash.ok ? dash.data.bundle_code ?? null : null,
|
|
locked_features: dash.ok ? dash.data.locked_features ?? [] : [],
|
|
workspace_health: dash.ok ? dash.data.workspace_health ?? null : null,
|
|
checklist_completion_percent: dash.ok
|
|
? dash.data.checklist_completion_percent ?? null
|
|
: null,
|
|
};
|
|
|
|
return { availability: "ready", data: snapshot, source: "core.tenant+commercial" };
|
|
} catch (err) {
|
|
return {
|
|
availability: "error",
|
|
data: null,
|
|
message: err instanceof Error ? err.message : "خطا در بارگذاری workspace",
|
|
source: "none",
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Checklist from discovered products' launch metadata — no hardcoded product list. */
|
|
export function buildSetupChecklist(
|
|
products: CommercialProduct[],
|
|
opts?: { installedCodes?: string[] }
|
|
): SetupChecklistItem[] {
|
|
const installed = new Set(opts?.installedCodes ?? []);
|
|
const fromProducts = products
|
|
.filter((p) => p.visibility !== "hidden")
|
|
.map((p) => ({
|
|
id: `product:${p.product_code}`,
|
|
label: p.display_name,
|
|
href: resolveProductLaunchUrl(p),
|
|
product_code: p.product_code,
|
|
done: installed.has(p.product_code),
|
|
required: false,
|
|
}));
|
|
|
|
const workspaceActions =
|
|
products.length === 0
|
|
? [
|
|
{ id: "workspace", label: "Workspace", href: "/dashboard", required: true },
|
|
{ id: "billing", label: "Billing", href: "/billing", required: false },
|
|
]
|
|
: [];
|
|
|
|
return [...workspaceActions, ...fromProducts];
|
|
}
|