10 Commits

Author SHA1 Message Date
Surya Paolo
fb40743694 - Aggiornamento Viaggi 2025-12-30 11:36:42 +01:00
Surya Paolo
85141df8a4 passo 3 2025-12-24 19:49:26 +01:00
Surya Paolo
cb965eaa27 - Parte 3 : Viaggi
- Chat
2025-12-24 00:26:38 +01:00
Surya Paolo
b78e3ce544 - Trasporti- Passo 2 2025-12-22 23:39:47 +01:00
Surya Paolo
2e7801b4ba - Implementazione TRASPORTI ! Passo 1 2025-12-22 01:19:28 +01:00
Surya Paolo
afeedf27a5 - Caricamento Video 2025-12-19 22:59:20 +01:00
Surya Paolo
80c929436c Versione 1.2.87 2025-12-18 19:45:52 +01:00
Surya Paolo
9a0cdec7bd - altro aggiornamento restying
- Invio RIS aggiornato
- Eventi
- Home Page restyling
2025-12-18 17:00:43 +01:00
Surya Paolo
3d87c336de - aggiornamento di tante cose...
- generazione Volantini
- pagina RIS
2025-12-17 10:07:51 +01:00
Surya Paolo
037ff6f7f9 - verifica email se non è stata verificata (componente)
- altri aggiornamenti grafica PAGERIS.
- OLLAMA AI
2025-12-12 00:44:12 +01:00
116 changed files with 21201 additions and 4435 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -43,4 +43,5 @@ MIAB_ADMIN_EMAIL=admin@lamiaposta.org
MIAB_ADMIN_PASSWORD=passpao1pabox@1A
DS_API_KEY="sk-222e3addb3d8455d8b0516d93906eec7"
SERVER_A_URL="http://51.77.156.69:3000"
API_KEY_MSSQL="m68yADSr123MIVIDA@154$DSAGVOK"
API_KEY_MSSQL="m68yADSr123MIVIDA@154$DSAGVOK"
ORS_API_KEY="eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjNjNTllZmY1ZTM1ZDQ5ODI5NThhOTIzYTQ5MDkxOWIwIiwiaCI6Im11cm11cjY0In0="

View File

@@ -39,3 +39,4 @@ AUTH_NEW_SITES=123123123
SCRIPTS_DIR=admin_scripts
CLOUDFLARE_TOKENS=[{"label":"Paolo.arena77@gmail.com","value":"M9EM309v8WFquJKpYgZCw-TViM2wX6vB3wlK6GD0"},{"label":"gruppomacro.com","value":"bqmzGShoX7WqOBzkXocoECyBkPq3GfqcM5t6VFd8"}]
DS_API_KEY="sk-222e3addb3d8455d8b0516d93906eec7"
ORS_API_KEY="eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjNjNTllZmY1ZTM1ZDQ5ODI5NThhOTIzYTQ5MDkxOWIwIiwiaCI6Im11cm11cjY0In0="

View File

@@ -39,3 +39,10 @@ AUTH_NEW_SITES=123123123
SCRIPTS_DIR=admin_scripts
CLOUDFLARE_TOKENS=[{"label":"Paolo.arena77@gmail.com","value":"M9EM309v8WFquJKpYgZCw-TViM2wX6vB3wlK6GD0"},{"label":"gruppomacro.com","value":"bqmzGShoX7WqOBzkXocoECyBkPq3GfqcM5t6VFd8"}]
DS_API_KEY="sk-222e3addb3d8455d8b0516d93906eec7"
OLLAMA_URL=http://localhost:11434
OLLAMA_DEFAULT_MODEL=llama3.2:3b
GROK_API="xai-PcNM5obgPaETtmnfDWPZk235D75ZgxENU2QmeqPfMQCHh9dwCDVeRRe0oVVA2YOpiUDh1uJieZsMasja"
REPLICATE_API_TOKEN="r8_AVhM6igwvoOnUA65cHVZdhEDfTqBVk94WTB0u"
FAL_KEY="7d251c88-21b5-4b55-8b3e-4bafd910f99f:b81c0a36a25b052f26eb8ac226c7efff"
HF_TOKEN="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"
ORS_API_KEY="eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjNjNTllZmY1ZTM1ZDQ5ODI5NThhOTIzYTQ5MDkxOWIwIiwiaCI6Im11cm11cjY0In0="

View File

@@ -41,4 +41,5 @@ MIAB_HOST=box.lamiaposta.org
MIAB_ADMIN_EMAIL=admin@lamiaposta.org
MIAB_ADMIN_PASSWORD=passpao1pabox@1A
SERVER_A_URL="http://51.77.156.69:3000"
API_KEY_MSSQL="m68yADSr123MIVIDA@154$DSAGVOK"
API_KEY_MSSQL="m68yADSr123MIVIDA@154$DSAGVOK"
ORS_API_KEY="eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjNjNTllZmY1ZTM1ZDQ5ODI5NThhOTIzYTQ5MDkxOWIwIiwiaCI6Im11cm11cjY0In0="

View File

@@ -38,4 +38,5 @@ SCRIPTS_DIR=admin_scripts
CLOUDFLARE_TOKENS=[{"label":"Paolo.arena77@gmail.com","value":"M9EM309v8WFquJKpYgZCw-TViM2wX6vB3wlK6GD0"},{"label":"gruppomacro.com","value":"bqmzGShoX7WqOBzkXocoECyBkPq3GfqcM5t6VFd8"}]
MIAB_HOST=box.lamiaposta.org
MIAB_ADMIN_EMAIL=admin@lamiaposta.org
MIAB_ADMIN_PASSWORD=passpao1pabox@1A
MIAB_ADMIN_PASSWORD=passpao1pabox@1A
ORS_API_KEY="eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjNjNTllZmY1ZTM1ZDQ5ODI5NThhOTIzYTQ5MDkxOWIwIiwiaCI6Im11cm11cjY0In0="

View File

@@ -365,7 +365,7 @@ html(lang="it")
//- Intro
.intro-text
| Ciao <strong>#{usernameMembro}</strong>,<br>
| complimenti! Sei stato abilitato al Circuito RIS del tuo territorio da #{usernameInvitante}.
| complimenti! Sei stato abilitato #{nomeTerritorio} da #{usernameInvitante}.
if linkProfiloAdmin
.divider(style="margin: 16px 0;")
@@ -379,7 +379,7 @@ html(lang="it")
.congrats-icon ✅
h3 Abilitazione Completata
p(style="font-size: 15px; color: #555; margin-top: 8px;")
| Ora puoi utilizzare i RIS per i tuoi scambi nella comunità
| Ora puoi utilizzare i #{symbol} per i tuoi scambi nella comunità
.territory-name 📍 #{nomeTerritorio}
//- Info comunità
@@ -448,7 +448,7 @@ html(lang="it")
.step-number 1
.step-content
h5 Esplora la Piattaforma
p Familiarizza con gli annunci, i membri e le funzionalità del Circuito RIS
p Familiarizza con gli annunci, i membri e le funzionalità del #{nomeTerritorio}
.step-item
.step-number 2
.step-content

View File

@@ -1 +1 @@
=`Richiesta ingresso di ${usernameMembro} - ${nomeMembro} ${cognomeMembro} su ${nomeTerritorio} in ${nomeapp}`
=`Abilitazione avvenuta su ${nomeTerritorio} in ${nomeapp} - (${usernameMembro})`

View File

@@ -300,7 +300,7 @@ html(lang="it")
//- Intro
.intro-text
| Ciao <strong>#{nomeFacilitatore}</strong>,<br>
| un nuovo membro richiede l'abilitazione alla fiducia al Circuito RIS del tuo territorio!
| un nuovo membro richiede l'abilitazione alla fiducia al Circuito del tuo territorio!
//- Card richiesta
.request-card
@@ -384,12 +384,12 @@ html(lang="it")
span.responsibility-icon 👥
span.responsibility-text
strong Integrazione:
| Supporta il nuovo membro nell'attivazione e utilizzo del Circuito RIS locale
| Supporta il nuovo membro nell'attivazione e utilizzo del Circuito locale
//- Info box
.info-box
p
| ✓ Dopo l'abilitazione, #{usernameMembro} potrà accedere al Circuito RIS di #{nomeTerritorio}
| ✓ Dopo l'abilitazione, #{usernameMembro} potrà accedere al #{nomeTerritorio}
p
| ✓ Il membro riceverà una notifica automatica dell'avvenuta attivazione

View File

@@ -0,0 +1,404 @@
doctype html
html(lang="it")
head
meta(charset="UTF-8")
meta(name="viewport" content="width=device-width, initial-scale=1.0")
style(type="text/css").
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
padding: 20px;
line-height: 1.6;
}
.header-logo {
width: 80px;
height: auto;
margin-bottom: 16px;
display: block;
margin-left: auto;
margin-right: auto;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.email-header {
background: linear-gradient(135deg, #1976D2 0%, #1565C0 100%);
color: white;
padding: 40px 24px;
text-align: center;
}
.email-header h1 {
margin: 0 0 8px 0;
font-size: 26px;
font-weight: 600;
}
.email-header p {
margin: 8px 0 0 0;
font-size: 16px;
opacity: 0.95;
line-height: 1.5;
}
.verification-icon {
font-size: 56px;
margin-bottom: 16px;
}
.email-body {
padding: 24px 20px;
}
.intro-text {
font-size: 16px;
color: #333;
margin-bottom: 20px;
text-align: center;
line-height: 1.7;
padding: 0 10px;
}
.intro-text strong {
color: #1976D2;
}
.info-message {
background: linear-gradient(135deg, #E3F2FD 0%, #BBDEFB 100%);
border-left: 4px solid #1976D2;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
text-align: center;
}
.info-message h2 {
color: #1565C0;
font-size: 18px;
margin-bottom: 12px;
font-weight: 600;
}
.info-message p {
color: #1976D2;
font-size: 15px;
line-height: 1.6;
margin: 8px 0;
}
.warning-box {
background: #FFF8E1;
border-left: 4px solid #FFA000;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.warning-box p {
color: #E65100;
font-size: 14px;
margin: 0;
line-height: 1.6;
}
.warning-box strong {
color: #BF360C;
}
.email-info-box {
background: #f8f9fa;
border-left: 4px solid #1976D2;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
text-align: center;
}
.email-info-title {
font-size: 14px;
font-weight: 600;
color: #555;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.email-address {
font-size: 18px;
font-weight: 600;
color: #1976D2;
word-break: break-word;
}
.cta-section {
margin: 24px 0;
padding: 24px 0;
text-align: center;
}
.cta-title {
font-size: 19px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 8px;
}
.cta-subtitle {
font-size: 14px;
color: #666;
margin-bottom: 20px;
line-height: 1.5;
}
.cta-button {
display: inline-block;
padding: 18px 40px;
font-size: 16px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #1976D2 0%, #1565C0 100%);
border-radius: 50px;
text-decoration: none;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.35);
transition: transform 0.2s, box-shadow 0.2s;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.45);
}
.button-icon {
font-size: 20px;
margin-right: 8px;
vertical-align: middle;
}
.link-fallback {
margin-top: 20px;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
text-align: center;
}
.link-fallback p {
font-size: 13px;
color: #666;
margin-bottom: 8px;
}
.link-fallback a {
font-size: 12px;
color: #1976D2;
word-break: break-all;
}
.expiry-notice {
background: linear-gradient(135deg, #FFEBEE 0%, #FFCDD2 100%);
border-radius: 8px;
padding: 16px;
margin: 24px 0;
text-align: center;
border: 1px solid #EF9A9A;
}
.expiry-notice p {
margin: 0;
color: #C62828;
font-size: 14px;
line-height: 1.6;
}
.expiry-notice strong {
color: #B71C1C;
}
.help-section {
background: #FAFAFA;
border-radius: 8px;
padding: 20px;
margin: 24px 0;
text-align: center;
}
.help-section h3 {
font-size: 16px;
color: #333;
margin-bottom: 12px;
font-weight: 600;
}
.help-section p {
font-size: 14px;
color: #666;
margin: 8px 0;
line-height: 1.6;
}
.help-section a {
color: #1976D2;
text-decoration: none;
}
.help-section a:hover {
text-decoration: underline;
}
.security-note {
background: #E8F5E9;
border-left: 4px solid #4CAF50;
border-radius: 8px;
padding: 14px;
margin: 20px 0;
}
.security-note p {
font-size: 13px;
color: #2E7D32;
margin: 0;
line-height: 1.5;
}
.email-footer {
padding: 20px 16px;
text-align: center;
background: #f8f9fa;
color: #777;
font-size: 13px;
}
.email-footer p {
margin: 6px 0;
}
.divider {
height: 1px;
background: linear-gradient(to right, transparent, #e0e0e0, transparent);
margin: 24px 0;
}
@media only screen and (max-width: 600px) {
body {
padding: 8px;
}
.email-header {
padding: 24px 16px;
}
.email-header h1 {
font-size: 22px;
}
.email-header p {
font-size: 15px;
}
.email-body {
padding: 20px 14px;
}
.info-message {
padding: 16px;
}
.email-address {
font-size: 16px;
}
.cta-button {
padding: 16px 32px;
font-size: 15px;
display: block;
width: 100%;
}
.link-fallback a {
font-size: 11px;
}
}
body
.email-container
.email-header
- var baseimg = baseurl + '/';
h1 ✉️ Verifica il tuo indirizzo email
p Conferma la tua email per continuare a usare #{nomeapp}
.email-body
.intro-text
| Ciao
strong #{name || username}
| ! 👋
br
| Abbiamo ricevuto una richiesta per verificare nuovamente il tuo indirizzo email.
.info-message
h2 📧 Perché devo verificare di nuovo?
p Potrebbe essere perché hai cambiato email, per motivi di sicurezza, o perché la verifica precedente è scaduta.
p Questa procedura ci aiuta a mantenere sicuro il tuo account.
if emailto
.email-info-box
.email-info-title Email da verificare
.email-address #{emailto}
.cta-section
.cta-title Conferma il tuo indirizzo email
.cta-subtitle Clicca il bottone qui sotto per completare la verifica
if verifyLink
a.cta-button(href=verifyLink target="_blank")
span.button-icon ✓
| Verifica Email
.link-fallback
p Se il bottone non funziona, copia e incolla questo link nel browser:
a(href=verifyLink) #{verifyLink}
.expiry-notice
p ⏰ Questo link scadrà tra
strong 24 ore
| .
p Se non completi la verifica in tempo, dovrai richiedere un nuovo link.
.warning-box
p ⚠️
strong Non hai richiesto questa verifica?
| Ignora questa email. Il tuo account resterà al sicuro.
.security-note
p 🔒 Per la tua sicurezza: non condividere mai questo link con nessuno. Il team di #{nomeapp} non ti chiederà mai la password via email.
.help-section
h3 Hai bisogno di aiuto?
p Non riesci a verificare la tua email?
if supportEmail
p Contattaci a
a(href="mailto:" + supportEmail) #{supportEmail}
if strlinksito
p Oppure visita la nostra
a(href=strlinksito + '/supporto' target="_blank") pagina di supporto
.email-footer
.divider
p Hai ricevuto questa email perché è stata richiesta una verifica per il tuo account su #{nomeapp}
p(style="margin-top: 8px; font-size: 12px; color: #999;")
| Se non hai fatto tu questa richiesta, puoi ignorare questa email.
p(style="margin-top: 12px; font-size: 12px;")
| © #{new Date().getFullYear()} #{nomeapp}

View File

@@ -0,0 +1 @@
Verifica la tua Email - ${nomeapp}`

View File

@@ -519,4 +519,89 @@ Gio 04/12 ORE 18:55: [<b>Circuito RIS Bologna</b>]: Inviate Monete da SurTest a
Saldi:
SurTest: 0.00 RIS]
ElenaEspx: 38.05 RIS]
ElenaEspx: 38.05 RIS]
Mar 09/12 ORE 21:36: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 11 RIS [causale: ssss]
Saldi:
surya1977: 63.00 RIS]
amandadi: 12.00 RIS]
Mer 10/12 ORE 16:18: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 12 RIS [causale: Ciaoo]
Saldi:
surya1977: 51.00 RIS]
amandadi: 24.00 RIS]
Mer 10/12 ORE 17:02: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 52 RIS [causale: Ancora test]
Saldi:
surya1977: -1.00 RIS]
amandadi: 76.00 RIS]
Gio 18/12 ORE 15:41: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 22 RIS [causale: Prova]
Saldi:
surya1977: -23.00 RIS]
amandadi: 98.00 RIS]
Gio 18/12 ORE 15:59: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 2 RIS [causale: aaaa]
Saldi:
surya1977: -3.00 RIS]
amandadi: 78.00 RIS]
Gio 18/12 ORE 16:00: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 2 RIS [causale: aaaa]
Saldi:
surya1977: -5.00 RIS]
amandadi: 80.00 RIS]
Gio 18/12 ORE 16:07: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 1 RIS [causale: aaa]
Saldi:
surya1977: -6.00 RIS]
amandadi: 81.00 RIS]
Gio 18/12 ORE 16:07: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 2 RIS [causale: asdasdas]
Saldi:
surya1977: -8.00 RIS]
amandadi: 83.00 RIS]
Gio 18/12 ORE 16:11: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 3 RIS [causale: Provaa]
Saldi:
surya1977: -11.00 RIS]
amandadi: 86.00 RIS]
Gio 18/12 ORE 16:35: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 2 RIS [causale: aaaa]
Saldi:
surya1977: -13.00 RIS]
amandadi: 88.00 RIS]
Gio 18/12 ORE 16:49: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 5 RIS [causale: Prova da 3. Eccolo 2!]
Saldi:
surya1977: -18.00 RIS]
amandadi: 96.00 RIS]
Gio 18/12 ORE 16:50: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 4 RIS [causale: dasdasdasd]
Saldi:
surya1977: -22.00 RIS]
amandadi: 100.00 RIS]
Gio 18/12 ORE 16:50: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 3 RIS [causale: -25]
Saldi:
surya1977: -25.00 RIS]
amandadi: 103.00 RIS]
Gio 18/12 ORE 16:52: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 1 RIS [causale: asdasdsadas]
Saldi:
surya1977: -27.50 RIS]
amandadi: 105.50 RIS]
Gio 18/12 ORE 18:30: [<b>Circuito RIS Bologna</b>]: Inviate Monete da surya1977 a ElenaEspx 1 RIS [causale: prova 1]
Saldi:
surya1977: 34.90 RIS]
ElenaEspx: 39.05 RIS]
Gio 18/12 ORE 18:42: [<b>Circuito RIS Pordenone</b>]: Inviate Monete da surya1977 a GruppoYurta 3 RIS [causale: ECCOLO]
Saldi:
surya1977: -3.00 RIS]
GruppoYurta: -1.00 RIS]
Gio 18/12 ORE 18:53: [<b>Circuito RIS Venezia</b>]: Inviate Monete da surya1977 a amandadi 50 RIS [causale: asdasdasda]
Saldi:
surya1977: -77.50 RIS]
amandadi: 155.50 RIS]

View File

@@ -16,11 +16,13 @@
"author": "Surya",
"license": "MIT",
"dependencies": {
"@fal-ai/client": "^1.7.2",
"axios": "^1.13.0",
"basic-ftp": "^5.0.5",
"bcryptjs": "^3.0.2",
"bluebird": "^3.7.2",
"body-parser": "^1.20.3",
"canvas": "^3.2.0",
"cheerio": "^1.0.0",
"compress-pdf": "^0.5.3",
"cookie-parser": "^1.4.7",
@@ -33,10 +35,12 @@
"email-templates": "^12.0.2",
"entities": "^7.0.0",
"express": "^4.21.2",
"express-rate-limit": "^7.1.5",
"fast-csv": "^5.0.5",
"formidable": "^3.5.2",
"fs-extra": "^11.3.2",
"ghostscript4js": "^3.2.3",
"groq-sdk": "^0.37.0",
"helmet": "^8.1.0",
"i18n": "^0.15.1",
"image-downloader": "^4.3.0",
@@ -58,7 +62,7 @@
"node-telegram-bot-api": "^0.66.0",
"nodemailer": "^6.10.0",
"npm-check-updates": "^17.1.15",
"openai": "^4.86.2",
"openai": "^4.104.0",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"pem": "^1.14.8",
@@ -66,6 +70,7 @@
"pug": "^3.0.3",
"puppeteer": "^24.9.0",
"rate-limiter-flexible": "^5.0.5",
"replicate": "^1.4.0",
"request": "^2.88",
"sanitize-html": "^2.14.0",
"save": "^2.9.0",

View File

@@ -25,6 +25,7 @@ var file = `.env.${node_env}`;
// GLOBALI (Uguali per TUTTI)
process.env.LINKVERIF_REG = '/vreg';
process.env.CHECKREVERIF_EMAIL = '/reverif_email';
process.env.LINK_REQUEST_NEWPASSWORD = '/requestnewpwd';
process.env.ADD_NEW_SITE = '/addNewSite';
process.env.LINK_UPDATE_PASSWORD = '/updatepassword';

View File

@@ -55,7 +55,11 @@ class UserController {
}
// Send response with tokens
res.header('x-auth', result.token).header('x-refrtok', result.refreshToken).header('x-browser-random', result.browser_random).send(result.user);
res
.header('x-auth', result.token)
.header('x-refrtok', result.refreshToken)
.header('x-browser-random', result.browser_random)
.send(result.user);
} catch (error) {
console.error('Error in registration:', error.message);
res.status(400).send({
@@ -103,11 +107,15 @@ class UserController {
}
// Send response with tokens
res.header('x-auth', result.token).header('x-refrtok', result.refreshToken).header('x-browser-random', result.browser_random).send({
usertosend: result.user,
code: server_constants.RIS_CODE_OK,
subsExistonDb: result.subsExistonDb,
});
res
.header('x-auth', result.token)
.header('x-refrtok', result.refreshToken)
.header('x-browser-random', result.browser_random)
.send({
usertosend: result.user,
code: server_constants.RIS_CODE_OK,
subsExistonDb: result.subsExistonDb,
});
} catch (error) {
console.error('Error in login:', error.message);
res.status(400).send({
@@ -487,6 +495,7 @@ class UserController {
const { User } = require('../models/user');
return User.isCollaboratore(user.perm);
}
}
module.exports = UserController;

View File

@@ -0,0 +1,588 @@
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
class VideoController {
constructor(baseUploadPath = 'uploads/videos') {
this.basePath = path.resolve(baseUploadPath);
this._ensureDirectory(this.basePath);
}
// ============ PRIVATE METHODS ============
_ensureDirectory(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
_isVideoFile(filename) {
return /\.(mp4|webm|ogg|mov|avi|mkv)$/i.test(filename);
}
_getFileInfo(filePath, relativePath = '') {
const stat = fs.statSync(filePath);
const filename = path.basename(filePath);
return {
id: uuidv4(),
filename,
folder: relativePath,
path: `/videos/${relativePath ? relativePath + '/' : ''}${filename}`,
size: stat.size,
createdAt: stat.birthtime.toISOString(),
modifiedAt: stat.mtime.toISOString(),
};
}
_scanFolders(dir, relativePath = '') {
const folders = [];
if (!fs.existsSync(dir)) return folders;
const items = fs.readdirSync(dir);
items.forEach((item) => {
const fullPath = path.join(dir, item);
const relPath = relativePath ? `${relativePath}/${item}` : item;
if (fs.statSync(fullPath).isDirectory()) {
folders.push({
name: item,
path: relPath,
level: relPath.split('/').length,
});
// Ricorsione per sottocartelle
folders.push(...this._scanFolders(fullPath, relPath));
}
});
return folders;
}
// ============ FOLDER METHODS ============
/**
* Ottiene tutte le cartelle
*/
getFolders = async (req, res) => {
try {
const folders = this._scanFolders(this.basePath);
res.json({
success: true,
data: { folders },
message: 'Cartelle recuperate con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Crea una nuova cartella
*/
createFolder = async (req, res) => {
try {
const { folderName, parentPath = '' } = req.body;
if (!folderName || !folderName.trim()) {
return res.status(400).json({
success: false,
error: 'Nome cartella richiesto',
});
}
// Sanitizza il nome cartella
const sanitizedName = folderName.replace(/[<>:"/\\|?*]/g, '_').trim();
const basePath = parentPath ? path.join(this.basePath, parentPath) : this.basePath;
const newFolderPath = path.join(basePath, sanitizedName);
if (fs.existsSync(newFolderPath)) {
return res.status(409).json({
success: false,
error: 'La cartella esiste già',
});
}
fs.mkdirSync(newFolderPath, { recursive: true });
const folderData = {
name: sanitizedName,
path: parentPath ? `${parentPath}/${sanitizedName}` : sanitizedName,
createdAt: new Date().toISOString(),
};
res.status(201).json({
success: true,
data: { folder: folderData },
message: 'Cartella creata con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Rinomina una cartella
*/
renameFolder = async (req, res) => {
try {
const { folderPath } = req.params;
const { newName } = req.body;
if (!newName || !newName.trim()) {
return res.status(400).json({
success: false,
error: 'Nuovo nome richiesto',
});
}
const sanitizedName = newName.replace(/[<>:"/\\|?*]/g, '_').trim();
const oldPath = path.join(this.basePath, folderPath);
const parentDir = path.dirname(oldPath);
const newPath = path.join(parentDir, sanitizedName);
if (!fs.existsSync(oldPath)) {
return res.status(404).json({
success: false,
error: 'Cartella non trovata',
});
}
if (fs.existsSync(newPath)) {
return res.status(409).json({
success: false,
error: 'Una cartella con questo nome esiste già',
});
}
fs.renameSync(oldPath, newPath);
res.json({
success: true,
message: 'Cartella rinominata con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Elimina una cartella
*/
deleteFolder = async (req, res) => {
try {
const { folderPath } = req.params;
const fullPath = path.join(this.basePath, folderPath);
if (!fs.existsSync(fullPath)) {
return res.status(404).json({
success: false,
error: 'Cartella non trovata',
});
}
// Verifica che sia una directory
if (!fs.statSync(fullPath).isDirectory()) {
return res.status(400).json({
success: false,
error: 'Il percorso non è una cartella',
});
}
fs.rmSync(fullPath, { recursive: true, force: true });
res.json({
success: true,
message: 'Cartella eliminata con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
// ============ VIDEO METHODS ============
/**
* Ottiene i video di una cartella
*/
getVideos = async (req, res) => {
try {
const folder = req.query.folder || '';
const targetPath = folder ? path.join(this.basePath, folder) : this.basePath;
if (!fs.existsSync(targetPath)) {
return res.json({
success: true,
data: {
videos: [],
folders: [],
currentPath: folder,
},
});
}
const items = fs.readdirSync(targetPath);
const videos = [];
const subfolders = [];
items.forEach((item) => {
const itemPath = path.join(targetPath, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
subfolders.push({
name: item,
path: folder ? `${folder}/${item}` : item,
});
} else if (stat.isFile() && this._isVideoFile(item)) {
videos.push(this._getFileInfo(itemPath, folder));
}
});
// Ordina per data di creazione (più recenti prima)
videos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
res.json({
success: true,
data: {
videos,
folders: subfolders,
currentPath: folder,
totalVideos: videos.length,
},
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Upload singolo video
*/
uploadVideo = async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Nessun file caricato',
});
}
// ✅ Legge da query parameter
const folder = req.query.folder || 'default';
const videoInfo = {
id: uuidv4(),
originalName: req.file.originalname,
filename: req.file.filename,
folder: folder,
path: `/videos/${folder}/${req.file.filename}`,
size: req.file.size,
mimetype: req.file.mimetype,
uploadedAt: new Date().toISOString(),
};
res.status(201).json({
success: true,
data: { video: videoInfo },
message: 'Video caricato con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Upload multiplo video
*/
uploadVideos = async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({
success: false,
error: 'Nessun file caricato',
});
}
// ✅ Legge da query parameter
const folder = req.query.folder || 'default';
const videos = req.files.map((file) => ({
id: uuidv4(),
originalName: file.originalname,
filename: file.filename,
folder: folder,
path: `/videos/${folder}/${file.filename}`,
size: file.size,
mimetype: file.mimetype,
uploadedAt: new Date().toISOString(),
}));
res.status(201).json({
success: true,
data: { videos },
message: `${videos.length} video caricati con successo`,
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Ottiene info di un singolo video
*/
getVideo = async (req, res) => {
try {
const { folder, filename } = req.params;
const videoPath = path.join(this.basePath, folder, filename);
if (!fs.existsSync(videoPath)) {
return res.status(404).json({
success: false,
error: 'Video non trovato',
});
}
const videoInfo = this._getFileInfo(videoPath, folder);
res.json({
success: true,
data: { video: videoInfo },
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Rinomina un video
*/
renameVideo = async (req, res) => {
try {
const { folder, filename } = req.params;
const { newFilename } = req.body;
if (!newFilename || !newFilename.trim()) {
return res.status(400).json({
success: false,
error: 'Nuovo nome file richiesto',
});
}
const oldPath = path.join(this.basePath, folder, filename);
const newPath = path.join(this.basePath, folder, newFilename);
if (!fs.existsSync(oldPath)) {
return res.status(404).json({
success: false,
error: 'Video non trovato',
});
}
if (fs.existsSync(newPath)) {
return res.status(409).json({
success: false,
error: 'Un file con questo nome esiste già',
});
}
fs.renameSync(oldPath, newPath);
res.json({
success: true,
message: 'Video rinominato con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Sposta un video in un'altra cartella
*/
moveVideo = async (req, res) => {
try {
const { folder, filename } = req.params;
const { destinationFolder } = req.body;
const sourcePath = path.join(this.basePath, folder, filename);
const destDir = path.join(this.basePath, destinationFolder);
const destPath = path.join(destDir, filename);
if (!fs.existsSync(sourcePath)) {
return res.status(404).json({
success: false,
error: 'Video non trovato',
});
}
this._ensureDirectory(destDir);
if (fs.existsSync(destPath)) {
return res.status(409).json({
success: false,
error: 'Un file con questo nome esiste già nella destinazione',
});
}
fs.renameSync(sourcePath, destPath);
res.json({
success: true,
message: 'Video spostato con successo',
data: {
newPath: `/videos/${destinationFolder}/${filename}`,
},
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Elimina un video
*/
deleteVideo = async (req, res) => {
try {
const { folder, filename } = req.params;
const videoPath = path.join(this.basePath, folder, filename);
if (!fs.existsSync(videoPath)) {
return res.status(404).json({
success: false,
error: 'Video non trovato',
});
}
fs.unlinkSync(videoPath);
res.json({
success: true,
message: 'Video eliminato con successo',
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
/**
* Stream video (per player)
*/
streamVideo = async (req, res) => {
try {
const { folder, filename } = req.params;
const videoPath = path.join(this.basePath, folder, filename);
if (!fs.existsSync(videoPath)) {
return res.status(404).json({
success: false,
error: 'Video non trovato',
});
}
const stat = fs.statSync(videoPath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const file = fs.createReadStream(videoPath, { start, end });
const headers = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, headers);
file.pipe(res);
} else {
const headers = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
};
res.writeHead(200, headers);
fs.createReadStream(videoPath).pipe(res);
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
};
// ============ ERROR HANDLER MIDDLEWARE ============
static errorHandler = (error, req, res, next) => {
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({
success: false,
error: 'File troppo grande. Dimensione massima: 500MB',
});
}
if (error.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({
success: false,
error: 'Troppi file. Massimo 10 file per upload',
});
}
if (error.message.includes('Tipo file non supportato')) {
return res.status(415).json({
success: false,
error: error.message,
});
}
res.status(500).json({
success: false,
error: error.message || 'Errore interno del server',
});
};
}
module.exports = VideoController;

View File

@@ -0,0 +1,398 @@
const Asset = require('../models/Asset');
const imageGenerator = require('../services/imageGenerator');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const UPLOAD_DIR = process.env.UPLOAD_DIR || './upload';
const assetController = {
// POST /assets/upload
async upload(req, res) {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Nessun file caricato'
});
}
const { category = 'other', tags, description, isReusable = true } = req.body;
const file = req.file;
// Ottieni dimensioni immagine
let dimensions = {};
try {
const metadata = await sharp(file.path).metadata();
dimensions = { width: metadata.width, height: metadata.height };
} catch (e) {
console.warn('Cannot read image dimensions');
}
// Genera thumbnail
const thumbDir = path.join(UPLOAD_DIR, 'thumbs');
await fs.mkdir(thumbDir, { recursive: true });
const thumbName = `thumb_${file.filename}`;
const thumbPath = path.join(thumbDir, thumbName);
try {
await sharp(file.path)
.resize(300, 300, { fit: 'cover' })
.jpeg({ quality: 80 })
.toFile(thumbPath);
} catch (e) {
console.warn('Cannot create thumbnail');
}
const asset = new Asset({
type: 'image',
category,
sourceType: 'upload',
file: {
path: file.path,
url: `/upload/${file.filename}`,
thumbnailPath: thumbPath,
thumbnailUrl: `/upload/thumbs/${thumbName}`,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
dimensions
},
metadata: {
userId: req.user._id,
tags: tags ? tags.split(',').map(t => t.trim()) : [],
description,
isReusable: isReusable === 'true' || isReusable === true
}
});
await asset.save();
res.status(201).json({
success: true,
data: asset
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /assets/upload-multiple
async uploadMultiple(req, res) {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({
success: false,
error: 'Nessun file caricato'
});
}
const { category = 'other' } = req.body;
const assets = [];
for (const file of req.files) {
let dimensions = {};
try {
const metadata = await sharp(file.path).metadata();
dimensions = { width: metadata.width, height: metadata.height };
} catch (e) {}
const asset = new Asset({
type: 'image',
category,
sourceType: 'upload',
file: {
path: file.path,
url: `/upload/${file.filename}`,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
dimensions
},
metadata: {
userId: req.user._id
}
});
await asset.save();
assets.push(asset);
}
res.status(201).json({
success: true,
data: assets
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /assets/generate-ai
async generateAi(req, res) {
try {
const {
prompt,
negativePrompt,
provider = 'hf',
category = 'other',
aspectRatio = '9:16',
model,
seed,
steps,
cfg
} = req.body;
if (!prompt) {
return res.status(400).json({
success: false,
error: 'Prompt richiesto'
});
}
const startTime = Date.now();
const imageUrl = await imageGenerator.generate(provider, prompt, {
negativePrompt,
aspectRatio,
model,
seed,
steps,
cfg
});
const generationTime = Date.now() - startTime;
// Salva file
const fileName = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.jpg`;
const filePath = path.join(UPLOAD_DIR, 'ai-generated', fileName);
await fs.mkdir(path.dirname(filePath), { recursive: true });
let fileSize = 0;
let dimensions = {};
if (imageUrl.startsWith('data:')) {
const base64Data = imageUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
await fs.writeFile(filePath, buffer);
fileSize = buffer.length;
const metadata = await sharp(buffer).metadata();
dimensions = { width: metadata.width, height: metadata.height };
} else {
const fetch = require('node-fetch');
const response = await fetch(imageUrl);
const buffer = await response.buffer();
await fs.writeFile(filePath, buffer);
fileSize = buffer.length;
const metadata = await sharp(buffer).metadata();
dimensions = { width: metadata.width, height: metadata.height };
}
const asset = new Asset({
type: 'image',
category,
sourceType: 'ai',
file: {
path: filePath,
url: `/upload/ai-generated/${fileName}`,
mimeType: 'image/jpeg',
size: fileSize,
dimensions
},
aiGeneration: {
prompt,
negativePrompt,
provider,
model,
seed,
steps,
cfg,
requestedSize: aspectRatio,
generationTime
},
metadata: {
userId: req.user._id,
isReusable: true
}
});
await asset.save();
res.status(201).json({
success: true,
data: asset
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /assets
async list(req, res) {
try {
const {
category,
sourceType,
page = 1,
limit = 50
} = req.query;
const query = {
'metadata.userId': req.user._id,
status: 'ready'
};
if (category) query.category = category;
if (sourceType) query.sourceType = sourceType;
const [assets, total] = await Promise.all([
Asset.find(query)
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(parseInt(limit)),
Asset.countDocuments(query)
]);
res.json({
success: true,
data: assets,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /assets/:id
async getById(req, res) {
try {
const asset = await Asset.findById(req.params.id);
if (!asset) {
return res.status(404).json({
success: false,
error: 'Asset non trovato'
});
}
res.json({
success: true,
data: asset
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /assets/:id/file
async getFile(req, res) {
try {
const asset = await Asset.findById(req.params.id);
if (!asset || !asset.file?.path) {
return res.status(404).json({
success: false,
error: 'File non trovato'
});
}
res.sendFile(path.resolve(asset.file.path));
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /assets/:id/thumbnail
async getThumbnail(req, res) {
try {
const asset = await Asset.findById(req.params.id);
if (!asset) {
return res.status(404).json({
success: false,
error: 'Asset non trovato'
});
}
const thumbPath = asset.file?.thumbnailPath || asset.file?.path;
if (!thumbPath) {
return res.status(404).json({
success: false,
error: 'Thumbnail non disponibile'
});
}
res.sendFile(path.resolve(thumbPath));
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// DELETE /assets/:id
async delete(req, res) {
try {
const asset = await Asset.findById(req.params.id);
if (!asset) {
return res.status(404).json({
success: false,
error: 'Asset non trovato'
});
}
if (asset.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
// Elimina file
try {
if (asset.file?.path) await fs.unlink(asset.file.path);
if (asset.file?.thumbnailPath) await fs.unlink(asset.file.thumbnailPath);
} catch (e) {
console.warn('File deletion warning:', e.message);
}
await Asset.deleteOne({ _id: asset._id });
res.json({
success: true,
message: 'Asset eliminato'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
}
};
module.exports = assetController;

View File

@@ -0,0 +1,789 @@
const Chat = require('../models/viaggi/Chat');
const Message = require('../models/Message');
const { User } = require('../models/user');
// ===== GET USER CHATS =====
exports.getUserChats = async (req, res) => {
try {
const userId = req.user._id;
const idapp = req.user.idapp;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
// ✅ Trova chat dove l'utente è partecipante E non l'ha cancellata
const chats = await Chat.find({
idapp,
participants: userId,
isActive: true,
deletedBy: { $ne: userId }, // ✅ Escludi chat cancellate
})
.populate('participants', 'username name surname profile')
.populate({
path: 'rideId',
select: 'departure destination departureDate departureTime status',
})
.sort({ updatedAt: -1 })
.skip(skip)
.limit(limit)
.lean();
const enrichedChats = chats.map((chat) => {
let unreadCount = 0;
if (chat.unreadCount) {
if (chat.unreadCount instanceof Map) {
unreadCount = chat.unreadCount.get(userId.toString()) || 0;
} else if (typeof chat.unreadCount === 'object') {
// Dopo .lean(), la Map diventa un oggetto plain
unreadCount = chat.unreadCount[userId.toString()] || 0;
}
}
return {
...chat,
unreadCount,
};
});
res.json({
success: true,
data: enrichedChats,
pagination: {
page,
limit,
hasMore: chats.length === limit,
},
});
} catch (error) {
console.error('Error fetching chats:', error);
res.status(500).json({
success: false,
message: 'Errore nel recupero delle chat',
error: error.message,
});
}
};
// ===== GET OR CREATE DIRECT CHAT =====
exports.getOrCreateDirectChat = async (req, res) => {
try {
const userId = req.user._id;
const { otherUserId, rideId } = req.body;
const idapp = req.user.idapp;
if (!otherUserId) {
return res.status(400).json({
success: false,
message: 'otherUserId è richiesto',
});
}
// Verifica che l'altro utente esista
const otherUser = await User.findById(otherUserId);
if (!otherUser) {
return res.status(404).json({
success: false,
message: 'Utente non trovato',
});
}
// Cerca chat esistente
let chat = await Chat.findOne({
idapp,
type: 'direct',
participants: { $all: [userId, otherUserId], $size: 2 },
});
if (!chat) {
// Crea nuova chat
chat = new Chat({
idapp,
type: 'direct',
participants: [userId, otherUserId],
rideId: rideId || null,
unreadCount: new Map(),
});
await chat.save();
} else if (rideId && !chat.rideId) {
// Aggiungi rideId se non presente
chat.rideId = rideId;
await chat.save();
}
// ✅ Se la chat era stata cancellata da uno dei due, rimuovilo da deletedBy
if (chat.deletedBy && chat.deletedBy.length > 0) {
const wasDeleted = chat.deletedBy.some((id) => id.toString() === userId.toString());
if (wasDeleted) {
chat.deletedBy = chat.deletedBy.filter((id) => id.toString() !== userId.toString());
await chat.save();
}
}
// Popola i partecipanti
await chat.populate('participants', 'username name surname profile');
if (chat.rideId) {
await chat.populate('rideId', 'departure destination departureDate');
}
// Aggiungi unread count
const chatObj = chat.toObject();
chatObj.unreadCount = chat.getUnreadForUser(userId);
res.json({
success: true,
data: chatObj,
});
} catch (error) {
console.error('Error getting/creating direct chat:', error);
res.status(500).json({
success: false,
message: 'Errore nella creazione della chat',
error: error.message,
});
}
};
// ===== GET CHAT BY ID =====
exports.getChatById = async (req, res) => {
try {
const userId = req.user._id;
const { chatId } = req.params;
const chat = await Chat.findById(chatId)
.populate('participants', 'username name surname profile')
.populate({
path: 'rideId',
select: 'departure destination departureDate departureTime status',
})
.lean();
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
// Verifica che l'utente sia partecipante
const isParticipant = chat.participants.some((p) => {
const pId = p._id ? p._id.toString() : p.toString();
return pId === userId.toString();
});
if (!isParticipant) {
return res.status(403).json({
success: false,
message: 'Non autorizzato',
});
}
// Verifica se l'utente ha cancellato questa chat
const wasDeleted = chat.deletedBy?.some((id) => id.toString() === userId.toString());
if (wasDeleted) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
// Aggiungi unread count per l'utente corrente
let unreadCount = 0;
if (chat.unreadCount) {
if (chat.unreadCount instanceof Map) {
unreadCount = chat.unreadCount.get(userId.toString()) || 0;
} else if (typeof chat.unreadCount === 'object') {
unreadCount = chat.unreadCount[userId.toString()] || 0;
}
}
const chatObj = {
...chat,
unreadCount,
};
res.json({
success: true,
data: chatObj,
});
} catch (error) {
console.error('Error getting chat by ID:', error);
res.status(500).json({
success: false,
message: 'Errore nel recupero della chat',
error: error.message,
});
}
};
// ===== GET CHAT MESSAGES =====
exports.getChatMessages = async (req, res) => {
try {
const userId = req.user._id;
const { chatId } = req.params;
const idapp = req.user.idapp;
const page = parseInt(req.query.page) || 1;
const { before, after, limit = 50 } = req.query;
const skip = (page - 1) * limit;
// Verifica chat e partecipazione
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({
success: false,
message: 'Non autorizzato',
});
}
const query = {
chatId,
idapp,
isDeleted: { $ne: true },
};
// clearedBefore
let clearedDate = null;
if (chat.clearedBefore) {
if (chat.clearedBefore instanceof Map) {
clearedDate = chat.clearedBefore.get(userId.toString());
} else if (typeof chat.clearedBefore === 'object') {
clearedDate = chat.clearedBefore[userId.toString()];
}
}
if (clearedDate) {
query.createdAt = { $gt: new Date(clearedDate) };
}
// ✅ Paginazione: before (messaggi più vecchi)
if (before) {
query.createdAt = {
...query.createdAt,
$lt: new Date(before),
};
}
// ✅ Polling: after (messaggi più nuovi)
if (after) {
query.createdAt = {
...query.createdAt,
$gt: new Date(after), // Messaggi DOPO questo timestamp
};
}
// ✅ Ordina in base alla direzione
const sortOrder = after ? 1 : -1; // after: asc, before: desc
const messages = await Message.find(query)
.sort({ createdAt: sortOrder })
.limit(parseInt(limit))
.populate('senderId', 'username name surname profile.img profile.avatar')
.populate({
path: 'replyTo',
select: 'text senderId',
populate: {
path: 'senderId',
select: 'username name',
},
})
.lean();
// ✅ Se usato after, i messaggi sono già in ordine cronologico
// Se usato before, invertili
if (!after) {
messages.reverse();
}
// Marca i messaggi come letti
await chat.markAsRead(userId);
res.json({
success: true,
data: messages.reverse(),
pagination: {
page,
limit,
hasMore: messages.length === limit,
},
});
} catch (error) {
console.error('Error fetching messages:', error);
res.status(500).json({
success: false,
message: 'Errore nel recupero dei messaggi',
error: error.message,
});
}
};
// ===== SEND MESSAGE =====
exports.sendMessage = async (req, res) => {
try {
const userId = req.user._id;
const idapp = req.user.idapp;
const { chatId } = req.params;
const { text, type = 'text', metadata } = req.body;
// Verifica chat
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({
success: false,
message: 'Non autorizzato',
});
}
if (chat.isBlockedFor(userId)) {
return res.status(403).json({
success: false,
message: 'Non puoi inviare messaggi in questa chat',
});
}
// ✅ IMPORTANTE: Se qualcuno aveva cancellato la chat, rimuovilo da deletedBy
// così la chat riappare nella sua lista
if (chat.deletedBy && chat.deletedBy.length > 0) {
const otherParticipants = chat.participants.filter((p) => p.toString() !== userId.toString());
let needsSave = false;
otherParticipants.forEach((participantId) => {
const wasDeleted = chat.deletedBy.some((id) => id.toString() === participantId.toString());
if (wasDeleted) {
chat.deletedBy = chat.deletedBy.filter((id) => id.toString() !== participantId.toString());
needsSave = true;
}
});
if (needsSave) {
await chat.save();
}
}
// Crea messaggio
const message = new Message({
idapp,
chatId: chat._id,
senderId: userId,
text,
type,
metadata,
readBy: [userId],
});
await message.save();
await message.populate('senderId', 'username name surname profile');
// Aggiorna chat
await chat.updateLastMessage(message);
await chat.incrementUnread(userId);
res.json({
success: true,
data: message,
});
} catch (error) {
console.error('Error sending message:', error);
res.status(500).json({
success: false,
message: "Errore nell'invio del messaggio",
error: error.message,
});
}
};
// ===== MARK MESSAGES AS READ =====
exports.markAsRead = async (req, res) => {
try {
const userId = req.user._id;
const { chatId } = req.params;
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({
success: false,
message: 'Non autorizzato',
});
}
// Marca come letti
await chat.markAsRead(userId);
// Aggiorna anche i singoli messaggi
await Message.updateMany(
{
chatId: chat._id,
senderId: { $ne: userId },
readBy: { $ne: userId },
},
{
$addToSet: { readBy: userId },
}
);
res.json({
success: true,
message: 'Messaggi marcati come letti',
});
} catch (error) {
console.error('Error marking as read:', error);
res.status(500).json({
success: false,
message: 'Errore nella marcatura dei messaggi',
error: error.message,
});
}
};
// ===== DELETE CHAT (SOFT DELETE) =====
exports.deleteChat = async (req, res) => {
try {
const userId = req.user._id;
const { chatId } = req.params;
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({
success: false,
message: 'Non autorizzato',
});
}
// ✅ Soft delete: aggiungi userId a deletedBy
if (!chat.deletedBy) {
chat.deletedBy = [];
}
const alreadyDeleted = chat.deletedBy.some((id) => id.toString() === userId.toString());
if (!alreadyDeleted) {
chat.deletedBy.push(userId);
}
// ✅ Salva il timestamp di quando l'utente ha cancellato
// così quando riappare la chat, vedrà solo messaggi nuovi
if (!chat.clearedBefore) {
chat.clearedBefore = new Map();
}
chat.clearedBefore.set(userId.toString(), new Date());
await chat.save();
res.json({
success: true,
message: 'Chat eliminata',
});
} catch (error) {
console.error('Error deleting chat:', error);
res.status(500).json({
success: false,
message: "Errore nell'eliminazione della chat",
error: error.message,
});
}
};
// ===== TOGGLE MUTE CHAT =====
exports.toggleMuteChat = async (req, res) => {
try {
const userId = req.user._id;
const { chatId } = req.params;
const { mute } = req.body;
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({
success: false,
message: 'Non autorizzato',
});
}
if (!chat.mutedBy) {
chat.mutedBy = [];
}
if (mute) {
// Aggiungi a mutedBy se non presente
const alreadyMuted = chat.mutedBy.some((id) => id.toString() === userId.toString());
if (!alreadyMuted) {
chat.mutedBy.push(userId);
}
} else {
// Rimuovi da mutedBy
chat.mutedBy = chat.mutedBy.filter((id) => id.toString() !== userId.toString());
}
await chat.save();
res.json({
success: true,
message: mute ? 'Chat silenziata' : 'Notifiche attivate',
data: { muted: mute },
});
} catch (error) {
console.error('Error toggling mute:', error);
res.status(500).json({
success: false,
message: "Errore nell'aggiornamento",
error: error.message,
});
}
};
// ===== GET UNREAD COUNT =====
exports.getUnreadCount = async (req, res) => {
try {
const userId = req.user._id;
const idapp = req.user.idapp;
const chats = await Chat.find({
idapp,
participants: userId,
isActive: true,
deletedBy: { $ne: userId },
}).lean();
let totalUnread = 0;
chats.forEach((chat) => {
const unread = chat.unreadCount?.get(userId.toString()) || 0;
totalUnread += unread;
});
res.json({
success: true,
data: {
totalUnread,
chatCount: chats.length,
},
});
} catch (error) {
console.error('Error getting unread count:', error);
res.status(500).json({
success: false,
message: 'Errore nel conteggio messaggi non letti',
error: error.message,
});
}
};
/**
* @desc Marca una chat come letta
* @route PUT /api/viaggi/chats/:chatId/read
* @access Private
*/
exports.markChatAsRead = async (req, res) => {
try {
const { chatId } = req.params;
const userId = req.user._id;
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({
success: false,
message: 'Chat non trovata',
});
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({
success: false,
message: 'Non sei autorizzato',
});
}
// Reset unread count
if (!chat.unreadCount) {
chat.unreadCount = new Map();
}
chat.unreadCount.set(userId.toString(), 0);
chat.markModified('unreadCount');
await chat.save();
// Marca messaggi come letti
await Message.updateMany(
{
chatId,
senderId: { $ne: userId },
readBy: { $ne: userId },
},
{
$addToSet: { readBy: userId },
}
);
res.json({
success: true,
message: 'Chat marcata come letta',
});
} catch (error) {
console.error('Errore marca come letto:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
/**
* @desc Muta/smuta notifiche
* @route PUT /api/viaggi/chats/:chatId/mute
* @access Private
*/
exports.toggleMuteChat = async (req, res) => {
try {
const { chatId } = req.params;
const { mute } = req.body;
const userId = req.user._id;
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({ success: false, message: 'Chat non trovata' });
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({ success: false, message: 'Non sei autorizzato' });
}
if (!chat.mutedBy) {
chat.mutedBy = [];
}
const isMuted = chat.mutedBy.some((mid) => mid.toString() === userId.toString());
if (mute && !isMuted) {
chat.mutedBy.push(userId);
} else if (!mute && isMuted) {
chat.mutedBy = chat.mutedBy.filter((mid) => mid.toString() !== userId.toString());
}
await chat.save();
res.json({
success: true,
message: mute ? 'Notifiche disattivate' : 'Notifiche attivate',
data: { muted: mute },
});
} catch (error) {
console.error('Errore mute chat:', error);
res.status(500).json({ success: false, message: 'Errore', error: error.message });
}
};
/**
* @desc Elimina un messaggio (soft delete)
* @route DELETE /api/viaggi/chats/:chatId/messages/:messageId
* @access Private
*/
exports.deleteMessage = async (req, res) => {
try {
const { chatId, messageId } = req.params;
const userId = req.user._id;
const message = await Message.findById(messageId);
if (!message) {
return res.status(404).json({ success: false, message: 'Messaggio non trovato' });
}
if (message.chatId.toString() !== chatId) {
return res.status(400).json({ success: false, message: 'Messaggio non appartiene a questa chat' });
}
if (message.senderId.toString() !== userId.toString()) {
return res.status(403).json({ success: false, message: 'Non sei autorizzato' });
}
message.isDeleted = true;
message.deletedAt = new Date();
message.text = 'Messaggio eliminato';
await message.save();
res.json({ success: true, message: 'Messaggio eliminato' });
} catch (error) {
console.error('Errore eliminazione messaggio:', error);
res.status(500).json({ success: false, message: 'Errore', error: error.message });
}
};
/**
* @desc Blocca/sblocca una chat
* @route PUT /api/viaggi/chats/:chatId/block
* @access Private
*/
exports.toggleBlockChat = async (req, res) => {
try {
const { chatId } = req.params;
const { block } = req.body;
const userId = req.user._id;
const chat = await Chat.findById(chatId);
if (!chat) {
return res.status(404).json({ success: false, message: 'Chat non trovata' });
}
if (!chat.hasParticipant(userId)) {
return res.status(403).json({ success: false, message: 'Non sei autorizzato' });
}
if (!chat.blockedBy) {
chat.blockedBy = [];
}
const isBlocked = chat.blockedBy.some((bid) => bid.toString() === userId.toString());
if (block && !isBlocked) {
chat.blockedBy.push(userId);
} else if (!block && isBlocked) {
chat.blockedBy = chat.blockedBy.filter((bid) => bid.toString() !== userId.toString());
}
await chat.save();
res.json({
success: true,
message: block ? 'Chat bloccata' : 'Chat sbloccata',
data: { blocked: block },
});
} catch (error) {
console.error('Errore blocco chat:', error);
res.status(500).json({ success: false, message: 'Errore', error: error.message });
}
};
module.exports = exports;

View File

@@ -0,0 +1,931 @@
const mongoose = require('mongoose');
const Feedback = require('../models/viaggi/Feedback');
const Ride = require('../models/viaggi/Ride');
const RideRequest = require('../models/viaggi/RideRequest');
const { User } = require('../models/user');
// ============================================================
// 🔧 HELPER FUNCTIONS (definite prima per essere disponibili)
// ============================================================
/**
* Converti userId in ObjectId in modo sicuro
*/
const toObjectId = (id) => {
if (!id) return null;
if (id instanceof mongoose.Types.ObjectId) {
return id;
}
if (typeof id === 'object' && id._id) {
return new mongoose.Types.ObjectId(id._id.toString());
}
return new mongoose.Types.ObjectId(id.toString());
};
/**
* Ottieni statistiche feedback per un utente
*/
const getStatsForUser = async (idapp, userId) => {
try {
const userObjectId = toObjectId(userId);
if (!userObjectId) {
return {
averageRating: 0,
totalFeedback: 0,
asDriver: 0,
asPassenger: 0,
distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
};
}
const result = await Feedback.aggregate([
{
$match: {
idapp,
toUserId: userObjectId,
},
},
{
$group: {
_id: null,
averageRating: { $avg: '$rating' },
totalFeedback: { $sum: 1 },
asDriver: {
$sum: { $cond: [{ $eq: ['$role', 'driver'] }, 1, 0] },
},
asPassenger: {
$sum: { $cond: [{ $eq: ['$role', 'passenger'] }, 1, 0] },
},
rating5: { $sum: { $cond: [{ $eq: ['$rating', 5] }, 1, 0] } },
rating4: { $sum: { $cond: [{ $eq: ['$rating', 4] }, 1, 0] } },
rating3: { $sum: { $cond: [{ $eq: ['$rating', 3] }, 1, 0] } },
rating2: { $sum: { $cond: [{ $eq: ['$rating', 2] }, 1, 0] } },
rating1: { $sum: { $cond: [{ $eq: ['$rating', 1] }, 1, 0] } },
},
},
]);
if (!result || result.length === 0) {
return {
averageRating: 0,
totalFeedback: 0,
asDriver: 0,
asPassenger: 0,
distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
};
}
const stats = result[0];
return {
averageRating: stats.averageRating
? Math.round(stats.averageRating * 10) / 10
: 0,
totalFeedback: stats.totalFeedback || 0,
asDriver: stats.asDriver || 0,
asPassenger: stats.asPassenger || 0,
distribution: {
1: stats.rating1 || 0,
2: stats.rating2 || 0,
3: stats.rating3 || 0,
4: stats.rating4 || 0,
5: stats.rating5 || 0,
},
};
} catch (error) {
console.error('Errore calcolo stats feedback:', error);
return {
averageRating: 0,
totalFeedback: 0,
asDriver: 0,
asPassenger: 0,
distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
};
}
};
/**
* Calcola la distribuzione dei rating per un utente
*/
const getRatingDistribution = async (idapp, userId) => {
try {
const userObjectId = toObjectId(userId);
if (!userObjectId) {
return { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
}
const result = await Feedback.aggregate([
{
$match: {
idapp,
toUserId: userObjectId,
},
},
{
$group: {
_id: '$rating',
count: { $sum: 1 },
},
},
{ $sort: { _id: -1 } },
]);
const distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
result.forEach((r) => {
if (r._id >= 1 && r._id <= 5) {
distribution[r._id] = r.count;
}
});
return distribution;
} catch (error) {
console.error('Errore calcolo distribuzione:', error);
return { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
}
};
/**
* Aggiorna la media rating nel profilo utente
*/
const updateUserRating = async (idapp, userId) => {
try {
const stats = await getStatsForUser(idapp, userId);
await User.findByIdAndUpdate(userId, {
$set: {
'profile.driverProfile.averageRating': stats.averageRating,
'profile.driverProfile.totalFeedback': stats.totalFeedback,
},
});
} catch (error) {
console.error('Errore aggiornamento rating utente:', error);
}
};
// ============================================================
// 📝 CONTROLLER FUNCTIONS
// ============================================================
/**
* @desc Crea un feedback per un viaggio
* @route POST /api/viaggi/feedback
* @access Private
*/
const createFeedback = async (req, res) => {
try {
const {
idapp,
rideId,
rideRequestId,
toUserId,
role,
rating,
categories,
comment,
pros,
cons,
tags,
isPublic,
} = req.body;
const fromUserId = req.user._id;
// Validazione base
if (!idapp || !rideId || !toUserId || !role || !rating) {
return res.status(400).json({
success: false,
message: 'Campi obbligatori: idapp, rideId, toUserId, role, rating',
});
}
// Verifica rating valido
if (rating < 1 || rating > 5) {
return res.status(400).json({
success: false,
message: 'Il rating deve essere tra 1 e 5',
});
}
// Verifica che il ride esista
const ride = await Ride.findById(rideId);
if (!ride) {
return res.status(404).json({
success: false,
message: 'Viaggio non trovato',
});
}
// Verifica che l'utente abbia partecipato al viaggio
const wasDriver = ride.userId.toString() === fromUserId.toString();
const wasPassenger = ride.confirmedPassengers?.some(
(p) => p.userId.toString() === fromUserId.toString()
);
if (!wasDriver && !wasPassenger) {
return res.status(403).json({
success: false,
message: 'Non hai partecipato a questo viaggio',
});
}
// Verifica che non stia valutando se stesso
if (fromUserId.toString() === toUserId.toString()) {
return res.status(400).json({
success: false,
message: 'Non puoi valutare te stesso',
});
}
// Verifica che non esista già un feedback
const existingFeedback = await Feedback.findOne({
rideId,
fromUserId,
toUserId,
});
if (existingFeedback) {
return res.status(400).json({
success: false,
message: 'Hai già lasciato un feedback per questo utente in questo viaggio',
});
}
// Crea il feedback
const feedbackData = {
idapp,
rideId,
fromUserId,
toUserId,
role,
rating,
isVerified: ride.status === 'completed',
};
if (rideRequestId) feedbackData.rideRequestId = rideRequestId;
if (categories) feedbackData.categories = categories;
if (comment) feedbackData.comment = comment;
if (pros) feedbackData.pros = pros;
if (cons) feedbackData.cons = cons;
if (tags) feedbackData.tags = tags;
if (isPublic !== undefined) feedbackData.isPublic = isPublic;
const feedback = new Feedback(feedbackData);
await feedback.save();
// Aggiorna la media rating dell'utente destinatario
await updateUserRating(idapp, toUserId);
// Aggiorna flag nella richiesta se presente
if (rideRequestId) {
await RideRequest.findByIdAndUpdate(rideRequestId, {
$set: { feedbackGiven: true },
});
}
await feedback.populate('fromUserId', 'username name surname profile.img');
await feedback.populate('toUserId', 'username name surname');
res.status(201).json({
success: true,
message: 'Feedback inviato con successo!',
data: feedback,
});
} catch (error) {
console.error('Errore creazione feedback:', error);
res.status(500).json({
success: false,
message: 'Errore nella creazione del feedback',
error: error.message,
});
}
};
/**
* @desc Ottieni i feedback ricevuti da un utente
* @route GET /api/viaggi/feedback/user/:userId
* @access Public
*/
const getUserFeedback = async (req, res) => {
try {
const { userId } = req.params;
const { idapp, role, page = 1, limit = 10 } = req.query;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è obbligatorio',
});
}
const query = {
idapp,
toUserId: userId,
isPublic: true,
};
if (role) {
query.role = role;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [feedbacks, total, stats] = await Promise.all([
Feedback.find(query)
.populate('fromUserId', 'username name surname profile.img')
.populate('rideId', 'departure destination departureDate')
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
Feedback.countDocuments(query),
getStatsForUser(idapp, userId),
]);
res.json({
success: true,
data: feedbacks,
stats,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit)),
},
});
} catch (error) {
console.error('Errore recupero feedbacks:', error);
res.status(500).json({
success: false,
message: 'Errore nel recupero dei feedback',
error: error.message,
});
}
};
/**
* @desc Ottieni statistiche feedback per un utente
* @route GET /api/viaggi/feedback/user/:userId/stats
* @access Public
*/
const getUserFeedbackStats = async (req, res) => {
try {
const { userId } = req.params;
const { idapp } = req.query;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è obbligatorio',
});
}
const stats = await getStatsForUser(idapp, userId);
res.json({
success: true,
data: stats,
});
} catch (error) {
console.error('Errore recupero stats:', error);
res.status(500).json({
success: false,
message: 'Errore nel recupero delle statistiche',
error: error.message,
});
}
};
/**
* @desc Ottieni i feedback per un viaggio
* @route GET /api/viaggi/feedback/ride/:rideId
* @access Public/Private
*/
const getRideFeedback = async (req, res) => {
try {
const { rideId } = req.params;
const { idapp } = req.query;
const userId = req.user?._id;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è obbligatorio',
});
}
const ride = await Ride.findById(rideId);
if (!ride) {
return res.status(404).json({
success: false,
message: 'Viaggio non trovato',
});
}
const query = {
idapp,
rideId,
isPublic: true,
};
const feedbacks = await Feedback.find(query)
.populate('fromUserId', 'username name surname profile.img')
.populate('toUserId', 'username name surname profile.img')
.sort({ createdAt: -1 });
let pendingFeedbacks = [];
let myFeedbacks = [];
if (userId) {
const wasDriver = ride.userId.toString() === userId.toString();
const wasPassenger = ride.confirmedPassengers?.some(
(p) => p.userId.toString() === userId.toString()
);
if (wasDriver || wasPassenger) {
myFeedbacks = feedbacks.filter(
(f) => f.fromUserId._id.toString() === userId.toString()
);
if (wasDriver) {
const feedbackGivenTo = myFeedbacks.map((f) => f.toUserId._id.toString());
pendingFeedbacks = (ride.confirmedPassengers || [])
.filter((p) => !feedbackGivenTo.includes(p.userId.toString()))
.map((p) => ({ userId: p.userId, role: 'passenger' }));
} else {
const hasGivenToDriver = myFeedbacks.some(
(f) => f.toUserId._id.toString() === ride.userId.toString()
);
if (!hasGivenToDriver) {
pendingFeedbacks.push({ userId: ride.userId, role: 'driver' });
}
}
}
}
res.json({
success: true,
data: {
feedbacks,
pendingFeedbacks,
myFeedbacks,
},
});
} catch (error) {
console.error('Errore recupero feedbacks viaggio:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
/**
* @desc Verifica se l'utente può lasciare un feedback
* @route GET /api/viaggi/feedback/can-leave/:rideId/:toUserId
* @access Private
*/
const canLeaveFeedback = async (req, res) => {
try {
const { rideId, toUserId } = req.params;
const { idapp } = req.query;
const fromUserId = req.user._id;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è obbligatorio',
});
}
const ride = await Ride.findById(rideId);
if (!ride) {
return res.status(404).json({
success: false,
message: 'Viaggio non trovato',
});
}
const wasDriver = ride.userId.toString() === fromUserId.toString();
const wasPassenger = ride.confirmedPassengers?.some(
(p) => p.userId.toString() === fromUserId.toString()
);
if (!wasDriver && !wasPassenger) {
return res.json({
success: true,
data: {
canLeave: false,
reason: 'Non hai partecipato a questo viaggio',
},
});
}
if (fromUserId.toString() === toUserId.toString()) {
return res.json({
success: true,
data: {
canLeave: false,
reason: 'Non puoi valutare te stesso',
},
});
}
const toUserWasDriver = ride.userId.toString() === toUserId.toString();
const toUserWasPassenger = ride.confirmedPassengers?.some(
(p) => p.userId.toString() === toUserId.toString()
);
if (!toUserWasDriver && !toUserWasPassenger) {
return res.json({
success: true,
data: {
canLeave: false,
reason: "L'utente destinatario non ha partecipato a questo viaggio",
},
});
}
if (ride.status !== 'completed') {
return res.json({
success: true,
data: {
canLeave: false,
reason: 'Il viaggio non è ancora stato completato',
rideStatus: ride.status,
},
});
}
const existingFeedback = await Feedback.findOne({
rideId,
fromUserId,
toUserId,
});
if (existingFeedback) {
return res.json({
success: true,
data: {
canLeave: false,
reason: 'Hai già lasciato un feedback per questo utente in questo viaggio',
existingFeedbackId: existingFeedback._id,
},
});
}
const toUserRole = toUserWasDriver ? 'driver' : 'passenger';
const toUser = await User.findById(toUserId).select('username name surname profile.img');
res.json({
success: true,
data: {
canLeave: true,
toUser: toUser ? {
_id: toUser._id,
username: toUser.username,
name: toUser.name,
surname: toUser.surname,
img: toUser.profile?.img,
} : null,
toUserRole,
ride: {
_id: ride._id,
departure: ride.departure,
destination: ride.destination,
departureDate: ride.departureDate,
},
},
});
} catch (error) {
console.error('Errore verifica canLeaveFeedback:', error);
res.status(500).json({
success: false,
message: 'Errore nella verifica',
error: error.message,
});
}
};
/**
* @desc Rispondi a un feedback ricevuto
* @route POST /api/viaggi/feedback/:id/response
* @access Private
*/
const respondToFeedback = async (req, res) => {
try {
const { id } = req.params;
const { text } = req.body;
const userId = req.user._id;
if (!text || !text.trim()) {
return res.status(400).json({
success: false,
message: 'Il testo della risposta è obbligatorio',
});
}
const feedback = await Feedback.findById(id);
if (!feedback) {
return res.status(404).json({
success: false,
message: 'Feedback non trovato',
});
}
if (feedback.toUserId.toString() !== userId.toString()) {
return res.status(403).json({
success: false,
message: 'Non sei autorizzato a rispondere a questo feedback',
});
}
if (feedback.response?.text) {
return res.status(400).json({
success: false,
message: 'Hai già risposto a questo feedback',
});
}
feedback.response = {
text: text.trim(),
createdAt: new Date(),
};
await feedback.save();
res.json({
success: true,
message: 'Risposta aggiunta',
data: feedback,
});
} catch (error) {
console.error('Errore risposta feedback:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
/**
* @desc Segna un feedback come utile
* @route POST /api/viaggi/feedback/:id/helpful
* @access Private
*/
const markAsHelpful = async (req, res) => {
try {
const { id } = req.params;
const userId = req.user._id;
const feedback = await Feedback.findById(id);
if (!feedback) {
return res.status(404).json({
success: false,
message: 'Feedback non trovato',
});
}
if (!feedback.helpful) {
feedback.helpful = { count: 0, users: [] };
}
const userIdStr = userId.toString();
const alreadyMarked = feedback.helpful.users.some(
(u) => u.toString() === userIdStr
);
if (alreadyMarked) {
feedback.helpful.users = feedback.helpful.users.filter(
(u) => u.toString() !== userIdStr
);
feedback.helpful.count = Math.max(0, feedback.helpful.count - 1);
} else {
feedback.helpful.users.push(userId);
feedback.helpful.count += 1;
}
await feedback.save();
res.json({
success: true,
message: alreadyMarked ? 'Voto rimosso' : 'Feedback segnato come utile',
data: {
helpfulCount: feedback.helpful.count,
isHelpful: !alreadyMarked,
},
});
} catch (error) {
console.error('Errore mark helpful:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
/**
* @desc Segnala un feedback inappropriato
* @route POST /api/viaggi/feedback/:id/report
* @access Private
*/
const reportFeedback = async (req, res) => {
try {
const { id } = req.params;
const { reason } = req.body;
const userId = req.user._id;
if (!reason || !reason.trim()) {
return res.status(400).json({
success: false,
message: 'La motivazione è obbligatoria',
});
}
const feedback = await Feedback.findById(id);
if (!feedback) {
return res.status(404).json({
success: false,
message: 'Feedback non trovato',
});
}
if (!feedback.reports) {
feedback.reports = [];
}
const alreadyReported = feedback.reports.some(
(r) => r.userId.toString() === userId.toString()
);
if (alreadyReported) {
return res.status(400).json({
success: false,
message: 'Hai già segnalato questo feedback',
});
}
feedback.reports.push({
userId,
reason: reason.trim(),
createdAt: new Date(),
});
if (feedback.reports.length >= 3) {
feedback.isPublic = false;
feedback.hiddenReason = 'Nascosto automaticamente per multiple segnalazioni';
}
await feedback.save();
res.json({
success: true,
message: 'Feedback segnalato. Lo esamineremo al più presto.',
});
} catch (error) {
console.error('Errore segnalazione:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
/**
* @desc Ottieni i miei feedback dati
* @route GET /api/viaggi/feedback/my/given
* @access Private
*/
const getMyGivenFeedback = async (req, res) => {
try {
const userId = req.user._id;
const { idapp, page = 1, limit = 20 } = req.query;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è obbligatorio',
});
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [feedbacks, total] = await Promise.all([
Feedback.find({ idapp, fromUserId: userId })
.populate('toUserId', 'username name surname profile.img')
.populate('rideId', 'departure destination departureDate')
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
Feedback.countDocuments({ idapp, fromUserId: userId }),
]);
res.json({
success: true,
data: feedbacks,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit)),
},
});
} catch (error) {
console.error('Errore recupero feedback dati:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
/**
* @desc Ottieni i miei feedback ricevuti
* @route GET /api/viaggi/feedback/my/received
* @access Private
*/
const getMyReceivedFeedback = async (req, res) => {
try {
const userId = req.user._id;
const { idapp, page = 1, limit = 20 } = req.query;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è obbligatorio',
});
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [feedbacks, total, stats] = await Promise.all([
Feedback.find({ idapp, toUserId: userId })
.populate('fromUserId', 'username name surname profile.img')
.populate('rideId', 'departure destination departureDate')
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
Feedback.countDocuments({ idapp, toUserId: userId }),
getStatsForUser(idapp, userId),
]);
res.json({
success: true,
data: feedbacks,
stats,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit)),
},
});
} catch (error) {
console.error('Errore recupero feedback ricevuti:', error);
res.status(500).json({
success: false,
message: 'Errore',
error: error.message,
});
}
};
// ============================================================
// 📤 EXPORTS
// ============================================================
module.exports = {
// Controller functions
createFeedback,
getUserFeedback,
getUserFeedbackStats,
getRideFeedback,
canLeaveFeedback,
respondToFeedback,
reportFeedback,
markAsHelpful,
getMyGivenFeedback,
getMyReceivedFeedback,
// Alias per compatibilità
getFeedbacksForUser: getUserFeedback,
getFeedbacksForRide: getRideFeedback,
getMyGivenFeedbacks: getMyGivenFeedback,
getMyReceivedFeedbacks: getMyReceivedFeedback,
// Helper functions
getStatsForUser,
getRatingDistribution,
updateUserRating,
};

View File

@@ -0,0 +1,735 @@
/**
* Controller per Geocoding usando OpenRouteService
* Documentazione: https://openrouteservice.org/dev/#/api-docs
*/
const https = require('https');
// Configurazione OpenRouteService
const ORS_BASE = 'https://api.openrouteservice.org';
const ORS_API_KEY = process.env.ORS_API_KEY || 'YOUR_API_KEY_HERE';
/**
* Helper per fare richieste HTTPS a OpenRouteService
*/
const makeRequest = (url, method = 'GET', body = null) => {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method,
headers: {
Authorization: ORS_API_KEY,
Accept: 'application/json',
'Content-Type': 'application/json',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 400) {
reject(new Error(parsed.error?.message || `HTTP ${res.statusCode}`));
} else {
resolve(parsed);
}
} catch (e) {
reject(new Error('Errore parsing risposta'));
}
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Timeout richiesta'));
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
};
/**
* @desc Autocomplete città (ORS Geocode Autocomplete)
* @route GET /api/geo/autocomplete
*/
const autocomplete = async (req, res) => {
try {
const { q, limit = 5, lang = 'it', country = 'IT' } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({
success: false,
message: 'Query deve essere almeno 2 caratteri',
});
}
const params = new URLSearchParams({
text: q,
size: limit,
lang,
'boundary.country': country,
layers: 'locality,county,region', // Solo città/comuni
});
const url = `${ORS_BASE}/geocode/autocomplete?${params}`;
const data = await makeRequest(url);
const results = data.features.map((feature) => ({
id: feature.properties.id,
city: feature.properties.name,
locality: feature.properties.locality,
county: feature.properties.county,
region: feature.properties.region,
country: feature.properties.country,
postalCode: feature.properties.postalcode,
coordinates: {
lat: feature.geometry.coordinates[1],
lng: feature.geometry.coordinates[0],
},
displayName: feature.properties.label,
type: feature.properties.layer,
confidence: feature.properties.confidence,
}));
res.status(200).json({
success: true,
count: results.length,
data: results,
});
} catch (error) {
console.error('Errore autocomplete:', error);
res.status(500).json({
success: false,
message: 'Errore durante la ricerca',
error: error.message,
});
}
};
/**
* @desc Geocoding - indirizzo a coordinate (ORS Geocode Search)
* @route GET /api/geo/geocode
*/
const geocode = async (req, res) => {
try {
const { address, city, country = 'IT', limit = 5, lang = 'it' } = req.query;
const searchQuery = [address, city].filter(Boolean).join(', ');
if (!searchQuery) {
return res.status(400).json({
success: false,
message: 'Fornisci un indirizzo o città da cercare',
});
}
const params = new URLSearchParams({
text: searchQuery,
size: limit,
lang,
'boundary.country': country,
});
const url = `${ORS_BASE}/geocode/search?${params}`;
const data = await makeRequest(url);
if (!data.features || data.features.length === 0) {
return res.status(404).json({
success: false,
message: 'Nessun risultato trovato',
});
}
const results = data.features.map((feature) => ({
id: feature.properties.id,
displayName: feature.properties.label,
name: feature.properties.name,
street: feature.properties.street,
houseNumber: feature.properties.housenumber,
city: feature.properties.locality || feature.properties.county,
county: feature.properties.county,
region: feature.properties.region,
country: feature.properties.country,
postalCode: feature.properties.postalcode,
coordinates: {
lat: feature.geometry.coordinates[1],
lng: feature.geometry.coordinates[0],
},
type: feature.properties.layer,
confidence: feature.properties.confidence,
}));
res.status(200).json({
success: true,
count: results.length,
data: results,
});
} catch (error) {
console.error('Errore geocoding:', error);
res.status(500).json({
success: false,
message: 'Errore durante il geocoding',
error: error.message,
});
}
};
/**
* @desc Reverse geocoding - coordinate a indirizzo (ORS Reverse)
* @route GET /api/geo/reverse
*/
const reverseGeocode = async (req, res) => {
try {
const { lat, lng, lang = 'it' } = req.query;
if (!lat || !lng) {
return res.status(400).json({
success: false,
message: 'Coordinate lat e lng richieste',
});
}
const params = new URLSearchParams({
'point.lat': lat,
'point.lon': lng,
lang,
size: '1',
layers: 'address,street,locality',
});
const url = `${ORS_BASE}/geocode/reverse?${params}`;
const data = await makeRequest(url);
if (!data.features || data.features.length === 0) {
return res.status(404).json({
success: false,
message: 'Nessun risultato trovato',
});
}
const feature = data.features[0];
const result = {
displayName: feature.properties.label,
name: feature.properties.name,
street: feature.properties.street,
houseNumber: feature.properties.housenumber,
city: feature.properties.locality || feature.properties.county,
county: feature.properties.county,
region: feature.properties.region,
country: feature.properties.country,
postalCode: feature.properties.postalcode,
coordinates: {
lat: parseFloat(lat),
lng: parseFloat(lng),
},
distance: feature.properties.distance, // distanza dal punto esatto
};
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
console.error('Errore reverse geocoding:', error);
res.status(500).json({
success: false,
message: 'Errore durante il reverse geocoding',
error: error.message,
});
}
};
/**
* @desc Calcola percorso tra due o più punti (ORS Directions)
* @route POST /api/geo/route
* @body { coordinates: [[lng,lat], [lng,lat], ...], profile: 'driving-car' }
*/
const getRoute = async (req, res) => {
try {
const {
startLat,
startLng,
endLat,
endLng,
waypoints, // formato: "lat1,lng1;lat2,lng2;..."
profile = 'driving-car', // driving-car, driving-hgv, cycling-regular, foot-walking
language = 'it',
units = 'km',
} = req.query;
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
success: false,
message: 'Coordinate di partenza e arrivo richieste',
});
}
// Costruisci array coordinate [lng, lat] (formato GeoJSON)
const coordinates = [[parseFloat(startLng), parseFloat(startLat)]];
if (waypoints) {
const waypointsList = waypoints.split(';');
waypointsList.forEach((wp) => {
const [lat, lng] = wp.split(',').map(parseFloat);
coordinates.push([lng, lat]);
});
}
coordinates.push([parseFloat(endLng), parseFloat(endLat)]);
// Richiesta POST a ORS Directions
const url = `${ORS_BASE}/v2/directions/${profile}`;
const body = {
coordinates,
language,
units,
geometry: true,
instructions: true,
maneuvers: true,
};
const data = await makeRequest(url, 'POST', body);
if (!data.routes || data.routes.length === 0) {
return res.status(404).json({
success: false,
message: 'Impossibile calcolare il percorso',
});
}
const route = data.routes[0];
const summary = route.summary;
const result = {
distance: Math.round(summary.distance * 10) / 10, // km
duration: Math.round(summary.duration / 60), // minuti
durationFormatted: formatDuration(summary.duration),
bbox: data.bbox, // Bounding box
geometry: route.geometry, // Polyline encoded
segments: route.segments.map((segment) => ({
distance: Math.round(segment.distance * 10) / 10,
duration: Math.round(segment.duration / 60),
steps: segment.steps.map((step) => ({
instruction: step.instruction,
name: step.name,
distance: Math.round(step.distance * 100) / 100,
duration: Math.round(step.duration / 60),
type: step.type,
maneuver: step.maneuver,
})),
})),
};
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
console.error('Errore calcolo percorso:', error);
res.status(500).json({
success: false,
message: 'Errore durante il calcolo del percorso',
error: error.message,
});
}
};
/**
* @desc Calcola matrice distanze tra più punti (ORS Matrix)
* @route POST /api/geo/matrix
*/
const getMatrix = async (req, res) => {
try {
const { locations, profile = 'driving-car' } = req.body;
if (!locations || locations.length < 2) {
return res.status(400).json({
success: false,
message: 'Almeno 2 location richieste',
});
}
// Formato locations: [[lng, lat], [lng, lat], ...]
const url = `${ORS_BASE}/v2/matrix/${profile}`;
const body = {
locations,
metrics: ['distance', 'duration'],
units: 'km',
};
const data = await makeRequest(url, 'POST', body);
const result = {
distances: data.distances, // Matrice distanze in km
durations: data.durations, // Matrice durate in secondi
sources: data.sources,
destinations: data.destinations,
};
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
console.error('Errore calcolo matrice:', error);
res.status(500).json({
success: false,
message: 'Errore durante il calcolo della matrice',
error: error.message,
});
}
};
/**
* @desc Suggerisci città intermedie su un percorso
* @route GET /api/geo/suggest-waypoints
*/
const suggestWaypoints = async (req, res) => {
try {
const { startLat, startLng, endLat, endLng, count = 3 } = req.query;
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
success: false,
message: 'Coordinate di partenza e arrivo richieste',
});
}
// Prima ottieni il percorso
const routeUrl = `${ORS_BASE}/v2/directions/driving-car`;
const routeBody = {
coordinates: [
[parseFloat(startLng), parseFloat(startLat)],
[parseFloat(endLng), parseFloat(endLat)],
],
geometry: true,
};
const routeData = await makeRequest(routeUrl, 'POST', routeBody);
if (!routeData.routes || routeData.routes.length === 0) {
return res.status(404).json({
success: false,
message: 'Impossibile calcolare il percorso',
});
}
// Decodifica polyline per ottenere punti
const geometry = routeData.routes[0].geometry;
const decodedPoints = decodePolyline(geometry);
// Seleziona punti equidistanti lungo il percorso
const totalPoints = decodedPoints.length;
const step = Math.floor(totalPoints / (parseInt(count) + 1));
const sampledPoints = [];
for (let i = 1; i <= count; i++) {
const index = Math.min(step * i, totalPoints - 1);
sampledPoints.push(decodedPoints[index]);
}
// Fai reverse geocoding per ogni punto
const cities = [];
const seenCities = new Set();
for (const point of sampledPoints) {
try {
const params = new URLSearchParams({
'point.lat': point[1],
'point.lon': point[0],
lang: 'it',
size: '1',
layers: 'locality,county',
});
const reverseUrl = `${ORS_BASE}/geocode/reverse?${params}`;
const data = await makeRequest(reverseUrl);
if (data.features && data.features.length > 0) {
const feature = data.features[0];
const cityName = feature.properties.locality || feature.properties.county;
if (cityName && !seenCities.has(cityName.toLowerCase())) {
seenCities.add(cityName.toLowerCase());
cities.push({
city: cityName,
county: feature.properties.county,
region: feature.properties.region,
coordinates: {
lat: point[1],
lng: point[0],
},
displayName: feature.properties.label,
});
}
}
} catch (e) {
console.log('Errore reverse per punto:', e.message);
}
}
res.status(200).json({
success: true,
count: cities.length,
data: cities,
});
} catch (error) {
console.error('Errore suggerimento waypoints:', error);
res.status(500).json({
success: false,
message: 'Errore durante il suggerimento delle tappe',
error: error.message,
});
}
};
/**
* @desc Cerca città italiane (ottimizzato)
* @route GET /api/geo/cities/it
*/
const searchItalianCities = async (req, res) => {
try {
const { q, limit = 10, region } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({
success: false,
message: 'Query deve essere almeno 2 caratteri',
});
}
const params = new URLSearchParams({
text: q,
size: limit,
lang: 'it',
'boundary.country': 'IT',
layers: 'locality,county',
});
// Filtro opzionale per regione
if (region) {
params.append('region', region);
}
const url = `${ORS_BASE}/geocode/search?${params}`;
const data = await makeRequest(url);
const results = data.features
.filter((f) => f.properties.locality || f.properties.county)
.map((feature) => ({
city: feature.properties.locality || feature.properties.name,
county: feature.properties.county,
region: feature.properties.region,
postalCode: feature.properties.postalcode,
coordinates: {
lat: feature.geometry.coordinates[1],
lng: feature.geometry.coordinates[0],
},
displayName: `${feature.properties.locality || feature.properties.name}, ${feature.properties.region}`,
confidence: feature.properties.confidence,
}));
// Rimuovi duplicati
const unique = results.filter(
(v, i, a) => a.findIndex((t) => t.city?.toLowerCase() === v.city?.toLowerCase()) === i
);
res.status(200).json({
success: true,
count: unique.length,
data: unique,
});
} catch (error) {
console.error('Errore ricerca città italiane:', error);
res.status(500).json({
success: false,
message: 'Errore durante la ricerca',
error: error.message,
});
}
};
/**
* @desc Calcola distanza e durata tra due punti (semplificato)
* @route GET /api/geo/distance
*/
const getDistance = async (req, res) => {
try {
const { startLat, startLng, endLat, endLng, profile = 'driving-car' } = req.query;
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
success: false,
message: 'Tutte le coordinate sono richieste',
});
}
const url = `${ORS_BASE}/v2/directions/${profile}`;
const body = {
coordinates: [
[parseFloat(startLng), parseFloat(startLat)],
[parseFloat(endLng), parseFloat(endLat)],
],
geometry: false,
instructions: false,
};
const data = await makeRequest(url, 'POST', body);
if (!data.routes || data.routes.length === 0) {
return res.status(404).json({
success: false,
message: 'Impossibile calcolare la distanza',
});
}
const summary = data.routes[0].summary;
res.status(200).json({
success: true,
data: {
distance: Math.round(summary.distance * 10) / 10, // km
duration: Math.round(summary.duration / 60), // minuti
durationFormatted: formatDuration(summary.duration),
profile,
},
});
} catch (error) {
console.error('Errore calcolo distanza:', error);
res.status(500).json({
success: false,
message: 'Errore durante il calcolo della distanza',
error: error.message,
});
}
};
/**
* @desc Ottieni isocrone (aree raggiungibili in X minuti)
* @route GET /api/geo/isochrone
*/
const getIsochrone = async (req, res) => {
try {
const { lat, lng, minutes = 30, profile = 'driving-car' } = req.query;
if (!lat || !lng) {
return res.status(400).json({
success: false,
message: 'Coordinate richieste',
});
}
const url = `${ORS_BASE}/v2/isochrones/${profile}`;
const body = {
locations: [[parseFloat(lng), parseFloat(lat)]],
range: [parseInt(minutes) * 60], // secondi
range_type: 'time',
};
const data = await makeRequest(url, 'POST', body);
res.status(200).json({
success: true,
data: {
type: 'FeatureCollection',
features: data.features,
center: { lat: parseFloat(lat), lng: parseFloat(lng) },
minutes: parseInt(minutes),
},
});
} catch (error) {
console.error('Errore calcolo isocrone:', error);
res.status(500).json({
success: false,
message: 'Errore durante il calcolo isocrone',
error: error.message,
});
}
};
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Formatta durata in formato leggibile
*/
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
if (hours === 0) {
return `${minutes} min`;
} else if (minutes === 0) {
return `${hours} h`;
} else {
return `${hours} h ${minutes} min`;
}
};
/**
* Decodifica polyline encoded (formato Google/ORS)
*/
const decodePolyline = (encoded) => {
const points = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let b;
let shift = 0;
let result = 0;
do {
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
const dlat = result & 1 ? ~(result >> 1) : result >> 1;
lat += dlat;
shift = 0;
result = 0;
do {
b = encoded.charCodeAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
const dlng = result & 1 ? ~(result >> 1) : result >> 1;
lng += dlng;
points.push([lng / 1e5, lat / 1e5]); // [lng, lat] formato GeoJSON
}
return points;
};
module.exports = {
autocomplete,
geocode,
reverseGeocode,
getRoute,
getMatrix,
suggestWaypoints,
searchItalianCities,
getDistance,
getIsochrone,
};

View File

@@ -0,0 +1,522 @@
/**
* Controller per Geocoding usando servizi Open Source
* - Nominatim (OpenStreetMap) per geocoding/reverse
* - OSRM per routing
* - Photon per autocomplete
*/
const https = require('https');
const http = require('http');
// Configurazione servizi
const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
const PHOTON_BASE = 'https://photon.komoot.io';
const OSRM_BASE = 'https://router.project-osrm.org';
// User-Agent richiesto da Nominatim
const USER_AGENT = 'FreePlanetApp/1.0';
/**
* Helper per fare richieste HTTP/HTTPS
*/
const makeRequest = (url) => {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Errore parsing risposta'));
}
});
});
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error('Timeout richiesta'));
});
});
};
/**
* @desc Autocomplete città (Photon API)
* @route GET /api/viaggi/geo/autocomplete
*/
const autocomplete = async (req, res) => {
try {
const { q, limit = 5, lang = 'it' } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({
success: false,
message: 'Query deve essere almeno 2 caratteri'
});
}
// Photon API - gratuito e veloce
const url = `${PHOTON_BASE}/api/?q=${encodeURIComponent(q)}&limit=${limit}&lang=${lang}&osm_tag=place:city&osm_tag=place:town&osm_tag=place:village`;
const data = await makeRequest(url);
// Formatta risultati
const results = data.features.map(feature => ({
city: feature.properties.name,
province: feature.properties.county || feature.properties.state,
region: feature.properties.state,
country: feature.properties.country,
postalCode: feature.properties.postcode,
coordinates: {
lat: feature.geometry.coordinates[1],
lng: feature.geometry.coordinates[0]
},
displayName: [
feature.properties.name,
feature.properties.county,
feature.properties.state,
feature.properties.country
].filter(Boolean).join(', '),
type: feature.properties.osm_value || 'place'
}));
res.status(200).json({
success: true,
data: results
});
} catch (error) {
console.error('Errore autocomplete:', error);
res.status(500).json({
success: false,
message: 'Errore durante la ricerca',
error: error.message
});
}
};
/**
* @desc Geocoding - indirizzo a coordinate (Nominatim)
* @route GET /api/viaggi/geo/geocode
*/
const geocode = async (req, res) => {
try {
const { address, city, country = 'Italy' } = req.query;
const searchQuery = [address, city, country].filter(Boolean).join(', ');
if (!searchQuery) {
return res.status(400).json({
success: false,
message: 'Fornisci un indirizzo o città da cercare'
});
}
const url = `${NOMINATIM_BASE}/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5&addressdetails=1`;
const data = await makeRequest(url);
if (!data || data.length === 0) {
return res.status(404).json({
success: false,
message: 'Nessun risultato trovato'
});
}
const results = data.map(item => ({
displayName: item.display_name,
city: item.address.city || item.address.town || item.address.village || item.address.municipality,
address: item.address.road ? `${item.address.road}${item.address.house_number ? ' ' + item.address.house_number : ''}` : null,
province: item.address.county || item.address.province,
region: item.address.state,
country: item.address.country,
postalCode: item.address.postcode,
coordinates: {
lat: parseFloat(item.lat),
lng: parseFloat(item.lon)
},
type: item.type,
importance: item.importance
}));
res.status(200).json({
success: true,
data: results
});
} catch (error) {
console.error('Errore geocoding:', error);
res.status(500).json({
success: false,
message: 'Errore durante il geocoding',
error: error.message
});
}
};
/**
* @desc Reverse geocoding - coordinate a indirizzo (Nominatim)
* @route GET /api/viaggi/geo/reverse
*/
const reverseGeocode = async (req, res) => {
try {
const { lat, lng } = req.query;
if (!lat || !lng) {
return res.status(400).json({
success: false,
message: 'Coordinate lat e lng richieste'
});
}
const url = `${NOMINATIM_BASE}/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`;
const data = await makeRequest(url);
if (!data || data.error) {
return res.status(404).json({
success: false,
message: 'Nessun risultato trovato'
});
}
const result = {
displayName: data.display_name,
city: data.address.city || data.address.town || data.address.village || data.address.municipality,
address: data.address.road ? `${data.address.road}${data.address.house_number ? ' ' + data.address.house_number : ''}` : null,
province: data.address.county || data.address.province,
region: data.address.state,
country: data.address.country,
postalCode: data.address.postcode,
coordinates: {
lat: parseFloat(lat),
lng: parseFloat(lng)
}
};
res.status(200).json({
success: true,
data: result
});
} catch (error) {
console.error('Errore reverse geocoding:', error);
res.status(500).json({
success: false,
message: 'Errore durante il reverse geocoding',
error: error.message
});
}
};
/**
* @desc Calcola percorso tra due punti (OSRM)
* @route GET /api/viaggi/geo/route
*/
const getRoute = async (req, res) => {
try {
const {
startLat, startLng,
endLat, endLng,
waypoints // formato: "lat1,lng1;lat2,lng2;..."
} = req.query;
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
success: false,
message: 'Coordinate di partenza e arrivo richieste'
});
}
// Costruisci stringa coordinate
let coordinates = `${startLng},${startLat}`;
if (waypoints) {
const waypointsList = waypoints.split(';');
waypointsList.forEach(wp => {
const [lat, lng] = wp.split(',');
coordinates += `;${lng},${lat}`;
});
}
coordinates += `;${endLng},${endLat}`;
const url = `${OSRM_BASE}/route/v1/driving/${coordinates}?overview=full&geometries=polyline&steps=true`;
const data = await makeRequest(url);
if (!data || data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
return res.status(404).json({
success: false,
message: 'Impossibile calcolare il percorso'
});
}
const route = data.routes[0];
// Estrai città attraversate (dalle istruzioni)
const citiesAlongRoute = [];
if (route.legs) {
route.legs.forEach(leg => {
if (leg.steps) {
leg.steps.forEach(step => {
if (step.name && step.name.length > 0) {
// Qui potresti fare reverse geocoding per ottenere città
// Per ora usiamo i nomi delle strade principali
}
});
}
});
}
const result = {
distance: Math.round(route.distance / 1000 * 10) / 10, // km
duration: Math.round(route.duration / 60), // minuti
polyline: route.geometry, // Polyline encoded
legs: route.legs.map(leg => ({
distance: Math.round(leg.distance / 1000 * 10) / 10,
duration: Math.round(leg.duration / 60),
summary: leg.summary,
steps: leg.steps ? leg.steps.slice(0, 10).map(s => ({ // Limita step
instruction: s.maneuver ? s.maneuver.instruction : '',
name: s.name,
distance: Math.round(s.distance),
duration: Math.round(s.duration / 60)
})) : []
}))
};
res.status(200).json({
success: true,
data: result
});
} catch (error) {
console.error('Errore calcolo percorso:', error);
res.status(500).json({
success: false,
message: 'Errore durante il calcolo del percorso',
error: error.message
});
}
};
/**
* @desc Suggerisci città intermedie su un percorso
* @route GET /api/viaggi/geo/suggest-waypoints
*/
const suggestWaypoints = async (req, res) => {
try {
const { startLat, startLng, endLat, endLng } = req.query;
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
success: false,
message: 'Coordinate di partenza e arrivo richieste'
});
}
// Prima ottieni il percorso
const routeUrl = `${OSRM_BASE}/route/v1/driving/${startLng},${startLat};${endLng},${endLat}?overview=full&geometries=geojson`;
const routeData = await makeRequest(routeUrl);
if (!routeData || routeData.code !== 'Ok') {
return res.status(404).json({
success: false,
message: 'Impossibile calcolare il percorso'
});
}
// Prendi punti lungo il percorso (ogni ~50km circa)
const coordinates = routeData.routes[0].geometry.coordinates;
const totalPoints = coordinates.length;
const step = Math.max(1, Math.floor(totalPoints / 6)); // ~5 punti intermedi
const sampledPoints = [];
for (let i = step; i < totalPoints - step; i += step) {
sampledPoints.push(coordinates[i]);
}
// Fai reverse geocoding per ogni punto
const cities = [];
const seenCities = new Set();
for (const point of sampledPoints.slice(0, 5)) { // Limita a 5 richieste
try {
const reverseUrl = `${NOMINATIM_BASE}/reverse?format=json&lat=${point[1]}&lon=${point[0]}&addressdetails=1&zoom=10`;
const data = await makeRequest(reverseUrl);
if (data && data.address) {
const cityName = data.address.city || data.address.town || data.address.village;
if (cityName && !seenCities.has(cityName.toLowerCase())) {
seenCities.add(cityName.toLowerCase());
cities.push({
city: cityName,
province: data.address.county || data.address.province,
region: data.address.state,
coordinates: {
lat: point[1],
lng: point[0]
}
});
}
}
// Rate limiting - aspetta 1 secondo tra le richieste (requisito Nominatim)
await new Promise(resolve => setTimeout(resolve, 1100));
} catch (e) {
console.log('Errore reverse per punto:', e.message);
}
}
res.status(200).json({
success: true,
data: cities
});
} catch (error) {
console.error('Errore suggerimento waypoints:', error);
res.status(500).json({
success: false,
message: 'Errore durante il suggerimento delle tappe',
error: error.message
});
}
};
/**
* @desc Cerca città italiane (ottimizzato per Italia)
* @route GET /api/viaggi/geo/cities/it
*/
const searchItalianCities = async (req, res) => {
try {
const { q, limit = 10 } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({
success: false,
message: 'Query deve essere almeno 2 caratteri'
});
}
// Usa Nominatim con filtro Italia
const url = `${NOMINATIM_BASE}/search?format=json&q=${encodeURIComponent(q)}&countrycodes=it&limit=${limit}&addressdetails=1&featuretype=city`;
const data = await makeRequest(url);
const results = data
.filter(item =>
item.address &&
(item.address.city || item.address.town || item.address.village)
)
.map(item => ({
city: item.address.city || item.address.town || item.address.village,
province: item.address.county || item.address.province,
region: item.address.state,
postalCode: item.address.postcode,
coordinates: {
lat: parseFloat(item.lat),
lng: parseFloat(item.lon)
},
displayName: `${item.address.city || item.address.town || item.address.village}, ${item.address.county || item.address.state}`
}));
// Rimuovi duplicati
const unique = results.filter((v, i, a) =>
a.findIndex(t => t.city.toLowerCase() === v.city.toLowerCase()) === i
);
res.status(200).json({
success: true,
data: unique
});
} catch (error) {
console.error('Errore ricerca città italiane:', error);
res.status(500).json({
success: false,
message: 'Errore durante la ricerca',
error: error.message
});
}
};
/**
* @desc Calcola distanza e durata tra due punti
* @route GET /api/viaggi/geo/distance
*/
const getDistance = async (req, res) => {
try {
const { startLat, startLng, endLat, endLng } = req.query;
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
success: false,
message: 'Tutte le coordinate sono richieste'
});
}
const url = `${OSRM_BASE}/route/v1/driving/${startLng},${startLat};${endLng},${endLat}?overview=false`;
const data = await makeRequest(url);
if (!data || data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
return res.status(404).json({
success: false,
message: 'Impossibile calcolare la distanza'
});
}
const route = data.routes[0];
res.status(200).json({
success: true,
data: {
distance: Math.round(route.distance / 1000 * 10) / 10, // km
duration: Math.round(route.duration / 60), // minuti
durationFormatted: formatDuration(route.duration)
}
});
} catch (error) {
console.error('Errore calcolo distanza:', error);
res.status(500).json({
success: false,
message: 'Errore durante il calcolo della distanza',
error: error.message
});
}
};
// Helper per formattare durata
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
if (hours === 0) {
return `${minutes} min`;
} else if (minutes === 0) {
return `${hours} h`;
} else {
return `${hours} h ${minutes} min`;
}
};
module.exports = {
autocomplete,
geocode,
reverseGeocode,
getRoute,
suggestWaypoints,
searchItalianCities,
getDistance
};

View File

@@ -0,0 +1,647 @@
const Poster = require('../models/Poster');
const Template = require('../models/Template');
const Asset = require('../models/Asset');
const posterRenderer = require('../services/posterRenderer');
const imageGenerator = require('../services/imageGenerator');
const path = require('path');
const fs = require('fs').promises;
const UPLOAD_DIR = process.env.UPLOAD_DIR || './upload';
const posterController = {
// POST /posters
async create(req, res) {
try {
const {
templateId,
name,
description,
content,
assets,
layerOverrides,
autoRender = false
} = req.body;
// Carica template
const template = await Template.findById(templateId);
if (!template) {
return res.status(404).json({
success: false,
error: 'Template non trovato'
});
}
// Valida contenuti richiesti
const requiredLayers = template.layers.filter(l => l.required);
for (const layer of requiredLayers) {
if (layer.type === 'title' && !content?.title) {
return res.status(400).json({
success: false,
error: `Campo richiesto: ${layer.type}`
});
}
}
const poster = new Poster({
templateId,
templateSnapshot: template.toObject(), // Snapshot per retrocompatibilità
name: name || content?.title || 'Nuova Locandina',
description,
status: 'draft',
content: content || {},
assets: assets || {},
layerOverrides: layerOverrides || {},
renderEngineVersion: '1.0.0',
metadata: {
userId: req.user._id
}
});
await poster.save();
// Incrementa uso template
await template.incrementUsage();
// Auto-render se richiesto
if (autoRender) {
await posterController._renderPoster(poster);
await poster.save();
}
res.status(201).json({
success: true,
data: poster
});
} catch (error) {
console.error('Poster create error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /posters
async list(req, res) {
try {
const {
status,
templateId,
search,
page = 1,
limit = 20,
sortBy = 'createdAt',
sortOrder = 'desc'
} = req.query;
const query = { 'metadata.userId': req.user._id };
if (status) query.status = status;
if (templateId) query.templateId = templateId;
if (search) query.$text = { $search: search };
const sort = { [sortBy]: sortOrder === 'asc' ? 1 : -1 };
const [posters, total] = await Promise.all([
Poster.find(query)
.populate('templateId', 'name templateType thumbnailUrl')
.sort(sort)
.skip((page - 1) * limit)
.limit(parseInt(limit))
.select('-templateSnapshot -history'),
Poster.countDocuments(query)
]);
res.json({
success: true,
data: posters,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /posters/favorites
async listFavorites(req, res) {
try {
const posters = await Poster.findFavorites(req.user._id);
res.json({
success: true,
data: posters
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /posters/recent
async listRecent(req, res) {
try {
const { limit = 10 } = req.query;
const posters = await Poster.findRecent(req.user._id, parseInt(limit));
res.json({
success: true,
data: posters
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /posters/:id
async getById(req, res) {
try {
const poster = await Poster.findById(req.params.id)
.populate('templateId')
.populate('assets.backgroundImage.assetId')
.populate('assets.mainImage.assetId')
.populate('assets.logos.assetId');
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
// Check ownership
if (poster.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Accesso negato'
});
}
res.json({
success: true,
data: poster
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// PUT /posters/:id
async update(req, res) {
try {
const poster = await Poster.findById(req.params.id);
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
if (poster.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
const updateFields = [
'name', 'description', 'content', 'assets', 'layerOverrides'
];
updateFields.forEach(field => {
if (req.body[field] !== undefined) {
poster[field] = req.body[field];
}
});
// Invalida render precedente se contenuto modificato
if (req.body.content || req.body.assets || req.body.layerOverrides) {
poster.status = 'draft';
poster.addHistory('updated', { fields: Object.keys(req.body) });
}
await poster.save();
res.json({
success: true,
data: poster
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// DELETE /posters/:id
async delete(req, res) {
try {
const poster = await Poster.findById(req.params.id);
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
if (poster.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
// Elimina file renderizzati
if (poster.renderOutput) {
const filesToDelete = [
poster.renderOutput.png?.path,
poster.renderOutput.jpg?.path,
poster.renderOutput.webp?.path
].filter(Boolean);
for (const filePath of filesToDelete) {
try {
await fs.unlink(filePath);
} catch (e) {
console.warn('File not found:', filePath);
}
}
}
await Poster.deleteOne({ _id: poster._id });
res.json({
success: true,
message: 'Poster eliminato'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /posters/:id/render
async render(req, res) {
try {
const poster = await Poster.findById(req.params.id)
.populate('templateId');
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
if (poster.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
poster.status = 'processing';
await poster.save();
try {
await posterController._renderPoster(poster);
await poster.save();
res.json({
success: true,
data: {
status: poster.status,
renderOutput: poster.renderOutput
}
});
} catch (renderError) {
poster.setError(renderError.message);
await poster.save();
throw renderError;
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /posters/:id/regenerate-ai
async regenerateAi(req, res) {
try {
const { assetType, prompt, provider = 'hf' } = req.body;
const poster = await Poster.findById(req.params.id);
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
if (poster.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
// Genera nuova immagine AI
const startTime = Date.now();
const imageUrl = await imageGenerator.generate(provider, prompt);
const generationTime = Date.now() - startTime;
// Salva su filesystem
const fileName = `${poster._id}_${assetType}_${Date.now()}.jpg`;
const filePath = path.join(UPLOAD_DIR, 'ai-generated', fileName);
await fs.mkdir(path.dirname(filePath), { recursive: true });
// Se è base64, converti
let savedPath;
if (imageUrl.startsWith('data:')) {
const base64Data = imageUrl.replace(/^data:image\/\w+;base64,/, '');
await fs.writeFile(filePath, base64Data, 'base64');
savedPath = filePath;
} else {
// Se è URL, scarica
const fetch = require('node-fetch');
const response = await fetch(imageUrl);
const buffer = await response.buffer();
await fs.writeFile(filePath, buffer);
savedPath = filePath;
}
// Aggiorna asset nel poster
const assetData = {
sourceType: 'ai',
url: `/upload/ai-generated/${fileName}`,
mimeType: 'image/jpeg',
aiParams: {
prompt,
provider,
generatedAt: new Date()
}
};
if (assetType === 'backgroundImage') {
poster.assets.backgroundImage = assetData;
poster.addHistory('ai_background_generated', { provider, duration: generationTime });
} else if (assetType === 'mainImage') {
poster.assets.mainImage = assetData;
poster.addHistory('ai_main_generated', { provider, duration: generationTime });
}
poster.status = 'draft';
await poster.save();
res.json({
success: true,
data: {
assetType,
asset: assetData
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /posters/:id/download/:format
async download(req, res) {
try {
const { format } = req.params;
const poster = await Poster.findById(req.params.id);
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
const outputFile = poster.renderOutput?.[format];
if (!outputFile?.path) {
return res.status(404).json({
success: false,
error: `Formato ${format} non disponibile`
});
}
// Incrementa download count
await poster.incrementDownload();
const fileName = `${poster.name.replace(/[^a-z0-9]/gi, '_')}_poster.${format}`;
res.download(outputFile.path, fileName);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /posters/:id/favorite
async toggleFavorite(req, res) {
try {
const poster = await Poster.findById(req.params.id);
if (!poster) {
return res.status(404).json({
success: false,
error: 'Poster non trovato'
});
}
if (poster.metadata.userId.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
await poster.toggleFavorite();
res.json({
success: true,
data: {
isFavorite: poster.metadata.isFavorite
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /posters/quick-generate (compatibile con la tua bozza)
async quickGenerate(req, res) {
try {
const {
templateId,
titolo,
descrizione,
data,
ora,
luogo,
contatti,
fotoDescrizione,
stile,
provider = 'hf',
aspectRatio = '9:16'
} = req.body;
// Validazione base
if (!titolo || !data || !luogo) {
return res.status(400).json({
success: false,
error: 'Compila titolo, data e luogo'
});
}
// Usa template default o quello specificato
let template;
if (templateId) {
template = await Template.findById(templateId);
} else {
// Template default per quick-generate
template = await Template.findOne({
templateType: 'quick-generate',
isActive: true
});
}
// Genera prompt per AI background
const aiPrompt = `Vertical event poster background, ${stile || 'modern style, vivid colors'}. Subject: ${fotoDescrizione || 'abstract artistic shapes'}. Composition: Central empty space suitable for text overlay. NO TEXT, NO LETTERS, clean illustration, high quality, 4k.`;
// Genera immagine AI
const startTime = Date.now();
const rawImageUrl = await imageGenerator.generate(provider, aiPrompt);
const generationTime = Date.now() - startTime;
// Salva asset generato
const fileName = `quick_${Date.now()}.jpg`;
const filePath = path.join(UPLOAD_DIR, 'ai-generated', fileName);
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (rawImageUrl.startsWith('data:')) {
const base64Data = rawImageUrl.replace(/^data:image\/\w+;base64,/, '');
await fs.writeFile(filePath, base64Data, 'base64');
}
// Crea poster
const poster = new Poster({
templateId: template?._id,
name: titolo,
status: 'processing',
content: {
title: titolo,
subtitle: descrizione,
eventDate: data,
eventTime: ora,
location: luogo,
contacts: contatti
},
assets: {
backgroundImage: {
sourceType: 'ai',
url: `/upload/ai-generated/${fileName}`,
mimeType: 'image/jpeg',
aiParams: {
prompt: aiPrompt,
provider,
generatedAt: new Date()
}
}
},
originalPrompt: aiPrompt,
styleUsed: stile,
aspectRatio,
provider,
metadata: {
userId: req.user._id
}
});
poster.addHistory('ai_background_generated', { provider, duration: generationTime });
// Render con testi sovrapposti
await posterController._renderPoster(poster, { useQuickRender: true });
await poster.save();
res.json({
success: true,
data: {
posterId: poster._id,
imageUrl: poster.renderOutput?.png?.url || rawImageUrl,
status: poster.status
}
});
} catch (error) {
console.error('Quick generate error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
},
// Helper interno: renderizza poster
async _renderPoster(poster, options = {}) {
const template = poster.templateId || poster.templateSnapshot;
const result = await posterRenderer.render({
template,
content: poster.content,
assets: poster.assets,
layerOverrides: Object.fromEntries(poster.layerOverrides || new Map()),
outputDir: path.join(UPLOAD_DIR, 'posters', 'final'),
posterId: poster._id.toString()
});
poster.setRenderOutput({
png: {
path: result.pngPath,
url: `/upload/posters/final/${path.basename(result.pngPath)}`,
size: result.pngSize
},
jpg: {
path: result.jpgPath,
url: `/upload/posters/final/${path.basename(result.jpgPath)}`,
size: result.jpgSize,
quality: 95
},
dimensions: result.dimensions,
duration: result.duration
});
}
};
module.exports = posterController;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
const Template = require('../models/Template');
// Presets formati standard
const FORMAT_PRESETS = {
'A4': { width: 2480, height: 3508, dpi: 300 },
'A4-landscape': { width: 3508, height: 2480, dpi: 300 },
'A3': { width: 3508, height: 4961, dpi: 300 },
'A3-landscape': { width: 4961, height: 3508, dpi: 300 },
'instagram-post': { width: 1080, height: 1080, dpi: 72 },
'instagram-story': { width: 1080, height: 1920, dpi: 72 },
'instagram-portrait': { width: 1080, height: 1350, dpi: 72 },
'facebook-post': { width: 1200, height: 630, dpi: 72 },
'facebook-event': { width: 1920, height: 1080, dpi: 72 },
'twitter-post': { width: 1200, height: 675, dpi: 72 },
'poster-24x36': { width: 7200, height: 10800, dpi: 300 },
'flyer-5x7': { width: 1500, height: 2100, dpi: 300 }
};
const templateController = {
// POST /templates
async create(req, res) {
try {
const {
name,
templateType,
description,
format,
safeArea,
backgroundColor,
layers,
logoSlots,
palette,
typography,
defaultAiPromptHints,
metadata
} = req.body;
// Applica preset se specificato
let finalFormat = format;
if (format?.preset && FORMAT_PRESETS[format.preset]) {
finalFormat = {
...FORMAT_PRESETS[format.preset],
preset: format.preset,
unit: 'px'
};
}
// Valida layers
if (!layers || !Array.isArray(layers) || layers.length === 0) {
return res.status(400).json({
success: false,
error: 'Almeno un layer è richiesto'
});
}
// Assicura ID unici per layer
const layersWithIds = layers.map((layer, idx) => ({
...layer,
id: layer.id || `layer_${layer.type}_${idx}`
}));
const template = new Template({
name,
templateType,
description,
format: finalFormat,
safeArea: safeArea || {},
backgroundColor: backgroundColor || '#1a1a2e',
layers: layersWithIds,
logoSlots: logoSlots || { enabled: false, slots: [] },
palette: palette || {},
typography: typography || {},
defaultAiPromptHints: defaultAiPromptHints || {},
metadata: {
...metadata,
author: req.user?.name || 'System'
},
userId: req.user?._id
});
await template.save();
res.status(201).json({
success: true,
data: template
});
} catch (error) {
console.error('Template create error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /templates
async list(req, res) {
try {
const {
type,
search,
tags,
page = 1,
limit = 20,
sortBy = 'createdAt',
sortOrder = 'desc'
} = req.query;
const query = { isActive: true };
if (type) {
query.templateType = type;
}
if (tags) {
const tagArray = tags.split(',').map(t => t.trim());
query['metadata.tags'] = { $in: tagArray };
}
if (search) {
query.$text = { $search: search };
}
// Se utente autenticato, mostra anche i suoi privati
if (req.user) {
query.$or = [
{ 'metadata.isPublic': true },
{ userId: req.user._id }
];
} else {
query['metadata.isPublic'] = true;
}
const sort = { [sortBy]: sortOrder === 'asc' ? 1 : -1 };
const [templates, total] = await Promise.all([
Template.find(query)
.sort(sort)
.skip((page - 1) * limit)
.limit(parseInt(limit))
.select('-layers -logoSlots'), // Escludi dati pesanti per list
Template.countDocuments(query)
]);
res.json({
success: true,
data: templates,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Template list error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /templates/types
async getTypes(req, res) {
try {
const types = await Template.distinct('templateType', { isActive: true });
res.json({
success: true,
data: types.sort()
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /templates/presets
async getFormatPresets(req, res) {
res.json({
success: true,
data: FORMAT_PRESETS
});
},
// GET /templates/:id
async getById(req, res) {
try {
const template = await Template.findById(req.params.id);
if (!template) {
return res.status(404).json({
success: false,
error: 'Template non trovato'
});
}
// Check accesso
if (!template.metadata.isPublic &&
(!req.user || template.userId?.toString() !== req.user._id.toString())) {
return res.status(403).json({
success: false,
error: 'Accesso negato'
});
}
res.json({
success: true,
data: template
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// PUT /templates/:id
async update(req, res) {
try {
const template = await Template.findById(req.params.id);
if (!template) {
return res.status(404).json({
success: false,
error: 'Template non trovato'
});
}
// Check ownership
if (template.userId?.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato a modificare questo template'
});
}
const updateFields = [
'name', 'description', 'templateType', 'format', 'safeArea',
'backgroundColor', 'layers', 'logoSlots', 'palette',
'typography', 'defaultAiPromptHints', 'metadata', 'isActive'
];
updateFields.forEach(field => {
if (req.body[field] !== undefined) {
template[field] = req.body[field];
}
});
// Incrementa versione
if (template.metadata) {
const version = template.metadata.version || '1.0.0';
const parts = version.split('.').map(Number);
parts[2]++;
template.metadata.version = parts.join('.');
}
await template.save();
res.json({
success: true,
data: template
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// DELETE /templates/:id
async delete(req, res) {
try {
const template = await Template.findById(req.params.id);
if (!template) {
return res.status(404).json({
success: false,
error: 'Template non trovato'
});
}
if (template.userId?.toString() !== req.user._id.toString()) {
return res.status(403).json({
success: false,
error: 'Non autorizzato'
});
}
// Soft delete
template.isActive = false;
await template.save();
res.json({
success: true,
message: 'Template eliminato'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// POST /templates/:id/duplicate
async duplicate(req, res) {
try {
const original = await Template.findById(req.params.id);
if (!original) {
return res.status(404).json({
success: false,
error: 'Template non trovato'
});
}
const duplicateData = original.toObject();
delete duplicateData._id;
delete duplicateData.createdAt;
delete duplicateData.updatedAt;
duplicateData.name = `${original.name} (copia)`;
duplicateData.userId = req.user._id;
duplicateData.metadata = {
...duplicateData.metadata,
isPublic: false,
usageCount: 0,
author: req.user?.name || 'System',
version: '1.0.0'
};
const duplicate = new Template(duplicateData);
await duplicate.save();
res.status(201).json({
success: true,
data: duplicate
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
},
// GET /templates/:id/preview
async getPreview(req, res) {
try {
const template = await Template.findById(req.params.id)
.select('previewUrl thumbnailUrl name');
if (!template) {
return res.status(404).json({
success: false,
error: 'Template non trovato'
});
}
res.json({
success: true,
data: {
previewUrl: template.previewUrl,
thumbnailUrl: template.thumbnailUrl,
name: template.name
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
}
};
module.exports = templateController;

View File

@@ -0,0 +1,847 @@
/**
* TrasportiNotifications.js
*
* Servizio notifiche centralizzato per Trasporti Solidali.
* USA il telegrambot.js esistente per Telegram, AGGIUNGE Email e Push.
*
* NON MODIFICA telegrambot.js - lo importa e usa i suoi metodi.
*/
const nodemailer = require('nodemailer');
const webpush = require('web-push');
// Importa il tuo telegrambot esistente
const MyTelegramBot = require('../../telegram/telegrambot');
// =============================================================================
// CONFIGURAZIONE
// =============================================================================
const config = {
// Email SMTP
smtp: {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: parseInt(process.env.SMTP_PORT) || 465,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
},
emailFrom: process.env.SMTP_FROM || 'noreply@trasporti.app',
// Push VAPID
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
vapidEmail: process.env.VAPID_EMAIL || 'admin@trasporti.app',
// App
appName: process.env.APP_NAME || 'Trasporti Solidali',
appUrl: process.env.APP_URL || 'https://trasporti.app'
};
// Configura web-push se le chiavi sono presenti
if (config.vapidPublicKey && config.vapidPrivateKey) {
webpush.setVapidDetails(
`mailto:${config.vapidEmail}`,
config.vapidPublicKey,
config.vapidPrivateKey
);
}
// Crea transporter email
let emailTransporter = null;
if (config.smtp.auth.user && config.smtp.auth.pass) {
emailTransporter = nodemailer.createTransport(config.smtp);
}
// =============================================================================
// TIPI DI NOTIFICA
// =============================================================================
const NotificationType = {
// Viaggi
NEW_RIDE_REQUEST: 'new_ride_request',
REQUEST_ACCEPTED: 'request_accepted',
REQUEST_REJECTED: 'request_rejected',
RIDE_REMINDER_24H: 'ride_reminder_24h',
RIDE_REMINDER_2H: 'ride_reminder_2h',
RIDE_CANCELLED: 'ride_cancelled',
RIDE_MODIFIED: 'ride_modified',
// Messaggi
NEW_MESSAGE: 'new_message',
// Community
NEW_COMMUNITY_RIDE: 'new_community_ride',
// Sistema
WEEKLY_DIGEST: 'weekly_digest',
TEST: 'test',
WELCOME: 'welcome'
};
// =============================================================================
// EMOJI PER NOTIFICHE
// =============================================================================
const emo = {
CAR: '🚗',
PASSENGER: '🧑‍🤝‍🧑',
CHECK: '✅',
CROSS: '❌',
BELL: '🔔',
CLOCK: '⏰',
CALENDAR: '📅',
PIN: '📍',
ARROW: '➡️',
MESSAGE: '💬',
STAR: '⭐',
WARNING: '⚠️',
INFO: '',
WAVE: '👋',
HEART: '❤️'
};
// =============================================================================
// TRADUZIONI NOTIFICHE
// =============================================================================
const translations = {
it: {
// Richieste
NEW_RIDE_REQUEST_TITLE: 'Nuova richiesta di passaggio',
NEW_RIDE_REQUEST_BODY: '{{passengerName}} chiede un passaggio per il viaggio {{departure}} → {{destination}} del {{date}}',
NEW_RIDE_REQUEST_ACTION: 'Visualizza richiesta',
// Accettazione
REQUEST_ACCEPTED_TITLE: 'Richiesta accettata!',
REQUEST_ACCEPTED_BODY: '{{driverName}} ha accettato la tua richiesta per {{departure}} → {{destination}} del {{date}}',
REQUEST_ACCEPTED_ACTION: 'Visualizza viaggio',
// Rifiuto
REQUEST_REJECTED_TITLE: 'Richiesta non accettata',
REQUEST_REJECTED_BODY: '{{driverName}} non ha potuto accettare la tua richiesta per {{departure}} → {{destination}}',
// Promemoria
RIDE_REMINDER_24H_TITLE: 'Viaggio domani!',
RIDE_REMINDER_24H_BODY: 'Promemoria: domani hai un viaggio {{departure}} → {{destination}} alle {{time}}',
RIDE_REMINDER_2H_TITLE: 'Viaggio tra 2 ore!',
RIDE_REMINDER_2H_BODY: 'Il tuo viaggio {{departure}} → {{destination}} parte tra 2 ore alle {{time}}',
// Cancellazione
RIDE_CANCELLED_TITLE: 'Viaggio cancellato',
RIDE_CANCELLED_BODY: 'Il viaggio {{departure}} → {{destination}} del {{date}} è stato cancellato',
RIDE_CANCELLED_REASON: 'Motivo: {{reason}}',
// Modifica
RIDE_MODIFIED_TITLE: 'Viaggio modificato',
RIDE_MODIFIED_BODY: 'Il viaggio {{departure}} → {{destination}} è stato modificato. Verifica i nuovi dettagli.',
// Messaggi
NEW_MESSAGE_TITLE: 'Nuovo messaggio',
NEW_MESSAGE_BODY: '{{senderName}}: {{preview}}',
// Community
NEW_COMMUNITY_RIDE_TITLE: 'Nuovo viaggio nella tua zona',
NEW_COMMUNITY_RIDE_BODY: 'Nuovo viaggio disponibile: {{departure}} → {{destination}} il {{date}}',
// Test
TEST_TITLE: 'Notifica di test',
TEST_BODY: 'Questa è una notifica di test da Trasporti Solidali. Se la vedi, tutto funziona!',
// Welcome
WELCOME_TITLE: 'Benvenuto su Trasporti Solidali!',
WELCOME_BODY: 'Le notifiche sono state attivate correttamente. Riceverai aggiornamenti sui tuoi viaggi.',
// Common
VIEW_DETAILS: 'Visualizza dettagli',
REPLY: 'Rispondi'
},
en: {
NEW_RIDE_REQUEST_TITLE: 'New ride request',
NEW_RIDE_REQUEST_BODY: '{{passengerName}} requests a ride for {{departure}} → {{destination}} on {{date}}',
NEW_RIDE_REQUEST_ACTION: 'View request',
REQUEST_ACCEPTED_TITLE: 'Request accepted!',
REQUEST_ACCEPTED_BODY: '{{driverName}} accepted your request for {{departure}} → {{destination}} on {{date}}',
REQUEST_ACCEPTED_ACTION: 'View ride',
REQUEST_REJECTED_TITLE: 'Request not accepted',
REQUEST_REJECTED_BODY: '{{driverName}} could not accept your request for {{departure}} → {{destination}}',
RIDE_REMINDER_24H_TITLE: 'Ride tomorrow!',
RIDE_REMINDER_24H_BODY: 'Reminder: tomorrow you have a ride {{departure}} → {{destination}} at {{time}}',
RIDE_REMINDER_2H_TITLE: 'Ride in 2 hours!',
RIDE_REMINDER_2H_BODY: 'Your ride {{departure}} → {{destination}} leaves in 2 hours at {{time}}',
RIDE_CANCELLED_TITLE: 'Ride cancelled',
RIDE_CANCELLED_BODY: 'The ride {{departure}} → {{destination}} on {{date}} has been cancelled',
RIDE_CANCELLED_REASON: 'Reason: {{reason}}',
RIDE_MODIFIED_TITLE: 'Ride modified',
RIDE_MODIFIED_BODY: 'The ride {{departure}} → {{destination}} has been modified. Check the new details.',
NEW_MESSAGE_TITLE: 'New message',
NEW_MESSAGE_BODY: '{{senderName}}: {{preview}}',
NEW_COMMUNITY_RIDE_TITLE: 'New ride in your area',
NEW_COMMUNITY_RIDE_BODY: 'New ride available: {{departure}} → {{destination}} on {{date}}',
TEST_TITLE: 'Test notification',
TEST_BODY: 'This is a test notification from Trasporti Solidali. If you see this, everything works!',
WELCOME_TITLE: 'Welcome to Trasporti Solidali!',
WELCOME_BODY: 'Notifications have been enabled successfully. You will receive updates about your rides.',
VIEW_DETAILS: 'View details',
REPLY: 'Reply'
}
};
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Ottiene traduzione con sostituzione variabili
*/
function getTranslation(lang, key, data = {}) {
const langTranslations = translations[lang] || translations['it'];
let text = langTranslations[key] || translations['it'][key] || key;
// Sostituisci {{variabile}}
Object.keys(data).forEach(varName => {
const regex = new RegExp(`\\{\\{${varName}\\}\\}`, 'g');
text = text.replace(regex, data[varName] || '');
});
return text;
}
/**
* Mappa tipo notifica a chiave preferenze
*/
function getPreferenceKey(type) {
const map = {
[NotificationType.NEW_RIDE_REQUEST]: 'newRideRequest',
[NotificationType.REQUEST_ACCEPTED]: 'requestAccepted',
[NotificationType.REQUEST_REJECTED]: 'requestRejected',
[NotificationType.RIDE_REMINDER_24H]: 'rideReminder24h',
[NotificationType.RIDE_REMINDER_2H]: 'rideReminder2h',
[NotificationType.RIDE_CANCELLED]: 'rideCancelled',
[NotificationType.RIDE_MODIFIED]: 'rideCancelled',
[NotificationType.NEW_MESSAGE]: 'newMessage',
[NotificationType.NEW_COMMUNITY_RIDE]: 'newCommunityRide',
[NotificationType.WEEKLY_DIGEST]: 'weeklyDigest',
[NotificationType.TEST]: null, // Sempre inviato
[NotificationType.WELCOME]: null // Sempre inviato
};
return map[type];
}
/**
* Verifica se inviare notifica su un canale
*/
function shouldSend(prefs, channel, type) {
if (!prefs) return false;
const channelPrefs = prefs[channel];
if (!channelPrefs || !channelPrefs.enabled) return false;
// Test e Welcome sempre inviati se canale abilitato
if (type === NotificationType.TEST || type === NotificationType.WELCOME) {
return true;
}
const prefKey = getPreferenceKey(type);
if (!prefKey) return true; // Se non mappato, invia
return channelPrefs[prefKey] !== false; // Default true
}
/**
* Tronca testo
*/
function truncate(text, maxLength = 100) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
// =============================================================================
// EMAIL TEMPLATES
// =============================================================================
function buildEmailHtml(type, data, lang = 'it') {
const t = (key) => getTranslation(lang, key, data);
const title = t(`${type.toUpperCase()}_TITLE`);
const body = t(`${type.toUpperCase()}_BODY`);
// Colori per tipo
const colors = {
[NotificationType.NEW_RIDE_REQUEST]: '#667eea',
[NotificationType.REQUEST_ACCEPTED]: '#21ba45',
[NotificationType.REQUEST_REJECTED]: '#c10015',
[NotificationType.RIDE_REMINDER_24H]: '#f2711c',
[NotificationType.RIDE_REMINDER_2H]: '#db2828',
[NotificationType.RIDE_CANCELLED]: '#c10015',
[NotificationType.NEW_MESSAGE]: '#2185d0',
[NotificationType.NEW_COMMUNITY_RIDE]: '#a333c8',
[NotificationType.TEST]: '#667eea',
[NotificationType.WELCOME]: '#21ba45'
};
const color = colors[type] || '#667eea';
// Emoji per tipo
const emojis = {
[NotificationType.NEW_RIDE_REQUEST]: emo.PASSENGER,
[NotificationType.REQUEST_ACCEPTED]: emo.CHECK,
[NotificationType.REQUEST_REJECTED]: emo.CROSS,
[NotificationType.RIDE_REMINDER_24H]: emo.CALENDAR,
[NotificationType.RIDE_REMINDER_2H]: emo.CLOCK,
[NotificationType.RIDE_CANCELLED]: emo.WARNING,
[NotificationType.NEW_MESSAGE]: emo.MESSAGE,
[NotificationType.NEW_COMMUNITY_RIDE]: emo.CAR,
[NotificationType.TEST]: emo.BELL,
[NotificationType.WELCOME]: emo.WAVE
};
const emoji = emojis[type] || emo.BELL;
// CTA button
let ctaHtml = '';
if (data.actionUrl) {
const actionText = data.actionText || t('VIEW_DETAILS');
ctaHtml = `
<div style="text-align: center; margin: 30px 0;">
<a href="${data.actionUrl}"
style="display: inline-block; padding: 14px 32px; background: ${color};
color: white; text-decoration: none; border-radius: 8px;
font-weight: 600; font-size: 16px;">
${actionText}
</a>
</div>
`;
}
// Info viaggio
let rideInfoHtml = '';
if (data.departure && data.destination) {
rideInfoHtml = `
<div style="background: #f8f9fa; border-radius: 12px; padding: 20px; margin: 20px 0;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<span style="font-size: 24px;">${emo.PIN}</span>
<div>
<div style="color: #666; font-size: 12px;">Partenza</div>
<div style="font-weight: 600; font-size: 16px;">${data.departure}</div>
</div>
</div>
<div style="text-align: center; color: #999; margin: 10px 0;">${emo.ARROW}</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 24px;">${emo.PIN}</span>
<div>
<div style="color: #666; font-size: 12px;">Destinazione</div>
<div style="font-weight: 600; font-size: 16px;">${data.destination}</div>
</div>
</div>
${data.date ? `
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;">
<span style="color: #666;">${emo.CALENDAR} ${data.date}</span>
${data.time ? `<span style="margin-left: 15px; color: #666;">${emo.CLOCK} ${data.time}</span>` : ''}
</div>
` : ''}
</div>
`;
}
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background: white;">
<!-- Header -->
<div style="background: linear-gradient(135deg, ${color} 0%, ${color}dd 100%); padding: 30px 20px; text-align: center;">
<div style="font-size: 48px; margin-bottom: 10px;">${emoji}</div>
<h1 style="margin: 0; color: white; font-size: 24px; font-weight: 600;">${title}</h1>
</div>
<!-- Content -->
<div style="padding: 30px 20px;">
<p style="font-size: 16px; line-height: 1.6; color: #333; margin: 0 0 20px;">
${body}
</p>
${rideInfoHtml}
${data.reason ? `<p style="color: #666; font-style: italic;">${t('RIDE_CANCELLED_REASON')}</p>` : ''}
${ctaHtml}
</div>
<!-- Footer -->
<div style="background: #f8f9fa; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666; font-size: 14px;">
${config.appName}
</p>
<p style="margin: 0; color: #999; font-size: 12px;">
Ricevi questa email perché hai attivato le notifiche.
<a href="${config.appUrl}/impostazioni" style="color: ${color};">Gestisci preferenze</a>
</p>
</div>
</div>
</body>
</html>
`;
}
// =============================================================================
// TELEGRAM MESSAGE BUILDER
// =============================================================================
function buildTelegramMessage(type, data, lang = 'it') {
const t = (key) => getTranslation(lang, key, data);
const title = t(`${type.toUpperCase()}_TITLE`);
const body = t(`${type.toUpperCase()}_BODY`);
// Emoji per tipo
const emojis = {
[NotificationType.NEW_RIDE_REQUEST]: emo.PASSENGER,
[NotificationType.REQUEST_ACCEPTED]: emo.CHECK,
[NotificationType.REQUEST_REJECTED]: emo.CROSS,
[NotificationType.RIDE_REMINDER_24H]: emo.CALENDAR,
[NotificationType.RIDE_REMINDER_2H]: emo.CLOCK,
[NotificationType.RIDE_CANCELLED]: emo.WARNING,
[NotificationType.NEW_MESSAGE]: emo.MESSAGE,
[NotificationType.NEW_COMMUNITY_RIDE]: emo.CAR,
[NotificationType.TEST]: emo.BELL,
[NotificationType.WELCOME]: emo.WAVE
};
const emoji = emojis[type] || emo.BELL;
let message = `${emoji} <b>${title}</b>\n\n${body}`;
// Aggiungi info viaggio
if (data.departure && data.destination) {
message += `\n\n${emo.PIN} <b>Percorso:</b>\n${data.departure} ${emo.ARROW} ${data.destination}`;
if (data.date) {
message += `\n${emo.CALENDAR} ${data.date}`;
}
if (data.time) {
message += ` ${emo.CLOCK} ${data.time}`;
}
}
// Motivo cancellazione
if (data.reason) {
message += `\n\n<i>${t('RIDE_CANCELLED_REASON')}</i>`;
}
return message;
}
// =============================================================================
// PUSH NOTIFICATION BUILDER
// =============================================================================
function buildPushPayload(type, data, lang = 'it') {
const t = (key) => getTranslation(lang, key, data);
const title = t(`${type.toUpperCase()}_TITLE`);
let body = t(`${type.toUpperCase()}_BODY`);
// Tronca body per push
body = truncate(body, 150);
// Icone per tipo
const icons = {
[NotificationType.NEW_RIDE_REQUEST]: '/icons/request.png',
[NotificationType.REQUEST_ACCEPTED]: '/icons/accepted.png',
[NotificationType.REQUEST_REJECTED]: '/icons/rejected.png',
[NotificationType.RIDE_REMINDER_24H]: '/icons/reminder.png',
[NotificationType.RIDE_REMINDER_2H]: '/icons/urgent.png',
[NotificationType.RIDE_CANCELLED]: '/icons/cancelled.png',
[NotificationType.NEW_MESSAGE]: '/icons/message.png',
[NotificationType.NEW_COMMUNITY_RIDE]: '/icons/community.png',
[NotificationType.TEST]: '/icons/notification.png',
[NotificationType.WELCOME]: '/icons/welcome.png'
};
return {
title,
body,
icon: icons[type] || '/icons/notification.png',
badge: '/icons/badge.png',
tag: type,
data: {
type,
url: data.actionUrl || config.appUrl,
...data
},
actions: data.actionUrl ? [
{ action: 'open', title: t('VIEW_DETAILS') }
] : []
};
}
// =============================================================================
// MAIN SERVICE
// =============================================================================
const TrasportiNotifications = {
// Esponi tipi e emoji
NotificationType,
emo,
// Esponi config
config,
/**
* Invia notifica su tutti i canali abilitati
*
* @param {Object} user - Utente destinatario (con notificationPreferences)
* @param {string} type - Tipo notifica (da NotificationType)
* @param {Object} data - Dati per template
* @param {string} idapp - ID app (per Telegram)
* @returns {Object} { success, results: { email, telegram, push } }
*/
async sendNotification(user, type, data, idapp) {
const results = {
email: null,
telegram: null,
push: null
};
const prefs = user.notificationPreferences || {};
const lang = user.lang || 'it';
// Aggiungi URL azione se non presente
if (!data.actionUrl && data.rideId) {
data.actionUrl = `${config.appUrl}/trasporti/viaggio/${data.rideId}`;
}
if (!data.actionUrl && data.requestId) {
data.actionUrl = `${config.appUrl}/trasporti/richieste/${data.requestId}`;
}
if (!data.actionUrl && data.chatId) {
data.actionUrl = `${config.appUrl}/trasporti/chat/${data.chatId}`;
}
// EMAIL
if (shouldSend(prefs, 'email', type) && user.email) {
results.email = await this.sendEmail(user.email, type, data, lang);
}
// TELEGRAM (usa il tuo telegrambot.js esistente!)
const telegId = user.profile?.teleg_id || prefs.telegram?.chatId;
if (shouldSend(prefs, 'telegram', type) && telegId) {
results.telegram = await this.sendTelegram(idapp, telegId, type, data, lang);
}
// PUSH
const pushSub = prefs.push?.subscription;
if (shouldSend(prefs, 'push', type) && pushSub) {
results.push = await this.sendPush(pushSub, type, data, lang);
}
return {
success: Object.values(results).some(r => r?.success),
results
};
},
/**
* Invia notifica a multipli utenti
*/
async sendNotificationToMany(users, type, data, idapp) {
const results = [];
for (const user of users) {
try {
const result = await this.sendNotification(user, type, data, idapp);
results.push({ userId: user._id, ...result });
// Delay per evitare rate limiting
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
results.push({ userId: user._id, success: false, error: error.message });
}
}
return results;
},
// ===========================================================================
// EMAIL
// ===========================================================================
async sendEmail(to, type, data, lang = 'it') {
if (!emailTransporter) {
return { success: false, error: 'Email not configured' };
}
try {
const t = (key) => getTranslation(lang, key, data);
const subject = `${config.appName} - ${t(`${type.toUpperCase()}_TITLE`)}`;
const html = buildEmailHtml(type, data, lang);
const info = await emailTransporter.sendMail({
from: `"${config.appName}" <${config.emailFrom}>`,
to,
subject,
html
});
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Email send error:', error);
return { success: false, error: error.message };
}
},
// ===========================================================================
// TELEGRAM (usa il tuo MyTelegramBot!)
// ===========================================================================
async sendTelegram(idapp, chatId, type, data, lang = 'it') {
try {
const message = buildTelegramMessage(type, data, lang);
// USA IL TUO METODO ESISTENTE!
const result = await MyTelegramBot.local_sendMsgTelegramByIdTelegram(
idapp,
chatId,
message,
null, // message_id
null, // chat_id reply
false, // ripr_menuPrec
null, // MyForm (bottoni)
'' // img
);
return { success: true, messageId: result?.message_id };
} catch (error) {
console.error('Telegram send error:', error);
return { success: false, error: error.message };
}
},
/**
* Invia notifica Telegram con bottoni inline
*/
async sendTelegramWithButtons(idapp, chatId, type, data, buttons, lang = 'it') {
try {
const message = buildTelegramMessage(type, data, lang);
// Crea inline keyboard
const cl = MyTelegramBot.getclTelegByidapp(idapp);
if (!cl) {
return { success: false, error: 'Telegram client not found' };
}
const keyboard = cl.getInlineKeyboard(lang, buttons);
const result = await MyTelegramBot.local_sendMsgTelegramByIdTelegram(
idapp,
chatId,
message,
null,
null,
false,
keyboard,
''
);
return { success: true, messageId: result?.message_id };
} catch (error) {
console.error('Telegram send error:', error);
return { success: false, error: error.message };
}
},
// ===========================================================================
// PUSH
// ===========================================================================
async sendPush(subscription, type, data, lang = 'it') {
if (!config.vapidPublicKey || !config.vapidPrivateKey) {
return { success: false, error: 'Push not configured' };
}
try {
const payload = JSON.stringify(buildPushPayload(type, data, lang));
await webpush.sendNotification(subscription, payload);
return { success: true };
} catch (error) {
console.error('Push send error:', error);
// Subscription scaduta
if (error.statusCode === 410 || error.statusCode === 404) {
return { success: false, error: 'Subscription expired', expired: true };
}
return { success: false, error: error.message };
}
},
// ===========================================================================
// METODI SPECIFICI PER TRASPORTI
// ===========================================================================
/**
* Notifica nuova richiesta passaggio al conducente
*/
async notifyNewRideRequest(driver, passenger, ride, request, idapp) {
return this.sendNotification(driver, NotificationType.NEW_RIDE_REQUEST, {
passengerName: `${passenger.name} ${passenger.surname}`,
departure: ride.departure?.city || ride.departure?.address,
destination: ride.destination?.city || ride.destination?.address,
date: formatDate(ride.departureTime),
time: formatTime(ride.departureTime),
seats: request.seats || 1,
rideId: ride._id,
requestId: request._id,
actionUrl: `${config.appUrl}/trasporti/richieste/${request._id}`
}, idapp);
},
/**
* Notifica richiesta accettata al passeggero
*/
async notifyRequestAccepted(passenger, driver, ride, idapp) {
return this.sendNotification(passenger, NotificationType.REQUEST_ACCEPTED, {
driverName: `${driver.name} ${driver.surname}`,
departure: ride.departure?.city || ride.departure?.address,
destination: ride.destination?.city || ride.destination?.address,
date: formatDate(ride.departureTime),
time: formatTime(ride.departureTime),
rideId: ride._id,
actionUrl: `${config.appUrl}/trasporti/viaggio/${ride._id}`
}, idapp);
},
/**
* Notifica richiesta rifiutata al passeggero
*/
async notifyRequestRejected(passenger, driver, ride, reason, idapp) {
return this.sendNotification(passenger, NotificationType.REQUEST_REJECTED, {
driverName: `${driver.name} ${driver.surname}`,
departure: ride.departure?.city || ride.departure?.address,
destination: ride.destination?.city || ride.destination?.address,
reason
}, idapp);
},
/**
* Notifica promemoria viaggio
*/
async notifyRideReminder(user, ride, hoursBefor, idapp) {
const type = hoursBefor === 24
? NotificationType.RIDE_REMINDER_24H
: NotificationType.RIDE_REMINDER_2H;
return this.sendNotification(user, type, {
departure: ride.departure?.city || ride.departure?.address,
destination: ride.destination?.city || ride.destination?.address,
date: formatDate(ride.departureTime),
time: formatTime(ride.departureTime),
rideId: ride._id,
actionUrl: `${config.appUrl}/trasporti/viaggio/${ride._id}`
}, idapp);
},
/**
* Notifica viaggio cancellato
*/
async notifyRideCancelled(user, ride, reason, idapp) {
return this.sendNotification(user, NotificationType.RIDE_CANCELLED, {
departure: ride.departure?.city || ride.departure?.address,
destination: ride.destination?.city || ride.destination?.address,
date: formatDate(ride.departureTime),
reason,
rideId: ride._id
}, idapp);
},
/**
* Notifica nuovo messaggio
*/
async notifyNewMessage(recipient, sender, message, chatId, idapp) {
return this.sendNotification(recipient, NotificationType.NEW_MESSAGE, {
senderName: `${sender.name} ${sender.surname}`,
preview: truncate(message.text, 100),
chatId,
actionUrl: `${config.appUrl}/trasporti/chat/${chatId}`
}, idapp);
},
/**
* Invia notifica di test
*/
async sendTestNotification(user, channel, idapp) {
const type = NotificationType.TEST;
const data = {
actionUrl: `${config.appUrl}/trasporti/impostazioni`
};
const lang = user.lang || 'it';
if (channel === 'email' && user.email) {
return this.sendEmail(user.email, type, data, lang);
}
const telegId = user.profile?.teleg_id || user.notificationPreferences?.telegram?.chatId;
if (channel === 'telegram' && telegId) {
return this.sendTelegram(idapp, telegId, type, data, lang);
}
const pushSub = user.notificationPreferences?.push?.subscription;
if (channel === 'push' && pushSub) {
return this.sendPush(pushSub, type, data, lang);
}
if (channel === 'all') {
return this.sendNotification(user, type, data, idapp);
}
return { success: false, error: 'Invalid channel or not configured' };
}
};
// =============================================================================
// HELPER DATE
// =============================================================================
function formatDate(date) {
if (!date) return '';
const d = new Date(date);
return d.toLocaleDateString('it-IT', {
weekday: 'short',
day: 'numeric',
month: 'short'
});
}
function formatTime(date) {
if (!date) return '';
const d = new Date(date);
return d.toLocaleTimeString('it-IT', {
hour: '2-digit',
minute: '2-digit'
});
}
// =============================================================================
// EXPORT
// =============================================================================
module.exports = TrasportiNotifications;

View File

@@ -0,0 +1,422 @@
// ============================================================
// 🔧 SETTINGS CONTROLLER - Trasporti Solidali
// ============================================================
// File: server/controllers/viaggi/settingsController.js
const UserSettings = require('../../models/viaggi/UserSettings');
/**
* 📄 GET /api/viaggi/settings
* Ottieni le impostazioni dell'utente
*/
exports.getSettings = async (req, res) => {
try {
const { idapp } = req.query;
const userId = req.user._id;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
// Ottieni o crea impostazioni
const settings = await UserSettings.getOrCreateSettings(idapp, userId);
return res.status(200).json({
success: true,
data: settings.toClientJSON()
});
} catch (error) {
console.error('❌ Errore getSettings:', error);
return res.status(500).json({
success: false,
message: 'Errore nel caricamento delle impostazioni',
error: error.message
});
}
};
/**
* 📝 PUT /api/viaggi/settings
* Aggiorna le impostazioni dell'utente
*/
exports.updateSettings = async (req, res) => {
try {
const userId = req.user._id;
const idapp = req.user.idapp;
const updates = req.body;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
if (!updates || Object.keys(updates).length === 0) {
return res.status(400).json({
success: false,
message: 'Nessuna modifica specificata'
});
}
// Aggiorna impostazioni
const settings = await UserSettings.updateSettings(idapp, userId, updates);
return res.status(200).json({
success: true,
message: 'Impostazioni aggiornate con successo',
data: settings.toClientJSON()
});
} catch (error) {
console.error('❌ Errore updateSettings:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'aggiornamento delle impostazioni',
error: error.message
});
}
};
/**
* 📝 PATCH /api/viaggi/settings/notifications
* Aggiorna solo le impostazioni notifiche
*/
exports.updateNotifications = async (req, res) => {
try {
const idapp = req.user.idapp;
const userId = req.user._id;
const { notifications } = req.body;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
if (!notifications) {
return res.status(400).json({
success: false,
message: 'notifications è richiesto'
});
}
// Aggiorna solo notifiche
const settings = await UserSettings.updateSettings(idapp, userId, { notifications });
return res.status(200).json({
success: true,
message: 'Notifiche aggiornate',
data: settings.notifications
});
} catch (error) {
console.error('❌ Errore updateNotifications:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'aggiornamento delle notifiche',
error: error.message
});
}
};
/**
* 📝 PATCH /api/viaggi/settings/privacy
* Aggiorna solo le impostazioni privacy
*/
exports.updatePrivacy = async (req, res) => {
try {
const idapp = req.user.idapp;
const userId = req.user._id;
const { privacy } = req.body;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
if (!privacy) {
return res.status(400).json({
success: false,
message: 'privacy è richiesto'
});
}
// Aggiorna solo privacy
const settings = await UserSettings.updateSettings(idapp, userId, { privacy });
return res.status(200).json({
success: true,
message: 'Privacy aggiornata',
data: settings.privacy
});
} catch (error) {
console.error('❌ Errore updatePrivacy:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'aggiornamento della privacy',
error: error.message
});
}
};
/**
* 📝 PATCH /api/viaggi/settings/ride-preferences
* Aggiorna solo le preferenze viaggi
*/
exports.updateRidePreferences = async (req, res) => {
try {
const idapp = req.user.idapp;
const userId = req.user._id;
const { ridePreferences } = req.body;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
if (!ridePreferences) {
return res.status(400).json({
success: false,
message: 'ridePreferences è richiesto'
});
}
// Aggiorna preferenze viaggi
const settings = await UserSettings.updateSettings(idapp, userId, { ridePreferences });
return res.status(200).json({
success: true,
message: 'Preferenze viaggi aggiornate',
data: settings.ridePreferences
});
} catch (error) {
console.error('❌ Errore updateRidePreferences:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'aggiornamento delle preferenze',
error: error.message
});
}
};
/**
* 📝 PATCH /api/viaggi/settings/interface
* Aggiorna solo le impostazioni interfaccia
*/
exports.updateInterface = async (req, res) => {
try {
const idapp = req.user.idapp;
const userId = req.user._id;
const { interface: interfaceSettings } = req.body;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
if (!interfaceSettings) {
return res.status(400).json({
success: false,
message: 'interface è richiesto'
});
}
// Aggiorna interfaccia
const settings = await UserSettings.updateSettings(idapp, userId, {
interface: interfaceSettings
});
return res.status(200).json({
success: true,
message: 'Interfaccia aggiornata',
data: settings.interface
});
} catch (error) {
console.error('❌ Errore updateInterface:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'aggiornamento dell\'interfaccia',
error: error.message
});
}
};
/**
* 🔄 POST /api/viaggi/settings/reset
* Reset impostazioni ai valori predefiniti
*/
exports.resetSettings = async (req, res) => {
try {
const idapp = req.user.idapp;
const userId = req.user._id;
const { section } = req.body; // Opzionale: resetta solo una sezione
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
// Trova impostazioni esistenti
let settings = await UserSettings.findOne({ idapp, userId });
if (!settings) {
return res.status(404).json({
success: false,
message: 'Impostazioni non trovate'
});
}
if (section) {
// Reset solo di una sezione specifica
const schema = UserSettings.schema.paths[section];
if (!schema) {
return res.status(400).json({
success: false,
message: 'Sezione non valida'
});
}
// Ottieni valori predefiniti dalla schema
settings[section] = schema.defaultValue || {};
} else {
// Reset completo - cancella e ricrea
await UserSettings.deleteOne({ idapp, userId });
settings = await UserSettings.getOrCreateSettings(idapp, userId);
}
await settings.save();
return res.status(200).json({
success: true,
message: section
? `Sezione ${section} resettata`
: 'Impostazioni resettate ai valori predefiniti',
data: settings.toClientJSON()
});
} catch (error) {
console.error('❌ Errore resetSettings:', error);
return res.status(500).json({
success: false,
message: 'Errore nel reset delle impostazioni',
error: error.message
});
}
};
/**
* 📊 GET /api/viaggi/settings/export
* Esporta tutte le impostazioni (per backup o trasferimento)
*/
exports.exportSettings = async (req, res) => {
try {
const { idapp } = req.query;
const userId = req.user._id;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
const settings = await UserSettings.findOne({ idapp, userId });
if (!settings) {
return res.status(404).json({
success: false,
message: 'Impostazioni non trovate'
});
}
// Esporta in formato JSON pulito
const exportData = {
exportDate: new Date().toISOString(),
userId: userId.toString(),
idapp,
settings: settings.toClientJSON()
};
return res.status(200).json({
success: true,
data: exportData
});
} catch (error) {
console.error('❌ Errore exportSettings:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'esportazione delle impostazioni',
error: error.message
});
}
};
/**
* 📥 POST /api/viaggi/settings/import
* Importa impostazioni da backup
*/
exports.importSettings = async (req, res) => {
try {
const idapp = req.user.idapp;
const userId = req.user._id;
const { settings: importedSettings } = req.body;
// Validazione
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
if (!importedSettings) {
return res.status(400).json({
success: false,
message: 'settings è richiesto'
});
}
// Aggiorna con le impostazioni importate
const settings = await UserSettings.updateSettings(idapp, userId, importedSettings);
return res.status(200).json({
success: true,
message: 'Impostazioni importate con successo',
data: settings.toClientJSON()
});
} catch (error) {
console.error('❌ Errore importSettings:', error);
return res.status(500).json({
success: false,
message: 'Errore nell\'importazione delle impostazioni',
error: error.message
});
}
};

View File

@@ -0,0 +1,506 @@
/**
* trasportiNotificationsController.js
*
* Controller API per gestire le preferenze di notifica utente.
* Funziona insieme a TrasportiNotifications.js
*/
const mongoose = require('mongoose');
const TrasportiNotifications = require('./TrasportiNotifications');
// =============================================================================
// SCHEMA PREFERENZE (da aggiungere al model User)
// =============================================================================
const notificationPreferencesSchema = new mongoose.Schema({
email: {
enabled: { type: Boolean, default: true },
newRideRequest: { type: Boolean, default: true },
requestAccepted: { type: Boolean, default: true },
requestRejected: { type: Boolean, default: true },
rideReminder24h: { type: Boolean, default: true },
rideReminder2h: { type: Boolean, default: true },
rideCancelled: { type: Boolean, default: true },
newMessage: { type: Boolean, default: true },
newCommunityRide: { type: Boolean, default: false },
weeklyDigest: { type: Boolean, default: false }
},
telegram: {
enabled: { type: Boolean, default: false },
chatId: { type: Number, default: 0 },
username: { type: String, default: '' },
connectedAt: { type: Date },
newRideRequest: { type: Boolean, default: true },
requestAccepted: { type: Boolean, default: true },
requestRejected: { type: Boolean, default: true },
rideReminder24h: { type: Boolean, default: true },
rideReminder2h: { type: Boolean, default: true },
rideCancelled: { type: Boolean, default: true },
newMessage: { type: Boolean, default: true }
},
push: {
enabled: { type: Boolean, default: false },
subscription: { type: mongoose.Schema.Types.Mixed },
subscribedAt: { type: Date },
newRideRequest: { type: Boolean, default: true },
requestAccepted: { type: Boolean, default: true },
requestRejected: { type: Boolean, default: true },
rideReminder24h: { type: Boolean, default: true },
rideReminder2h: { type: Boolean, default: true },
rideCancelled: { type: Boolean, default: true },
newMessage: { type: Boolean, default: true }
}
}, { _id: false });
// =============================================================================
// STORAGE CODICI TELEGRAM (in-memory, usa Redis in produzione)
// =============================================================================
const telegramConnectCodes = new Map();
// Pulizia codici scaduti ogni 5 minuti
setInterval(() => {
const now = Date.now();
for (const [code, data] of telegramConnectCodes) {
if (now - data.createdAt > 10 * 60 * 1000) { // 10 minuti
telegramConnectCodes.delete(code);
}
}
}, 5 * 60 * 1000);
/**
* Genera codice random 6 caratteri
*/
function generateCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Escludo caratteri ambigui
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
// =============================================================================
// CONTROLLER
// =============================================================================
const trasportiNotificationsController = {
// Esponi schema per User model
notificationPreferencesSchema,
/**
* GET /api/trasporti/notifications/preferences
* Ottiene preferenze notifiche utente
*/
async getNotificationPreferences(req, res) {
try {
const user = req.user;
// Default preferences se non esistono
const defaultPrefs = {
email: {
enabled: true,
newRideRequest: true,
requestAccepted: true,
requestRejected: true,
rideReminder24h: true,
rideReminder2h: true,
rideCancelled: true,
newMessage: true,
newCommunityRide: false,
weeklyDigest: false
},
telegram: {
enabled: false,
chatId: user.profile?.teleg_id || 0,
username: user.profile?.teleg_username || '',
newRideRequest: true,
requestAccepted: true,
requestRejected: true,
rideReminder24h: true,
rideReminder2h: true,
rideCancelled: true,
newMessage: true
},
push: {
enabled: false,
newRideRequest: true,
requestAccepted: true,
requestRejected: true,
rideReminder24h: true,
rideReminder2h: true,
rideCancelled: true,
newMessage: true
}
};
// Merge con preferenze salvate
const prefs = user.notificationPreferences || {};
const mergedPrefs = {
email: { ...defaultPrefs.email, ...prefs.email },
telegram: { ...defaultPrefs.telegram, ...prefs.telegram },
push: { ...defaultPrefs.push, ...prefs.push }
};
// Sync chatId da profile se presente
if (user.profile?.teleg_id && !mergedPrefs.telegram.chatId) {
mergedPrefs.telegram.chatId = user.profile.teleg_id;
mergedPrefs.telegram.enabled = true;
}
res.json({
success: true,
data: {
email: user.email,
preferences: mergedPrefs,
vapidPublicKey: TrasportiNotifications.config.vapidPublicKey,
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME || 'TrasportiSolidaliBot'
}
});
} catch (error) {
console.error('getNotificationPreferences error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* PUT /api/trasporti/notifications/preferences
* Aggiorna preferenze notifiche
*/
async updateNotificationPreferences(req, res) {
try {
const { User } = require('../../models/user');
const { email, telegram, push } = req.body;
const updateData = {};
// Email preferences
if (email) {
Object.keys(email).forEach(key => {
if (key !== 'enabled' || typeof email[key] === 'boolean') {
updateData[`notificationPreferences.email.${key}`] = email[key];
}
});
}
// Telegram preferences (escludi chatId, username - gestiti via connect)
if (telegram) {
const allowedKeys = ['enabled', 'newRideRequest', 'requestAccepted', 'requestRejected',
'rideReminder24h', 'rideReminder2h', 'rideCancelled', 'newMessage'];
Object.keys(telegram).forEach(key => {
if (allowedKeys.includes(key)) {
updateData[`notificationPreferences.telegram.${key}`] = telegram[key];
}
});
}
// Push preferences (escludi subscription - gestito via subscribe)
if (push) {
const allowedKeys = ['enabled', 'newRideRequest', 'requestAccepted', 'requestRejected',
'rideReminder24h', 'rideReminder2h', 'rideCancelled', 'newMessage'];
Object.keys(push).forEach(key => {
if (allowedKeys.includes(key)) {
updateData[`notificationPreferences.push.${key}`] = push[key];
}
});
}
await User.updateOne(
{ _id: req.user._id },
{ $set: updateData }
);
res.json({ success: true, message: 'Preferenze aggiornate' });
} catch (error) {
console.error('updateNotificationPreferences error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* POST /api/trasporti/notifications/telegram/code
* Genera codice per connessione Telegram
*/
async generateTelegramCode(req, res) {
try {
const userId = req.user._id.toString();
// Rimuovi codici esistenti per questo utente
for (const [code, data] of telegramConnectCodes) {
if (data.userId === userId) {
telegramConnectCodes.delete(code);
}
}
// Genera nuovo codice
let code;
do {
code = generateCode();
} while (telegramConnectCodes.has(code));
// Salva
telegramConnectCodes.set(code, {
userId,
createdAt: Date.now(),
chatId: null,
username: null
});
res.json({
success: true,
data: {
code,
expiresIn: 600, // 10 minuti
botUsername: process.env.TELEGRAM_BOT_USERNAME || 'TrasportiSolidaliBot',
instructions: `Invia "${code}" al bot @${process.env.TELEGRAM_BOT_USERNAME || 'TrasportiSolidaliBot'} su Telegram`
}
});
} catch (error) {
console.error('generateTelegramCode error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* POST /api/trasporti/notifications/telegram/connect
* Completa connessione Telegram dopo validazione codice dal bot
*/
async connectTelegram(req, res) {
try {
const { User } = require('../../models/user');
const { code } = req.body;
if (!code) {
return res.status(400).json({ success: false, message: 'Codice richiesto' });
}
const codeData = telegramConnectCodes.get(code.toUpperCase());
if (!codeData) {
return res.status(400).json({ success: false, message: 'Codice non valido o scaduto' });
}
if (codeData.userId !== req.user._id.toString()) {
return res.status(400).json({ success: false, message: 'Codice non valido' });
}
// Verifica che il bot abbia validato il codice (impostando chatId)
if (!codeData.chatId) {
return res.status(400).json({
success: false,
message: 'Invia prima il codice al bot su Telegram',
needsBotInteraction: true
});
}
// Aggiorna utente
await User.updateOne(
{ _id: req.user._id },
{
$set: {
'notificationPreferences.telegram.enabled': true,
'notificationPreferences.telegram.chatId': codeData.chatId,
'notificationPreferences.telegram.username': codeData.username || '',
'notificationPreferences.telegram.connectedAt': new Date(),
// Retrocompatibilità con profile.teleg_id
'profile.teleg_id': codeData.chatId,
'profile.teleg_username': codeData.username || ''
}
}
);
// Rimuovi codice usato
telegramConnectCodes.delete(code.toUpperCase());
// Invia messaggio benvenuto
const idapp = req.user.idapp;
await TrasportiNotifications.sendTelegram(
idapp,
codeData.chatId,
TrasportiNotifications.NotificationType.WELCOME,
{},
req.user.lang || 'it'
);
res.json({
success: true,
message: 'Telegram connesso!',
data: {
chatId: codeData.chatId,
username: codeData.username
}
});
} catch (error) {
console.error('connectTelegram error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* DELETE /api/trasporti/notifications/telegram/disconnect
* Disconnette Telegram
*/
async disconnectTelegram(req, res) {
try {
const { User } = require('../../models/user');
// Ottieni chatId prima di disconnettere per inviare messaggio
const chatId = req.user.notificationPreferences?.telegram?.chatId || req.user.profile?.teleg_id;
const idapp = req.user.idapp;
// Aggiorna utente
await User.updateOne(
{ _id: req.user._id },
{
$set: {
'notificationPreferences.telegram.enabled': false,
'notificationPreferences.telegram.chatId': 0,
'notificationPreferences.telegram.username': '',
'notificationPreferences.telegram.connectedAt': null,
'profile.teleg_id': 0,
'profile.teleg_username': ''
}
}
);
// Invia messaggio di disconnessione
if (chatId && idapp) {
const MyTelegramBot = require('./telegram/telegrambot');
await MyTelegramBot.local_sendMsgTelegramByIdTelegram(
idapp,
chatId,
'👋 Telegram disconnesso da Trasporti Solidali.\n\nPuoi riconnettere in qualsiasi momento dalla pagina impostazioni.',
null, null, false, null, ''
);
}
res.json({ success: true, message: 'Telegram disconnesso' });
} catch (error) {
console.error('disconnectTelegram error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* POST /api/trasporti/notifications/push/subscribe
* Registra subscription push
*/
async subscribePushNotifications(req, res) {
try {
const { User } = require('../../models/user');
const { subscription } = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ success: false, message: 'Subscription non valida' });
}
await User.updateOne(
{ _id: req.user._id },
{
$set: {
'notificationPreferences.push.enabled': true,
'notificationPreferences.push.subscription': subscription,
'notificationPreferences.push.subscribedAt': new Date()
}
}
);
res.json({ success: true, message: 'Push notifications attivate' });
} catch (error) {
console.error('subscribePushNotifications error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* DELETE /api/trasporti/notifications/push/unsubscribe
* Rimuove subscription push
*/
async unsubscribePushNotifications(req, res) {
try {
const { User } = require('../../models/user');
await User.updateOne(
{ _id: req.user._id },
{
$set: {
'notificationPreferences.push.enabled': false,
'notificationPreferences.push.subscription': null
}
}
);
res.json({ success: true, message: 'Push notifications disattivate' });
} catch (error) {
console.error('unsubscribePushNotifications error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* POST /api/trasporti/notifications/test
* Invia notifica di test
*/
async sendTestNotification(req, res) {
try {
const { channel } = req.body; // 'email', 'telegram', 'push', 'all'
const idapp = req.user.idapp;
const result = await TrasportiNotifications.sendTestNotification(req.user, channel, idapp);
if (result.success) {
res.json({ success: true, message: `Notifica di test inviata su ${channel}` });
} else {
res.status(400).json({ success: false, message: result.error });
}
} catch (error) {
console.error('sendTestNotification error:', error);
res.status(500).json({ success: false, message: error.message });
}
},
/**
* Handler per il bot Telegram quando riceve un codice
* Chiamare questa funzione dal tuo telegrambot.js
*/
handleTelegramCodeFromBot(code, chatId, username) {
const codeUpper = code.toUpperCase();
const codeData = telegramConnectCodes.get(codeUpper);
if (!codeData) {
return { success: false, error: 'Codice non valido o scaduto' };
}
// Aggiorna con chatId e username
codeData.chatId = chatId;
codeData.username = username;
telegramConnectCodes.set(codeUpper, codeData);
return { success: true, userId: codeData.userId };
},
/**
* Rimuovi subscription push scadute
* Chiamare quando si riceve errore 410/404
*/
async removePushSubscription(userId) {
try {
const { User } = require('../../models/user');
await User.updateOne(
{ _id: userId },
{
$set: {
'notificationPreferences.push.subscription': null,
'notificationPreferences.push.enabled': false
}
}
);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
};
module.exports = trasportiNotificationsController;

View File

@@ -0,0 +1,219 @@
// ============================================================
// 📊 WIDGET & STATS CONTROLLER - Trasporti Solidali
// ============================================================
// File: server/controllers/viaggi/widgetController.js
const Ride = require('../../models/viaggi/Ride');
const RideRequest = require('../../models/viaggi/RideRequest');
const Feedback = require('../../models/viaggi/Feedback');
const Chat = require('../../models/viaggi/Chat');
const mongoose = require('mongoose');
/**
* 📊 GET /api/viaggi/widget/data
* Ottieni dati per il widget dashboard
*/
exports.getWidgetData = async (req, res) => {
try {
const { idapp } = req.query;
const userId = req.user._id;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
const now = new Date();
// Query parallele per ottimizzare
const [
offersCount,
requestsCount,
recentRides,
myActiveRides,
pendingRequestsCount,
unreadMessagesCount
] = await Promise.all([
// Conta offerte attive
Ride.countDocuments({
idapp,
type: 'offer',
status: 'active',
departureDate: { $gte: now }
}),
// Conta richieste attive
Ride.countDocuments({
idapp,
type: 'request',
status: 'active',
departureDate: { $gte: now }
}),
// Ultimi viaggi pubblicati (non propri)
Ride.find({
idapp,
userId: { $ne: userId },
status: 'active',
departureDate: { $gte: now }
})
.sort({ createdAt: -1 })
.limit(5)
.populate('userId', 'name surname profile')
.lean(),
// I miei viaggi attivi
Ride.find({
idapp,
userId: userId,
status: 'active',
departureDate: { $gte: now }
})
.sort({ departureDate: 1 })
.limit(3)
.lean(),
// Richieste pendenti ricevute (per i miei viaggi)
RideRequest.countDocuments({
idapp,
driverUserId: userId,
status: 'pending'
}),
// Messaggi non letti
Chat.countDocuments({
idapp,
participants: userId,
isDeleted: false,
[`deletedBy.${userId}`]: { $ne: true },
'messages': {
$elemMatch: {
senderId: { $ne: userId },
readBy: { $ne: userId }
}
}
})
]);
// Calcola "matches" - viaggi compatibili con le mie richieste
let matchesCount = 0;
const myRequests = await Ride.find({
idapp,
userId: userId,
type: 'request',
status: 'active',
departureDate: { $gte: now }
}).select('departure destination departureDate').lean();
if (myRequests.length > 0) {
// Per ogni mia richiesta, cerca offerte compatibili
for (const request of myRequests) {
const compatibleOffers = await Ride.countDocuments({
idapp,
userId: { $ne: userId },
type: 'offer',
status: 'active',
departureDate: {
$gte: new Date(request.departureDate.getTime() - 2 * 60 * 60 * 1000), // -2h
$lte: new Date(request.departureDate.getTime() + 2 * 60 * 60 * 1000) // +2h
},
// Potresti aggiungere filtri geografici qui
'departure.city': request.departure?.city,
'destination.city': request.destination?.city
});
matchesCount += compatibleOffers;
}
}
return res.status(200).json({
success: true,
data: {
stats: {
offers: offersCount,
requests: requestsCount,
matches: matchesCount
},
recentRides: recentRides,
myActiveRides: myActiveRides,
pendingRequests: pendingRequestsCount,
unreadMessages: unreadMessagesCount
}
});
} catch (error) {
console.error('❌ Errore getWidgetData:', error);
return res.status(500).json({
success: false,
message: 'Errore nel caricamento dei dati widget',
error: error.message
});
}
};
/**
* 📊 GET /api/viaggi/stats/quick
* Statistiche rapide per badge/notifiche
*/
exports.getQuickStats = async (req, res) => {
try {
const { idapp } = req.query;
const userId = req.user._id;
if (!idapp) {
return res.status(400).json({
success: false,
message: 'idapp è richiesto'
});
}
const [pendingRequests, unreadMessages, activeRides] = await Promise.all([
RideRequest.countDocuments({
idapp,
driverUserId: userId,
status: 'pending'
}),
Chat.countDocuments({
idapp,
participants: userId,
isDeleted: false,
[`deletedBy.${userId}`]: { $ne: true },
'messages': {
$elemMatch: {
senderId: { $ne: userId },
readBy: { $ne: userId }
}
}
}),
Ride.countDocuments({
idapp,
userId: userId,
status: 'active',
departureDate: { $gte: new Date() }
})
]);
return res.status(200).json({
success: true,
data: {
pendingRequests,
unreadMessages,
activeRides,
totalNotifications: pendingRequests + unreadMessages
}
});
} catch (error) {
console.error('❌ Errore getQuickStats:', error);
return res.status(500).json({
success: false,
message: 'Errore nel caricamento delle statistiche rapide',
error: error.message
});
}
};
module.exports = exports;

45
src/data/asset.json Normal file
View File

@@ -0,0 +1,45 @@
{
"_id": "asset_bg_001",
"type": "image",
"category": "background",
"sourceType": "ai",
"file": {
"path": "/upload/assets/backgrounds/forest_autumn_001.jpg",
"url": "/api/assets/asset_bg_001/file",
"thumbnailPath": "/upload/assets/backgrounds/thumbs/forest_autumn_001_thumb.jpg",
"thumbnailUrl": "/api/assets/asset_bg_001/thumbnail",
"originalName": null,
"mimeType": "image/jpeg",
"size": 2458000,
"dimensions": { "width": 2480, "height": 3508 }
},
"aiGeneration": {
"prompt": "Mystical autumn forest at golden hour...",
"negativePrompt": "text, letters, words...",
"provider": "hf",
"model": "FLUX.1-dev",
"seed": 8847291,
"steps": 35,
"cfg": 7.5,
"requestedSize": "1024x1536",
"actualSize": "1024x1536",
"generationTime": 12500,
"cost": 0
},
"usage": {
"usedInPosters": ["poster_sagra_funghi_2025_001"],
"usedInTemplates": [],
"usageCount": 1
},
"metadata": {
"userId": "user_001",
"tags": ["forest", "autumn", "background", "nature"],
"isReusable": true
},
"createdAt": "2025-01-15T10:25:00.000Z"
}

150
src/data/poster.json Normal file
View File

@@ -0,0 +1,150 @@
{
"_id": "poster_sagra_funghi_2025_001",
"templateId": "template_raccolta_funghi_001",
"name": "Sagra del Fungo Porcino 2025",
"status": "completed",
"content": {
"title": "SAGRA DEL FUNGO PORCINO",
"subtitle": "XXV Edizione - Tradizione e Sapori del Bosco",
"eventDate": "15-16-17 Ottobre 2025",
"eventTime": "10:00 - 23:00",
"location": "Parco delle Querce, Borgo Montano (PG)",
"contacts": "Tel: 0742 123456 | info@sagrafungoporcino.it | www.sagrafungoporcino.it",
"extraText": [
"Ingresso Libero",
"Stand Gastronomici • Musica dal Vivo • Mercatino Artigianale"
]
},
"assets": {
"backgroundImage": {
"id": "asset_bg_001",
"sourceType": "ai",
"url": "/upload/posters/poster_sagra_2025_bg.jpg",
"thumbnailUrl": "/upload/posters/thumbs/poster_sagra_2025_bg_thumb.jpg",
"mimeType": "image/jpeg",
"size": 2458000,
"dimensions": { "width": 2480, "height": 3508 },
"aiParams": {
"prompt": "Mystical autumn forest at golden hour, morning mist between ancient oak trees, forest floor covered with porcini mushrooms, warm orange and golden light filtering through leaves, photorealistic, cinematic composition, National Geographic style, 8k quality",
"negativePrompt": "text, letters, words, watermark, signature, blurry, low quality, cartoon, anime",
"provider": "hf",
"model": "FLUX.1-dev",
"seed": 8847291,
"steps": 35,
"cfg": 7.5,
"size": "1024x1536",
"generatedAt": "2025-01-15T10:25:00.000Z"
}
},
"mainImage": {
"id": "asset_main_001",
"sourceType": "upload",
"url": "/upload/assets/porcini_basket_hero.jpg",
"thumbnailUrl": "/upload/assets/thumbs/porcini_basket_hero_thumb.jpg",
"originalName": "IMG_20241015_porcini.jpg",
"mimeType": "image/jpeg",
"size": 1845000,
"dimensions": { "width": 1920, "height": 1280 },
"uploadedAt": "2025-01-15T10:20:00.000Z"
},
"logos": [
{
"id": "asset_logo_001",
"slotId": "logo_slot_1",
"sourceType": "upload",
"url": "/upload/logos/comune_borgomontano.png",
"originalName": "logo_comune.png",
"mimeType": "image/png",
"size": 45000
},
{
"id": "asset_logo_002",
"slotId": "logo_slot_2",
"sourceType": "upload",
"url": "/upload/logos/proloco_borgomontano.png",
"originalName": "logo_proloco.png",
"mimeType": "image/png",
"size": 38000
},
{
"id": "asset_logo_003",
"slotId": "logo_slot_3",
"sourceType": "ai",
"url": "/upload/logos/ai_generated_mushroom_logo.png",
"mimeType": "image/png",
"size": 52000,
"aiParams": {
"prompt": "Minimal vector logo of a porcini mushroom, flat design, golden brown color, white background, simple elegant icon",
"provider": "ideogram",
"model": "ideogram-v2"
}
}
]
},
"layerOverrides": {
"layer_title": {
"style": {
"fontSize": 78,
"color": "#fff8e7"
}
},
"layer_event_date": {
"style": {
"color": "#ffa502"
}
}
},
"renderOutput": {
"png": {
"path": "/upload/posters/final/poster_sagra_2025_final.png",
"size": 8945000,
"url": "/api/posters/poster_sagra_funghi_2025_001/download/png"
},
"jpg": {
"path": "/upload/posters/final/poster_sagra_2025_final.jpg",
"quality": 95,
"size": 2145000,
"url": "/api/posters/poster_sagra_funghi_2025_001/download/jpg"
},
"dimensions": {
"width": 2480,
"height": 3508
},
"renderedAt": "2025-01-15T10:30:00.000Z"
},
"renderEngineVersion": "1.0.0",
"history": [
{
"action": "created",
"timestamp": "2025-01-15T10:15:00.000Z",
"userId": "user_001"
},
{
"action": "ai_background_generated",
"timestamp": "2025-01-15T10:25:00.000Z",
"details": { "provider": "hf", "duration": 12500 }
},
{
"action": "rendered",
"timestamp": "2025-01-15T10:30:00.000Z",
"details": { "duration": 3200 }
}
],
"metadata": {
"userId": "user_001",
"projectId": "project_eventi_2025",
"tags": ["sagra", "fungo", "autunno", "2025"],
"isPublic": false,
"isFavorite": true
},
"createdAt": "2025-01-15T10:15:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}

272
src/data/template.json Normal file
View File

@@ -0,0 +1,272 @@
{
"_id": "template_raccolta_funghi_001",
"name": "Raccolta Funghi Autunnale",
"templateType": "outdoor-event",
"description": "Template per eventi all'aperto legati alla natura",
"format": {
"preset": "A4",
"width": 2480,
"height": 3508,
"unit": "px",
"dpi": 300
},
"safeArea": {
"top": 0.04,
"right": 0.04,
"bottom": 0.04,
"left": 0.04
},
"backgroundColor": "#1a1a2e",
"layers": [
{
"id": "layer_bg",
"type": "backgroundImage",
"zIndex": 0,
"position": { "x": 0, "y": 0, "w": 1, "h": 1 },
"anchor": "top-left",
"required": false,
"fallback": {
"type": "gradient",
"direction": "to-bottom",
"colors": ["#2d3436", "#636e72"]
},
"style": {
"opacity": 1,
"blur": 0,
"objectFit": "cover",
"overlay": {
"enabled": true,
"type": "gradient",
"direction": "to-bottom",
"stops": [
{ "position": 0, "color": "rgba(0,0,0,0)" },
{ "position": 0.5, "color": "rgba(0,0,0,0.3)" },
{ "position": 1, "color": "rgba(0,0,0,0.85)" }
]
}
}
},
{
"id": "layer_main_image",
"type": "mainImage",
"zIndex": 1,
"position": { "x": 0.5, "y": 0.28, "w": 0.85, "h": 0.38 },
"anchor": "center",
"required": false,
"style": {
"borderRadius": 24,
"objectFit": "cover",
"shadow": {
"enabled": true,
"blur": 40,
"spread": 0,
"offsetX": 0,
"offsetY": 20,
"color": "rgba(0,0,0,0.6)"
},
"border": {
"enabled": false,
"width": 4,
"color": "#ffffff"
}
}
},
{
"id": "layer_title",
"type": "title",
"zIndex": 10,
"position": { "x": 0.5, "y": 0.54, "w": 0.92, "h": 0.12 },
"anchor": "center",
"required": true,
"maxLines": 2,
"style": {
"fontFamily": "Montserrat",
"fontWeight": 900,
"fontSize": 82,
"fontSizeMin": 48,
"fontSizeMax": 120,
"autoFit": true,
"color": "#ffffff",
"textAlign": "center",
"textTransform": "uppercase",
"letterSpacing": 6,
"lineHeight": 1.05,
"shadow": {
"enabled": true,
"blur": 15,
"offsetX": 3,
"offsetY": 3,
"color": "rgba(0,0,0,0.9)"
},
"stroke": {
"enabled": true,
"width": 3,
"color": "rgba(0,0,0,0.5)"
}
}
},
{
"id": "layer_subtitle",
"type": "subtitle",
"zIndex": 10,
"position": { "x": 0.5, "y": 0.635, "w": 0.85, "h": 0.05 },
"anchor": "center",
"required": false,
"style": {
"fontFamily": "Open Sans",
"fontWeight": 400,
"fontSize": 32,
"color": "#f0f0f0",
"textAlign": "center",
"letterSpacing": 2,
"lineHeight": 1.3,
"shadow": {
"enabled": true,
"blur": 8,
"offsetX": 1,
"offsetY": 1,
"color": "rgba(0,0,0,0.7)"
}
}
},
{
"id": "layer_event_date",
"type": "eventDate",
"zIndex": 10,
"position": { "x": 0.5, "y": 0.72, "w": 0.9, "h": 0.06 },
"anchor": "center",
"required": true,
"style": {
"fontFamily": "Bebas Neue",
"fontWeight": 400,
"fontSize": 56,
"color": "#ffd700",
"textAlign": "center",
"letterSpacing": 4,
"textTransform": "uppercase",
"shadow": {
"enabled": true,
"blur": 10,
"offsetX": 2,
"offsetY": 2,
"color": "rgba(0,0,0,0.8)"
}
}
},
{
"id": "layer_location",
"type": "location",
"zIndex": 10,
"position": { "x": 0.5, "y": 0.79, "w": 0.85, "h": 0.05 },
"anchor": "center",
"required": true,
"icon": {
"enabled": true,
"name": "location_on",
"size": 28,
"color": "#e74c3c"
},
"style": {
"fontFamily": "Open Sans",
"fontWeight": 600,
"fontSize": 28,
"color": "#ffffff",
"textAlign": "center",
"letterSpacing": 1
}
},
{
"id": "layer_contacts",
"type": "contacts",
"zIndex": 10,
"position": { "x": 0.5, "y": 0.86, "w": 0.9, "h": 0.04 },
"anchor": "center",
"required": false,
"style": {
"fontFamily": "Open Sans",
"fontWeight": 400,
"fontSize": 22,
"color": "#cccccc",
"textAlign": "center",
"letterSpacing": 0.5
}
},
{
"id": "layer_extra_text",
"type": "extraText",
"zIndex": 10,
"position": { "x": 0.5, "y": 0.91, "w": 0.85, "h": 0.03 },
"anchor": "center",
"required": false,
"style": {
"fontFamily": "Open Sans",
"fontWeight": 300,
"fontSize": 18,
"fontStyle": "italic",
"color": "#aaaaaa",
"textAlign": "center"
}
}
],
"logoSlots": {
"enabled": true,
"maxCount": 3,
"collapseIfEmpty": true,
"slots": [
{
"id": "logo_slot_1",
"position": { "x": 0.12, "y": 0.96, "w": 0.12, "h": 0.05 },
"anchor": "bottom-left",
"style": { "objectFit": "contain", "opacity": 0.9 }
},
{
"id": "logo_slot_2",
"position": { "x": 0.5, "y": 0.96, "w": 0.12, "h": 0.05 },
"anchor": "bottom-center",
"style": { "objectFit": "contain", "opacity": 0.9 }
},
{
"id": "logo_slot_3",
"position": { "x": 0.88, "y": 0.96, "w": 0.12, "h": 0.05 },
"anchor": "bottom-right",
"style": { "objectFit": "contain", "opacity": 0.9 }
}
]
},
"palette": {
"primary": "#e94560",
"secondary": "#0f3460",
"accent": "#ffd700",
"background": "#1a1a2e",
"text": "#ffffff",
"textSecondary": "#cccccc",
"textMuted": "#888888"
},
"typography": {
"titleFont": "Montserrat",
"headingFont": "Bebas Neue",
"bodyFont": "Open Sans",
"accentFont": "Playfair Display"
},
"defaultAiPromptHints": {
"backgroundImage": "atmospheric outdoor scene, nature, forest, autumn colors, cinematic lighting, no text, no letters",
"mainImage": "detailed illustration, high quality, vibrant colors, no text"
},
"metadata": {
"author": "System",
"version": "1.0.0",
"tags": ["natura", "outdoor", "autunno", "sagra"]
},
"createdAt": "2025-01-15T10:00:00.000Z",
"updatedAt": "2025-01-15T10:00:00.000Z"
}

View File

@@ -0,0 +1,77 @@
// Helper per calcolare le date dalle ricorrenze
const getRecurrenceDates = (ride, startRange, endRange) => {
const { recurrence, departureDate } = ride;
if (!recurrence || recurrence.type === 'once') {
return [new Date(departureDate)];
}
const dates = [];
const start = new Date(startRange || recurrence.startDate || departureDate);
const end = new Date(endRange || recurrence.endDate || new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000)); // Default 1 anno
const excludedDatesSet = new Set(
(recurrence.excludedDates || []).map(d => new Date(d).toISOString().split('T')[0])
);
switch (recurrence.type) {
case 'weekly':
if (!recurrence.daysOfWeek || recurrence.daysOfWeek.length === 0) break;
let current = new Date(start);
while (current <= end) {
const dayOfWeek = current.getDay();
if (recurrence.daysOfWeek.includes(dayOfWeek)) {
const dateStr = current.toISOString().split('T')[0];
if (!excludedDatesSet.has(dateStr)) {
dates.push(new Date(current));
}
}
current.setDate(current.getDate() + 1);
}
break;
case 'custom_days':
if (!recurrence.daysOfWeek || recurrence.daysOfWeek.length === 0) break;
let curr = new Date(start);
while (curr <= end) {
const dayOfWeek = curr.getDay();
if (recurrence.daysOfWeek.includes(dayOfWeek)) {
const dateStr = curr.toISOString().split('T')[0];
if (!excludedDatesSet.has(dateStr)) {
dates.push(new Date(curr));
}
}
curr.setDate(curr.getDate() + 1);
}
break;
case 'custom_dates':
if (!recurrence.customDates || recurrence.customDates.length === 0) break;
recurrence.customDates.forEach(date => {
const d = new Date(date);
if (d >= start && d <= end) {
const dateStr = d.toISOString().split('T')[0];
if (!excludedDatesSet.has(dateStr)) {
dates.push(d);
}
}
});
break;
}
return dates.length > 0 ? dates : [new Date(departureDate)];
};
const isRideActiveOnDate = (ride, targetDate) => {
const dates = getRecurrenceDates(ride, targetDate, targetDate);
const targetStr = new Date(targetDate).toISOString().split('T')[0];
return dates.some(d => d.toISOString().split('T')[0] === targetStr);
};
module.exports = {
getRecurrenceDates,
isRideActiveOnDate
};

42
src/middleware/upload.js Normal file
View File

@@ -0,0 +1,42 @@
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const UPLOAD_DIR = process.env.UPLOAD_DIR || './upload';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const uniqueId = crypto.randomBytes(8).toString('hex');
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}_${uniqueId}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml'
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Tipo file non supportato'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 20 * 1024 * 1024 // 20MB max
}
});
module.exports = upload;

View File

@@ -0,0 +1,81 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
class UploadMiddleware {
constructor(baseUploadPath = 'uploads/videos') {
this.baseUploadPath = path.resolve(baseUploadPath);
this._ensureDirectory(this.baseUploadPath);
}
_ensureDirectory(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
_createStorage() {
return multer.diskStorage({
destination: (req, file, cb) => {
// ✅ Legge SOLO da req.query (affidabile con multer)
const folder = req.query.folder || 'default';
console.log('📁 Upload folder:', folder); // Debug
const uploadPath = path.join(this.baseUploadPath, folder);
this._ensureDirectory(uploadPath);
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const uniqueName = `${uuidv4()}-${Date.now()}${ext}`;
cb(null, uniqueName);
}
});
}
_fileFilter(req, file, cb) {
const allowedMimes = [
'video/mp4',
'video/webm',
'video/ogg',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Tipo file non supportato: ${file.mimetype}`), false);
}
}
getUploader(options = {}) {
const config = {
storage: this._createStorage(),
fileFilter: this._fileFilter.bind(this),
limits: {
fileSize: options.maxSize || 500 * 1024 * 1024,
files: options.maxFiles || 10
}
};
return multer(config);
}
single(fieldName = 'video') {
return this.getUploader().single(fieldName);
}
multiple(fieldName = 'videos', maxCount = 10) {
return this.getUploader({ maxFiles: maxCount }).array(fieldName, maxCount);
}
getBasePath() {
return this.baseUploadPath;
}
}
module.exports = UploadMiddleware;

137
src/models/Asset.js Normal file
View File

@@ -0,0 +1,137 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// Sub-schema: File Info
const FileInfoSchema = new Schema({
path: { type: String, required: true },
url: { type: String },
thumbnailPath: { type: String },
thumbnailUrl: { type: String },
originalName: { type: String },
mimeType: { type: String, required: true },
size: { type: Number }, // bytes
dimensions: {
width: { type: Number },
height: { type: Number }
}
}, { _id: false });
// Sub-schema: AI Generation Params
const AiGenerationSchema = new Schema({
prompt: { type: String, required: true },
negativePrompt: { type: String },
provider: {
type: String,
required: true,
enum: ['hf', 'fal', 'ideogram', 'openai', 'stability', 'midjourney']
},
model: { type: String },
seed: { type: Number },
steps: { type: Number },
cfg: { type: Number },
requestedSize: { type: String },
actualSize: { type: String },
aspectRatio: { type: String },
styleType: { type: String },
generationTime: { type: Number }, // ms
cost: { type: Number, default: 0 },
rawResponse: { type: Schema.Types.Mixed }
}, { _id: false });
// Sub-schema: Usage Tracking
const UsageTrackingSchema = new Schema({
usedInPosters: [{ type: Schema.Types.ObjectId, ref: 'Poster' }],
usedInTemplates: [{ type: Schema.Types.ObjectId, ref: 'Template' }],
usageCount: { type: Number, default: 0 }
}, { _id: false });
// Sub-schema: Asset Metadata
const AssetMetadataSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project' },
tags: [{ type: String }],
description: { type: String },
isReusable: { type: Boolean, default: true },
isPublic: { type: Boolean, default: false }
}, { _id: false });
// MAIN SCHEMA: Asset
const AssetSchema = new Schema({
type: {
type: String,
required: true,
enum: ['image', 'logo', 'icon', 'font']
},
category: {
type: String,
required: true,
enum: ['background', 'main', 'logo', 'decoration', 'overlay', 'other'],
index: true
},
sourceType: {
type: String,
required: true,
enum: ['upload', 'ai', 'library', 'url'],
index: true
},
file: { type: FileInfoSchema, required: true },
aiGeneration: { type: AiGenerationSchema },
usage: { type: UsageTrackingSchema, default: () => ({}) },
metadata: { type: AssetMetadataSchema, default: () => ({}) },
status: {
type: String,
enum: ['processing', 'ready', 'error', 'deleted'],
default: 'ready'
},
errorMessage: { type: String }
}, {
timestamps: true,
toJSON: { virtuals: true }
});
// Indexes
AssetSchema.index({ 'metadata.userId': 1, category: 1 });
AssetSchema.index({ 'metadata.tags': 1 });
AssetSchema.index({ sourceType: 1, status: 1 });
// Virtual: isAiGenerated
AssetSchema.virtual('isAiGenerated').get(function() {
return this.sourceType === 'ai';
});
// Methods
AssetSchema.methods.addUsage = async function(posterId, type = 'poster') {
if (type === 'poster' && !this.usage.usedInPosters.includes(posterId)) {
this.usage.usedInPosters.push(posterId);
} else if (type === 'template' && !this.usage.usedInTemplates.includes(posterId)) {
this.usage.usedInTemplates.push(posterId);
}
this.usage.usageCount = this.usage.usedInPosters.length + this.usage.usedInTemplates.length;
return this.save();
};
AssetSchema.methods.getPublicUrl = function() {
return this.file.url || `/api/assets/${this._id}/file`;
};
// Statics
AssetSchema.statics.findByUser = function(userId, category = null) {
const query = { 'metadata.userId': userId, status: 'ready' };
if (category) query.category = category;
return this.find(query).sort({ createdAt: -1 });
};
AssetSchema.statics.findReusable = function(userId, category = null) {
const query = {
'metadata.userId': userId,
'metadata.isReusable': true,
status: 'ready'
};
if (category) query.category = category;
return this.find(query).sort({ 'usage.usageCount': -1 });
};
module.exports = mongoose.model('Asset', AssetSchema);

241
src/models/Message.js Normal file
View File

@@ -0,0 +1,241 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const MessageSchema = new Schema({
idapp: {
type: String,
required: true,
index: true
},
chatId: {
type: Schema.Types.ObjectId,
ref: 'Chat',
required: true,
index: true
},
senderId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true
},
text: {
type: String,
trim: true,
maxlength: 2000
},
type: {
type: String,
enum: ['text', 'ride_share', 'location', 'image', 'voice', 'system', 'ride_request', 'ride_accepted', 'ride_rejected'],
default: 'text'
},
metadata: {
// Per messaggi speciali (condivisione viaggio, posizione, ecc.)
rideId: {
type: Schema.Types.ObjectId,
ref: 'Ride'
},
rideRequestId: {
type: Schema.Types.ObjectId,
ref: 'RideRequest'
},
location: {
lat: Number,
lng: Number,
address: String
},
imageUrl: String,
voiceUrl: String,
voiceDuration: Number,
systemAction: String
},
readBy: [{
userId: {
type: Schema.Types.ObjectId,
ref: 'User'
},
readAt: {
type: Date,
default: Date.now
}
}],
replyTo: {
type: Schema.Types.ObjectId,
ref: 'Message'
},
isEdited: {
type: Boolean,
default: false
},
editedAt: {
type: Date
},
isDeleted: {
type: Boolean,
default: false
},
deletedAt: {
type: Date
},
reactions: [{
userId: {
type: Schema.Types.ObjectId,
ref: 'User'
},
emoji: String,
createdAt: {
type: Date,
default: Date.now
}
}]
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Indici per query efficienti
MessageSchema.index({ chatId: 1, createdAt: -1 });
MessageSchema.index({ senderId: 1, createdAt: -1 });
MessageSchema.index({ idapp: 1, chatId: 1 });
// Virtual per verificare se il messaggio è stato letto da tutti
MessageSchema.virtual('isReadByAll').get(function() {
// Logica da implementare confrontando con partecipanti chat
return false;
});
// Metodo per marcare come letto da un utente
MessageSchema.methods.markAsReadBy = function(userId) {
const alreadyRead = this.readBy.some(
r => r.userId.toString() === userId.toString()
);
if (!alreadyRead) {
this.readBy.push({
userId,
readAt: new Date()
});
return this.save();
}
return Promise.resolve(this);
};
// Metodo per verificare se è stato letto da un utente
MessageSchema.methods.isReadBy = function(userId) {
return this.readBy.some(
r => r.userId.toString() === userId.toString()
);
};
// Metodo per aggiungere reazione
MessageSchema.methods.addReaction = function(userId, emoji) {
// Rimuovi eventuale reazione precedente dello stesso utente
this.reactions = this.reactions.filter(
r => r.userId.toString() !== userId.toString()
);
this.reactions.push({
userId,
emoji,
createdAt: new Date()
});
return this.save();
};
// Metodo per rimuovere reazione
MessageSchema.methods.removeReaction = function(userId) {
this.reactions = this.reactions.filter(
r => r.userId.toString() !== userId.toString()
);
return this.save();
};
// Metodo per soft delete
MessageSchema.methods.softDelete = function() {
this.isDeleted = true;
this.deletedAt = new Date();
this.text = '[Messaggio eliminato]';
return this.save();
};
// Metodo per modificare testo
MessageSchema.methods.editText = function(newText) {
this.text = newText;
this.isEdited = true;
this.editedAt = new Date();
return this.save();
};
// Metodo statico per ottenere messaggi di una chat con paginazione
// Message.js (model)
MessageSchema.statics.getByChat = async function(idapp, chatId, options = {}) {
const { limit = 50, before, after } = options;
const query = {
idapp,
chatId,
isDeleted: false
};
// Filtra per timestamp
if (before) {
query.createdAt = { $lt: new Date(before) };
}
if (after) {
query.createdAt = { $gt: new Date(after) };
}
// ✅ Sempre in ordine decrescente (dal più recente al più vecchio)
return this.find(query)
.populate('senderId', 'username name surname profile.img')
.populate('replyTo', 'text senderId')
.sort({ createdAt: -1 }) // -1 = più recente prima
.limit(limit)
};
// Metodo statico per creare messaggio di sistema
MessageSchema.statics.createSystemMessage = async function(idapp, chatId, text, action = null) {
const message = new this({
idapp,
chatId,
senderId: null, // Sistema
text,
type: 'system',
metadata: {
systemAction: action
}
});
return message.save();
};
// Metodo statico per contare messaggi non letti
MessageSchema.statics.countUnreadForUser = async function(idapp, chatId, userId) {
return this.countDocuments({
idapp,
chatId,
isDeleted: false,
senderId: { $ne: userId },
'readBy.userId': { $ne: userId }
});
};
// Hook post-save per aggiornare la chat
MessageSchema.post('save', async function(doc) {
try {
const Chat = mongoose.model('Chat');
const chat = await Chat.findById(doc.chatId);
if (chat) {
await chat.updateLastMessage(doc);
await chat.incrementUnread(doc.senderId);
}
} catch (error) {
console.error('Errore aggiornamento chat dopo messaggio:', error);
}
});
const Message = mongoose.model('Message', MessageSchema);
module.exports = Message;

262
src/models/Poster.js Normal file
View File

@@ -0,0 +1,262 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// Sub-schema: Content
const PosterContentSchema = new Schema({
title: { type: String, maxlength: 500 },
subtitle: { type: String, maxlength: 500 },
eventDate: { type: String, maxlength: 200 },
eventTime: { type: String, maxlength: 100 },
location: { type: String, maxlength: 500 },
contacts: { type: String, maxlength: 1000 },
extraText: [{ type: String }],
customFields: { type: Map, of: String }
}, { _id: false });
// Sub-schema: Asset AI Params (embedded)
const EmbeddedAiParamsSchema = new Schema({
prompt: { type: String },
negativePrompt: { type: String },
provider: { type: String },
model: { type: String },
seed: { type: Number },
steps: { type: Number },
cfg: { type: Number },
size: { type: String },
generatedAt: { type: Date }
}, { _id: false });
// Sub-schema: Poster Asset Reference
const PosterAssetSchema = new Schema({
id: { type: String },
assetId: { type: Schema.Types.ObjectId, ref: 'Asset' },
slotId: { type: String }, // per loghi
sourceType: { type: String, enum: ['upload', 'ai', 'library', 'url'] },
url: { type: String },
thumbnailUrl: { type: String },
originalName: { type: String },
mimeType: { type: String },
size: { type: Number },
dimensions: {
width: { type: Number },
height: { type: Number }
},
aiParams: EmbeddedAiParamsSchema
}, { _id: false });
// Sub-schema: Assets Container
const PosterAssetsSchema = new Schema({
backgroundImage: PosterAssetSchema,
mainImage: PosterAssetSchema,
logos: [PosterAssetSchema]
}, { _id: false });
// Sub-schema: Layer Override Style
const LayerOverrideStyleSchema = new Schema({
fontSize: { type: Number },
color: { type: String },
fontWeight: { type: Number },
opacity: { type: Number },
// altri override possibili
}, { _id: false });
// Sub-schema: Layer Override
const LayerOverrideSchema = new Schema({
position: {
x: { type: Number },
y: { type: Number },
w: { type: Number },
h: { type: Number }
},
visible: { type: Boolean },
style: LayerOverrideStyleSchema
}, { _id: false });
// Sub-schema: Render Output File
const RenderOutputFileSchema = new Schema({
path: { type: String, required: true },
url: { type: String },
size: { type: Number },
quality: { type: Number }
}, { _id: false });
// Sub-schema: Render Output
const RenderOutputSchema = new Schema({
png: RenderOutputFileSchema,
jpg: RenderOutputFileSchema,
webp: RenderOutputFileSchema,
pdf: RenderOutputFileSchema,
dimensions: {
width: { type: Number },
height: { type: Number }
},
renderedAt: { type: Date }
}, { _id: false });
// Sub-schema: History Entry
const HistoryEntrySchema = new Schema({
action: {
type: String,
required: true,
enum: ['created', 'updated', 'ai_background_generated', 'ai_main_generated', 'rendered', 'downloaded', 'shared', 'deleted']
},
timestamp: { type: Date, default: Date.now },
userId: { type: Schema.Types.ObjectId, ref: 'User' },
details: { type: Schema.Types.Mixed }
}, { _id: false });
// Sub-schema: Poster Metadata
const PosterMetadataSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
projectId: { type: Schema.Types.ObjectId, ref: 'Project' },
tags: [{ type: String }],
isPublic: { type: Boolean, default: false },
isFavorite: { type: Boolean, default: false },
viewCount: { type: Number, default: 0 },
downloadCount: { type: Number, default: 0 }
}, { _id: false });
// MAIN SCHEMA: Poster
const PosterSchema = new Schema({
templateId: {
type: Schema.Types.ObjectId,
ref: 'Template',
required: true,
index: true
},
templateSnapshot: { type: Schema.Types.Mixed }, // copia del template al momento della creazione
name: { type: String, required: true, trim: true, maxlength: 300 },
description: { type: String, maxlength: 1000 },
status: {
type: String,
enum: ['draft', 'processing', 'completed', 'error'],
default: 'draft',
index: true
},
content: { type: PosterContentSchema, required: true },
assets: { type: PosterAssetsSchema, default: () => ({}) },
layerOverrides: { type: Map, of: LayerOverrideSchema, default: () => new Map() },
renderOutput: RenderOutputSchema,
renderEngineVersion: { type: String, default: '1.0.0' },
history: [HistoryEntrySchema],
metadata: { type: PosterMetadataSchema, required: true },
errorMessage: { type: String },
// Campi dalla tua bozza originale
originalPrompt: { type: String }, // prompt completo usato
styleUsed: { type: String },
aspectRatio: { type: String },
provider: { type: String }
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Indexes
PosterSchema.index({ 'metadata.userId': 1, status: 1 });
PosterSchema.index({ 'metadata.tags': 1 });
PosterSchema.index({ 'metadata.isFavorite': 1, 'metadata.userId': 1 });
PosterSchema.index({ createdAt: -1 });
PosterSchema.index({ name: 'text', description: 'text' });
// Virtual: isCompleted
PosterSchema.virtual('isCompleted').get(function() {
return this.status === 'completed' && this.renderOutput?.png?.path;
});
// Virtual: downloadUrl
PosterSchema.virtual('downloadUrl').get(function() {
if (this.renderOutput?.png?.path) {
return `/api/posters/${this._id}/download/png`;
}
return null;
});
// Pre-save: aggiorna history
PosterSchema.pre('save', function(next) {
if (this.isNew) {
this.history = this.history || [];
this.history.push({
action: 'created',
timestamp: new Date(),
userId: this.metadata.userId
});
}
next();
});
// Methods
PosterSchema.methods.addHistory = function(action, details = {}) {
this.history.push({
action,
timestamp: new Date(),
userId: this.metadata.userId,
details
});
return this;
};
PosterSchema.methods.setRenderOutput = function(outputData) {
this.renderOutput = {
...outputData,
renderedAt: new Date()
};
this.status = 'completed';
this.addHistory('rendered', { duration: outputData.duration });
return this;
};
PosterSchema.methods.setError = function(errorMessage) {
this.status = 'error';
this.errorMessage = errorMessage;
return this;
};
PosterSchema.methods.incrementDownload = async function() {
this.metadata.downloadCount = (this.metadata.downloadCount || 0) + 1;
this.addHistory('downloaded');
return this.save();
};
PosterSchema.methods.toggleFavorite = async function() {
this.metadata.isFavorite = !this.metadata.isFavorite;
return this.save();
};
// Statics
PosterSchema.statics.findByUser = function(userId, options = {}) {
const query = { 'metadata.userId': userId };
if (options.status) query.status = options.status;
if (options.isFavorite) query['metadata.isFavorite'] = true;
return this.find(query)
.populate('templateId', 'name templateType thumbnailUrl')
.sort({ createdAt: -1 })
.limit(options.limit || 50);
};
PosterSchema.statics.findFavorites = function(userId) {
return this.find({
'metadata.userId': userId,
'metadata.isFavorite': true
}).sort({ updatedAt: -1 });
};
PosterSchema.statics.findRecent = function(userId, limit = 10) {
return this.find({
'metadata.userId': userId,
status: 'completed'
})
.sort({ createdAt: -1 })
.limit(limit)
.select('name renderOutput.png.url thumbnailUrl createdAt');
};
module.exports = mongoose.model('Poster', PosterSchema);

253
src/models/Template.js Normal file
View File

@@ -0,0 +1,253 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// Sub-schema: Posizione layer
const PositionSchema = new Schema({
x: { type: Number, required: true, min: 0, max: 1 },
y: { type: Number, required: true, min: 0, max: 1 },
w: { type: Number, required: true, min: 0, max: 1 },
h: { type: Number, required: true, min: 0, max: 1 }
}, { _id: false });
// Sub-schema: Ombra
const ShadowSchema = new Schema({
enabled: { type: Boolean, default: false },
blur: { type: Number, default: 10 },
spread: { type: Number, default: 0 },
offsetX: { type: Number, default: 0 },
offsetY: { type: Number, default: 4 },
color: { type: String, default: 'rgba(0,0,0,0.5)' }
}, { _id: false });
// Sub-schema: Stroke
const StrokeSchema = new Schema({
enabled: { type: Boolean, default: false },
width: { type: Number, default: 2 },
color: { type: String, default: '#000000' }
}, { _id: false });
// Sub-schema: Border
const BorderSchema = new Schema({
enabled: { type: Boolean, default: false },
width: { type: Number, default: 2 },
color: { type: String, default: '#ffffff' },
style: { type: String, enum: ['solid', 'dashed', 'dotted'], default: 'solid' }
}, { _id: false });
// Sub-schema: Gradient Stop
const GradientStopSchema = new Schema({
position: { type: Number, required: true, min: 0, max: 1 },
color: { type: String, required: true }
}, { _id: false });
// Sub-schema: Overlay
const OverlaySchema = new Schema({
enabled: { type: Boolean, default: false },
type: { type: String, enum: ['solid', 'gradient'], default: 'gradient' },
color: { type: String },
direction: { type: String, default: 'to-bottom' },
stops: [GradientStopSchema]
}, { _id: false });
// Sub-schema: Fallback
const FallbackSchema = new Schema({
type: { type: String, enum: ['solid', 'gradient'], default: 'solid' },
color: { type: String },
direction: { type: String },
colors: [{ type: String }]
}, { _id: false });
// Sub-schema: Icon
const IconSchema = new Schema({
enabled: { type: Boolean, default: false },
name: { type: String },
size: { type: Number, default: 24 },
color: { type: String, default: '#ffffff' }
}, { _id: false });
// Sub-schema: Stile Layer (unificato per immagini e testi)
const LayerStyleSchema = new Schema({
// Comuni
opacity: { type: Number, default: 1, min: 0, max: 1 },
// Per immagini
objectFit: { type: String, enum: ['cover', 'contain', 'fill', 'none'], default: 'cover' },
blur: { type: Number, default: 0 },
borderRadius: { type: Number, default: 0 },
overlay: OverlaySchema,
border: BorderSchema,
// Per testi
fontFamily: { type: String },
fontWeight: { type: Number, default: 400 },
fontSize: { type: Number },
fontSizeMin: { type: Number },
fontSizeMax: { type: Number },
autoFit: { type: Boolean, default: false },
fontStyle: { type: String, enum: ['normal', 'italic'], default: 'normal' },
color: { type: String },
textAlign: { type: String, enum: ['left', 'center', 'right'], default: 'center' },
textTransform: { type: String, enum: ['none', 'uppercase', 'lowercase', 'capitalize'], default: 'none' },
letterSpacing: { type: Number, default: 0 },
lineHeight: { type: Number, default: 1.2 },
// Effetti
shadow: ShadowSchema,
stroke: StrokeSchema
}, { _id: false });
// Sub-schema: Layer
const LayerSchema = new Schema({
id: { type: String, required: true },
type: {
type: String,
required: true,
enum: ['backgroundImage', 'mainImage', 'logo', 'title', 'subtitle', 'eventDate', 'eventTime', 'location', 'contacts', 'extraText', 'customText', 'customImage', 'shape', 'divider']
},
zIndex: { type: Number, default: 0 },
position: { type: PositionSchema, required: true },
anchor: {
type: String,
enum: ['top-left', 'top-center', 'top-right', 'center-left', 'center', 'center-right', 'bottom-left', 'bottom-center', 'bottom-right'],
default: 'center'
},
required: { type: Boolean, default: false },
visible: { type: Boolean, default: true },
locked: { type: Boolean, default: false },
maxLines: { type: Number },
fallback: FallbackSchema,
icon: IconSchema,
style: { type: LayerStyleSchema, default: () => ({}) }
}, { _id: false });
// Sub-schema: Logo Slot
const LogoSlotSchema = new Schema({
id: { type: String, required: true },
position: { type: PositionSchema, required: true },
anchor: { type: String, default: 'center' },
style: { type: LayerStyleSchema, default: () => ({}) }
}, { _id: false });
// Sub-schema: Logo Slots Config
const LogoSlotsConfigSchema = new Schema({
enabled: { type: Boolean, default: true },
maxCount: { type: Number, default: 3, min: 1, max: 10 },
collapseIfEmpty: { type: Boolean, default: true },
slots: [LogoSlotSchema]
}, { _id: false });
// Sub-schema: Format
const FormatSchema = new Schema({
preset: { type: String, default: 'custom' }, // A4, A3, Instagram, Facebook, custom
width: { type: Number, required: true },
height: { type: Number, required: true },
unit: { type: String, enum: ['px', 'mm', 'in'], default: 'px' },
dpi: { type: Number, default: 300 }
}, { _id: false });
// Sub-schema: Safe Area
const SafeAreaSchema = new Schema({
top: { type: Number, default: 0, min: 0, max: 0.5 },
right: { type: Number, default: 0, min: 0, max: 0.5 },
bottom: { type: Number, default: 0, min: 0, max: 0.5 },
left: { type: Number, default: 0, min: 0, max: 0.5 }
}, { _id: false });
// Sub-schema: Palette
const PaletteSchema = new Schema({
primary: { type: String, default: '#e94560' },
secondary: { type: String, default: '#0f3460' },
accent: { type: String, default: '#ffd700' },
background: { type: String, default: '#1a1a2e' },
text: { type: String, default: '#ffffff' },
textSecondary: { type: String, default: '#cccccc' },
textMuted: { type: String, default: '#888888' }
}, { _id: false });
// Sub-schema: Typography
const TypographySchema = new Schema({
titleFont: { type: String, default: 'Montserrat' },
headingFont: { type: String, default: 'Bebas Neue' },
bodyFont: { type: String, default: 'Open Sans' },
accentFont: { type: String, default: 'Playfair Display' }
}, { _id: false });
// Sub-schema: AI Prompt Hints
const AiPromptHintsSchema = new Schema({
backgroundImage: { type: String },
mainImage: { type: String }
}, { _id: false });
// Sub-schema: Metadata
const TemplateMetadataSchema = new Schema({
author: { type: String, default: 'System' },
version: { type: String, default: '1.0.0' },
tags: [{ type: String }],
isPublic: { type: Boolean, default: false },
usageCount: { type: Number, default: 0 }
}, { _id: false });
// MAIN SCHEMA: Template
const TemplateSchema = new Schema({
name: { type: String, required: true, trim: true, maxlength: 200 },
templateType: { type: String, required: true, trim: true, index: true },
description: { type: String, maxlength: 1000 },
format: { type: FormatSchema, required: true },
safeArea: { type: SafeAreaSchema, default: () => ({}) },
backgroundColor: { type: String, default: '#1a1a2e' },
layers: { type: [LayerSchema], required: true, validate: [arr => arr.length > 0, 'Almeno un layer richiesto'] },
logoSlots: { type: LogoSlotsConfigSchema, default: () => ({}) },
palette: { type: PaletteSchema, default: () => ({}) },
typography: { type: TypographySchema, default: () => ({}) },
defaultAiPromptHints: { type: AiPromptHintsSchema, default: () => ({}) },
previewUrl: { type: String },
thumbnailUrl: { type: String },
metadata: { type: TemplateMetadataSchema, default: () => ({}) },
isActive: { type: Boolean, default: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', index: true }
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Indexes
TemplateSchema.index({ templateType: 1, isActive: 1 });
TemplateSchema.index({ 'metadata.tags': 1 });
TemplateSchema.index({ name: 'text', description: 'text', templateType: 'text' });
// Virtual: layer count
TemplateSchema.virtual('layerCount').get(function() {
return this.layers ? this.layers.length : 0;
});
// Methods
TemplateSchema.methods.getLayerById = function(layerId) {
return this.layers.find(l => l.id === layerId);
};
TemplateSchema.methods.getLayersByType = function(type) {
return this.layers.filter(l => l.type === type);
};
TemplateSchema.methods.incrementUsage = async function() {
this.metadata.usageCount = (this.metadata.usageCount || 0) + 1;
return this.save();
};
// Statics
TemplateSchema.statics.findByType = function(templateType) {
return this.find({ templateType, isActive: true }).sort({ 'metadata.usageCount': -1 });
};
TemplateSchema.statics.findPublic = function() {
return this.find({ 'metadata.isPublic': true, isActive: true });
};
module.exports = mongoose.model('Template', TemplateSchema);

View File

@@ -32,6 +32,12 @@ const AccountSchema = new Schema({
numtransactions: {
type: Number,
},
sent: {
type: Number,
},
received: {
type: Number,
},
username: {
type: String,
},
@@ -240,8 +246,22 @@ AccountSchema.statics.addtoSaldo = async function (myaccount, amount, mitt) {
myaccount.date_updated = new Date();
myaccountupdate.saldo = myaccount.saldo;
myaccountupdate.sent = myaccount.sent;
myaccountupdate.received = myaccount.received;
myaccountupdate.totTransato = myaccount.totTransato;
myaccountupdate.numtransactions = myaccount.numtransactions;
if (amount > 0) {
if (myaccountupdate.received === undefined) {
myaccountupdate.received = 0;
}
myaccountupdate.received += 1;
} else {
if (myaccountupdate.sent === undefined) {
myaccountupdate.sent = 0;
}
myaccountupdate.sent += 1;
}
myaccountupdate.date_updated = myaccount.date_updated;
const ris = await Account.updateOne(
@@ -324,6 +344,8 @@ AccountSchema.statics.getAccountByUsernameAndCircuitId = async function (
username_admin_abilitante: '',
qta_maxConcessa: 0,
totTransato: 0,
sent: 0,
received: 0,
numtransactions: 0,
totTransato_pend: 0,
});

View File

@@ -87,6 +87,9 @@ const CircuitSchema = new Schema({
totTransato: {
type: Number,
},
numTransazioni: {
type: Number,
},
nome_valuta: {
type: String,
maxlength: 20,
@@ -327,6 +330,7 @@ CircuitSchema.statics.getWhatToShow = function (idapp, username) {
numMembers: 1,
totCircolante: 1,
totTransato: 1,
numTransazioni: 1,
systemUserId: 1,
createdBy: 1,
date_created: 1,
@@ -412,6 +416,7 @@ CircuitSchema.statics.getWhatToShow_Unknown = function (idapp, username) {
nome_valuta: 1,
totCircolante: 1,
totTransato: 1,
numTransazioni: 1,
fido_scoperto_default: 1,
fido_scoperto_default_grp: 1,
qta_max_default_grp: 1,
@@ -825,6 +830,7 @@ CircuitSchema.statics.sendCoins = async function (onlycheck, idapp, usernameOrig
const circolanteAtt = this.getCircolanteSingolaTransaz(accountorigTable, accountdestTable);
// Somma di tutte le transazioni
circuittable.numTransazioni += 1;
circuittable.totTransato += myqty;
// circuittable.totCircolante = circuittable.totCircolante + (circolanteAtt - circolantePrec);
circuittable.totCircolante = await Account.calcTotCircolante(idapp, circuittable._id);
@@ -832,6 +838,7 @@ CircuitSchema.statics.sendCoins = async function (onlycheck, idapp, usernameOrig
paramstoupdate = {
totTransato: circuittable.totTransato,
totCircolante: circuittable.totCircolante,
numTransazioni: circuittable.numTransazioni,
};
await Circuit.updateOne({ _id: circuittable }, { $set: paramstoupdate });
@@ -901,7 +908,14 @@ CircuitSchema.statics.sendCoins = async function (onlycheck, idapp, usernameOrig
let myuserDest = await User.getUserByUsername(idapp, extrarec.dest);
// Invia una email al destinatario !
await sendemail.sendEmail_RisRicevuti(myuserDest.lang, myuserDest, myuserDest.email, idapp, paramsrec, extrarec);
await sendemail.sendEmail_RisRicevuti(
myuserDest.lang,
myuserDest,
myuserDest.email,
idapp,
paramsrec,
extrarec
);
} else if (extrarec.groupdest || extrarec.contoComDest) {
const groupDestoContoCom = extrarec.groupdest
? extrarec.groupdest
@@ -1047,16 +1061,16 @@ CircuitSchema.statics.getListAdminsByCircuitPath = async function (idapp, circui
let adminObjects = circuit && circuit.admins ? circuit.admins : [];
// Aggiungi USER_ADMIN_CIRCUITS come oggetti
let systemAdmins = shared_consts.USER_ADMIN_CIRCUITS.map(username => ({
let systemAdmins = shared_consts.USER_ADMIN_CIRCUITS.map((username) => ({
username,
date: null,
_id: null
_id: null,
}));
// Unisci e rimuovi duplicati per username
let allAdmins = [...adminObjects, ...systemAdmins];
let uniqueAdmins = allAdmins.filter((admin, index, self) =>
index === self.findIndex(a => a.username === admin.username)
let uniqueAdmins = allAdmins.filter(
(admin, index, self) => index === self.findIndex((a) => a.username === admin.username)
);
return uniqueAdmins;
@@ -1190,6 +1204,7 @@ CircuitSchema.statics.createCircuitIfNotExist = async function (req, idapp, prov
qta_max_default_grp: shared_consts.CIRCUIT_PARAMS.SCOPERTO_MAX_GRP,
valuta_per_euro: 1,
totTransato: 0,
numTransazioni: 0,
totCircolante: 0,
date_created: new Date(),
admins: admins.map((username) => ({ username })),
@@ -1388,7 +1403,12 @@ CircuitSchema.statics.setFido = async function (idapp, username, circuitName, gr
const ris = await Account.updateFido(idapp, username, groupname, circuitId, fido, username_action);
if (ris) {
return { qta_maxConcessa: qtamax, fidoConcesso: fido, username_admin_abilitante: username_action, changed: variato || (ris && ris.modifiedCount > 0) };
return {
qta_maxConcessa: qtamax,
fidoConcesso: fido,
username_admin_abilitante: username_action,
changed: variato || (ris && ris.modifiedCount > 0),
};
}
}
}
@@ -1441,7 +1461,7 @@ CircuitSchema.statics.getFido = async function (idapp, username, circuitName, gr
return null;
};
CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi) {
CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi, options) {
const { User } = require('../models/user');
const { MyGroup } = require('../models/mygroup');
const { SendNotif } = require('../models/sendnotif');
@@ -1540,7 +1560,7 @@ CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi) {
let numtransazionitot = 0;
const arrcircuits = await Circuit.find({ idapp }).lean();
const arrcircuits = await Circuit.find({ idapp });
for (const circuit of arrcircuits) {
let strusersnotinaCircuit = '';
let strusersnotExist = '';
@@ -1620,6 +1640,16 @@ CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi) {
_id: null,
numtransactions: { $sum: 1 },
totTransato: { $sum: { $abs: '$amount' } },
sentCount: {
$sum: {
$cond: [{ $eq: ['$accountFromId', account._id] }, 1, 0],
},
},
receivedCount: {
$sum: {
$cond: [{ $eq: ['$accountToId', account._id] }, 1, 0],
},
},
saldo: {
$sum: {
$cond: [
@@ -1636,6 +1666,8 @@ CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi) {
]);
let numtransactions = result && result.length > 0 ? result[0].numtransactions : 0;
let sentCount = result && result.length > 0 ? result[0].sentCount : 0;
let receivedCount = result && result.length > 0 ? result[0].receivedCount : 0;
let totTransato = result && result.length > 0 ? result[0].totTransato : 0;
let saldo = result && result.length > 0 ? result[0].saldo : 0;
@@ -1679,6 +1711,8 @@ CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi) {
if (correggi) await Account.findOneAndUpdate({ _id: account._id }, { $set: { totTransato } });
}
await Account.findOneAndUpdate({ _id: account._id }, { $set: { sent: sentCount, received: receivedCount } });
saldotot += account.saldo;
// if (account.totTransato === NaN || account.totTransato === undefined)
@@ -1693,6 +1727,11 @@ CircuitSchema.statics.CheckTransazioniCircuiti = async function (correggi) {
// await account.calcPending();
ind++;
} // FINE ACCOUNT
if (options?.setnumtransaction) {
circuit.numTransazioni = numtransazionitot;
await circuit.save(); // salva su db
}
let numaccounts = accounts.length;
@@ -1876,6 +1915,11 @@ CircuitSchema.statics.getCircuitiExtraProvinciali = async function (idapp) {
return circuits;
};
CircuitSchema.statics.ricalcolaNumTransazioni = async function (circuitId) {
const Circuit = this;
// +TODO: Ricalcola il numero delle transazioni avvenute
};
CircuitSchema.statics.getCircuitoItalia = async function (idapp) {
const Circuit = this;
@@ -1884,6 +1928,13 @@ CircuitSchema.statics.getCircuitoItalia = async function (idapp) {
return circuit;
};
CircuitSchema.statics.getSymbolByCircuitId = async function (circuitId) {
const Circuit = this;
const circuit = await Circuit.findOne({ _id: circuitId }, { symbol: 1});
return circuit?.symbol || '';
};
CircuitSchema.statics.isEnableToReceiveEmailByExtraRec = async function (idapp, recnotif) {
let ricevo = true;
if (recnotif.tag === 'setfido') {

View File

@@ -56,11 +56,11 @@ ContribtypeSchema.statics.findAllIdApp = async function (idapp) {
return await Contribtype.find(myfind).lean();
};
const Contribtype = mongoose.model('Contribtype', ContribtypeSchema);
const Contribtype = mongoose.models.Contribtype || mongoose.model('Contribtype', ContribtypeSchema);
Contribtype.createIndexes()
.then(() => { })
.catch((err) => { throw err; });
module.exports = { Contribtype };
module.exports = { Contribtype };

View File

@@ -106,6 +106,8 @@ MovementSchema.statics.addMov = async function (
idOrdersCart
) {
try {
const { Circuit } = require('./circuit');
// Only positive values
amount = Math.abs(amount);
@@ -342,8 +344,12 @@ MovementSchema.statics.getQueryMovsByCircuitId = async function (idapp, username
'circuitfrom.symbol': 1,
'circuitto.symbol': 1,
'userfrom.verified_by_aportador': 1,
'userfrom.name': 1,
'userfrom.surname': 1,
'userfrom.username': 1,
'userfrom.profile.img': 1,
'userto.name': 1,
'userto.surname': 1,
'userto.username': 1,
'userto.profile.img': 1,
'userto.verified_by_aportador': 1,
@@ -579,7 +585,11 @@ MovementSchema.statics.getQueryAllUsersMovsByCircuitId = async function (idapp,
'circuitfrom.symbol': 1,
'circuitto.symbol': 1,
'userfrom.username': 1,
'userfrom.name': 1,
'userfrom.surname': 1,
'userfrom.profile.img': 1,
'userto.name': 1,
'userto.surname': 1,
'userto.username': 1,
'userto.profile.img': 1,
'groupfrom.groupname': 1,
@@ -1013,7 +1023,11 @@ MovementSchema.statics.getLastN_Transactions = async function (
'circuitto.name': 1,
'userfrom.verified_by_aportador': 1,
'userfrom.username': 1,
'userfrom.name': 1,
'userfrom.surname': 1,
'userfrom.profile.img': 1,
'userto.name': 1,
'userto.surname': 1,
'userto.username': 1,
'userto.profile.img': 1,
'userto.verified_by_aportador': 1,

View File

@@ -150,6 +150,9 @@ const MySingleElemSchema = {
parambool4: {
type: Boolean,
},
parambool5: {
type: Boolean,
},
number: {
type: Number,
},
@@ -236,6 +239,12 @@ const MySingleElemSchema = {
class4: {
type: String,
},
stiletit_str: {
type: String,
},
stiletit_icon: {
type: String,
},
styleadd: {
type: String,
},

View File

@@ -174,6 +174,8 @@ const SiteSchema = new Schema({
bookingEvents: { type: Boolean, default: false },
enableEcommerce: { type: Boolean, default: false },
enableAI: { type: Boolean, default: false },
enablePoster: { type: Boolean, default: false },
enableTrasporti: { type: Boolean, default: false },
enableGroups: { type: Boolean, default: false },
enableCircuits: { type: Boolean, default: false },
enableGoods: { type: Boolean, default: false },

View File

@@ -1,5 +1,7 @@
const bcrypt = require('bcryptjs');
const mongoose = require('mongoose').set('debug', false);
const Schema = mongoose.Schema;
const validator = require('validator');
const jwt = require('jsonwebtoken');
const _ = require('lodash');
@@ -30,6 +32,8 @@ const i18n = require('i18n');
const shared_consts = require('../tools/shared_nodejs');
const { notificationPreferencesSchema } = require('../controllers/viaggi/trasportiNotificationsController');
mongoose.Promise = global.Promise;
mongoose.level = 'F';
@@ -56,6 +60,9 @@ const UserSchema = new mongoose.Schema(
message: '{VALUE} is not a valid email'
}*/
},
link_verif_email: {
type: String,
},
hash: {
type: String,
},
@@ -282,6 +289,10 @@ const UserSchema = new mongoose.Schema(
cell: {
type: String,
},
cellVerified: {
type: Boolean,
default: false,
},
country_pay: {
type: String,
},
@@ -581,6 +592,266 @@ const UserSchema = new mongoose.Schema(
],
version: { type: Number },
insert_circuito_ita: { type: Boolean },
// ============ DRIVER PROFILE ============
driverProfile: {
isDriver: {
type: Boolean,
default: false,
},
bio: {
type: String,
trim: true,
maxlength: 500,
},
vehicles: [
{
type: {
type: String,
default: 'auto',
},
brand: {
type: String,
trim: true,
},
model: {
type: String,
trim: true,
},
color: {
type: String,
trim: true,
},
colorHex: {
type: String,
trim: true,
},
year: {
type: Number,
},
seats: {
type: Number,
min: 1,
max: 50,
},
licensePlate: {
type: String,
trim: true,
},
features: [
{
type: String,
},
],
photos: [
{
type: String,
trim: true,
},
],
isDefault: {
type: Boolean,
default: false,
},
isVerified: {
type: Boolean,
default: false,
},
},
],
ridesCompletedAsDriver: {
type: Number,
default: 0,
},
ridesCompletedAsPassenger: {
type: Number,
default: 0,
},
averageRating: {
type: Number,
default: 0,
min: 0,
max: 5,
},
totalRatings: {
type: Number,
default: 0,
},
verifiedDriver: {
type: Boolean,
default: false,
},
licenseVerified: {
type: Boolean,
default: false,
},
licenseNumber: {
type: String,
trim: true,
},
licenseExpiry: {
type: Date,
},
memberSince: {
type: Date,
default: Date.now,
},
responseRate: {
type: Number,
default: 100,
min: 0,
max: 100,
},
responseTime: {
type: String,
default: 'within_day',
},
totalKmShared: {
type: Number,
default: 0,
},
co2Saved: {
type: Number,
default: 0,
// kg di CO2 risparmiati
},
badges: [
{
name: {
type: String,
},
earnedAt: {
type: Date,
default: Date.now,
},
},
],
level: {
type: Number,
default: 1,
min: 1,
},
points: {
type: Number,
default: 0,
},
},
// ============ PREFERENCES ============
preferences: {
// Preferenze di viaggio
smoking: {
type: String,
default: 'no',
},
pets: {
type: String,
default: 'small',
},
music: {
type: String,
default: 'moderate',
},
conversation: {
type: String,
default: 'moderate',
},
// Notifiche
notifications: {
rideRequests: {
type: Boolean,
default: true,
},
rideAccepted: {
type: Boolean,
default: true,
},
rideReminders: {
type: Boolean,
default: true,
},
messages: {
type: Boolean,
default: true,
},
marketing: {
type: Boolean,
default: false,
},
pushEnabled: {
type: Boolean,
default: true,
},
emailEnabled: {
type: Boolean,
default: true,
},
},
// Privacy
privacy: {
showEmail: {
type: Boolean,
default: false,
},
showPhone: {
type: Boolean,
default: false,
},
showLastName: {
type: Boolean,
default: true,
},
showRides: {
type: Boolean,
default: true,
},
},
// Località preferite
favoriteLocations: [
{
name: {
type: String,
trim: true,
},
city: {
type: String,
trim: true,
},
address: {
type: String,
trim: true,
},
coordinates: {
lat: Number,
lng: Number,
},
type: {
type: String,
default: 'other',
},
},
],
// Lingue parlate
languages: [
{
type: String,
},
],
// Metodo di pagamento preferito
preferredContribType: {
type: Schema.Types.ObjectId,
ref: 'Contribtype',
},
},
},
notificationPreferences: {
type: notificationPreferencesSchema,
default: () => ({}),
},
updatedAt: { type: Date, default: Date.now },
},
@@ -2614,6 +2885,12 @@ UserSchema.statics.removeBookmark = async function (idapp, username, id, tab) {
UserSchema.statics.addBookmark = async function (idapp, username, id, tab) {
return await User.updateOne({ idapp, username }, { $push: { 'profile.bookmark': { id, tab } } });
};
UserSchema.statics.setLinkToVerifiedEmail = async function (idapp, username, link_verif_email) {
return await User.updateOne({ idapp, username }, { $set: { link_verif_email } });
};
UserSchema.statics.findByLinkVerifEmail = async function (idapp, link_verif_email) {
return await User.findOne({ idapp, link_verif_email });
};
// Rimuovo il Partecipa
UserSchema.statics.removeAttend = async function (idapp, username, id, tab) {
return await User.updateOne({ idapp, username }, { $pull: { 'profile.attend': { id: { $in: [id] }, tab } } });
@@ -6801,7 +7078,9 @@ UserSchema.statics.addNewSite = async function (idappPass, body) {
}
if (arrSite && arrSite.length === 1 && numutenti < 2) {
const MyTelegramBot = require('../telegram/telegrambot');
//const MyTelegramBot = require('../telegram/telegrambot');
const MyTelegramBot = require('../telegram');
// Nessun Sito Installato e Nessun Utente installato !
let myuser = new User();
@@ -6989,30 +7268,24 @@ UserSchema.statics.getTokenByUsernameAndCircuitName = async function (idapp, use
return user?.profile?.mycircuits?.[0]?.token || null;
};
UserSchema.statics.softDelete = async function(id) {
UserSchema.statics.softDelete = async function (id) {
return this.findByIdAndUpdate(
id,
{
{
deleted: true,
deletedAt: new Date()
deletedAt: new Date(),
},
{ new: true }
);
};
UserSchema.statics.getUsersList = function(idapp) {
UserSchema.statics.getUsersList = function (idapp) {
return this.find({
idapp: idapp,
$or: [
{ deleted: { $exists: false } },
{ deleted: false }
]
$or: [{ deleted: { $exists: false } }, { deleted: false }],
}).lean();
};
const User = mongoose.model('User', UserSchema);
class Hero {
constructor(name, level) {
this.name = name;
@@ -7069,6 +7342,8 @@ const FuncUsers = {
UserSchema.index({ 'tokens.token': 1, 'tokens.access': 1, idapp: 1, deleted: 1, updatedAt: 1 });
const User = mongoose.models.User || mongoose.model('User', UserSchema);
module.exports = {
User,
Hero,

237
src/models/viaggi/Chat.js Normal file
View File

@@ -0,0 +1,237 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const LastMessageSchema = new Schema(
{
text: {
type: String,
trim: true,
},
senderId: {
type: Schema.Types.ObjectId,
ref: 'User',
},
timestamp: {
type: Date,
default: Date.now,
},
type: String,
},
{ _id: false }
);
const ChatSchema = new Schema(
{
idapp: {
type: String,
required: true,
index: true,
},
participants: [
{
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
},
],
rideId: {
type: Schema.Types.ObjectId,
ref: 'Ride',
index: true,
// Opzionale: chat collegata a un viaggio specifico
},
rideRequestId: {
type: Schema.Types.ObjectId,
ref: 'RideRequest',
},
type: {
type: String,
enum: ['direct', 'ride', 'group'],
default: 'direct',
},
title: {
type: String,
trim: true,
// Solo per chat di gruppo
},
lastMessage: {
type: LastMessageSchema,
},
unreadCount: {
type: Map,
of: Number,
default: new Map(),
// { odIdUtente: numeroMessaggiNonLetti }
},
isActive: {
type: Boolean,
default: true,
},
mutedBy: [
{
type: Schema.Types.ObjectId,
ref: 'User',
},
],
blockedBy: [
{
type: Schema.Types.ObjectId,
ref: 'User',
},
],
deletedBy: [
{
type: Schema.Types.ObjectId,
ref: 'User',
},
],
clearedBefore: {
type: Map,
of: Date,
},
metadata: {
type: Schema.Types.Mixed,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Indici
ChatSchema.index({ participants: 1 });
ChatSchema.index({ idapp: 1, participants: 1 });
ChatSchema.index({ idapp: 1, updatedAt: -1 });
// Virtual per contare messaggi non letti totali
ChatSchema.virtual('totalUnread').get(function () {
if (!this.unreadCount) return 0;
let total = 0;
this.unreadCount.forEach((count) => {
total += count;
});
return total;
});
// Metodo per ottenere unread count per un utente specifico
ChatSchema.methods.getUnreadForUser = function (userId) {
if (!this.unreadCount) return 0;
return this.unreadCount.get(userId.toString()) || 0;
};
// ✅ FIX: incrementUnread (assicura conversione corretta)
ChatSchema.methods.incrementUnread = function (excludeUserId) {
const excludeIdStr = excludeUserId.toString();
this.participants.forEach((participantId) => {
// Gestisci sia ObjectId che oggetti popolati
const id = participantId._id ? participantId._id.toString() : participantId.toString();
if (id !== excludeIdStr) {
const current = this.unreadCount.get(id) || 0;
this.unreadCount.set(id, current + 1);
}
});
return this.save();
};
// Metodo per resettare unread count per un utente
ChatSchema.methods.markAsRead = function (userId) {
this.unreadCount.set(userId.toString(), 0);
return this.save();
};
// Metodo per aggiornare ultimo messaggio
ChatSchema.methods.updateLastMessage = function (message) {
this.lastMessage = {
text: message.text,
senderId: message.senderId,
timestamp: message.createdAt || new Date(),
type: message.type || 'text',
};
return this.save();
};
// Metodo per verificare se un utente è partecipante
// ✅ FIX: Gestisce sia ObjectId che oggetti User popolati
ChatSchema.methods.hasParticipant = function (userId) {
const userIdStr = userId.toString();
return this.participants.some((p) => {
// Se p è un oggetto popolato (ha _id), usa p._id
// Altrimenti p è già un ObjectId
const participantId = p._id ? p._id.toString() : p.toString();
return participantId === userIdStr;
});
};
// Metodo per verificare se la chat è bloccata per un utente
// ✅ FIX: Metodo isBlockedFor (stesso problema)
ChatSchema.methods.isBlockedFor = function (userId) {
const userIdStr = userId.toString();
return this.blockedBy.some((id) => {
const blockedId = id._id ? id._id.toString() : id.toString();
return blockedId === userIdStr;
});
};
// Metodo statico per trovare o creare una chat diretta
ChatSchema.statics.findOrCreateDirect = async function (idapp, userId1, userId2, rideId = null) {
// Cerca chat esistente tra i due utenti
let chat = await this.findOne({
idapp,
type: 'direct',
participants: { $all: [userId1, userId2], $size: 2 },
});
if (!chat) {
chat = new this({
idapp,
type: 'direct',
participants: [userId1, userId2],
rideId,
unreadCount: new Map(),
});
await chat.save();
} else if (rideId && !chat.rideId) {
// Aggiorna con rideId se fornito
chat.rideId = rideId;
await chat.save();
}
return chat;
};
// Metodo statico per ottenere tutte le chat di un utente
ChatSchema.statics.getChatsForUser = function (idapp, userId) {
return this.find({
idapp,
participants: userId,
isActive: true,
blockedBy: { $ne: userId },
})
.populate('participants', 'username name surname profile.avatar')
.populate('rideId', 'departure destination dateTime')
.sort({ updatedAt: -1 });
};
// Metodo statico per creare chat di gruppo per un viaggio
ChatSchema.statics.createRideGroupChat = async function (idapp, rideId, title, participantIds) {
const chat = new this({
idapp,
type: 'group',
rideId,
title,
participants: participantIds,
unreadCount: new Map(),
});
return chat.save();
};
const Chat = mongoose.model('Chat', ChatSchema);
module.exports = Chat;

View File

@@ -0,0 +1,357 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const FeedbackCategoriesSchema = new Schema(
{
punctuality: {
type: Number,
min: 1,
max: 5,
},
cleanliness: {
type: Number,
min: 1,
max: 5,
},
communication: {
type: Number,
min: 1,
max: 5,
},
driving: {
type: Number,
min: 1,
max: 5,
// Solo per feedback a conducenti
},
respect: {
type: Number,
min: 1,
max: 5,
},
reliability: {
type: Number,
min: 1,
max: 5,
},
},
{ _id: false }
);
const FeedbackSchema = new Schema(
{
idapp: {
type: String,
required: true,
index: true,
},
rideId: {
type: Schema.Types.ObjectId,
ref: 'Ride',
required: true,
index: true,
},
rideRequestId: {
type: Schema.Types.ObjectId,
ref: 'RideRequest',
},
fromUserId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
toUserId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
role: {
type: String,
enum: ['driver', 'passenger'],
required: true,
// Il ruolo dell'utente che RICEVE il feedback
// 'driver' = sto valutando il conducente
// 'passenger' = sto valutando il passeggero
},
rating: {
type: Number,
required: true,
min: 1,
max: 5,
},
categories: {
type: FeedbackCategoriesSchema,
},
comment: {
type: String,
trim: true,
maxlength: 1000,
},
pros: [
{
type: String,
trim: true,
},
],
cons: [
{
type: String,
trim: true,
},
],
tags: [
{
type: String,
enum: [
'puntuale',
'gentile',
'auto_pulita',
'guida_sicura',
'buona_conversazione',
'silenzioso',
'flessibile',
'rispettoso',
'affidabile',
'consigliato',
// Tag negativi
'in_ritardo',
'scortese',
'guida_pericolosa',
'auto_sporca',
'non_rispettoso',
],
},
],
isPublic: {
type: Boolean,
default: true,
},
isVerified: {
type: Boolean,
default: false,
// Feedback verificato (viaggio effettivamente completato)
},
response: {
text: {
type: String,
trim: true,
maxlength: 500,
},
respondedAt: {
type: Date,
},
},
helpful: {
count: {
type: Number,
default: 0,
},
users: [
{
type: Schema.Types.ObjectId,
ref: 'User',
},
],
},
reported: {
isReported: {
type: Boolean,
default: false,
},
reason: String,
reportedBy: {
type: Schema.Types.ObjectId,
ref: 'User',
},
reportedAt: Date,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Indici composti
FeedbackSchema.index({ toUserId: 1, role: 1 });
FeedbackSchema.index({ rideId: 1, fromUserId: 1 });
FeedbackSchema.index({ idapp: 1, toUserId: 1 });
// Vincolo: un utente può lasciare un solo feedback per viaggio verso un altro utente
FeedbackSchema.index({ rideId: 1, fromUserId: 1, toUserId: 1 }, { unique: true });
// Virtual per calcolare media categorie
FeedbackSchema.virtual('categoryAverage').get(function () {
if (!this.categories) return null;
const cats = this.categories.toObject ? this.categories.toObject() : this.categories;
const values = Object.values(cats).filter((v) => typeof v === 'number');
if (values.length === 0) return null;
return values.reduce((a, b) => a + b, 0) / values.length;
});
// Metodo per aggiungere risposta
FeedbackSchema.methods.addResponse = function (text) {
this.response = {
text,
respondedAt: new Date(),
};
return this.save();
};
// Metodo per segnare come utile
FeedbackSchema.methods.markAsHelpful = function (userId) {
if (!this.helpful.users.includes(userId)) {
this.helpful.users.push(userId);
this.helpful.count = this.helpful.users.length;
return this.save();
}
return Promise.resolve(this);
};
// Metodo per segnalare feedback
FeedbackSchema.methods.report = function (userId, reason) {
this.reported = {
isReported: true,
reason,
reportedBy: userId,
reportedAt: new Date(),
};
return this.save();
};
// Metodo statico per ottenere feedback di un utente
FeedbackSchema.statics.getForUser = function (idapp, userId, options = {}) {
const query = {
idapp,
toUserId: userId,
isPublic: true,
};
if (options.role) {
query.role = options.role;
}
return this.find(query)
.populate('fromUserId', 'username name surname profile.avatar')
.populate('rideId', 'departure destination dateTime')
.sort({ createdAt: -1 })
.limit(options.limit || 20);
};
// Metodo statico per calcolare statistiche
FeedbackSchema.statics.getStatsForUser = async function (idapp, userId) {
const stats = await this.aggregate([
{
$match: {
idapp,
toUserId: new mongoose.Types.ObjectId(userId),
},
},
{
$group: {
_id: '$role',
averageRating: { $avg: '$rating' },
totalFeedbacks: { $sum: 1 },
avgPunctuality: { $avg: '$categories.punctuality' },
avgCleanliness: { $avg: '$categories.cleanliness' },
avgCommunication: { $avg: '$categories.communication' },
avgDriving: { $avg: '$categories.driving' },
avgRespect: { $avg: '$categories.respect' },
avgReliability: { $avg: '$categories.reliability' },
},
},
]);
// Trasforma in oggetto più leggibile
const result = {
asDriver: null,
asPassenger: null,
overall: {
averageRating: 0,
totalFeedbacks: 0,
},
};
stats.forEach((stat) => {
const data = {
averageRating: Math.round(stat.averageRating * 10) / 10,
totalFeedbacks: stat.totalFeedbacks,
categories: {
punctuality: stat.avgPunctuality ? Math.round(stat.avgPunctuality * 10) / 10 : null,
cleanliness: stat.avgCleanliness ? Math.round(stat.avgCleanliness * 10) / 10 : null,
communication: stat.avgCommunication ? Math.round(stat.avgCommunication * 10) / 10 : null,
driving: stat.avgDriving ? Math.round(stat.avgDriving * 10) / 10 : null,
respect: stat.avgRespect ? Math.round(stat.avgRespect * 10) / 10 : null,
reliability: stat.avgReliability ? Math.round(stat.avgReliability * 10) / 10 : null,
},
};
if (stat._id === 'driver') {
result.asDriver = data;
} else if (stat._id === 'passenger') {
result.asPassenger = data;
}
});
// Calcola overall
const allStats = stats.reduce(
(acc, s) => {
acc.total += s.totalFeedbacks;
acc.sum += s.averageRating * s.totalFeedbacks;
return acc;
},
{ total: 0, sum: 0 }
);
if (allStats.total > 0) {
result.overall = {
averageRating: Math.round((allStats.sum / allStats.total) * 10) / 10,
totalFeedbacks: allStats.total,
};
}
return result;
};
// Metodo statico per contare distribuzioni rating
FeedbackSchema.statics.getRatingDistribution = async function (idapp, userId, role = null) {
const match = {
idapp,
toUserId: new mongoose.Types.ObjectId(userId),
};
if (role) match.role = role;
return this.aggregate([
{ $match: match },
{
$group: {
_id: '$rating',
count: { $sum: 1 },
},
},
{ $sort: { _id: -1 } },
]);
};
// Hook post-save per aggiornare rating utente
FeedbackSchema.post('save', async function (doc) {
try {
const { User } = require('../User');
const stats = await mongoose.model('Feedback').getStatsForUser(doc.idapp, doc.toUserId);
await User.findByIdAndUpdate(doc.toUserId, {
'profile.driverProfile.averageRating': stats.overall.averageRating,
'profile.driverProfile.totalRatings': stats.overall.totalFeedbacks,
});
} catch (error) {
console.error('Errore aggiornamento rating utente:', error);
}
});
const Feedback = mongoose.model('Feedback', FeedbackSchema);
module.exports = Feedback;

538
src/models/viaggi/Ride.js Normal file
View File

@@ -0,0 +1,538 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// Schema per le coordinate geografiche
const CoordinatesSchema = new Schema(
{
lat: {
type: Number,
required: true,
},
lng: {
type: Number,
required: true,
},
},
{ _id: false }
);
// Schema per una località (partenza, destinazione, waypoint)
const LocationSchema = new Schema(
{
city: {
type: String,
required: true,
trim: true,
},
address: {
type: String,
trim: true,
},
province: {
type: String,
trim: true,
},
region: {
type: String,
trim: true,
},
country: {
type: String,
default: 'Italia',
trim: true,
},
postalCode: {
type: String,
trim: true,
},
coordinates: {
type: CoordinatesSchema,
required: true,
},
},
{ _id: false }
);
// Schema per i waypoint (tappe intermedie)
const WaypointSchema = new Schema({
/*_id: {
type: String,
required: false
},*/
location: {
type: LocationSchema,
required: true,
},
order: {
type: Number,
required: true,
},
estimatedArrival: {
type: Date,
},
stopDuration: {
type: Number, // minuti di sosta
default: 0,
},
}, { _id: false }); // 👈 AGGIUNGI QUESTO
// Schema per la ricorrenza del viaggio
const RecurrenceSchema = new Schema(
{
type: {
type: String,
enum: ['once', 'weekly', 'custom_days', 'custom_dates'],
default: 'once',
},
daysOfWeek: [
{
type: Number,
min: 0,
max: 6,
// 0 = Domenica, 1 = Lunedì, ..., 6 = Sabato
},
],
customDates: [
{
type: Date,
},
],
startDate: {
type: Date,
},
endDate: {
type: Date,
},
excludedDates: [
{
type: Date,
},
],
},
{ _id: false }
);
// Schema per i passeggeri
const PassengersSchema = new Schema(
{
available: {
type: Number,
required: true,
min: 0,
},
max: {
type: Number,
required: true,
min: 1,
},
},
{ _id: false }
);
// Schema per il veicolo
const VehicleSchema = new Schema(
{
type: {
type: String,
default: 'auto',
},
brand: {
type: String,
trim: true,
},
model: {
type: String,
trim: true,
},
color: {
type: String,
trim: true,
},
colorHex: {
type: String,
trim: true,
},
year: {
type: Number,
},
licensePlate: {
type: String,
trim: true,
},
seats: {
type: Number,
min: 1,
},
photos: [
{
type: String,
trim: true,
},
],
features: [
{
type: String,
enum: ['aria_condizionata', 'wifi', 'presa_usb', 'bluetooth', 'bagagliaio_grande', 'seggiolino_bimbi'],
},
],
},
{ _id: false }
);
// Schema per le preferenze di viaggio
const RidePreferencesSchema = new Schema(
{
smoking: {
type: String,
enum: ['yes', 'no', 'outside_only'],
default: 'no',
},
pets: {
type: String,
enum: ['no', 'small', 'medium', 'large', 'all'],
default: 'no',
},
luggage: {
type: String,
enum: ['none', 'small', 'medium', 'large'],
default: 'medium',
},
packages: {
type: Boolean,
default: false,
},
maxPackageSize: {
type: String,
enum: ['small', 'medium', 'large', 'xlarge'],
default: 'medium',
},
music: {
type: String,
enum: ['no_music', 'quiet', 'moderate', 'loud', 'passenger_choice'],
default: 'moderate',
},
conversation: {
type: String,
enum: ['quiet', 'moderate', 'chatty'],
default: 'moderate',
},
foodAllowed: {
type: Boolean,
default: true,
},
childrenFriendly: {
type: Boolean,
default: true,
},
wheelchairAccessible: {
type: Boolean,
default: false,
},
otherPreferences: {
type: String,
trim: true,
maxlength: 500,
},
},
{ _id: false }
);
// Schema per il contributo/pagamento
const ContributionItemSchema = new Schema({
contribTypeId: {
type: Schema.Types.ObjectId,
ref: 'Contribtype',
required: true,
},
price: {
type: Number,
min: 0,
},
pricePerKm: {
type: Number,
min: 0,
},
notes: {
type: String,
trim: true,
},
});
const ContributionSchema = new Schema(
{
contribTypes: [ContributionItemSchema],
negotiable: {
type: Boolean,
default: true,
},
freeForStudents: {
type: Boolean,
default: false,
},
freeForElders: {
type: Boolean,
default: false,
},
},
{ _id: false }
);
// Schema principale del Ride
const RideSchema = new Schema(
{
idapp: {
type: String,
required: true,
index: true,
},
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
type: {
type: String,
enum: ['offer', 'request'],
required: true,
index: true,
// offer = 🟢 Offerta passaggio (sono conducente)
// request = 🔴 Richiesta passaggio (cerco passaggio)
},
departure: {
type: LocationSchema,
required: true,
},
destination: {
type: LocationSchema,
required: true,
},
waypoints: [WaypointSchema],
departureDate: {
type: Date,
required: true,
index: true,
},
flexibleTime: {
type: Boolean,
default: false,
},
flexibleMinutes: {
type: Number,
default: 30,
min: 0,
max: 180,
},
recurrence: {
type: RecurrenceSchema,
default: () => ({ type: 'once' }),
},
passengers: {
type: PassengersSchema,
required: function () {
return this.type === 'offer';
},
},
seatsNeeded: {
type: Number,
min: 1,
default: 1,
// Solo per type = 'request'
},
vehicle: {
type: VehicleSchema,
required: function () {
return this.type === 'offer';
},
},
preferences: {
type: RidePreferencesSchema,
default: () => ({}),
},
contribution: {
type: ContributionSchema,
default: () => ({ contribTypes: [] }),
},
status: {
type: String,
enum: ['draft', 'active', 'full', 'in_progress', 'completed', 'cancelled', 'expired'],
default: 'active',
index: true,
},
estimatedDistance: {
type: Number, // in km
min: 0,
},
estimatedDuration: {
type: Number, // in minuti
min: 0,
},
routePolyline: {
type: String, // Polyline encoded per visualizzare il percorso
},
confirmedPassengers: [
{
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
},
seats: {
type: Number,
default: 1,
},
pickupPoint: LocationSchema,
dropoffPoint: LocationSchema,
confirmedAt: {
type: Date,
default: Date.now,
},
},
],
views: {
type: Number,
default: 0,
},
isFeatured: {
type: Boolean,
default: false,
},
notes: {
type: String,
trim: true,
maxlength: 1000,
},
cancellationReason: {
type: String,
trim: true,
},
cancelledAt: {
type: Date,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Indici per ricerche ottimizzate
RideSchema.index({ 'departure.city': 1, 'destination.city': 1 });
RideSchema.index({ 'departure.coordinates': '2dsphere' });
RideSchema.index({ 'destination.coordinates': '2dsphere' });
RideSchema.index({ 'waypoints.location.city': 1 });
RideSchema.index({ departureDate: 1, status: 1 });
RideSchema.index({ idapp: 1, status: 1, departureDate: 1 });
// Virtual per verificare se il viaggio è pieno
RideSchema.virtual('isFull').get(function () {
if (this.type === 'request') return false;
// ⚠️ CONTROLLO: verifica che passengers esista
if (!this.passengers || typeof this.passengers.available === 'undefined') return false;
return this.passengers.available <= 0;
});
// Virtual per calcolare posti occupati
RideSchema.virtual('bookedSeats').get(function () {
if (!this.confirmedPassengers) return 0;
return this.confirmedPassengers.reduce((sum, p) => sum + (p.seats || 1), 0);
});
// Virtual per ottenere tutte le città del percorso
RideSchema.virtual('allCities').get(function () {
const cities = [this.departure.city];
if (this.waypoints && this.waypoints.length > 0) {
this.waypoints.sort((a, b) => a.order - b.order).forEach((wp) => cities.push(wp.location.city));
}
cities.push(this.destination.city);
return cities;
});
// Metodo per verificare se passa per una città
RideSchema.methods.passesThrough = function (cityName) {
const normalizedCity = cityName.toLowerCase().trim();
return this.allCities.some(
(city) => city.toLowerCase().trim().includes(normalizedCity) || normalizedCity.includes(city.toLowerCase().trim())
);
};
// Metodo per aggiornare posti disponibili
RideSchema.methods.updateAvailableSeats = function () {
// ⚠️ CONTROLLO: verifica che sia un'offerta e che passengers esista
if (this.type === 'offer' && this.passengers) {
const booked = this.bookedSeats;
this.passengers.available = this.passengers.max - booked;
if (this.passengers.available <= 0) {
this.status = 'full';
} else if (this.status === 'full') {
this.status = 'active';
}
}
return this.save();
};
// Pre-save hook
RideSchema.pre('save', function (next) {
// ⚠️ CONTROLLO: Aggiorna posti disponibili solo se è un'offerta e passengers esiste
if (this.type === 'offer' && this.passengers && this.isModified('confirmedPassengers')) {
const booked = this.confirmedPassengers.reduce((sum, p) => sum + (p.seats || 1), 0);
this.passengers.available = this.passengers.max - booked;
if (this.passengers.available <= 0) {
this.status = 'full';
}
}
next();
});
// Metodi statici per ricerche comuni
RideSchema.statics.findActiveByCity = function (idapp, departureCity, destinationCity, options = {}) {
const query = {
idapp,
status: { $in: ['active', 'full'] },
departureDate: { $gte: new Date() },
};
if (departureCity) {
query['departure.city'] = new RegExp(departureCity, 'i');
}
if (destinationCity) {
query['destination.city'] = new RegExp(destinationCity, 'i');
}
if (options.type) {
query.type = options.type;
}
if (options.date) {
const startOfDay = new Date(options.date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(options.date);
endOfDay.setHours(23, 59, 59, 999);
query.departureDate = { $gte: startOfDay, $lte: endOfDay };
}
return this.find(query)
.populate('userId', 'username name surname profile.driverProfile.averageRating')
.sort({ departureDate: 1 });
};
// Ricerca viaggi che passano per una città intermedia
RideSchema.statics.findPassingThrough = function (idapp, cityName, options = {}) {
const cityRegex = new RegExp(cityName, 'i');
const query = {
idapp,
status: { $in: ['active'] },
departureDate: { $gte: new Date() },
$or: [{ 'departure.city': cityRegex }, { 'destination.city': cityRegex }, { 'waypoints.location.city': cityRegex }],
};
if (options.type) {
query.type = options.type;
}
return this.find(query)
.populate('userId', 'username name surname profile.driverProfile.averageRating')
.sort({ departureDate: 1 });
};
const Ride = mongoose.model('Ride', RideSchema);
module.exports = Ride;

View File

@@ -0,0 +1,296 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// Schema per le coordinate
const CoordinatesSchema = new Schema({
lat: {
type: Number,
required: true
},
lng: {
type: Number,
required: true
}
}, { _id: false });
// Schema per località
const LocationSchema = new Schema({
city: {
type: String,
required: true,
trim: true
},
address: {
type: String,
trim: true
},
province: {
type: String,
trim: true
},
coordinates: {
type: CoordinatesSchema,
required: true
}
}, { _id: false });
const RideRequestSchema = new Schema({
idapp: {
type: String,
required: true,
index: true
},
rideId: {
type: Schema.Types.ObjectId,
ref: 'Ride',
required: true,
index: true
},
passengerId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
driverId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
message: {
type: String,
trim: true,
maxlength: 500
},
pickupPoint: {
type: LocationSchema
},
dropoffPoint: {
type: LocationSchema
},
useOriginalRoute: {
type: Boolean,
default: true
// true = usa partenza/destinazione originali del ride
},
seatsRequested: {
type: Number,
required: true,
min: 1,
default: 1
},
hasLuggage: {
type: Boolean,
default: false
},
luggageSize: {
type: String,
enum: ['small', 'medium', 'large'],
default: 'small'
},
hasPackages: {
type: Boolean,
default: false
},
packageDescription: {
type: String,
trim: true,
maxlength: 200
},
hasPets: {
type: Boolean,
default: false
},
petType: {
type: String,
trim: true
},
petSize: {
type: String,
enum: ['small', 'medium', 'large']
},
specialNeeds: {
type: String,
trim: true,
maxlength: 300
},
status: {
type: String,
enum: ['pending', 'accepted', 'rejected', 'cancelled', 'expired', 'completed'],
default: 'pending',
index: true
},
responseMessage: {
type: String,
trim: true,
maxlength: 500
},
respondedAt: {
type: Date
},
contribution: {
agreed: {
type: Boolean,
default: false
},
contribTypeId: {
type: Schema.Types.ObjectId,
ref: 'Contribtype'
},
amount: {
type: Number,
min: 0
},
notes: {
type: String,
trim: true
}
},
cancelledBy: {
type: String,
enum: ['passenger', 'driver']
},
cancellationReason: {
type: String,
trim: true
},
cancelledAt: {
type: Date
},
completedAt: {
type: Date
},
feedbackGiven: {
type: Boolean,
default: false
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Indici composti per ricerche ottimizzate
RideRequestSchema.index({ rideId: 1, status: 1 });
RideRequestSchema.index({ passengerId: 1, status: 1 });
RideRequestSchema.index({ driverId: 1, status: 1 });
RideRequestSchema.index({ idapp: 1, createdAt: -1 });
// Virtual per verificare se la richiesta può essere cancellata
RideRequestSchema.virtual('canCancel').get(function() {
return ['pending', 'accepted'].includes(this.status);
});
// Virtual per verificare se è in attesa
RideRequestSchema.virtual('isPending').get(function() {
return this.status === 'pending';
});
// Metodo per accettare la richiesta
RideRequestSchema.methods.accept = async function(responseMessage = '') {
this.status = 'accepted';
this.responseMessage = responseMessage;
this.respondedAt = new Date();
// Aggiorna il ride con il passeggero confermato
const Ride = mongoose.model('Ride');
const ride = await Ride.findById(this.rideId);
if (ride) {
ride.confirmedPassengers.push({
userId: this.passengerId,
seats: this.seatsRequested,
pickupPoint: this.pickupPoint || ride.departure,
dropoffPoint: this.dropoffPoint || ride.destination,
confirmedAt: new Date()
});
await ride.updateAvailableSeats();
}
return this.save();
};
// Metodo per rifiutare la richiesta
RideRequestSchema.methods.reject = function(responseMessage = '') {
this.status = 'rejected';
this.responseMessage = responseMessage;
this.respondedAt = new Date();
return this.save();
};
// Metodo per cancellare la richiesta
RideRequestSchema.methods.cancel = async function(cancelledBy, reason = '') {
this.status = 'cancelled';
this.cancelledBy = cancelledBy;
this.cancellationReason = reason;
this.cancelledAt = new Date();
// Se era accettata, rimuovi il passeggero dal ride
if (this.status === 'accepted') {
const Ride = mongoose.model('Ride');
const ride = await Ride.findById(this.rideId);
if (ride) {
ride.confirmedPassengers = ride.confirmedPassengers.filter(
p => p.userId.toString() !== this.passengerId.toString()
);
await ride.updateAvailableSeats();
}
}
return this.save();
};
// Metodo statico per ottenere richieste pendenti di un conducente
RideRequestSchema.statics.getPendingForDriver = function(idapp, driverId) {
return this.find({
idapp,
driverId,
status: 'pending'
})
.populate('passengerId', 'username name surname email')
.populate('rideId', 'departure destination departureDate')
.sort({ createdAt: -1 });
};
// Metodo statico per ottenere richieste di un passeggero
RideRequestSchema.statics.getByPassenger = function(idapp, passengerId, status = null) {
const query = { idapp, passengerId };
if (status) {
query.status = status;
}
return this.find(query)
.populate('rideId')
.populate('driverId', 'username name surname')
.sort({ createdAt: -1 });
};
// Pre-save hook per validazioni
RideRequestSchema.pre('save', async function(next) {
if (this.isNew) {
// Verifica che il ride esista e abbia posti disponibili
const Ride = mongoose.model('Ride');
const ride = await Ride.findById(this.rideId);
if (!ride) {
throw new Error('Viaggio non trovato');
}
if (ride.type === 'offer' && ride.passengers.available < this.seatsRequested) {
throw new Error('Posti non sufficienti per questo viaggio');
}
if (ride.userId.toString() === this.passengerId.toString()) {
throw new Error('Non puoi richiedere un passaggio per il tuo stesso viaggio');
}
// Imposta il driverId dal ride
this.driverId = ride.userId;
}
next();
});
const RideRequest = mongoose.model('RideRequest', RideRequestSchema);
module.exports = RideRequest;

View File

@@ -0,0 +1,310 @@
// ============================================================
// 🔧 USER SETTINGS MODEL - Trasporti Solidali
// ============================================================
// File: server/models/viaggi/UserSettings.js
const mongoose = require('mongoose');
const userSettingsSchema = new mongoose.Schema({
// ID App e Utente
idapp: {
type: String,
required: true,
index: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'User',
index: true
},
// ============================================================
// 🔔 NOTIFICHE
// ============================================================
notifications: {
// Notifiche Email
email: {
newMessage: { type: Boolean, default: true },
rideRequest: { type: Boolean, default: true },
rideConfirmation: { type: Boolean, default: true },
rideCancellation: { type: Boolean, default: true },
rideReminder: { type: Boolean, default: true },
feedbackReceived: { type: Boolean, default: true },
newsletter: { type: Boolean, default: false }
},
// Notifiche Push
push: {
newMessage: { type: Boolean, default: true },
rideRequest: { type: Boolean, default: true },
rideConfirmation: { type: Boolean, default: true },
rideCancellation: { type: Boolean, default: true },
rideReminder: { type: Boolean, default: true },
feedbackReceived: { type: Boolean, default: true }
},
// Notifiche In-App
inApp: {
newMessage: { type: Boolean, default: true },
rideRequest: { type: Boolean, default: true },
rideConfirmation: { type: Boolean, default: true },
rideCancellation: { type: Boolean, default: true }
}
},
// ============================================================
// 🔒 PRIVACY
// ============================================================
privacy: {
// Visibilità profilo
profileVisibility: {
type: String,
enum: ['public', 'members', 'private'],
default: 'members'
},
// Mostra informazioni di contatto
showPhone: { type: Boolean, default: false },
showEmail: { type: Boolean, default: false },
// Mostra statistiche profilo
showStats: { type: Boolean, default: true },
// Mostra feedback ricevuti
showFeedbacks: { type: Boolean, default: true },
// Condividi posizione durante viaggio
shareLocation: { type: Boolean, default: true },
// Chi può contattarmi
whoCanContact: {
type: String,
enum: ['everyone', 'verified', 'afterBooking'],
default: 'verified'
}
},
// ============================================================
// 🚗 PREFERENZE VIAGGI
// ============================================================
ridePreferences: {
// Preferenze come conducente
driver: {
// Accetta prenotazioni istantanee
instantBooking: { type: Boolean, default: false },
// Richiede verifica documento passeggeri
requireVerification: { type: Boolean, default: false },
// Conversazione durante il viaggio
chattiness: {
type: String,
enum: ['silent', 'moderate', 'chatty', 'any'],
default: 'any'
},
// Musica
music: {
type: String,
enum: ['no', 'soft', 'any'],
default: 'any'
},
// Fumatori
smoking: {
type: String,
enum: ['no', 'outside', 'yes'],
default: 'no'
},
// Animali
pets: {
type: String,
enum: ['no', 'small', 'yes'],
default: 'no'
},
// Bagagli extra
luggage: {
type: String,
enum: ['small', 'medium', 'large'],
default: 'medium'
}
},
// Preferenze come passeggero
passenger: {
// Conversazione
chattiness: {
type: String,
enum: ['silent', 'moderate', 'chatty', 'any'],
default: 'any'
},
// Musica
music: {
type: String,
enum: ['no', 'soft', 'any'],
default: 'any'
},
// Fumatori
smokingTolerance: {
type: String,
enum: ['no', 'outside', 'yes'],
default: 'no'
},
// Viaggio con animali
comfortableWithPets: { type: Boolean, default: true }
}
},
// ============================================================
// 🔍 RICERCA & FILTRI PREDEFINITI
// ============================================================
searchPreferences: {
// Raggio di ricerca predefinito (km)
defaultRadius: { type: Number, default: 50 },
// Ordine risultati
defaultSortBy: {
type: String,
enum: ['date', 'price', 'distance', 'rating'],
default: 'date'
},
// Solo viaggi verificati
verifiedOnly: { type: Boolean, default: false },
// Solo con recensioni positive
minRating: { type: Number, min: 0, max: 5, default: 0 }
},
// ============================================================
// 💳 PAGAMENTI & DONAZIONI
// ============================================================
payment: {
// Metodo di pagamento predefinito
defaultMethod: {
type: String,
enum: ['cash', 'card', 'app', 'none'],
default: 'cash'
},
// Contributo suggerito automatico
autoSuggestContribution: { type: Boolean, default: true },
// Accetta pagamenti anticipati
acceptAdvancePayment: { type: Boolean, default: false }
},
// ============================================================
// 📱 INTERFACCIA
// ============================================================
interface: {
// Tema
theme: {
type: String,
enum: ['light', 'dark', 'auto'],
default: 'auto'
},
// Lingua
language: {
type: String,
enum: ['it', 'en', 'de', 'fr', 'es'],
default: 'it'
},
// Mostra tutorial
showTutorials: { type: Boolean, default: true },
// Vista mappa predefinita
defaultMapView: { type: Boolean, default: false }
},
// ============================================================
// 🔐 SICUREZZA
// ============================================================
security: {
// Richiedi verifica telefono per prenotazioni
requirePhoneVerification: { type: Boolean, default: true },
// Autenticazione a due fattori
twoFactorAuth: { type: Boolean, default: false },
// Logout automatico dopo inattività (minuti)
autoLogout: { type: Number, default: 30 },
// Richiedi conferma prima di cancellare viaggio
confirmBeforeCancel: { type: Boolean, default: true }
}
}, {
timestamps: true
});
// ============================================================
// 📊 INDICI
// ============================================================
userSettingsSchema.index({ idapp: 1, userId: 1 }, { unique: true });
// ============================================================
// 🎯 METODI STATICI
// ============================================================
/**
* Ottieni o crea impostazioni utente con valori predefiniti
*/
userSettingsSchema.statics.getOrCreateSettings = async function(idapp, userId) {
let settings = await this.findOne({ idapp, userId });
if (!settings) {
settings = await this.create({
idapp,
userId,
// I valori predefiniti sono già definiti nello schema
});
}
return settings;
};
/**
* Aggiorna impostazioni parziali
*/
userSettingsSchema.statics.updateSettings = async function(idapp, userId, updates) {
const settings = await this.getOrCreateSettings(idapp, userId);
// Merge delle impostazioni
Object.keys(updates).forEach(section => {
if (settings[section] && typeof updates[section] === 'object') {
settings[section] = {
...settings[section],
...updates[section]
};
} else {
settings[section] = updates[section];
}
});
await settings.save();
return settings;
};
// ============================================================
// 🎯 METODI ISTANZA
// ============================================================
/**
* Verifica se una notifica è abilitata
*/
userSettingsSchema.methods.isNotificationEnabled = function(type, channel) {
if (!this.notifications[channel]) return false;
return this.notifications[channel][type] !== false;
};
/**
* Ottieni preferenze compatibilità viaggio
*/
userSettingsSchema.methods.getCompatibilityPreferences = function(asRole = 'passenger') {
if (asRole === 'driver') {
return this.ridePreferences.driver;
}
return this.ridePreferences.passenger;
};
/**
* Esporta impostazioni per frontend
*/
userSettingsSchema.methods.toClientJSON = function() {
return {
notifications: this.notifications,
privacy: this.privacy,
ridePreferences: this.ridePreferences,
searchPreferences: this.searchPreferences,
payment: this.payment,
interface: this.interface,
security: {
requirePhoneVerification: this.security.requirePhoneVerification,
twoFactorAuth: this.security.twoFactorAuth,
confirmBeforeCancel: this.security.confirmBeforeCancel
}
};
};
module.exports = mongoose.model('TrasportiUserSettings', userSettingsSchema);

View File

@@ -202,7 +202,7 @@ class CronMod {
} else if (mydata.dbop === 'RewriteCategESubCateg') {
const migration = require('../populate/migration-categories');
ris = await migration.aggiornaCategorieESottoCategorie()
ris = await migration.aggiornaCategorieESottoCategorie();
} else if (mydata.dbop === 'ReplaceUsername') {
if (User.isAdmin(req.user.perm)) {
ris = globalTables.replaceUsername(req.body.idapp, mydata.search_username, mydata.replace_username);
@@ -270,6 +270,8 @@ class CronMod {
await Order.RemoveDeletedOrdersInOrderscart();
} else if (mydata.dbop === 'CheckTransazioniCircuiti') {
await Circuit.CheckTransazioniCircuiti(false);
} else if (mydata.dbop === 'CalcNumTransCircuiti') {
await Circuit.CheckTransazioniCircuiti(false, { setnumtransaction: true });
} else if (mydata.dbop === 'CorreggiTransazioniCircuiti') {
await Circuit.CheckTransazioniCircuiti(true);
} else if (mydata.dbop === 'RemovePendentTransactions') {

View File

@@ -463,6 +463,8 @@ async function aggiornaCategorieESottoCategorie() {
async function runMigration() {
try {
const { User } = require('../models/user');
const idapp = 0; // TUTTI
console.log('🚀 Controllo Versioni Tabelle (runMigration)');
@@ -471,6 +473,10 @@ async function runMigration() {
idapp,
shared_consts.JOB_TO_EXECUTE.MIGRATION_SECTORS_DIC25
);
const isMigratione30Dic2025Telegram = await Version.isJobExecuted(
idapp,
shared_consts.JOB_TO_EXECUTE.MIGRATION_TELEGRAM_30DIC25
);
const vers_server_str = await tools.getVersServer();
@@ -522,6 +528,18 @@ async function runMigration() {
console.log('\n✅ Migrazione DIC 2025 completata con successo!');
}
if (isMigratione30Dic2025Telegram) {
await User.updateMany({ 'profile.teleg_id': { $exists: true, $ne: 0 } }, [
{
$set: {
'notificationPreferences.telegram.enabled': true,
'notificationPreferences.telegram.chatId': '$profile.teleg_id',
},
},
]);
await Version.setJobExecuted(idapp, shared_consts.JOB_TO_EXECUTE.MIGRATION_TELEGRAM_30DIC25);
}
await Version.setLastVersionRun(idapp, version_server);
} catch (error) {
console.error('❌ Errore durante la migrazione:', error);
@@ -535,5 +553,5 @@ module.exports = {
subSkillMapping,
sectorGoodMapping,
sectorBachecaMapping,
aggiornaCategorieESottoCategorie,
aggiornaCategorieESottoCategorie,
};

View File

@@ -25,6 +25,7 @@ module.exports = {
});
}
}
} catch (e) {
console.log('error insertIntoDb', e);
}

913
src/router/api2_router.js Normal file
View File

@@ -0,0 +1,913 @@
/**
* API2 Router - Ollama Integration
*/
const express = require('express');
const router = express.Router();
// Configurazione Ollama
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const DEFAULT_MODEL = process.env.OLLAMA_DEFAULT_MODEL || 'llama3.2';
// ============================================
// ROUTES PRINCIPALI
// ============================================
/**
* Health check Ollama
* GET /api2/health
*/
router.get('/health', async (req, res) => {
try {
const response = await fetch(`${OLLAMA_URL}/api/tags`);
if (response.ok) {
const data = await response.json();
res.json({
status: 'ok',
ollama: 'connected',
modelsCount: data.models?.length || 0
});
} else {
res.status(503).json({ status: 'error', ollama: 'disconnected' });
}
} catch (error) {
res.status(503).json({
status: 'error',
ollama: 'unreachable',
message: error.message
});
}
});
/**
* Lista modelli disponibili
* GET /api2/models
*/
router.get('/models', async (req, res) => {
try {
const response = await fetch(`${OLLAMA_URL}/api/tags`);
if (!response.ok) {
console.error('[api2/models] Error:', response.status);
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({ models: data.models || [] });
} catch (error) {
console.error('[api2/models] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Info modello specifico
* POST /api2/models/info
*/
router.post('/models/info', async (req, res) => {
try {
const { model } = req.body;
if (!model) {
return res.status(400).json({ error: 'Model name richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/show`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: model }),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================
// GENERAZIONE TESTO
// ============================================
/**
* Generazione testo (non-streaming)
* POST /api2/generate
*/
router.post('/generate', async (req, res) => {
try {
const {
model = DEFAULT_MODEL,
prompt,
temperature = 0.7,
maxTokens,
system,
topP,
topK,
} = req.body;
if (!prompt) {
return res.status(400).json({ error: 'Prompt richiesto' });
}
const payload = {
model,
prompt,
stream: false,
options: {
temperature,
...(maxTokens && { num_predict: maxTokens }),
...(topP && { top_p: topP }),
...(topK && { top_k: topK }),
},
};
if (system) payload.system = system;
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
response: data.response,
model: data.model,
totalDuration: data.total_duration,
loadDuration: data.load_duration,
promptEvalCount: data.prompt_eval_count,
evalCount: data.eval_count,
});
} catch (error) {
console.error('[api2/generate] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Generazione testo con streaming (SSE)
* POST /api2/generate/stream
*/
router.post('/generate/stream', async (req, res) => {
try {
const {
model = DEFAULT_MODEL,
prompt,
temperature = 0.7,
system,
maxTokens,
} = req.body;
if (!prompt) {
return res.status(400).json({ error: 'Prompt richiesto' });
}
const payload = {
model,
prompt,
stream: true,
options: {
temperature,
...(maxTokens && { num_predict: maxTokens }),
},
};
if (system) payload.system = system;
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
// Setup SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const reader = response.body.getReader();
const decoder = new TextDecoder();
const pump = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
res.write('data: [DONE]\n\n');
res.end();
break;
}
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const json = JSON.parse(line);
res.write(`data: ${JSON.stringify(json)}\n\n`);
} catch (e) {
// Ignora linee non JSON
}
}
}
} catch (error) {
console.error('[api2/generate/stream] Stream error:', error);
res.end();
}
};
// Handle client disconnect
req.on('close', () => {
reader.cancel();
});
pump();
} catch (error) {
console.error('[api2/generate/stream] Error:', error);
res.status(500).json({ error: error.message });
}
});
// ============================================
// CHAT
// ============================================
/**
* Chat (non-streaming)
* POST /api2/chat
*/
router.post('/chat', async (req, res) => {
try {
const {
model = DEFAULT_MODEL,
messages,
temperature = 0.7,
system,
maxTokens,
} = req.body;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Messages array richiesto' });
}
const payload = {
model,
messages,
stream: false,
options: {
temperature,
...(maxTokens && { num_predict: maxTokens }),
},
};
if (system) payload.system = system;
// console.log('payload', payload);
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status} - ${response.statusText}`);
}
const data = await response.json();
res.json({
message: data.message,
model: data.model,
totalDuration: data.total_duration,
});
} catch (error) {
console.error('[api2/chat] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Chat con streaming (SSE)
* POST /api2/chat/stream
*/
router.post('/chat/stream', async (req, res) => {
try {
const {
model = DEFAULT_MODEL,
messages,
temperature = 0.7,
system,
maxTokens,
} = req.body;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Messages array richiesto' });
}
const payload = {
model,
messages,
stream: true,
options: {
temperature,
...(maxTokens && { num_predict: maxTokens }),
},
};
if (system) payload.system = system;
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
// Setup SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const reader = response.body.getReader();
const decoder = new TextDecoder();
const pump = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
res.write('data: [DONE]\n\n');
res.end();
break;
}
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const json = JSON.parse(line);
res.write(`data: ${JSON.stringify(json)}\n\n`);
} catch (e) {
// Ignora linee non JSON
}
}
}
} catch (error) {
console.error('[api2/chat/stream] Stream error:', error);
res.end();
}
};
// Handle client disconnect
req.on('close', () => {
reader.cancel();
});
pump();
} catch (error) {
console.error('[api2/chat/stream] Error:', error);
res.status(500).json({ error: error.message });
}
});
// ============================================
// ENDPOINTS HELPER
// ============================================
/**
* Genera codice
* POST /api2/code
*/
router.post('/code', async (req, res) => {
try {
const {
prompt,
language = 'javascript',
model = DEFAULT_MODEL,
temperature = 0.3,
} = req.body;
if (!prompt) {
return res.status(400).json({ error: 'Prompt richiesto' });
}
const systemPrompt = `Sei un esperto programmatore. Rispondi SOLO con codice ${language} valido e funzionante, senza spiegazioni prima o dopo. Usa commenti nel codice se necessario per spiegare.`;
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Scrivi codice ${language} per: ${prompt}`,
system: systemPrompt,
stream: false,
options: { temperature },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
code: data.response,
language,
model: data.model,
});
} catch (error) {
console.error('[api2/code] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Traduci testo
* POST /api/translate
*/
router.post('/translate', async (req, res) => {
try {
const {
text,
targetLang = 'english',
sourceLang,
model = DEFAULT_MODEL,
} = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
const sourceInfo = sourceLang ? ` da ${sourceLang}` : '';
const prompt = `Traduci il seguente testo${sourceInfo} in ${targetLang}. Rispondi SOLO con la traduzione, senza spiegazioni:\n\n${text}`;
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: { temperature: 0.3 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
translation: data.response.trim(),
targetLang,
sourceLang,
});
} catch (error) {
console.error('[api2/translate] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Riassumi testo
* POST /api/summarize
*/
router.post('/summarize', async (req, res) => {
try {
const {
text,
maxLength,
style = 'conciso', // conciso, dettagliato, bullet
model = DEFAULT_MODEL,
} = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
let styleInstruction = '';
switch (style) {
case 'bullet':
styleInstruction = 'Usa un elenco puntato.';
break;
case 'dettagliato':
styleInstruction = 'Fornisci un riassunto dettagliato.';
break;
default:
styleInstruction = 'Sii conciso e chiaro.';
}
const lengthInstruction = maxLength ? ` Massimo ${maxLength} parole.` : '';
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Riassumi il seguente testo. ${styleInstruction}${lengthInstruction}\n\n${text}`,
stream: false,
options: { temperature: 0.5 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
summary: data.response.trim(),
style,
});
} catch (error) {
console.error('[api2/summarize] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Analisi sentiment
* POST /api/sentiment
*/
router.post('/sentiment', async (req, res) => {
try {
const { text, model = DEFAULT_MODEL } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Analizza il sentiment del seguente testo e rispondi SOLO con un JSON valido nel formato:
{"sentiment": "positive" | "negative" | "neutral" | "mixed", "confidence": 0.0-1.0, "emotions": ["emotion1", "emotion2"], "explanation": "breve spiegazione"}
Testo da analizzare:
${text}`,
stream: false,
options: { temperature: 0.1 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
try {
const jsonMatch = data.response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
res.json(parsed);
} else {
res.json({ sentiment: 'unknown', raw: data.response });
}
} catch (e) {
res.json({ sentiment: 'unknown', raw: data.response });
}
} catch (error) {
console.error('[api2/sentiment] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Estrai JSON strutturato da testo
* POST /api/extract
*/
router.post('/extract', async (req, res) => {
try {
const { text, schema, model = DEFAULT_MODEL } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
if (!schema) {
return res.status(400).json({ error: 'Schema richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Estrai le informazioni dal seguente testo e restituisci SOLO un JSON valido con questa struttura:
${JSON.stringify(schema, null, 2)}
Testo da analizzare:
${text}
Rispondi SOLO con il JSON, senza altro testo.`,
stream: false,
options: { temperature: 0.1 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
try {
const jsonMatch = data.response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
res.json(JSON.parse(jsonMatch[0]));
} else {
res.status(422).json({ error: 'Could not parse JSON', raw: data.response });
}
} catch (e) {
res.status(422).json({ error: 'Invalid JSON response', raw: data.response });
}
} catch (error) {
console.error('[api2/extract] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Correggi grammatica
* POST /api/grammar
*/
router.post('/grammar', async (req, res) => {
try {
const {
text,
language = 'italiano',
model = DEFAULT_MODEL,
} = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Correggi gli errori grammaticali e di ortografia nel seguente testo in ${language}. Rispondi SOLO con il testo corretto, senza spiegazioni:\n\n${text}`,
stream: false,
options: { temperature: 0.2 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
corrected: data.response.trim(),
original: text,
language,
});
} catch (error) {
console.error('[api2/grammar] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Rispondi a domanda con contesto
* POST /api/qa
*/
router.post('/qa', async (req, res) => {
try {
const {
question,
context,
model = DEFAULT_MODEL,
} = req.body;
if (!question) {
return res.status(400).json({ error: 'Question richiesta' });
}
if (!context) {
return res.status(400).json({ error: 'Context richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Basandoti SOLO sul seguente contesto, rispondi alla domanda. Se la risposta non è nel contesto, dì che non hai abbastanza informazioni.
Contesto:
${context}
Domanda: ${question}
Risposta:`,
stream: false,
options: { temperature: 0.3 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
answer: data.response.trim(),
question,
});
} catch (error) {
console.error('[api2/qa] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Genera embeddings
* POST /api/embeddings
*/
router.post('/embeddings', async (req, res) => {
try {
const {
text,
model = 'nomic-embed-text',
} = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: text,
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
res.json({
embedding: data.embedding,
dimensions: data.embedding?.length,
model,
});
} catch (error) {
console.error('[api2/embeddings] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Classifica testo in categorie
* POST /api/classify
*/
router.post('/classify', async (req, res) => {
try {
const {
text,
categories,
model = DEFAULT_MODEL,
} = req.body;
if (!text) {
return res.status(400).json({ error: 'Text richiesto' });
}
if (!categories || !Array.isArray(categories)) {
return res.status(400).json({ error: 'Categories array richiesto' });
}
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt: `Classifica il seguente testo in UNA delle seguenti categorie: ${categories.join(', ')}
Rispondi SOLO con un JSON nel formato:
{"category": "categoria_scelta", "confidence": 0.0-1.0, "reason": "breve motivazione"}
Testo: ${text}`,
stream: false,
options: { temperature: 0.1 },
}),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
try {
const jsonMatch = data.response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
res.json(JSON.parse(jsonMatch[0]));
} else {
res.json({ category: 'unknown', raw: data.response });
}
} catch (e) {
res.json({ category: 'unknown', raw: data.response });
}
} catch (error) {
console.error('[api2/classify] Error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Pull/scarica un nuovo modello
* POST /api2/models/pull
*/
router.post('/models/pull', async (req, res) => {
try {
const { model } = req.body;
if (!model) {
return res.status(400).json({ error: 'Model name richiesto' });
}
// Streaming del progresso
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const response = await fetch(`${OLLAMA_URL}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: model, stream: true }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) {
res.write('data: [DONE]\n\n');
res.end();
break;
}
const chunk = decoder.decode(value);
res.write(`data: ${chunk}\n\n`);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* Elimina un modello
* DELETE /api2/models/:name
*/
router.delete('/models/:name', async (req, res) => {
try {
const { name } = req.params;
const response = await fetch(`${OLLAMA_URL}/api/delete`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (response.ok) {
res.json({ success: true, message: `Modello ${name} eliminato` });
} else {
throw new Error(`Errore eliminazione: ${response.status}`);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================
// EXPORT
// ============================================
module.exports = router;

View File

@@ -2,8 +2,21 @@ const express = require('express');
const { authenticate, authenticate_noerror } = require('../middleware/authenticate');
const router = express.Router();
const templatesRouter = require('../routes/templates');
const postersRouter = require('../routes/posters');
const assetsRouter = require('../routes/assets');
const PageView = require('../models/PageView');
// const { Groq } = require('groq-sdk');
const fal = require('@fal-ai/client');
const imageGenerator = require('../services/imageGenerator'); // Assicurati che il percorso sia corretto
const posterEditor = require('../services/PosterEditor'); // <--- Importa la nuova classe
const multer = require('multer');
const XLSX = require('xlsx');
@@ -19,6 +32,26 @@ const { MyElem } = require('../models/myelem');
const axios = require('axios');
const settingsRoutes = require('../routes/viaggi/settingsRoutes');
router.use('/viaggi/settings', settingsRoutes);
const widgetRoutes = require('../routes/viaggi/widgetRoutes');
router.use('/viaggi/widget', widgetRoutes);
const viaggiRoutes = require('../routes/viaggiRoutes');
router.use('/viaggi', viaggiRoutes);
// Importa le routes video
const videoRoutes = require('../routes/videoRoutes');
// Monta le routes video
router.use('/video', videoRoutes);
router.use('/templates', authenticate, templatesRouter);
router.use('/posters', authenticate, postersRouter);
router.use('/assets', authenticate, assetsRouter);
router.post('/test-lungo', authenticate, (req, res) => {
const timeout = req.body.timeout;
@@ -389,7 +422,6 @@ router.post('/search-books', authenticate, async (req, res) => {
let productfind = null;
for (let field of book) {
field = field.trim();
let valido = typeof field === 'string' && field.length > 4 && field.length < 50;
if (valido) {
@@ -494,4 +526,82 @@ router.post('/chatbot', authenticate, async (req, res) => {
}
});
router.post('/generateposter', async (req, res) => {
const {
titolo,
data,
ora,
luogo,
descrizione,
contatti,
fotoDescrizione,
stile,
provider = 'hf', // Default a HF (Gratis)
} = req.body;
// 1. Prompt per l'AI: Chiediamo SOLO lo sfondo, VIETIAMO il testo.
// Questo garantisce che Flux si concentri sulla bellezza dell'immagine.
const promptAI = `Vertical event poster background, ${stile || 'modern style, vivid colors'}.
Subject: ${fotoDescrizione || 'abstract artistic shapes'}.
Composition: Central empty space or clean layout suitable for overlaying text later.
NO TEXT, NO LETTERS, clean illustration, high quality, 4k.`;
try {
console.log('1. Generazione Sfondo AI...');
// Genera solo l'immagine base
const rawImageUrl = await imageGenerator.generate(provider, promptAI);
console.log('2. Composizione Grafica Testi...');
// Sovrapponi i testi con Canvas
const finalPosterBase64 = await posterEditor.createPoster(rawImageUrl, {
titolo,
data,
ora,
luogo,
contatti,
});
res.json({
success: true,
imageUrl: finalPosterBase64, // Restituisce l'immagine completa in base64
step: 'AI + Canvas Composition',
});
} catch (err) {
console.error('Errore:', err.message);
res.status(500).json({ error: err.message });
}
});
router.get('/users/search', authenticate, async (req, res) => {
try {
const { User } = require('../models/user');
const { q, idapp } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({ success: false, message: 'Query too short' });
}
const query = q.trim();
const users = await User.find({
idapp,
$or: [
{ name: { $regex: query, $options: 'i' } },
{ surname: { $regex: query, $options: 'i' } },
{ username: { $regex: query, $options: 'i' } },
],
_id: { $ne: req.user?._id }, // escludi l'utente corrente se autenticato
})
.select('_id name surname username profile') // solo campi necessari
.limit(10); // evita overload
res.json({ success: true, data: users });
} catch (error) {
console.error('User search error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
});
module.exports = router;

View File

@@ -300,6 +300,44 @@ router.post(process.env.LINKVERIF_REG, (req, res) => {
});
});
router.post(process.env.CHECKREVERIF_EMAIL, (req, res) => {
const body = _.pick(req.body, ['idapp', 'idlink']);
const idapp = body.idapp;
const idlink = body.idlink;
// Cerco l'idlink se è ancora da Verificare
User.findByLinkVerifEmail(idapp, idlink)
.then((user) => {
if (!user) {
//console.log("NON TROVATO!");
return res.status(404).send({code: RIS_CODE_ERRORE, msg: 'Verifica email non andata a buon fine. Ripetere.'});
} else {
console.log('user', user);
if (user.verified_email) {
res.send({
code: server_constants.RIS_CODE_EMAIL_ALREADY_VERIFIED,
msg: tools.getres__("L'Email è già stata Verificata", res),
});
} else {
user.verified_email = true;
user.lasttimeonline = new Date();
user.save().then(() => {
//console.log("TROVATOOOOOO!");
res.send({
code: server_constants.RIS_CODE_EMAIL_VERIFIED,
msg: tools.getres__('EMAIL', res) + ' ' + tools.getres__('VERIF', res),
});
});
}
}
})
.catch((e) => {
console.log(process.env.LINKVERIF_REG, e.message);
res.status(400).send();
});
});
router.post(process.env.ADD_NEW_SITE, async (req, res) => {
try {
const body = req.body;

View File

@@ -317,7 +317,11 @@ router.post('/', async (req, res) => {
await telegrambot.askConfirmationUser(myuser.idapp, shared_consts.CallFunz.REGISTRATION, myuser);
const { token, refreshToken, browser_random } = await myuser.generateAuthToken(req, browser_random);
res.header('x-auth', token).header('x-refrtok', refreshToken).header('x-browser-random', browser_random).send(myuser);
res
.header('x-auth', token)
.header('x-refrtok', refreshToken)
.header('x-browser-random', browser_random)
.send(myuser);
return true;
}
}
@@ -368,7 +372,11 @@ router.post('/', async (req, res) => {
// if (!tools.testing()) {
await sendemail.sendEmail_Registration(user.lang, user.email, user, user.idapp, user.linkreg);
// }
res.header('x-auth', ris.token).header('x-refrtok', ris.refreshToken).header('x-browser-random', ris.browser_random).send(user);
res
.header('x-auth', ris.token)
.header('x-refrtok', ris.refreshToken)
.header('x-browser-random', ris.browser_random)
.send(user);
return true;
});
})
@@ -411,7 +419,9 @@ router.patch('/:id', authenticate, (req, res) => {
if (!User.isAdmin(req.user.perm)) {
// If without permissions, exit
return res.status(server_constants.RIS_CODE_ERR_UNAUTHORIZED).send({ code: server_constants.RIS_CODE_ERR_UNAUTHORIZED, msg: '' });
return res
.status(server_constants.RIS_CODE_ERR_UNAUTHORIZED)
.send({ code: server_constants.RIS_CODE_ERR_UNAUTHORIZED, msg: '' });
}
User.findByIdAndUpdate(id, { $set: body })
@@ -506,13 +516,15 @@ router.post('/profile', authenticate, (req, res) => {
const perm = req.user ? req.user.perm : tools.Perm.PERM_NONE;
const username = req.body['username'];
const idapp = req.body.idapp;
const idnotif = req.body['idnotif'] || '';
//++Todo: controlla che tipo di dati ha il permesso di leggere
try {
// Check if ìs a Notif to read
const idnotif = req.body['idnotif'] ? req.body['idnotif'] : '';
SendNotif.setNotifAsRead(idapp, usernameOrig, idnotif);
if (idnotif) {
SendNotif.setNotifAsRead(idapp, usernameOrig, idnotif);
}
return User.getUserProfileByUsername(idapp, username, usernameOrig, false, perm)
.then((ris) => {
@@ -589,9 +601,14 @@ router.post('/panel', authenticate, async (req, res) => {
idapp = req.body.idapp;
locale = req.body.locale;
if (!req.user || !User.isAdmin(req.user.perm) && !User.isManager(req.user.perm) && !User.isFacilitatore(req.user.perm)) {
if (
!req.user ||
(!User.isAdmin(req.user.perm) && !User.isManager(req.user.perm) && !User.isFacilitatore(req.user.perm))
) {
// If without permissions, exit
return res.status(server_constants.RIS_CODE_ERR_UNAUTHORIZED).send({ code: server_constants.RIS_CODE_ERR_UNAUTHORIZED, msg: '' });
return res
.status(server_constants.RIS_CODE_ERR_UNAUTHORIZED)
.send({ code: server_constants.RIS_CODE_ERR_UNAUTHORIZED, msg: '' });
}
try {
@@ -601,6 +618,7 @@ router.post('/panel', authenticate, async (req, res) => {
username: 1,
name: 1,
surname: 1,
verified_email: 1,
email: 1,
verified_by_aportador: 1,
aportador_solidario: 1,
@@ -657,6 +675,7 @@ router.post('/notifs', authenticate, async (req, res) => {
router.post('/newtok', async (req, res) => {
try {
const refreshToken = req.body.refreshToken;
const browser_random = req.body.br;
// return res.status(403).send({ error: 'Refresh token non valido' });
@@ -665,7 +684,7 @@ router.post('/newtok', async (req, res) => {
}
const recFound = await User.findByRefreshTokenAnyAccess(refreshToken);
if (!recFound) {
return res.status(403).send({ error: 'Refresh token non valido' });
}
@@ -949,7 +968,9 @@ router.post('/friends/cmd', authenticate, async (req, res) => {
usernameDest !== usernameLogged &&
(cmd === shared_consts.FRIENDSCMD.SETFRIEND || cmd === shared_consts.FRIENDSCMD.SETHANDSHAKE)
) {
return res.status(server_constants.RIS_CODE_ERR_UNAUTHORIZED).send({ code: server_constants.RIS_CODE_ERR_UNAUTHORIZED, msg: '' });
return res
.status(server_constants.RIS_CODE_ERR_UNAUTHORIZED)
.send({ code: server_constants.RIS_CODE_ERR_UNAUTHORIZED, msg: '' });
}
}
@@ -1115,7 +1136,10 @@ async function eseguiDbOpUser(idapp, mydata, locale, req, res) {
} else if (mydata.dbop === 'noNameSurname') {
await User.findOneAndUpdate({ _id: mydata._id }, { $set: { 'profile.noNameSurname': mydata.value } });
} else if (mydata.dbop === 'telegram_verification_skipped') {
await User.findOneAndUpdate({ _id: mydata._id }, { $set: { 'profile.telegram_verification_skipped': mydata.value } });
await User.findOneAndUpdate(
{ _id: mydata._id },
{ $set: { 'profile.telegram_verification_skipped': mydata.value } }
);
} else if (mydata.dbop === 'pwdLikeAdmin') {
await User.setPwdComeQuellaDellAdmin(mydata);
} else if (mydata.dbop === 'ripristinaPwdPrec') {
@@ -1124,6 +1148,11 @@ async function eseguiDbOpUser(idapp, mydata, locale, req, res) {
await User.findOneAndUpdate({ _id: mydata._id }, { $set: { 'profile.noCircuit': mydata.value } });
} else if (mydata.dbop === 'noComune') {
await User.findOneAndUpdate({ _id: mydata._id }, { $set: { 'profile.noComune': mydata.value } });
} else if (mydata.dbop === 'verifiedemail') {
await User.findOneAndUpdate({ _id: mydata._id }, { $set: { verified_email: mydata.value } });
} else if (mydata.dbop === 'resendVerificationEmail') {
// Invia la email di Verifica email
const ris = await sendemail.sendEmail_ReVerifyingEmail(mydata, idapp);
} else if (mydata.dbop === 'noCircIta') {
await User.findOneAndUpdate({ _id: mydata._id }, { $set: { 'profile.noCircIta': mydata.value } });
} else if (mydata.dbop === 'insert_circuito_ita') {

21
src/routes/assets.js Normal file
View File

@@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const assetController = require('../controllers/assetController');
const { authenticate } = require('../middleware/authenticate');
const upload = require('../middleware/upload');
// Upload
router.post('/upload', authenticate, upload.single('file'), assetController.upload);
router.post('/upload-multiple', authenticate, upload.array('files', 10), assetController.uploadMultiple);
// AI Generation
router.post('/generate-ai', authenticate, assetController.generateAi);
// CRUD
router.get('/', authenticate, assetController.list);
router.get('/:id', assetController.getById);
router.get('/:id/file', assetController.getFile);
router.get('/:id/thumbnail', assetController.getThumbnail);
router.delete('/:id', authenticate, assetController.delete);
module.exports = router;

37
src/routes/geoRoutes.js Normal file
View File

@@ -0,0 +1,37 @@
const express = require('express');
const router = express.Router();
const {
autocomplete,
geocode,
reverseGeocode,
getRoute,
getMatrix,
suggestWaypoints,
searchItalianCities,
getDistance,
getIsochrone
} = require('../controllers/geocodingController');
// Rate limiting opzionale
const rateLimit = require('express-rate-limit');
const geoLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 60, // 60 richieste per minuto
message: { success: false, message: 'Troppe richieste, riprova tra poco' }
});
router.use(geoLimiter);
// Routes
router.get('/autocomplete', autocomplete);
router.get('/geocode', geocode);
router.get('/reverse', reverseGeocode);
router.get('/route', getRoute);
router.post('/matrix', getMatrix);
router.get('/suggest-waypoints', suggestWaypoints);
router.get('/cities/it', searchItalianCities);
router.get('/distance', getDistance);
router.get('/isochrone', getIsochrone);
module.exports = router;

25
src/routes/posters.js Normal file
View File

@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const posterController = require('../controllers/posterController');
const { authenticate } = require('../middleware/authenticate');
const upload = require('../middleware/upload');
// CRUD Posters
router.post('/', authenticate, posterController.create);
router.get('/', authenticate, posterController.list);
router.get('/favorites', authenticate, posterController.listFavorites);
router.get('/recent', authenticate, posterController.listRecent);
router.get('/:id', authenticate, posterController.getById);
router.put('/:id', authenticate, posterController.update);
router.delete('/:id', authenticate, posterController.delete);
// Azioni speciali
router.post('/:id/render', authenticate, posterController.render);
router.post('/:id/regenerate-ai', authenticate, posterController.regenerateAi);
router.get('/:id/download/:format', posterController.download);
router.post('/:id/favorite', authenticate, posterController.toggleFavorite);
// Quick generate (come nella tua bozza)
router.post('/quick-generate', authenticate, posterController.quickGenerate);
module.exports = router;

17
src/routes/templates.js Normal file
View File

@@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const templateController = require('../controllers/templateController');
const { authenticate } = require('../middleware/authenticate');
// CRUD Templates
router.post('/', authenticate, templateController.create);
router.get('/', templateController.list);
router.get('/types', templateController.getTypes);
router.get('/presets', templateController.getFormatPresets);
router.get('/:id', templateController.getById);
router.put('/:id', authenticate, templateController.update);
router.delete('/:id', authenticate, templateController.delete);
router.post('/:id/duplicate', authenticate, templateController.duplicate);
router.get('/:id/preview', templateController.getPreview);
module.exports = router;

View File

@@ -0,0 +1,100 @@
// ============================================================
// 🔧 SETTINGS ROUTES - Trasporti Solidali
// ============================================================
// File: server/routes/viaggi/settingsRoutes.js
const express = require('express');
const router = express.Router();
const settingsController = require('../../controllers/viaggi/settingsController');
const { authenticate } = require('../../middleware/authenticate');
// ============================================================
// 🔐 TUTTE LE ROUTES RICHIEDONO AUTENTICAZIONE
// ============================================================
router.use(authenticate);
// ============================================================
// 📄 IMPOSTAZIONI GENERALI
// ============================================================
/**
* GET /api/viaggi/settings
* Ottieni tutte le impostazioni dell'utente
*/
router.get('/', settingsController.getSettings);
/**
* PUT /api/viaggi/settings
* Aggiorna le impostazioni (completo)
*/
router.put('/', settingsController.updateSettings);
/**
* POST /api/viaggi/settings/reset
* Reset impostazioni ai valori predefiniti
*/
router.post('/reset', settingsController.resetSettings);
// ============================================================
// 📝 AGGIORNAMENTI PARZIALI (per sezione)
// ============================================================
const notifController = require('../../controllers/viaggi/trasportiNotificationsController');
// Preferenze
router.get('/notifications/preferences', authenticate, notifController.getNotificationPreferences);
router.put('/notifications/preferences', authenticate, notifController.updateNotificationPreferences);
// Telegram
router.post('/notifications/telegram/code', authenticate, notifController.generateTelegramCode);
router.post('/notifications/telegram/connect', authenticate, notifController.connectTelegram);
router.delete('/notifications/telegram/disconnect', authenticate, notifController.disconnectTelegram);
// Push
router.post('/notifications/push/subscribe', authenticate, notifController.subscribePushNotifications);
router.delete('/notifications/push/unsubscribe', authenticate, notifController.unsubscribePushNotifications);
// Test
router.post('/notifications/test', authenticate, notifController.sendTestNotification);
/**
* PATCH /api/viaggi/settings/notifications
* Aggiorna solo le notifiche
*/
router.patch('/notifications', settingsController.updateNotifications);
/**
* PATCH /api/viaggi/settings/privacy
* Aggiorna solo la privacy
*/
router.patch('/privacy', settingsController.updatePrivacy);
/**
* PATCH /api/viaggi/settings/ride-preferences
* Aggiorna solo le preferenze viaggi
*/
router.patch('/ride-preferences', settingsController.updateRidePreferences);
/**
* PATCH /api/viaggi/settings/interface
* Aggiorna solo l'interfaccia
*/
router.patch('/interface', settingsController.updateInterface);
// ============================================================
// 📊 EXPORT / IMPORT
// ============================================================
/**
* GET /api/viaggi/settings/export
* Esporta tutte le impostazioni
*/
router.get('/export', settingsController.exportSettings);
/**
* POST /api/viaggi/settings/import
* Importa impostazioni da backup
*/
router.post('/import', settingsController.importSettings);
module.exports = router;

View File

@@ -0,0 +1,32 @@
// ============================================================
// 📊 WIDGET & STATS ROUTES - Trasporti Solidali
// ============================================================
// File: server/routes/viaggi/widgetRoutes.js
const express = require('express');
const router = express.Router();
const widgetController = require('../../controllers/viaggi/widgetController');
const { authenticate } = require('../../middleware/authenticate');
// ============================================================
// 🔐 TUTTE LE ROUTES RICHIEDONO AUTENTICAZIONE
// ============================================================
router.use(authenticate);
// ============================================================
// 📊 WIDGET DATA
// ============================================================
/**
* GET /api/viaggi/widget/data
* Ottieni tutti i dati per il widget dashboard
*/
router.get('/data', widgetController.getWidgetData);
/**
* GET /api/viaggi/widget/stats
* Statistiche rapide per badge/notifiche
*/
router.get('/stats', widgetController.getQuickStats);
module.exports = router;

1052
src/routes/viaggiRoutes.js Normal file

File diff suppressed because it is too large Load Diff

58
src/routes/videoRoutes.js Normal file
View File

@@ -0,0 +1,58 @@
const express = require('express');
const VideoController = require('../controllers/VideoController');
const UploadMiddleware = require('../middleware/uploadMiddleware');
const {
authenticate,
authenticate_noerror,
authenticate_noerror_WithUser,
authenticate_noerror_WithUserLean,
} = require('../middleware/authenticate');
const router = express.Router();
// Configurazione
const UPLOAD_PATH = process.env.VIDEO_UPLOAD_PATH || 'uploads/videos';
// Istanze
const videoController = new VideoController(UPLOAD_PATH);
const uploadMiddleware = new UploadMiddleware(UPLOAD_PATH);
// ============ FOLDER ROUTES ============
router.get('/folders', authenticate, videoController.getFolders);
router.post('/folders', authenticate, videoController.createFolder);
router.put('/folders/:folderPath(*)', authenticate, videoController.renameFolder);
router.delete('/folders/:folderPath(*)', authenticate, videoController.deleteFolder);
// ============ VIDEO ROUTES ============
router.get('/videos', authenticate, videoController.getVideos);
router.get('/videos/:folder/:filename', authenticate, videoController.getVideo);
// Upload
router.post(
'/videos/upload',
uploadMiddleware.single('video'),
videoController.uploadVideo
);
router.post(
'/videos/upload-multiple',
uploadMiddleware.multiple('videos', 10),
videoController.uploadVideos
);
// Modifica
router.put('/videos/:folder/:filename/rename', authenticate, videoController.renameVideo);
router.put('/videos/:folder/:filename/move', authenticate, videoController.moveVideo);
// Elimina
router.delete('/videos/:folder/:filename', authenticate, videoController.deleteVideo);
// Stream
router.get('/stream/:folder/:filename', authenticate, videoController.streamVideo);
// Error Handler
router.use(VideoController.errorHandler);
module.exports = router;

View File

@@ -0,0 +1,33 @@
const Template = require('../models/Template');
const templateSeeds = require('../templates/template-seeds');
const MONGODB_URI = process.env.MONGODB_URI || '';
async function seedTemplates() {
try {
// await mongoose.connect(MONGODB_URI);
// Opzionale: rimuovi template esistenti con stessi templateType
const existingTypes = templateSeeds.map(t => t.templateType);
await Template.deleteMany({ templateType: { $in: existingTypes } });
console.log('✓ Template esistenti rimossi');
// Inserisci nuovi template
const result = await Template.insertMany(templateSeeds);
console.log(`${result.length} template inseriti con successo`);
// Log dei template creati
result.forEach(t => {
console.log(` - ${t.name} (${t.templateType})`);
});
// await mongoose.disconnect();
console.log('✓ Disconnesso da MongoDB');
process.exit(0);
} catch (error) {
console.error('✗ Errore seeding:', error);
process.exit(1);
}
}
seedTemplates();

View File

@@ -14,4 +14,3 @@ const seedTemplates = async () => {
};
seedTemplates();
s

View File

@@ -368,17 +368,17 @@ function checkifSendEmail() {
module.exports = {
sendEmail_base_e_manager: async function (idapp, template, to, mylocalsconf, replyTo, transport, previewonly) {
await this.sendEmail_base(template, to, mylocalsconf, replyTo, transport, previewonly);
await this.sendEmail_base(idapp, template, to, mylocalsconf, replyTo, transport, previewonly);
await this.sendEmail_base(template, tools.getAdminEmailByIdApp(idapp), mylocalsconf, '', transport, previewonly);
await this.sendEmail_base(idapp, template, tools.getAdminEmailByIdApp(idapp), mylocalsconf, '', transport, previewonly);
if (tools.isManagAndAdminDifferent(idapp)) {
const email = tools.getManagerEmailByIdApp(idapp);
await this.sendEmail_base(template, email, mylocalsconf, '', transport, previewonly);
await this.sendEmail_base(idapp, template, email, mylocalsconf, '', transport, previewonly);
}
},
sendEmail_base: async function (template, to, mylocalsconf, replyTo, transport, previewonly) {
sendEmail_base: async function (idapp, template, to, mylocalsconf, replyTo, transport, previewonly) {
if (to === '') return false;
// console.log('mylocalsconf', mylocalsconf);
@@ -389,9 +389,17 @@ module.exports = {
if (!replyTo) replyTo = '';
const emailSender = mylocalsconf.dataemail.from;
let senderName = '';
if (idapp) {
senderName = tools.getNomeAppByIdApp(mylocalsconf.idapp);
}
const emailcompleta = senderName ? `"${senderName}" <${emailSender}>` : emailSender;
const paramemail = {
message: {
from: mylocalsconf.dataemail.from, // sender address
from: emailcompleta,
headers: {
'Reply-To': replyTo,
},
@@ -457,9 +465,12 @@ module.exports = {
sendEmail_Normale: async function (mylocalsconf, to, subject, html, replyTo) {
try {
const emailSender = tools.getEmailByIdApp(mylocalsconf.idapp);
const senderName = tools.getNomeAppByIdApp(mylocalsconf.idapp);
// setup e-mail data with unicode symbols
var mailOptions = {
from: tools.getEmailByIdApp(mylocalsconf.idapp), // sender address
from: `"${senderName}" <${emailSender}>`,
dataemail: await this.getdataemail(mylocalsconf.idapp),
to: to,
generateTextFromHTML: true,
@@ -494,6 +505,21 @@ module.exports = {
const strlinkreg = tools.getHostByIdApp(idapp) + process.env.LINKVERIF_REG + `/?idapp=${idapp}&idlink=${idreg}`;
return strlinkreg;
},
getlinkVerifyEmail: async function (idapp, email, username) {
try {
const reg = require('./reg/registration');
const idverif = reg.getlinkregByEmail(idapp, email, username);
await User.setLinkToVerifiedEmail(idapp, username, idverif);
const strlinkreg =
tools.getHostByIdApp(idapp) + process.env.CHECKREVERIF_EMAIL + `/?idapp=${idapp}&idlink=${idverif}`;
return strlinkreg;
} catch (e) {
console.error('ERROR getlinkVerifyEmail');
}
},
getlinkInvitoReg: function (idapp, dati) {
const strlinkreg = tools.getHostByIdApp(idapp) + `/invitetoreg/${dati.token}`;
return strlinkreg;
@@ -507,15 +533,22 @@ module.exports = {
},
getLinkAbilitaCircuito: function (idapp, user, data) {
if (data.token_circuito_da_ammettere) {
const strlink = tools.getHostByIdApp(idapp) + `/abcirc/${data.cmd}/${data.token_circuito_da_ammettere}/${user.username}/${data.myusername}`;
const strlink =
tools.getHostByIdApp(idapp) +
`/abcirc/${data.cmd}/${data.token_circuito_da_ammettere}/${user.username}/${data.myusername}`;
return strlink;
}
return '';
},
getPathEmail(idapp, email_template) {
const RISO_TEMPLATES = ['reg_notifica_all_invitante', 'reg_email_benvenuto_ammesso', 'reg_chiedi_ammettere_all_invitante',
'circuit_chiedi_facilitatori_di_entrare', 'circuit_abilitato_al_fido_membro'];
const RISO_TEMPLATES = [
'reg_notifica_all_invitante',
'reg_email_benvenuto_ammesso',
'reg_chiedi_ammettere_all_invitante',
'circuit_chiedi_facilitatori_di_entrare',
'circuit_abilitato_al_fido_membro',
];
if (idapp === '13') {
if (RISO_TEMPLATES.includes(email_template)) {
@@ -570,34 +603,24 @@ module.exports = {
}
//Invia una email al nuovo utente
await this.sendEmail_base(quale_email_inviare, emailto, mylocalsconf, tools.getreplyToEmailByIdApp(idapp));
await this.sendEmail_base(idapp, quale_email_inviare, emailto, mylocalsconf, tools.getreplyToEmailByIdApp(idapp));
if (user.verified_email && user.aportador_solidario && user.verified_by_aportador) {
const pathemail = this.getPathEmail(idapp, 'reg_notifica_all_invitante');
// Manda anche una email al suo Invitante
const recaportador = await User.getUserShortDataByUsername(idapp, user.aportador_solidario);
const ris = await this.sendEmail_base(
pathemail + '/' + tools.LANGADMIN,
recaportador.email,
mylocalsconf,
''
);
const ris = await this.sendEmail_base(idapp, pathemail + '/' + tools.LANGADMIN, recaportador.email, mylocalsconf, '');
} else if (user.aportador_solidario && !user.verified_by_aportador) {
const pathemail = this.getPathEmail(idapp, 'reg_chiedi_ammettere_all_invitante');
// Manda una email al suo Invitante per chiedere di essere ammesso
const recaportador = await User.getUserShortDataByUsername(idapp, user.aportador_solidario);
const ris = await this.sendEmail_base(
pathemail + '/' + tools.LANGADMIN,
recaportador.email,
mylocalsconf,
''
);
const ris = await this.sendEmail_base(idapp, pathemail + '/' + tools.LANGADMIN, recaportador.email, mylocalsconf, '');
}
// Send to the Admin an Email
const ris = await this.sendEmail_base(
const ris = await this.sendEmail_base(idapp,
'admin/registration/' + tools.LANGADMIN,
tools.getAdminEmailByIdApp(idapp),
mylocalsconf,
@@ -639,7 +662,7 @@ module.exports = {
messaggioPersonalizzato: dati.messaggioPersonalizzato,
};
const ris = await this.sendEmail_base('invitaamico/' + lang, emailto, mylocalsconf, '');
const ris = await this.sendEmail_base(idapp, 'invitaamico/' + lang, emailto, mylocalsconf, '');
await telegrambot.notifyToTelegram(telegrambot.phase.INVITA_AMICO, mylocalsconf);
@@ -666,7 +689,7 @@ module.exports = {
const quale_email_inviare = this.getPathEmail(idapp, 'reg_email_benvenuto_ammesso') + '/' + lang;
const ris = await this.sendEmail_base(quale_email_inviare, emailto, mylocalsconf, '');
const ris = await this.sendEmail_base(idapp, quale_email_inviare, emailto, mylocalsconf, '');
await telegrambot.notifyToTelegram(telegrambot.phase.AMMETTI_UTENTE, mylocalsconf);
@@ -675,8 +698,42 @@ module.exports = {
console.error('Err sendEmail_Utente_Ammesso', e);
}
},
sendEmail_ReVerifyingEmail: async function (dati, idapp) {
try {
const user = await User.getUserById(idapp, dati._id);
const lang = user.lang;
const username = user.username;
const email = user.email;
let mylocalsconf = {
idapp,
dataemail: await this.getdataemail(idapp),
baseurl: tools.getHostByIdApp(idapp),
locale: lang,
nomeapp: tools.getNomeAppByIdApp(idapp),
strlinksito: tools.getHostByIdApp(idapp),
//strlinkreg: this.getlinkReg(idapp, idreg),
emailto: email,
name: user.name,
username: user.username,
verifyLink: await this.getlinkVerifyEmail(idapp, email, username),
user,
supportEmail: tools.getContactEmailSupportBydApp(idapp),
};
const quale_email_inviare = this.getPathEmail(idapp, 'reg_resend_email_to_verifiyng') + '/' + lang;
const ris = await this.sendEmail_base(idapp, quale_email_inviare, email, mylocalsconf, '');
return ris;
} catch (e) {
console.error('Err sendEmail_ReVerifyingEmail', e);
}
},
sendEmail_Utente_Abilitato_Circuito_FidoConcesso: async function (lang, emailto, user, idapp, dati) {
try {
const { Circuit } = require('../models/circuit');
let mylocalsconf = {
idapp,
dataemail: await this.getdataemail(idapp),
@@ -688,14 +745,15 @@ module.exports = {
usernameInvitante: dati.usernameInvitante,
linkProfiloAdmin: tools.getLinkUserProfile(idapp, dati.usernameInvitante),
user,
symbol: await Circuit.getSymbolByCircuitId(dati.circuitId),
usernameMembro: user.username,
nomeTerritorio: dati.nomeTerritorio,
linkTelegramTerritorio: dati.link_group,
};
};
const quale_email_inviare = this.getPathEmail(idapp, 'circuit_abilitato_al_fido_membro') + '/' + lang;
const ris = await this.sendEmail_base(quale_email_inviare, emailto, mylocalsconf, '');
const ris = await this.sendEmail_base(idapp, quale_email_inviare, emailto, mylocalsconf, '');
await telegrambot.notifyToTelegram(telegrambot.phase.AMMETTI_UTENTE, mylocalsconf);
@@ -704,7 +762,14 @@ module.exports = {
console.error('Err sendEmail_Utente_Ammesso', e);
}
},
sendEmail_Richiesta_Al_Facilitatore_Di_FarEntrare_AlCircuito: async function (lang, emailto, user, userInvitante, idapp, dati) {
sendEmail_Richiesta_Al_Facilitatore_Di_FarEntrare_AlCircuito: async function (
lang,
emailto,
user,
userInvitante,
idapp,
dati
) {
try {
// dati.circuitId
// dati.groupname
@@ -712,6 +777,8 @@ module.exports = {
const linkAbilitazione = this.getLinkAbilitaCircuito(idapp, user, dati);
const { Circuit } = require('../models/circuit');
let mylocalsconf = {
idapp,
dataemail: await this.getdataemail(idapp),
@@ -733,6 +800,7 @@ module.exports = {
comuneResidenza: user.profile.resid_str_comune,
provinciaResidenza: user.profile.resid_province,
user,
symbol: await Circuit.getSymbolByCircuitId(dati.circuitId),
linkAbilitazione: linkAbilitazione,
linkProfiloMembro: tools.getLinkUserProfile(idapp, user.username),
linkProfiloInvitante: tools.getLinkUserProfile(idapp, user.aportador_solidario),
@@ -742,7 +810,7 @@ module.exports = {
const quale_email_inviare = this.getPathEmail(idapp, 'circuit_chiedi_facilitatori_di_entrare') + '/' + lang;
const ris = await this.sendEmail_base(quale_email_inviare, emailto, mylocalsconf, '');
const ris = await this.sendEmail_base(idapp, quale_email_inviare, emailto, mylocalsconf, '');
// await telegrambot.notifyToTelegram(telegrambot.phase.AMMETTI_UTENTE, mylocalsconf);
@@ -770,6 +838,7 @@ module.exports = {
mylocalsconf = this.setParamsForTemplate(iscritto, mylocalsconf);
await this.sendEmail_base(
idapp,
'iscrizione_conacreis/' + lang,
emailto,
mylocalsconf,
@@ -778,6 +847,7 @@ module.exports = {
// Send to the Admin an Email
await this.sendEmail_base(
idapp,
'admin/iscrizione_conacreis/' + tools.LANGADMIN,
tools.getAdminEmailByIdApp(idapp),
mylocalsconf,
@@ -798,6 +868,7 @@ module.exports = {
if (tools.isManagAndAdminDifferent(idapp)) {
await this.sendEmail_base(
idapp,
'admin/iscrizione_conacreis/' + tools.LANGADMIN,
tools.getManagerEmailByIdApp(idapp),
mylocalsconf,
@@ -821,7 +892,7 @@ module.exports = {
mylocalsconf = this.setParamsForTemplate(user, mylocalsconf);
await this.sendEmail_base('resetpwd/' + lang, emailto, mylocalsconf, '');
await this.sendEmail_base(idapp, 'resetpwd/' + lang, emailto, mylocalsconf, '');
},
sendEmail_RisRicevuti: async function (lang, userDest, emailto, idapp, myrec, extrarec) {
@@ -848,7 +919,7 @@ module.exports = {
mylocalsconf = this.setParamsForTemplate(userDest, mylocalsconf);
await this.sendEmail_base('risricevuti/' + lang, emailto, mylocalsconf, '');
await this.sendEmail_base(idapp, 'risricevuti/' + lang, emailto, mylocalsconf, '');
},
sendEmail_Booking: async function (res, lang, emailto, user, idapp, recbooking) {
@@ -886,6 +957,7 @@ module.exports = {
}
await this.sendEmail_base(
idapp,
'booking/' + texthtml + '/' + lang,
emailto,
mylocalsconf,
@@ -894,6 +966,7 @@ module.exports = {
// Send Email also to the Admin
await this.sendEmail_base(
idapp,
'admin/' + texthtml + '/' + tools.LANGADMIN,
tools.getAdminEmailByIdApp(idapp),
mylocalsconf,
@@ -902,6 +975,7 @@ module.exports = {
if (tools.isManagAndAdminDifferent(idapp)) {
await this.sendEmail_base(
idapp,
'admin/' + texthtml + '/' + tools.LANGADMIN,
tools.getManagerEmailByIdApp(idapp),
mylocalsconf,
@@ -999,6 +1073,7 @@ module.exports = {
telegrambot.sendMsgTelegramToTheManagers(idapp, msgtelegram);
await this.sendEmail_base(
idapp,
'booking/cancelbooking/' + lang,
emailto,
mylocalsconf,
@@ -1007,6 +1082,7 @@ module.exports = {
// Send Email also to the Admin
await this.sendEmail_base(
idapp,
'admin/cancelbooking/' + tools.LANGADMIN,
tools.getAdminEmailByIdApp(idapp),
mylocalsconf,
@@ -1015,6 +1091,7 @@ module.exports = {
if (tools.isManagAndAdminDifferent(idapp)) {
await this.sendEmail_base(
idapp,
'admin/cancelbooking/' + tools.LANGADMIN,
tools.getManagerEmailByIdApp(idapp),
mylocalsconf,
@@ -1046,7 +1123,7 @@ module.exports = {
if (mylocalsconf.infoevent !== '') replyto = user.email;
else replyto = tools.getreplyToEmailByIdApp(idapp);
return await this.sendEmail_base('msg/sendmsg/' + lang, emailto, mylocalsconf, replyto);
return await this.sendEmail_base(idapp, 'msg/sendmsg/' + lang, emailto, mylocalsconf, replyto);
// Send Email also to the Admin
// this.sendEmail_base('admin/sendmsg/' + lang, tools.getAdminEmailByIdApp(idapp), mylocalsconf);
@@ -1168,6 +1245,7 @@ module.exports = {
if (sendnews) {
// Send to the Admin an Email
await this.sendEmail_base(
idapp,
'admin/added_to_newsletter/' + tools.LANGADMIN,
tools.getAdminEmailByIdApp(idapp),
mylocalsconf,
@@ -1176,6 +1254,7 @@ module.exports = {
if (tools.isManagAndAdminDifferent(idapp)) {
await this.sendEmail_base(
idapp,
'admin/added_to_newsletter/' + tools.LANGADMIN,
tools.getManagerEmailByIdApp(idapp),
mylocalsconf,
@@ -1474,6 +1553,7 @@ module.exports = {
if (status !== shared_consts.OrderStatus.CANCELED && status !== shared_consts.OrderStatus.COMPLETED) {
const esito = await this.sendEmail_base(
idapp,
'ecommerce/' + ordertype + '/' + lang,
mylocalsconf.emailto,
mylocalsconf,
@@ -1570,6 +1650,7 @@ module.exports = {
// Send Email to the User
// console.log('-> Invio Email (', mynewsrec.numemail_sent, '/', mynewsrec.numemail_tot, ')');
const esito = await this.sendEmail_base(
idapp,
'newsletter/' + lang,
mylocalsconf.emailto,
mylocalsconf,
@@ -1709,6 +1790,7 @@ module.exports = {
console.log('-> Invio Email TEST a', mylocalsconf.emailto, 'previewonly', previewonly);
return await this.sendEmail_base(
idapp,
'newsletter/' + lang,
mylocalsconf.emailto,
mylocalsconf,
@@ -1749,6 +1831,7 @@ module.exports = {
console.log('-> Invio Email ' + mylocalsconf.subject + ' a', mylocalsconf.emailto, 'in corso...');
const risult = await this.sendEmail_base(
idapp,
'newsletter/' + userto.lang,
mylocalsconf.emailto,
mylocalsconf,

View File

@@ -120,6 +120,10 @@ async function runStartupTasks() {
await inizia();
if (true) {
// const Seed = require('../scripts/seedTemplates');
}
// 4) reset job pendenti
await resetProcessingJob();

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ function setupExpress(app, corsOptions) {
app.use(helmet());
app.use(morgan('dev'));
app.use(cors(corsOptions));
app.set('trust proxy', true);
app.set('trust proxy', (process.env.NODE_ENV === 'development') ? false : true);
// parser
app.use(express.json({ limit: '100mb' }));

View File

@@ -36,6 +36,7 @@ function setupRouters(app) {
['/aitools', 'aitools_router'],
['/apisqlsrv', 'articleRoutes'],
['/api', 'api_router'],
['/api2', 'api2_router'],
['/api/telegram', 'telegram_router'],
['/inviti', 'invitaAmicoRoutes'],
];
@@ -58,6 +59,10 @@ function setupRouters(app) {
});
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
return true;
}

View File

@@ -1,57 +0,0 @@
const fs = require('fs');
const path = require('path');
const pty = require('node-pty');
const { User } = require('../models/user');
const { sendMessage } = require('../telegram/api');
function setupShellWebSocket(ws) {
console.log('🔌 Client WebSocket Shell connesso');
let scriptProcess = null;
let buffer = '';
ws.on('message', async (message) => {
try {
const parsed = JSON.parse(message);
const { type, user_id, scriptName, data } = parsed;
if (type === 'start_script' && (await User.isAdminById(user_id))) {
if (scriptProcess) scriptProcess.kill();
const scriptPath = path.join(__dirname, '..', '..', scriptName);
if (!fs.existsSync(scriptPath)) {
return ws.send(JSON.stringify({ type: 'error', data: 'Script non trovato' }));
}
scriptProcess = pty.spawn('bash', [scriptPath], {
name: 'xterm-color',
cols: 80,
rows: 40,
cwd: process.cwd(),
env: process.env,
});
scriptProcess.on('data', (chunk) => {
buffer += chunk;
ws.send(JSON.stringify({ type: 'output', data: chunk }));
if (buffer.length > 4096) buffer = buffer.slice(-2048);
});
scriptProcess.on('exit', (code) => {
const msg = code === 0 ? '✅ Script completato' : `❌ Uscito con codice ${code}`;
ws.send(JSON.stringify({ type: 'close', data: msg }));
});
} else if (type === 'input' && scriptProcess) {
scriptProcess.write(data + '\n');
}
} catch (err) {
console.error('❌ Errore WS Shell:', err.message);
}
});
ws.on('close', () => {
if (scriptProcess) scriptProcess.kill();
console.log('🔌 WS Shell chiuso');
});
}
module.exports = { setupShellWebSocket };

View File

@@ -146,9 +146,12 @@ connectToDatabase(connectionUrl, options)
const aitools_router = require('./router/aitools_router');
const article_router = require('./router/articleRoutes');
const api_router = require('./router/api_router');
const api2_router = require('./router/api2_router');
const { MyEvent } = require('./models/myevent');
app.use('/videos', express.static(path.join(__dirname, 'uploads/videos')));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
@@ -252,6 +255,7 @@ connectToDatabase(connectionUrl, options)
app.use('/aitools', aitools_router);
app.use('/apisqlsrv', article_router);
app.use('/api', api_router);
app.use('/api2', api2_router);
mystart();
});
@@ -1060,6 +1064,7 @@ connectToDatabase(connectionUrl, options)
const NOCORS = false;
const { domains, domainsAllowed } = parseDomains();
console.log('domains:', domains);

View File

@@ -0,0 +1,151 @@
const { createCanvas, loadImage } = require('canvas');
class PosterEditor {
/**
* Crea poster con testi sovrapposti (compatibile con tua API originale)
*/
async createPoster(backgroundImageUrl, data) {
const { titolo, data: eventDate, ora, luogo, contatti } = data;
const width = 1080;
const height = 1920;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// Carica e disegna background
try {
let img;
if (backgroundImageUrl.startsWith('data:')) {
img = await loadImage(backgroundImageUrl);
} else {
const fetch = require('node-fetch');
const response = await fetch(backgroundImageUrl);
const buffer = await response.buffer();
img = await loadImage(buffer);
}
// Cover fit
const imgRatio = img.width / img.height;
const canvasRatio = width / height;
let dw, dh, dx, dy;
if (imgRatio > canvasRatio) {
dh = height;
dw = height * imgRatio;
dx = (width - dw) / 2;
dy = 0;
} else {
dw = width;
dh = width / imgRatio;
dx = 0;
dy = (height - dh) / 2;
}
ctx.drawImage(img, dx, dy, dw, dh);
} catch (e) {
// Fallback colore solido
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, width, height);
}
// Overlay gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, 'rgba(0,0,0,0)');
gradient.addColorStop(0.5, 'rgba(0,0,0,0.3)');
gradient.addColorStop(1, 'rgba(0,0,0,0.85)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Titolo
if (titolo) {
ctx.save();
ctx.font = 'bold 68px Arial, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.9)';
ctx.shadowBlur = 20;
ctx.shadowOffsetX = 3;
ctx.shadowOffsetY = 3;
// Word wrap manuale
const maxWidth = width * 0.9;
const lines = this._wrapText(ctx, titolo.toUpperCase(), maxWidth);
const lineHeight = 80;
const startY = height * 0.50 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, i) => {
ctx.fillText(line, width / 2, startY + i * lineHeight);
});
ctx.restore();
}
// Data e ora
if (eventDate) {
ctx.save();
ctx.font = 'bold 44px Arial, sans-serif';
ctx.fillStyle = '#ffd700';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 10;
const dateText = ora ? `${eventDate} • ORE ${ora}` : eventDate;
ctx.fillText(dateText.toUpperCase(), width / 2, height * 0.68);
ctx.restore();
}
// Luogo
if (luogo) {
ctx.save();
ctx.font = '600 30px Arial, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.fillText(`📍 ${luogo}`, width / 2, height * 0.76);
ctx.restore();
}
// Contatti
if (contatti) {
ctx.save();
ctx.font = '400 24px Arial, sans-serif';
ctx.fillStyle = '#cccccc';
ctx.textAlign = 'center';
ctx.fillText(contatti, width / 2, height * 0.85);
ctx.restore();
}
// Ritorna base64
return canvas.toDataURL('image/png');
}
/**
* Word wrap utility
*/
_wrapText(ctx, text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
words.forEach(word => {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
}
module.exports = new PosterEditor();

View File

@@ -0,0 +1,154 @@
const fetch = require('node-fetch');
class ImageGenerator {
constructor() {
this.falKey = process.env.FAL_KEY;
this.hfToken = process.env.HF_TOKEN;
this.ideogramKey = process.env.IDEOGRAM_KEY;
}
async generate(provider, prompt, options = {}) {
const {
negativePrompt,
aspectRatio = '9:16',
model,
seed,
steps,
cfg
} = options;
switch (provider) {
case 'ideogram':
return this._generateIdeogram(prompt, { aspectRatio });
case 'fal':
return this._generateFal(prompt, { aspectRatio, seed, steps, cfg });
case 'hf':
default:
return this._generateHuggingFace(prompt, { negativePrompt });
}
}
// Ideogram V2 (via Fal.ai) - Ottimo per testo
async _generateIdeogram(prompt, options = {}) {
console.log('--- Generazione Ideogram V2 ---');
const response = await fetch('https://fal.run/fal-ai/ideogram/v2', {
method: 'POST',
headers: {
Authorization: `Key ${this.falKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
aspect_ratio: options.aspectRatio || '9:16',
style_type: 'DESIGN',
expand_prompt: true
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ideogram error: ${response.status} - ${errorText}`);
}
const result = await response.json();
const imageUrl = result.images?.[0]?.url;
if (!imageUrl) throw new Error('Ideogram: nessun URL restituito');
return imageUrl;
}
// Flux Dev (via Fal.ai)
async _generateFal(prompt, options = {}) {
console.log('--- Generazione Fal Flux Dev ---');
const imageSizeMap = {
'9:16': 'portrait_16_9',
'16:9': 'landscape_16_9',
'1:1': 'square'
};
const response = await fetch('https://fal.run/fal-ai/flux/dev', {
method: 'POST',
headers: {
Authorization: `Key ${this.falKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
image_size: imageSizeMap[options.aspectRatio] || 'portrait_16_9',
num_images: 1,
enable_safety_checker: false,
seed: options.seed,
num_inference_steps: options.steps || 28,
guidance_scale: options.cfg || 7.5
}),
});
if (!response.ok) {
throw new Error(`Fal error: ${response.status}`);
}
const result = await response.json();
return result.images?.[0]?.url;
}
// Flux Dev (via Hugging Face) - GRATIS
async _generateHuggingFace(prompt, options = {}) {
console.log('--- Generazione HuggingFace (Gratis) ---');
const response = await fetch(
'https://router.huggingface.co/hf-inference/models/black-forest-labs/FLUX.1-dev',
{
headers: {
Authorization: `Bearer ${this.hfToken}`,
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
inputs: prompt,
parameters: {
negative_prompt: options.negativePrompt
}
}),
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`HuggingFace error: ${response.status} - ${err}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}
// Utility: verifica disponibilità provider
async checkProvider(provider) {
const checks = {
hf: !!this.hfToken,
fal: !!this.falKey,
ideogram: !!this.falKey // Ideogram via Fal
};
return checks[provider] || false;
}
// Lista provider disponibili
getAvailableProviders() {
const providers = [];
if (this.hfToken) {
providers.push({ id: 'hf', name: 'HuggingFace (Gratis)', cost: 'free' });
}
if (this.falKey) {
providers.push({ id: 'fal', name: 'Fal Flux Dev', cost: 'paid' });
providers.push({ id: 'ideogram', name: 'Ideogram V2', cost: 'paid' });
}
return providers;
}
}
module.exports = new ImageGenerator();

View File

@@ -0,0 +1,871 @@
const { createCanvas, loadImage, registerFont } = require('canvas');
const fs = require('fs').promises;
const path = require('path');
const sharp = require('sharp');
// Registra font personalizzati
const FONTS_DIR = process.env.FONTS_DIR || './fonts';
const registerFonts = async () => {
const fontMappings = [
{ file: 'Montserrat-Black.ttf', family: 'Montserrat', weight: '900' },
{ file: 'Montserrat-Bold.ttf', family: 'Montserrat', weight: '700' },
{ file: 'Montserrat-Regular.ttf', family: 'Montserrat', weight: '400' },
{ file: 'BebasNeue-Regular.ttf', family: 'Bebas Neue', weight: '400' },
{ file: 'OpenSans-Bold.ttf', family: 'Open Sans', weight: '700' },
{ file: 'OpenSans-SemiBold.ttf', family: 'Open Sans', weight: '600' },
{ file: 'OpenSans-Regular.ttf', family: 'Open Sans', weight: '400' },
{ file: 'OpenSans-Light.ttf', family: 'Open Sans', weight: '300' },
{ file: 'PlayfairDisplay-Bold.ttf', family: 'Playfair Display', weight: '700' },
{ file: 'PlayfairDisplay-Regular.ttf', family: 'Playfair Display', weight: '400' }
];
for (const font of fontMappings) {
const fontPath = path.join(FONTS_DIR, font.file);
try {
await fs.access(fontPath);
registerFont(fontPath, { family: font.family, weight: font.weight });
} catch (e) {
// Font non trovato, usa fallback
}
}
};
// Inizializza fonts al caricamento modulo
registerFonts().catch(console.warn);
class PosterRenderer {
constructor() {
this.version = '1.0.0';
}
/**
* Render principale
*/
async render(options) {
const {
template,
content,
assets,
layerOverrides = {},
outputDir,
posterId
} = options;
const startTime = Date.now();
// Dimensioni canvas
const width = template.format?.width || 2480;
const height = template.format?.height || 3508;
// Crea canvas
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 1. Disegna background
await this._drawBackground(ctx, template, assets, width, height);
// 2. Ordina layer per zIndex
const sortedLayers = [...(template.layers || [])]
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
// 3. Disegna ogni layer
for (const layer of sortedLayers) {
if (layer.visible === false) continue;
const override = layerOverrides[layer.id] || {};
const mergedLayer = this._mergeLayerOverride(layer, override);
await this._drawLayer(ctx, mergedLayer, content, assets, width, height, template);
}
// 4. Disegna loghi
if (template.logoSlots?.enabled && assets?.logos?.length > 0) {
await this._drawLogos(ctx, template.logoSlots, assets.logos, width, height);
}
// 5. Salva output
await fs.mkdir(outputDir, { recursive: true });
const baseName = `poster_${posterId}_${Date.now()}`;
const pngPath = path.join(outputDir, `${baseName}.png`);
const jpgPath = path.join(outputDir, `${baseName}.jpg`);
// Salva PNG
const pngBuffer = canvas.toBuffer('image/png');
await fs.writeFile(pngPath, pngBuffer);
// Salva JPG con Sharp (migliore qualità)
await sharp(pngBuffer)
.jpeg({ quality: 95, progressive: true })
.toFile(jpgPath);
const [pngStats, jpgStats] = await Promise.all([
fs.stat(pngPath),
fs.stat(jpgPath)
]);
return {
pngPath,
jpgPath,
pngSize: pngStats.size,
jpgSize: jpgStats.size,
dimensions: { width, height },
duration: Date.now() - startTime,
engineVersion: this.version
};
}
/**
* Disegna background
*/
async _drawBackground(ctx, template, assets, width, height) {
// Colore di sfondo base
ctx.fillStyle = template.backgroundColor || '#1a1a2e';
ctx.fillRect(0, 0, width, height);
// Background image
const bgAsset = assets?.backgroundImage;
const bgLayer = template.layers?.find(l => l.type === 'backgroundImage');
if (bgAsset?.url) {
try {
const img = await this._loadImageFromUrl(bgAsset.url);
// Calcola dimensioni per cover
const imgRatio = img.width / img.height;
const canvasRatio = width / height;
let drawWidth, drawHeight, drawX, drawY;
if (imgRatio > canvasRatio) {
drawHeight = height;
drawWidth = height * imgRatio;
drawX = (width - drawWidth) / 2;
drawY = 0;
} else {
drawWidth = width;
drawHeight = width / imgRatio;
drawX = 0;
drawY = (height - drawHeight) / 2;
}
// Applica blur se definito
if (bgLayer?.style?.blur > 0) {
ctx.filter = `blur(${bgLayer.style.blur}px)`;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
ctx.filter = 'none';
// Applica overlay gradient
const overlay = bgLayer?.style?.overlay;
if (overlay?.enabled) {
this._drawOverlay(ctx, overlay, width, height);
}
} catch (e) {
console.warn('Background image load failed:', e.message);
// Usa fallback
if (bgLayer?.fallback) {
this._drawFallback(ctx, bgLayer.fallback, width, height);
}
}
} else if (bgLayer?.fallback) {
this._drawFallback(ctx, bgLayer.fallback, width, height);
}
}
/**
* Disegna overlay gradient
*/
_drawOverlay(ctx, overlay, width, height) {
if (overlay.type === 'solid') {
ctx.fillStyle = overlay.color || 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, width, height);
return;
}
// Gradient
let gradient;
const dir = overlay.direction || 'to-bottom';
if (dir === 'to-bottom') {
gradient = ctx.createLinearGradient(0, 0, 0, height);
} else if (dir === 'to-top') {
gradient = ctx.createLinearGradient(0, height, 0, 0);
} else if (dir === 'to-right') {
gradient = ctx.createLinearGradient(0, 0, width, 0);
} else if (dir === 'to-left') {
gradient = ctx.createLinearGradient(width, 0, 0, 0);
} else if (dir === 'to-bottom-right') {
gradient = ctx.createLinearGradient(0, 0, width, height);
} else {
gradient = ctx.createLinearGradient(0, 0, 0, height);
}
if (overlay.stops) {
overlay.stops.forEach(stop => {
gradient.addColorStop(stop.position, stop.color);
});
} else {
gradient.addColorStop(0, 'rgba(0,0,0,0)');
gradient.addColorStop(1, 'rgba(0,0,0,0.7)');
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
/**
* Disegna fallback
*/
_drawFallback(ctx, fallback, width, height) {
if (fallback.type === 'solid') {
ctx.fillStyle = fallback.color || '#333333';
ctx.fillRect(0, 0, width, height);
} else if (fallback.type === 'gradient' && fallback.colors) {
const gradient = ctx.createLinearGradient(0, 0, 0, height);
fallback.colors.forEach((color, i) => {
gradient.addColorStop(i / (fallback.colors.length - 1), color);
});
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
}
/**
* Disegna singolo layer
*/
async _drawLayer(ctx, layer, content, assets, canvasWidth, canvasHeight, template) {
const pos = this._calculatePosition(layer.position, layer.anchor, canvasWidth, canvasHeight);
switch (layer.type) {
case 'backgroundImage':
// Già gestito in _drawBackground
break;
case 'mainImage':
await this._drawMainImage(ctx, assets?.mainImage, pos, layer.style);
break;
case 'title':
this._drawText(ctx, content?.title, pos, layer.style, template.palette);
break;
case 'subtitle':
this._drawText(ctx, content?.subtitle, pos, layer.style, template.palette);
break;
case 'eventDate':
const dateText = content?.eventTime
? `${content.eventDate}${content.eventTime}`
: content?.eventDate;
this._drawText(ctx, dateText, pos, layer.style, template.palette);
break;
case 'eventTime':
this._drawText(ctx, content?.eventTime, pos, layer.style, template.palette);
break;
case 'location':
this._drawTextWithIcon(ctx, content?.location, pos, layer, template.palette);
break;
case 'contacts':
this._drawText(ctx, content?.contacts, pos, layer.style, template.palette);
break;
case 'extraText':
const extraTexts = Array.isArray(content?.extraText)
? content.extraText.join(' • ')
: content?.extraText;
this._drawText(ctx, extraTexts, pos, layer.style, template.palette);
break;
case 'customText':
const customValue = content?.customFields?.get(layer.id);
this._drawText(ctx, customValue, pos, layer.style, template.palette);
break;
case 'divider':
this._drawDivider(ctx, pos, layer.style);
break;
case 'shape':
this._drawShape(ctx, pos, layer.style);
break;
default:
console.warn(`Layer type non gestito: ${layer.type}`);
}
}
/**
* Calcola posizione assoluta da coordinate relative
*/
_calculatePosition(position, anchor, canvasWidth, canvasHeight) {
const relX = position.x || 0;
const relY = position.y || 0;
const relW = position.w || 1;
const relH = position.h || 0.1;
const absW = relW * canvasWidth;
const absH = relH * canvasHeight;
let absX = relX * canvasWidth;
let absY = relY * canvasHeight;
// Aggiusta per anchor
switch (anchor) {
case 'top-center':
absX -= absW / 2;
break;
case 'top-right':
absX -= absW;
break;
case 'center-left':
absY -= absH / 2;
break;
case 'center':
absX -= absW / 2;
absY -= absH / 2;
break;
case 'center-right':
absX -= absW;
absY -= absH / 2;
break;
case 'bottom-left':
absY -= absH;
break;
case 'bottom-center':
absX -= absW / 2;
absY -= absH;
break;
case 'bottom-right':
absX -= absW;
absY -= absH;
break;
// top-left è default, nessun aggiustamento
}
return { x: absX, y: absY, w: absW, h: absH };
}
/**
* Disegna main image
*/
async _drawMainImage(ctx, asset, pos, style = {}) {
if (!asset?.url) return;
try {
const img = await this._loadImageFromUrl(asset.url);
ctx.save();
// Border radius (clip)
const radius = style.borderRadius || 0;
if (radius > 0) {
this._roundRect(ctx, pos.x, pos.y, pos.w, pos.h, radius);
ctx.clip();
}
// Shadow
if (style.shadow?.enabled) {
ctx.shadowColor = style.shadow.color || 'rgba(0,0,0,0.5)';
ctx.shadowBlur = style.shadow.blur || 20;
ctx.shadowOffsetX = style.shadow.offsetX || 0;
ctx.shadowOffsetY = style.shadow.offsetY || 10;
}
// Calcola dimensioni per object-fit
const { sx, sy, sw, sh, dx, dy, dw, dh } = this._calculateObjectFit(
img.width, img.height, pos.w, pos.h, style.objectFit || 'cover'
);
ctx.drawImage(img, sx, sy, sw, sh, pos.x + dx, pos.y + dy, dw, dh);
// Border
if (style.border?.enabled) {
ctx.strokeStyle = style.border.color || '#ffffff';
ctx.lineWidth = style.border.width || 2;
if (radius > 0) {
this._roundRect(ctx, pos.x, pos.y, pos.w, pos.h, radius);
} else {
ctx.strokeRect(pos.x, pos.y, pos.w, pos.h);
}
ctx.stroke();
}
ctx.restore();
} catch (e) {
console.warn('Main image load failed:', e.message);
}
}
/**
* Calcola object-fit
*/
_calculateObjectFit(imgW, imgH, boxW, boxH, fit) {
let sx = 0, sy = 0, sw = imgW, sh = imgH;
let dx = 0, dy = 0, dw = boxW, dh = boxH;
const imgRatio = imgW / imgH;
const boxRatio = boxW / boxH;
if (fit === 'cover') {
if (imgRatio > boxRatio) {
sw = imgH * boxRatio;
sx = (imgW - sw) / 2;
} else {
sh = imgW / boxRatio;
sy = (imgH - sh) / 2;
}
} else if (fit === 'contain') {
if (imgRatio > boxRatio) {
dh = boxW / imgRatio;
dy = (boxH - dh) / 2;
} else {
dw = boxH * imgRatio;
dx = (boxW - dw) / 2;
}
}
// 'fill' usa valori default
return { sx, sy, sw, sh, dx, dy, dw, dh };
}
/**
* Disegna testo
*/
_drawText(ctx, text, pos, style = {}, palette = {}) {
if (!text) return;
ctx.save();
// Font
const fontWeight = style.fontWeight || 400;
const fontSize = style.fontSize || 48;
const fontFamily = style.fontFamily || 'Open Sans';
ctx.font = `${fontWeight} ${fontSize}px "${fontFamily}"`;
// Colore
ctx.fillStyle = style.color || palette.text || '#ffffff';
// Allineamento
const align = style.textAlign || 'center';
ctx.textAlign = align;
ctx.textBaseline = 'middle';
// Transform
let displayText = text;
if (style.textTransform === 'uppercase') {
displayText = text.toUpperCase();
} else if (style.textTransform === 'lowercase') {
displayText = text.toLowerCase();
} else if (style.textTransform === 'capitalize') {
displayText = text.replace(/\b\w/g, c => c.toUpperCase());
}
// Calcola X in base ad allineamento
let textX;
if (align === 'center') {
textX = pos.x + pos.w / 2;
} else if (align === 'right') {
textX = pos.x + pos.w;
} else {
textX = pos.x;
}
const textY = pos.y + pos.h / 2;
// Letter spacing (manuale)
if (style.letterSpacing && style.letterSpacing > 0) {
this._drawTextWithSpacing(ctx, displayText, textX, textY, style, pos);
} else {
// Shadow
if (style.shadow?.enabled) {
ctx.shadowColor = style.shadow.color || 'rgba(0,0,0,0.8)';
ctx.shadowBlur = style.shadow.blur || 10;
ctx.shadowOffsetX = style.shadow.offsetX || 2;
ctx.shadowOffsetY = style.shadow.offsetY || 2;
}
// Stroke
if (style.stroke?.enabled) {
ctx.strokeStyle = style.stroke.color || 'rgba(0,0,0,0.5)';
ctx.lineWidth = style.stroke.width || 2;
ctx.strokeText(displayText, textX, textY);
}
// Fill
ctx.fillText(displayText, textX, textY);
}
ctx.restore();
}
/**
* Disegna testo con letter-spacing
*/
_drawTextWithSpacing(ctx, text, x, y, style, pos) {
const spacing = style.letterSpacing || 0;
const chars = text.split('');
// Calcola larghezza totale
let totalWidth = 0;
chars.forEach(char => {
totalWidth += ctx.measureText(char).width + spacing;
});
totalWidth -= spacing; // Rimuovi ultimo spacing
// Calcola startX in base ad allineamento
let startX;
if (style.textAlign === 'center') {
startX = x - totalWidth / 2;
} else if (style.textAlign === 'right') {
startX = x - totalWidth;
} else {
startX = x;
}
// Shadow
if (style.shadow?.enabled) {
ctx.shadowColor = style.shadow.color || 'rgba(0,0,0,0.8)';
ctx.shadowBlur = style.shadow.blur || 10;
ctx.shadowOffsetX = style.shadow.offsetX || 2;
ctx.shadowOffsetY = style.shadow.offsetY || 2;
}
// Disegna ogni carattere
ctx.textAlign = 'left';
let currentX = startX;
chars.forEach(char => {
if (style.stroke?.enabled) {
ctx.strokeStyle = style.stroke.color || 'rgba(0,0,0,0.5)';
ctx.lineWidth = style.stroke.width || 2;
ctx.strokeText(char, currentX, y);
}
ctx.fillText(char, currentX, y);
currentX += ctx.measureText(char).width + spacing;
});
}
/**
* Disegna testo con icona
*/
_drawTextWithIcon(ctx, text, pos, layer, palette) {
if (!text) return;
const icon = layer.icon;
const style = layer.style || {};
// Se icona abilitata, disegna simbolo prima del testo
if (icon?.enabled) {
ctx.save();
const iconSize = icon.size || 24;
const iconColor = icon.color || palette?.accent || '#e74c3c';
// Disegna simbolo location semplificato
ctx.fillStyle = iconColor;
ctx.font = `${iconSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const iconChar = '📍'; // Emoji o usa font icon
const textWithIcon = `${iconChar} ${text}`;
// Ora disegna testo normale con icona
this._drawText(ctx, textWithIcon, pos, style, palette);
ctx.restore();
} else {
this._drawText(ctx, text, pos, style, palette);
}
}
/**
* Disegna loghi
*/
async _drawLogos(ctx, logoSlots, logos, canvasWidth, canvasHeight) {
const slots = logoSlots.slots || [];
const maxCount = Math.min(logos.length, logoSlots.maxCount || 3, slots.length);
for (let i = 0; i < maxCount; i++) {
const logo = logos[i];
const slot = slots[i];
if (!logo?.url || !slot) continue;
try {
const img = await this._loadImageFromUrl(logo.url);
const pos = this._calculatePosition(slot.position, slot.anchor, canvasWidth, canvasHeight);
ctx.save();
// Opacity
ctx.globalAlpha = slot.style?.opacity ?? 0.9;
// Object fit contain per loghi
const { sx, sy, sw, sh, dx, dy, dw, dh } = this._calculateObjectFit(
img.width, img.height, pos.w, pos.h, 'contain'
);
ctx.drawImage(img, sx, sy, sw, sh, pos.x + dx, pos.y + dy, dw, dh);
ctx.restore();
} catch (e) {
console.warn(`Logo ${i} load failed:`, e.message);
}
}
}
/**
* Disegna divider
*/
_drawDivider(ctx, pos, style = {}) {
ctx.save();
ctx.strokeStyle = style.color || '#ffffff';
ctx.lineWidth = style.width || 2;
ctx.globalAlpha = style.opacity || 0.5;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y + pos.h / 2);
ctx.lineTo(pos.x + pos.w, pos.y + pos.h / 2);
ctx.stroke();
ctx.restore();
}
/**
* Disegna shape
*/
_drawShape(ctx, pos, style = {}) {
ctx.save();
ctx.fillStyle = style.fill || 'rgba(255,255,255,0.1)';
ctx.strokeStyle = style.stroke || 'transparent';
ctx.lineWidth = style.strokeWidth || 0;
ctx.globalAlpha = style.opacity || 1;
const shape = style.shape || 'rectangle';
const radius = style.borderRadius || 0;
if (shape === 'rectangle') {
if (radius > 0) {
this._roundRect(ctx, pos.x, pos.y, pos.w, pos.h, radius);
ctx.fill();
if (style.strokeWidth) ctx.stroke();
} else {
ctx.fillRect(pos.x, pos.y, pos.w, pos.h);
if (style.strokeWidth) ctx.strokeRect(pos.x, pos.y, pos.w, pos.h);
}
} else if (shape === 'circle' || shape === 'ellipse') {
ctx.beginPath();
ctx.ellipse(
pos.x + pos.w / 2,
pos.y + pos.h / 2,
pos.w / 2,
pos.h / 2,
0, 0, Math.PI * 2
);
ctx.fill();
if (style.strokeWidth) ctx.stroke();
}
ctx.restore();
}
/**
* Utility: rounded rectangle
*/
_roundRect(ctx, x, y, w, h, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/**
* Utility: carica immagine da URL o path locale
*/
async _loadImageFromUrl(url) {
if (!url) throw new Error('URL mancante');
// Base64
if (url.startsWith('data:')) {
return loadImage(url);
}
// Path locale
if (url.startsWith('/upload') || url.startsWith('./upload')) {
const localPath = url.startsWith('/')
? path.join(process.cwd(), url)
: url;
return loadImage(localPath);
}
// URL remoto
if (url.startsWith('http://') || url.startsWith('https://')) {
const fetch = require('node-fetch');
const response = await fetch(url);
const buffer = await response.buffer();
return loadImage(buffer);
}
// Assume path locale
return loadImage(url);
}
/**
* Merge layer con override
*/
_mergeLayerOverride(layer, override) {
if (!override || Object.keys(override).length === 0) {
return layer;
}
return {
...layer,
position: override.position ? { ...layer.position, ...override.position } : layer.position,
visible: override.visible !== undefined ? override.visible : layer.visible,
style: override.style ? { ...layer.style, ...override.style } : layer.style
};
}
/**
* Quick render (semplificato per quick-generate)
*/
async quickRender(options) {
const {
backgroundUrl,
content,
outputPath,
width = 1080,
height = 1920
} = options;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// Background
if (backgroundUrl) {
try {
const img = await this._loadImageFromUrl(backgroundUrl);
const imgRatio = img.width / img.height;
const canvasRatio = width / height;
let dw, dh, dx, dy;
if (imgRatio > canvasRatio) {
dh = height;
dw = height * imgRatio;
dx = (width - dw) / 2;
dy = 0;
} else {
dw = width;
dh = width / imgRatio;
dx = 0;
dy = (height - dh) / 2;
}
ctx.drawImage(img, dx, dy, dw, dh);
} catch (e) {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, width, height);
}
}
// Overlay gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, 'rgba(0,0,0,0)');
gradient.addColorStop(0.4, 'rgba(0,0,0,0.2)');
gradient.addColorStop(0.7, 'rgba(0,0,0,0.6)');
gradient.addColorStop(1, 'rgba(0,0,0,0.85)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Title
if (content.title) {
ctx.save();
ctx.font = 'bold 72px "Montserrat", sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 15;
ctx.shadowOffsetY = 4;
ctx.fillText(content.title.toUpperCase(), width / 2, height * 0.52);
ctx.restore();
}
// Subtitle
if (content.subtitle) {
ctx.save();
ctx.font = '400 32px "Open Sans", sans-serif';
ctx.fillStyle = '#f0f0f0';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = 8;
ctx.fillText(content.subtitle, width / 2, height * 0.60);
ctx.restore();
}
// Date
if (content.eventDate) {
ctx.save();
ctx.font = '400 48px "Bebas Neue", sans-serif';
ctx.fillStyle = '#ffd700';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 10;
const dateText = content.eventTime
? `${content.eventDate} • ORE ${content.eventTime}`
: content.eventDate;
ctx.fillText(dateText.toUpperCase(), width / 2, height * 0.70);
ctx.restore();
}
// Location
if (content.location) {
ctx.save();
ctx.font = '600 28px "Open Sans", sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.fillText(`📍 ${content.location}`, width / 2, height * 0.78);
ctx.restore();
}
// Contacts
if (content.contacts) {
ctx.save();
ctx.font = '400 22px "Open Sans", sans-serif';
ctx.fillStyle = '#cccccc';
ctx.textAlign = 'center';
ctx.fillText(content.contacts, width / 2, height * 0.86);
ctx.restore();
}
// Salva
const buffer = canvas.toBuffer('image/png');
await fs.writeFile(outputPath, buffer);
return {
path: outputPath,
size: buffer.length,
dimensions: { width, height }
};
}
}
module.exports = new PosterRenderer();

View File

@@ -1,33 +0,0 @@
const axios = require('axios');
const { API_URL, TIMEOUT } = require('./config');
async function callTelegram(method, params) {
try {
const { data } = await axios.post(`${API_URL}/${method}`, params, { timeout: TIMEOUT });
if (!data.ok) throw new Error(`Telegram error: ${data.description}`);
return data.result;
} catch (err) {
console.error('❌ Telegram API error:', err.message);
return null;
}
}
async function sendMessage(chatId, text, options = {}) {
return callTelegram('sendMessage', {
chat_id: chatId,
text,
parse_mode: options.parse_mode || 'HTML',
disable_web_page_preview: true,
});
}
async function sendPhoto(chatId, photo, caption = '', options = {}) {
return callTelegram('sendPhoto', {
chat_id: chatId,
photo,
caption,
parse_mode: 'HTML',
});
}
module.exports = { sendMessage, sendPhoto };

View File

@@ -1,8 +0,0 @@
module.exports = {
TOKEN: process.env.TELEGRAM_BOT_TOKEN,
API_URL: `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`,
ADMIN_GROUP_IDS: process.env.TELEGRAM_ADMIN_GROUPS
? process.env.TELEGRAM_ADMIN_GROUPS.split(',')
: [],
TIMEOUT: 5000,
};

View File

@@ -1,18 +0,0 @@
// Ruoli, fasi logiche e costanti admin (adatta gli ID ai tuoi reali)
module.exports = {
ADMIN_USER_SERVER: process.env.ADMIN_USER_SERVER || 'server_admin',
ADMIN_IDTELEGRAM_SERVER: process.env.ADMIN_IDTELEGRAM_SERVER || '',
phase: {
REGISTRATION: 'REGISTRATION',
REGISTRATION_CONFIRMED: 'REGISTRATION_CONFIRMED',
RESET_PWD: 'RESET_PWD',
NOTIFICATION: 'NOTIFICATION',
GENERIC: 'GENERIC',
},
roles: {
ADMIN: 'ADMIN',
MANAGER: 'MANAGER',
FACILITATORE: 'FACILITATORE',
EDITOR: 'EDITOR',
},
};

View File

@@ -1,11 +0,0 @@
const { sendMessage } = require('../api');
const { ADMIN_GROUP_IDS } = require('../config');
const { safeExec } = require('../helpers');
const sendToAdmins = safeExec(async (message) => {
for (const id of ADMIN_GROUP_IDS) {
await sendMessage(id, message);
}
});
module.exports = { sendToAdmins };

View File

@@ -1,101 +0,0 @@
// telegram/handlers/callbackHandler.js
const tools = require('../../tools/general');
const shared_consts = require('../../tools/shared_nodejs');
const { User } = require('../../models/user');
const { Circuit } = require('../../models/circuit');
const { handleRegistration } = require('./registrationHandler');
const { handleFriends } = require('./friendsHandler');
const { handleCircuit } = require('./circuitHandler');
const { handleZoom } = require('./zoomHandler');
const { handlePassword } = require('./passwordHandler');
async function handleCallback(bot, cl, callbackQuery) {
const idapp = cl.idapp;
let notifyText = ''; // testo di notifica Telegram (answerCallbackQuery)
try {
// parsing payload dal tuo formato originale (action|username|userDest|groupId|circuitId|groupname)
let data = {
action: '',
username: '',
userDest: '',
groupId: '',
circuitId: '',
groupname: '',
};
const raw = callbackQuery?.data || '';
if (raw) {
const arr = raw.split(tools.SEP);
data = {
action: arr[0] || '',
username: arr[1] || '',
userDest: arr[2] || '',
groupId: arr[3] || '',
circuitId: arr[4] || '',
groupname: arr[5] || '',
};
}
// normalizza username reali (come nel sorgente)
data.username = await User.getRealUsernameByUsername(idapp, data.username);
data.userDest = data.userDest ? await User.getRealUsernameByUsername(idapp, data.userDest) : '';
const msg = callbackQuery.message;
const opts = { chat_id: msg.chat.id, message_id: msg.message_id };
// contest utente corrente
await cl.setInit?.(msg); // se presente nel tuo codice
const rec = cl.getRecInMem?.(msg);
const username_action = rec?.user ? rec.user.username : '';
// carica user e userDest compatti (come nel tuo codice)
const user = data.username ? await User.getUserShortDataByUsername(idapp, data.username) : null;
const userDest = data.userDest ? await User.getUserShortDataByUsername(idapp, data.userDest) : null;
// routing per ambito
const act = data.action || '';
// 1) REGISTRAZIONE e varianti
if (act.includes(shared_consts.CallFunz.REGISTRATION)) {
notifyText = await handleRegistration({ bot, cl, idapp, msg, data, user, userDest, username_action });
}
// 2) AMICIZIA / HANDSHAKE
else if (
act.includes(shared_consts.CallFunz.RICHIESTA_AMICIZIA) ||
act.includes(shared_consts.CallFunz.RICHIESTA_HANDSHAKE)
) {
notifyText = await handleFriends({ bot, cl, idapp, msg, data, user, userDest, username_action });
}
// 3) CIRCUITI (aggiunta/rimozione)
else if (
act.includes(shared_consts.CallFunz.ADDUSERTOCIRCUIT) ||
act.includes(shared_consts.CallFunz.REMUSERFROMCIRCUIT)
) {
notifyText = await handleCircuit({ bot, cl, idapp, msg, data, user, userDest, username_action });
}
// 4) ZOOM (registrazione/presenze)
else if (act.includes(shared_consts.CallFunz.REGISTRATION_TOZOOM) || act.includes('ZOOM')) {
notifyText = await handleZoom({ bot, cl, idapp, msg, data, user, userDest, username_action });
}
// 5) RESET PASSWORD
else if (act.includes(shared_consts.CallFunz.RESET_PWD)) {
notifyText = await handlePassword({ bot, cl, idapp, msg, data, user, userDest, username_action });
} else if (act.includes(shared_consts.CallFunz.RICHIESTA_GRUPPO)) {
notifyText = await handleGroup({ bot, cl, idapp, msg, data, user, userDest, username_action });
}
// default
else {
notifyText = 'Operazione completata';
await cl.sendMsg(msg.chat.id, `⚙️ Azione non riconosciuta: ${act}`);
}
await bot.answerCallbackQuery(callbackQuery.id, { text: notifyText || 'OK' });
} catch (err) {
console.error('❌ callbackHandler error:', err.message);
try {
await bot.answerCallbackQuery(callbackQuery.id, { text: 'Errore', show_alert: true });
} catch (_) {}
}
}
module.exports = { handleCallback };

View File

@@ -1,54 +0,0 @@
// telegram/handlers/circuitHandler.js
const shared_consts = require('../../tools/shared_nodejs');
const tools = require('../../tools/general');
const { User } = require('../../models/user');
const InlineConferma = { RISPOSTA_SI: 'SI_', RISPOSTA_NO: 'NO_' };
async function handleCircuit({ bot, cl, idapp, msg, data, user, userDest, username_action }) {
let notifyText = '';
// Aggiunta al circuito
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.ADDUSERTOCIRCUIT) {
const cmd = shared_consts.CIRCUITCMD.ADDUSERTOCIRCUIT;
const req = tools.getReqByPar(idapp, username_action);
// se viene da gruppo usa ifCircuitAlreadyInGroup, altrimenti ifAlreadyInCircuit (come nel tuo codice)
const already = data.groupname
? await User.ifCircuitAlreadyInGroup(idapp, data.groupname, data.circuitId)
: await User.ifAlreadyInCircuit(idapp, data.username, data.circuitId);
if (!already) {
await User.setCircuitCmd(idapp, data.username, data.circuitId, cmd, 1, username_action, { groupname: data.groupname });
await cl.sendMsg(msg.chat.id, `${data.username} aggiunto al circuito ${data.circuitId}`);
notifyText = 'Circuito OK';
} else {
await cl.sendMsg(msg.chat.id, ` ${data.username} è già nel circuito ${data.circuitId}`);
notifyText = 'Già presente';
}
return notifyText;
}
// Rimozione dal circuito
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.REMUSERFROMCIRCUIT) {
const cmd = shared_consts.CIRCUITCMD.REMOVEUSERFROMCIRCUIT;
await User.setCircuitCmd(idapp, data.username, data.circuitId, cmd, 0, username_action, { groupname: data.groupname });
await cl.sendMsg(msg.chat.id, `🗑️ ${data.username} rimosso dal circuito ${data.circuitId}`);
notifyText = 'Rimosso';
return notifyText;
}
// NO / annulla
if (
data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.ADDUSERTOCIRCUIT ||
data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.REMUSERFROMCIRCUIT
) {
await cl.sendMsg(msg.chat.id, '❌ Operazione circuito annullata.');
notifyText = 'Annullata';
return notifyText;
}
return 'OK';
}
module.exports = { handleCircuit };

View File

@@ -1,24 +0,0 @@
const { sendMessage, sendPhoto } = require('../api');
const { safeExec } = require('../helpers');
const sendMsgTelegram = safeExec(async (user, text) => {
if (!user || !user.telegram_id) return null;
return sendMessage(user.telegram_id, text);
});
const sendMsgTelegramByIdTelegram = safeExec(async (telegramId, text) => {
if (!telegramId) return null;
return sendMessage(telegramId, text);
});
const sendPhotoTelegram = safeExec(async (chatIdOrUser, photoUrl, caption = '') => {
const chatId = typeof chatIdOrUser === 'object' ? chatIdOrUser?.telegram_id : chatIdOrUser;
if (!chatId || !photoUrl) return null;
return sendPhoto(chatId, photoUrl, caption);
});
module.exports = {
sendMsgTelegram,
sendMsgTelegramByIdTelegram,
sendPhotoTelegram,
};

View File

@@ -1,12 +0,0 @@
const { sendMessage } = require('../api');
const { ADMIN_GROUP_IDS } = require('../config');
const { safeExec } = require('../helpers');
const reportError = safeExec(async (context, err) => {
const msg = `🚨 Errore in <b>${context}</b>\n<pre>${err.stack || err.message}</pre>`;
for (const id of ADMIN_GROUP_IDS) {
await sendMessage(id, msg);
}
});
module.exports = { reportError };

View File

@@ -1,61 +0,0 @@
// telegram/handlers/friendsHandler.js
const shared_consts = require('../../tools/shared_nodejs');
const tools = require('../../tools/general');
const { User } = require('../../models/user');
const printf = require('util').format;
const { handleRegistration, InlineConferma } = require('./registrationHandler');
async function handleFriends({ bot, cl, idapp, msg, data, user, userDest, username_action }) {
let notifyText = '';
// SI -> amicizia
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.RICHIESTA_AMICIZIA) {
if (userDest) {
const req = tools.getReqByPar(idapp, username_action);
const already = await User.isMyFriend(idapp, data.username, data.userDest);
if (!already) await User.setFriendsCmd(req, idapp, data.username, data.userDest, shared_consts.FRIENDSCMD.SETFRIEND);
await cl.sendMsg(msg.chat.id, '🤝 Amicizia confermata!');
notifyText = 'Amicizia OK';
}
return notifyText;
}
// NO -> amicizia (rimuovi/nega)
if (data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.RICHIESTA_AMICIZIA) {
if (userDest) {
const req = tools.getReqByPar(idapp, username_action);
const ris = await User.setFriendsCmd(req, idapp, data.username, data.userDest, shared_consts.FRIENDSCMD.REMOVE_FROM_MYFRIENDS);
if (ris) {
const msgDest = printf(tools.gettranslate('MSG_FRIENDS_NOT_ACCEPTED_CONFIRMED', user.lang), data.username);
await localSendMsgByUsername(idapp, data.userDest, msgDest);
}
await cl.sendMsg(msg.chat.id, '🚫 Amicizia rifiutata.');
notifyText = 'Rifiutata';
}
return notifyText;
}
// SI -> handshake
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.RICHIESTA_HANDSHAKE) {
if (userDest) {
const req = tools.getReqByPar(idapp, username_action);
const already = await User.isMyHandShake(idapp, data.userDest, data.username);
if (!already) await User.setFriendsCmd(req, idapp, data.userDest, data.username, shared_consts.FRIENDSCMD.SETHANDSHAKE);
await cl.sendMsg(msg.chat.id, '🤝 Handshake confermato!');
notifyText = 'Handshake OK';
}
return notifyText;
}
return 'OK';
}
// helper locale (equivalente del tuo local_sendMsgTelegram)
async function localSendMsgByUsername(idapp, username, text) {
const teleg_id = await User.TelegIdByUsername(idapp, username);
const cl = require('../telegram.bot.init').getclTelegByidapp(idapp);
if (cl && teleg_id) return await cl.sendMsg(teleg_id, text);
return null;
}
module.exports = { handleFriends };

View File

@@ -1,70 +0,0 @@
// telegram/handlers/groupHandler.js
const shared_consts = require('../../tools/shared_nodejs');
const tools = require('../../tools/general');
const { User } = require('../../models/user');
const InlineConferma = { RISPOSTA_SI: 'SI_', RISPOSTA_NO: 'NO_' };
/**
* Gestisce conferma/rifiuto a richieste di GRUPPO
* Payload data:
* - action
* - username (mittente originale)
* - userDest (destinatario/utente da aggiungere)
* - groupId (id o path del gruppo)
* - groupname (nome del gruppo)
*/
async function handleGroup({ bot, cl, idapp, msg, data, user, userDest, username_action }) {
let notifyText = '';
// SI → accetta richiesta d'ingresso nel gruppo
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.RICHIESTA_GRUPPO) {
// Se lapp ha funzioni di persistenza specifiche, usale se esistono
// (non assumo nomi rigidi per non rompere il deploy)
if (typeof User.setGroupCmd === 'function') {
try {
await User.setGroupCmd(idapp, data.userDest || data.username, data.groupId, shared_consts.GROUPCMD.ADDUSERTOGROUP, 1, username_action, { groupname: data.groupname });
} catch (e) {
console.warn('handleGroup:setGroupCmd non eseguito:', e.message);
}
}
// Messaggi di conferma
await cl.sendMsg(msg.chat.id, `${data.userDest || data.username} è stato aggiunto al gruppo ${data.groupname || data.groupId}.`);
// Notifica anche lutente interessato
const targetUsername = data.userDest || data.username;
const teleg_id = await User.TelegIdByUsername(idapp, targetUsername);
if (teleg_id) {
await cl.sendMsg(teleg_id, `👥 Sei stato aggiunto al gruppo: ${data.groupname || data.groupId}`);
}
notifyText = 'Gruppo: aggiunta OK';
return notifyText;
}
// NO → rifiuta/annulla richiesta
if (data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.RICHIESTA_GRUPPO) {
if (typeof User.setGroupCmd === 'function') {
try {
await User.setGroupCmd(idapp, data.userDest || data.username, data.groupId, shared_consts.GROUPCMD.REMOVEUSERFROMGROUP, 0, username_action, { groupname: data.groupname });
} catch (e) {
console.warn('handleGroup:setGroupCmd non eseguito:', e.message);
}
}
await cl.sendMsg(msg.chat.id, '🚫 Richiesta gruppo rifiutata.');
// Avvisa il richiedente
const targetUsername = data.userDest || data.username;
const teleg_id = await User.TelegIdByUsername(idapp, targetUsername);
if (teleg_id) {
await cl.sendMsg(teleg_id, `❌ La tua richiesta per il gruppo ${data.groupname || data.groupId} è stata rifiutata.`);
}
notifyText = 'Gruppo: rifiutata';
return notifyText;
}
return 'OK';
}
module.exports = { handleGroup };

View File

@@ -1,47 +0,0 @@
const { sendMessage } = require('../api');
const { safeExec, eachSeries } = require('../helpers');
const tools = require('../../tools/general');
const {
getAdminTelegramUsers,
getManagersTelegramUsers,
getAllTelegramUsersByApp,
} = require('./userQuery');
const sendMsgTelegramToTheAdminAllSites = safeExec(async (text, alsoGroups = false) => {
const apps = await tools.getApps(); // deve restituire {idapp,...}
await eachSeries(apps, async (app) => {
const admins = await getAdminTelegramUsers(app.idapp);
await eachSeries(admins, async (u) => sendMessage(u.telegram_id, text));
if (alsoGroups && app?.telegram_admin_group_id) {
await sendMessage(app.telegram_admin_group_id, text);
}
});
});
const sendMsgTelegramByIdApp = safeExec(async (idapp, text) => {
const users = await getAllTelegramUsersByApp(idapp);
await eachSeries(users, async (u) => sendMessage(u.telegram_id, text));
});
const sendMsgTelegramToTheManagers = safeExec(async (idapp, text) => {
const managers = await getManagersTelegramUsers(idapp);
await eachSeries(managers, async (u) => sendMessage(u.telegram_id, text));
});
const sendMsgTelegramToTheAdmin = safeExec(async (idapp, text) => {
const admins = await getAdminTelegramUsers(idapp);
await eachSeries(admins, async (u) => sendMessage(u.telegram_id, text));
});
const sendMsgTelegramToTheGroup = safeExec(async (chatId, text) => {
if (!chatId) return null;
return sendMessage(chatId, text);
});
module.exports = {
sendMsgTelegramToTheAdminAllSites,
sendMsgTelegramByIdApp,
sendMsgTelegramToTheManagers,
sendMsgTelegramToTheAdmin,
sendMsgTelegramToTheGroup,
};

View File

@@ -1,9 +0,0 @@
const { sendMessage } = require('../api');
const { safeExec } = require('../helpers');
const sendNotification = safeExec(async (chatId, title, body) => {
const msg = `🔔 <b>${title}</b>\n${body}`;
await sendMessage(chatId, msg);
});
module.exports = { sendNotification };

View File

@@ -1,35 +0,0 @@
// telegram/handlers/passwordHandler.js
const shared_consts = require('../../tools/shared_nodejs');
const tools = require('../../tools/general');
const InlineConferma = { RISPOSTA_SI: 'SI_', RISPOSTA_NO: 'NO_' };
async function handlePassword({ bot, cl, idapp, msg, data, user, userDest, username_action }) {
let notifyText = '';
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.RESET_PWD) {
// Nel tuo codice usavi anche tools.sendNotificationToUser ecc.
await tools.sendNotificationToUser(
user?._id || msg.chat.id,
'🔑 Reset Password',
`La password di ${data.username} è stata resettata.`,
'/',
'',
'server',
[]
);
await cl.sendMsg(msg.chat.id, '✅ Password resettata.');
notifyText = 'Reset OK';
return notifyText;
}
if (data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.RESET_PWD) {
await cl.sendMsg(msg.chat.id, '❌ Reset password annullato.');
notifyText = 'Annullato';
return notifyText;
}
return 'OK';
}
module.exports = { handlePassword };

View File

@@ -1,65 +0,0 @@
const messages = require('../messages');
const { phase } = require('../constants');
const { safeExec } = require('../helpers');
const { sendMessage } = require('../api');
const {
getAdminTelegramUsers,
getManagersTelegramUsers,
} = require('./userQuery');
// locals: { idapp, username, nomeapp, text, ... }
const notifyToTelegram = safeExec(async (ph, locals = {}) => {
const idapp = String(locals.idapp || '');
let text = '';
const templ = messages.byPhase[ph] || messages.byPhase.GENERIC;
text = templ(locals);
// router di default: manda agli admin dell'app
const admins = await getAdminTelegramUsers(idapp);
for (const a of admins) {
if (a.telegram_id) await sendMessage(a.telegram_id, text);
}
});
const askConfirmationUser = safeExec(async (idapp, phaseCode, user) => {
const txt = messages.askConfirmationUser({
idapp,
username: user?.username,
nomeapp: user?.nomeapp,
});
if (user?.telegram_id) await sendMessage(user.telegram_id, txt);
});
// helper semplici
const sendNotifToAdmin = safeExec(async (idapp, title, body = '') => {
const admins = await getAdminTelegramUsers(String(idapp));
const txt = `📣 <b>${title}</b>\n${body}`;
for (const a of admins) {
if (a.telegram_id) await sendMessage(a.telegram_id, txt);
}
});
const sendNotifToManager = safeExec(async (idapp, title, body = '') => {
const managers = await getManagersTelegramUsers(String(idapp));
const txt = `📣 <b>${title}</b>\n${body}`;
for (const m of managers) {
if (m.telegram_id) await sendMessage(m.telegram_id, txt);
}
});
const sendNotifToAdminOrManager = safeExec(async (idapp, title, body = '', preferManagers = false) => {
if (preferManagers) {
return sendNotifToManager(idapp, title, body);
}
return sendNotifToAdmin(idapp, title, body);
});
module.exports = {
notifyToTelegram,
askConfirmationUser,
sendNotifToAdmin,
sendNotifToManager,
sendNotifToAdminOrManager,
phase, // re-export utile
};

View File

@@ -1,50 +0,0 @@
// telegram/handlers/registrationHandler.js
const shared_consts = require('../../tools/shared_nodejs');
const { User } = require('../../models/user');
const telegrambot = require('../telegram.bot.init'); // per sendMsgTelegramToTheAdminAllSites
const printf = require('util').format;
const InlineConferma = {
RISPOSTA_SI: 'SI_',
RISPOSTA_NO: 'NO_',
};
async function handleRegistration({ bot, cl, idapp, msg, data, user, userDest, username_action }) {
let notifyText = '';
// NO alla registrazione
if (data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.REGISTRATION) {
await cl.sendMsg(msg.chat.id, '❌ Registrazione annullata.');
notifyText = 'Annullata';
return notifyText;
}
// SI alla registrazione standard
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.REGISTRATION) {
// set verified (come da tuo codice)
await User.setVerifiedReg(idapp, data.username, data.userDest);
await cl.sendMsg(msg.chat.id, '✅ Registrazione confermata.');
await telegrambot.sendMsgTelegramToTheAdminAllSites(`🆕 Nuova registrazione confermata: ${data.userDest}`);
notifyText = 'Registrazione OK';
return notifyText;
}
// SI/NO alla REGISTRATION_FRIEND
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.REGISTRATION_FRIEND) {
await User.setVerifiedReg(idapp, data.username, data.userDest);
await cl.sendMsg(msg.chat.id, '🤝 Conferma amicizia completata!');
notifyText = 'Amicizia OK';
return notifyText;
}
if (data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.REGISTRATION_FRIEND) {
await cl.sendMsg(msg.chat.id, '🚫 Invito amicizia rifiutato.');
notifyText = 'Rifiutata';
return notifyText;
}
// deleghe future (es. REGISTRATION_TOZOOM gestita in zoomHandler)
return 'OK';
}
module.exports = { handleRegistration, InlineConferma };

View File

@@ -1,15 +0,0 @@
const { sendMessage, sendPhoto } = require('../api');
const { formatUser, safeExec } = require('../helpers');
const notifyUser = safeExec(async (user, text) => {
if (!user?.telegram_id) return;
const msg = `👋 Ciao ${formatUser(user)}\n${text}`;
await sendMessage(user.telegram_id, msg);
});
const sendUserPhoto = safeExec(async (user, photoUrl, caption) => {
if (!user?.telegram_id) return;
await sendPhoto(user.telegram_id, photoUrl, caption);
});
module.exports = { notifyUser, sendUserPhoto };

View File

@@ -1,32 +0,0 @@
const { User } = require('../../models/user');
async function getTelegramUsersByQuery(query = {}) {
return User.find({
...query,
telegram_id: { $exists: true, $ne: null },
}).lean();
}
async function getAdminTelegramUsers(idapp) {
return getTelegramUsersByQuery({ idapp, isAdmin: true });
}
async function getManagersTelegramUsers(idapp) {
return getTelegramUsersByQuery({ idapp, isManager: true });
}
async function getFacilitatoriTelegramUsers(idapp) {
return getTelegramUsersByQuery({ idapp, isFacilitatore: true });
}
async function getAllTelegramUsersByApp(idapp) {
return getTelegramUsersByQuery({ idapp });
}
module.exports = {
getTelegramUsersByQuery,
getAdminTelegramUsers,
getManagersTelegramUsers,
getFacilitatoriTelegramUsers,
getAllTelegramUsersByApp,
};

View File

@@ -1,27 +0,0 @@
// telegram/handlers/zoomHandler.js
const shared_consts = require('../../tools/shared_nodejs');
const { User } = require('../../models/user');
const InlineConferma = { RISPOSTA_SI: 'SI_', RISPOSTA_NO: 'NO_' };
async function handleZoom({ bot, cl, idapp, msg, data, user, userDest, username_action }) {
let notifyText = '';
if (data.action === InlineConferma.RISPOSTA_SI + shared_consts.CallFunz.REGISTRATION_TOZOOM) {
// nelle tue callback originale: conferma registrazione + messaggio
await User.setVerifiedReg(idapp, data.username, data.userDest);
await cl.sendMsg(msg.chat.id, '🟢 Accesso Zoom confermato!');
notifyText = 'Zoom OK';
return notifyText;
}
if (data.action === InlineConferma.RISPOSTA_NO + shared_consts.CallFunz.REGISTRATION_TOZOOM) {
await cl.sendMsg(msg.chat.id, '🚫 Accesso Zoom rifiutato.');
notifyText = 'Rifiutato';
return notifyText;
}
return 'OK';
}
module.exports = { handleZoom };

View File

@@ -1,31 +0,0 @@
function formatUser(user) {
const u = user || {};
const username = u.username || (u.profile && u.profile.username_telegram) || 'no_username';
return `${u.name || ''} ${u.surname || ''} (@${username})`.trim();
}
function safeExec(fn) {
return async (...args) => {
try {
return await fn(...args);
} catch (e) {
console.error('Telegram helper error:', e);
return null;
}
};
}
function ensureArray(x) {
if (!x) return [];
return Array.isArray(x) ? x : [x];
}
// utility semplice per evitare flood (se ti serve rate-limit: usa bottleneck)
async function eachSeries(arr, fn) {
for (const item of arr) {
// eslint-disable-next-line no-await-in-loop
await fn(item);
}
}
module.exports = { formatUser, safeExec, ensureArray, eachSeries };

View File

@@ -1,66 +0,0 @@
const messages = require('./messages');
// base api/handlers già creati in precedenza
const { sendMessage, sendPhoto } = require('./api');
const { sendToAdmins } = require('./handlers/adminHandler');
const { notifyUser, sendUserPhoto } = require('./handlers/userHandler');
const { sendNotification } = require('./handlers/notificationHandler');
const { reportError } = require('./handlers/errorHandler');
// NUOVI HANDLER aggiunti ora
const {
sendMsgTelegram,
sendMsgTelegramByIdTelegram,
sendPhotoTelegram,
} = require('./handlers/directHandler');
const {
sendMsgTelegramToTheAdminAllSites,
sendMsgTelegramByIdApp,
sendMsgTelegramToTheManagers,
sendMsgTelegramToTheAdmin,
sendMsgTelegramToTheGroup,
} = require('./handlers/multiAppHandler');
const {
notifyToTelegram,
askConfirmationUser,
sendNotifToAdmin,
sendNotifToManager,
sendNotifToAdminOrManager,
phase,
} = require('./handlers/phaseHandler');
module.exports = {
// messaggi/template
messages,
phase,
// API raw
sendMessage,
sendPhoto,
// generico
sendToAdmins,
notifyUser,
sendUserPhoto,
sendNotification,
reportError,
// EQUIVALENTI del vecchio file
sendMsgTelegram, // (user, text)
sendMsgTelegramByIdTelegram, // (telegramId, text)
sendPhotoTelegram, // (chatIdOrUser, photoUrl, caption)
sendMsgTelegramToTheAdminAllSites, // (text, alsoGroups?)
sendMsgTelegramByIdApp, // (idapp, text)
sendMsgTelegramToTheManagers, // (idapp, text)
sendMsgTelegramToTheAdmin, // (idapp, text)
sendMsgTelegramToTheGroup, // (chatId, text)
notifyToTelegram, // (phase, locals)
askConfirmationUser, // (idapp, phase, user)
sendNotifToAdmin, // (idapp, title, body)
sendNotifToManager, // (idapp, title, body)
sendNotifToAdminOrManager, // (idapp, title, body, preferManagers?)
};

View File

@@ -1,25 +0,0 @@
module.exports = {
// messaggi generici
serverStarted: (dbName) => `🚀 Il server <b>${dbName}</b> è stato avviato con successo.`,
userUnlocked: (user) => `⚠️ L'utente <b>${user.username}</b> (${user.name} ${user.surname}) è stato sbloccato.`,
errorOccurred: (context, err) =>
`❌ Errore in <b>${context}</b>\n<code>${(err && err.message) || err}</code>`,
notifyAdmin: (msg) => `📢 Notifica Admin:\n${msg}`,
// fasi logiche
byPhase: {
REGISTRATION: (locals = {}) =>
`🆕 Nuova registrazione su <b>${locals.nomeapp || 'App'}</b>\nUtente: <b>${locals.username}</b>`,
REGISTRATION_CONFIRMED: (locals = {}) =>
`✅ Registrazione confermata su <b>${locals.nomeapp || 'App'}</b> da <b>${locals.username}</b>`,
RESET_PWD: (locals = {}) =>
`🔁 Reset password richiesto per <b>${locals.username}</b>`,
NOTIFICATION: (locals = {}) =>
`🔔 Notifica: ${locals.text || ''}`,
GENERIC: (locals = {}) =>
`${locals.text || ''}`,
},
askConfirmationUser: (locals = {}) =>
`👋 Ciao <b>${locals.username}</b>!\nConfermi l'operazione su <b>${locals.nomeapp || 'App'}</b>?`,
};

View File

@@ -1 +0,0 @@
http://localhost:8084/signup/paoloar77/SuryaArena/5356627050

Some files were not shown because too many files have changed in this diff Show More