feat(F-103): completed feature
This commit is contained in:
@@ -7,6 +7,7 @@ import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { aiTextToHtml, generateWithModel } from '../../../shared/ai-content.js';
|
||||
import {
|
||||
AttachProductImage,
|
||||
DetachProductImage,
|
||||
@@ -285,13 +286,14 @@ export async function registerCatalogRoutes(
|
||||
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
|
||||
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
|
||||
if (!product.description?.trim()) {
|
||||
patch.description = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve solo la descripción.'))).slice(0, 2_000);
|
||||
const raw = await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_product_description_prompt, 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Devuelve SOLO HTML válido y seguro (párrafos <p>, y si procede listas <ul><li> y <strong> para resaltar). No uses títulos ni markdown.'));
|
||||
patch.description = aiTextToHtml(raw).slice(0, 4_000);
|
||||
}
|
||||
if (!product.seoTitle?.trim()) {
|
||||
patch.seoTitle = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_title_prompt, 'Genera un título SEO breve para {{name}}. Devuelve solo el título.'))).slice(0, 200);
|
||||
patch.seoTitle = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_title_prompt, 'Genera un título SEO breve para {{name}}. Devuelve solo el título.'))).replace(/<[^>]*>/g, '').trim().slice(0, 200);
|
||||
}
|
||||
if (!product.seoDescription?.trim()) {
|
||||
patch.seoDescription = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_description_prompt, 'Genera una meta descripción SEO en español para {{name}}. Devuelve solo la descripción.'))).slice(0, 500);
|
||||
patch.seoDescription = (await generateWithModel(baseUrl, model, apiKey, promptFor(settings.ai_seo_description_prompt, 'Genera una meta descripción SEO en español para {{name}}. Devuelve solo la descripción.'))).replace(/<[^>]*>/g, '').trim().slice(0, 500);
|
||||
}
|
||||
const updated = await repository.update(id, patch);
|
||||
return reply.send(serializeProduct(updated ?? product));
|
||||
@@ -675,20 +677,6 @@ function mapProductError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error('Unknown product error');
|
||||
}
|
||||
|
||||
async function generateWithModel(baseUrl: string, model: string, apiKey: string, prompt: string): Promise<string> {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.4 }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { choices?: Array<{ message?: { content?: unknown } }>; error?: { message?: string } } | null;
|
||||
if (!response.ok) throw new AppError(502, 'AI_PROVIDER_ERROR', payload?.error?.message ?? `El proveedor IA respondió ${response.status}`);
|
||||
const content = payload?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) throw new AppError(502, 'AI_EMPTY_RESPONSE', 'El modelo IA no devolvió contenido');
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
function sanitizeSearchTelemetryQuery(query: string | undefined): string | undefined {
|
||||
if (query === undefined) {
|
||||
return undefined;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
|
||||
import { generateWithModel, loadAiSettings, requireAiConfigured } from '../../../shared/ai-content.js';
|
||||
|
||||
export interface CategoriesRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
@@ -41,9 +42,12 @@ const newCategorySchema = z.object({
|
||||
slug: slugSchema,
|
||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
description: z.string().min(1).max(4000).optional().nullable(),
|
||||
isParent: z.boolean().optional(),
|
||||
emoji: z.string().max(10).optional().nullable(),
|
||||
color: z.string().max(200).optional().nullable(),
|
||||
bgColor: z.string().max(20).optional().nullable(),
|
||||
textColor: z.string().max(20).optional().nullable(),
|
||||
});
|
||||
|
||||
const categoryPatchSchema = newCategorySchema
|
||||
@@ -156,6 +160,81 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
const generateCatSeoSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Generate category content with AI (admin)',
|
||||
description:
|
||||
'Completa con IA la descripción, el título SEO y la descripción SEO de la categoría cuando están vacíos.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 422: errorSchema },
|
||||
};
|
||||
app.post('/categories/:id/generate-seo', { schema: generateCatSeoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const existing = await repository.findById(id);
|
||||
if (!existing) throw new AppError(404, 'NOT_FOUND', 'Category not found');
|
||||
|
||||
const settings = await loadAiSettings(deps.pool, [
|
||||
'ai_category_description_prompt',
|
||||
'ai_category_seo_title_prompt',
|
||||
'ai_category_seo_description_prompt',
|
||||
]);
|
||||
const { baseUrl, model, apiKey } = requireAiConfigured(settings);
|
||||
|
||||
const replacements: Record<string, string> = { name: existing.name, description: existing.description ?? '' };
|
||||
const promptFor = (template: string | undefined, fallback: string) =>
|
||||
(template || fallback).replace(/\{\{(name|description)\}\}/g, (_, key: string) => replacements[key] ?? '');
|
||||
|
||||
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
|
||||
if (!existing.description?.trim()) {
|
||||
const raw = await generateWithModel(
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey,
|
||||
promptFor(
|
||||
settings.ai_category_description_prompt,
|
||||
'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
|
||||
),
|
||||
);
|
||||
patch.description = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1000);
|
||||
}
|
||||
if (!existing.seoTitle?.trim()) {
|
||||
const raw = await generateWithModel(
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey,
|
||||
promptFor(
|
||||
settings.ai_category_seo_title_prompt,
|
||||
'Genera un título SEO breve para esta categoría: {{name}}. Devuelve solo el título.',
|
||||
),
|
||||
);
|
||||
patch.seoTitle = raw.replace(/<[^>]*>/g, '').trim().slice(0, 200);
|
||||
}
|
||||
if (!existing.seoDescription?.trim()) {
|
||||
const raw = await generateWithModel(
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey,
|
||||
promptFor(
|
||||
settings.ai_category_seo_description_prompt,
|
||||
'Genera una meta descripción SEO en español para esta categoría: {{name}}. Devuelve solo la descripción.',
|
||||
),
|
||||
);
|
||||
patch.seoDescription = raw.replace(/<[^>]*>/g, '').trim().slice(0, 500);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return reply.send(serializeCategory(existing));
|
||||
}
|
||||
const updated = await repository.update(id, patch);
|
||||
return reply.send(serializeCategory(updated ?? existing));
|
||||
});
|
||||
}
|
||||
|
||||
function mapCategoryError(error: unknown): Error {
|
||||
@@ -183,9 +262,12 @@ function serializeCategory(category: Category) {
|
||||
url: `/categoria/${category.slug}`,
|
||||
seoTitle: category.seoTitle,
|
||||
seoDescription: category.seoDescription,
|
||||
description: category.description,
|
||||
isParent: category.isParent,
|
||||
emoji: category.emoji,
|
||||
color: category.color,
|
||||
bgColor: category.bgColor,
|
||||
textColor: category.textColor,
|
||||
createdAt: category.createdAt.toISOString(),
|
||||
updatedAt: category.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -8,10 +8,13 @@ export interface Category {
|
||||
slug: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
description?: string | null;
|
||||
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
|
||||
isParent: boolean;
|
||||
emoji?: string | null;
|
||||
color?: string | null;
|
||||
bgColor?: string | null;
|
||||
textColor?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -22,9 +25,12 @@ export interface NewCategory {
|
||||
slug: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
description?: string | null;
|
||||
isParent?: boolean;
|
||||
emoji?: string | null;
|
||||
color?: string | null;
|
||||
bgColor?: string | null;
|
||||
textColor?: string | null;
|
||||
}
|
||||
|
||||
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
|
||||
|
||||
@@ -10,9 +10,12 @@ interface CategoryRow {
|
||||
slug: string;
|
||||
seo_title: string | null;
|
||||
seo_description: string | null;
|
||||
description: string | null;
|
||||
is_parent: boolean;
|
||||
emoji: string | null;
|
||||
color: string | null;
|
||||
bg_color: string | null;
|
||||
text_color: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -25,9 +28,12 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
|
||||
['slug', 'slug'],
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
['description', 'description'],
|
||||
['isParent', 'is_parent'],
|
||||
['emoji', 'emoji'],
|
||||
['color', 'color'],
|
||||
['bgColor', 'bg_color'],
|
||||
['textColor', 'text_color'],
|
||||
];
|
||||
|
||||
export class PgCategoryRepository implements CategoryRepository {
|
||||
@@ -61,8 +67,8 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
async create(input: NewCategory): Promise<Category> {
|
||||
try {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent, emoji, color)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, description, is_parent, emoji, color, bg_color, text_color)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.parentId ?? null,
|
||||
@@ -70,9 +76,12 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
input.slug,
|
||||
input.seoTitle ?? null,
|
||||
input.seoDescription ?? null,
|
||||
input.description ?? null,
|
||||
input.isParent ?? false,
|
||||
input.emoji ?? null,
|
||||
input.color ?? null,
|
||||
input.bgColor ?? null,
|
||||
input.textColor ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -155,9 +164,12 @@ function toCategory(row: CategoryRow): Category {
|
||||
slug: row.slug,
|
||||
seoTitle: row.seo_title,
|
||||
seoDescription: row.seo_description,
|
||||
description: row.description,
|
||||
isParent: row.is_parent,
|
||||
emoji: row.emoji,
|
||||
color: row.color,
|
||||
bgColor: row.bg_color,
|
||||
textColor: row.text_color,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -153,13 +153,30 @@ export async function registerOrdersRoutes(
|
||||
const listOrdersSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'List orders (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Filtro por estado del pedido' },
|
||||
q: { type: 'string', maxLength: 120, description: 'Búsqueda por ID o email del cliente' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const orders = await service.listOrders();
|
||||
return reply.send(orders.map(serializeOrder));
|
||||
const query = request.query as { status?: string; q?: string; limit?: string; offset?: string };
|
||||
const limit = Math.min(Math.max(Number(query.limit ?? 20) || 20, 1), 100);
|
||||
const offset = Math.max(Number(query.offset ?? 0) || 0, 0);
|
||||
const { items, total } = await service.searchOrders({
|
||||
state: query.status?.trim() || undefined,
|
||||
q: query.q?.trim() || undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return reply.send({ items: items.map(serializeOrder), total });
|
||||
});
|
||||
|
||||
const getOrderAdminSchema: FastifySchema = {
|
||||
|
||||
@@ -26,6 +26,19 @@ export class OrderService implements OrderServicePort {
|
||||
return this.repo.findAll();
|
||||
}
|
||||
|
||||
async searchOrders(filters: {
|
||||
state?: string;
|
||||
q?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: OrderView[]; total: number }> {
|
||||
if (!this.repo.search) {
|
||||
const all = await this.repo.findAll();
|
||||
return { items: all.slice(filters.offset, filters.offset + filters.limit), total: all.length };
|
||||
}
|
||||
return this.repo.search(filters);
|
||||
}
|
||||
|
||||
async transition(id: string, next: OrderState, userId: string): Promise<OrderView> {
|
||||
const existing = await this.repo.findByIdAndUserId(id, userId);
|
||||
if (!existing) throw new OrderNotFoundError();
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface OrderRepository {
|
||||
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
|
||||
}): Promise<OrderView>;
|
||||
findAll(): Promise<OrderView[]>;
|
||||
search?(filters: {
|
||||
state?: string;
|
||||
q?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: OrderView[]; total: number }>;
|
||||
findById(id: string): Promise<OrderView | undefined>;
|
||||
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
|
||||
updateState(id: string, state: OrderState): Promise<OrderView | undefined>;
|
||||
@@ -25,6 +31,12 @@ export type OrderEvent =
|
||||
export interface OrderServicePort {
|
||||
create(input: CreateOrderCommand): Promise<OrderView>;
|
||||
listOrders(): Promise<OrderView[]>;
|
||||
searchOrders?(filters: {
|
||||
state?: string;
|
||||
q?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: OrderView[]; total: number }>;
|
||||
transition(id: string, next: OrderState, userId: string): Promise<OrderView>;
|
||||
transitionAdmin(id: string, next: OrderState): Promise<OrderView>;
|
||||
getOrder(id: string, userId: string): Promise<OrderView | undefined>;
|
||||
|
||||
@@ -103,6 +103,52 @@ export class PgOrderRepository implements OrderRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async search(filters: {
|
||||
state?: string;
|
||||
q?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: OrderView[]; total: number }> {
|
||||
const where: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (filters.state) {
|
||||
values.push(filters.state);
|
||||
where.push(`o.state = $${values.length}`);
|
||||
}
|
||||
if (filters.q?.trim()) {
|
||||
values.push(`%${filters.q.trim()}%`);
|
||||
where.push(`(o.id::text ILIKE $${values.length} OR u.email ILIKE $${values.length})`);
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const countResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM orders_orders o
|
||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||
${whereSql}`,
|
||||
values,
|
||||
);
|
||||
const total = Number(countResult.rows[0]?.count ?? '0');
|
||||
values.push(filters.limit, filters.offset);
|
||||
const result = await this.pool.query<OrderRow>(
|
||||
`SELECT o.* FROM orders_orders o
|
||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
||||
${whereSql}
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT $${values.length - 1} OFFSET $${values.length}`,
|
||||
values,
|
||||
);
|
||||
const items = await Promise.all(
|
||||
result.rows.map(async (order) => {
|
||||
const itemResult = await this.pool.query<ItemRow>(
|
||||
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
|
||||
[order.id],
|
||||
);
|
||||
return { ...toOrder(order), items: itemResult.rows.map(toItem) };
|
||||
}),
|
||||
);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<OrderView | undefined> {
|
||||
const orderResult = await this.pool.query<OrderRow>(
|
||||
'SELECT * FROM orders_orders WHERE id = $1',
|
||||
|
||||
@@ -28,6 +28,9 @@ const updateSettingsSchema = z.object({
|
||||
aiSeoTitlePrompt: z.string().max(2000).optional(),
|
||||
aiSeoDescriptionPrompt: z.string().max(4000).optional(),
|
||||
aiProductDescriptionPrompt: z.string().max(4000).optional(),
|
||||
aiCategoryDescriptionPrompt: z.string().max(4000).optional(),
|
||||
aiCategorySeoTitlePrompt: z.string().max(2000).optional(),
|
||||
aiCategorySeoDescriptionPrompt: z.string().max(4000).optional(),
|
||||
smtpHost: z.string().max(255).optional(),
|
||||
smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
smtpSecure: z.boolean().optional(),
|
||||
@@ -52,6 +55,9 @@ const SETTING_KEYS: Record<string, string> = {
|
||||
aiSeoTitlePrompt: 'ai_seo_title_prompt',
|
||||
aiSeoDescriptionPrompt: 'ai_seo_description_prompt',
|
||||
aiProductDescriptionPrompt: 'ai_product_description_prompt',
|
||||
aiCategoryDescriptionPrompt: 'ai_category_description_prompt',
|
||||
aiCategorySeoTitlePrompt: 'ai_category_seo_title_prompt',
|
||||
aiCategorySeoDescriptionPrompt: 'ai_category_seo_description_prompt',
|
||||
smtpHost: 'smtp_host',
|
||||
smtpPort: 'smtp_port',
|
||||
smtpSecure: 'smtp_secure',
|
||||
@@ -97,6 +103,9 @@ export async function registerStoreSettingsRoutes(
|
||||
aiSeoTitlePrompt: map['ai_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para este producto: {{name}}. Devuelve solo el título.',
|
||||
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Devuelve solo la descripción.',
|
||||
aiProductDescriptionPrompt: map['ai_product_description_prompt'] ?? 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
|
||||
aiCategoryDescriptionPrompt: map['ai_category_description_prompt'] ?? 'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
|
||||
aiCategorySeoTitlePrompt: map['ai_category_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para esta categoría: {{name}}. Devuelve solo el título.',
|
||||
aiCategorySeoDescriptionPrompt: map['ai_category_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para esta categoría: {{name}}. Devuelve solo la descripción.',
|
||||
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
|
||||
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
|
||||
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
|
||||
@@ -174,6 +183,9 @@ export async function registerStoreSettingsRoutes(
|
||||
aiSeoTitlePrompt: map['ai_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para este producto: {{name}}. Devuelve solo el título.',
|
||||
aiSeoDescriptionPrompt: map['ai_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
|
||||
aiProductDescriptionPrompt: map['ai_product_description_prompt'] ?? 'Escribe una descripción comercial clara y útil en español para este producto: {{name}}. Incluye sus beneficios y características usando solo la información disponible. Devuelve solo la descripción.',
|
||||
aiCategoryDescriptionPrompt: map['ai_category_description_prompt'] ?? 'Escribe una descripción breve y atractiva en español para esta categoría de una tienda de productos naturales y ecológicos: {{name}}. Devuelve solo la descripción, sin formato.',
|
||||
aiCategorySeoTitlePrompt: map['ai_category_seo_title_prompt'] ?? 'Genera un título SEO breve y atractivo para esta categoría: {{name}}. Devuelve solo el título.',
|
||||
aiCategorySeoDescriptionPrompt: map['ai_category_seo_description_prompt'] ?? 'Genera una meta descripción SEO en español, clara y persuasiva, para esta categoría: {{name}}. Devuelve solo la descripción.',
|
||||
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
|
||||
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
|
||||
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
|
||||
|
||||
Reference in New Issue
Block a user