API: mehrere Produkte je Zugang (m2m) + Produktwahl im Order-Request

- api_clients bekommt ein m2m-Feld `products` (Mehrfachauswahl) über die neue
  Junction `api_client_products`; das Einzelfeld `product` bleibt als Fallback.
- POST /v1/orders: neues Pflichtfeld `product` (Produkt-Key) wählt aus den
  erlaubten Produkten; bei genau einem optional. Ungültig/fehlend → 400 mit
  Liste der erlaubten Keys.
- POST /v1/clients akzeptiert `products: [id, …]`.
- Doku (Swagger UI) + OpenAPI um das product-Feld ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-08-18 11:25:46 +02:00
parent 1d5c1a14d4
commit c535af5781
2 changed files with 46 additions and 9 deletions

View File

@@ -453,7 +453,10 @@ const collections = [
f('key_prefix', 'string', { interface: 'input', note: 'Erkennungspräfix des Keys (nur Anzeige).' }), f('key_prefix', 'string', { interface: 'input', note: 'Erkennungspräfix des Keys (nur Anzeige).' }),
f('key_hash', 'string', { interface: 'input', note: 'SHA-256 des API-Keys – automatisch gesetzt. Nicht manuell ändern.' }), f('key_hash', 'string', { interface: 'input', note: 'SHA-256 des API-Keys – automatisch gesetzt. Nicht manuell ändern.' }),
m2o('customer', 'directus_users', { note: 'Verknüpftes Kundenkonto (optional).' }), m2o('customer', 'directus_users', { note: 'Verknüpftes Kundenkonto (optional).' }),
m2o('product', 'products', { nullable: false, note: 'Einziges Produkt, das dieser Zugang bestellen darf.' }), // Mehrere erlaubte Produkte (m2m). Die Order gibt per „product" an, welches genutzt wird.
{ field: 'products', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'], note: 'Erlaubte Produkte für diesen Zugang (Mehrfachauswahl).' }, schema: null },
// Einzelprodukt (optional, Fallback/alt) – wird nur genutzt, wenn keine m2m-Produkte gesetzt sind.
m2o('product', 'products', { note: 'Einzelprodukt (Fallback). Für mehrere Produkte „products" nutzen.' }),
f('active', 'boolean', { interface: 'boolean', default: true }), f('active', 'boolean', { interface: 'boolean', default: true }),
f('webhook_url', 'string', { interface: 'input', note: 'Optionaler Webhook für Statusänderungen (derzeit ungenutzt – Kunde pollt).' }), f('webhook_url', 'string', { interface: 'input', note: 'Optionaler Webhook für Statusänderungen (derzeit ungenutzt – Kunde pollt).' }),
f('config', 'json', { interface: 'input-code', note: 'Kundenspezifische Konfiguration (optional).' }), f('config', 'json', { interface: 'input-code', note: 'Kundenspezifische Konfiguration (optional).' }),
@@ -464,6 +467,12 @@ const collections = [
f('contract_ref', 'string', { interface: 'input', note: 'Referenz zum unterschriebenen Vertrag (Aktenzeichen/Link).' }), f('contract_ref', 'string', { interface: 'input', note: 'Referenz zum unterschriebenen Vertrag (Aktenzeichen/Link).' }),
] }, ] },
// Junction API-Zugang ↔ Produkt (m2m für api_clients.products).
{ collection: 'api_client_products', meta: { icon: 'link', note: 'Junction API-Zugang ↔ Produkt.', hidden: true }, fields: [
m2o('api_client', 'api_clients', { onDelete: 'CASCADE' }),
m2o('product', 'products', { onDelete: 'CASCADE' }),
] },
// Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt. // Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt.
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [ { collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }), m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
@@ -636,6 +645,9 @@ async function applySchema() {
try { try {
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } }); await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } }); await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
// m2m api_clients.products ↔ products über api_client_products
await api('PATCH', '/relations/api_client_products/api_client', { meta: { one_field: 'products', junction_field: 'product' } });
await api('PATCH', '/relations/api_client_products/product', { meta: { junction_field: 'api_client' } });
log('m2m verdrahtet'); log('m2m verdrahtet');
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); } } catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }

View File

