21 Commits

Author SHA1 Message Date
fcarraUniSa
8d12a2558d Update App.tsx 2025-12-23 12:23:45 +01:00
fcarraUniSa
f38ad1b6e7 Update TemplateEditor.tsx 2025-12-23 12:23:30 +01:00
fcarraUniSa
394b87dc25 Update server.js 2025-12-23 12:23:07 +01:00
fcarraUniSa
3390bd8f17 Update storage.ts 2025-12-23 12:22:50 +01:00
fcarraUniSa
95b9a0f820 Update server.js 2025-12-23 12:00:48 +01:00
fcarraUniSa
4e5226d213 Update storage.ts 2025-12-23 12:00:16 +01:00
fcarraUniSa
947c5d3952 Update TemplateEditor.tsx 2025-12-23 11:59:55 +01:00
fcarraUniSa
7c78819b86 Delete .github/workflows directory 2025-12-23 11:37:21 +01:00
fcarraUniSa
ce927c88ae Update docker-image.yml 2025-12-23 11:35:01 +01:00
fcarraUniSa
61a16442d5 Update Dockerfile 2025-12-23 11:33:10 +01:00
fcarraUniSa
44d944a5d5 Update Dockerfile 2025-12-23 11:29:25 +01:00
fcarraUniSa
35a3ac6c24 Update docker-image.yml 2025-12-23 11:19:29 +01:00
fcarraUniSa
05bd36f68a Update docker-image.yml 2025-12-23 11:07:27 +01:00
fcarraUniSa
383c11f148 Create docker-image.yml 2025-12-23 10:54:10 +01:00
fcarraUniSa
753d8c4ab1 Update storage.ts 2025-12-23 10:35:29 +01:00
fcarraUniSa
68f3b1d6c0 Update server.js 2025-12-23 10:35:04 +01:00
fcarraUniSa
8f01fc8a6f Update types.ts 2025-12-23 10:34:47 +01:00
fcarraUniSa
155f523a4c Update README.md 2025-12-19 08:54:22 +01:00
fcarraUniSa
64e696d0a6 Update Dockerfile 2025-12-19 08:53:38 +01:00
fcarraUniSa
f85bf8df59 Update server.js 2025-12-19 08:52:55 +01:00
fcarraUniSa
db71ddbd4c Update docker-compose.yml 2025-12-19 08:52:27 +01:00
8 changed files with 289 additions and 327 deletions

