69 lines
5.1 KiB
JavaScript
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(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<')
|
|
.replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/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 }; }
|
|
}
|