This commit is contained in:
seb
2026-10-09 17:55:45 +02:00
parent de7afbcbe9
commit 848ca0aff5
13 changed files with 331 additions and 48 deletions

View File

@@ -85,8 +85,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` | | [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` | | [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
| [`routes/brand/list.md`](routes/brand/list.md) | `GET /brand` | | [`routes/brand/list.md`](routes/brand/list.md) | `GET /brand` |
| [`routes/brand/upsert.md`](routes/brand/upsert.md) | `PUT /brand` | | [`routes/brand/create.md`](routes/brand/create.md) | `POST /brand` |
| [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` | | [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` |
| [`routes/brand/update.md`](routes/brand/update.md) | `PATCH /brand/{id}` |
| [`routes/brand/_id/image.delete.md`](routes/brand/_id/image.delete.md) | `DELETE /brand/{id}/image` | | [`routes/brand/_id/image.delete.md`](routes/brand/_id/image.delete.md) | `DELETE /brand/{id}/image` |
| [`routes/brand/_id/image.post.md`](routes/brand/_id/image.post.md) | `POST /brand/{id}/image` | | [`routes/brand/_id/image.post.md`](routes/brand/_id/image.post.md) | `POST /brand/{id}/image` |
| [`routes/category/create.md`](routes/category/create.md) | `POST /category` | | [`routes/category/create.md`](routes/category/create.md) | `POST /category` |
@@ -247,6 +248,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
|--------|------------| |--------|------------|
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` | | `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}` | | `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` | | `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 204 API endpoints across all modules. Overview of all 205 API endpoints across all modules.
### Notation ### Notation
@@ -216,12 +216,15 @@ _none_
#### List brands — `GET /brand` #### List brands — `GET /brand`
_none_ _none_
- Returns: `200 { brands: { id: int, name: str, pics: T30[], gsprpinfo?: str|null }[] }` - Returns: `200 { brands: { id: int, name: str, pics: T30[], gsprpinfo?: str|null }[] }`
#### Upsert brand — `PUT /brand` #### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `200 { brand: { id: int, name: str, pics: T30[], gsprpinfo?: str|null } }` - Returns: `201 { brand: { id: int, name: str, pics: T30[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Rename brand — `PATCH /brand/{id}`
- Params: `id: int, body: { name?: str, gsprpinfo?: str }`
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T30[] } }` - Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T30[] } }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 158 API endpoints across all modules. Overview of all 159 API endpoints across all modules.
### Notation ### Notation
@@ -199,12 +199,15 @@ _none_
#### List brands — `GET /brand` #### List brands — `GET /brand`
_none_ _none_
- Returns: `200 { brands: { id: int, name: str, pics: T28[], gsprpinfo?: str|null }[] }` - Returns: `200 { brands: { id: int, name: str, pics: T28[], gsprpinfo?: str|null }[] }`
#### Upsert brand — `PUT /brand` #### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `200 { brand: { id: int, name: str, pics: T28[], gsprpinfo?: str|null } }` - Returns: `201 { brand: { id: int, name: str, pics: T28[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Rename brand — `PATCH /brand/{id}`
- Params: `id: int, body: { name?: str, gsprpinfo?: str }`
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T28[] } }` - Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T28[] } }`

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete brand image Delete brand image
Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `brand` with payload `{ sender, brandId }`.
## Path parameters ## Path parameters
@@ -12,6 +12,10 @@ Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null.
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `id` | yes | integer | Brand id | | `id` | yes | integer | Brand id |
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `200` ## Response `200`
Image removed Image removed

View File

@@ -5,7 +5,8 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Add or replace brand image Add or replace brand image
Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail. Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
Replaces any existing image for this brand. Replaces any existing image for this brand. Emits SSE signal `brand`
with payload `{ sender, brandId }`.
## Path parameters ## Path parameters
@@ -22,6 +23,10 @@ Raw binary (`image/webp`).
Raw binary (`application/octet-stream`). Raw binary (`application/octet-stream`).
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `201` ## Response `201`
Image stored and linked Image stored and linked

View File

@@ -1,10 +1,12 @@
# `PUT /brand` # `POST /brand`
Protected. Requires a valid session cookie (`credentials: 'include'`). Protected. Requires a valid session cookie (`credentials: 'include'`).
Upsert brand Create brand
Insert or update brand by `name` for the selected company. `gsprpinfo` optional. Creates a brand for the selected company. Fails with `Brand already exists`
if the name is taken. Emits SSE signal `brand` with payload
`{ sender, brandId }` (`sender` echoes the caller's `x-client-id`).
## Body (JSON) ## Body (JSON)
@@ -21,9 +23,13 @@ Insert or update brand by `name` for the selected company. `gsprpinfo` optional.
} }
``` ```
## Response `200` ## Signals
Upserted brand On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `201`
Created brand
```json ```json
{ {
@@ -53,6 +59,7 @@ Upserted brand
| 401 | Unauthorized — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` | | 404 | Company not found — `{"error":"…"}` |
| 409 | Brand already exists — `{"error":"…"}` |
--- ---

View File

@@ -5,7 +5,8 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete brand Delete brand
Deletes the brand for the selected company. Fails with `Brand in use` Deletes the brand for the selected company. Fails with `Brand in use`
if any article still references it. if any article still references it. Emits SSE signal `brand`
with payload `{ sender, brandId }`.
## Path parameters ## Path parameters
@@ -14,6 +15,10 @@ if any article still references it.
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `id` | yes | integer | | | `id` | yes | integer | |
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `204` ## Response `204`
Deleted Deleted

View File

@@ -0,0 +1,65 @@
# `PATCH /brand/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Rename brand
Renames/updates a brand by id for the selected company. Fails with
`Brand already exists` if the name is taken by another brand.
When the name changes, linked articles (`brand_id`) get their
`updated_at` bumped; the response reports `touchedArticleCount`.
Emits SSE signal `brand` with payload `{ sender, brandId }`
(`sender` echoes the caller's `x-client-id`).
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `name` | no | string | |
| `gsprpinfo` | no | string | |
```json
{
"name": "Acme GmbH"
}
```
## Signals
On success, emits SSE signal: `brand` (see [`GET /events`](../events.md)).
## Response `200`
Updated brand
```json
{
"brand": {
"id": 1,
"name": "…",
"gsprpinfo": "…",
"touchedArticleCount": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid brand, invalid id, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Brand or company not found — `{"error":"…"}` |
| 409 | Brand already exists — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -29,6 +29,12 @@ to the company at connect time).
| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` | | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |
| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` | | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |
| `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` | | `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |
| `brand` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
The `brand` signal carries a JSON payload `{ sender, brandId }`:
`sender` echoes the caller's `x-client-id` header (empty string if
absent) so the sender can ignore its own event; `brandId` is the
affected brand id (`null` only if unknown). Other signals send `{}`.
Signals are only sent when the mutation actually succeeds (2xx). Signals are only sent when the mutation actually succeeds (2xx).
Failed or no-op validation errors do not emit. The `article-<sku>` Failed or no-op validation errors do not emit. The `article-<sku>`
@@ -38,7 +44,8 @@ event name uses the concrete SKU (e.g. `article-42000045`).
## Response `200` ## Response `200`
SSE stream (`text/event-stream`). Each signal is an SSE event SSE stream (`text/event-stream`). Each signal is an SSE event
whose name is the signal id and whose data is `{}`. whose name is the signal id. Most signals send `{}` as data;
`brand` sends `{ "sender": "<x-client-id>", "brandId": 12 }`.
## Errors ## Errors

View File

@@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import * as db from '../../db/brand.js'; import * as db from '../../db/brand.js';
import { emit } from '../sse.js';
const AVIF_BODY_LIMIT = 10 * 1024 * 1024; const AVIF_BODY_LIMIT = 10 * 1024 * 1024;
@@ -18,11 +19,20 @@ function brandErrorStatus(error) {
if (error.message === 'Company not found') return 404; if (error.message === 'Company not found') return 404;
if (error.message === 'Brand not found') return 404; if (error.message === 'Brand not found') return 404;
if (error.message === 'Invalid brand') return 400; if (error.message === 'Invalid brand') return 400;
if (error.message === 'Brand already exists') return 409;
if (error.message === 'Brand in use') return 409; if (error.message === 'Brand in use') return 409;
if (error.message === 'Image not found') return 404; if (error.message === 'Image not found') return 404;
return 500; return 500;
} }
/** Notify SSE subscribers after brand change; payload is `{ sender, brandId }` so receivers know which brand changed and the sender can ignore its own event. */
function emitBrandSignal(req, brandId = null) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) return;
const clientId = req.headers?.['x-client-id'];
emit('brand', { companyId, data: { sender: typeof clientId === 'string' ? clientId : '', brandId } });
}
/** /**
* Registers brand routes for the authenticated user's selected company. * Registers brand routes for the authenticated user's selected company.
* @param {import('express').Application} app * @param {import('express').Application} app
@@ -75,14 +85,44 @@ export function register(app, basePath = '') {
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
* put: */
app.get(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const brands = await db.list(req.user.id);
res.status(200).json({ brands });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('List brands failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /brand:
* post:
* tags: [Brand] * tags: [Brand]
* summary: Upsert brand * summary: Create brand
* description: | * description: |
* Insert or update brand by `name` for the selected company. `gsprpinfo` optional. * Creates a brand for the selected company. Fails with `Brand already exists`
* if the name is taken. Emits SSE signal `brand` with payload
* `{ sender, brandId }` (`sender` echoes the caller's `x-client-id`).
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: brand/upsert.md * x-signals: [brand]
* x-markdown: brand/create.md
* parameters:
* - in: header
* name: x-client-id
* required: false
* schema:
* type: string
* description: Sender id echoed as SSE payload so the sender can ignore its own event.
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -99,8 +139,8 @@ export function register(app, basePath = '') {
* name: Acme * name: Acme
* gsprpinfo: 'Acme GmbH' * gsprpinfo: 'Acme GmbH'
* responses: * responses:
* 200: * 201:
* description: Upserted brand * description: Created brand
* content: * content:
* application/json: * application/json:
* schema: * schema:
@@ -132,24 +172,14 @@ export function register(app, basePath = '') {
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
* 409:
* description: Brand already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/brand`, async (req, res) => { app.post(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const brands = await db.list(req.user.id);
res.status(200).json({ brands });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('List brands failed:', error);
res.status(status).json({ error: error.message });
}
});
app.put(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
return res.status(400).json({ error: selected.error }); return res.status(400).json({ error: selected.error });
@@ -158,14 +188,137 @@ export function register(app, basePath = '') {
const { name, gsprpinfo } = req.body ?? {}; const { name, gsprpinfo } = req.body ?? {};
try { try {
const brand = await db.upsert(req.user.id, { const brand = await db.create(req.user.id, {
name, name,
gsprpinfo, gsprpinfo,
}); });
emitBrandSignal(req, brand.id);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Create brand failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /brand/{id}:
* patch:
* tags: [Brand]
* summary: Rename brand
* description: |
* Renames/updates a brand by id for the selected company. Fails with
* `Brand already exists` if the name is taken by another brand.
* When the name changes, linked articles (`brand_id`) get their
* `updated_at` bumped; the response reports `touchedArticleCount`.
* Emits SSE signal `brand` with payload `{ sender, brandId }`
* (`sender` echoes the caller's `x-client-id`).
* security:
* - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/update.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: header
* name: x-client-id
* required: false
* schema:
* type: string
* description: Sender id echoed as SSE payload so the sender can ignore its own event.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* gsprpinfo:
* type: string
* example:
* name: Acme GmbH
* responses:
* 200:
* description: Updated brand
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* type: object
* required: [id, name, touchedArticleCount]
* properties:
* id:
* type: integer
* name:
* type: string
* gsprpinfo:
* type: string
* nullable: true
* touchedArticleCount:
* type: integer
* description: Number of linked articles whose updated_at was bumped.
* 400:
* description: Invalid brand, invalid id, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Brand or company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Brand already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.patch(`${basePath}/brand/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const { name, gsprpinfo } = req.body ?? {};
try {
const brand = await db.update(req.user.id, id, {
name,
gsprpinfo,
});
emitBrandSignal(req, id);
res.status(200).json({ brand }); res.status(200).json({ brand });
} catch (error) { } catch (error) {
const status = brandErrorStatus(error); const status = brandErrorStatus(error);
if (status === 500) console.error('Upsert brand failed:', error); if (status === 500) console.error('Update brand failed:', error);
res.status(status).json({ error: error.message }); res.status(status).json({ error: error.message });
} }
}); });
@@ -178,9 +331,11 @@ export function register(app, basePath = '') {
* summary: Delete brand * summary: Delete brand
* description: | * description: |
* Deletes the brand for the selected company. Fails with `Brand in use` * Deletes the brand for the selected company. Fails with `Brand in use`
* if any article still references it. * if any article still references it. Emits SSE signal `brand`
* with payload `{ sender, brandId }`.
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-signals: [brand]
* x-markdown: brand/delete.md * x-markdown: brand/delete.md
* parameters: * parameters:
* - in: path * - in: path
@@ -235,6 +390,7 @@ export function register(app, basePath = '') {
try { try {
await db.remove(req.user.id, id); await db.remove(req.user.id, id);
emitBrandSignal(req, id);
res.sendStatus(204); res.sendStatus(204);
} catch (error) { } catch (error) {
const status = brandErrorStatus(error); const status = brandErrorStatus(error);
@@ -251,9 +407,11 @@ export function register(app, basePath = '') {
* summary: Add or replace brand image * summary: Add or replace brand image
* description: | * description: |
* Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail. * Uploads an image for the brand (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
* Replaces any existing image for this brand. * Replaces any existing image for this brand. Emits SSE signal `brand`
* with payload `{ sender, brandId }`.
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-signals: [brand]
* parameters: * parameters:
* - in: path * - in: path
* name: id * name: id
@@ -328,9 +486,10 @@ export function register(app, basePath = '') {
* delete: * delete:
* tags: [Brand] * tags: [Brand]
* summary: Delete brand image * summary: Delete brand image
* description: Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. * description: Removes the brand's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `brand` with payload `{ sender, brandId }`.
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-signals: [brand]
* parameters: * parameters:
* - in: path * - in: path
* name: id * name: id
@@ -410,6 +569,7 @@ export function register(app, basePath = '') {
try { try {
const brand = await db.addImage(req.user.id, id, imageBuffer); const brand = await db.addImage(req.user.id, id, imageBuffer);
emitBrandSignal(req, id);
res.status(201).json({ brand }); res.status(201).json({ brand });
} catch (error) { } catch (error) {
const status = brandErrorStatus(error); const status = brandErrorStatus(error);
@@ -432,6 +592,7 @@ export function register(app, basePath = '') {
try { try {
const brand = await db.deleteImage(req.user.id, id); const brand = await db.deleteImage(req.user.id, id);
emitBrandSignal(req, id);
res.status(200).json({ brand }); res.status(200).json({ brand });
} catch (error) { } catch (error) {
const status = brandErrorStatus(error); const status = brandErrorStatus(error);

View File

@@ -38,6 +38,12 @@ export function register(app, basePath = '') {
* | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` | * | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |
* | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` | * | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |
* | `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` | * | `article-<sku>` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |
* | `brand` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
*
* The `brand` signal carries a JSON payload `{ sender, brandId }`:
* `sender` echoes the caller's `x-client-id` header (empty string if
* absent) so the sender can ignore its own event; `brandId` is the
* affected brand id (`null` only if unknown). Other signals send `{}`.
* *
* Signals are only sent when the mutation actually succeeds (2xx). * Signals are only sent when the mutation actually succeeds (2xx).
* Failed or no-op validation errors do not emit. The `article-<sku>` * Failed or no-op validation errors do not emit. The `article-<sku>`
@@ -49,7 +55,8 @@ export function register(app, basePath = '') {
* 200: * 200:
* description: | * description: |
* SSE stream (`text/event-stream`). Each signal is an SSE event * SSE stream (`text/event-stream`). Each signal is an SSE event
* whose name is the signal id and whose data is `{}`. * whose name is the signal id. Most signals send `{}` as data;
* `brand` sends `{ "sender": "<x-client-id>", "brandId": 12 }`.
* content: * content:
* text/event-stream: * text/event-stream:
* schema: * schema:
@@ -63,6 +70,9 @@ export function register(app, basePath = '') {
* *
* event: article-42000045 * event: article-42000045
* data: {} * data: {}
*
* event: brand
* data: {"sender":"tab-1","brandId":12}
* 400: * 400:
* description: No company selected * description: No company selected
* content: * content:

View File

@@ -57,12 +57,13 @@ export function closeAll() {
/** /**
* Broadcast a named signal to subscribers of the given company. * Broadcast a named signal to subscribers of the given company.
* @param {string} signal - SSE event name (e.g. `category`) * @param {string} signal - SSE event name (e.g. `category`)
* @param {{ companyId: number }} options * @param {{ companyId: number, data?: string|object }} options
*/ */
export function emit(signal, { companyId }) { export function emit(signal, { companyId, data }) {
if (companyId == null) return; if (companyId == null) return;
const frame = `event: ${signal}\ndata: {}\n\n`; const payload = data == null ? '{}' : (typeof data === 'string' ? data : JSON.stringify(data));
const frame = `event: ${signal}\ndata: ${payload}\n\n`;
for (const client of clients) { for (const client of clients) {
if (client.companyId !== companyId) continue; if (client.companyId !== companyId) continue;
if (client.res.writableEnded) continue; if (client.res.writableEnded) continue;

View File

@@ -5,6 +5,16 @@ export async function list(googleAuthId) {
return store.brand.list(googleAuthId); return store.brand.list(googleAuthId);
} }
export async function create(googleAuthId, data) {
const store = await getStore();
return store.brand.create(googleAuthId, data);
}
export async function update(googleAuthId, id, data) {
const store = await getStore();
return store.brand.update(googleAuthId, id, data);
}
export async function upsert(googleAuthId, data) { export async function upsert(googleAuthId, data) {
const store = await getStore(); const store = await getStore();
return store.brand.upsert(googleAuthId, data); return store.brand.upsert(googleAuthId, data);