diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs index 5f29619..5b1e9e6 100644 --- a/Docker/directus/bootstrap/bootstrap.mjs +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -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); } diff --git a/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js index 73573fa..722701f 100644 --- a/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js @@ -72,7 +72,7 @@ const DOCS_HTML = `

Overview

-

REST API over HTTPS. Request and response bodies are JSON (Content-Type: application/json, UTF-8). An order yields one document and/or one envelope per recipient; the quantity is derived from the number of recipients. The product (letters, postcards or envelopes) is bound to the API key.

+

REST API over HTTPS. Request and response bodies are JSON (Content-Type: application/json, UTF-8). An order yields one document and/or one envelope per recipient; the quantity is derived from the number of recipients. One or more products are bound to your API key; each order selects one via the product field.

Authentication

@@ -114,6 +114,7 @@ const DOCS_HTML = `

Request body

+ @@ -128,7 +129,7 @@ const DOCS_HTML = `
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.' },
FieldTypeDescription
productstringProduct key to use. Required if your key allows multiple products; optional (auto) if exactly one.
recipientsarrayRequired. One entry per recipient; count = quantity. Address as free_text (up to 5 lines, separated by \\n).
document_formatstringDocument format: a4, a6h or a6l.
fontstringtilda, alva or ellie.