@@ -72,7 +72,7 @@ const DOCS_HTML = `<!doctype html>
<h2>Overview</h2> <h2>Overview</h2>
<div class="card"> <div class="card">
<p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. The product (letters, postcards or envelopes) is bound to the API key.</p> <p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. One or more products are bound to your API key; each order selects one via the <code>product</code> field.</p>
</div> </div>
<h2>Authentication</h2> <h2>Authentication</h2>
@@ -114,6 +114,7 @@ const DOCS_HTML = `<!doctype html>
<h3>Request body</h3> <h3>Request body</h3>
<table> <table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr> <tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>product</td><td>string</td><td>Product key to use. Required if your key allows multiple products; optional (auto) if exactly one.</td></tr>
<tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr> <tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr>
<tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr> <tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr>
<tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr> <tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr>
@@ -128,7 +129,7 @@ const DOCS_HTML = `<!doctype html>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\ <pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\ -H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{ -d '{
"document_format":"a4","font":"tilda","realistic":true, "product":"briefe","document_format":"a4","font":"tilda","realistic":true,
"shipping_type":"bulk","shipping_day":"montag", "shipping_type":"bulk","shipping_day":"montag",
"letter":{"text":"Dear Anna,\\n\\nthank you for ..."}, "letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
"envelope":{"mode":"recipient"}, "envelope":{"mode":"recipient"},
@@ -214,7 +215,9 @@ const handler = (router, { services, getSchema, logger }) => {
const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({ const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({
filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } }, filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } },
limit: 1, limit: 1,
fields: ['id', 'name', 'customer', 'webhook_url', 'config', 'product.id', 'product.key', 'product.type', 'product.input_mode'], fields: ['id', 'name', 'customer', 'webhook_url', 'config',
'product.id', 'product.key', 'product.type', 'product.input_mode',
'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'],
}); });
const client = rows?.[0] || null; const client = rows?.[0] || null;
if (client) { if (client) {
@@ -261,10 +264,28 @@ const handler = (router, { services, getSchema, logger }) => {
router.post('/v1/orders', async (req, res) => { router.post('/v1/orders', async (req, res) => {
const client = await authClient(req); const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.'); if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const product = client.product;
if (!product?.id) return fehler(res, 409, 'No product is assigned to this API key.');
const body = req.body || {}; const body = req.body || {};
// Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert.
const erlaubt = {};
if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product;
if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product;
const erlaubteListe = Object.values(erlaubt);
if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.');
// Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional.
const gewuenscht = String(body.product || '').trim();
let product;
if (gewuenscht) {
product = erlaubteListe.find((p) => String(p.key) === gewuenscht);
if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
} else if (erlaubteListe.length === 1) {
product = erlaubteListe[0];
} else {
return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
}
const recipients = Array.isArray(body.recipients) ? body.recipients : []; const recipients = Array.isArray(body.recipients) ? body.recipients : [];
if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.'); if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.');
if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).'); if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).');
@@ -418,13 +439,16 @@ const handler = (router, { services, getSchema, logger }) => {
// Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert. // Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert.
router.post('/v1/clients', async (req, res) => { router.post('/v1/clients', async (req, res) => {
if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.'); if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.');
const { name, product, customer, webhook_url } = req.body || {}; const { name, product, products, customer, webhook_url } = req.body || {};
if (!name || !product) return fehler(res, 400, 'name and product (product ID) are required.'); // Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform.
const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []);
if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.');
try { try {
const schema = await getSchema(); const schema = await getSchema();
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`; const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({ const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({
name: String(name), product, customer: customer || null, webhook_url: webhook_url || null, name: String(name), products: prodIds.map((pid) => ({ product: pid })),
customer: customer || null, webhook_url: webhook_url || null,
key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true, key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true,
}); });
// Klartext-Key nur JETZT – wird nirgends gespeichert. // Klartext-Key nur JETZT – wird nirgends gespeichert.
@@ -454,6 +478,7 @@ const handler = (router, { services, getSchema, logger }) => {
OrderRequest: { OrderRequest: {
type: 'object', required: ['recipients'], type: 'object', required: ['recipients'],
properties: { properties: {
product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' },
document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' }, document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' },
font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] }, font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] },
realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' }, realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' },