Files
vendoo/app/modules/listings/service.mjs
T
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

69 lines
5.1 KiB
JavaScript

import { PlatformError } from '../../kernel/errors.mjs';
import { sanitizeRichHtml } from '../../../lib/html-templates.mjs';
const ALLOWED_FIELDS = new Set([
'platform','ai_provider','title','description','description_html','tags','photos','seller_notes',
'brand','category','size','color','condition','price','status','language','sku','storage_location',
]);
const ARRAY_LIMITS = { tags: 50, photos: 100 };
function fail(message, field, status = 400, code = 'LISTING_INPUT_INVALID') {
throw new PlatformError(message, { code, status, expose: true, details: { field } });
}
function htmlToPlainText(html) {
return String(html || '').replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n')
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/\n{3,}/g, '\n\n').trim();
}
function cleanText(value, max, field, { required = false } = {}) {
const text = String(value ?? '').trim();
if (required && !text) fail(`${field} ist erforderlich.`, field);
if (text.length > max) fail(`${field} ist zu lang.`, field);
if (/\u0000/.test(text)) fail(`${field} enthält ungültige Steuerzeichen.`, field);
return text;
}
function normalizePayload(input = {}, { partial = false } = {}) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
for (const key of Object.keys(source)) if (!ALLOWED_FIELDS.has(key)) fail(`Unbekanntes Artikelfeld: ${key}`, key);
const out = {};
const textRules = {
platform:64, ai_provider:64, title:240, description:12000, seller_notes:6000, brand:160,
category:240, size:120, color:120, condition:120, status:64, language:16, sku:80, storage_location:160,
};
for (const [field,max] of Object.entries(textRules)) if (source[field] !== undefined) out[field] = cleanText(source[field], max, field, { required: field === 'title' && !partial });
if (!partial && source.title === undefined) fail('title ist erforderlich.', 'title');
if (source.description_html !== undefined) {
const html = String(source.description_html || '');
if (html.length > 50000) fail('description_html ist zu lang.', 'description_html');
out.description_html = sanitizeRichHtml(html);
if (source.description === undefined) out.description = htmlToPlainText(out.description_html).slice(0, 12000);
}
for (const field of ['tags','photos']) if (source[field] !== undefined) {
if (!Array.isArray(source[field])) fail(`${field} muss eine Liste sein.`, field);
out[field] = source[field].map(v => cleanText(v, field === 'photos' ? 500 : 120, field)).filter(Boolean).slice(0, ARRAY_LIMITS[field]);
}
if (source.price !== undefined && source.price !== null && source.price !== '') {
const price = Number(source.price);
if (!Number.isFinite(price) || price < 0 || price > 100000000) fail('price ist ungültig.', 'price');
out.price = price;
} else if (source.price !== undefined) out.price = null;
return Object.freeze(out);
}
export class ListingsService {
constructor(repository) { this.repository = repository; }
validateCreate(input) { return normalizePayload(input, { partial: false }); }
validateUpdate(input) { return normalizePayload(input, { partial: true }); }
create(input, actorId) { return this.repository.create({ ...this.validateCreate(input), created_by: actorId || null, updated_by: actorId || null }); }
list(filters = {}) { return this.repository.list(cleanText(filters.q || '', 240, 'q'), cleanText(filters.platform || '', 64, 'platform'), cleanText(filters.status || '', 64, 'status')); }
get(id) { const item = this.repository.get(Number(id)); if (!item) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return item; }
update(id, input, actorId) { this.get(id); return this.repository.update(Number(id), { ...this.validateUpdate(input), updated_by: actorId || null }); }
softDelete(id) { this.get(id); this.repository.softDelete(Number(id)); return { ok:true }; }
duplicate(id) { const copy = this.repository.duplicate(Number(id)); if (!copy) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return copy; }
trash() { return this.repository.listDeleted(); }
trashCount() { return { count: this.repository.trashCount() }; }
restore(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.restore(Number(id)); return { ok:true }; }
permanentDelete(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.permanentDelete(Number(id)); return { ok:true }; }
emptyTrash() { const items = this.repository.listDeleted(); for (const item of items) this.repository.permanentDelete(item.id); return { ok:true, deleted:items.length }; }
}