This commit is contained in:
seb
2026-09-27 16:19:43 +02:00
parent 69f27a2942
commit 3e64fff747
13 changed files with 697 additions and 7 deletions

View File

@@ -132,6 +132,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` | | [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` | | [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` |
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` | | [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
| [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` | | [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` |
| [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` | | [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` |
| [`routes/supplier/create.md`](routes/supplier/create.md) | `POST /supplier` | | [`routes/supplier/create.md`](routes/supplier/create.md) | `POST /supplier` |

View File

@@ -91,6 +91,10 @@
{ {
"name": "Stock", "name": "Stock",
"description": "Manual stock additions and removals" "description": "Manual stock additions and removals"
},
{
"name": "Serper",
"description": "Serper Google image search and thumbnail streaming"
} }
], ],
"paths": { "paths": {
@@ -986,6 +990,142 @@
} }
} }
}, },
"/serper/images": {
"post": {
"tags": [
"Serper"
],
"summary": "Search images via Serper.dev and stream WebP thumbnails (SSE)",
"description": "Queries Google Image search via `https://google.serper.dev/images` using the\ncompany setting `keys.serper` (or server `SERPER_API_KEY`), and streams the\nresults via Server-Sent Events (`text/event-stream`).\n\n1. First emits an `images` event with the full search result list (including `imageUrl`).\n2. Then streams `thumb` events with WebP base64 thumbnails (max 200×200) as they are generated.\n3. Emits `done` when all thumbnails have completed (or `error` on failure).\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "serper/images.md",
"x-sse-events": [
{
"name": "images",
"description": "First event; list of search result images with id, url, width, and height",
"payload": "{ \"images\": [ { \"id\": 0, \"url\": \"…\", \"width\": 1000, \"height\": 800 } ] }"
},
{
"name": "thumb",
"description": "Emitted for each image as its 200×200 max WebP thumbnail is generated",
"payload": "{ \"id\": 0, \"dataUrl\": \"data:image/webp;base64,…\" }"
},
{
"name": "done",
"description": "Emitted after all thumbnails have finished streaming; closes the SSE stream",
"payload": "{ \"total\": 100 }"
},
{
"name": "error",
"description": "Emitted when an error occurs during Serper request or processing",
"payload": "{ \"error\": \"…\" }"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"q"
],
"properties": {
"q": {
"type": "string",
"description": "Image search query (e.g. \"Purize logo\")"
},
"gl": {
"type": "string",
"default": "de",
"description": "Country code"
},
"hl": {
"type": "string",
"default": "de",
"description": "Language code"
},
"num": {
"type": "integer",
"default": 100,
"minimum": 1,
"maximum": 100,
"description": "Number of search results to fetch"
},
"preferSource": {
"type": "boolean",
"default": false,
"description": "Whether to prefer downloading from the full imageUrl before falling back to Google thumbnailUrl"
}
},
"example": {
"q": "Purize logo",
"gl": "de",
"hl": "de",
"num": 100
}
}
}
}
},
"responses": {
"200": {
"description": "`text/event-stream` — emits `images`, `thumb`, `done` (and `error` on failure).\n",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Query missing, invalid input, or Serper API key not configured",
"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": "Company not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/quote": { "/quote": {
"get": { "get": {
"tags": [ "tags": [
@@ -6617,7 +6757,7 @@
"CompanySetting" "CompanySetting"
], ],
"summary": "Upsert company setting", "summary": "Upsert company setting",
"description": "Sets (upserts) a company setting for the selected company.\nOnly a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,\n`keys.openrouter`, `labelprinter.addr`, `labelprinter.type`.\n", "description": "Sets (upserts) a company setting for the selected company.\nOnly a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,\n`keys.openrouter`, `keys.serper`, `labelprinter.addr`, `labelprinter.type`.\n",
"security": [ "security": [
{ {
"cookieAuth": [] "cookieAuth": []
@@ -10834,11 +10974,43 @@
} }
] ]
}, },
"BrandPic": {
"type": "object",
"required": [
"id",
"sort",
"fullHash",
"thumbW",
"thumbH",
"thumbHash"
],
"properties": {
"id": {
"type": "integer"
},
"sort": {
"type": "integer"
},
"fullHash": {
"type": "string"
},
"thumbW": {
"type": "integer"
},
"thumbH": {
"type": "integer"
},
"thumbHash": {
"type": "string"
}
}
},
"Brand": { "Brand": {
"type": "object", "type": "object",
"required": [ "required": [
"id", "id",
"name" "name",
"pics"
], ],
"properties": { "properties": {
"id": { "id": {
@@ -10850,6 +11022,13 @@
"gsprpinfo": { "gsprpinfo": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"pics": {
"type": "array",
"description": "Brand pictures sorted by sort",
"items": {
"$ref": "#/components/schemas/BrandPic"
}
} }
} }
}, },

View File

@@ -16,7 +16,17 @@ Brand list
{ {
"id": 1, "id": 1,
"name": "…", "name": "…",
"gsprpinfo": "…" "gsprpinfo": "…",
"pics": [
{
"id": 1,
"sort": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
]
} }
] ]
} }

View File

@@ -25,7 +25,17 @@ Upserted brand
"brand": { "brand": {
"id": 1, "id": 1,
"name": "…", "name": "…",
"gsprpinfo": "…" "gsprpinfo": "…",
"pics": [
{
"id": 1,
"sort": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
]
} }
} }
``` ```

View File

@@ -6,7 +6,7 @@ Upsert company setting
Sets (upserts) a company setting for the selected company. Sets (upserts) a company setting for the selected company.
Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,
`keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. `keys.openrouter`, `keys.serper`, `labelprinter.addr`, `labelprinter.type`.
## Path parameters ## Path parameters

View File

@@ -0,0 +1,54 @@
# `POST /serper/images`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Search images via Serper.dev and stream WebP thumbnails (SSE)
Queries Google Image search via `https://google.serper.dev/images` using the
company setting `keys.serper` (or server `SERPER_API_KEY`), and streams the
results via Server-Sent Events (`text/event-stream`).
1. First emits an `images` event with the full search result list (including `imageUrl`).
2. Then streams `thumb` events with WebP base64 thumbnails (max 200×200) as they are generated.
3. Emits `done` when all thumbnails have completed (or `error` on failure).
## Body (JSON)
```json
{
"q": "Purize logo",
"gl": "de",
"hl": "de",
"num": 100
}
```
## SSE Events
The response is a `text/event-stream`. Events are emitted in order until the stream closes.
| Event | Description | Payload |
|-------|-------------|---------|
| `images` | First event; list of search result images with id, url, width, and height | `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }` |
| `thumb` | Emitted for each image as its 200×200 max WebP thumbnail is generated | `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }` |
| `done` | Emitted after all thumbnails have finished streaming; closes the SSE stream | `{ "total": 100 }` |
| `error` | Emitted when an error occurs during Serper request or processing | `{ "error": "…" }` |
## Response `200`
`text/event-stream` — emits `images`, `thumb`, `done` (and `error` on failure).
## Errors
| Status | Description |
|--------|-------------|
| 400 | Query missing, invalid input, or Serper API key not configured — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -48,6 +48,7 @@ const options = {
{ name: 'Printer', description: 'Company-scoped receipt and label printers' }, { name: 'Printer', description: 'Company-scoped receipt and label printers' },
{ name: 'Ledger', description: 'Append-only ledger transactions' }, { name: 'Ledger', description: 'Append-only ledger transactions' },
{ name: 'Stock', description: 'Manual stock additions and removals' }, { name: 'Stock', description: 'Manual stock additions and removals' },
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
], ],
}, },
apis: [ apis: [

View File

@@ -207,9 +207,25 @@
* properties: * properties:
* article: * article:
* $ref: '#/components/schemas/SupplierArticleInfo' * $ref: '#/components/schemas/SupplierArticleInfo'
* BrandPic:
* type: object
* required: [id, sort, fullHash, thumbW, thumbH, thumbHash]
* properties:
* id:
* type: integer
* sort:
* type: integer
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* Brand: * Brand:
* type: object * type: object
* required: [id, name] * required: [id, name, pics]
* properties: * properties:
* id: * id:
* type: integer * type: integer
@@ -218,6 +234,11 @@
* gsprpinfo: * gsprpinfo:
* type: string * type: string
* nullable: true * nullable: true
* pics:
* type: array
* description: Brand pictures sorted by sort
* items:
* $ref: '#/components/schemas/BrandPic'
* NumberSequence: * NumberSequence:
* type: object * type: object
* required: [id, type, prefix, padding, next] * required: [id, type, prefix, padding, next]

View File

@@ -1,5 +1,8 @@
import express from 'express';
import * as db from '../../db/brand.js'; import * as db from '../../db/brand.js';
const AVIF_BODY_LIMIT = 10 * 1024 * 1024;
function requireSelectedCompany(req) { function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId; const companyId = req.user.selectedCompanyId;
if (companyId == null) { if (companyId == null) {
@@ -15,6 +18,7 @@ function brandErrorStatus(error) {
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 in use') return 409; if (error.message === 'Brand in use') return 409;
if (error.message === 'Image not found') return 404;
return 500; return 500;
} }
@@ -237,4 +241,55 @@ export function register(app, basePath = '') {
res.status(status).json({ error: error.message }); res.status(status).json({ error: error.message });
} }
}); });
app.post(
`${basePath}/brand/:id/image`,
express.raw({ type: ['image/avif', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }),
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 imageBuffer = Buffer.isBuffer(req.body) ? req.body : null;
if (!imageBuffer?.length) {
return res.status(400).json({ error: 'AVIF image body is required' });
}
try {
const brand = await db.addImage(req.user.id, id, imageBuffer);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Add brand image failed:', error);
res.status(status).json({ error: error.message });
}
}
);
app.delete(`${basePath}/brand/:id/image`, 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' });
}
try {
const brand = await db.deleteImage(req.user.id, id);
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Delete brand image failed:', error);
res.status(status).json({ error: error.message });
}
});
} }

