6 Commits

Author SHA1 Message Date
fcarraUniSa
a733d147fb Update storage.ts 2025-12-23 12:22:26 +01:00
fcarraUniSa
60ba274275 Update server.js 2025-12-23 12:21:47 +01:00
fcarraUniSa
d64a8bd89a Update server.js 2025-12-23 12:19:41 +01:00
fcarraUniSa
4d9e2c78e6 Update storage.ts 2025-12-23 12:19:20 +01:00
fcarraUniSa
e08b1e0f2f Delete .github/workflows directory 2025-12-23 10:53:22 +01:00
fcarraUniSa
e55d846883 Create docker-image.yml 2025-12-23 10:49:13 +01:00
8 changed files with 327 additions and 288 deletions

27
App.tsx
View File

@@ -1,9 +1,8 @@
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, generateUUID } from './services/storage';
import { getTemplates, deleteTemplate } from './services/storage';
import { Database } from 'lucide-react';
const App: React.FC = () => {
@@ -19,14 +18,9 @@ const App: React.FC = () => {
const refreshTemplates = async () => {
setIsLoading(true);
try {
const data = await getTemplates();
setTemplates(data);
} catch (err) {
console.error("Refresh Error:", err);
} finally {
setIsLoading(false);
}
const data = await getTemplates();
setTemplates(data);
setIsLoading(false);
};
const handleCreate = () => {
@@ -40,9 +34,10 @@ 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: generateUUID(),
id: crypto.randomUUID(), // Generate new ID so it's treated as a new insert
name: `${t.name} (Copia)`,
updatedAt: new Date().toISOString()
};
@@ -69,7 +64,7 @@ const App: React.FC = () => {
<>
{isLoading ? (
<div className="flex h-screen items-center justify-center text-slate-500">
Caricamento...
Caricamento template...
</div>
) : (
<TemplateList
@@ -100,13 +95,17 @@ 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 DB
Configurazione Database per n8n
</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>
@@ -114,7 +113,7 @@ const App: React.FC = () => {
<button
onClick={() => {
navigator.clipboard.writeText(SQL_SCHEMA);
alert("Copiato!");
alert("Schema copiato!");
}}
className="px-4 py-2 bg-slate-100 text-slate-700 font-medium rounded hover:bg-slate-200"
>

View File

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

View File

@@ -1,42 +1,20 @@
<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>
# Email Template Builder
# Run and deploy your AI Studio app
Uno strumento visuale per creare, gestire ed esportare template email HTML con placeholder dinamici.
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1PJrPIeFdvYwt0ImdYe7PMH6YCDGTYQxH
## 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`
## 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
3. Run the app:
`npm run dev`

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { EmailTemplate } from '../types';
import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode, generateUUID } from '../services/storage';
import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode } from '../services/storage';
import { generateEmailContent } from '../services/geminiService';
import RichTextEditor from './RichTextEditor';
import {
@@ -46,6 +45,7 @@ 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,6 +58,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
detectVariables();
}, [detectVariables]);
// Clear name error when typing
useEffect(() => {
if (nameError) setNameError('');
}, [name]);
@@ -66,35 +67,30 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const newKey = generateTemplateKey(name);
if (!newKey) {
setNameError('Il nome del template non può essere vuoto.');
setNameError('Il nome del template non può essere vuoto o contenere solo simboli.');
alert('Il nome del template non può essere vuoto.');
return;
}
setIsSaving(true);
console.log("Saving process started for:", name);
try {
// 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);
}
// Check for duplicates
const allTemplates = await getTemplates();
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 || generateUUID(),
id: initialTemplate?.id || crypto.randomUUID(),
name,
description,
subject,
@@ -104,14 +100,10 @@ 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: any) {
console.error("SAVE ERROR DETAILS:", e);
alert(`Errore di salvataggio: ${e.message}`);
} catch (e) {
alert("Impossibile salvare il template. Controlla i log del server.");
} finally {
setIsSaving(false);
}
@@ -166,6 +158,7 @@ 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',
@@ -174,6 +167,7 @@ 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">
@@ -205,8 +199,11 @@ 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>
@@ -218,7 +215,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. 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. Usato per generare la chiave DB: <span className="font-mono bg-slate-100 px-1">{generateTemplateKey(name) || '...'}</span></p>
</div>
<div>
@@ -227,7 +224,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..."
placeholder="Note interne (es. Usato per i nuovi iscritti)"
rows={2}
/>
</div>
@@ -239,17 +236,19 @@ 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..."
placeholder="Oggetto... (supporta {{placeholder}})"
/>
</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</span>
<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>
</div>
<div className="flex flex-wrap gap-2">
{detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile. Usa {'{{nome}}'}.</span>}
{detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile rilevata. Scrivi {'{{nome}}'} per aggiungerne una.</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}}}`}
@@ -258,6 +257,7 @@ 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,43 +274,59 @@ 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</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>
<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>
</div>
<div className="h-[400px]">
<RichTextEditor
key={activeTab}
key={activeTab} // Force remount on tab change to sync contentEditable
value={getActiveContent()}
onChange={setActiveContent}
placeholder={`Scrivi qui...`}
placeholder={`Crea qui la sezione ${tabNames[activeTab]}...`}
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 shrink-0">
<div className="p-3 bg-white border-b border-slate-200 flex justify-between items-center shadow-sm z-10 shrink-0">
<span className="font-semibold text-slate-600 flex items-center gap-2">
<Eye size={18} /> Anteprima
<Eye size={18} /> Anteprima Live
</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}</p>
<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>
</div>
{/* Email Content */}
<div dangerouslySetInnerHTML={{ __html: header }} />
<div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" />
<div dangerouslySetInnerHTML={{ __html: footer }} />
@@ -319,50 +335,122 @@ 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"/>
Integrazione
Dettagli Integrazione
</h3>
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600 text-2xl">&times;</button>
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600">
<span className="text-2xl">&times;</span>
</button>
</div>
<div className="space-y-6 overflow-y-auto">
<div>
<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 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>
<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>
<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>
</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">Generatore IA</h3>
<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>
<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="Descrivi cosa generare..."
placeholder="es. Scrivi una notifica gentile che avvisi l'utente del cambio password avvenuto con successo. Includi un placeholder per il nome utente."
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">Annulla</button>
<button
onClick={() => setShowAiModal(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded"
>
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"
className={`px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2 ${isGenerating ? 'opacity-70' : ''}`}
>
{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,41 +1,20 @@
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:
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
# 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}
volumes:
db_data:
# Application Keys
- API_KEY=${API_KEY}

155
server.js
View File

@@ -1,4 +1,3 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -15,27 +14,12 @@ 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.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.json());
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';
@@ -47,48 +31,83 @@ const dbConfig = {
port: process.env.DB_PORT || (DB_TYPE === 'postgres' ? 5432 : 3306),
};
console.log('--- App Version: 1.0.2 ---');
console.log(`DB Type: ${DB_TYPE}`);
console.log(`DB Host: ${dbConfig.host}`);
// 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('------------------------------');
let pool;
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));
}
// 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).");
}
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');
@@ -98,6 +117,7 @@ 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,
@@ -106,24 +126,21 @@ 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("Fetch Error:", err);
res.status(500).json({ error: 'Failed to fetch templates', details: err.message });
console.error(err);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
// SAVE (Upsert) Template
app.post('/api/templates', async (req, res) => {
const t = req.body;
if (!t.name || !t.id) {
return res.status(400).json({ error: "Name and ID are required" });
}
// Ensure default values to prevent undefined errors in Postgres
const header = t.header || '';
const body = t.body || '';
const footer = t.footer || '';
@@ -145,9 +162,8 @@ 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())
@@ -166,8 +182,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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
template_key = VALUES(template_key),
name = VALUES(name),
@@ -177,20 +193,22 @@ 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);
required_variables = VALUES(required_variables),
updated_at = NOW();
`;
await pool.query(query, params);
}
res.json({ success: true });
} catch (err) {
console.error("DB Save Error:", err.message);
res.status(500).json({ error: 'Database save failed', details: err.message });
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 });
}
});
// 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 {
@@ -198,15 +216,16 @@ app.delete('/api/templates/:id', async (req, res) => {
}
res.json({ success: true });
} catch (err) {
console.error("Delete Error:", err);
console.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, '0.0.0.0', () => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@@ -1,22 +1,7 @@
import { EmailTemplate } from '../types';
// 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);
});
};
// Helper functions remain synchronous as they are utility functions
export const generateTemplateKey = (name: string): string => {
return name.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
};
@@ -29,15 +14,10 @@ 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}', '${name}', '${desc}', '${subject}', '${header}', '${body}', '${footer}', '${fullHtml}', '${vars}')
VALUES ('${template.id}', '${key}', '${template.name.replace(/'/g, "''")}', '${template.description?.replace(/'/g, "''") || ''}', '${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),
@@ -56,18 +36,24 @@ 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'}
${hasVars ? varsMap : ' // Nessuna variabile rilevata in questo template'}
};
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) {
@@ -75,48 +61,54 @@ ${hasVars ? varsMap : ' // Nessuna variabile rilevata'}
}
}
// 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[]> => {
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}`);
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 [];
}
return await response.json();
};
export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
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),
});
try {
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
});
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;
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Salvataggio fallito');
}
throw new Error(errorMessage);
} catch (e) {
console.error("Fallito il salvataggio del template", e);
throw e;
}
};
export const deleteTemplate = async (id: string): Promise<void> => {
const response = await fetch(`/api/templates/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Eliminazione fallita');
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;
}
};

View File

@@ -18,37 +18,18 @@ export interface ToastMessage {
text: string;
}
// Uniform schema for both Postgres and MySQL, showing the VARCHAR id needed for UUIDs
// Simple schema for n8n SQL generation
export const SQL_SCHEMA = `
-- Per MySQL
CREATE TABLE IF NOT EXISTS email_templates (
id VARCHAR(255) PRIMARY KEY,
id INT AUTO_INCREMENT PRIMARY KEY,
template_key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
subject VARCHAR(255),
header_html MEDIUMTEXT,
body_html MEDIUMTEXT,
footer_html MEDIUMTEXT,
full_html MEDIUMTEXT,
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
);
-- 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
-- );
`;