274 lines
24 KiB
TypeScript
274 lines
24 KiB
TypeScript
|
|
import type { FastifyInstance } from 'fastify';
|
||
|
|
import { z } from 'zod';
|
||
|
|
import { randomUUID } from 'node:crypto';
|
||
|
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
import { calculatePrice } from '@kc/platform/pricing';
|
||
|
|
import { getConnector, loadInstance } from '@kc/connectors';
|
||
|
|
import { ConnectorError } from '@kc/connector-sdk';
|
||
|
|
import { one, query, run, tx } from '../../core/db.js';
|
||
|
|
import { audit } from '../../core/audit.js';
|
||
|
|
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||
|
|
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
|
||
|
|
import { canInOrg } from '../../core/policy.js';
|
||
|
|
import type { KcModule } from '../../core/module.js';
|
||
|
|
|
||
|
|
const int = (max: number) => z.number().int().min(0).max(max);
|
||
|
|
const versionSchema = z.object({
|
||
|
|
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
|
||
|
|
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
|
||
|
|
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
|
||
|
|
provisioning: z.record(z.string(), z.unknown()).default({}),
|
||
|
|
});
|
||
|
|
type VersionIn = z.infer<typeof versionSchema>;
|
||
|
|
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||
|
|
const ACTIONS = z.array(z.enum(CUSTOMER_ACTIONS));
|
||
|
|
|
||
|
|
/** Fachregeln für Laufzeiten/Preise je Abrechnungsintervall. */
|
||
|
|
function checkTerms(v: VersionIn): number {
|
||
|
|
if (v.billingInterval === 'once') {
|
||
|
|
if (v.recurringCents > 0) throw badRequest('Einmalprodukte haben keinen wiederkehrenden Preis', 'BAD_TERMS');
|
||
|
|
if (v.termMonths > 0 || v.renewal === 'auto') throw badRequest('Einmalprodukte haben keine Laufzeit und keine Verlängerung', 'BAD_TERMS');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
const per = v.billingInterval === 'monthly' ? 1 : 12;
|
||
|
|
const renewalMonths = v.renewal === 'auto' ? (v.renewalTermMonths ?? per) : 0;
|
||
|
|
if (v.renewal === 'auto' && renewalMonths < 1) throw badRequest('Bei automatischer Verlängerung ist eine Verlängerungslaufzeit nötig', 'BAD_TERMS');
|
||
|
|
if (v.termMonths > 0 && v.termMonths % per !== 0) throw badRequest(`Die Laufzeit muss ein Vielfaches des Abrechnungsintervalls (${per} Monat${per > 1 ? 'e' : ''}) sein`, 'BAD_TERMS');
|
||
|
|
return renewalMonths;
|
||
|
|
}
|
||
|
|
async function checkConnector(instanceId: string | null | undefined, provisioning: Record<string, unknown>, forActivation = false): Promise<void> {
|
||
|
|
if (!instanceId) { if (Object.keys(provisioning).length) throw badRequest('Provisionierungsparameter ohne Verbindung', 'BAD_PROVISIONING'); return; }
|
||
|
|
const inst = await one('SELECT connector_key, capabilities_json FROM connector_instances WHERE id = ?', [instanceId]);
|
||
|
|
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
|
||
|
|
const c = getConnector(inst.connector_key);
|
||
|
|
const msg = c.validateProvisioning?.(provisioning) ?? null;
|
||
|
|
if (msg) throw badRequest(`Provisionierung ungültig: ${msg}`, 'BAD_PROVISIONING');
|
||
|
|
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
|
||
|
|
if (caps.length && !caps.includes('lifecycle.create')) throw badRequest('Diese Verbindung kann aktuell keine Objekte anlegen (Zugangsdaten mit Schreibrechten prüfen)', 'NO_CREATE_CAPABILITY');
|
||
|
|
// Edition (Schlüssel-Präfix) und festes Ablaufdatum: ein aktives Produkt darf sie nur versprechen, wenn der Anbieter sie nachweislich umsetzt.
|
||
|
|
if (forActivation) {
|
||
|
|
if (provisioning.keyPrefix && !caps.includes('license.key_prefix')) throw badRequest('Produkte mit Edition (Schlüssel-Präfix) können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt. Als Entwurf ist es gespeichert.', 'NEEDS_LICENSE_EXTENSION');
|
||
|
|
if (provisioning.validityDays && !caps.includes('license.expiry')) throw badRequest('Produkte mit festem Ablaufdatum können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt.', 'NEEDS_LICENSE_EXTENSION');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async function insertVersion(c: Parameters<typeof run>[2], productId: string, v: VersionIn, by: string): Promise<string> {
|
||
|
|
const tax = await one('SELECT id, rate_bp, active FROM tax_rates WHERE id = ?', [v.taxRateId], c);
|
||
|
|
if (!tax || !tax.active) throw badRequest('Steuersatz nicht gefunden', 'BAD_TAX');
|
||
|
|
const renewalMonths = checkTerms(v);
|
||
|
|
const last = await one('SELECT COALESCE(MAX(version), 0) AS n FROM product_versions WHERE product_id = ?', [productId], c);
|
||
|
|
const id = randomUUID();
|
||
|
|
await run(
|
||
|
|
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
|
||
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||
|
|
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
|
||
|
|
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
|
||
|
|
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
|
||
|
|
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
|
||
|
|
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
|
||
|
|
await checkConnector(b.connectorInstanceId, b.version.provisioning, b.status === 'active');
|
||
|
|
const id = randomUUID();
|
||
|
|
await tx(async (c) => {
|
||
|
|
await run('INSERT INTO products (id, sku, category, status, connector_instance_id, external_ref, orderable_by_customer, requires_approval, customer_actions) VALUES (?,?,?,?,?,?,?,?,?)',
|
||
|
|
[id, b.sku, b.category, b.status, b.connectorInstanceId ?? null, b.externalRef ?? null, b.orderableByCustomer ? 1 : 0, b.requiresApproval ? 1 : 0, JSON.stringify(b.customerActions)], c);
|
||
|
|
await insertVersion(c, id, b.version, actorId);
|
||
|
|
});
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Produktpakete (Vorlagen, z. B. Editionen einer Software) --------------------
|
||
|
|
const bundleSchema = z.object({
|
||
|
|
key: z.string().regex(/^[a-z0-9-]{2,40}$/), name: z.string().max(200), description: z.string().max(2000).optional(), source: z.string().max(200).optional(),
|
||
|
|
products: z.array(z.object({
|
||
|
|
sku: z.string().regex(/^[A-Za-z0-9._-]{2,50}$/), name: z.string().max(200), description: z.string().max(2000).optional(), category: z.enum(['hosting', 'license', 'addon', 'service']),
|
||
|
|
priceBasis: z.enum(['net', 'gross']), setupCents: int(100_000_00), recurringCents: int(100_000_00), taxBp: int(10000), interval: z.enum(['once', 'monthly', 'yearly']),
|
||
|
|
termMonths: int(120), renewal: z.enum(['auto', 'none']), renewalTermMonths: int(120), noticeDays: int(365), provisioning: z.record(z.string(), z.unknown()),
|
||
|
|
orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]),
|
||
|
|
})).min(1).max(50),
|
||
|
|
});
|
||
|
|
type Bundle = z.infer<typeof bundleSchema>;
|
||
|
|
const bundleDir = fileURLToPath(new URL('../../../../../bundles/', import.meta.url));
|
||
|
|
function loadBundles(): Bundle[] {
|
||
|
|
if (!existsSync(bundleDir)) return [];
|
||
|
|
const out: Bundle[] = [];
|
||
|
|
for (const f of readdirSync(bundleDir).filter((n) => n.endsWith('.json')).sort()) {
|
||
|
|
const r = bundleSchema.safeParse(JSON.parse(readFileSync(join(bundleDir, f), 'utf8')));
|
||
|
|
if (r.success) out.push(r.data);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
const versionView = (r: any) => ({
|
||
|
|
id: r.vid ?? r.id, version: r.version, name: r.vname ?? r.name, description: r.description, taxBp: r.tax_bp, priceBasis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, currency: r.currency,
|
||
|
|
billingInterval: r.billing_interval, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days,
|
||
|
|
});
|
||
|
|
const productSelect = `SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.description, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days, v.provisioning_json, i.name AS connector_name
|
||
|
|
FROM products p LEFT JOIN product_versions v ON v.id = p.current_version_id LEFT JOIN connector_instances i ON i.id = p.connector_instance_id`;
|
||
|
|
const j = (v: unknown, d: unknown) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
|
||
|
|
const productView = (r: any) => ({
|
||
|
|
id: r.id, sku: r.sku, category: r.category, status: r.status, connectorInstanceId: r.connector_instance_id, externalRef: r.external_ref ?? null, connectorName: r.connector_name, orderableByCustomer: !!r.orderable_by_customer, requiresApproval: !!r.requires_approval,
|
||
|
|
customerActions: j(r.customer_actions, []), provisioning: j(r.provisioning_json, {}), current: r.vid ? versionView(r) : null,
|
||
|
|
});
|
||
|
|
|
||
|
|
export const catalogModule: KcModule = {
|
||
|
|
name: 'catalog',
|
||
|
|
permissions: { staff: { support: ['products.read'], accounting: ['products.read'], admin: ['products.read', 'products.write'], superadmin: ['products.read', 'products.write'] } },
|
||
|
|
register(app: FastifyInstance) {
|
||
|
|
app.get('/admin/tax-rates', async (req) => {
|
||
|
|
requirePermission(req, 'products.read');
|
||
|
|
return (await query('SELECT id, name, rate_bp FROM tax_rates WHERE active = 1 ORDER BY rate_bp DESC')).map((t) => ({ id: t.id, name: t.name, rateBp: t.rate_bp }));
|
||
|
|
});
|
||
|
|
app.get('/admin/products', async (req) => {
|
||
|
|
requirePermission(req, 'products.read');
|
||
|
|
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
|
||
|
|
});
|
||
|
|
app.get('/admin/products/:id', async (req) => {
|
||
|
|
requirePermission(req, 'products.read');
|
||
|
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||
|
|
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||
|
|
if (!r) throw notFound();
|
||
|
|
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
|
||
|
|
return { ...productView(r), versions: versions.map(versionView) };
|
||
|
|
});
|
||
|
|
app.post('/admin/products', async (req) => {
|
||
|
|
const a = requirePermission(req, 'products.write');
|
||
|
|
const b = z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/, 'Nur Buchstaben, Ziffern, Punkt, Unterstrich und Bindestrich'), category: z.enum(['hosting', 'license', 'addon', 'service']),
|
||
|
|
connectorInstanceId: z.string().uuid().nullable().optional(), externalRef: z.string().trim().max(100).optional(), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), status: z.enum(['draft', 'active']).default('draft'), version: versionSchema }).parse(req.body);
|
||
|
|
if (b.externalRef && !b.connectorInstanceId) throw badRequest('Herkunft ohne Verbindung', 'BAD_CONNECTOR');
|
||
|
|
const id = await createProduct(b, a.user.id);
|
||
|
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.sku, importedFrom: b.externalRef, status: b.status, version: b.version.name } });
|
||
|
|
return { id };
|
||
|
|
});
|
||
|
|
/** Preis-/Vertragsänderung = neue unveränderliche Version. Bestehende Bestellungen und Verträge behalten ihren Snapshot. */
|
||
|
|
app.post('/admin/products/:id/versions', async (req) => {
|
||
|
|
const a = requirePermission(req, 'products.write');
|
||
|
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||
|
|
const v = versionSchema.parse(req.body);
|
||
|
|
const p = await one('SELECT id, connector_instance_id, status FROM products WHERE id = ?', [id]);
|
||
|
|
if (!p) throw notFound();
|
||
|
|
await checkConnector(p.connector_instance_id, v.provisioning, p.status === 'active');
|
||
|
|
const vid = await tx((c) => insertVersion(c, id, v, a.user.id));
|
||
|
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.version', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { versionId: vid, setupCents: v.setupCents, recurringCents: v.recurringCents } });
|
||
|
|
return { versionId: vid };
|
||
|
|
});
|
||
|
|
app.patch('/admin/products/:id', async (req) => {
|
||
|
|
const a = requirePermission(req, 'products.write');
|
||
|
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||
|
|
const b = z.object({ status: z.enum(['draft', 'active', 'retired']).optional(), orderableByCustomer: z.boolean().optional(), requiresApproval: z.boolean().optional(), customerActions: ACTIONS.optional(), connectorInstanceId: z.string().uuid().nullable().optional() }).parse(req.body);
|
||
|
|
const before = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||
|
|
if (!before) throw notFound();
|
||
|
|
if (b.status === 'active' && !before.vid) throw badRequest('Ohne Version nicht aktivierbar');
|
||
|
|
if (b.status === 'active') await checkConnector(before.connector_instance_id, j(before.provisioning_json, {}) as Record<string, unknown>, true);
|
||
|
|
if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record<string, unknown>);
|
||
|
|
await run('UPDATE products SET status = COALESCE(?, status), orderable_by_customer = COALESCE(?, orderable_by_customer), requires_approval = COALESCE(?, requires_approval), customer_actions = COALESCE(?, customer_actions), connector_instance_id = ? WHERE id = ?',
|
||
|
|
[b.status ?? null, b.orderableByCustomer === undefined ? null : b.orderableByCustomer ? 1 : 0, b.requiresApproval === undefined ? null : b.requiresApproval ? 1 : 0, b.customerActions ? JSON.stringify(b.customerActions) : null, b.connectorInstanceId === undefined ? before.connector_instance_id : b.connectorInstanceId, id]);
|
||
|
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.update', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: before.status, orderableByCustomer: !!before.orderable_by_customer, requiresApproval: !!before.requires_approval }, after: b });
|
||
|
|
return { status: 'ok' };
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Übernahme: Angebote/Programme eines Anbieters live auslesen (Verbindung, Zugangsdaten bleiben serverseitig).
|
||
|
|
* Zeigt je Eintrag, wie viele Produkte bereits daraus angelegt wurden.
|
||
|
|
*/
|
||
|
|
app.get('/admin/connectors/:id/catalog', async (req) => {
|
||
|
|
requirePermission(req, 'products.write');
|
||
|
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||
|
|
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
|
||
|
|
try {
|
||
|
|
const { connector, ctx } = await loadInstance(id, req.correlationId);
|
||
|
|
if (!connector.listCatalog || !(await connector.capabilities(ctx)).includes('catalog.list')) throw badRequest('Diese Verbindung unterstützt keine Produktübernahme', 'NO_CATALOG');
|
||
|
|
const items = await connector.listCatalog(ctx);
|
||
|
|
const counts = await query('SELECT external_ref, COUNT(*) AS n FROM products WHERE connector_instance_id = ? AND external_ref IS NOT NULL GROUP BY external_ref', [id]);
|
||
|
|
const byRef = new Map(counts.map((c) => [c.external_ref as string, Number(c.n)]));
|
||
|
|
return items.map((i) => ({ ...i, importedProducts: byRef.get(i.externalRef) ?? 0 }));
|
||
|
|
} catch (e) {
|
||
|
|
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`);
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/** Vorlagenpakete (Dateien in /bundles): Vorschau mit Preisen, Laufzeiten und Provisionierung. */
|
||
|
|
app.get('/admin/product-bundles', async (req) => {
|
||
|
|
requirePermission(req, 'products.write');
|
||
|
|
const taken = new Set((await query('SELECT sku FROM products')).map((r) => r.sku as string));
|
||
|
|
return loadBundles().map((b) => ({ ...b, products: b.products.map((p) => ({ ...p, exists: taken.has(p.sku) })) }));
|
||
|
|
});
|
||
|
|
/** Importiert ausgewählte Produkte eines Pakets als Entwürfe und verknüpft sie mit Verbindung und Anbieter-Programm. */
|
||
|
|
app.post('/admin/product-bundles/:key/import', async (req) => {
|
||
|
|
const a = requirePermission(req, 'products.write');
|
||
|
|
const { key } = z.object({ key: z.string().max(40) }).parse(req.params);
|
||
|
|
const b = z.object({ connectorInstanceId: z.string().uuid(), programRef: z.string().trim().min(1).max(100), skus: z.array(z.string()).min(1).max(50) }).parse(req.body);
|
||
|
|
const bundle = loadBundles().find((x) => x.key === key);
|
||
|
|
if (!bundle) throw notFound('Paket nicht gefunden');
|
||
|
|
const inst = await one('SELECT connector_key FROM connector_instances WHERE id = ?', [b.connectorInstanceId]);
|
||
|
|
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
|
||
|
|
const tax = await query('SELECT id, rate_bp FROM tax_rates WHERE active = 1');
|
||
|
|
const created: { sku: string; id: string }[] = []; const skipped: { sku: string; reason: string }[] = [];
|
||
|
|
for (const sku of b.skus) {
|
||
|
|
const p = bundle.products.find((x) => x.sku === sku);
|
||
|
|
if (!p) { skipped.push({ sku, reason: 'nicht im Paket' }); continue; }
|
||
|
|
const t = tax.find((x) => Number(x.rate_bp) === p.taxBp);
|
||
|
|
if (!t) { skipped.push({ sku, reason: `Steuersatz ${p.taxBp / 100} % nicht angelegt` }); continue; }
|
||
|
|
try {
|
||
|
|
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
|
||
|
|
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
|
||
|
|
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
|
||
|
|
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
|
||
|
|
created.push({ sku, id });
|
||
|
|
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
|
||
|
|
}
|
||
|
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.bundle.import', resourceType: 'product', connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { bundle: key, programRef: b.programRef, created: created.map((c) => c.sku), skipped } });
|
||
|
|
return { created, skipped };
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Neuen Hosting-Tarif beim Anbieter anlegen UND als Produkt definieren. Wirkt sofort beim Anbieter (Tarif entsteht dort):
|
||
|
|
* Name muss frei sein, Größen in GB, "unbegrenzt" nur wenn die Instanz es kennt. Danach entsteht das Produkt (Entwurf oder aktiv).
|
||
|
|
*/
|
||
|
|
app.post('/admin/connectors/:id/hosting-plans', async (req) => {
|
||
|
|
const a = requirePermission(req, 'products.write');
|
||
|
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||
|
|
const lim = z.number().min(0).max(1_000_000).nullable().optional();
|
||
|
|
const b = z.object({
|
||
|
|
plan: z.object({ name: z.string().trim().min(1).max(100), limits: z.object({ diskSpaceGb: lim, trafficGb: lim, domains: lim, subdomains: lim, emailAccounts: lim, emailAddresses: lim, emailForwardings: lim, databases: lim, ftpUsers: lim, scheduledTasks: lim }).default({}), permissions: z.record(z.string(), z.boolean()).optional() }),
|
||
|
|
product: z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/), status: z.enum(['draft', 'active']).default('draft'), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), version: versionSchema.omit({ provisioning: true }) }),
|
||
|
|
}).parse(req.body);
|
||
|
|
const inst = await one('SELECT id, connector_key, capabilities_json FROM connector_instances WHERE id = ?', [id]);
|
||
|
|
if (!inst) throw notFound();
|
||
|
|
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
|
||
|
|
if (!caps.includes('catalog.write')) throw badRequest('Diese Verbindung kann keine Tarife anlegen', 'NO_CATALOG_WRITE');
|
||
|
|
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.product.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
|
||
|
|
let item;
|
||
|
|
try { const { connector, ctx } = await loadInstance(id, req.correlationId); item = await connector.createCatalogItem!(ctx, b.plan); }
|
||
|
|
catch (e) { if (e instanceof ConnectorError) throw new AppError(e.code === 'CONFLICT' ? 409 : e.code === 'INVALID_INPUT' ? 400 : 502, e.code === 'CONFLICT' ? 'PLAN_EXISTS' : 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; }
|
||
|
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'catalog.plan.create', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.plan.name, ref: item.externalRef, limits: b.plan.limits } });
|
||
|
|
try {
|
||
|
|
const productId = await createProduct({ sku: b.product.sku, category: item.category, connectorInstanceId: id, externalRef: item.externalRef, orderableByCustomer: b.product.orderableByCustomer, requiresApproval: b.product.requiresApproval, customerActions: b.product.customerActions, status: b.product.status,
|
||
|
|
version: { ...b.product.version, provisioning: item.provisioning } }, a.user.id);
|
||
|
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: productId, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.product.sku, importedFrom: item.externalRef, viaPlanCreate: true } });
|
||
|
|
return { productId, plan: { ref: item.externalRef, name: item.name, features: item.features } };
|
||
|
|
} catch (e) {
|
||
|
|
// Der Tarif existiert beim Anbieter bereits: nicht erneut anlegen, sondern über "Übernehmen" als Produkt anlegen
|
||
|
|
throw new AppError(e instanceof AppError ? e.status : 500, 'PRODUCT_AFTER_PLAN_FAILED', `Der Tarif „${item.name}“ wurde beim Anbieter angelegt, das Produkt konnte aber nicht angelegt werden${e instanceof AppError ? `: ${e.message}` : ''}. Bitte den Tarif über „Aus Verbindung übernehmen“ als Produkt übernehmen.`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/** Katalog für Kunden: nur aktive, bestellbare Produkte, Preise für die jeweilige Organisation (Netto/Brutto). */
|
||
|
|
app.get('/catalog', async (req) => {
|
||
|
|
const a = requireAuth(req);
|
||
|
|
const q = z.object({ org: z.string().uuid() }).parse(req.query);
|
||
|
|
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
|
||
|
|
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
|
||
|
|
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
|
||
|
|
return rows.map((r) => {
|
||
|
|
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
|
||
|
|
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
|
||
|
|
});
|
||
|
|
});
|
||
|
|
},
|
||
|
|
};
|