View File

@@ -4,6 +4,7 @@ const ALLOWED_KEYS = [
'ui.theme', 'ui.theme',
'keys.tavily', 'keys.tavily',
'keys.openrouter', 'keys.openrouter',
'keys.serper',
'labelprinter.addr', 'labelprinter.addr',
'labelprinter.type', 'labelprinter.type',
]; ];
@@ -205,7 +206,7 @@ export function register(app, basePath = '') {
* description: | * description: |
* Sets (upserts) a company setting for the selected company. * Sets (upserts) a company setting for the selected company.
* Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, * Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,
* `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. * `keys.openrouter`, `keys.serper`, `labelprinter.addr`, `labelprinter.type`.
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: companysetting/set.md * x-markdown: companysetting/set.md

347
src/api/routes/serper.js Normal file
View File

@@ -0,0 +1,347 @@
import sharp from 'sharp';
import * as dbCompanySetting from '../../db/companysetting.js';
import env from '../../config/env.js';
const CONCURRENCY = 6;
const FETCH_TIMEOUT_MS = 6000;
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
/**
* Downloads an image (trying thumbnailUrl then imageUrl, or vice versa) and
* scales it to max 200×200 WebP base64 data URL.
*
* @param {object} img
* @param {string} [img.imageUrl]
* @param {string} [img.thumbnailUrl]
* @param {object} options
* @param {AbortSignal} [options.signal]
* @param {boolean} [options.preferSource=false]
* @returns {Promise<{ dataUrl: string, width: number, height: number, size: number }>}
*/
async function generateWebpThumbnail(img, { signal, preferSource = false } = {}) {
const urls = preferSource
? [img.imageUrl, img.thumbnailUrl].filter(Boolean)
: [img.thumbnailUrl, img.imageUrl].filter(Boolean);
if (urls.length === 0) {
throw new Error('No image URL available');
}
let lastError = null;
for (const url of urls) {
if (signal?.aborted) throw new Error('Aborted');
try {
const fetchSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
: AbortSignal.timeout(FETCH_TIMEOUT_MS);
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
},
signal: fetchSignal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const thumb = await sharp(buffer)
.rotate()
.resize(200, 200, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 80 })
.toBuffer({ resolveWithObject: true });
const dataUrl = `data:image/webp;base64,${thumb.data.toString('base64')}`;
return {
dataUrl,
width: thumb.info.width,
height: thumb.info.height,
size: thumb.info.size,
};
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('Failed to load thumbnail');
}
/**
* Registers Serper image search routes.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /serper/images:
* post:
* tags: [Serper]
* summary: Search images via Serper.dev and stream WebP thumbnails (SSE)
* description: |
* Queries Google Image search via `https://google.serper.dev/images` using the
* company setting `keys.serper` (or server `SERPER_API_KEY`), and streams the
* results via Server-Sent Events (`text/event-stream`).
*
* 1. First emits an `images` event with the full search result list (including `imageUrl`).
* 2. Then streams `thumb` events with WebP base64 thumbnails (max 200×200) as they are generated.
* 3. Emits `done` when all thumbnails have completed (or `error` on failure).
* security:
* - cookieAuth: []
* x-markdown: serper/images.md
* x-sse-events:
* - name: images
* description: First event; list of search result images with id, url, width, and height
* payload: '{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }'
* - name: thumb
* description: Emitted for each image as its 200×200 max WebP thumbnail is generated
* payload: '{ "id": 0, "dataUrl": "data:image/webp;base64,…" }'
* - name: done
* description: Emitted after all thumbnails have finished streaming; closes the SSE stream
* payload: '{ "total": 100 }'
* - name: error
* description: Emitted when an error occurs during Serper request or processing
* payload: '{ "error": "…" }'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [q]
* properties:
* q:
* type: string
* description: Image search query (e.g. "Purize logo")
* gl:
* type: string
* default: de
* description: Country code
* hl:
* type: string
* default: de
* description: Language code
* num:
* type: integer
* default: 100
* minimum: 1
* maximum: 100
* description: Number of search results to fetch
* preferSource:
* type: boolean
* default: false
* description: Whether to prefer downloading from the full imageUrl before falling back to Google thumbnailUrl
* example:
* q: "Purize logo"
* gl: "de"
* hl: "de"
* num: 100
* responses:
* 200:
* description: |
* `text/event-stream` — emits `images`, `thumb`, `done` (and `error` on failure).
* content:
* text/event-stream:
* schema:
* type: string
* 400:
* description: Query missing, invalid input, or Serper API key not configured
* 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: Company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
const handleImagesSearch = async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const query = typeof req.body?.q === 'string'
? req.body.q
: (typeof req.body?.query === 'string' ? req.body.query : '');
if (!query.trim()) {
return res.status(400).json({ error: 'q is required' });
}
let apiKey = null;
try {
const setting = await dbCompanySetting.getByKey(req.user.id, 'keys.serper', { maskWriteonly: false });
apiKey = setting?.value || env.serperApiKey;
} catch (err) {
console.warn('Serper images: failed to read company setting keys.serper:', err?.message || err);
apiKey = env.serperApiKey;
}
if (!apiKey) {
return res.status(400).json({
error: 'Serper API key not configured (companysetting: keys.serper)',
});
}
const gl = typeof req.body?.gl === 'string' && req.body.gl.trim() ? req.body.gl.trim() : 'de';
const hl = typeof req.body?.hl === 'string' && req.body.hl.trim() ? req.body.hl.trim() : 'de';
let num = Number(req.body?.num);
if (!Number.isFinite(num) || num <= 0) num = 100;
num = Math.min(100, Math.max(1, Math.floor(num)));
const preferSource = Boolean(req.body?.preferSource);
// ── Open SSE Stream ──────────────────────────────────────────────
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
const abortController = new AbortController();
const onClose = () => {
abortController.abort();
};
req.on('close', onClose);
res.on('close', onClose);
const sseWrite = (event, data) => {
if (abortController.signal.aborted || res.writableEnded || res.destroyed) return;
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
// ── Fetch from Serper ────────────────────────────────────────────
let serperData;
try {
const raw = JSON.stringify({
q: query.trim(),
gl,
hl,
num,
});
const serperResponse = await fetch('https://google.serper.dev/images', {
method: 'POST',
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: raw,
signal: abortController.signal,
});
if (!serperResponse.ok) {
let errMessage = `${serperResponse.status} ${serperResponse.statusText}`;
try {
const errBody = await serperResponse.json();
if (errBody?.message) errMessage = errBody.message;
} catch {
/* ignore json parse failure */
}
sseWrite('error', { error: `Serper API error: ${errMessage}` });
return res.end();
}
serperData = await serperResponse.json();
} catch (err) {
if (abortController.signal.aborted) return;
sseWrite('error', { error: `Failed to fetch images from Serper: ${err.message}` });
return res.end();
}
const rawImages = Array.isArray(serperData?.images) ? serperData.images : [];
// ── 1. Emit list of search results (id, url, width, height) ─────
const images = rawImages.map((img, id) => ({
id,
url: img.imageUrl,
width: img.imageWidth,
height: img.imageHeight,
}));
sseWrite('images', { images });
if (images.length === 0) {
sseWrite('done', { total: 0 });
return res.end();
}
// ── 2. Stream WebP thumbnails (id, dataUrl) ──────────────────────
let cursor = 0;
async function worker() {
while (cursor < rawImages.length) {
if (abortController.signal.aborted) break;
const id = cursor++;
const img = rawImages[id];
try {
const thumb = await generateWebpThumbnail(img, {
signal: abortController.signal,
preferSource,
});
sseWrite('thumb', {
id,
dataUrl: thumb.dataUrl,
});
} catch {
if (abortController.signal.aborted) break;
sseWrite('thumb', {
id,
dataUrl: null,
});
}
}
}
const workers = Array.from(
{ length: Math.min(CONCURRENCY, rawImages.length) },
() => worker()
);
await Promise.all(workers);
if (!abortController.signal.aborted && !res.writableEnded) {
sseWrite('done', { total: rawImages.length });
res.end();
}
};
app.post(`${basePath}/serper/images`, handleImagesSearch);
app.post(`${basePath}/search/images`, handleImagesSearch);
}

View File

@@ -17,6 +17,7 @@ export default {
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production', jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
dbProvider: process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql'), dbProvider: process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql'),
openrouterFreeKey: process.env.OPENROUTER_FREE_KEY, openrouterFreeKey: process.env.OPENROUTER_FREE_KEY,
serperApiKey: process.env.SERPER_API_KEY,
sqlite: { sqlite: {
filename: process.env.SQLITE_PATH || process.env.SQLITE_FILENAME || resolve(__dirname, '../../data/dash.db'), filename: process.env.SQLITE_PATH || process.env.SQLITE_FILENAME || resolve(__dirname, '../../data/dash.db'),
}, },

View File

@@ -14,3 +14,13 @@ export async function remove(googleAuthId, id) {
const store = await getStore(); const store = await getStore();
return store.brand.delete(googleAuthId, id); return store.brand.delete(googleAuthId, id);
} }
export async function addImage(googleAuthId, brandId, image) {
const store = await getStore();
return store.brand.addImage(googleAuthId, brandId, image);
}
export async function deleteImage(googleAuthId, brandId) {
const store = await getStore();
return store.brand.deleteImage(googleAuthId, brandId);
}