27
App.tsx
View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import TemplateList from './components/TemplateList'; import TemplateList from './components/TemplateList';
import TemplateEditor from './components/TemplateEditor'; import TemplateEditor from './components/TemplateEditor';
import { ViewState, EmailTemplate, SQL_SCHEMA } from './types'; import { ViewState, EmailTemplate, SQL_SCHEMA } from './types';
import { getTemplates, deleteTemplate } from './services/storage'; import { getTemplates, deleteTemplate, generateUUID } from './services/storage';
import { Database } from 'lucide-react'; import { Database } from 'lucide-react';
const App: React.FC = () => { const App: React.FC = () => {
@@ -18,9 +19,14 @@ const App: React.FC = () => {
const refreshTemplates = async () => { const refreshTemplates = async () => {
setIsLoading(true); setIsLoading(true);
const data = await getTemplates(); try {
setTemplates(data); const data = await getTemplates();
setIsLoading(false); setTemplates(data);
} catch (err) {
console.error("Refresh Error:", err);
} finally {
setIsLoading(false);
}
}; };
const handleCreate = () => { const handleCreate = () => {
@@ -34,10 +40,9 @@ const App: React.FC = () => {
}; };
const handleClone = (t: EmailTemplate) => { const handleClone = (t: EmailTemplate) => {
// Create a copy of the template with a new ID and updated name
const clonedTemplate: EmailTemplate = { const clonedTemplate: EmailTemplate = {
...t, ...t,
id: crypto.randomUUID(), // Generate new ID so it's treated as a new insert id: generateUUID(),
name: `${t.name} (Copia)`, name: `${t.name} (Copia)`,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -64,7 +69,7 @@ const App: React.FC = () => {
<> <>
{isLoading ? ( {isLoading ? (
<div className="flex h-screen items-center justify-center text-slate-500"> <div className="flex h-screen items-center justify-center text-slate-500">
Caricamento template... Caricamento...
</div> </div>
) : ( ) : (
<TemplateList <TemplateList
@@ -95,17 +100,13 @@ const App: React.FC = () => {
/> />
)} )}
{/* Database Schema Modal (Global Help) */}
{showSchema && ( {showSchema && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6"> <div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Database className="text-brand-600"/> <Database className="text-brand-600"/>
Configurazione Database per n8n Configurazione DB
</h2> </h2>
<p className="text-slate-600 text-sm mb-4">
Per far funzionare questa app con il tuo software interno, crea questa tabella nel tuo database MySQL o PostgreSQL.
</p>
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto custom-scrollbar mb-4"> <div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto custom-scrollbar mb-4">
<pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre> <pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre>
</div> </div>
@@ -113,7 +114,7 @@ const App: React.FC = () => {
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(SQL_SCHEMA); navigator.clipboard.writeText(SQL_SCHEMA);
alert("Schema copiato!"); alert("Copiato!");
}} }}
className="px-4 py-2 bg-slate-100 text-slate-700 font-medium rounded hover:bg-slate-200" className="px-4 py-2 bg-slate-100 text-slate-700 font-medium rounded hover:bg-slate-200"
> >

View File

@@ -3,44 +3,41 @@ FROM node:20-bookworm AS builder
WORKDIR /app WORKDIR /app
# Copy ONLY package.json # Copiamo il package.json. Non usiamo npm ci perché richiede obbligatoriamente il package-lock.json
COPY package.json ./ COPY package.json ./
# Install dependencies # Installiamo tutte le dipendenze necessarie per il build (incluse le devDependencies)
# --legacy-peer-deps: Ignores peer dependency conflicts
# --no-audit: Skips vulnerability audit (faster, less noise)
# --no-fund: Hides funding messages
RUN npm install --legacy-peer-deps --no-audit --no-fund RUN npm install --legacy-peer-deps --no-audit --no-fund
# Copy the rest of the application source code # Copiamo il resto del codice sorgente
COPY . . COPY . .
# Build the frontend assets # Eseguiamo il build del frontend (genera la cartella /dist)
RUN npm run build RUN npm run build
# Stage 2: Setup the Production Server # Stage 2: Setup del Server di Produzione
FROM node:20-bookworm-slim FROM node:20-bookworm-slim
WORKDIR /app WORKDIR /app
# Copy ONLY package.json # Copiamo il package.json per installare le dipendenze di runtime
COPY package.json ./ COPY package.json ./
# Install ONLY production dependencies # Installiamo SOLO le dipendenze di produzione per mantenere l'immagine leggera
RUN npm install --omit=dev --legacy-peer-deps --no-audit --no-fund RUN npm install --omit=dev --legacy-peer-deps --no-audit --no-fund
# Copy the built frontend assets from the 'builder' stage # Copiamo i file compilati (dist) dallo stage builder
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Copy the server entry point # Copiamo il file del server Node.js
COPY server.js ./ COPY server.js ./
# Set environment variables # Configurazioni di ambiente
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
# Expose the port # Esponiamo la porta 3000
EXPOSE 3000 EXPOSE 3000
# Start the Node.js server # Avvio dell'applicazione
CMD ["npm", "start"] CMD ["node", "server.js"]

View File

@@ -1,20 +1,42 @@
<div align="center"> <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div> </div>
# Run and deploy your AI Studio app # Email Template Builder
This contains everything you need to run your app locally. Uno strumento visuale per creare, gestire ed esportare template email HTML con placeholder dinamici.
View your app in AI Studio: https://ai.studio/apps/drive/1PJrPIeFdvYwt0ImdYe7PMH6YCDGTYQxH
## Run Locally ## Run Locally
**Prerequisites:** Node.js **Prerequisites:** Node.js
1. Install dependencies: `npm install`
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app: 3. Run the app: `npm run dev`
`npm run dev`
## Deploy with Docker
This application can be deployed as a complete stack with its own MySQL database:
1. Set your `GEMINI_API_KEY` in the environment variables
2. Run `docker-compose up -d` to start the stack
3. Access the application at `http://localhost:3000`
The stack includes:
- Frontend/Backend service (Node.js + React)
- MySQL database (with persistent data)
- Automatic database initialization
## Deploy on Portainer
To deploy on Portainer as a stack:
1. Create a new stack in Portainer
2. Copy the contents of [docker-compose.yml](docker-compose.yml) into the editor
3. Add your `API_KEY` environment variable in the Portainer interface
4. Deploy the stack
The application will automatically:
- Create and initialize the MySQL database
- Start the application service
- Connect to the internal database

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { EmailTemplate } from '../types'; import { EmailTemplate } from '../types';
import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode } from '../services/storage'; import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode, generateUUID } from '../services/storage';
import { generateEmailContent } from '../services/geminiService'; import { generateEmailContent } from '../services/geminiService';
import RichTextEditor from './RichTextEditor'; import RichTextEditor from './RichTextEditor';
import { import {
@@ -45,7 +46,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const [showAiModal, setShowAiModal] = useState(false); const [showAiModal, setShowAiModal] = useState(false);
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
// Variable detection logic
const detectVariables = useCallback(() => { const detectVariables = useCallback(() => {
const regex = /\{\{([\w\d_-]+)\}\}/g; const regex = /\{\{([\w\d_-]+)\}\}/g;
const allText = `${subject} ${header} ${body} ${footer}`; const allText = `${subject} ${header} ${body} ${footer}`;
@@ -58,7 +58,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
detectVariables(); detectVariables();
}, [detectVariables]); }, [detectVariables]);
// Clear name error when typing
useEffect(() => { useEffect(() => {
if (nameError) setNameError(''); if (nameError) setNameError('');
}, [name]); }, [name]);
@@ -67,30 +66,35 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const newKey = generateTemplateKey(name); const newKey = generateTemplateKey(name);
if (!newKey) { if (!newKey) {
setNameError('Il nome del template non può essere vuoto o contenere solo simboli.'); setNameError('Il nome del template non può essere vuoto.');
alert('Il nome del template non può essere vuoto.');
return; return;
} }
setIsSaving(true); setIsSaving(true);
console.log("Saving process started for:", name);
try { try {
// Check for duplicates // 1. Get current templates to check duplicates
const allTemplates = await getTemplates(); let allTemplates: EmailTemplate[] = [];
try {
allTemplates = await getTemplates();
} catch (err) {
console.warn("Could not fetch templates for duplicate check, proceeding anyway...", err);
}
const isDuplicate = allTemplates.some(t => { const isDuplicate = allTemplates.some(t => {
// Exclude current template if we are editing
if (initialTemplate && t.id === initialTemplate.id) return false; if (initialTemplate && t.id === initialTemplate.id) return false;
return generateTemplateKey(t.name) === newKey; return generateTemplateKey(t.name) === newKey;
}); });
if (isDuplicate) { if (isDuplicate) {
setNameError('Esiste già un template con questo nome.'); setNameError('Esiste già un template con questo nome.');
alert('Un template con questo nome (o ID risultante) esiste già. Per favore scegli un nome univoco.');
setIsSaving(false); setIsSaving(false);
return; return;
} }
const newTemplate: EmailTemplate = { const newTemplate: EmailTemplate = {
id: initialTemplate?.id || crypto.randomUUID(), id: initialTemplate?.id || generateUUID(),
name, name,
description, description,
subject, subject,
@@ -100,10 +104,14 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
variables: detectedVars, variables: detectedVars,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
console.log("Sending POST to server with data:", newTemplate);
await saveTemplate(newTemplate); await saveTemplate(newTemplate);
console.log("Save successful!");
onSave(); onSave();
} catch (e) { } catch (e: any) {
alert("Impossibile salvare il template. Controlla i log del server."); console.error("SAVE ERROR DETAILS:", e);
alert(`Errore di salvataggio: ${e.message}`);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -158,7 +166,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
} }
}; };
// Maps internal tab keys to Italian display names
const tabNames: Record<string, string> = { const tabNames: Record<string, string> = {
header: 'Testata', header: 'Testata',
body: 'Corpo', body: 'Corpo',
@@ -167,7 +174,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
return ( return (
<div className="flex flex-col h-screen bg-slate-50"> <div className="flex flex-col h-screen bg-slate-50">
{/* Top Bar */}
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-20 shrink-0"> <header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-20 shrink-0">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-full text-slate-500"> <button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-full text-slate-500">
@@ -199,11 +205,8 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</header> </header>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left: Inputs */}
<div className="w-1/2 flex flex-col border-r border-slate-200 bg-white overflow-y-auto custom-scrollbar"> <div className="w-1/2 flex flex-col border-r border-slate-200 bg-white overflow-y-auto custom-scrollbar">
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Metadata */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-1">Nome Template</label> <label className="block text-sm font-semibold text-slate-700 mb-1">Nome Template</label>
@@ -215,7 +218,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
placeholder="es. Email di Benvenuto" placeholder="es. Email di Benvenuto"
/> />
{nameError && <p className="text-red-500 text-xs mt-1">{nameError}</p>} {nameError && <p className="text-red-500 text-xs mt-1">{nameError}</p>}
<p className="text-xs text-slate-400 mt-1">Deve essere univoco. Usato per generare la chiave DB: <span className="font-mono bg-slate-100 px-1">{generateTemplateKey(name) || '...'}</span></p> <p className="text-xs text-slate-400 mt-1">Deve essere univoco. Chiave DB: <span className="font-mono bg-slate-100 px-1">{generateTemplateKey(name) || '...'}</span></p>
</div> </div>
<div> <div>
@@ -224,7 +227,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white resize-none text-sm" className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white resize-none text-sm"
placeholder="Note interne (es. Usato per i nuovi iscritti)" placeholder="Note interne..."
rows={2} rows={2}
/> />
</div> </div>
@@ -236,19 +239,17 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
value={subject} value={subject}
onChange={e => setSubject(e.target.value)} onChange={e => setSubject(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white" className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white"
placeholder="Oggetto... (supporta {{placeholder}})" placeholder="Oggetto..."
/> />
</div> </div>
</div> </div>
{/* Variable Manager */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200"> <div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Variabili Attive</span> <span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Variabili</span>
<span className="text-xs text-slate-400">Rilevate automaticamente dal testo</span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile rilevata. Scrivi {'{{nome}}'} per aggiungerne una.</span>} {detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile. Usa {'{{nome}}'}.</span>}
{detectedVars.map(v => ( {detectedVars.map(v => (
<span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono"> <span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono">
{`{{${v}}}`} {`{{${v}}}`}
@@ -257,7 +258,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</div> </div>
</div> </div>
{/* Tabs */}
<div className="flex border-b border-slate-200"> <div className="flex border-b border-slate-200">
{(['header', 'body', 'footer'] as const).map((tab) => ( {(['header', 'body', 'footer'] as const).map((tab) => (
<button <button
@@ -274,59 +274,43 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
))} ))}
</div> </div>
{/* Editor Area */}
<div className="relative"> <div className="relative">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<label className="text-sm font-semibold text-slate-700">Editor Contenuti</label> <label className="text-sm font-semibold text-slate-700">Editor</label>
<div className="flex gap-2"> <button
<button onClick={() => setShowAiModal(true)}
onClick={() => setShowAiModal(true)} className="text-xs flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium bg-purple-50 px-2 py-1 rounded"
className="text-xs flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium bg-purple-50 px-2 py-1 rounded" >
> <Wand2 size={14} />
<Wand2 size={14} /> IA
Genera con IA </button>
</button>
</div>
</div> </div>
<div className="h-[400px]"> <div className="h-[400px]">
<RichTextEditor <RichTextEditor
key={activeTab} // Force remount on tab change to sync contentEditable key={activeTab}
value={getActiveContent()} value={getActiveContent()}
onChange={setActiveContent} onChange={setActiveContent}
placeholder={`Crea qui la sezione ${tabNames[activeTab]}...`} placeholder={`Scrivi qui...`}
className="h-full shadow-sm" className="h-full shadow-sm"
/> />
</div> </div>
<p className="mt-2 text-xs text-slate-500">
Usa il pulsante "Variabile" nella toolbar per inserire placeholder come {'{{nome}}'}.
</p>
</div> </div>
</div> </div>
</div> </div>
{/* Right: Live Preview */}
<div className="w-1/2 bg-slate-100 flex flex-col overflow-hidden"> <div className="w-1/2 bg-slate-100 flex flex-col overflow-hidden">
<div className="p-3 bg-white border-b border-slate-200 flex justify-between items-center shadow-sm z-10 shrink-0"> <div className="p-3 bg-white border-b border-slate-200 flex justify-between items-center shadow-sm shrink-0">
<span className="font-semibold text-slate-600 flex items-center gap-2"> <span className="font-semibold text-slate-600 flex items-center gap-2">
<Eye size={18} /> Anteprima Live <Eye size={18} /> Anteprima
</span> </span>
<div className="text-xs text-slate-400">
Renderizzato come HTML standard
</div>
</div> </div>
{/* Scrollable container: flex-1 ensures it takes available space, overflow-y-auto enables scrolling */}
<div className="flex-1 p-8 overflow-y-auto custom-scrollbar"> <div className="flex-1 p-8 overflow-y-auto custom-scrollbar">
{/* mx-auto centers the card without using flexbox on the parent which can cause scroll issues */}
<div className="w-full max-w-2xl bg-white shadow-xl rounded-lg overflow-hidden min-h-[600px] flex flex-col mx-auto"> <div className="w-full max-w-2xl bg-white shadow-xl rounded-lg overflow-hidden min-h-[600px] flex flex-col mx-auto">
{/* Simulate Subject Line in Preview */}
<div className="bg-slate-50 border-b border-slate-100 p-4"> <div className="bg-slate-50 border-b border-slate-100 p-4">
<span className="text-xs font-bold text-slate-400 uppercase">Oggetto:</span> <span className="text-xs font-bold text-slate-400 uppercase">Oggetto:</span>
<p className="text-sm font-medium text-slate-800">{subject.replace(/\{\{([\w\d_-]+)\}\}/g, (match, p1) => `<span class="bg-yellow-100 text-yellow-800 px-1 rounded">${match}</span>`)}</p> <p className="text-sm font-medium text-slate-800">{subject}</p>
</div> </div>
{/* Email Content */}
<div dangerouslySetInnerHTML={{ __html: header }} /> <div dangerouslySetInnerHTML={{ __html: header }} />
<div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" /> <div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" />
<div dangerouslySetInnerHTML={{ __html: footer }} /> <div dangerouslySetInnerHTML={{ __html: footer }} />
@@ -335,122 +319,50 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</div> </div>
</div> </div>
{/* SQL Modal */}
{showSqlModal && ( {showSqlModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl p-6 flex flex-col max-h-[90vh]"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl p-6 flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2"> <h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Database size={20} className="text-brand-600"/> <Database size={20} className="text-brand-600"/>
Dettagli Integrazione Integrazione
</h3> </h3>
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600"> <button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600 text-2xl">&times;</button>
<span className="text-2xl">&times;</span>
</button>
</div> </div>
<div className="space-y-6 overflow-y-auto">
<div className="space-y-8 overflow-y-auto px-1 pb-4">
{/* INSERT Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2 block">1. Setup (Esegui una volta nel DB)</label>
<div className="relative">
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono custom-scrollbar h-40">
{currentInsertSql}
</pre>
<button
onClick={() => navigator.clipboard.writeText(currentInsertSql)}
className="absolute top-2 right-2 p-2 bg-slate-700 text-white rounded hover:bg-slate-600"
title="Copia SQL INSERT"
>
<Copy size={16} />
</button>
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2 block">2. Recupero (Nodo SQL n8n)</label>
<div className="relative">
<pre className="bg-slate-800 text-slate-50 p-4 rounded-lg overflow-x-auto text-sm font-mono custom-scrollbar h-40">
{currentSelectSql}
</pre>
<button
onClick={() => navigator.clipboard.writeText(currentSelectSql)}
className="absolute top-2 right-2 p-2 bg-slate-600 text-white rounded hover:bg-slate-500"
title="Copia SQL SELECT"
>
<Copy size={16} />
</button>
</div>
</div>
</div>
{/* n8n Code Section */}
<div> <div>
<label className="text-xs font-bold text-purple-600 uppercase tracking-wider mb-2 flex items-center gap-2"> <label className="text-xs font-bold text-slate-500 uppercase block mb-1">SQL INSERT/UPDATE</label>
<Code size={16} /> <pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentInsertSql}</pre>
3. Popolamento (Nodo Codice n8n) </div>
</label> <div>
<div className="relative"> <label className="text-xs font-bold text-slate-500 uppercase block mb-1">JS n8n</label>
<pre className="bg-slate-50 border border-slate-200 text-slate-700 p-4 rounded-lg overflow-x-auto text-sm font-mono custom-scrollbar max-h-60"> <pre className="bg-slate-50 border border-slate-200 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentN8nCode}</pre>
{currentN8nCode}
</pre>
<button
onClick={() => navigator.clipboard.writeText(currentN8nCode)}
className="absolute top-2 right-2 p-2 bg-white border border-slate-200 text-slate-600 rounded hover:bg-slate-50 shadow-sm"
title="Copia Codice JS"
>
<Copy size={16} />
</button>
</div>
<p className="text-xs text-slate-500 mt-2">
Incolla questo codice in un <strong>Nodo Code</strong> collegato dopo il tuo nodo SQL. Sostituisci <code>REPLACE_WITH_VALUE</code> con le variabili reali (es. <code>$('NodeName').item.json.name</code>).
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* AI Modal */}
{showAiModal && ( {showAiModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6">
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2"> <h3 className="text-lg font-bold text-slate-800 mb-2">Generatore IA</h3>
<Wand2 className="text-purple-600"/>
Generatore Contenuti IA
</h3>
<p className="text-sm text-slate-600 mb-4">
Descrivi cosa vuoi per la sezione <strong>{tabNames[activeTab]}</strong>.
L'IA genererà il codice HTML con i placeholder necessari.
</p>
<textarea <textarea
className="w-full h-32 border border-slate-300 rounded p-3 text-sm focus:ring-2 focus:ring-purple-500 outline-none mb-4" className="w-full h-32 border border-slate-300 rounded p-3 text-sm focus:ring-2 focus:ring-purple-500 outline-none mb-4"
placeholder="es. Scrivi una notifica gentile che avvisi l'utente del cambio password avvenuto con successo. Includi un placeholder per il nome utente." placeholder="Descrivi cosa generare..."
value={aiPrompt} value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)} onChange={(e) => setAiPrompt(e.target.value)}
/> />
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button onClick={() => setShowAiModal(false)} className="px-4 py-2 text-slate-600">Annulla</button>
onClick={() => setShowAiModal(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded"
>
Annulla
</button>
<button <button
onClick={handleAiGenerate} onClick={handleAiGenerate}
disabled={isGenerating || !aiPrompt} disabled={isGenerating || !aiPrompt}
className={`px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2 ${isGenerating ? 'opacity-70' : ''}`} className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2"
> >
{isGenerating ? 'Generazione...' : 'Genera HTML'} {isGenerating ? 'Generazione...' : 'Genera HTML'}
{!isGenerating && <Wand2 size={16} />}
</button> </button>
</div> </div>
{!process.env.API_KEY && (
<p className="mt-3 text-xs text-red-500">
Nota: API_KEY non rilevata nell'ambiente. Questa funzione richiede una chiave API Gemini.
</p>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,20 +1,41 @@
version: '3.8' version: '3.8'
services: services:
db:
image: mysql:8.0
container_name: email_templates_db
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: email_templates
MYSQL_USER: appuser
MYSQL_PASSWORD: apppassword
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
app: app:
build: . build: .
container_name: email_templates_app
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
# Database Connection Configuration DB_HOST: db
# We removed the ':-default' syntax. DB_USER: appuser
# Now, the container will strictly use what is provided by the host environment. DB_PASSWORD: apppassword
- DB_TYPE=${DB_TYPE} DB_NAME: email_templates
- DB_HOST=${DB_HOST} DB_PORT: 3306
- DB_PORT=${DB_PORT} DB_TYPE: mysql
- DB_USER=${DB_USER} API_KEY: ${API_KEY}
- DB_PASSWORD=${DB_PASSWORD} depends_on:
- DB_NAME=${DB_NAME} db:
condition: service_healthy
restart: unless-stopped
# Application Keys volumes:
- API_KEY=${API_KEY} db_data:

155
server.js
View File

@@ -1,3 +1,4 @@
import express from 'express'; import express from 'express';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -14,12 +15,27 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// 1. CORS deve essere la prima cosa per gestire le richieste OPTIONS dei browser
app.use(cors()); app.use(cors());
app.use(express.json()); app.options('*', cors());
// 2. Logging immediato di ogni richiesta
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} - Status: ${res.statusCode} (${duration}ms)`);
});
next();
});
// 3. Parser JSON con limite aumentato (fondamentale per template HTML grandi)
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ limit: '10mb', extended: true }));
// 4. File statici
app.use(express.static(path.join(__dirname, 'dist'))); app.use(express.static(path.join(__dirname, 'dist')));
// DB Configuration
// Normalize DB_TYPE: allow 'postgres', 'postgresql', 'Postgres', etc.
const rawDbType = (process.env.DB_TYPE || 'mysql').toLowerCase().trim(); const rawDbType = (process.env.DB_TYPE || 'mysql').toLowerCase().trim();
const DB_TYPE = (rawDbType === 'postgres' || rawDbType === 'postgresql') ? 'postgres' : 'mysql'; const DB_TYPE = (rawDbType === 'postgres' || rawDbType === 'postgresql') ? 'postgres' : 'mysql';
@@ -31,83 +47,48 @@ const dbConfig = {
port: process.env.DB_PORT || (DB_TYPE === 'postgres' ? 5432 : 3306), port: process.env.DB_PORT || (DB_TYPE === 'postgres' ? 5432 : 3306),
}; };
// Debug Log: Print config to console (masking password) console.log('--- App Version: 1.0.2 ---');
console.log('--- Database Configuration ---'); console.log(`DB Type: ${DB_TYPE}`);
console.log(`Type: ${DB_TYPE} (Input: ${process.env.DB_TYPE || 'default'})`); console.log(`DB Host: ${dbConfig.host}`);
console.log(`Host: ${dbConfig.host}`);
console.log(`User: ${dbConfig.user}`);
console.log(`Database: ${dbConfig.database}`);
console.log(`Port: ${dbConfig.port}`);
console.log(`Password: ${dbConfig.password ? '******' : '(Not Set)'}`);
console.log('------------------------------');
let pool; let pool;
// Initialize DB Connection const initDB = async (retries = 5) => {
const initDB = async () => { while (retries > 0) {
try { try {
if (!dbConfig.host || !dbConfig.user || !dbConfig.database) { if (!dbConfig.host || !dbConfig.user || !dbConfig.database) {
throw new Error("Missing required database environment variables (DB_HOST, DB_USER, or DB_NAME)."); throw new Error("Missing required database environment variables (DB_HOST, DB_USER, DB_NAME).");
}
if (DB_TYPE === 'postgres') {
const { Pool } = pg;
pool = new Pool(dbConfig);
await pool.query('SELECT 1'); // Test connection
} else {
pool = mysql.createPool(dbConfig);
await pool.query('SELECT 1'); // Test connection
}
console.log(`Connected to ${DB_TYPE} database successfully.`);
return;
} catch (err) {
retries -= 1;
console.error(`Database connection failed (${retries} retries left):`, err.message);
if (retries === 0) {
console.error("FATAL: Could not connect to database.");
// Non usciamo per permettere al server di servire i file statici e mostrare errori via API
} else {
await new Promise(resolve => setTimeout(resolve, 5000));
}
} }
if (DB_TYPE === 'postgres') {
const { Pool } = pg;
pool = new Pool(dbConfig);
// Create table for Postgres
// Using JSONB for variables
await pool.query(`
CREATE TABLE IF NOT EXISTS email_templates (
id VARCHAR(255) PRIMARY KEY,
template_key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
subject VARCHAR(255),
header_html TEXT,
body_html TEXT,
footer_html TEXT,
full_html TEXT,
required_variables JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
} else {
// MySQL
pool = mysql.createPool(dbConfig);
// Create table for MySQL
await pool.query(`
CREATE TABLE IF NOT EXISTS email_templates (
id VARCHAR(255) PRIMARY KEY,
template_key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
subject VARCHAR(255),
header_html TEXT,
body_html TEXT,
footer_html TEXT,
full_html TEXT,
required_variables JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
`);
}
console.log(`Connected to ${DB_TYPE} database successfully.`);
} catch (err) {
console.error('Database connection failed:', err);
process.exit(1);
} }
}; };
initDB(); initDB();
// API Routes
// GET All Templates
app.get('/api/templates', async (req, res) => { app.get('/api/templates', async (req, res) => {
try { try {
if (!pool) return res.status(503).json({ error: "Database not connected" });
let rows; let rows;
if (DB_TYPE === 'postgres') { if (DB_TYPE === 'postgres') {
const result = await pool.query('SELECT * FROM email_templates ORDER BY updated_at DESC'); const result = await pool.query('SELECT * FROM email_templates ORDER BY updated_at DESC');
@@ -117,7 +98,6 @@ app.get('/api/templates', async (req, res) => {
rows = result; rows = result;
} }
// Map DB fields back to frontend types
const templates = rows.map(row => ({ const templates = rows.map(row => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -126,21 +106,24 @@ app.get('/api/templates', async (req, res) => {
header: row.header_html, header: row.header_html,
body: row.body_html, body: row.body_html,
footer: row.footer_html, footer: row.footer_html,
variables: typeof row.required_variables === 'string' ? JSON.parse(row.required_variables) : row.required_variables, variables: typeof row.required_variables === 'string' ? JSON.parse(row.required_variables) : (row.required_variables || []),
updatedAt: row.updated_at updatedAt: row.updated_at
})); }));
res.json(templates); res.json(templates);
} catch (err) { } catch (err) {
console.error(err); console.error("Fetch Error:", err);
res.status(500).json({ error: 'Failed to fetch templates' }); res.status(500).json({ error: 'Failed to fetch templates', details: err.message });
} }
}); });
// SAVE (Upsert) Template
app.post('/api/templates', async (req, res) => { app.post('/api/templates', async (req, res) => {
const t = req.body; const t = req.body;
// Ensure default values to prevent undefined errors in Postgres
if (!t.name || !t.id) {
return res.status(400).json({ error: "Name and ID are required" });
}
const header = t.header || ''; const header = t.header || '';
const body = t.body || ''; const body = t.body || '';
const footer = t.footer || ''; const footer = t.footer || '';
@@ -162,8 +145,9 @@ app.post('/api/templates', async (req, res) => {
]; ];
try { try {
if (!pool) throw new Error("Database not connected");
if (DB_TYPE === 'postgres') { if (DB_TYPE === 'postgres') {
// Postgres requires explicit casting for JSONB parameters when passed as strings ($10::jsonb)
const query = ` const query = `
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at) INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, NOW())
@@ -182,8 +166,8 @@ app.post('/api/templates', async (req, res) => {
await pool.query(query, params); await pool.query(query, params);
} else { } else {
const query = ` const query = `
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at) INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
template_key = VALUES(template_key), template_key = VALUES(template_key),
name = VALUES(name), name = VALUES(name),
@@ -193,22 +177,20 @@ app.post('/api/templates', async (req, res) => {
body_html = VALUES(body_html), body_html = VALUES(body_html),
footer_html = VALUES(footer_html), footer_html = VALUES(footer_html),
full_html = VALUES(full_html), full_html = VALUES(full_html),
required_variables = VALUES(required_variables), required_variables = VALUES(required_variables);
updated_at = NOW();
`; `;
await pool.query(query, params); await pool.query(query, params);
} }
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error("Save Template Error:", err); console.error("DB Save Error:", err.message);
// Return specific error details to help debugging on the client res.status(500).json({ error: 'Database save failed', details: err.message });
res.status(500).json({ error: 'Failed to save template', details: err.message });
} }
}); });
// DELETE Template
app.delete('/api/templates/:id', async (req, res) => { app.delete('/api/templates/:id', async (req, res) => {
try { try {
if (!pool) return res.status(503).json({ error: "Database not connected" });
if (DB_TYPE === 'postgres') { if (DB_TYPE === 'postgres') {
await pool.query('DELETE FROM email_templates WHERE id = $1', [req.params.id]); await pool.query('DELETE FROM email_templates WHERE id = $1', [req.params.id]);
} else { } else {
@@ -216,16 +198,15 @@ app.delete('/api/templates/:id', async (req, res) => {
} }
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error(err); console.error("Delete Error:", err);
res.status(500).json({ error: 'Failed to delete template' }); res.status(500).json({ error: 'Failed to delete template' });
} }
}); });
// Handle React Routing
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html')); res.sendFile(path.join(__dirname, 'dist', 'index.html'));
}); });
app.listen(PORT, () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);
}); });

View File

@@ -1,6 +1,22 @@
import { EmailTemplate } from '../types'; import { EmailTemplate } from '../types';
// Helper functions remain synchronous as they are utility functions // Fallback for crypto.randomUUID in non-secure (HTTP) contexts
export const generateUUID = () => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
try {
return crypto.randomUUID();
} catch (e) {
// Fallback if it exists but fails
}
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
export const generateTemplateKey = (name: string): string => { export const generateTemplateKey = (name: string): string => {
return name.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''); return name.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
}; };
@@ -13,10 +29,15 @@ export const generateSQL = (template: EmailTemplate): string => {
const subject = template.subject.replace(/'/g, "''"); const subject = template.subject.replace(/'/g, "''");
const vars = JSON.stringify(template.variables).replace(/'/g, "''"); const vars = JSON.stringify(template.variables).replace(/'/g, "''");
const key = generateTemplateKey(template.name); const key = generateTemplateKey(template.name);
const name = template.name.replace(/'/g, "''");
const desc = (template.description || '').replace(/'/g, "''");
return `INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables) return `INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables)
VALUES ('${template.id}', '${key}', '${template.name.replace(/'/g, "''")}', '${template.description?.replace(/'/g, "''") || ''}', '${subject}', '${header}', '${body}', '${footer}', '${fullHtml}', '${vars}') VALUES ('${template.id}', '${key}', '${name}', '${desc}', '${subject}', '${header}', '${body}', '${footer}', '${fullHtml}', '${vars}')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
template_key = VALUES(template_key),
name = VALUES(name),
description = VALUES(description),
subject = VALUES(subject), subject = VALUES(subject),
header_html = VALUES(header_html), header_html = VALUES(header_html),
body_html = VALUES(body_html), body_html = VALUES(body_html),
@@ -35,24 +56,18 @@ export const generateN8nCode = (template: EmailTemplate): string => {
const hasVars = template.variables.length > 0; const hasVars = template.variables.length > 0;
return `// Nodo Code n8n - Popolatore Template return `// Nodo Code n8n - Popolatore Template
// 1. Assicurati che il nodo precedente (SQL) restituisca 'full_html' e 'subject'.
// 2. Aggiusta il percorso (item.json.full_html) se l'output del tuo nodo SQL è diverso.
for (const item of items) { for (const item of items) {
const templateHtml = item.json.full_html; const templateHtml = item.json.full_html;
const templateSubject = item.json.subject; const templateSubject = item.json.subject;
// Definisci qui i tuoi dati dinamici
const replacements = { const replacements = {
${hasVars ? varsMap : ' // Nessuna variabile rilevata in questo template'} ${hasVars ? varsMap : ' // Nessuna variabile rilevata'}
}; };
let finalHtml = templateHtml; let finalHtml = templateHtml;
let finalSubject = templateSubject; let finalSubject = templateSubject;
// Esegui sostituzione
for (const [key, value] of Object.entries(replacements)) { for (const [key, value] of Object.entries(replacements)) {
// Sostituisce {{key}} globalmente nell'HTML e nell'Oggetto
const regex = new RegExp('{{' + key + '}}', 'g'); const regex = new RegExp('{{' + key + '}}', 'g');
finalHtml = finalHtml.replace(regex, value); finalHtml = finalHtml.replace(regex, value);
if (finalSubject) { if (finalSubject) {
@@ -60,54 +75,48 @@ ${hasVars ? varsMap : ' // Nessuna variabile rilevata in questo template'}
} }
} }
// Output del contenuto processato
item.json.processed_html = finalHtml; item.json.processed_html = finalHtml;
item.json.processed_subject = finalSubject; item.json.processed_subject = finalSubject;
} }
return items;`; return items;`;
}; };
// Async API calls to replace synchronous localStorage
export const getTemplates = async (): Promise<EmailTemplate[]> => { export const getTemplates = async (): Promise<EmailTemplate[]> => {
try { console.log("Fetching templates from /api/templates...");
const response = await fetch('/api/templates'); const response = await fetch('/api/templates');
if (!response.ok) throw new Error('Fallito il recupero'); if (!response.ok) {
return await response.json(); const errorBody = await response.text();
} catch (e) { throw new Error(`Errore API (${response.status}): ${errorBody || response.statusText}`);
console.error("Fallito il caricamento dei template", e);
return [];
} }
return await response.json();
}; };
export const saveTemplate = async (template: EmailTemplate): Promise<void> => { export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
try { console.log("Saving template to /api/templates...", template.name);
const response = await fetch('/api/templates', { const response = await fetch('/api/templates', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(template), body: JSON.stringify(template),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); let errorMessage = `Errore HTTP ${response.status}`;
throw new Error(error.message || 'Salvataggio fallito'); try {
const errorData = await response.json();
errorMessage = errorData.details || errorData.error || errorMessage;
} catch (e) {
const textError = await response.text();
if (textError) errorMessage = textError;
} }
} catch (e) { throw new Error(errorMessage);
console.error("Fallito il salvataggio del template", e);
throw e;
} }
}; };
export const deleteTemplate = async (id: string): Promise<void> => { export const deleteTemplate = async (id: string): Promise<void> => {
try { const response = await fetch(`/api/templates/${id}`, {
const response = await fetch(`/api/templates/${id}`, { method: 'DELETE',
method: 'DELETE', });
}); if (!response.ok) throw new Error('Eliminazione fallita');
if (!response.ok) throw new Error('Eliminazione fallita');
} catch (e) {
console.error("Fallita l'eliminazione del template", e);
throw e;
}
}; };

View File

@@ -18,18 +18,37 @@ export interface ToastMessage {
text: string; text: string;
} }
// Simple schema for n8n SQL generation // Uniform schema for both Postgres and MySQL, showing the VARCHAR id needed for UUIDs
export const SQL_SCHEMA = ` export const SQL_SCHEMA = `
-- Per MySQL
CREATE TABLE IF NOT EXISTS email_templates ( CREATE TABLE IF NOT EXISTS email_templates (
id INT AUTO_INCREMENT PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
template_key VARCHAR(255) UNIQUE NOT NULL, template_key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
subject VARCHAR(255), subject VARCHAR(255),
header_html TEXT, header_html MEDIUMTEXT,
body_html TEXT, body_html MEDIUMTEXT,
footer_html TEXT, footer_html MEDIUMTEXT,
full_html TEXT, full_html MEDIUMTEXT,
required_variables JSON, required_variables JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
); );
-- Per PostgreSQL
-- CREATE TABLE IF NOT EXISTS email_templates (
-- id VARCHAR(255) PRIMARY KEY,
-- template_key VARCHAR(255) UNIQUE NOT NULL,
-- name VARCHAR(255) NOT NULL,
-- description TEXT,
-- subject VARCHAR(255),
-- header_html TEXT,
-- body_html TEXT,
-- footer_html TEXT,
-- full_html TEXT,
-- required_variables JSONB,
-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-- );
`; `;