Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d12a2558d | ||
|
|
f38ad1b6e7 | ||
|
|
394b87dc25 | ||
|
|
3390bd8f17 | ||
|
|
95b9a0f820 | ||
|
|
4e5226d213 | ||
|
|
947c5d3952 | ||
|
|
7c78819b86 | ||
|
|
ce927c88ae | ||
|
|
61a16442d5 | ||
|
|
44d944a5d5 | ||
|
|
35a3ac6c24 | ||
|
|
05bd36f68a | ||
|
|
383c11f148 | ||
|
|
753d8c4ab1 | ||
|
|
68f3b1d6c0 | ||
|
|
8f01fc8a6f | ||
|
|
155f523a4c | ||
|
|
64e696d0a6 | ||
|
|
f85bf8df59 | ||
|
|
db71ddbd4c |
21
App.tsx
21
App.tsx
@@ -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);
|
||||
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"
|
||||
>
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -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"]
|
||||
|
||||
42
README.md
42
README.md
@@ -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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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">
|
||||
<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} />
|
||||
Genera con IA
|
||||
IA
|
||||
</button>
|
||||
</div>
|
||||
</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">×</span>
|
||||
</button>
|
||||
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600 text-2xl">×</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 className="space-y-6 overflow-y-auto">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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-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">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>
|
||||
)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
133
server.js
133
server.js
@@ -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 () => {
|
||||
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, 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);
|
||||
|
||||
// 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
|
||||
);
|
||||
`);
|
||||
await pool.query('SELECT 1'); // Test connection
|
||||
} 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
|
||||
);
|
||||
`);
|
||||
await pool.query('SELECT 1'); // Test connection
|
||||
}
|
||||
console.log(`Connected to ${DB_TYPE} database successfully.`);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Database connection failed:', err);
|
||||
process.exit(1);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
@@ -1,6 +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, '');
|
||||
};
|
||||
@@ -13,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),
|
||||
@@ -35,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) {
|
||||
@@ -60,28 +75,24 @@ ${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 {
|
||||
console.log("Fetching templates from /api/templates...");
|
||||
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 [];
|
||||
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 {
|
||||
console.log("Saving template to /api/templates...", template.name);
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -91,23 +102,21 @@ export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Salvataggio fallito');
|
||||
}
|
||||
let errorMessage = `Errore HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.details || errorData.error || errorMessage;
|
||||
} catch (e) {
|
||||
console.error("Fallito il salvataggio del template", e);
|
||||
throw e;
|
||||
const textError = await response.text();
|
||||
if (textError) errorMessage = textError;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
31
types.ts
31
types.ts
@@ -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
|
||||
-- );
|
||||
`;
|
||||
Reference in New Issue
Block a user