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 288 additions and 327 deletions

27
App.tsx
View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import TemplateList from './components/TemplateList';
import TemplateEditor from './components/TemplateEditor';
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';
const App: React.FC = () => {
@@ -18,9 +19,14 @@ const App: React.FC = () => {
const refreshTemplates = async () => {
setIsLoading(true);
const data = await getTemplates();
setTemplates(data);
setIsLoading(false);
try {
const data = await getTemplates();
setTemplates(data);
} catch (err) {
console.error("Refresh Error:", err);
} finally {
setIsLoading(false);
}
};
const handleCreate = () => {
@@ -34,10 +40,9 @@ const App: React.FC = () => {
};
const handleClone = (t: EmailTemplate) => {
// Create a copy of the template with a new ID and updated name
const clonedTemplate: EmailTemplate = {
...t,
id: crypto.randomUUID(), // Generate new ID so it's treated as a new insert
id: generateUUID(),
name: `${t.name} (Copia)`,
updatedAt: new Date().toISOString()
};
@@ -64,7 +69,7 @@ const App: React.FC = () => {
<>
{isLoading ? (
<div className="flex h-screen items-center justify-center text-slate-500">
Caricamento template...
Caricamento...
</div>
) : (
<TemplateList
@@ -95,17 +100,13 @@ const App: React.FC = () => {
/>
)}
{/* Database Schema Modal (Global Help) */}
{showSchema && (
<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">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Database className="text-brand-600"/>
Configurazione Database per n8n
Configurazione DB
</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">
<pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre>
</div>
@@ -113,7 +114,7 @@ const App: React.FC = () => {
<button
onClick={() => {
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"
>

View File

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

View File

@@ -1,20 +1,42 @@
<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>
# Run and deploy your AI Studio app
# Email Template Builder
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1PJrPIeFdvYwt0ImdYe7PMH6YCDGTYQxH
Uno strumento visuale per creare, gestire ed esportare template email HTML con placeholder dinamici.
## 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
3. Run the app:
`npm run dev`
3. Run the app: `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 { 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 RichTextEditor from './RichTextEditor';
import {
@@ -45,7 +46,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const [showAiModal, setShowAiModal] = useState(false);
const [nameError, setNameError] = useState('');
// Variable detection logic
const detectVariables = useCallback(() => {
const regex = /\{\{([\w\d_-]+)\}\}/g;
const allText = `${subject} ${header} ${body} ${footer}`;
@@ -58,7 +58,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
detectVariables();
}, [detectVariables]);
// Clear name error when typing
useEffect(() => {
if (nameError) setNameError('');
}, [name]);
@@ -67,30 +66,35 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const newKey = generateTemplateKey(name);
if (!newKey) {
setNameError('Il nome del template non può essere vuoto o contenere solo simboli.');
alert('Il nome del template non può essere vuoto.');
setNameError('Il nome del template non può essere vuoto.');
return;
}
setIsSaving(true);
console.log("Saving process started for:", name);
try {
// Check for duplicates
const allTemplates = await getTemplates();
// 1. Get current templates to check duplicates
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 => {
// Exclude current template if we are editing
if (initialTemplate && t.id === initialTemplate.id) return false;
return generateTemplateKey(t.name) === newKey;
});
if (isDuplicate) {
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);
return;
}
const newTemplate: EmailTemplate = {
id: initialTemplate?.id || crypto.randomUUID(),
id: initialTemplate?.id || generateUUID(),
name,
description,
subject,
@@ -100,10 +104,14 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
variables: detectedVars,
updatedAt: new Date().toISOString()
};
console.log("Sending POST to server with data:", newTemplate);
await saveTemplate(newTemplate);
console.log("Save successful!");
onSave();
} catch (e) {
alert("Impossibile salvare il template. Controlla i log del server.");
} catch (e: any) {
console.error("SAVE ERROR DETAILS:", e);
alert(`Errore di salvataggio: ${e.message}`);
} finally {
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> = {
header: 'Testata',
body: 'Corpo',
@@ -167,7 +174,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
return (
<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">
<div className="flex items-center gap-4">
<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>
<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="p-6 space-y-6">
{/* Metadata */}
<div className="space-y-4">
<div>
<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"
/>
{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>
@@ -224,7 +227,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
value={description}
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"
placeholder="Note interne (es. Usato per i nuovi iscritti)"
placeholder="Note interne..."
rows={2}
/>
</div>
@@ -236,19 +239,17 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
value={subject}
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"
placeholder="Oggetto... (supporta {{placeholder}})"
placeholder="Oggetto..."
/>
</div>
</div>
{/* Variable Manager */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<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 text-slate-400">Rilevate automaticamente dal testo</span>
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Variabili</span>
</div>
<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 => (
<span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono">
{`{{${v}}}`}
@@ -257,7 +258,6 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-200">
{(['header', 'body', 'footer'] as const).map((tab) => (
<button
@@ -274,59 +274,43 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
))}
</div>
{/* Editor Area */}
<div className="relative">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-semibold text-slate-700">Editor Contenuti</label>
<div className="flex gap-2">
<button
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"
>
<Wand2 size={14} />
Genera con IA
</button>
</div>
<label className="text-sm font-semibold text-slate-700">Editor</label>
<button
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"
>
<Wand2 size={14} />
IA
</button>
</div>
<div className="h-[400px]">
<RichTextEditor
key={activeTab} // Force remount on tab change to sync contentEditable
key={activeTab}
value={getActiveContent()}
onChange={setActiveContent}
placeholder={`Crea qui la sezione ${tabNames[activeTab]}...`}
placeholder={`Scrivi qui...`}
className="h-full shadow-sm"
/>
</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>
{/* Right: Live Preview */}
<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">
<Eye size={18} /> Anteprima Live
<Eye size={18} /> Anteprima
</span>
<div className="text-xs text-slate-400">
Renderizzato come HTML standard
</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">
{/* 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">
{/* Simulate Subject Line in Preview */}
<div className="bg-slate-50 border-b border-slate-100 p-4">
<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>
{/* Email Content */}
<div dangerouslySetInnerHTML={{ __html: header }} />
<div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" />
<div dangerouslySetInnerHTML={{ __html: footer }} />
@@ -335,122 +319,50 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</div>
</div>
{/* SQL Modal */}
{showSqlModal && (
<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="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Database size={20} className="text-brand-600"/>
Dettagli Integrazione
Integrazione
</h3>
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600">
<span className="text-2xl">&times;</span>
</button>
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600 text-2xl">&times;</button>
</div>
<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 className="space-y-6 overflow-y-auto">
<div>
<label className="text-xs font-bold text-purple-600 uppercase tracking-wider mb-2 flex items-center gap-2">
<Code size={16} />
3. Popolamento (Nodo Codice n8n)
</label>
<div className="relative">
<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">
{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>
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">SQL INSERT/UPDATE</label>
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentInsertSql}</pre>
</div>
<div>
<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 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentN8nCode}</pre>
</div>
</div>
</div>
</div>
)}
{/* AI Modal */}
{showAiModal && (
<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">
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
<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>
<h3 className="text-lg font-bold text-slate-800 mb-2">Generatore IA</h3>
<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"
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}
onChange={(e) => setAiPrompt(e.target.value)}
/>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowAiModal(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded"
>
Annulla
</button>
<button onClick={() => setShowAiModal(false)} className="px-4 py-2 text-slate-600">Annulla</button>
<button
onClick={handleAiGenerate}
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 && <Wand2 size={16} />}
</button>
</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>
)}

View File

@@ -1,20 +1,41 @@
version: '3.8'
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:
build: .
container_name: email_templates_app
ports:
- "3000:3000"
environment:
# Database Connection Configuration
# We removed the ':-default' syntax.
# Now, the container will strictly use what is provided by the host environment.
- DB_TYPE=${DB_TYPE}
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
DB_HOST: db
DB_USER: appuser
DB_PASSWORD: apppassword
DB_NAME: email_templates
DB_PORT: 3306
DB_TYPE: mysql
API_KEY: ${API_KEY}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# Application Keys
- API_KEY=${API_KEY}
volumes:
db_data:

155
server.js
View File

@@ -1,3 +1,4 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -14,12 +15,27 @@ const __dirname = path.dirname(__filename);
const app = express();
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(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')));
// DB Configuration
// Normalize DB_TYPE: allow 'postgres', 'postgresql', 'Postgres', etc.
const rawDbType = (process.env.DB_TYPE || 'mysql').toLowerCase().trim();
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),
};
// Debug Log: Print config to console (masking password)
console.log('--- Database Configuration ---');
console.log(`Type: ${DB_TYPE} (Input: ${process.env.DB_TYPE || 'default'})`);
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('------------------------------');
console.log('--- App Version: 1.0.2 ---');
console.log(`DB Type: ${DB_TYPE}`);
console.log(`DB Host: ${dbConfig.host}`);
let pool;
// Initialize DB Connection
const initDB = async () => {
try {
if (!dbConfig.host || !dbConfig.user || !dbConfig.database) {
throw new Error("Missing required database environment variables (DB_HOST, DB_USER, or DB_NAME).");
const initDB = async (retries = 5) => {
while (retries > 0) {
try {
if (!dbConfig.host || !dbConfig.user || !dbConfig.database) {
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();
// API Routes
// GET All Templates
app.get('/api/templates', async (req, res) => {
try {
if (!pool) return res.status(503).json({ error: "Database not connected" });
let rows;
if (DB_TYPE === 'postgres') {
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;
}
// Map DB fields back to frontend types
const templates = rows.map(row => ({
id: row.id,
name: row.name,
@@ -126,21 +106,24 @@ app.get('/api/templates', async (req, res) => {
header: row.header_html,
body: row.body_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
}));
res.json(templates);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch templates' });
console.error("Fetch Error:", err);
res.status(500).json({ error: 'Failed to fetch templates', details: err.message });
}
});
// SAVE (Upsert) Template
app.post('/api/templates', async (req, res) => {
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 body = t.body || '';
const footer = t.footer || '';
@@ -162,8 +145,9 @@ app.post('/api/templates', async (req, res) => {
];
try {
if (!pool) throw new Error("Database not connected");
if (DB_TYPE === 'postgres') {
// Postgres requires explicit casting for JSONB parameters when passed as strings ($10::jsonb)
const query = `
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())
@@ -182,8 +166,8 @@ app.post('/api/templates', async (req, res) => {
await pool.query(query, params);
} else {
const query = `
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
template_key = VALUES(template_key),
name = VALUES(name),
@@ -193,22 +177,20 @@ app.post('/api/templates', async (req, res) => {
body_html = VALUES(body_html),
footer_html = VALUES(footer_html),
full_html = VALUES(full_html),
required_variables = VALUES(required_variables),
updated_at = NOW();
required_variables = VALUES(required_variables);
`;
await pool.query(query, params);
}
res.json({ success: true });
} catch (err) {
console.error("Save Template Error:", err);
// Return specific error details to help debugging on the client
res.status(500).json({ error: 'Failed to save template', details: err.message });
console.error("DB Save Error:", err.message);
res.status(500).json({ error: 'Database save failed', details: err.message });
}
});
// DELETE Template
app.delete('/api/templates/:id', async (req, res) => {
try {
if (!pool) return res.status(503).json({ error: "Database not connected" });
if (DB_TYPE === 'postgres') {
await pool.query('DELETE FROM email_templates WHERE id = $1', [req.params.id]);
} else {
@@ -216,16 +198,15 @@ app.delete('/api/templates/:id', async (req, res) => {
}
res.json({ success: true });
} catch (err) {
console.error(err);
console.error("Delete Error:", err);
res.status(500).json({ error: 'Failed to delete template' });
}
});
// Handle React Routing
app.get('*', (req, res) => {
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}`);
});

View File

@@ -1,7 +1,22 @@
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 => {
return name.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
};
@@ -14,10 +29,15 @@ export const generateSQL = (template: EmailTemplate): string => {
const subject = template.subject.replace(/'/g, "''");
const vars = JSON.stringify(template.variables).replace(/'/g, "''");
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)
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
template_key = VALUES(template_key),
name = VALUES(name),
description = VALUES(description),
subject = VALUES(subject),
header_html = VALUES(header_html),
body_html = VALUES(body_html),
@@ -36,24 +56,18 @@ export const generateN8nCode = (template: EmailTemplate): string => {
const hasVars = template.variables.length > 0;
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) {
const templateHtml = item.json.full_html;
const templateSubject = item.json.subject;
// Definisci qui i tuoi dati dinamici
const replacements = {
${hasVars ? varsMap : ' // Nessuna variabile rilevata in questo template'}
${hasVars ? varsMap : ' // Nessuna variabile rilevata'}
};
let finalHtml = templateHtml;
let finalSubject = templateSubject;
// Esegui sostituzione
for (const [key, value] of Object.entries(replacements)) {
// Sostituisce {{key}} globalmente nell'HTML e nell'Oggetto
const regex = new RegExp('{{' + key + '}}', 'g');
finalHtml = finalHtml.replace(regex, value);
if (finalSubject) {
@@ -61,54 +75,48 @@ ${hasVars ? varsMap : ' // Nessuna variabile rilevata in questo template'}
}
}
// Output del contenuto processato
item.json.processed_html = finalHtml;
item.json.processed_subject = finalSubject;
}
return items;`;
};
// Async API calls to replace synchronous localStorage
export const getTemplates = async (): Promise<EmailTemplate[]> => {
try {
const response = await fetch('/api/templates');
if (!response.ok) throw new Error('Fallito il recupero');
return await response.json();
} catch (e) {
console.error("Fallito il caricamento dei template", e);
return [];
console.log("Fetching templates from /api/templates...");
const response = await fetch('/api/templates');
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Errore API (${response.status}): ${errorBody || response.statusText}`);
}
return await response.json();
};
export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
try {
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
});
console.log("Saving template to /api/templates...", template.name);
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Salvataggio fallito');
if (!response.ok) {
let errorMessage = `Errore HTTP ${response.status}`;
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) {
console.error("Fallito il salvataggio del template", e);
throw e;
throw new Error(errorMessage);
}
};
export const deleteTemplate = async (id: string): Promise<void> => {
try {
const response = await fetch(`/api/templates/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Eliminazione fallita');
} catch (e) {
console.error("Fallita l'eliminazione del template", e);
throw e;
}
const response = await fetch(`/api/templates/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Eliminazione fallita');
};

View File

@@ -18,18 +18,37 @@ export interface ToastMessage {
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 = `
-- Per MySQL
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,
name VARCHAR(255) NOT NULL,
description TEXT,
subject VARCHAR(255),
header_html TEXT,
body_html TEXT,
footer_html TEXT,
full_html TEXT,
header_html MEDIUMTEXT,
body_html MEDIUMTEXT,
footer_html MEDIUMTEXT,
full_html MEDIUMTEXT,
required_variables JSON,
created_at TIMESTAMP DEFAULT 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
-- );
`;