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

21
App.tsx
View File

@@ -1,9 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import TemplateList from './components/TemplateList'; import TemplateList from './components/TemplateList';
import TemplateEditor from './components/TemplateEditor'; import TemplateEditor from './components/TemplateEditor';
import { ViewState, EmailTemplate, SQL_SCHEMA } from './types'; import { ViewState, EmailTemplate, SQL_SCHEMA } from './types';
import { getTemplates, deleteTemplate, generateUUID } from './services/storage'; import { getTemplates, deleteTemplate } from './services/storage';
import { Database } from 'lucide-react'; import { Database } from 'lucide-react';
const App: React.FC = () => { const App: React.FC = () => {
@@ -19,14 +18,9 @@ const App: React.FC = () => {
const refreshTemplates = async () => { const refreshTemplates = async () => {
setIsLoading(true); setIsLoading(true);
try {
const data = await getTemplates(); const data = await getTemplates();
setTemplates(data); setTemplates(data);
} catch (err) {
console.error("Refresh Error:", err);
} finally {
setIsLoading(false); setIsLoading(false);
}
}; };
const handleCreate = () => { const handleCreate = () => {
@@ -40,9 +34,10 @@ const App: React.FC = () => {
}; };
const handleClone = (t: EmailTemplate) => { const handleClone = (t: EmailTemplate) => {
// Create a copy of the template with a new ID and updated name
const clonedTemplate: EmailTemplate = { const clonedTemplate: EmailTemplate = {
...t, ...t,
id: generateUUID(), id: crypto.randomUUID(), // Generate new ID so it's treated as a new insert
name: `${t.name} (Copia)`, name: `${t.name} (Copia)`,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -69,7 +64,7 @@ const App: React.FC = () => {
<> <>
{isLoading ? ( {isLoading ? (
<div className="flex h-screen items-center justify-center text-slate-500"> <div className="flex h-screen items-center justify-center text-slate-500">
Caricamento... Caricamento template...
</div> </div>
) : ( ) : (
<TemplateList <TemplateList
@@ -100,13 +95,17 @@ const App: React.FC = () => {
/> />
)} )}
{/* Database Schema Modal (Global Help) */}
{showSchema && ( {showSchema && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6"> <div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Database className="text-brand-600"/> <Database className="text-brand-600"/>
Configurazione DB Configurazione Database per n8n
</h2> </h2>
<p className="text-slate-600 text-sm mb-4">
Per far funzionare questa app con il tuo software interno, crea questa tabella nel tuo database MySQL o PostgreSQL.
</p>
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto custom-scrollbar mb-4"> <div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto custom-scrollbar mb-4">
<pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre> <pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre>
</div> </div>
@@ -114,7 +113,7 @@ const App: React.FC = () => {
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(SQL_SCHEMA); 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" 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 WORKDIR /app
# Copiamo il package.json. Non usiamo npm ci perché richiede obbligatoriamente il package-lock.json # Copy ONLY package.json
COPY 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 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 . . COPY . .
# Eseguiamo il build del frontend (genera la cartella /dist) # Build the frontend assets
RUN npm run build RUN npm run build
# Stage 2: Setup del Server di Produzione # Stage 2: Setup the Production Server
FROM node:20-bookworm-slim FROM node:20-bookworm-slim
WORKDIR /app WORKDIR /app
# Copiamo il package.json per installare le dipendenze di runtime # Copy ONLY package.json
COPY 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 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 COPY --from=builder /app/dist ./dist
# Copiamo il file del server Node.js # Copy the server entry point
COPY server.js ./ COPY server.js ./
# Configurazioni di ambiente # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
# Esponiamo la porta 3000 # Expose the port
EXPOSE 3000 EXPOSE 3000
# Avvio dell'applicazione # Start the Node.js server
CMD ["node", "server.js"] CMD ["npm", "start"]

View File

@@ -2,41 +2,19 @@
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div> </div>
# 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 ## Run Locally
**Prerequisites:** Node.js **Prerequisites:** Node.js
1. Install dependencies: `npm install`
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app: `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,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { EmailTemplate } from '../types'; import { EmailTemplate } from '../types';
import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode, generateUUID } from '../services/storage'; import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode } from '../services/storage';
import { generateEmailContent } from '../services/geminiService'; import { generateEmailContent } from '../services/geminiService';
import RichTextEditor from './RichTextEditor'; import RichTextEditor from './RichTextEditor';
import { import {
@@ -46,6 +45,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const [showAiModal, setShowAiModal] = useState(false); const [showAiModal, setShowAiModal] = useState(false);
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
// Variable detection logic
const detectVariables = useCallback(() => { const detectVariables = useCallback(() => {
const regex = /\{\{([\w\d_-]+)\}\}/g; const regex = /\{\{([\w\d_-]+)\}\}/g;
const allText = `${subject} ${header} ${body} ${footer}`; const allText = `${subject} ${header} ${body} ${footer}`;
@@ -58,6 +58,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
detectVariables(); detectVariables();
}, [detectVariables]); }, [detectVariables]);
// Clear name error when typing
useEffect(() => { useEffect(() => {
if (nameError) setNameError(''); if (nameError) setNameError('');
}, [name]); }, [name]);
@@ -66,35 +67,30 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
const newKey = generateTemplateKey(name); const newKey = generateTemplateKey(name);
if (!newKey) { if (!newKey) {
setNameError('Il nome del template non può essere vuoto.'); setNameError('Il nome del template non può essere vuoto o contenere solo simboli.');
alert('Il nome del template non può essere vuoto.');
return; return;
} }
setIsSaving(true); setIsSaving(true);
console.log("Saving process started for:", name);
try { try {
// 1. Get current templates to check duplicates // Check for duplicates
let allTemplates: EmailTemplate[] = []; const allTemplates = await getTemplates();
try {
allTemplates = await getTemplates();
} catch (err) {
console.warn("Could not fetch templates for duplicate check, proceeding anyway...", err);
}
const isDuplicate = allTemplates.some(t => { const isDuplicate = allTemplates.some(t => {
// Exclude current template if we are editing
if (initialTemplate && t.id === initialTemplate.id) return false; if (initialTemplate && t.id === initialTemplate.id) return false;
return generateTemplateKey(t.name) === newKey; return generateTemplateKey(t.name) === newKey;
}); });
if (isDuplicate) { if (isDuplicate) {
setNameError('Esiste già un template con questo nome.'); setNameError('Esiste già un template con questo nome.');
alert('Un template con questo nome (o ID risultante) esiste già. Per favore scegli un nome univoco.');
setIsSaving(false); setIsSaving(false);
return; return;
} }
const newTemplate: EmailTemplate = { const newTemplate: EmailTemplate = {
id: initialTemplate?.id || generateUUID(), id: initialTemplate?.id || crypto.randomUUID(),
name, name,
description, description,
subject, subject,
@@ -104,14 +100,10 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
variables: detectedVars, variables: detectedVars,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
console.log("Sending POST to server with data:", newTemplate);
await saveTemplate(newTemplate); await saveTemplate(newTemplate);
console.log("Save successful!");
onSave(); onSave();
} catch (e: any) { } catch (e) {
console.error("SAVE ERROR DETAILS:", e); alert("Impossibile salvare il template. Controlla i log del server.");
alert(`Errore di salvataggio: ${e.message}`);
} finally { } finally {
setIsSaving(false); 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> = { const tabNames: Record<string, string> = {
header: 'Testata', header: 'Testata',
body: 'Corpo', body: 'Corpo',
@@ -174,6 +167,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
return ( return (
<div className="flex flex-col h-screen bg-slate-50"> <div className="flex flex-col h-screen bg-slate-50">
{/* Top Bar */}
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-20 shrink-0"> <header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-20 shrink-0">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-full text-slate-500"> <button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-full text-slate-500">
@@ -205,8 +199,11 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</header> </header>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left: Inputs */}
<div className="w-1/2 flex flex-col border-r border-slate-200 bg-white overflow-y-auto custom-scrollbar"> <div className="w-1/2 flex flex-col border-r border-slate-200 bg-white overflow-y-auto custom-scrollbar">
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Metadata */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-1">Nome Template</label> <label className="block text-sm font-semibold text-slate-700 mb-1">Nome Template</label>
@@ -218,7 +215,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
placeholder="es. Email di Benvenuto" placeholder="es. Email di Benvenuto"
/> />
{nameError && <p className="text-red-500 text-xs mt-1">{nameError}</p>} {nameError && <p className="text-red-500 text-xs mt-1">{nameError}</p>}
<p className="text-xs text-slate-400 mt-1">Deve essere univoco. 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>
<div> <div>
@@ -227,7 +224,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white resize-none text-sm" className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white resize-none text-sm"
placeholder="Note interne..." placeholder="Note interne (es. Usato per i nuovi iscritti)"
rows={2} rows={2}
/> />
</div> </div>
@@ -239,17 +236,19 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
value={subject} value={subject}
onChange={e => setSubject(e.target.value)} onChange={e => setSubject(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white" className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white"
placeholder="Oggetto..." placeholder="Oggetto... (supporta {{placeholder}})"
/> />
</div> </div>
</div> </div>
{/* Variable Manager */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200"> <div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Variabili</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>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile. 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 => ( {detectedVars.map(v => (
<span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono"> <span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono">
{`{{${v}}}`} {`{{${v}}}`}
@@ -258,6 +257,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</div> </div>
</div> </div>
{/* Tabs */}
<div className="flex border-b border-slate-200"> <div className="flex border-b border-slate-200">
{(['header', 'body', 'footer'] as const).map((tab) => ( {(['header', 'body', 'footer'] as const).map((tab) => (
<button <button
@@ -274,43 +274,59 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
))} ))}
</div> </div>
{/* Editor Area */}
<div className="relative"> <div className="relative">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<label className="text-sm font-semibold text-slate-700">Editor</label> <label className="text-sm font-semibold text-slate-700">Editor Contenuti</label>
<div className="flex gap-2">
<button <button
onClick={() => setShowAiModal(true)} onClick={() => setShowAiModal(true)}
className="text-xs flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium bg-purple-50 px-2 py-1 rounded" className="text-xs flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium bg-purple-50 px-2 py-1 rounded"
> >
<Wand2 size={14} /> <Wand2 size={14} />
IA Genera con IA
</button> </button>
</div> </div>
</div>
<div className="h-[400px]"> <div className="h-[400px]">
<RichTextEditor <RichTextEditor
key={activeTab} key={activeTab} // Force remount on tab change to sync contentEditable
value={getActiveContent()} value={getActiveContent()}
onChange={setActiveContent} onChange={setActiveContent}
placeholder={`Scrivi qui...`} placeholder={`Crea qui la sezione ${tabNames[activeTab]}...`}
className="h-full shadow-sm" className="h-full shadow-sm"
/> />
</div> </div>
<p className="mt-2 text-xs text-slate-500">
Usa il pulsante "Variabile" nella toolbar per inserire placeholder come {'{{nome}}'}.
</p>
</div> </div>
</div> </div>
</div> </div>
{/* Right: Live Preview */}
<div className="w-1/2 bg-slate-100 flex flex-col overflow-hidden"> <div className="w-1/2 bg-slate-100 flex flex-col overflow-hidden">
<div className="p-3 bg-white border-b border-slate-200 flex justify-between items-center shadow-sm 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"> <span className="font-semibold text-slate-600 flex items-center gap-2">
<Eye size={18} /> Anteprima <Eye size={18} /> Anteprima Live
</span> </span>
<div className="text-xs text-slate-400">
Renderizzato come HTML standard
</div> </div>
</div>
{/* Scrollable container: flex-1 ensures it takes available space, overflow-y-auto enables scrolling */}
<div className="flex-1 p-8 overflow-y-auto custom-scrollbar"> <div className="flex-1 p-8 overflow-y-auto custom-scrollbar">
{/* mx-auto centers the card without using flexbox on the parent which can cause scroll issues */}
<div className="w-full max-w-2xl bg-white shadow-xl rounded-lg overflow-hidden min-h-[600px] flex flex-col mx-auto"> <div className="w-full max-w-2xl bg-white shadow-xl rounded-lg overflow-hidden min-h-[600px] flex flex-col mx-auto">
{/* Simulate Subject Line in Preview */}
<div className="bg-slate-50 border-b border-slate-100 p-4"> <div className="bg-slate-50 border-b border-slate-100 p-4">
<span className="text-xs font-bold text-slate-400 uppercase">Oggetto:</span> <span className="text-xs font-bold text-slate-400 uppercase">Oggetto:</span>
<p className="text-sm font-medium text-slate-800">{subject}</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> </div>
{/* Email Content */}
<div dangerouslySetInnerHTML={{ __html: header }} /> <div dangerouslySetInnerHTML={{ __html: header }} />
<div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" /> <div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" />
<div dangerouslySetInnerHTML={{ __html: footer }} /> <div dangerouslySetInnerHTML={{ __html: footer }} />
@@ -319,50 +335,122 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
</div> </div>
</div> </div>
{/* SQL Modal */}
{showSqlModal && ( {showSqlModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl p-6 flex flex-col max-h-[90vh]"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl p-6 flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2"> <h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Database size={20} className="text-brand-600"/> <Database size={20} className="text-brand-600"/>
Integrazione Dettagli Integrazione
</h3> </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>
<div className="space-y-6 overflow-y-auto">
<div className="space-y-8 overflow-y-auto px-1 pb-4">
{/* INSERT Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">SQL INSERT/UPDATE</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2 block">1. Setup (Esegui una volta nel DB)</label>
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentInsertSql}</pre> <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>
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">JS n8n</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2 block">2. Recupero (Nodo SQL 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 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>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* AI Modal */}
{showAiModal && ( {showAiModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6"> <div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6">
<h3 className="text-lg font-bold text-slate-800 mb-2">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 <textarea
className="w-full h-32 border border-slate-300 rounded p-3 text-sm focus:ring-2 focus:ring-purple-500 outline-none mb-4" className="w-full h-32 border border-slate-300 rounded p-3 text-sm focus:ring-2 focus:ring-purple-500 outline-none mb-4"
placeholder="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} value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)} onChange={(e) => setAiPrompt(e.target.value)}
/> />
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button 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 <button
onClick={handleAiGenerate} onClick={handleAiGenerate}
disabled={isGenerating || !aiPrompt} disabled={isGenerating || !aiPrompt}
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2" 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 ? 'Generazione...' : 'Genera HTML'}
{!isGenerating && <Wand2 size={16} />}
</button> </button>
</div> </div>
{!process.env.API_KEY && (
<p className="mt-3 text-xs text-red-500">
Nota: API_KEY non rilevata nell'ambiente. Questa funzione richiede una chiave API Gemini.
</p>
)}
</div> </div>
</div> </div>
)} )}

View File

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

133
server.js
View File

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

View File

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

View File

@@ -18,37 +18,18 @@ export interface ToastMessage {
text: string; 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 = ` export const SQL_SCHEMA = `
-- Per MySQL
CREATE TABLE IF NOT EXISTS email_templates ( 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, template_key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
subject VARCHAR(255), subject VARCHAR(255),
header_html MEDIUMTEXT, header_html TEXT,
body_html MEDIUMTEXT, body_html TEXT,
footer_html MEDIUMTEXT, footer_html TEXT,
full_html MEDIUMTEXT, full_html TEXT,
required_variables JSON, required_variables JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
); );
-- Per PostgreSQL
-- CREATE TABLE IF NOT EXISTS email_templates (
-- id VARCHAR(255) PRIMARY KEY,
-- template_key VARCHAR(255) UNIQUE NOT NULL,
-- name VARCHAR(255) NOT NULL,
-- description TEXT,
-- subject VARCHAR(255),
-- header_html TEXT,
-- body_html TEXT,
-- footer_html TEXT,
-- full_html TEXT,
-- required_variables JSONB,
-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-- );
`; `;