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
| Field | Type | Description |
+| product | string | Product key to use. Required if your key allows multiple products; optional (auto) if exactly one. |
| recipients | array | Required. One entry per recipient; count = quantity. Address as free_text (up to 5 lines, separated by \\n). |
| document_format | string | Document format: a4, a6h or a6l. |
| font | string | tilda, alva or ellie. |
@@ -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.' },