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:
@@ -453,7 +453,10 @@ const collections = [
|
||||
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.' }),
|
||||
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('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).' }),
|
||||
@@ -464,6 +467,12 @@ const collections = [
|
||||
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.
|
||||
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
|
||||
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
|
||||
@@ -636,6 +645,9 @@ async function applySchema() {
|
||||
try {
|
||||
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' } });
|
||||
// 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');
|
||||
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ const DOCS_HTML = `<!doctype html>
|
||||
|
||||
<h2>Overview</h2>
|
||||
<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>
|
||||
|
||||
<h2>Authentication</h2>
|
||||
@@ -114,6 +114,7 @@ const DOCS_HTML = `<!doctype html>
|
||||
<h3>Request body</h3>
|
||||
<table>
|
||||
<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>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>
|
||||
@@ -128,7 +129,7 @@ const DOCS_HTML = `<!doctype html>
|
||||
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"document_format":"a4","font":"tilda","realistic":true,
|
||||
"product":"briefe","document_format":"a4","font":"tilda","realistic":true,
|
||||
"shipping_type":"bulk","shipping_day":"montag",
|
||||
"letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
|
||||
"envelope":{"mode":"recipient"},
|
||||
@@ -214,7 +215,9 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({
|
||||
filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } },
|
||||
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;
|
||||
if (client) {
|
||||
@@ -261,10 +264,28 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
router.post('/v1/orders', async (req, res) => {
|
||||
const client = await authClient(req);
|
||||
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 || {};
|
||||
|
||||
// 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 : [];
|
||||
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).');
|
||||
@@ -418,13 +439,16 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert.
|
||||
router.post('/v1/clients', async (req, res) => {
|
||||
if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.');
|
||||
const { name, product, customer, webhook_url } = req.body || {};
|
||||
if (!name || !product) return fehler(res, 400, 'name and product (product ID) are required.');
|
||||
const { name, product, products, customer, webhook_url } = req.body || {};
|
||||
// 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 {
|
||||
const schema = await getSchema();
|
||||
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
|
||||
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,
|
||||
});
|
||||
// Klartext-Key nur JETZT – wird nirgends gespeichert.
|
||||
@@ -454,6 +478,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
OrderRequest: {
|
||||
type: 'object', required: ['recipients'],
|
||||
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.' },
|
||||
font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] },
|
||||
realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' },
|
||||
|
||||
Reference in New Issue
Block a user