chore: consolidate local WIP (repo cleanup 20260707)
Some checks failed
API CI / API Lint (push) Successful in 47s
API CI / API Type Check (push) Failing after 47s
API CI / API Test (push) Successful in 1m0s
API CI / API Build (push) Failing after 50s
API CI / Build Docker Image (push) Has been skipped
Build Crossplane Provider / build (push) Failing after 5m51s
CD Pipeline / Deploy to Staging (push) Failing after 29s
CI Pipeline / Lint and Type Check (push) Failing after 36s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 1m33s
CI Pipeline / Test Frontend (push) Failing after 30s
CI Pipeline / Security Scan (push) Failing after 1m16s
Crossplane Provider CI / Go Test (push) Failing after 3m23s
Crossplane Provider CI / Go Lint (push) Failing after 7m27s
Crossplane Provider CI / Go Build (push) Failing after 3m27s
Deploy to Staging / Deploy to Staging (push) Failing after 30s
Portal CI / Portal Lint (push) Failing after 21s
Portal CI / Portal Type Check (push) Failing after 21s
Portal CI / Portal Test (push) Failing after 21s
Portal CI / Portal Build (push) Failing after 22s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 49s
Test Suite / blockchain-tests (push) Failing after 30s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 23s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 21s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 19s
Validate Configuration Files / validate (push) Failing after 1m52s
CD Pipeline / Deploy to Production (push) Has been skipped
Some checks failed
API CI / API Lint (push) Successful in 47s
API CI / API Type Check (push) Failing after 47s
API CI / API Test (push) Successful in 1m0s
API CI / API Build (push) Failing after 50s
API CI / Build Docker Image (push) Has been skipped
Build Crossplane Provider / build (push) Failing after 5m51s
CD Pipeline / Deploy to Staging (push) Failing after 29s
CI Pipeline / Lint and Type Check (push) Failing after 36s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 1m33s
CI Pipeline / Test Frontend (push) Failing after 30s
CI Pipeline / Security Scan (push) Failing after 1m16s
Crossplane Provider CI / Go Test (push) Failing after 3m23s
Crossplane Provider CI / Go Lint (push) Failing after 7m27s
Crossplane Provider CI / Go Build (push) Failing after 3m27s
Deploy to Staging / Deploy to Staging (push) Failing after 30s
Portal CI / Portal Lint (push) Failing after 21s
Portal CI / Portal Type Check (push) Failing after 21s
Portal CI / Portal Test (push) Failing after 21s
Portal CI / Portal Build (push) Failing after 22s
Test Suite / frontend-tests (push) Failing after 30s
Test Suite / api-tests (push) Failing after 49s
Test Suite / blockchain-tests (push) Failing after 30s
Type Check / type-check (map[directory:. name:root]) (push) Failing after 23s
Type Check / type-check (map[directory:api name:api]) (push) Failing after 21s
Type Check / type-check (map[directory:portal name:portal]) (push) Failing after 19s
Validate Configuration Files / validate (push) Failing after 1m52s
CD Pipeline / Deploy to Production (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
201
api/config/marketplace-entitlement-registry.v1.json
Normal file
201
api/config/marketplace-entitlement-registry.v1.json
Normal file
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"updated": "2026-07-02",
|
||||
"description": "Canonical marketplace entitlement registry — maps product slugs to Keycloak/feature flags, SKUs, and onboarding runbooks",
|
||||
"entitlementModel": "contract_po_first",
|
||||
"billingNote": "Automated billing is roadmap (PUBLIC_SECTOR baseline G2). Grant entitlements via PO/contract then Keycloak flags.",
|
||||
"products": [
|
||||
{
|
||||
"productSlug": "phoenix-ledger-service",
|
||||
"entitlementKeys": ["PHOENIX_LEDGER_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "LEDGER_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-identity-service",
|
||||
"entitlementKeys": ["PHOENIX_IDENTITY_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "IDENTITY_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-wallet-registry",
|
||||
"entitlementKeys": ["PHOENIX_WALLET_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "WALLET_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-tx-orchestrator",
|
||||
"entitlementKeys": ["PHOENIX_TX_ORCH_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "ORCHESTRATION_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-messaging-orchestrator",
|
||||
"entitlementKeys": ["PHOENIX_MSG_ORCH_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "ORCHESTRATION_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-voice-orchestrator",
|
||||
"entitlementKeys": ["PHOENIX_VOICE_ORCH_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "ORCHESTRATION_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-event-bus",
|
||||
"entitlementKeys": ["PHOENIX_EVENT_BUS_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "PLATFORM_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-audit-service",
|
||||
"entitlementKeys": ["PHOENIX_AUDIT_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "PLATFORM_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-observability",
|
||||
"entitlementKeys": ["PHOENIX_OBSERVABILITY_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "PLATFORM_SERVICES",
|
||||
"manifestRef": null,
|
||||
"onboardingRunbook": null,
|
||||
"fulfillmentMode": "self_service"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-b2b-integration-hub",
|
||||
"displayName": "DBIS B2B Integration Hub",
|
||||
"entitlementKeys": ["B2B_HUB_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "PLATFORM_SERVICES",
|
||||
"manifestRef": "config/b2b-integration-hub-marketplace.v1.json",
|
||||
"onboardingRunbook": "docs/03-deployment/B2B_MARKETPLACE_TENANT_ONBOARDING_RUNBOOK.md",
|
||||
"deployScript": "scripts/deployment/deploy-b2b-integration-stack-from-marketplace.sh",
|
||||
"verifyCommand": "pnpm b2b:validate",
|
||||
"catalogSkus": ["b2b-hub-lab", "b2b-hub-dedicated", "b2b-stack-bundle"],
|
||||
"fulfillmentMode": "operator_provisioned",
|
||||
"surfaces": {
|
||||
"productPath": "/marketplace/products/phoenix-b2b-integration-hub",
|
||||
"managePath": "/marketplace/entitlements/phoenix-b2b-integration-hub"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-peppol-hosted-connector",
|
||||
"displayName": "PEPPOL Hosted Connector",
|
||||
"entitlementKeys": ["PEPPOL_HOSTED_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "PLATFORM_SERVICES",
|
||||
"manifestRef": "config/peppol-hosted-marketplace.v1.json",
|
||||
"onboardingRunbook": "docs/03-deployment/B2B_MARKETPLACE_TENANT_ONBOARDING_RUNBOOK.md",
|
||||
"catalogSkus": ["peppol-hosted-connector"],
|
||||
"fulfillmentMode": "operator_provisioned",
|
||||
"surfaces": {
|
||||
"productPath": "/marketplace/products/phoenix-peppol-hosted-connector",
|
||||
"managePath": "/marketplace/entitlements/phoenix-peppol-hosted-connector"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-ebics-banking-gateway",
|
||||
"displayName": "EBICS Banking Gateway",
|
||||
"entitlementKeys": ["EBICS_GATEWAY_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "FINANCIAL_MESSAGING",
|
||||
"manifestRef": "config/ebics-gateway-marketplace.v1.json",
|
||||
"onboardingRunbook": "docs/03-deployment/B2B_MARKETPLACE_TENANT_ONBOARDING_RUNBOOK.md",
|
||||
"catalogSkus": ["ebics-gateway-dedicated"],
|
||||
"fulfillmentMode": "operator_provisioned",
|
||||
"surfaces": {
|
||||
"productPath": "/marketplace/products/phoenix-ebics-banking-gateway",
|
||||
"managePath": "/marketplace/entitlements/phoenix-ebics-banking-gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-chain138-participant-onboard",
|
||||
"displayName": "Chain 138 Participant Onboarding",
|
||||
"entitlementKeys": ["CHAIN138_ONBOARD_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "BLOCKCHAIN_STACK",
|
||||
"manifestRef": "config/chain138-onboard-marketplace.v1.json",
|
||||
"onboardingRunbook": "docs/03-deployment/CHAIN138_ONBOARD_PORTAL_RUNBOOK.md",
|
||||
"integrationDoc": "docs/11-references/CHAIN138_ONBOARD_SANKOFA_MARKETPLACE_INTEGRATION.md",
|
||||
"catalogSkus": [
|
||||
"chain138-onboard-portal-dedicated",
|
||||
"chain138-besu-sentry-order",
|
||||
"chain138-besu-core-rpc-order",
|
||||
"chain138-besu-public-rpc-order",
|
||||
"chain138-besu-private-rpc-order",
|
||||
"chain138-besu-permissioned-rpc-order"
|
||||
],
|
||||
"fulfillmentMode": "request_only",
|
||||
"surfaces": {
|
||||
"productPath": "/marketplace/products/phoenix-chain138-participant-onboard",
|
||||
"managePath": "/marketplace/entitlements/phoenix-chain138-participant-onboard"
|
||||
}
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-x-road-interoperability",
|
||||
"displayName": "Phoenix X-Road Interoperability",
|
||||
"entitlementKeys": ["XROAD_INTEROP_ENTITLED"],
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"category": "INTEROPERABILITY_SERVICES",
|
||||
"manifestRef": null,
|
||||
"programRef": "config/public-sector-program-manifest.json#x-road-global",
|
||||
"onboardingRunbook": "docs/11-references/X_ROAD_SANKOFA_ECOSYSTEM_INTEGRATION.md",
|
||||
"catalogSkus": ["x-road-interop-lab", "x-road-security-server-dedicated", "x-road-federation-connector"],
|
||||
"fulfillmentMode": "request_only",
|
||||
"status": "preview"
|
||||
},
|
||||
{
|
||||
"productSlug": "phoenix-aegis-vault-cti",
|
||||
"displayName": "AEGIS-VAULT by CyberSecur Global",
|
||||
"entitlementKeys": [
|
||||
"AEGIS_VAULT_ENTITLED",
|
||||
"AEGIS_VAULT_ESSENTIALS",
|
||||
"AEGIS_VAULT_PRO",
|
||||
"AEGIS_VAULT_ENTERPRISE"
|
||||
],
|
||||
"tierEntitlements": {
|
||||
"aegis-vault-essentials": "AEGIS_VAULT_ESSENTIALS",
|
||||
"aegis-vault-professional": "AEGIS_VAULT_PRO",
|
||||
"aegis-vault-enterprise": "AEGIS_VAULT_ENTERPRISE"
|
||||
},
|
||||
"publisher": "phoenix-cloud-services",
|
||||
"partnerPublisher": "CyberSecur Global (AF-CSG-001)",
|
||||
"category": "SECURITY_SERVICES",
|
||||
"manifestRef": "config/aegis-vault-cti-marketplace.v1.json",
|
||||
"onboardingRunbook": "docs/03-deployment/AEGIS_VAULT_MARKETPLACE_TENANT_ONBOARDING_RUNBOOK.md",
|
||||
"deployScript": "scripts/deployment/deploy-aegis-vault-from-marketplace.sh",
|
||||
"verifyCommand": "pnpm aegis-vault:validate",
|
||||
"catalogSkus": ["aegis-vault-essentials", "aegis-vault-professional", "aegis-vault-enterprise"],
|
||||
"fulfillmentMode": "operator_provisioned",
|
||||
"surfaces": {
|
||||
"productPath": "/marketplace/products/phoenix-aegis-vault-cti",
|
||||
"managePath": "/marketplace/entitlements/phoenix-aegis-vault-cti"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
3
api/pnpm-workspace.yaml
Normal file
3
api/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
'@apollo/protobufjs': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
4
api/postcss.config.js
Normal file
4
api/postcss.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Node API tests only — prevent Vitest from loading ../postcss.config.js (tailwind). */
|
||||
export default {
|
||||
plugins: {},
|
||||
}
|
||||
136
api/scripts/fix-ts6133.py
Normal file
136
api/scripts/fix-ts6133.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix TS6133 unused variable/import errors reported by tsc."""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run_tsc() -> list[str]:
|
||||
result = subprocess.run(
|
||||
[str(ROOT / "node_modules/.bin/tsc"), "--noEmit"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
lines = (result.stdout + result.stderr).splitlines()
|
||||
return [l for l in lines if "error TS6133" in l or "error TS6198" in l]
|
||||
|
||||
|
||||
def parse_error(line: str) -> tuple[str, int, str, str] | None:
|
||||
m = re.match(
|
||||
r"^(src/[^:]+)\((\d+),(\d+)\): error TS(6133|6198): '([^']+)'",
|
||||
line,
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
path, row, col, code, name = m.groups()
|
||||
return path, int(row), int(col), name, code
|
||||
|
||||
|
||||
def fix_file(path: Path, row: int, name: str, code: str) -> bool:
|
||||
lines = path.read_text().splitlines(keepends=True)
|
||||
if row < 1 or row > len(lines):
|
||||
return False
|
||||
idx = row - 1
|
||||
line = lines[idx]
|
||||
|
||||
if code == "6198":
|
||||
# All destructured elements unused — prefix each binding with _
|
||||
new_line = re.sub(r"\{([^}]+)\}", lambda m: "{" + ", ".join(
|
||||
(p.strip() if p.strip().startswith("_") else "_" + p.strip().split(":")[0].strip() + (":" + ":".join(p.strip().split(":")[1:]) if ":" in p.strip() else ""))
|
||||
for p in m.group(1).split(",")
|
||||
) + "}", line, count=1)
|
||||
if new_line != line:
|
||||
lines[idx] = new_line
|
||||
path.write_text("".join(lines))
|
||||
return True
|
||||
return False
|
||||
|
||||
# Unused import — remove named import or whole import line
|
||||
if re.search(rf"\bimport\b.*\b{re.escape(name)}\b", line):
|
||||
if re.match(r"import\s+\{" , line):
|
||||
names = re.search(r"\{([^}]+)\}", line)
|
||||
if names:
|
||||
parts = [p.strip() for p in names.group(1).split(",")]
|
||||
kept = []
|
||||
for p in parts:
|
||||
base = p.split(" as ")[0].strip()
|
||||
if base != name:
|
||||
kept.append(p)
|
||||
if not kept:
|
||||
lines[idx] = ""
|
||||
path.write_text("".join(lines))
|
||||
return True
|
||||
new_import = re.sub(r"\{[^}]+\}", "{" + ", ".join(kept) + "}", line)
|
||||
lines[idx] = new_import
|
||||
path.write_text("".join(lines))
|
||||
return True
|
||||
if re.match(rf"import\s+{re.escape(name)}\s", line) or re.match(rf"import\s+\*\s+as\s+{re.escape(name)}\s", line):
|
||||
lines[idx] = ""
|
||||
path.write_text("".join(lines))
|
||||
return True
|
||||
|
||||
# Parameter or local — prefix with underscore
|
||||
patterns = [
|
||||
(rf"\(({re.escape(name)})\:", rf"(_{name}:"),
|
||||
(rf",\s*{re.escape(name)}\:", rf", _{name}:"),
|
||||
(rf"\(\s*{re.escape(name)}\s*\)", rf"(_{name})"),
|
||||
(rf"\(\s*{re.escape(name)}\s*,", rf"(_{name},"),
|
||||
(rf",\s*{re.escape(name)}\s*\)", rf", _{name})"),
|
||||
(rf"\b(const|let)\s+{re.escape(name)}\b", rf"\1 _{name}"),
|
||||
(rf"\basync\s+{re.escape(name)}\b", rf"async _{name}"),
|
||||
(rf"function\s+{re.escape(name)}\b", rf"function _{name}"),
|
||||
]
|
||||
new_line = line
|
||||
for pat, repl in patterns:
|
||||
new_line2 = re.sub(pat, repl, new_line)
|
||||
if new_line2 != new_line:
|
||||
new_line = new_line2
|
||||
break
|
||||
else:
|
||||
# fallback: word boundary prefix in destructuring { name }
|
||||
new_line = re.sub(rf"\{{([^}}]*)\b{re.escape(name)}\b", rf"{{\1_{name}", line, count=1)
|
||||
if new_line == line:
|
||||
new_line = re.sub(rf"\b{re.escape(name)}\b", f"_{name}", line, count=1)
|
||||
|
||||
if new_line != line:
|
||||
lines[idx] = new_line
|
||||
path.write_text("".join(lines))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fixed = 0
|
||||
for _ in range(5):
|
||||
errors = run_tsc()
|
||||
if not errors:
|
||||
break
|
||||
changed = False
|
||||
seen: set[tuple] = set()
|
||||
for err in errors:
|
||||
parsed = parse_error(err)
|
||||
if not parsed:
|
||||
continue
|
||||
rel, row, col, name, code = parsed
|
||||
key = (rel, row, name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
path = ROOT / rel
|
||||
if fix_file(path, row, name, code):
|
||||
fixed += 1
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
print(f"Fixed {fixed} TS6133/6198 issues")
|
||||
remaining = run_tsc()
|
||||
print(f"Remaining TS6133/6198: {len(remaining)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
22
api/src/__tests__/helpers/context.ts
Normal file
22
api/src/__tests__/helpers/context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { TenantContext } from '../../middleware/tenant-auth'
|
||||
import type { Context } from '../../types/context'
|
||||
|
||||
/** Minimal tenant context for unit tests. */
|
||||
export function testTenantContext(overrides: Partial<TenantContext> = {}): TenantContext {
|
||||
return {
|
||||
userId: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
role: 'USER',
|
||||
permissions: {},
|
||||
isSystemAdmin: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a GraphQL Context stub for tests. */
|
||||
export function testContext(overrides: Partial<Context> = {}): Context {
|
||||
return {
|
||||
db: {} as Context['db'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
126
api/src/__tests__/helpers/mock-db.ts
Normal file
126
api/src/__tests__/helpers/mock-db.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/** Default mock query handler for integration tests (no live Postgres). */
|
||||
export function createIntegrationMockQuery() {
|
||||
const metricsRows = Array.from({ length: 20 }, (_, i) => ({
|
||||
timestamp: new Date(Date.now() - (20 - i) * 60000),
|
||||
value: (50 + (i === 15 ? 150 : 0)).toString(),
|
||||
labels: {},
|
||||
}))
|
||||
|
||||
return vi.fn().mockImplementation((sql: string) => {
|
||||
const q = sql.toLowerCase()
|
||||
|
||||
if (q.includes('from metrics')) {
|
||||
return Promise.resolve({ rows: metricsRows })
|
||||
}
|
||||
if (q.includes('from tenants')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: 'tenant-1',
|
||||
quota_limits: {
|
||||
compute: { vcpu: 64, memory: 256, instances: 100 },
|
||||
storage: { total: 10000 },
|
||||
network: { bandwidth: 10000, egress: 5000 },
|
||||
},
|
||||
metadata: { region: 'US' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (q.includes('from pillars')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: 'pillar-1',
|
||||
code: 'OPERATIONAL_EXCELLENCE',
|
||||
name: 'Operational Excellence',
|
||||
description: 'Ops pillar',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (q.includes('from controls')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: 'control-1',
|
||||
pillar_id: 'pillar-1',
|
||||
code: 'C1',
|
||||
name: 'Control 1',
|
||||
description: 'Test control',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (q.includes('insert into resources')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: 'resource-new-1',
|
||||
name: 'test-vm',
|
||||
type: 'VM',
|
||||
status: 'PENDING',
|
||||
site_id: 'site-1',
|
||||
tenant_id: null,
|
||||
metadata: JSON.stringify({ cpu: 4, memory: '8Gi' }),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (q.includes('from resources') && q.includes('where id')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: 'resource-new-1',
|
||||
name: 'test-vm',
|
||||
type: 'VM',
|
||||
status: 'PENDING',
|
||||
site_id: 'site-1',
|
||||
tenant_id: null,
|
||||
metadata: JSON.stringify({ cpu: 4, memory: '8Gi' }),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (q.includes('from resources')) {
|
||||
return Promise.resolve({ rows: [] })
|
||||
}
|
||||
if (q.includes('from sites')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
id: 'site-1',
|
||||
name: 'Site 1',
|
||||
region: 'us-east-1',
|
||||
status: 'ACTIVE',
|
||||
metadata: '{}',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
if (q.includes('from findings')) {
|
||||
return Promise.resolve({ rows: [] })
|
||||
}
|
||||
if (q.includes('insert into') || q.includes('update ')) {
|
||||
return Promise.resolve({ rows: [] })
|
||||
}
|
||||
|
||||
return Promise.resolve({ rows: [] })
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockDbPool(query = createIntegrationMockQuery()) {
|
||||
return { query }
|
||||
}
|
||||
429
api/src/db/migrations/027_client_subscription_entitlements.ts
Normal file
429
api/src/db/migrations/027_client_subscription_entitlements.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { Migration } from '../migrate.js'
|
||||
|
||||
export const up: Migration['up'] = async (db) => {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
primary_domain VARCHAR(255),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('ACTIVE', 'PENDING', 'SUSPENDED', 'DELETED')),
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS client_users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'CLIENT_USER'
|
||||
CHECK (role IN ('CLIENT_OWNER', 'CLIENT_ADMIN', 'CLIENT_BILLING_ADMIN', 'CLIENT_USER', 'CLIENT_VIEWER')),
|
||||
permissions JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE (client_id, user_id)
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE tenants
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS service_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
offer_code VARCHAR(255) NOT NULL,
|
||||
offer_name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL
|
||||
CHECK (offer_type IN ('native', 'partner')),
|
||||
commercial_model VARCHAR(50) NOT NULL
|
||||
CHECK (commercial_model IN ('IRU', 'SaaS', 'managed_service', 'reserved_capacity', 'custom')),
|
||||
support_owner VARCHAR(50) NOT NULL
|
||||
CHECK (support_owner IN ('sankofa', 'partner', 'shared')),
|
||||
fulfillment_mode VARCHAR(50) NOT NULL
|
||||
CHECK (fulfillment_mode IN ('self_service', 'request_only', 'operator_provisioned')),
|
||||
billing_mode VARCHAR(50) NOT NULL
|
||||
CHECK (billing_mode IN ('subscription', 'contract', 'quote')),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'ACTIVE', 'SUSPENDED', 'CANCELLED', 'PREVIEW', 'REQUEST_ONLY')),
|
||||
activated_at TIMESTAMP WITH TIME ZONE,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS entitlements (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
subscription_id UUID NOT NULL REFERENCES service_subscriptions(id) ON DELETE CASCADE,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
entitlement_key VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'ACTIVE', 'SUSPENDED', 'REVOKED')),
|
||||
scope JSONB DEFAULT '{}'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE billing_accounts
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE billing_accounts
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE usage_records
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE usage_records
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE cost_allocations
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE cost_allocations
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE billing_alerts
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE billing_alerts
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE budgets
|
||||
ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES clients(id) ON DELETE SET NULL
|
||||
`)
|
||||
await db.query(`
|
||||
ALTER TABLE budgets
|
||||
ADD COLUMN IF NOT EXISTS subscription_id UUID REFERENCES service_subscriptions(id) ON DELETE SET NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
INSERT INTO clients (name, primary_domain, status, metadata)
|
||||
SELECT
|
||||
t.name,
|
||||
t.domain,
|
||||
CASE
|
||||
WHEN t.status = 'ACTIVE' THEN 'ACTIVE'
|
||||
WHEN t.status = 'SUSPENDED' THEN 'SUSPENDED'
|
||||
WHEN t.status = 'DELETED' THEN 'DELETED'
|
||||
ELSE 'PENDING'
|
||||
END,
|
||||
jsonb_build_object(
|
||||
'backfilledFromTenantId', t.id,
|
||||
'source', '027_client_subscription_entitlements'
|
||||
)
|
||||
FROM tenants t
|
||||
LEFT JOIN clients c ON c.name = t.name
|
||||
WHERE c.id IS NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE tenants t
|
||||
SET client_id = c.id
|
||||
FROM clients c
|
||||
WHERE t.client_id IS NULL
|
||||
AND c.name = t.name
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
INSERT INTO client_users (client_id, user_id, role, permissions)
|
||||
SELECT
|
||||
t.client_id,
|
||||
tu.user_id,
|
||||
CASE tu.role
|
||||
WHEN 'TENANT_OWNER' THEN 'CLIENT_OWNER'
|
||||
WHEN 'TENANT_ADMIN' THEN 'CLIENT_ADMIN'
|
||||
WHEN 'TENANT_BILLING_ADMIN' THEN 'CLIENT_BILLING_ADMIN'
|
||||
WHEN 'TENANT_VIEWER' THEN 'CLIENT_VIEWER'
|
||||
ELSE 'CLIENT_USER'
|
||||
END,
|
||||
COALESCE(tu.permissions, '{}'::jsonb)
|
||||
FROM tenant_users tu
|
||||
JOIN tenants t ON t.id = tu.tenant_id
|
||||
LEFT JOIN client_users cu ON cu.client_id = t.client_id AND cu.user_id = tu.user_id
|
||||
WHERE t.client_id IS NOT NULL
|
||||
AND cu.id IS NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
INSERT INTO service_subscriptions (
|
||||
client_id,
|
||||
tenant_id,
|
||||
offer_code,
|
||||
offer_name,
|
||||
offer_type,
|
||||
commercial_model,
|
||||
support_owner,
|
||||
fulfillment_mode,
|
||||
billing_mode,
|
||||
status,
|
||||
activated_at,
|
||||
metadata
|
||||
)
|
||||
SELECT
|
||||
t.client_id,
|
||||
t.id,
|
||||
'tenant-workspace',
|
||||
'Tenant Workspace',
|
||||
'native',
|
||||
'custom',
|
||||
'sankofa',
|
||||
'operator_provisioned',
|
||||
'subscription',
|
||||
CASE
|
||||
WHEN t.status = 'ACTIVE' THEN 'ACTIVE'
|
||||
WHEN t.status = 'SUSPENDED' THEN 'SUSPENDED'
|
||||
ELSE 'PENDING'
|
||||
END,
|
||||
CASE WHEN t.status = 'ACTIVE' THEN NOW() ELSE NULL END,
|
||||
jsonb_build_object(
|
||||
'tier', t.tier,
|
||||
'backfilledFromTenantId', t.id,
|
||||
'source', '027_client_subscription_entitlements'
|
||||
)
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE t.client_id IS NOT NULL
|
||||
AND s.id IS NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
INSERT INTO entitlements (
|
||||
subscription_id,
|
||||
tenant_id,
|
||||
entitlement_key,
|
||||
status,
|
||||
scope,
|
||||
metadata
|
||||
)
|
||||
SELECT
|
||||
s.id,
|
||||
s.tenant_id,
|
||||
'tenant.workspace',
|
||||
CASE
|
||||
WHEN s.status = 'ACTIVE' THEN 'ACTIVE'
|
||||
WHEN s.status = 'SUSPENDED' THEN 'SUSPENDED'
|
||||
ELSE 'PENDING'
|
||||
END,
|
||||
jsonb_build_object(
|
||||
'offerCode', s.offer_code,
|
||||
'offerType', s.offer_type,
|
||||
'commercialModel', s.commercial_model
|
||||
),
|
||||
jsonb_build_object(
|
||||
'source', '027_client_subscription_entitlements'
|
||||
)
|
||||
FROM service_subscriptions s
|
||||
LEFT JOIN entitlements e
|
||||
ON e.subscription_id = s.id
|
||||
AND e.entitlement_key = 'tenant.workspace'
|
||||
WHERE s.offer_code = 'tenant-workspace'
|
||||
AND e.id IS NULL
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE billing_accounts ba
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE ba.tenant_id = t.id
|
||||
AND (ba.client_id IS NULL OR ba.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE usage_records ur
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE ur.tenant_id = t.id
|
||||
AND (ur.client_id IS NULL OR ur.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE cost_allocations ca
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE ca.tenant_id = t.id
|
||||
AND (ca.client_id IS NULL OR ca.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE invoices i
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE i.tenant_id = t.id
|
||||
AND (i.client_id IS NULL OR i.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE payments p
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE p.tenant_id = t.id
|
||||
AND (p.client_id IS NULL OR p.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE billing_alerts ba
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE ba.tenant_id = t.id
|
||||
AND (ba.client_id IS NULL OR ba.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
UPDATE budgets b
|
||||
SET
|
||||
client_id = t.client_id,
|
||||
subscription_id = s.id
|
||||
FROM tenants t
|
||||
LEFT JOIN service_subscriptions s
|
||||
ON s.tenant_id = t.id
|
||||
AND s.offer_code = 'tenant-workspace'
|
||||
WHERE b.tenant_id = t.id
|
||||
AND (b.client_id IS NULL OR b.subscription_id IS NULL)
|
||||
`)
|
||||
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_tenants_client_id ON tenants(client_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_clients_primary_domain ON clients(primary_domain) WHERE primary_domain IS NOT NULL`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_client_users_client_id ON client_users(client_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_client_users_user_id ON client_users(user_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_service_subscriptions_client_id ON service_subscriptions(client_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_service_subscriptions_tenant_id ON service_subscriptions(tenant_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_service_subscriptions_status ON service_subscriptions(status)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_entitlements_subscription_id ON entitlements(subscription_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_entitlements_tenant_id ON entitlements(tenant_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_usage_records_client_id ON usage_records(client_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_invoices_client_id ON invoices(client_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_budgets_client_id ON budgets(client_id)`)
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_billing_alerts_client_id ON billing_alerts(client_id)`)
|
||||
|
||||
await db.query(`
|
||||
CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE TRIGGER update_client_users_updated_at BEFORE UPDATE ON client_users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE TRIGGER update_service_subscriptions_updated_at BEFORE UPDATE ON service_subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE TRIGGER update_entitlements_updated_at BEFORE UPDATE ON entitlements
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()
|
||||
`)
|
||||
}
|
||||
|
||||
export const down: Migration['down'] = async (db) => {
|
||||
await db.query(`DROP TRIGGER IF EXISTS update_entitlements_updated_at ON entitlements`)
|
||||
await db.query(`DROP TRIGGER IF EXISTS update_service_subscriptions_updated_at ON service_subscriptions`)
|
||||
await db.query(`DROP TRIGGER IF EXISTS update_client_users_updated_at ON client_users`)
|
||||
await db.query(`DROP TRIGGER IF EXISTS update_clients_updated_at ON clients`)
|
||||
|
||||
await db.query(`DROP INDEX IF EXISTS idx_billing_alerts_client_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_budgets_client_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_invoices_client_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_usage_records_client_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_entitlements_tenant_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_entitlements_subscription_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_service_subscriptions_status`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_service_subscriptions_tenant_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_service_subscriptions_client_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_client_users_user_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_client_users_client_id`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_clients_primary_domain`)
|
||||
await db.query(`DROP INDEX IF EXISTS idx_tenants_client_id`)
|
||||
|
||||
await db.query(`ALTER TABLE budgets DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE budgets DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE billing_alerts DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE billing_alerts DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE payments DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE payments DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE invoices DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE invoices DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE cost_allocations DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE cost_allocations DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE usage_records DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE usage_records DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE billing_accounts DROP COLUMN IF EXISTS subscription_id`)
|
||||
await db.query(`ALTER TABLE billing_accounts DROP COLUMN IF EXISTS client_id`)
|
||||
await db.query(`ALTER TABLE tenants DROP COLUMN IF EXISTS client_id`)
|
||||
|
||||
await db.query(`DROP TABLE IF EXISTS entitlements`)
|
||||
await db.query(`DROP TABLE IF EXISTS service_subscriptions`)
|
||||
await db.query(`DROP TABLE IF EXISTS client_users`)
|
||||
await db.query(`DROP TABLE IF EXISTS clients`)
|
||||
}
|
||||
58
api/src/db/migrations/028_marketplace_extended_categories.ts
Normal file
58
api/src/db/migrations/028_marketplace_extended_categories.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Pool } from 'pg'
|
||||
|
||||
/**
|
||||
* Extend products.category CHECK for partner/security/interoperability offerings.
|
||||
*/
|
||||
export async function up(db: Pool): Promise<void> {
|
||||
await db.query(`
|
||||
ALTER TABLE products
|
||||
DROP CONSTRAINT IF EXISTS products_category_check
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE products
|
||||
ADD CONSTRAINT products_category_check
|
||||
CHECK (category IN (
|
||||
'COMPUTE',
|
||||
'NETWORK_INFRA',
|
||||
'BLOCKCHAIN_STACK',
|
||||
'BLOCKCHAIN_TOOLS',
|
||||
'FINANCIAL_MESSAGING',
|
||||
'INTERNET_REGISTRY',
|
||||
'AI_LLM_AGENT',
|
||||
'LEDGER_SERVICES',
|
||||
'IDENTITY_SERVICES',
|
||||
'WALLET_SERVICES',
|
||||
'ORCHESTRATION_SERVICES',
|
||||
'PLATFORM_SERVICES',
|
||||
'SECURITY_SERVICES',
|
||||
'INTEROPERABILITY_SERVICES'
|
||||
))
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down(db: Pool): Promise<void> {
|
||||
await db.query(`
|
||||
ALTER TABLE products
|
||||
DROP CONSTRAINT IF EXISTS products_category_check
|
||||
`)
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE products
|
||||
ADD CONSTRAINT products_category_check
|
||||
CHECK (category IN (
|
||||
'COMPUTE',
|
||||
'NETWORK_INFRA',
|
||||
'BLOCKCHAIN_STACK',
|
||||
'BLOCKCHAIN_TOOLS',
|
||||
'FINANCIAL_MESSAGING',
|
||||
'INTERNET_REGISTRY',
|
||||
'AI_LLM_AGENT',
|
||||
'LEDGER_SERVICES',
|
||||
'IDENTITY_SERVICES',
|
||||
'WALLET_SERVICES',
|
||||
'ORCHESTRATION_SERVICES',
|
||||
'PLATFORM_SERVICES'
|
||||
))
|
||||
`)
|
||||
}
|
||||
55
api/src/db/seeds/bootstrap_portal_dashboard.ts
Normal file
55
api/src/db/seeds/bootstrap_portal_dashboard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'dotenv/config'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getDb } from '../index.js'
|
||||
import { operatingModelService } from '../../services/operating-model.js'
|
||||
|
||||
async function main() {
|
||||
const db = getDb()
|
||||
const portalEmail = process.env.PORTAL_BOOTSTRAP_EMAIL || 'portal@sankofa.nexus'
|
||||
const portalPassword = process.env.PORTAL_BOOTSTRAP_PASSWORD || 'admin123'
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO users (email, name, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, role = EXCLUDED.role`,
|
||||
[portalEmail, 'Portal Operator', await bcrypt.hash(portalPassword, 10), 'ADMIN']
|
||||
)
|
||||
|
||||
let tenantId: string
|
||||
const existing = await db.query(`SELECT id FROM tenants WHERE domain = $1 LIMIT 1`, [
|
||||
'portal.sankofa.nexus',
|
||||
])
|
||||
if (existing.rows.length) {
|
||||
tenantId = existing.rows[0].id
|
||||
} else {
|
||||
const billingAccountId = `portal-${Date.now()}`
|
||||
const created = await db.query(
|
||||
`INSERT INTO tenants (name, domain, billing_account_id, status, tier)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
['Sankofa Sovereign Console', 'portal.sankofa.nexus', billingAccountId, 'ACTIVE', 'SOVEREIGN']
|
||||
)
|
||||
tenantId = created.rows[0].id
|
||||
}
|
||||
|
||||
const users = await db.query(`SELECT id FROM users WHERE email = ANY($1)`, [
|
||||
[portalEmail, 'admin@sankofa.nexus'],
|
||||
])
|
||||
for (const row of users.rows) {
|
||||
await db.query(
|
||||
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
[tenantId, row.id, 'TENANT_OWNER']
|
||||
)
|
||||
}
|
||||
|
||||
await operatingModelService.bootstrapClientForTenant(tenantId)
|
||||
console.log(JSON.stringify({ tenantId, portalEmail }))
|
||||
await db.end()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -483,6 +483,114 @@ const services: ServiceDefinition[] = [
|
||||
features: ['Basic observability', '7-day retention']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'DBIS B2B Integration Hub',
|
||||
slug: 'phoenix-b2b-integration-hub',
|
||||
category: 'PLATFORM_SERVICES',
|
||||
description: 'Enterprise B2B orchestration for DBIS and OMNL — EDI, file transfer, PEPPOL and EBICS fan-out to CanonicalMessage and Fineract.',
|
||||
shortDescription: 'B2B orchestration hub for DBIS and OMNL',
|
||||
tags: ['b2b', 'edi', 'integration', 'platform'],
|
||||
featured: true,
|
||||
documentationUrl: 'https://docs.sankofa.nexus/marketplace/sovereign-stack/b2b-integration-hub-service',
|
||||
metadata: { architecture: 'docs/marketplace/sovereign-stack/b2b-integration-hub-service.md', entitlement: 'B2B_HUB_ENTITLED' },
|
||||
pricingType: 'SUBSCRIPTION',
|
||||
pricingConfig: {
|
||||
currency: 'USD',
|
||||
billingPeriod: 'MONTHLY',
|
||||
billingModel: 'enterprise_contract',
|
||||
quoteRequired: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Phoenix PEPPOL Hosted Connector',
|
||||
slug: 'phoenix-peppol-hosted-connector',
|
||||
category: 'PLATFORM_SERVICES',
|
||||
description: 'Hosted Peppol Access Point connector — UBL invoice ingest, outbound submit, Peppol ID routing.',
|
||||
shortDescription: 'Hosted Peppol Access Point connector',
|
||||
tags: ['peppol', 'invoicing', 'b2b', 'platform'],
|
||||
featured: false,
|
||||
documentationUrl: 'https://docs.sankofa.nexus/marketplace/sovereign-stack/peppol-hosted-connector-service',
|
||||
metadata: { architecture: 'docs/marketplace/sovereign-stack/peppol-hosted-connector-service.md', entitlement: 'PEPPOL_HOSTED_ENTITLED' },
|
||||
pricingType: 'SUBSCRIPTION',
|
||||
pricingConfig: {
|
||||
currency: 'USD',
|
||||
billingPeriod: 'MONTHLY',
|
||||
billingModel: 'enterprise_contract',
|
||||
quoteRequired: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Phoenix EBICS Banking Gateway',
|
||||
slug: 'phoenix-ebics-banking-gateway',
|
||||
category: 'FINANCIAL_MESSAGING',
|
||||
description: 'EBICS 3.0 corporate host-to-host gateway — H004 payment upload, H005 statement download, camt/pain routing.',
|
||||
shortDescription: 'EBICS 3.0 banking gateway',
|
||||
tags: ['ebics', 'banking', 'financial-messaging'],
|
||||
featured: false,
|
||||
documentationUrl: 'https://docs.sankofa.nexus/marketplace/sovereign-stack/ebics-banking-gateway-service',
|
||||
metadata: { architecture: 'docs/marketplace/sovereign-stack/ebics-banking-gateway-service.md', entitlement: 'EBICS_GATEWAY_ENTITLED' },
|
||||
pricingType: 'SUBSCRIPTION',
|
||||
pricingConfig: {
|
||||
currency: 'USD',
|
||||
billingPeriod: 'MONTHLY',
|
||||
billingModel: 'enterprise_contract',
|
||||
quoteRequired: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Chain 138 Participant Onboarding',
|
||||
slug: 'phoenix-chain138-participant-onboard',
|
||||
category: 'BLOCKCHAIN_STACK',
|
||||
description: 'Screened institutions subscribe through Sankofa Phoenix marketplace and deploy Besu nodes on Chain 138.',
|
||||
shortDescription: 'Chain 138 participant and Besu node onboarding',
|
||||
tags: ['chain138', 'besu', 'onboarding', 'blockchain'],
|
||||
featured: true,
|
||||
documentationUrl: 'https://docs.sankofa.nexus/marketplace/sovereign-stack/chain138-participant-onboard-service',
|
||||
metadata: { architecture: 'docs/marketplace/sovereign-stack/chain138-participant-onboard-service.md', entitlement: 'CHAIN138_ONBOARD_ENTITLED' },
|
||||
pricingType: 'SUBSCRIPTION',
|
||||
pricingConfig: {
|
||||
currency: 'USD',
|
||||
billingPeriod: 'MONTHLY',
|
||||
billingModel: 'enterprise_contract',
|
||||
quoteRequired: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Phoenix X-Road Interoperability',
|
||||
slug: 'phoenix-x-road-interoperability',
|
||||
category: 'INTEROPERABILITY_SERVICES',
|
||||
description: 'NIIS-aligned X-Road sovereign data exchange for cross-organisation interoperability.',
|
||||
shortDescription: 'X-Road interoperability service',
|
||||
tags: ['x-road', 'interop', 'sovereign'],
|
||||
featured: false,
|
||||
documentationUrl: 'https://docs.sankofa.nexus/marketplace/sovereign-stack/x-road-interoperability-service',
|
||||
metadata: { architecture: 'docs/marketplace/sovereign-stack/x-road-interoperability-service.md', entitlement: 'XROAD_INTEROP_ENTITLED', status: 'preview' },
|
||||
pricingType: 'SUBSCRIPTION',
|
||||
pricingConfig: {
|
||||
currency: 'USD',
|
||||
billingPeriod: 'MONTHLY',
|
||||
billingModel: 'enterprise_contract',
|
||||
quoteRequired: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AEGIS-VAULT CTI Platform',
|
||||
slug: 'phoenix-aegis-vault-cti',
|
||||
category: 'SECURITY_SERVICES',
|
||||
description: 'Defensive external threat intelligence and dark-web exposure monitoring for financial-sector tenants.',
|
||||
shortDescription: 'AEGIS-VAULT CTI platform',
|
||||
tags: ['cti', 'security', 'threat-intelligence'],
|
||||
featured: false,
|
||||
documentationUrl: 'https://docs.sankofa.nexus/marketplace/sovereign-stack/aegis-vault-cti-service',
|
||||
metadata: { architecture: 'docs/marketplace/sovereign-stack/aegis-vault-cti-service.md', entitlement: 'AEGIS_VAULT_ENTITLED' },
|
||||
pricingType: 'SUBSCRIPTION',
|
||||
pricingConfig: {
|
||||
currency: 'USD',
|
||||
billingPeriod: 'MONTHLY',
|
||||
billingModel: 'enterprise_contract',
|
||||
quoteRequired: true,
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
51
api/src/lib/json-utils.ts
Normal file
51
api/src/lib/json-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Narrow unknown JSON / fetch payloads for strict TypeScript. */
|
||||
|
||||
export interface PrometheusQueryResponse {
|
||||
status: string
|
||||
data?: {
|
||||
result?: Array<{
|
||||
values?: Array<[number | string, string]>
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProxmoxListResponse<T = unknown> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return (typeof value === 'object' && value !== null ? value : {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
export function asString(value: unknown, fallback = ''): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === 'number' && !Number.isNaN(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
return Number.isNaN(parsed) ? fallback : parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function asArray<T = unknown>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? (value as T[]) : []
|
||||
}
|
||||
|
||||
export function asDate(value: unknown): Date {
|
||||
if (value instanceof Date) return value
|
||||
if (typeof value === 'string' || typeof value === 'number') return new Date(value)
|
||||
return new Date()
|
||||
}
|
||||
|
||||
export function asPrometheusResponse(value: unknown): PrometheusQueryResponse {
|
||||
return asRecord(value) as unknown as PrometheusQueryResponse
|
||||
}
|
||||
|
||||
export function asProxmoxResponse<T>(value: unknown): ProxmoxListResponse<T> {
|
||||
return asRecord(value) as unknown as ProxmoxListResponse<T>
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export async function tenantAuthMiddleware(
|
||||
// Set tenant context in database session for RLS policies
|
||||
const db = getDb()
|
||||
if (context.userId) {
|
||||
await db.query(`SET LOCAL app.current_user_id = $1`, [context.userId])
|
||||
await db.query(`SELECT set_config('app.current_user_id', $1, true)`, [context.userId])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
api/src/routes/marketplace-entitlements.ts
Normal file
53
api/src/routes/marketplace-entitlements.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Authenticated marketplace entitlement routes (subscribe, workspace entitlements).
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { requireTenant } from '../middleware/tenant-auth.js'
|
||||
import { marketplaceSubscriptionService } from '../services/marketplace-subscription.js'
|
||||
import { operatingModelService } from '../services/operating-model.js'
|
||||
|
||||
function tenantFromRequest(request: FastifyRequest, reply: FastifyReply) {
|
||||
const headerTenant = (request.headers['x-tenant-id'] as string | undefined)?.trim()
|
||||
if (headerTenant && request.tenantContext) {
|
||||
request.tenantContext = { ...request.tenantContext, tenantId: headerTenant }
|
||||
}
|
||||
return requireTenant(request, reply)
|
||||
}
|
||||
|
||||
export async function registerMarketplaceEntitlementRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/api/v1/marketplace/subscribe', async (request, reply) => {
|
||||
const tenantContext = tenantFromRequest(request, reply)
|
||||
const body = (request.body || {}) as { productSlug?: string; sku?: string; syncKeycloak?: boolean }
|
||||
if (!body.productSlug?.trim()) {
|
||||
return reply.code(400).send({ error: 'productSlug is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await marketplaceSubscriptionService.subscribeToProduct(
|
||||
{ request, user: (request as any).user, tenantContext, db: undefined as any },
|
||||
{
|
||||
productSlug: body.productSlug.trim(),
|
||||
sku: body.sku?.trim(),
|
||||
syncKeycloak: body.syncKeycloak,
|
||||
}
|
||||
)
|
||||
return reply.send(result)
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'Subscribe failed'
|
||||
const code = message.includes('Tenant') ? 403 : message.includes('not found') ? 404 : 400
|
||||
return reply.code(code).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/v1/marketplace/me/entitlements', async (request, reply) => {
|
||||
const tenantContext = tenantFromRequest(request, reply)
|
||||
const entitlements = await marketplaceSubscriptionService.getTenantEntitlements(
|
||||
tenantContext.tenantId!
|
||||
)
|
||||
const subscriptions = await marketplaceSubscriptionService.getTenantSubscriptions(
|
||||
tenantContext.tenantId!
|
||||
)
|
||||
const client = await operatingModelService.getClientByTenantId(tenantContext.tenantId!)
|
||||
return reply.send({ client, subscriptions, entitlements })
|
||||
})
|
||||
}
|
||||
38
api/src/routes/marketplace-public.ts
Normal file
38
api/src/routes/marketplace-public.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Public marketplace catalog — published products only (no auth).
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { catalogService } from '../services/catalog.js'
|
||||
import type { Context } from '../types/context.js'
|
||||
|
||||
function publicContext(): Context {
|
||||
return {
|
||||
request: {} as Context['request'],
|
||||
user: undefined,
|
||||
tenantContext: undefined,
|
||||
db: undefined as unknown as Context['db'],
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerMarketplacePublicRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/api/v1/marketplace/products', async (request, reply) => {
|
||||
const q = request.query as { category?: string; search?: string; featured?: string; limit?: string }
|
||||
const filter = {
|
||||
...(q.category && { category: q.category }),
|
||||
...(q.search && { search: q.search }),
|
||||
...(q.featured === 'true' && { featured: true }),
|
||||
...(q.limit && { limit: Math.min(Number(q.limit) || 50, 100) }),
|
||||
}
|
||||
const products = await catalogService.getProducts(publicContext(), filter)
|
||||
return reply.send({ products, count: products.length })
|
||||
})
|
||||
|
||||
fastify.get('/api/v1/marketplace/products/:slug', async (request, reply) => {
|
||||
const { slug } = request.params as { slug: string }
|
||||
const product = await catalogService.getProductBySlug(publicContext(), slug)
|
||||
if (!product || product.status !== 'PUBLISHED') {
|
||||
return reply.code(404).send({ error: 'Product not found', slug })
|
||||
}
|
||||
return reply.send({ product })
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import * as apiMarketplaceService from '../services/api-marketplace'
|
||||
import * as analyticsService from '../services/analytics'
|
||||
import * as aiOptimizationService from '../services/ai-optimization'
|
||||
import { infrastructureResolvers } from '../resolvers/infrastructure'
|
||||
import { operatingModelService } from '../services/operating-model.js'
|
||||
import {
|
||||
createTenantInputSchema,
|
||||
updateTenantInputSchema,
|
||||
@@ -199,6 +200,39 @@ export const resolvers = {
|
||||
return tenantService.getTenant(context.tenantContext.tenantId)
|
||||
},
|
||||
|
||||
myClient: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
const tenantId = context.tenantContext?.tenantId
|
||||
if (!tenantId) return null
|
||||
return operatingModelService.getClientByTenantId(tenantId)
|
||||
},
|
||||
|
||||
mySubscriptions: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
const tenantId = context.tenantContext?.tenantId
|
||||
if (!tenantId) return []
|
||||
return operatingModelService.listSubscriptions({ tenantId })
|
||||
},
|
||||
|
||||
myEntitlements: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required', {
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
})
|
||||
}
|
||||
const tenantId = context.tenantContext?.tenantId
|
||||
if (!tenantId) return []
|
||||
return operatingModelService.listEntitlements({ tenantId })
|
||||
},
|
||||
|
||||
tenantUsage: async (
|
||||
_: unknown,
|
||||
args: { tenantId: string; timeRange: TimeRange },
|
||||
|
||||
@@ -6,7 +6,7 @@ export const typeDefs = gql`
|
||||
|
||||
type Query {
|
||||
# Health check
|
||||
health: HealthStatus
|
||||
health: ApiHealthStatus
|
||||
|
||||
# Resources
|
||||
resources(filter: ResourceFilter): [Resource!]!
|
||||
@@ -73,6 +73,9 @@ export const typeDefs = gql`
|
||||
tenant(id: ID!): Tenant
|
||||
tenantByDomain(domain: String!): Tenant
|
||||
myTenant: Tenant
|
||||
myClient: OperatingClient
|
||||
mySubscriptions: [OperatingSubscription!]!
|
||||
myEntitlements: [OperatingEntitlement!]!
|
||||
tenantUsage(tenantId: ID!, timeRange: TimeRangeInput!): UsageReport!
|
||||
|
||||
# Billing (Superior to Azure Cost Management)
|
||||
@@ -310,7 +313,47 @@ export const typeDefs = gql`
|
||||
resourceDeleted(id: ID!): ID!
|
||||
}
|
||||
|
||||
type HealthStatus {
|
||||
type OperatingClient {
|
||||
id: ID!
|
||||
name: String!
|
||||
primaryDomain: String
|
||||
status: String!
|
||||
metadata: JSON
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type OperatingSubscription {
|
||||
id: ID!
|
||||
clientId: ID!
|
||||
tenantId: ID
|
||||
offerCode: String!
|
||||
offerName: String!
|
||||
offerType: String!
|
||||
commercialModel: String!
|
||||
supportOwner: String!
|
||||
fulfillmentMode: String!
|
||||
billingMode: String!
|
||||
status: String!
|
||||
activatedAt: DateTime
|
||||
metadata: JSON
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type OperatingEntitlement {
|
||||
id: ID!
|
||||
subscriptionId: ID!
|
||||
tenantId: ID
|
||||
entitlementKey: String!
|
||||
status: String!
|
||||
scope: JSON
|
||||
metadata: JSON
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type ApiHealthStatus {
|
||||
status: String!
|
||||
timestamp: DateTime!
|
||||
version: String!
|
||||
@@ -660,7 +703,7 @@ export const typeDefs = gql`
|
||||
end: DateTime!
|
||||
}
|
||||
|
||||
enum HealthStatus {
|
||||
enum ComponentHealthLevel {
|
||||
HEALTHY
|
||||
DEGRADED
|
||||
UNHEALTHY
|
||||
|
||||
@@ -45,12 +45,19 @@ export async function login(email: string, password: string): Promise<AuthPayloa
|
||||
throw AppErrors.unauthenticated('Invalid email or password')
|
||||
}
|
||||
|
||||
const tenantResult = await db.query(
|
||||
`SELECT tenant_id FROM tenant_users WHERE user_id = $1 ORDER BY created_at ASC NULLS LAST LIMIT 1`,
|
||||
[user.id]
|
||||
)
|
||||
const tenantId = tenantResult.rows[0]?.tenant_id as string | undefined
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
|
||||
243
api/src/services/marketplace-subscription.ts
Normal file
243
api/src/services/marketplace-subscription.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Marketplace subscription — contract/PO-first self-service subscribe + entitlement grants.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getDb } from '../db/index.js'
|
||||
import { logger } from '../lib/logger.js'
|
||||
import { catalogService } from './catalog.js'
|
||||
import { operatingModelService } from './operating-model.js'
|
||||
import { identityService } from './identity.js'
|
||||
import type { Context } from '../types/context.js'
|
||||
import type { Entitlement, ServiceSubscription } from '../types/operating-model.js'
|
||||
|
||||
interface RegistryProduct {
|
||||
productSlug: string
|
||||
entitlementKeys: string[]
|
||||
fulfillmentMode?: string
|
||||
tierEntitlements?: Record<string, string>
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type FulfillmentMode = 'self_service' | 'operator_provisioned' | 'request_only'
|
||||
|
||||
function loadRegistryProducts(): RegistryProduct[] {
|
||||
const candidates = [
|
||||
process.env.MARKETPLACE_ENTITLEMENT_REGISTRY_PATH,
|
||||
path.join(process.cwd(), 'config/marketplace-entitlement-registry.v1.json'),
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as { products?: RegistryProduct[] }
|
||||
return parsed.products || []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function subscriptionStatusForMode(mode: FulfillmentMode): 'ACTIVE' | 'PENDING' | 'REQUEST_ONLY' {
|
||||
if (mode === 'self_service') return 'ACTIVE'
|
||||
if (mode === 'request_only') return 'REQUEST_ONLY'
|
||||
return 'PENDING'
|
||||
}
|
||||
|
||||
class MarketplaceSubscriptionService {
|
||||
getRegistryEntry(slug: string): RegistryProduct | undefined {
|
||||
return loadRegistryProducts().find((entry) => entry.productSlug === slug)
|
||||
}
|
||||
|
||||
resolveEntitlementKeys(
|
||||
slug: string,
|
||||
productMetadata?: Record<string, unknown>,
|
||||
sku?: string
|
||||
): string[] {
|
||||
const entry = this.getRegistryEntry(slug)
|
||||
if (sku && entry?.tierEntitlements?.[sku]) {
|
||||
const tierKey = entry.tierEntitlements[sku]
|
||||
const base = entry.entitlementKeys.includes('AEGIS_VAULT_ENTITLED')
|
||||
? ['AEGIS_VAULT_ENTITLED', tierKey]
|
||||
: [tierKey]
|
||||
return [...new Set(base)]
|
||||
}
|
||||
if (entry?.entitlementKeys?.length) {
|
||||
return modePrimaryKeys(entry.entitlementKeys)
|
||||
}
|
||||
const flag = productMetadata?.entitlementFeatureFlag
|
||||
if (typeof flag === 'string' && flag.trim()) {
|
||||
return [flag.trim()]
|
||||
}
|
||||
return [`${slug.replace(/-/g, '_').toUpperCase()}_ENTITLED`]
|
||||
}
|
||||
|
||||
async getTenantEntitlements(tenantId: string): Promise<Entitlement[]> {
|
||||
return operatingModelService.listEntitlements({ tenantId })
|
||||
}
|
||||
|
||||
async getTenantSubscriptions(tenantId: string): Promise<ServiceSubscription[]> {
|
||||
return operatingModelService.listSubscriptions({ tenantId })
|
||||
}
|
||||
|
||||
async subscribeToProduct(
|
||||
context: Context,
|
||||
input: { productSlug: string; sku?: string; syncKeycloak?: boolean }
|
||||
): Promise<{
|
||||
subscription: ServiceSubscription
|
||||
entitlements: Entitlement[]
|
||||
productSlug: string
|
||||
fulfillmentMode: FulfillmentMode
|
||||
keycloakSynced: boolean
|
||||
}> {
|
||||
const tenantId = context.tenantContext?.tenantId
|
||||
const userEmail = context.tenantContext?.email
|
||||
if (!tenantId) {
|
||||
throw new Error('Tenant membership required to subscribe')
|
||||
}
|
||||
|
||||
const product = await catalogService.getProductBySlug(context, input.productSlug)
|
||||
if (!product || product.status !== 'PUBLISHED') {
|
||||
throw new Error(`Product not found or not published: ${input.productSlug}`)
|
||||
}
|
||||
|
||||
const registryEntry = this.getRegistryEntry(input.productSlug)
|
||||
const fulfillmentMode = (registryEntry?.fulfillmentMode ||
|
||||
(product.metadata?.fulfillmentMode as string) ||
|
||||
'operator_provisioned') as FulfillmentMode
|
||||
const status = subscriptionStatusForMode(fulfillmentMode)
|
||||
const entitlementKeys = this.resolveEntitlementKeys(
|
||||
input.productSlug,
|
||||
product.metadata,
|
||||
input.sku
|
||||
)
|
||||
|
||||
const bootstrap = await operatingModelService.bootstrapClientForTenant(tenantId)
|
||||
const db = getDb()
|
||||
|
||||
const existing = await db.query(
|
||||
`SELECT * FROM service_subscriptions
|
||||
WHERE tenant_id = $1 AND offer_code = $2
|
||||
ORDER BY created_at ASC LIMIT 1`,
|
||||
[tenantId, input.productSlug]
|
||||
)
|
||||
|
||||
let subscriptionRow = existing.rows[0]
|
||||
if (!subscriptionRow) {
|
||||
const created = await db.query(
|
||||
`INSERT INTO service_subscriptions (
|
||||
client_id, tenant_id, offer_code, offer_name, offer_type,
|
||||
commercial_model, support_owner, fulfillment_mode, billing_mode,
|
||||
status, activated_at, metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
bootstrap.client.id,
|
||||
tenantId,
|
||||
input.productSlug,
|
||||
product.name,
|
||||
'marketplace',
|
||||
'contract_po_first',
|
||||
'sankofa',
|
||||
fulfillmentMode,
|
||||
'manual',
|
||||
status,
|
||||
status === 'ACTIVE' ? new Date() : null,
|
||||
JSON.stringify({
|
||||
source: 'marketplaceSubscribe',
|
||||
productSlug: input.productSlug,
|
||||
sku: input.sku || null,
|
||||
registryDisplayName: registryEntry?.displayName || product.name,
|
||||
}),
|
||||
]
|
||||
)
|
||||
subscriptionRow = created.rows[0]
|
||||
} else if (existing.rows[0].status === 'PENDING' && status === 'ACTIVE') {
|
||||
const updated = await db.query(
|
||||
`UPDATE service_subscriptions
|
||||
SET status = $1, activated_at = COALESCE(activated_at, NOW()), updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[status, existing.rows[0].id]
|
||||
)
|
||||
subscriptionRow = updated.rows[0]
|
||||
}
|
||||
|
||||
const entitlements: Entitlement[] = []
|
||||
for (const key of entitlementKeys) {
|
||||
const found = await db.query(
|
||||
`SELECT * FROM entitlements
|
||||
WHERE subscription_id = $1 AND entitlement_key = $2 LIMIT 1`,
|
||||
[subscriptionRow.id, key]
|
||||
)
|
||||
let row = found.rows[0]
|
||||
if (!row) {
|
||||
const createdEntitlement = await db.query(
|
||||
`INSERT INTO entitlements (subscription_id, tenant_id, entitlement_key, status, scope, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
subscriptionRow.id,
|
||||
tenantId,
|
||||
key,
|
||||
status === 'ACTIVE' ? 'ACTIVE' : status === 'REQUEST_ONLY' ? 'REQUEST_ONLY' : 'PENDING',
|
||||
JSON.stringify({ productSlug: input.productSlug, sku: input.sku || null }),
|
||||
JSON.stringify({ source: 'marketplaceSubscribe' }),
|
||||
]
|
||||
)
|
||||
row = createdEntitlement.rows[0]
|
||||
}
|
||||
entitlements.push({
|
||||
id: row.id,
|
||||
subscriptionId: row.subscription_id,
|
||||
tenantId: row.tenant_id,
|
||||
entitlementKey: row.entitlement_key,
|
||||
status: row.status,
|
||||
scope: row.scope || {},
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
let keycloakSynced = false
|
||||
const shouldSync =
|
||||
input.syncKeycloak !== false &&
|
||||
status === 'ACTIVE' &&
|
||||
process.env.MARKETPLACE_KEYCLOAK_SYNC !== '0'
|
||||
if (shouldSync && userEmail) {
|
||||
keycloakSynced = await identityService.mergeUserEntitlementAttributes(
|
||||
userEmail,
|
||||
entitlementKeys,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Marketplace subscription created', {
|
||||
tenantId,
|
||||
productSlug: input.productSlug,
|
||||
subscriptionId: subscriptionRow.id,
|
||||
entitlementKeys,
|
||||
fulfillmentMode,
|
||||
keycloakSynced,
|
||||
})
|
||||
|
||||
const subscription = (await operatingModelService.listSubscriptions({ tenantId })).find(
|
||||
(s) => s.id === subscriptionRow.id
|
||||
)!
|
||||
|
||||
return {
|
||||
subscription,
|
||||
entitlements,
|
||||
productSlug: input.productSlug,
|
||||
fulfillmentMode,
|
||||
keycloakSynced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function modePrimaryKeys(keys: string[]): string[] {
|
||||
if (keys.length <= 1) return keys
|
||||
const base = keys.find((k) => k.endsWith('_ENTITLED') && !k.includes('_ESSENTIALS') && !k.includes('_PRO') && !k.includes('_ENTERPRISE'))
|
||||
return base ? [base] : [keys[0]]
|
||||
}
|
||||
|
||||
export const marketplaceSubscriptionService = new MarketplaceSubscriptionService()
|
||||
283
api/src/services/operating-model.ts
Normal file
283
api/src/services/operating-model.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { getDb } from '../db/index.js'
|
||||
import { logger } from '../lib/logger.js'
|
||||
import { Client, Entitlement, ServiceSubscription } from '../types/operating-model.js'
|
||||
|
||||
class OperatingModelService {
|
||||
private formatClient(row: any): Client {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
primaryDomain: row.primary_domain,
|
||||
status: row.status,
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
private formatSubscription(row: any): ServiceSubscription {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.client_id,
|
||||
tenantId: row.tenant_id,
|
||||
offerCode: row.offer_code,
|
||||
offerName: row.offer_name,
|
||||
offerType: row.offer_type,
|
||||
commercialModel: row.commercial_model,
|
||||
supportOwner: row.support_owner,
|
||||
fulfillmentMode: row.fulfillment_mode,
|
||||
billingMode: row.billing_mode,
|
||||
status: row.status,
|
||||
activatedAt: row.activated_at,
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
private formatEntitlement(row: any): Entitlement {
|
||||
return {
|
||||
id: row.id,
|
||||
subscriptionId: row.subscription_id,
|
||||
tenantId: row.tenant_id,
|
||||
entitlementKey: row.entitlement_key,
|
||||
status: row.status,
|
||||
scope: row.scope || {},
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrapClientForTenant(tenantId: string): Promise<{
|
||||
client: Client
|
||||
subscription: ServiceSubscription
|
||||
entitlement: Entitlement
|
||||
}> {
|
||||
const db = getDb()
|
||||
const tenantResult = await db.query(`SELECT * FROM tenants WHERE id = $1`, [tenantId])
|
||||
if (tenantResult.rows.length === 0) {
|
||||
throw new Error(`Tenant ${tenantId} not found`)
|
||||
}
|
||||
|
||||
const tenant = tenantResult.rows[0]
|
||||
let clientRow: any
|
||||
|
||||
if (tenant.client_id) {
|
||||
const clientResult = await db.query(`SELECT * FROM clients WHERE id = $1`, [tenant.client_id])
|
||||
clientRow = clientResult.rows[0]
|
||||
} else {
|
||||
const createdClient = await db.query(
|
||||
`INSERT INTO clients (name, primary_domain, status, metadata)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
tenant.name,
|
||||
tenant.domain,
|
||||
tenant.status === 'ACTIVE' ? 'ACTIVE' : tenant.status === 'SUSPENDED' ? 'SUSPENDED' : 'PENDING',
|
||||
JSON.stringify({
|
||||
source: 'bootstrapClientForTenant',
|
||||
tenantId: tenant.id,
|
||||
}),
|
||||
]
|
||||
)
|
||||
clientRow = createdClient.rows[0]
|
||||
|
||||
await db.query(`UPDATE tenants SET client_id = $1, updated_at = NOW() WHERE id = $2`, [
|
||||
clientRow.id,
|
||||
tenant.id,
|
||||
])
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO client_users (client_id, user_id, role, permissions)
|
||||
SELECT
|
||||
$1,
|
||||
user_id,
|
||||
CASE role
|
||||
WHEN 'TENANT_OWNER' THEN 'CLIENT_OWNER'
|
||||
WHEN 'TENANT_ADMIN' THEN 'CLIENT_ADMIN'
|
||||
WHEN 'TENANT_BILLING_ADMIN' THEN 'CLIENT_BILLING_ADMIN'
|
||||
WHEN 'TENANT_VIEWER' THEN 'CLIENT_VIEWER'
|
||||
ELSE 'CLIENT_USER'
|
||||
END,
|
||||
COALESCE(permissions, '{}'::jsonb)
|
||||
FROM tenant_users
|
||||
WHERE tenant_id = $2
|
||||
ON CONFLICT (client_id, user_id) DO NOTHING`,
|
||||
[clientRow.id, tenant.id]
|
||||
)
|
||||
}
|
||||
|
||||
let subscriptionRow: any
|
||||
const existingSubscription = await db.query(
|
||||
`SELECT * FROM service_subscriptions WHERE tenant_id = $1 AND offer_code = 'tenant-workspace' ORDER BY created_at ASC LIMIT 1`,
|
||||
[tenant.id]
|
||||
)
|
||||
|
||||
if (existingSubscription.rows.length > 0) {
|
||||
subscriptionRow = existingSubscription.rows[0]
|
||||
} else {
|
||||
const createdSubscription = await db.query(
|
||||
`INSERT INTO service_subscriptions (
|
||||
client_id,
|
||||
tenant_id,
|
||||
offer_code,
|
||||
offer_name,
|
||||
offer_type,
|
||||
commercial_model,
|
||||
support_owner,
|
||||
fulfillment_mode,
|
||||
billing_mode,
|
||||
status,
|
||||
activated_at,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, 'tenant-workspace', 'Tenant Workspace', 'native', 'custom', 'sankofa', 'operator_provisioned', 'subscription', $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
clientRow.id,
|
||||
tenant.id,
|
||||
tenant.status === 'ACTIVE' ? 'ACTIVE' : tenant.status === 'SUSPENDED' ? 'SUSPENDED' : 'PENDING',
|
||||
tenant.status === 'ACTIVE' ? new Date() : null,
|
||||
JSON.stringify({
|
||||
tier: tenant.tier,
|
||||
source: 'bootstrapClientForTenant',
|
||||
}),
|
||||
]
|
||||
)
|
||||
subscriptionRow = createdSubscription.rows[0]
|
||||
}
|
||||
|
||||
let entitlementRow: any
|
||||
const existingEntitlement = await db.query(
|
||||
`SELECT * FROM entitlements WHERE subscription_id = $1 AND entitlement_key = 'tenant.workspace' LIMIT 1`,
|
||||
[subscriptionRow.id]
|
||||
)
|
||||
if (existingEntitlement.rows.length > 0) {
|
||||
entitlementRow = existingEntitlement.rows[0]
|
||||
} else {
|
||||
const createdEntitlement = await db.query(
|
||||
`INSERT INTO entitlements (subscription_id, tenant_id, entitlement_key, status, scope, metadata)
|
||||
VALUES ($1, $2, 'tenant.workspace', $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
subscriptionRow.id,
|
||||
tenant.id,
|
||||
subscriptionRow.status === 'ACTIVE' ? 'ACTIVE' : subscriptionRow.status === 'SUSPENDED' ? 'SUSPENDED' : 'PENDING',
|
||||
JSON.stringify({
|
||||
offerCode: subscriptionRow.offer_code,
|
||||
commercialModel: subscriptionRow.commercial_model,
|
||||
}),
|
||||
JSON.stringify({ source: 'bootstrapClientForTenant' }),
|
||||
]
|
||||
)
|
||||
entitlementRow = createdEntitlement.rows[0]
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE billing_accounts
|
||||
SET client_id = $1, subscription_id = $2, updated_at = NOW()
|
||||
WHERE tenant_id = $3`,
|
||||
[clientRow.id, subscriptionRow.id, tenant.id]
|
||||
)
|
||||
|
||||
logger.info('Bootstrapped Phoenix operating model for tenant', {
|
||||
tenantId: tenant.id,
|
||||
clientId: clientRow.id,
|
||||
subscriptionId: subscriptionRow.id,
|
||||
entitlementId: entitlementRow.id,
|
||||
})
|
||||
|
||||
return {
|
||||
client: this.formatClient(clientRow),
|
||||
subscription: this.formatSubscription(subscriptionRow),
|
||||
entitlement: this.formatEntitlement(entitlementRow),
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(clientId: string): Promise<Client> {
|
||||
const db = getDb()
|
||||
const result = await db.query(`SELECT * FROM clients WHERE id = $1`, [clientId])
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`Client ${clientId} not found`)
|
||||
}
|
||||
return this.formatClient(result.rows[0])
|
||||
}
|
||||
|
||||
async listClients(): Promise<Client[]> {
|
||||
const db = getDb()
|
||||
const result = await db.query(`SELECT * FROM clients ORDER BY created_at DESC`)
|
||||
return result.rows.map((row) => this.formatClient(row))
|
||||
}
|
||||
|
||||
async getClientByTenantId(tenantId: string): Promise<Client | null> {
|
||||
const db = getDb()
|
||||
const result = await db.query(
|
||||
`SELECT c.*
|
||||
FROM tenants t
|
||||
JOIN clients c ON c.id = t.client_id
|
||||
WHERE t.id = $1`,
|
||||
[tenantId]
|
||||
)
|
||||
return result.rows.length > 0 ? this.formatClient(result.rows[0]) : null
|
||||
}
|
||||
|
||||
async listSubscriptions(filter?: { clientId?: string; tenantId?: string }): Promise<ServiceSubscription[]> {
|
||||
const db = getDb()
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (filter?.clientId) {
|
||||
params.push(filter.clientId)
|
||||
conditions.push(`client_id = $${params.length}`)
|
||||
}
|
||||
if (filter?.tenantId) {
|
||||
params.push(filter.tenantId)
|
||||
conditions.push(`tenant_id = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await db.query(
|
||||
`SELECT * FROM service_subscriptions ${whereClause} ORDER BY created_at DESC`,
|
||||
params
|
||||
)
|
||||
return result.rows.map((row) => this.formatSubscription(row))
|
||||
}
|
||||
|
||||
async getActiveSubscriptionForTenant(tenantId: string): Promise<ServiceSubscription | null> {
|
||||
const db = getDb()
|
||||
const result = await db.query(
|
||||
`SELECT * FROM service_subscriptions
|
||||
WHERE tenant_id = $1
|
||||
AND status IN ('ACTIVE', 'PENDING', 'REQUEST_ONLY')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`,
|
||||
[tenantId]
|
||||
)
|
||||
return result.rows.length > 0 ? this.formatSubscription(result.rows[0]) : null
|
||||
}
|
||||
|
||||
async listEntitlements(filter?: { tenantId?: string; subscriptionId?: string }): Promise<Entitlement[]> {
|
||||
const db = getDb()
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (filter?.tenantId) {
|
||||
params.push(filter.tenantId)
|
||||
conditions.push(`tenant_id = $${params.length}`)
|
||||
}
|
||||
if (filter?.subscriptionId) {
|
||||
params.push(filter.subscriptionId)
|
||||
conditions.push(`subscription_id = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await db.query(
|
||||
`SELECT * FROM entitlements ${whereClause} ORDER BY created_at DESC`,
|
||||
params
|
||||
)
|
||||
return result.rows.map((row) => this.formatEntitlement(row))
|
||||
}
|
||||
}
|
||||
|
||||
export const operatingModelService = new OperatingModelService()
|
||||
13
api/src/types/graphql.ts
Normal file
13
api/src/types/graphql.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Context } from './context'
|
||||
|
||||
/** Standard GraphQL resolver signature used across this API. */
|
||||
export type ResolverFn<TArgs = Record<string, unknown>, TResult = unknown> = (
|
||||
parent: unknown,
|
||||
args: TArgs,
|
||||
context: Context,
|
||||
info?: unknown
|
||||
) => TResult | Promise<TResult>
|
||||
|
||||
export type ResolverMap = {
|
||||
[key: string]: ResolverFn | ResolverMap | undefined
|
||||
}
|
||||
39
api/src/types/operating-model.ts
Normal file
39
api/src/types/operating-model.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface Client {
|
||||
id: string
|
||||
name: string
|
||||
primaryDomain: string | null
|
||||
status: 'ACTIVE' | 'PENDING' | 'SUSPENDED' | 'DELETED'
|
||||
metadata: Record<string, unknown>
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface ServiceSubscription {
|
||||
id: string
|
||||
clientId: string
|
||||
tenantId: string | null
|
||||
offerCode: string
|
||||
offerName: string
|
||||
offerType: 'native' | 'partner'
|
||||
commercialModel: 'IRU' | 'SaaS' | 'managed_service' | 'reserved_capacity' | 'custom'
|
||||
supportOwner: 'sankofa' | 'partner' | 'shared'
|
||||
fulfillmentMode: 'self_service' | 'request_only' | 'operator_provisioned'
|
||||
billingMode: 'subscription' | 'contract' | 'quote'
|
||||
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'CANCELLED' | 'PREVIEW' | 'REQUEST_ONLY'
|
||||
activatedAt: Date | null
|
||||
metadata: Record<string, unknown>
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface Entitlement {
|
||||
id: string
|
||||
subscriptionId: string
|
||||
tenantId: string | null
|
||||
entitlementKey: string
|
||||
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'REVOKED'
|
||||
scope: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
8
api/vitest.setup.ts
Normal file
8
api/vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Vitest global setup — test env vars required by secret-validation and db bootstrap.
|
||||
*/
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'test'
|
||||
process.env.DB_PASSWORD = process.env.DB_PASSWORD || 'VitestDbPass1!'
|
||||
process.env.JWT_SECRET =
|
||||
process.env.JWT_SECRET ||
|
||||
'VitestJwtSecret_Abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+'
|
||||
Reference in New Issue
Block a user