Sistemato magazzino

This commit is contained in:
Lorenzo Sanesi
2026-08-01 03:12:23 +02:00
parent de921abcd6
commit 0fdf395733
128 changed files with 9307 additions and 3076 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ RUN npm run build
EXPOSE 8083
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed && node dist/server.js"]
+4
View File
@@ -12,6 +12,7 @@
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"test": "jest --runInBand --passWithNoTests",
"test:watch": "jest --watch --runInBand"
},
@@ -38,5 +39,8 @@
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
@@ -4,6 +4,18 @@ CREATE TYPE "stato_materiale" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_magazzino_voce" AS ENUM ('buono', 'da_riparare', 'mancante');
-- CreateEnum
CREATE TYPE "stato_lista" AS ENUM ('bozza', 'privato', 'gruppo', 'pubblico');
-- CreateEnum
CREATE TYPE "stato_moderazione_lista" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_categoria" AS ENUM ('confermata', 'da_approvare');
-- CreateEnum
CREATE TYPE "tipo_notifica" AS ENUM ('MATERIALE_PROPOSTO', 'CATEGORIA_PROPOSTA', 'TIPO_EVENTO_PROPOSTO', 'LISTA_PROPOSTA');
-- CreateTable
CREATE TABLE "materiale" (
"id" TEXT NOT NULL,
@@ -21,35 +33,35 @@ CREATE TABLE "materiale" (
CREATE TABLE "tipo_evento" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
"creato_da_org_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tipo_evento_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello" (
CREATE TABLE "categoria" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"tipo_evento_id" TEXT NOT NULL,
"pubblica" BOOLEAN NOT NULL DEFAULT true,
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
"creato_da_org_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lista_modello_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello_voce" (
"lista_modello_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"quantita" INTEGER NOT NULL,
CONSTRAINT "lista_modello_voce_pkey" PRIMARY KEY ("lista_modello_id","materiale_id")
CONSTRAINT "categoria_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"org_id" TEXT,
"creata_da_user_id" TEXT,
"stato" "stato_lista" NOT NULL DEFAULT 'bozza',
"creata_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"stato_moderazione" "stato_moderazione_lista",
"tipo_evento_id" TEXT,
"parent_id" TEXT,
CONSTRAINT "lista_pkey" PRIMARY KEY ("id")
);
@@ -63,6 +75,14 @@ CREATE TABLE "lista_voce" (
CONSTRAINT "lista_voce_pkey" PRIMARY KEY ("lista_id","materiale_id")
);
-- CreateTable
CREATE TABLE "lista_sotto_lista" (
"lista_id" TEXT NOT NULL,
"sotto_lista_id" TEXT NOT NULL,
CONSTRAINT "lista_sotto_lista_pkey" PRIMARY KEY ("lista_id","sotto_lista_id")
);
-- CreateTable
CREATE TABLE "magazzino_voce" (
"id" TEXT NOT NULL,
@@ -72,10 +92,22 @@ CREATE TABLE "magazzino_voce" (
"stato" "stato_magazzino_voce" NOT NULL,
"posizione" TEXT,
"note" TEXT,
"gruppo_id" TEXT,
CONSTRAINT "magazzino_voce_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "gruppo_magazzino" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"parent_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gruppo_magazzino_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evento" (
"id" TEXT NOT NULL,
@@ -97,14 +129,41 @@ CREATE TABLE "evento_check" (
CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id")
);
-- AddForeignKey
ALTER TABLE "lista_modello" ADD CONSTRAINT "lista_modello_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "notifica" (
"id" SERIAL NOT NULL,
"tipo" "tipo_notifica" NOT NULL,
"messaggio" TEXT NOT NULL,
"link" TEXT,
"letta" BOOLEAN NOT NULL DEFAULT false,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifica_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "lista_stato_stato_moderazione_idx" ON "lista"("stato", "stato_moderazione");
-- CreateIndex
CREATE INDEX "lista_tipo_evento_id_idx" ON "lista"("tipo_evento_id");
-- CreateIndex
CREATE INDEX "lista_parent_id_idx" ON "lista"("parent_id");
-- CreateIndex
CREATE INDEX "gruppo_magazzino_org_id_idx" ON "gruppo_magazzino"("org_id");
-- CreateIndex
CREATE INDEX "gruppo_magazzino_parent_id_idx" ON "gruppo_magazzino"("parent_id");
-- CreateIndex
CREATE INDEX "magazzino_voce_gruppo_id_idx" ON "magazzino_voce"("gruppo_id");
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_lista_modello_id_fkey" FOREIGN KEY ("lista_modello_id") REFERENCES "lista_modello"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "lista" ADD CONSTRAINT "lista_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "lista" ADD CONSTRAINT "lista_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "lista"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -112,9 +171,21 @@ ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY (
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_sotto_lista_id_fkey" FOREIGN KEY ("sotto_lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_gruppo_id_fkey" FOREIGN KEY ("gruppo_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "gruppo_magazzino" ADD CONSTRAINT "gruppo_magazzino_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+143 -36
View File
@@ -23,6 +23,68 @@ enum StatoMagazzinoVoce {
@@map("stato_magazzino_voce")
}
enum StatoLista {
bozza
privato
gruppo
pubblico
@@map("stato_lista")
}
enum StatoModerazioneLista {
proposto
approvato
rifiutato
@@map("stato_moderazione_lista")
}
enum StatoCategoria {
confermata
da_approvare
@@map("stato_categoria")
}
enum TipoNotifica {
MATERIALE_PROPOSTO
CATEGORIA_PROPOSTA
TIPO_EVENTO_PROPOSTO
LISTA_PROPOSTA
@@map("tipo_notifica")
}
// Coda di notifiche di moderazione (nuove proposte di materiale/categoria/tipo
// evento/lista in attesa di revisione): visibile solo a chi ha ruolo 'moderatore',
// non ci sono ancora notifiche personali nel dominio magazzino.
model Notifica {
id Int @id @default(autoincrement())
tipo TipoNotifica
messaggio String
link String?
letta Boolean @default(false)
dataCreazione DateTime @default(now()) @map("data_creazione")
@@map("notifica")
}
// Tassonomia delle categorie di materiale, gestita dal moderatore (CRUD diretto,
// sempre creata "confermata") con un flusso di proposta aperto a qualsiasi org
// autenticata (crea in "da_approvare", poi approvata/rifiutata dal moderatore).
// Materiale.categoria resta per ora un campo stringa libero, non collegato a questa
// tabella: introdurla come FK è un passo successivo, fuori scope qui.
model Categoria {
id String @id @default(uuid())
nome String
stato StatoCategoria @default(confermata)
creatoDaOrgId String? @map("creato_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
@@map("categoria")
}
model Materiale {
id String @id @default(uuid())
nome String
@@ -32,56 +94,67 @@ model Materiale {
propostoDaOrgId String @map("proposto_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
listaModelloVoci ListaModelloVoce[]
listaVoci ListaVoce[]
magazzinoVoci MagazzinoVoce[]
eventoCheck EventoCheck[]
listaVoci ListaVoce[]
magazzinoVoci MagazzinoVoce[]
eventoCheck EventoCheck[]
@@map("materiale")
}
model TipoEvento {
id String @id @default(uuid())
nome String
id String @id @default(uuid())
nome String
// Stessa semantica/enum di Categoria.stato: un tipo evento creato dal moderatore
// nasce 'confermata', uno proposto da un'organizzazione nasce 'da_approvare'.
stato StatoCategoria @default(confermata)
creatoDaOrgId String? @map("creato_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
listeModello ListaModello[]
liste Lista[]
@@map("tipo_evento")
}
model ListaModello {
id String @id @default(uuid())
nome String
tipoEventoId String @map("tipo_evento_id")
pubblica Boolean @default(true)
tipoEvento TipoEvento @relation(fields: [tipoEventoId], references: [id])
voci ListaModelloVoce[]
@@map("lista_modello")
}
model ListaModelloVoce {
listaModelloId String @map("lista_modello_id")
materialeId String @map("materiale_id")
quantita Int
listaModello ListaModello @relation(fields: [listaModelloId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
@@id([listaModelloId, materialeId])
@@map("lista_modello_voce")
}
model Lista {
id String @id @default(uuid())
nome String
orgId String @map("org_id")
creataIl DateTime @default(now()) @map("creata_il")
id String @id @default(uuid())
nome String
// Valorizzato solo per le liste in stato 'gruppo' (condivise con l'organizzazione):
// le liste 'bozza'/'privato'/'pubblico' sono personali e non hanno alcuna org, anche
// per un utente che ha un'organizzazione attiva sul token al momento della creazione.
orgId String? @map("org_id")
// Nullable perché le liste create prima di questo campo non hanno un autore noto;
// da qui in poi è sempre valorizzato da req.auth.userId alla creazione.
creataDaUserId String? @map("creata_da_user_id")
stato StatoLista @default(bozza)
creataIl DateTime @default(now()) @map("creata_il")
// Moderazione, ortogonale a `stato`: nullo per bozza/privato/gruppo, valorizzato
// solo quando stato = pubblico (proposto/approvato/rifiutato, vedi liste.service.ts).
statoModerazione StatoModerazioneLista? @map("stato_moderazione")
// Ex campo obbligatorio di ListaModello, ora opzionale su tutte le liste: serve solo
// a filtrare il catalogo pubblico per tipo evento.
tipoEventoId String? @map("tipo_evento_id")
tipoEvento TipoEvento? @relation(fields: [tipoEventoId], references: [id])
// Lineage self-referenziato: da quale lista pubblica approvata è stata forkata questa
// (fork = "usa come base"). Best-effort/storico: cancellare l'origine non deve
// bloccare né trascinare i figli, quindi SetNull.
parentId String? @map("parent_id")
parent Lista? @relation("ListaParent", fields: [parentId], references: [id], onDelete: SetNull)
figlie Lista[] @relation("ListaParent")
voci ListaVoce[]
eventi Evento[]
// Sotto-liste agganciate a questa lista personale: un solo livello di nesting
// (una sotto-lista non può avere a sua volta sotto-liste), validato in liste.service.ts.
sottoListe ListaSottoLista[] @relation("SottoListePadre")
usataComeSottoListaIn ListaSottoLista[] @relation("SottoListaFiglia")
@@index([stato, statoModerazione])
@@index([tipoEventoId])
@@index([parentId])
@@map("lista")
}
@@ -97,6 +170,35 @@ model ListaVoce {
@@map("lista_voce")
}
model ListaSottoLista {
listaId String @map("lista_id")
sottoListaId String @map("sotto_lista_id")
lista Lista @relation("SottoListePadre", fields: [listaId], references: [id])
sottoLista Lista @relation("SottoListaFiglia", fields: [sottoListaId], references: [id])
@@id([listaId, sottoListaId])
@@map("lista_sotto_lista")
}
model GruppoMagazzino {
id String @id @default(uuid())
orgId String @map("org_id")
nome String
// Un solo livello di nesting (uno scaffale contiene kit, un kit non contiene
// a sua volta altri gruppi), validato in gruppiMagazzino.service.ts.
parentId String? @map("parent_id")
creatoIl DateTime @default(now()) @map("creato_il")
parent GruppoMagazzino? @relation("GruppoMagazzinoParent", fields: [parentId], references: [id], onDelete: SetNull)
figli GruppoMagazzino[] @relation("GruppoMagazzinoParent")
voci MagazzinoVoce[]
@@index([orgId])
@@index([parentId])
@@map("gruppo_magazzino")
}
model MagazzinoVoce {
id String @id @default(uuid())
orgId String @map("org_id")
@@ -105,9 +207,14 @@ model MagazzinoVoce {
stato StatoMagazzinoVoce
posizione String?
note String?
// Nullo finché la giacenza non viene organizzata in un gruppo (scaffale/kit):
// eliminare il gruppo non elimina la voce, la riporta solo tra le "non organizzate".
gruppoId String? @map("gruppo_id")
materiale Materiale @relation(fields: [materialeId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
gruppo GruppoMagazzino? @relation(fields: [gruppoId], references: [id], onDelete: SetNull)
@@index([gruppoId])
@@map("magazzino_voce")
}
+204
View File
@@ -0,0 +1,204 @@
import { PrismaClient, StatoMateriale } from "@prisma/client";
const prisma = new PrismaClient();
// Placeholder di orgId per il materiale di catalogo seedato manualmente (non riconducibile
// a nessuna organizzazione reale), analogo a UTENTE_MODIFICA="MANUALE" nel seed di attivita-be.
const ORG_SISTEMA = "SISTEMA";
async function main() {
const materiali = [
{ id: "materiale-corda", nome: "Corda", categoria: "Attrezzatura", unitaMisura: "pz" },
{ id: "materiale-torcia", nome: "Torcia", categoria: "Attrezzatura", unitaMisura: "pz" },
{
id: "materiale-kit-primo-soccorso",
nome: "Kit primo soccorso",
categoria: "Sicurezza",
unitaMisura: "kit",
},
{ id: "materiale-tenda", nome: "Tenda", categoria: "Campeggio", unitaMisura: "pz" },
{
id: "materiale-fornello-campo",
nome: "Fornello da campo",
categoria: "Campeggio",
unitaMisura: "pz",
},
{
id: "materiale-zaino",
nome: "Zaino",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{
id: "materiale-sacco-a-pelo",
nome: "Sacco a pelo",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{
id: "materiale-borraccia",
nome: "Borraccia",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{ id: "materiale-garze", nome: "Garze", categoria: "Sicurezza", unitaMisura: "pz" },
{ id: "materiale-cerotti", nome: "Cerotti", categoria: "Sicurezza", unitaMisura: "pz" },
{
id: "materiale-disinfettante",
nome: "Disinfettante",
categoria: "Sicurezza",
unitaMisura: "flacone",
},
];
await Promise.all(
materiali.map((materiale) =>
prisma.materiale.upsert({
where: { id: materiale.id },
update: {
...materiale,
stato: StatoMateriale.approvato,
propostoDaOrgId: ORG_SISTEMA,
},
create: {
...materiale,
stato: StatoMateriale.approvato,
propostoDaOrgId: ORG_SISTEMA,
},
})
)
);
const tipiEvento = [
{ id: "tipo-evento-uscita-giorno", nome: "Uscita di un giorno" },
{ id: "tipo-evento-campo-estivo", nome: "Campo estivo" },
{ id: "tipo-evento-riunione-settimanale", nome: "Riunione settimanale" },
];
await Promise.all(
tipiEvento.map((tipoEvento) =>
prisma.tipoEvento.upsert({
where: { id: tipoEvento.id },
update: { nome: tipoEvento.nome },
create: tipoEvento,
})
)
);
// Le ex "liste modello" sono ora liste unificate: pubbliche, già approvate dalla
// moderazione, senza autore/org (scritte direttamente dal seed, come farebbe un
// moderatore in bypass della coda di proposte).
const listeModello = [
{
id: "lista-modello-uscita-giorno",
nome: "Materiale base uscita giornaliera",
tipoEventoId: "tipo-evento-uscita-giorno",
},
{
id: "lista-modello-campo-estivo",
nome: "Materiale base campo estivo",
tipoEventoId: "tipo-evento-campo-estivo",
},
// Sotto-lista "foglia" (nessuna sotto-lista propria): allegata come sotto-lista sia a
// "uscita di un giorno" sia a "campo estivo", al posto del vecchio materiale generico
// "materiale-kit-primo-soccorso", per verificare il nesting a un livello.
{
id: "lista-modello-kit-primo-soccorso",
nome: "Kit di pronto soccorso",
tipoEventoId: "tipo-evento-uscita-giorno",
},
];
for (const listaModello of listeModello) {
await prisma.lista.upsert({
where: { id: listaModello.id },
update: { ...listaModello, stato: "pubblico", statoModerazione: "approvato" },
create: {
...listaModello,
orgId: null,
creataDaUserId: null,
stato: "pubblico",
statoModerazione: "approvato",
},
});
}
const listeModelloVoci = [
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-zaino", quantita: 1 },
{
listaId: "lista-modello-uscita-giorno",
materialeId: "materiale-borraccia",
quantita: 1,
},
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-torcia", quantita: 1 },
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-tenda", quantita: 1 },
{
listaId: "lista-modello-campo-estivo",
materialeId: "materiale-sacco-a-pelo",
quantita: 1,
},
{
listaId: "lista-modello-campo-estivo",
materialeId: "materiale-fornello-campo",
quantita: 1,
},
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-corda", quantita: 2 },
// Voci della sotto-lista "Kit di pronto soccorso" (foglia, allegata sotto a due liste diverse).
{ listaId: "lista-modello-kit-primo-soccorso", materialeId: "materiale-garze", quantita: 3 },
{
listaId: "lista-modello-kit-primo-soccorso",
materialeId: "materiale-cerotti",
quantita: 10,
},
{
listaId: "lista-modello-kit-primo-soccorso",
materialeId: "materiale-disinfettante",
quantita: 1,
},
];
// Rimuove il vecchio materiale generico "kit primo soccorso" piazzato direttamente nelle due
// liste padre: da questo seed in poi il kit è la sotto-lista dedicata, non più una singola voce.
await prisma.listaVoce.deleteMany({
where: {
materialeId: "materiale-kit-primo-soccorso",
listaId: { in: ["lista-modello-uscita-giorno", "lista-modello-campo-estivo"] },
},
});
for (const voce of listeModelloVoci) {
await prisma.listaVoce.upsert({
where: {
listaId_materialeId: {
listaId: voce.listaId,
materialeId: voce.materialeId,
},
},
update: { quantita: voce.quantita },
create: voce,
});
}
const sottoListe = [
{ listaId: "lista-modello-uscita-giorno", sottoListaId: "lista-modello-kit-primo-soccorso" },
{ listaId: "lista-modello-campo-estivo", sottoListaId: "lista-modello-kit-primo-soccorso" },
];
for (const sottoLista of sottoListe) {
await prisma.listaSottoLista.upsert({
where: {
listaId_sottoListaId: {
listaId: sottoLista.listaId,
sottoListaId: sottoLista.sottoListaId,
},
},
update: {},
create: sottoLista,
});
}
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+12 -3
View File
@@ -2,25 +2,34 @@ import express from 'express';
import cors from 'cors';
import { healthRouter } from './routes/health.routes';
import { materialiRouter } from './routes/materiali.routes';
import { categorieRouter } from './routes/categorie.routes';
import { tipiEventoRouter } from './routes/tipiEvento.routes';
import { listeModelloRouter } from './routes/listeModello.routes';
import { listeRouter } from './routes/liste.routes';
import { magazzinoRouter } from './routes/magazzino.routes';
import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes';
import { eventiRouter } from './routes/eventi.routes';
import { autocompleteRouter } from './routes/autocomplete.routes';
import { notificheRouter } from './routes/notifiche.routes';
import { errorHandler } from './middleware/errorHandler';
export const app = express();
app.use(express.json());
// strict: false perché l'endpoint di autocomplete (vedi autocomplete.controller.ts) accetta un
// body JSON che è una stringa "nuda" (es. `"gioco"`), non solo oggetti/array: con lo strict mode
// di default body-parser la rifiuterebbe.
app.use(express.json({ strict: false }));
app.use(cors());
app.use(healthRouter);
app.use(materialiRouter);
app.use(categorieRouter);
app.use(tipiEventoRouter);
app.use(listeModelloRouter);
app.use(listeRouter);
app.use(magazzinoRouter);
app.use(gruppiMagazzinoRouter);
app.use(eventiRouter);
app.use(autocompleteRouter);
app.use(notificheRouter);
app.use((req, res) => {
res.status(404).json({ message: 'not found' });
@@ -0,0 +1,16 @@
import { Request, Response, NextFunction } from 'express';
import { search } from '../services/autocomplete.service';
function extractKeyword(body: unknown): string | undefined {
return typeof body === 'string' ? body : undefined;
}
export async function postSearch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const keyword = extractKeyword(req.body);
const gruppi = await search(keyword);
res.status(200).json(gruppi);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,99 @@
import { Request, Response, NextFunction } from 'express';
import {
aggiornaCategoria,
approvaCategoria,
creaCategoria,
eliminaCategoria,
listCategorie,
listCategorieConfermate,
proponiCategoria,
rifiutaCategoria,
} from '../services/categorie.service';
import { HttpError } from '../errors';
function parseNome(body: { nome?: unknown }): string {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
return body.nome;
}
export async function getCategorie(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const categorie = await listCategorie();
res.status(200).json(categorie);
} catch (err) {
next(err);
}
}
// Query pubblica, usata dall'autocomplete: mostra solo le categorie confermate,
// filtrabili per sottostringa del nome (case-insensitive).
export async function getCategoriePubbliche(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { nome } = req.query;
const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined;
const categorie = await listCategorieConfermate(filtroNome);
res.status(200).json(categorie);
} catch (err) {
next(err);
}
}
export async function postCategoria(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const categoria = await creaCategoria(nome);
res.status(201).json(categoria);
} catch (err) {
next(err);
}
}
export async function putCategoria(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const categoria = await aggiornaCategoria(req.params.id, nome);
res.status(200).json(categoria);
} catch (err) {
next(err);
}
}
export async function deleteCategoria(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaCategoria(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function postCategoriaProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const categoria = await proponiCategoria({ nome, orgId: req.auth!.orgId! });
res.status(201).json(categoria);
} catch (err) {
next(err);
}
}
export async function postCategoriaApprova(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const categoria = await approvaCategoria(req.params.id);
res.status(200).json(categoria);
} catch (err) {
next(err);
}
}
export async function postCategoriaRifiuta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await rifiutaCategoria(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,88 @@
import { Request, Response, NextFunction } from 'express';
import {
AggiornaGruppoInput,
CreaGruppoInput,
aggiornaGruppo,
creaGruppo,
eliminaGruppo,
listGruppiPerOrg,
} from '../services/gruppiMagazzino.service';
import { HttpError } from '../errors';
interface PostGruppoBody {
nome?: unknown;
parentId?: unknown;
}
function parseCreateBody(body: PostGruppoBody): CreaGruppoInput {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (body.parentId !== undefined && body.parentId !== null && typeof body.parentId !== 'string') {
throw new HttpError(400, "Il campo 'parentId', se presente, deve essere una stringa o null");
}
return {
nome: body.nome,
parentId: body.parentId as string | null | undefined,
};
}
interface PutGruppoBody {
nome?: unknown;
parentId?: unknown;
}
function parseUpdateBody(body: PutGruppoBody): AggiornaGruppoInput {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
if (body.parentId !== undefined && body.parentId !== null && typeof body.parentId !== 'string') {
throw new HttpError(400, "Il campo 'parentId', se presente, deve essere una stringa o null");
}
return {
nome: body.nome as string | undefined,
parentId: body.parentId as string | null | undefined,
};
}
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
export async function getGruppiMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const gruppi = await listGruppiPerOrg(req.auth!.orgId!);
res.status(200).json(gruppi);
} catch (err) {
next(err);
}
}
export async function postGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const gruppo = await creaGruppo(req.auth!.orgId!, input);
res.status(201).json(gruppo);
} catch (err) {
next(err);
}
}
export async function putGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const gruppo = await aggiornaGruppo(req.params.id, req.auth!.orgId!, input);
res.status(200).json(gruppo);
} catch (err) {
next(err);
}
}
export async function deleteGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaGruppo(req.params.id, req.auth!.orgId!);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -1,14 +1,24 @@
import { StatoLista } from '@prisma/client';
import { Request, Response, NextFunction } from 'express';
import { ListaVoceInput } from '../repositories/liste.repository';
import {
aggiornaLista,
creaListaVuota,
creaLista,
DecisioneProposta,
decidiProposta,
eliminaLista,
forkListaDaModello,
listListePerOrg,
forkListaDaLista,
listListePubbliche,
listMieListe,
listProposte,
SottoListeInput,
trovaListaPubblicaPerId,
Viewer,
} from '../services/liste.service';
import { HttpError } from '../errors';
const STATI_LISTA: StatoLista[] = ['bozza', 'privato', 'gruppo', 'pubblico'];
interface VoceBody {
materialeId?: unknown;
quantita?: unknown;
@@ -30,6 +40,42 @@ function parseVoci(voci: unknown): ListaVoceInput[] {
});
}
function parseStringArray(value: unknown, campo: string): string[] {
if (!Array.isArray(value)) {
throw new HttpError(400, `Il campo '${campo}' deve essere un array`);
}
return value.map((id: unknown) => {
if (typeof id !== 'string' || id.trim().length === 0) {
throw new HttpError(400, `Ogni elemento di '${campo}' deve essere una stringa non vuota`);
}
return id;
});
}
interface SottoListeBody {
sottoListeIds?: unknown;
sottoListeModelloIds?: unknown;
}
function parseSottoListe(body: SottoListeBody): SottoListeInput {
return {
sottoListeIds: body.sottoListeIds !== undefined ? parseStringArray(body.sottoListeIds, 'sottoListeIds') : [],
sottoListeModelloIds:
body.sottoListeModelloIds !== undefined
? parseStringArray(body.sottoListeModelloIds, 'sottoListeModelloIds')
: [],
};
}
// In update, sottoListe resta undefined (nessuna modifica) se il client non manda
// nessuno dei due campi — stessa semantica di update parziale già usata per 'voci'.
function parseSottoListeOpzionale(body: SottoListeBody): SottoListeInput | undefined {
if (body.sottoListeIds === undefined && body.sottoListeModelloIds === undefined) {
return undefined;
}
return parseSottoListe(body);
}
function parseNome(body: { nome?: unknown }): string {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
@@ -37,30 +83,126 @@ function parseNome(body: { nome?: unknown }): string {
return body.nome;
}
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
// Se assente, la lista nasce come 'bozza' (stato iniziale di default nel wizard di creazione).
function parseStato(body: { stato?: unknown }): StatoLista {
if (body.stato === undefined) {
return 'bozza';
}
if (typeof body.stato !== 'string' || !STATI_LISTA.includes(body.stato as StatoLista)) {
throw new HttpError(400, `Il campo 'stato', se presente, deve essere uno tra: ${STATI_LISTA.join(', ')}`);
}
return body.stato as StatoLista;
}
function parseStatoOpzionale(body: { stato?: unknown }): StatoLista | undefined {
if (body.stato === undefined) {
return undefined;
}
if (typeof body.stato !== 'string' || !STATI_LISTA.includes(body.stato as StatoLista)) {
throw new HttpError(400, `Il campo 'stato', se presente, deve essere uno tra: ${STATI_LISTA.join(', ')}`);
}
return body.stato as StatoLista;
}
function parseTipoEventoIdOpzionale(body: { tipoEventoId?: unknown }): string | undefined {
if (body.tipoEventoId === undefined) {
return undefined;
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota");
}
return body.tipoEventoId;
}
// req.auth.orgId può essere null (utente senza organizzazione attiva): le liste
// personali (bozza/privato/pubblico) non ne richiedono una, solo quelle 'gruppo'.
function viewerOf(req: Request): Viewer {
return { userId: req.auth!.userId, orgId: req.auth!.orgId ?? null, roles: req.auth!.roles };
}
// "Le mie liste": le proprie (qualunque stato) + le liste 'gruppo' della propria org,
// create da chiunque nell'org.
export async function getListe(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const liste = await listListePerOrg(req.auth!.orgId!);
const liste = await listMieListe(viewerOf(req));
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
// Nessuna autenticazione richiesta: le liste 'pubblico' già approvate sono visibili a
// chiunque. Filtro opzionale per tipo evento, mirror del vecchio GET /liste-modello.
export async function getListePubbliche(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tipoEventoId } = req.query;
const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined;
const liste = await listListePubbliche(filtro);
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
// Dettaglio pubblico per singolo id, mirror del vecchio GET /liste-modello/:id.
export async function getListaPubblicaById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const lista = await trovaListaPubblicaPerId(req.params.id);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
// Coda di moderazione: solo admin/moderatore (vedi routes).
export async function getListeProposte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const proposte = await listProposte();
res.status(200).json(proposte);
} catch (err) {
next(err);
}
}
interface PatchPropostaBody {
decisione?: unknown;
}
function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta {
if (body.decisione !== 'approvato' && body.decisione !== 'rifiutato') {
throw new HttpError(400, "Il campo 'decisione' deve valere 'approvato' o 'rifiutato'");
}
return body.decisione;
}
export async function patchListaProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const decisione = parseDecisioneBody(req.body ?? {});
const lista = await decidiProposta(req.params.id, decisione);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
export async function postLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const lista = await creaListaVuota(req.auth!.orgId!, nome);
const body = req.body ?? {};
const nome = parseNome(body);
const stato = parseStato(body);
const voci = body.voci !== undefined ? parseVoci(body.voci) : [];
const sottoListe = parseSottoListe(body);
const tipoEventoId = parseTipoEventoIdOpzionale(body);
const lista = await creaLista(nome, stato, voci, sottoListe, viewerOf(req), tipoEventoId);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
export async function postListaDaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
export async function postListaDaFork(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const lista = await forkListaDaModello(req.auth!.orgId!, req.params.listaModelloId);
const lista = await forkListaDaLista(req.params.id, viewerOf(req));
res.status(201).json(lista);
} catch (err) {
next(err);
@@ -69,24 +211,56 @@ export async function postListaDaModello(req: Request, res: Response, next: Next
interface PutListaBody {
nome?: unknown;
stato?: unknown;
voci?: unknown;
sottoListeIds?: unknown;
sottoListeModelloIds?: unknown;
tipoEventoId?: unknown;
}
function parseUpdateBody(body: PutListaBody): { nome?: string; voci?: ListaVoceInput[] } {
// A differenza di parseTipoEventoIdOpzionale (solo creazione, dove 'non presente' è
// l'unico modo per dire 'nessun tipo evento'), in update body.tipoEventoId: null è un
// valore esplicito ('rimuovi il tipo evento associato'), distinto da undefined ('non
// toccare il campo').
function parseTipoEventoIdUpdate(body: { tipoEventoId?: unknown }): string | null | undefined {
if (body.tipoEventoId === undefined) {
return undefined;
}
if (body.tipoEventoId === null) {
return null;
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota o null");
}
return body.tipoEventoId;
}
function parseUpdateBody(
body: PutListaBody,
): {
nome?: string;
stato?: StatoLista;
voci?: ListaVoceInput[];
sottoListe?: SottoListeInput;
tipoEventoId?: string | null;
} {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
return {
nome: body.nome as string | undefined,
stato: parseStatoOpzionale(body),
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
sottoListe: parseSottoListeOpzionale(body),
tipoEventoId: parseTipoEventoIdUpdate(body),
};
}
export async function putLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const lista = await aggiornaLista(req.params.id, req.auth!.orgId!, input);
const lista = await aggiornaLista(req.params.id, input, viewerOf(req));
res.status(200).json(lista);
} catch (err) {
next(err);
@@ -95,7 +269,7 @@ export async function putLista(req: Request, res: Response, next: NextFunction):
export async function deleteLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaLista(req.params.id, req.auth!.orgId!);
await eliminaLista(req.params.id, viewerOf(req));
res.status(204).send();
} catch (err) {
next(err);
@@ -1,111 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { ListaModelloVoceInput } from '../repositories/listeModello.repository';
import {
aggiornaListaModello,
creaListaModello,
eliminaListaModello,
listListeModello,
} from '../services/listeModello.service';
import { HttpError } from '../errors';
interface VoceBody {
materialeId?: unknown;
quantita?: unknown;
}
function parseVoci(voci: unknown): ListaModelloVoceInput[] {
if (!Array.isArray(voci)) {
throw new HttpError(400, "Il campo 'voci' deve essere un array");
}
return voci.map((voce: VoceBody) => {
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
}
if (typeof voce.quantita !== 'number' || !Number.isInteger(voce.quantita) || voce.quantita <= 0) {
throw new HttpError(400, "Ogni voce deve avere una 'quantita' intera positiva");
}
return { materialeId: voce.materialeId, quantita: voce.quantita };
});
}
interface PostListaModelloBody {
nome?: unknown;
tipoEventoId?: unknown;
voci?: unknown;
}
function parseCreateBody(body: PostListaModelloBody): { nome: string; tipoEventoId: string; voci: ListaModelloVoceInput[] } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId' è obbligatorio ed è una stringa non vuota");
}
return { nome: body.nome, tipoEventoId: body.tipoEventoId, voci: parseVoci(body.voci ?? []) };
}
interface PutListaModelloBody {
nome?: unknown;
tipoEventoId?: unknown;
voci?: unknown;
}
function parseUpdateBody(body: PutListaModelloBody): { nome?: string; tipoEventoId?: string; voci?: ListaModelloVoceInput[] } {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
if (body.tipoEventoId !== undefined && (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0)) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota");
}
return {
nome: body.nome as string | undefined,
tipoEventoId: body.tipoEventoId as string | undefined,
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
};
}
// Query pubblica: unico filtro accettato è "tipoEventoId". "pubblica" non è mai
// un parametro esposto al client: le liste modello sono per definizione pubbliche.
export async function getListeModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tipoEventoId } = req.query;
const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined;
const liste = await listListeModello(filtro);
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
export async function postListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const lista = await creaListaModello(input);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
export async function putListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const lista = await aggiornaListaModello(req.params.id, input);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
export async function deleteListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaListaModello(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -15,6 +15,7 @@ interface PostVoceBody {
stato?: unknown;
posizione?: unknown;
note?: unknown;
gruppoId?: unknown;
}
function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
@@ -33,6 +34,9 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
if (body.note !== undefined && typeof body.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa");
}
if (body.gruppoId !== undefined && body.gruppoId !== null && typeof body.gruppoId !== 'string') {
throw new HttpError(400, "Il campo 'gruppoId', se presente, deve essere una stringa o null");
}
return {
materialeId: body.materialeId,
@@ -40,6 +44,7 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
stato: body.stato,
posizione: body.posizione as string | undefined,
note: body.note as string | undefined,
gruppoId: body.gruppoId as string | null | undefined,
};
}
@@ -49,6 +54,7 @@ interface PutVoceBody {
stato?: unknown;
posizione?: unknown;
note?: unknown;
gruppoId?: unknown;
}
function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
@@ -70,6 +76,9 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
if (body.note !== undefined && body.note !== null && typeof body.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null");
}
if (body.gruppoId !== undefined && body.gruppoId !== null && typeof body.gruppoId !== 'string') {
throw new HttpError(400, "Il campo 'gruppoId', se presente, deve essere una stringa o null");
}
return {
materialeId: body.materialeId as string | undefined,
@@ -77,6 +86,7 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
stato: body.stato as StatoMagazzinoVoce | undefined,
posizione: body.posizione as string | null | undefined,
note: body.note as string | null | undefined,
gruppoId: body.gruppoId as string | null | undefined,
};
}
@@ -1,20 +1,23 @@
import { Request, Response, NextFunction } from 'express';
import {
DecisioneProposta,
aggiornaMateriale,
creaMateriale,
decidiProposta,
eliminaMateriale,
listMaterialiApprovati,
listProposte,
proponiMateriale,
} from '../services/materiali.service';
import { HttpError } from '../errors';
interface PostPropostaBody {
interface MaterialeBody {
nome?: unknown;
categoria?: unknown;
unitaMisura?: unknown;
}
function parsePropostaBody(body: PostPropostaBody): { nome: string; categoria: string; unitaMisura: string } {
function parseMaterialeBody(body: MaterialeBody): { nome: string; categoria: string; unitaMisura: string } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
@@ -39,15 +42,16 @@ function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta {
return body.decisione;
}
// Query pubblica: unico filtro accettato dal client è "categoria". Lo stato
// non è mai un parametro esposto: il catalogo pubblico mostra solo i
// materiali con stato "approvato".
// Query pubblica: filtri accettati dal client sono "categoria" e "nome" (ricerca
// per sottostringa, case-insensitive, usata dall'autocomplete). Lo stato non è mai
// un parametro esposto: il catalogo pubblico mostra solo i materiali con stato "approvato".
export async function getMaterialiPubblici(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { categoria } = req.query;
const filtro = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined;
const { categoria, nome } = req.query;
const filtroCategoria = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined;
const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined;
const materiali = await listMaterialiApprovati(filtro);
const materiali = await listMaterialiApprovati(filtroCategoria, filtroNome);
res.status(200).json(materiali);
} catch (err) {
next(err);
@@ -56,7 +60,7 @@ export async function getMaterialiPubblici(req: Request, res: Response, next: Ne
export async function postProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parsePropostaBody(req.body ?? {});
const input = parseMaterialeBody(req.body ?? {});
const proposta = await proponiMateriale({ ...input, orgId: req.auth!.orgId! });
res.status(201).json(proposta);
} catch (err) {
@@ -64,6 +68,35 @@ export async function postProposta(req: Request, res: Response, next: NextFuncti
}
}
export async function postMateriale(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseMaterialeBody(req.body ?? {});
const materiale = await creaMateriale({ ...input, orgId: req.auth!.orgId! });
res.status(201).json(materiale);
} catch (err) {
next(err);
}
}
export async function putMateriale(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseMaterialeBody(req.body ?? {});
const materiale = await aggiornaMateriale(req.params.id, input);
res.status(200).json(materiale);
} catch (err) {
next(err);
}
}
export async function deleteMateriale(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaMateriale(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function getProposte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const proposte = await listProposte();
@@ -0,0 +1,45 @@
import { NextFunction, Request, Response } from 'express';
import { HttpError } from '../errors';
import { countNonLette, listNotifiche, segnaLetta, segnaTutteLette } from '../services/notifiche.service';
function parseId(req: Request): number {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
throw new HttpError(400, "Il campo 'id' deve essere un numero intero");
}
return id;
}
export async function getNotifiche(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
res.status(200).json(await listNotifiche());
} catch (err) {
next(err);
}
}
export async function getCountNonLette(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
res.status(200).json({ count: await countNonLette() });
} catch (err) {
next(err);
}
}
export async function putLetta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await segnaLetta(parseId(req));
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function putLetteTutte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await segnaTutteLette();
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -1,5 +1,14 @@
import { Request, Response, NextFunction } from 'express';
import { aggiornaTipoEvento, creaTipoEvento, eliminaTipoEvento, listTipiEvento } from '../services/tipiEvento.service';
import {
aggiornaTipoEvento,
approvaTipoEvento,
creaTipoEvento,
eliminaTipoEvento,
listTipiEvento,
listTipiEventoConfermati,
proponiTipoEvento,
rifiutaTipoEvento,
} from '../services/tipiEvento.service';
import { HttpError } from '../errors';
function parseNome(body: { nome?: unknown }): string {
@@ -9,7 +18,22 @@ function parseNome(body: { nome?: unknown }): string {
return body.nome;
}
export async function getTipiEvento(_req: Request, res: Response, next: NextFunction): Promise<void> {
// Pubblica: usata sia dal filtro del catalogo liste sia dall'autocomplete nel wizard
// di creazione lista. Mostra solo i tipi evento confermati.
export async function getTipiEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { nome } = req.query;
const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined;
const tipiEvento = await listTipiEventoConfermati(filtroNome);
res.status(200).json(tipiEvento);
} catch (err) {
next(err);
}
}
// Moderazione: tutti gli stati, incluse le proposte in attesa di approvazione.
export async function getTipiEventoModerazione(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tipiEvento = await listTipiEvento();
res.status(200).json(tipiEvento);
@@ -28,6 +52,16 @@ export async function postTipoEvento(req: Request, res: Response, next: NextFunc
}
}
export async function postTipoEventoProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const tipoEvento = await proponiTipoEvento({ nome, orgId: req.auth!.orgId! });
res.status(201).json(tipoEvento);
} catch (err) {
next(err);
}
}
export async function putTipoEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
@@ -46,3 +80,21 @@ export async function deleteTipoEvento(req: Request, res: Response, next: NextFu
next(err);
}
}
export async function postTipoEventoApprova(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tipoEvento = await approvaTipoEvento(req.params.id);
res.status(200).json(tipoEvento);
} catch (err) {
next(err);
}
}
export async function postTipoEventoRifiuta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await rifiutaTipoEvento(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,46 @@
import { Categoria, StatoCategoria } from '@prisma/client';
import { prisma } from '../db/prisma';
export interface CreateCategoriaData {
nome: string;
stato: StatoCategoria;
creatoDaOrgId?: string | null;
}
export class CategorieRepository {
findAll(): Promise<Categoria[]> {
return prisma.categoria.findMany({ orderBy: { nome: 'asc' } });
}
findConfermate(nome?: string): Promise<Categoria[]> {
return prisma.categoria.findMany({
where: {
stato: StatoCategoria.confermata,
...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}),
},
orderBy: { nome: 'asc' },
});
}
findById(id: string): Promise<Categoria | null> {
return prisma.categoria.findUnique({ where: { id } });
}
create(data: CreateCategoriaData): Promise<Categoria> {
return prisma.categoria.create({ data });
}
update(id: string, nome: string): Promise<Categoria> {
return prisma.categoria.update({ where: { id }, data: { nome } });
}
updateStato(id: string, stato: StatoCategoria): Promise<Categoria> {
return prisma.categoria.update({ where: { id }, data: { stato } });
}
async delete(id: string): Promise<void> {
await prisma.categoria.delete({ where: { id } });
}
}
export const categorieRepository = new CategorieRepository();
@@ -0,0 +1,60 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeAlbero = {
voci: { include: { materiale: true } },
figli: { include: { voci: { include: { materiale: true } } } },
} satisfies Prisma.GruppoMagazzinoInclude;
export type GruppoMagazzinoConAlbero = Prisma.GruppoMagazzinoGetPayload<{ include: typeof includeAlbero }>;
export interface CreateGruppoMagazzinoData {
orgId: string;
nome: string;
parentId?: string | null;
}
export interface UpdateGruppoMagazzinoData {
nome?: string;
parentId?: string | null;
}
export class GruppiMagazzinoRepository {
// Solo i gruppi radice (parentId null): i figli arrivano annidati via include,
// un solo livello come da vincolo applicativo.
findRadiceByOrg(orgId: string): Promise<GruppoMagazzinoConAlbero[]> {
return prisma.gruppoMagazzino.findMany({
where: { orgId, parentId: null },
include: includeAlbero,
orderBy: { creatoIl: 'asc' },
});
}
findByIdAndOrg(id: string, orgId: string): Promise<GruppoMagazzinoConAlbero | null> {
return prisma.gruppoMagazzino.findFirst({ where: { id, orgId }, include: includeAlbero });
}
// Usato per validare che un parentId indicato esista, sia della stessa org e sia
// esso stesso un gruppo radice (nessun nesting a più di un livello).
findRadiceByIdAndOrg(id: string, orgId: string): Promise<{ id: string } | null> {
return prisma.gruppoMagazzino.findFirst({ where: { id, orgId, parentId: null }, select: { id: true } });
}
countFigli(id: string): Promise<number> {
return prisma.gruppoMagazzino.count({ where: { parentId: id } });
}
create(data: CreateGruppoMagazzinoData): Promise<GruppoMagazzinoConAlbero> {
return prisma.gruppoMagazzino.create({ data, include: includeAlbero });
}
update(id: string, data: UpdateGruppoMagazzinoData): Promise<GruppoMagazzinoConAlbero> {
return prisma.gruppoMagazzino.update({ where: { id }, data, include: includeAlbero });
}
async delete(id: string): Promise<void> {
await prisma.gruppoMagazzino.delete({ where: { id } });
}
}
export const gruppiMagazzinoRepository = new GruppiMagazzinoRepository();
@@ -1,8 +1,16 @@
import { Prisma } from '@prisma/client';
import { Prisma, PrismaClient, StatoLista, StatoModerazioneLista } from '@prisma/client';
import { prisma } from '../db/prisma';
// Alcuni metodi vengono invocati sia standalone sia dentro una transazione orchestrata
// dal service (fork-e-aggancio atomico delle sotto-liste, vedi liste.service.ts): il
// chiamante può passare il client di transazione al posto del PrismaClient globale.
type Db = PrismaClient | Prisma.TransactionClient;
const includeVoci = {
voci: { include: { materiale: true } },
// Le sotto-liste sono a un solo livello: qui basta espandere le voci materiale della
// sotto-lista, non le sue eventuali (vietate) sotto-liste.
sottoListe: { include: { sottoLista: { include: { voci: { include: { materiale: true } } } } } },
} satisfies Prisma.ListaInclude;
export type ListaConVoci = Prisma.ListaGetPayload<{ include: typeof includeVoci }>;
@@ -14,66 +22,214 @@ export interface ListaVoceInput {
export interface CreateListaData {
nome: string;
orgId: string;
// Valorizzato solo per le liste 'gruppo': per bozza/privato/pubblico è sempre null,
// anche se il chiamante ha un'org attiva (vedi liste.service.ts::risolviOrgId).
orgId: string | null;
creataDaUserId: string;
stato: StatoLista;
// Nullo per bozza/privato/gruppo, valorizzato solo se stato='pubblico' (vedi
// liste.service.ts::risolviStatoModerazione).
statoModerazione: StatoModerazioneLista | null;
tipoEventoId?: string | null;
// Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base").
parentId?: string | null;
voci: ListaVoceInput[];
sottoListeIds: string[];
}
export interface UpdateListaData {
nome?: string;
stato?: StatoLista;
orgId?: string | null;
statoModerazione?: StatoModerazioneLista | null;
tipoEventoId?: string | null;
voci?: ListaVoceInput[];
sottoListeIds?: string[];
}
export class ListeRepository {
findAllByOrg(orgId: string): Promise<ListaConVoci[]> {
// "Visibili" a un utente: le proprie liste (qualunque stato) + le liste 'gruppo'
// della sua organizzazione attiva, create da chiunque nell'org (comportamento
// collaborativo: una lista di gruppo è condivisa da tutti i membri, non solo da chi
// l'ha creata). Se l'utente non ha un'org attiva, solo le proprie.
findVisibiliPerUtente(userId: string, orgId: string | null): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: { orgId },
where: {
OR: [{ creataDaUserId: userId }, ...(orgId ? [{ stato: 'gruppo' as StatoLista, orgId }] : [])],
},
include: includeVoci,
orderBy: { creataIl: 'desc' },
});
}
// id + orgId nella stessa where: una lista di un'altra org risulta
// semplicemente "non trovata", mai un 403 che ne rivela l'esistenza.
// Catalogo pubblico: liste 'pubblico' E già approvate dalla moderazione. Sostituisce
// sia il vecchio findPubbliche() (che filtrava solo stato) sia ListeModelloRepository
// .findAll() (che filtrava solo pubblica:true) — ora un'unica condizione a due assi.
findApprovatePubbliche(tipoEventoId?: string): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: {
stato: 'pubblico',
statoModerazione: 'approvato',
...(tipoEventoId ? { tipoEventoId } : {}),
},
include: includeVoci,
orderBy: { creataIl: 'desc' },
});
}
// Dettaglio pubblico per singolo id (usato dal deep-link al catalogo e dal fork):
// stesso filtro a due assi di findApprovatePubbliche, ma per un id preciso.
findApprovataPerId(id: string, db: Db = prisma): Promise<ListaConVoci | null> {
return db.lista.findFirst({
where: { id, stato: 'pubblico', statoModerazione: 'approvato' },
include: includeVoci,
});
}
// Coda di moderazione: liste 'pubblico' ancora in attesa di decisione, FIFO per data
// di creazione — mirror di materiali.repository.ts::findProposte.
findProposte(): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: { stato: 'pubblico', statoModerazione: 'proposto' },
include: includeVoci,
orderBy: { creataIl: 'asc' },
});
}
// Unica transizione di stato di moderazione, mirror di
// materiali.repository.ts::updateStato.
updateStatoModerazione(id: string, statoModerazione: StatoModerazioneLista): Promise<ListaConVoci> {
return prisma.lista.update({
where: { id },
data: { statoModerazione },
include: includeVoci,
});
}
// Lookup senza alcun filtro di org: il controllo di chi può gestire questa lista
// (creatore, o membro dell'org se è di gruppo) è applicativo, vedi
// liste.service.ts::assicuraAccessoGestione.
findById(id: string): Promise<ListaConVoci | null> {
return prisma.lista.findFirst({ where: { id }, include: includeVoci });
}
// Usato solo da eventi.service.ts (un Evento, sempre org-scoped, può collegarsi solo
// a una lista 'gruppo' della propria org: le altre liste hanno orgId null e non
// potranno mai combaciare). Non toccare la firma: è un contratto tra i due moduli.
findByIdAndOrg(id: string, orgId: string): Promise<ListaConVoci | null> {
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
}
create(data: CreateListaData): Promise<ListaConVoci> {
return prisma.lista.create({
create(data: CreateListaData, db: Db = prisma): Promise<ListaConVoci> {
return db.lista.create({
data: {
nome: data.nome,
orgId: data.orgId,
creataDaUserId: data.creataDaUserId,
stato: data.stato,
statoModerazione: data.statoModerazione,
tipoEventoId: data.tipoEventoId ?? null,
parentId: data.parentId ?? null,
voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) },
sottoListe: { create: data.sottoListeIds.map((sottoListaId) => ({ sottoListaId })) },
},
include: includeVoci,
});
}
update(id: string, data: UpdateListaData): Promise<ListaConVoci> {
return prisma.$transaction(async (tx) => {
if (data.voci) {
await tx.listaVoce.deleteMany({ where: { listaId: id } });
}
// Se il chiamante passa già un client di transazione (orchestrazione a monte nel
// service), le due operazioni (delete + update) vengono semplicemente eseguite su
// quel client, atomiche insieme al resto della transazione esterna. Se invece non
// viene passato nulla, questo metodo resta atomico da solo aprendo la propria
// transazione, come faceva prima di introdurre le sotto-liste.
update(id: string, data: UpdateListaData, db?: Db): Promise<ListaConVoci> {
if (db) {
return this.eseguiUpdate(db, id, data);
}
return prisma.$transaction((tx) => this.eseguiUpdate(tx, id, data));
}
return tx.lista.update({
where: { id },
data: {
...(data.nome !== undefined ? { nome: data.nome } : {}),
...(data.voci
? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } }
: {}),
},
include: includeVoci,
});
private async eseguiUpdate(db: Db, id: string, data: UpdateListaData): Promise<ListaConVoci> {
if (data.voci) {
await db.listaVoce.deleteMany({ where: { listaId: id } });
}
if (data.sottoListeIds) {
await db.listaSottoLista.deleteMany({ where: { listaId: id } });
}
return db.lista.update({
where: { id },
data: {
...(data.nome !== undefined ? { nome: data.nome } : {}),
...(data.stato !== undefined ? { stato: data.stato } : {}),
...(data.orgId !== undefined ? { orgId: data.orgId } : {}),
...(data.statoModerazione !== undefined ? { statoModerazione: data.statoModerazione } : {}),
...(data.tipoEventoId !== undefined ? { tipoEventoId: data.tipoEventoId } : {}),
...(data.voci
? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } }
: {}),
...(data.sottoListeIds
? { sottoListe: { create: data.sottoListeIds.map((sottoListaId) => ({ sottoListaId })) } }
: {}),
},
include: includeVoci,
});
}
async delete(id: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.listaVoce.deleteMany({ where: { listaId: id } });
// Solo i propri agganci come padre: MAI quelli come figlio (sottoListaId: id),
// altrimenti si aggirerebbe il vincolo RESTRICT che impedisce di eliminare una
// lista ancora usata come sotto-lista altrove.
await tx.listaSottoLista.deleteMany({ where: { listaId: id } });
await tx.lista.delete({ where: { id } });
});
}
// Tra gli id candidati, quali sono utilizzabili come sotto-lista per l'utente
// corrente: proprie liste (qualunque stato), liste 'gruppo' della stessa org (solo se
// permettiGruppo, cioè la lista padre è essa stessa 'gruppo'), oppure liste pubbliche
// già approvate (agganciabili da chiunque, come le ex-ListaModello). Una lista
// personale può quindi agganciare solo proprie liste personali + liste pubbliche; una
// lista di gruppo può agganciare anche liste di gruppo della sua org.
async trovaVisibiliComeSottoLista(
userId: string,
orgId: string | null,
permettiGruppo: boolean,
ids: string[],
db: Db = prisma,
): Promise<string[]> {
if (ids.length === 0) {
return [];
}
const righe = await db.lista.findMany({
where: {
id: { in: ids },
OR: [
{ creataDaUserId: userId },
...(permettiGruppo && orgId ? [{ stato: 'gruppo' as StatoLista, orgId }] : []),
{ stato: 'pubblico' as StatoLista, statoModerazione: 'approvato' as StatoModerazioneLista },
],
},
select: { id: true },
});
return righe.map((r) => r.id);
}
// Strutturale, non dipende dall'org: la visibilità/autorizzazione è già stata
// verificata da trovaVisibiliComeSottoLista.
async trovaConSottoListe(ids: string[], db: Db = prisma): Promise<string[]> {
if (ids.length === 0) {
return [];
}
const righe = await db.listaSottoLista.findMany({
where: { listaId: { in: ids } },
select: { listaId: true },
distinct: ['listaId'],
});
return righe.map((r) => r.listaId);
}
}
export const listeRepository = new ListeRepository();
@@ -1,82 +0,0 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeVoci = {
voci: { include: { materiale: true } },
} satisfies Prisma.ListaModelloInclude;
export type ListaModelloConVoci = Prisma.ListaModelloGetPayload<{ include: typeof includeVoci }>;
export interface ListaModelloVoceInput {
materialeId: string;
quantita: number;
}
export interface CreateListaModelloData {
nome: string;
tipoEventoId: string;
voci: ListaModelloVoceInput[];
}
export interface UpdateListaModelloData {
nome?: string;
tipoEventoId?: string;
voci?: ListaModelloVoceInput[];
}
export class ListeModelloRepository {
// "pubblica" è sempre true per le liste modello: non è un filtro opzionale,
// è la condizione fissa che qualifica queste liste come tali.
findAll(tipoEventoId?: string): Promise<ListaModelloConVoci[]> {
return prisma.listaModello.findMany({
where: { pubblica: true, ...(tipoEventoId ? { tipoEventoId } : {}) },
include: includeVoci,
orderBy: { nome: 'asc' },
});
}
findById(id: string): Promise<ListaModelloConVoci | null> {
return prisma.listaModello.findUnique({ where: { id }, include: includeVoci });
}
create(data: CreateListaModelloData): Promise<ListaModelloConVoci> {
return prisma.listaModello.create({
data: {
nome: data.nome,
tipoEventoId: data.tipoEventoId,
pubblica: true,
voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) },
},
include: includeVoci,
});
}
update(id: string, data: UpdateListaModelloData): Promise<ListaModelloConVoci> {
return prisma.$transaction(async (tx) => {
if (data.voci) {
await tx.listaModelloVoce.deleteMany({ where: { listaModelloId: id } });
}
return tx.listaModello.update({
where: { id },
data: {
...(data.nome !== undefined ? { nome: data.nome } : {}),
...(data.tipoEventoId !== undefined ? { tipoEventoId: data.tipoEventoId } : {}),
...(data.voci
? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } }
: {}),
},
include: includeVoci,
});
});
}
async delete(id: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.listaModelloVoce.deleteMany({ where: { listaModelloId: id } });
await tx.listaModello.delete({ where: { id } });
});
}
}
export const listeModelloRepository = new ListeModelloRepository();
@@ -14,6 +14,7 @@ export interface CreateMagazzinoVoceData {
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
gruppoId?: string | null;
}
export interface UpdateMagazzinoVoceData {
@@ -22,6 +23,7 @@ export interface UpdateMagazzinoVoceData {
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
gruppoId?: string | null;
}
export interface QuantitaPosseduta {
@@ -6,12 +6,23 @@ export interface CreateMaterialeData {
categoria: string;
unitaMisura: string;
propostoDaOrgId: string;
stato?: StatoMateriale;
}
export interface UpdateMaterialeData {
nome: string;
categoria: string;
unitaMisura: string;
}
export class MaterialiRepository {
findApprovati(categoria?: string): Promise<Materiale[]> {
findApprovati(categoria?: string, nome?: string): Promise<Materiale[]> {
return prisma.materiale.findMany({
where: { stato: StatoMateriale.approvato, ...(categoria ? { categoria } : {}) },
where: {
stato: StatoMateriale.approvato,
...(categoria ? { categoria } : {}),
...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}),
},
orderBy: { nome: 'asc' },
});
}
@@ -29,13 +40,21 @@ export class MaterialiRepository {
create(data: CreateMaterialeData): Promise<Materiale> {
return prisma.materiale.create({
data: { ...data, stato: StatoMateriale.proposto },
data: { ...data, stato: data.stato ?? StatoMateriale.proposto },
});
}
update(id: string, data: UpdateMaterialeData): Promise<Materiale> {
return prisma.materiale.update({ where: { id }, data });
}
updateStato(id: string, stato: StatoMateriale): Promise<Materiale> {
return prisma.materiale.update({ where: { id }, data: { stato } });
}
async delete(id: string): Promise<void> {
await prisma.materiale.delete({ where: { id } });
}
}
export const materialiRepository = new MaterialiRepository();
@@ -0,0 +1,36 @@
import { Notifica, TipoNotifica } from '@prisma/client';
import { prisma } from '../db/prisma';
export interface CreateNotificaData {
tipo: TipoNotifica;
messaggio: string;
link?: string;
}
export class NotificheRepository {
findAll(): Promise<Notifica[]> {
return prisma.notifica.findMany({ orderBy: { dataCreazione: 'desc' }, take: 50 });
}
countNonLette(): Promise<number> {
return prisma.notifica.count({ where: { letta: false } });
}
findById(id: number): Promise<Notifica | null> {
return prisma.notifica.findUnique({ where: { id } });
}
segnaLetta(id: number): Promise<Notifica> {
return prisma.notifica.update({ where: { id }, data: { letta: true } });
}
async segnaTutteLette(): Promise<void> {
await prisma.notifica.updateMany({ where: { letta: false }, data: { letta: true } });
}
create(data: CreateNotificaData): Promise<Notifica> {
return prisma.notifica.create({ data });
}
}
export const notificheRepository = new NotificheRepository();
@@ -1,19 +1,45 @@
import { TipoEvento } from '@prisma/client';
import { StatoCategoria, TipoEvento } from '@prisma/client';
import { prisma } from '../db/prisma';
export interface CreateTipoEventoData {
nome: string;
stato: StatoCategoria;
creatoDaOrgId?: string | null;
}
export class TipiEventoRepository {
// Pubblico (catalogo, autocomplete): solo i tipi evento confermati.
findConfermati(nome?: string): Promise<TipoEvento[]> {
return prisma.tipoEvento.findMany({
where: {
stato: StatoCategoria.confermata,
...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}),
},
orderBy: { nome: 'asc' },
});
}
// Moderazione: tutti gli stati, incluse le proposte in attesa.
findAll(): Promise<TipoEvento[]> {
return prisma.tipoEvento.findMany({ orderBy: { nome: 'asc' } });
}
create(nome: string): Promise<TipoEvento> {
return prisma.tipoEvento.create({ data: { nome } });
findById(id: string): Promise<TipoEvento | null> {
return prisma.tipoEvento.findUnique({ where: { id } });
}
create(data: CreateTipoEventoData): Promise<TipoEvento> {
return prisma.tipoEvento.create({ data });
}
update(id: string, nome: string): Promise<TipoEvento> {
return prisma.tipoEvento.update({ where: { id }, data: { nome } });
}
updateStato(id: string, stato: StatoCategoria): Promise<TipoEvento> {
return prisma.tipoEvento.update({ where: { id }, data: { stato } });
}
async delete(id: string): Promise<void> {
await prisma.tipoEvento.delete({ where: { id } });
}
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { postSearch } from '../controllers/autocomplete.controller';
export const autocompleteRouter = Router();
autocompleteRouter.post('/autocomplete/search', postSearch);
@@ -0,0 +1,25 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import {
deleteCategoria,
getCategorie,
getCategoriePubbliche,
postCategoria,
postCategoriaApprova,
postCategoriaProposta,
postCategoriaRifiuta,
putCategoria,
} from '../controllers/categorie.controller';
export const categorieRouter = Router();
categorieRouter.get('/categorie/pubbliche', getCategoriePubbliche);
categorieRouter.get('/categorie', verifyToken, requireModeratore, getCategorie);
categorieRouter.post('/categorie', verifyToken, requireModeratore, postCategoria);
categorieRouter.put('/categorie/:id', verifyToken, requireModeratore, putCategoria);
categorieRouter.delete('/categorie/:id', verifyToken, requireModeratore, deleteCategoria);
categorieRouter.post('/categorie/:id/approva', verifyToken, requireModeratore, postCategoriaApprova);
categorieRouter.post('/categorie/:id/rifiuta', verifyToken, requireModeratore, postCategoriaRifiuta);
categorieRouter.post('/categorie/proposte', verifyToken, requireOrgId, postCategoriaProposta);
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import {
deleteGruppoMagazzino,
getGruppiMagazzino,
postGruppoMagazzino,
putGruppoMagazzino,
} from '../controllers/gruppiMagazzino.controller';
export const gruppiMagazzinoRouter = Router();
gruppiMagazzinoRouter.get('/gruppi-magazzino', verifyToken, requireOrgId, getGruppiMagazzino);
gruppiMagazzinoRouter.post('/gruppi-magazzino', verifyToken, requireOrgId, postGruppoMagazzino);
gruppiMagazzinoRouter.put('/gruppi-magazzino/:id', verifyToken, requireOrgId, putGruppoMagazzino);
gruppiMagazzinoRouter.delete('/gruppi-magazzino/:id', verifyToken, requireOrgId, deleteGruppoMagazzino);
@@ -1,12 +1,29 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { deleteLista, getListe, postLista, postListaDaModello, putLista } from '../controllers/liste.controller';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import {
deleteLista,
getListaPubblicaById,
getListe,
getListePubbliche,
getListeProposte,
patchListaProposta,
postLista,
postListaDaFork,
putLista,
} from '../controllers/liste.controller';
export const listeRouter = Router();
listeRouter.get('/liste', verifyToken, requireOrgId, getListe);
listeRouter.post('/liste', verifyToken, requireOrgId, postLista);
listeRouter.post('/liste/da-modello/:listaModelloId', verifyToken, requireOrgId, postListaDaModello);
listeRouter.put('/liste/:id', verifyToken, requireOrgId, putLista);
listeRouter.delete('/liste/:id', verifyToken, requireOrgId, deleteLista);
listeRouter.get('/liste', verifyToken, getListe);
// Nessun middleware: le liste 'pubblico' approvate sono visibili anche senza
// autenticazione. Le due route sotto vanno dichiarate prima di eventuali futuri
// GET /liste/:id generici, per evitare ambiguità di matching.
listeRouter.get('/liste/pubbliche', getListePubbliche);
listeRouter.get('/liste/pubbliche/:id', getListaPubblicaById);
listeRouter.get('/liste/proposte', verifyToken, requireModeratore, getListeProposte);
listeRouter.patch('/liste/proposte/:id', verifyToken, requireModeratore, patchListaProposta);
listeRouter.post('/liste', verifyToken, postLista);
listeRouter.post('/liste/da-fork/:id', verifyToken, postListaDaFork);
listeRouter.put('/liste/:id', verifyToken, putLista);
listeRouter.delete('/liste/:id', verifyToken, deleteLista);
@@ -1,16 +0,0 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import {
deleteListaModello,
getListeModello,
postListaModello,
putListaModello,
} from '../controllers/listeModello.controller';
export const listeModelloRouter = Router();
listeModelloRouter.get('/liste-modello', getListeModello);
listeModelloRouter.post('/liste-modello', verifyToken, requireModeratore, postListaModello);
listeModelloRouter.put('/liste-modello/:id', verifyToken, requireModeratore, putListaModello);
listeModelloRouter.delete('/liste-modello/:id', verifyToken, requireModeratore, deleteListaModello);
@@ -2,11 +2,22 @@ import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import { getMaterialiPubblici, getProposte, patchProposta, postProposta } from '../controllers/materiali.controller';
import {
deleteMateriale,
getMaterialiPubblici,
getProposte,
patchProposta,
postMateriale,
postProposta,
putMateriale,
} from '../controllers/materiali.controller';
export const materialiRouter = Router();
materialiRouter.get('/materiali', getMaterialiPubblici);
materialiRouter.post('/materiali', verifyToken, requireOrgId, requireModeratore, postMateriale);
materialiRouter.put('/materiali/:id', verifyToken, requireModeratore, putMateriale);
materialiRouter.delete('/materiali/:id', verifyToken, requireModeratore, deleteMateriale);
materialiRouter.post('/materiali/proposte', verifyToken, requireOrgId, postProposta);
materialiRouter.get('/materiali/proposte', verifyToken, requireModeratore, getProposte);
materialiRouter.patch('/materiali/proposte/:id', verifyToken, requireModeratore, patchProposta);
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import { getCountNonLette, getNotifiche, putLetta, putLetteTutte } from '../controllers/notifiche.controller';
export const notificheRouter = Router();
notificheRouter.get('/notifiche', verifyToken, requireModeratore, getNotifiche);
notificheRouter.get('/notifiche/non-lette/count', verifyToken, requireModeratore, getCountNonLette);
notificheRouter.put('/notifiche/letta-tutte', verifyToken, requireModeratore, putLetteTutte);
notificheRouter.put('/notifiche/:id/letta', verifyToken, requireModeratore, putLetta);
@@ -1,11 +1,25 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import { deleteTipoEvento, getTipiEvento, postTipoEvento, putTipoEvento } from '../controllers/tipiEvento.controller';
import {
deleteTipoEvento,
getTipiEvento,
getTipiEventoModerazione,
postTipoEvento,
postTipoEventoApprova,
postTipoEventoProposta,
postTipoEventoRifiuta,
putTipoEvento,
} from '../controllers/tipiEvento.controller';
export const tipiEventoRouter = Router();
tipiEventoRouter.get('/tipi-evento', getTipiEvento);
tipiEventoRouter.get('/tipi-evento/moderazione', verifyToken, requireModeratore, getTipiEventoModerazione);
tipiEventoRouter.post('/tipi-evento', verifyToken, requireModeratore, postTipoEvento);
tipiEventoRouter.post('/tipi-evento/proposte', verifyToken, requireOrgId, postTipoEventoProposta);
tipiEventoRouter.put('/tipi-evento/:id', verifyToken, requireModeratore, putTipoEvento);
tipiEventoRouter.delete('/tipi-evento/:id', verifyToken, requireModeratore, deleteTipoEvento);
tipiEventoRouter.post('/tipi-evento/:id/approva', verifyToken, requireModeratore, postTipoEventoApprova);
tipiEventoRouter.post('/tipi-evento/:id/rifiuta', verifyToken, requireModeratore, postTipoEventoRifiuta);
@@ -0,0 +1,36 @@
import { listTipiEventoConfermati } from './tipiEvento.service';
import { listMaterialiApprovati } from './materiali.service';
export interface AutocompleteObjectDto {
id: string;
nome: string;
gruppo: 'tipoEvento' | 'materiale';
}
export interface AutocompleteGroupDto {
label: string;
gruppo: 'tipoEvento' | 'materiale';
objectsList: AutocompleteObjectDto[];
}
export async function search(keyword?: string): Promise<AutocompleteGroupDto[]> {
const [tipiEvento, materiali] = await Promise.all([
listTipiEventoConfermati(keyword),
listMaterialiApprovati(undefined, keyword),
]);
const gruppi: AutocompleteGroupDto[] = [
{
label: 'Tipo evento',
gruppo: 'tipoEvento',
objectsList: tipiEvento.map((tipoEvento) => ({ id: tipoEvento.id, nome: tipoEvento.nome, gruppo: 'tipoEvento' })),
},
{
label: 'Materiale',
gruppo: 'materiale',
objectsList: materiali.map((materiale) => ({ id: materiale.id, nome: materiale.nome, gruppo: 'materiale' })),
},
];
return gruppi.filter((gruppo) => gruppo.objectsList.length > 0);
}
@@ -0,0 +1,76 @@
import { Categoria, StatoCategoria } from '@prisma/client';
import { categorieRepository } from '../repositories/categorie.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
import { creaNotificaModerazione } from './notifiche.service';
export function listCategorie(): Promise<Categoria[]> {
return categorieRepository.findAll();
}
export function listCategorieConfermate(nome?: string): Promise<Categoria[]> {
return categorieRepository.findConfermate(nome);
}
export function creaCategoria(nome: string): Promise<Categoria> {
return categorieRepository.create({ nome, stato: StatoCategoria.confermata });
}
export async function aggiornaCategoria(id: string, nome: string): Promise<Categoria> {
try {
return await categorieRepository.update(id, nome);
} catch (err) {
throw toHttpError(err, 'Categoria non trovata', "Conflitto durante l'aggiornamento della categoria");
}
}
export async function eliminaCategoria(id: string): Promise<void> {
try {
await categorieRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Categoria non trovata', 'Impossibile eliminare la categoria: è referenziata altrove');
}
}
export interface ProponiCategoriaInput {
nome: string;
orgId: string;
}
export async function proponiCategoria(input: ProponiCategoriaInput): Promise<Categoria> {
const categoria = await categorieRepository.create({
nome: input.nome,
stato: StatoCategoria.da_approvare,
creatoDaOrgId: input.orgId,
});
await creaNotificaModerazione(
'CATEGORIA_PROPOSTA',
`Nuova categoria proposta: "${categoria.nome}"`,
'/tassonomie?tab=categorie',
);
return categoria;
}
export async function approvaCategoria(id: string): Promise<Categoria> {
const categoria = await categorieRepository.findById(id);
if (!categoria) {
throw new HttpError(404, 'Categoria non trovata');
}
if (categoria.stato !== StatoCategoria.da_approvare) {
throw new HttpError(409, 'La categoria è già confermata');
}
return categorieRepository.updateStato(id, StatoCategoria.confermata);
}
export async function rifiutaCategoria(id: string): Promise<void> {
const categoria = await categorieRepository.findById(id);
if (!categoria) {
throw new HttpError(404, 'Categoria non trovata');
}
if (categoria.stato !== StatoCategoria.da_approvare) {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
await categorieRepository.delete(id);
}
@@ -0,0 +1,115 @@
import {
CreateGruppoMagazzinoData,
GruppoMagazzinoConAlbero,
UpdateGruppoMagazzinoData,
gruppiMagazzinoRepository,
} from '../repositories/gruppiMagazzino.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
import { MagazzinoVoceView, toView as toVoceView } from './magazzino.service';
export interface GruppoMagazzinoView {
id: string;
orgId: string;
nome: string;
parentId: string | null;
creatoIl: Date;
voci: MagazzinoVoceView[];
// Sempre vuoto sui gruppi restituiti come figli: un solo livello di nesting.
figli: GruppoMagazzinoView[];
}
function toView(gruppo: GruppoMagazzinoConAlbero): GruppoMagazzinoView {
return {
id: gruppo.id,
orgId: gruppo.orgId,
nome: gruppo.nome,
parentId: gruppo.parentId,
creatoIl: gruppo.creatoIl,
voci: gruppo.voci.map(toVoceView),
figli: gruppo.figli.map((figlio) => ({
id: figlio.id,
orgId: figlio.orgId,
nome: figlio.nome,
parentId: figlio.parentId,
creatoIl: figlio.creatoIl,
voci: figlio.voci.map(toVoceView),
figli: [],
})),
};
}
// Solo l'albero radice: i figli arrivano annidati (vedi toView), niente
// duplicazione di un gruppo figlio come voce di primo livello nella risposta.
export function listGruppiPerOrg(orgId: string): Promise<GruppoMagazzinoView[]> {
return gruppiMagazzinoRepository.findRadiceByOrg(orgId).then((gruppi) => gruppi.map(toView));
}
async function assicuraGenitoreValido(parentId: string, orgId: string): Promise<void> {
const genitore = await gruppiMagazzinoRepository.findRadiceByIdAndOrg(parentId, orgId);
if (!genitore) {
throw new HttpError(
400,
'Il gruppo padre indicato non esiste, non appartiene alla tua organizzazione o non è un gruppo di primo livello',
);
}
}
export interface CreaGruppoInput {
nome: string;
parentId?: string | null;
}
export async function creaGruppo(orgId: string, input: CreaGruppoInput): Promise<GruppoMagazzinoView> {
if (input.parentId) {
await assicuraGenitoreValido(input.parentId, orgId);
}
const data: CreateGruppoMagazzinoData = { orgId, nome: input.nome, parentId: input.parentId ?? null };
const gruppo = await gruppiMagazzinoRepository.create(data);
return toView(gruppo);
}
async function trovaGruppoDiOrg(id: string, orgId: string): Promise<GruppoMagazzinoConAlbero> {
const gruppo = await gruppiMagazzinoRepository.findByIdAndOrg(id, orgId);
if (!gruppo) {
throw new HttpError(404, 'Gruppo di magazzino non trovato');
}
return gruppo;
}
export interface AggiornaGruppoInput {
nome?: string;
parentId?: string | null;
}
export async function aggiornaGruppo(id: string, orgId: string, input: AggiornaGruppoInput): Promise<GruppoMagazzinoView> {
const esistente = await trovaGruppoDiOrg(id, orgId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === id) {
throw new HttpError(400, 'Un gruppo non può essere padre di se stesso');
}
if (esistente.figli.length > 0) {
throw new HttpError(400, 'Un gruppo con dei figli non può a sua volta diventare figlio di un altro gruppo');
}
await assicuraGenitoreValido(input.parentId, orgId);
}
const data: UpdateGruppoMagazzinoData = { nome: input.nome, parentId: input.parentId };
try {
const gruppo = await gruppiMagazzinoRepository.update(id, data);
return toView(gruppo);
} catch (err) {
throw toHttpError(err, 'Gruppo di magazzino non trovato', 'Il gruppo padre indicato non esiste');
}
}
export async function eliminaGruppo(id: string, orgId: string): Promise<void> {
await trovaGruppoDiOrg(id, orgId);
try {
await gruppiMagazzinoRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Gruppo di magazzino non trovato', 'Impossibile eliminare il gruppo di magazzino');
}
}
@@ -1,89 +1,423 @@
import { Prisma, PrismaClient, StatoLista, StatoModerazioneLista } from '@prisma/client';
import { prisma } from '../db/prisma';
import { ListaConVoci, ListaVoceInput, listeRepository } from '../repositories/liste.repository';
import { listeModelloRepository } from '../repositories/listeModello.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
import { creaNotificaModerazione } from './notifiche.service';
export interface ListaVoceView {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
// Presente solo se il chiamante può vederlo (vedi toView): il materiale è stato
// proposto da poco e non è ancora stato approvato dal moderatore.
inAttesaConferma?: boolean;
}
export interface SottoListaView {
id: string;
nome: string;
voci: ListaVoceView[];
}
export interface ListaView {
id: string;
nome: string;
orgId: string;
orgId: string | null;
stato: StatoLista;
// Nullo per bozza/privato/gruppo: la moderazione si applica solo quando stato =
// pubblico (vedi risolviStatoModerazione).
statoModerazione: StatoModerazioneLista | null;
tipoEventoId: string | null;
// Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base").
parentId: string | null;
creataIl: Date;
// Il chiamante è l'autore della lista: per le liste 'gruppo' (visibili a tutta
// l'org, non solo a chi le ha create) serve al frontend per distinguere "le mie
// liste di gruppo" dalle liste di gruppo altrui nella stessa vista.
creataDaMe: boolean;
voci: ListaVoceView[];
sottoListe: SottoListaView[];
}
function toView(lista: ListaConVoci): ListaView {
// Chi sta leggendo/scrivendo la lista. orgId è l'organizzazione attiva sul token (null
// se l'utente non ne ha una: le liste personali bozza/privato/pubblico non richiedono
// un'org). Serve sia per decidere se mostrare inAttesaConferma sia per l'ownership.
export interface Viewer {
userId: string;
orgId: string | null;
roles: string[];
}
const viewerAnonimo: Viewer = { userId: '', orgId: null, roles: [] };
// Usato solo per costruire la ListaView di ritorno delle route di moderazione (già
// protette da requireModeratore a livello router): garantisce che inAttesaConferma sui
// materiali dentro la lista sia visibile a chi la sta decidendo.
const viewerModeratore: Viewer = { userId: '', orgId: null, roles: ['moderatore'] };
function toVoceView(v: ListaConVoci['voci'][number], puoVedereAttesaConferma: boolean): ListaVoceView {
return {
materialeId: v.materialeId,
nome: v.materiale.nome,
unitaMisura: v.materiale.unitaMisura,
quantita: v.quantita,
...(puoVedereAttesaConferma && v.materiale.stato === 'proposto' ? { inAttesaConferma: true } : {}),
};
}
function toView(lista: ListaConVoci, viewer: Viewer): ListaView {
const puoVedereAttesaConferma = lista.creataDaUserId === viewer.userId || viewer.roles.includes('moderatore');
return {
id: lista.id,
nome: lista.nome,
orgId: lista.orgId,
stato: lista.stato,
statoModerazione: lista.statoModerazione,
tipoEventoId: lista.tipoEventoId,
parentId: lista.parentId,
creataIl: lista.creataIl,
voci: lista.voci.map((v) => ({
materialeId: v.materialeId,
nome: v.materiale.nome,
unitaMisura: v.materiale.unitaMisura,
quantita: v.quantita,
creataDaMe: lista.creataDaUserId === viewer.userId,
voci: lista.voci.map((v) => toVoceView(v, puoVedereAttesaConferma)),
sottoListe: lista.sottoListe.map((sl) => ({
id: sl.sottoLista.id,
nome: sl.sottoLista.nome,
voci: sl.sottoLista.voci.map((v) => toVoceView(v, puoVedereAttesaConferma)),
})),
};
}
export async function listListePerOrg(orgId: string): Promise<ListaView[]> {
const liste = await listeRepository.findAllByOrg(orgId);
return liste.map(toView);
// "Le mie liste": le proprie (qualunque stato) + le liste di gruppo della propria org,
// create da chiunque nell'org (comportamento collaborativo).
export async function listMieListe(viewer: Viewer): Promise<ListaView[]> {
const liste = await listeRepository.findVisibiliPerUtente(viewer.userId, viewer.orgId);
return liste.map((lista) => toView(lista, viewer));
}
export async function creaListaVuota(orgId: string, nome: string): Promise<ListaView> {
const lista = await listeRepository.create({ nome, orgId, voci: [] });
return toView(lista);
// Catalogo pubblico: liste 'pubblico' già approvate dalla moderazione, visibili anche
// senza autenticazione. Nessun inAttesaConferma per un chiamante anonimo.
export async function listListePubbliche(tipoEventoId?: string): Promise<ListaView[]> {
const liste = await listeRepository.findApprovatePubbliche(tipoEventoId);
return liste.map((lista) => toView(lista, viewerAnonimo));
}
// Fork: copia nome e voci correnti della lista modello in una nuova lista
// dell'org. Da qui in poi le due entità non hanno più alcun legame: nessun
// listaModelloId viene salvato sulla lista creata.
export async function forkListaDaModello(orgId: string, listaModelloId: string): Promise<ListaView> {
const modello = await listeModelloRepository.findById(listaModelloId);
if (!modello || !modello.pubblica) {
throw new HttpError(404, 'Lista modello non trovata');
}
const voci: ListaVoceInput[] = modello.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita }));
const lista = await listeRepository.create({ nome: modello.nome, orgId, voci });
return toView(lista);
}
async function assicuraListaDiOrg(id: string, orgId: string): Promise<void> {
const lista = await listeRepository.findByIdAndOrg(id, orgId);
// Dettaglio pubblico per singolo id (deep-link diretto al catalogo, a parità con il
// vecchio GET /liste-modello/:id).
export async function trovaListaPubblicaPerId(id: string): Promise<ListaView> {
const lista = await listeRepository.findApprovataPerId(id);
if (!lista) {
throw new HttpError(404, 'Lista non trovata');
}
return toView(lista, viewerAnonimo);
}
// Coda di moderazione: liste pubbliche in attesa di decisione.
export async function listProposte(): Promise<ListaView[]> {
const liste = await listeRepository.findProposte();
return liste.map((lista) => toView(lista, viewerModeratore));
}
export type DecisioneProposta = 'approvato' | 'rifiutato';
// Mirror esatto di materiali.service.ts::decidiProposta: transizione singola non
// reversibile via questo endpoint.
export async function decidiProposta(id: string, decisione: DecisioneProposta): Promise<ListaView> {
const lista = await listeRepository.findById(id);
if (!lista) {
throw new HttpError(404, 'Proposta non trovata');
}
if (lista.statoModerazione !== 'proposto') {
throw new HttpError(409, 'La proposta è già stata decisa');
}
const statoModerazione: StatoModerazioneLista = decisione === 'approvato' ? 'approvato' : 'rifiutato';
const aggiornata = await listeRepository.updateStatoModerazione(id, statoModerazione);
return toView(aggiornata, viewerModeratore);
}
// Solo le liste 'gruppo' hanno un'org: per tutti gli altri stati l'org del token viene
// sempre ignorata, anche se presente.
function risolviOrgId(stato: StatoLista, viewerOrgId: string | null): string | null {
if (stato !== 'gruppo') {
return null;
}
if (!viewerOrgId) {
throw new HttpError(400, "Serve un'organizzazione attiva per creare una lista di gruppo");
}
return viewerOrgId;
}
// Ogni volta che una lista entra in stato 'pubblico', il backend forza
// statoModerazione='proposto' (ignora qualunque input client) — TRANNE quando il
// richiedente ha ruolo 'moderatore', che può impostarla direttamente 'approvato'
// (scrittura diretta, caso raro, mirror del vecchio ListaModello.pubblica sempre
// forzato a true per chi passa già da requireModeratore).
function risolviStatoModerazione(stato: StatoLista, viewer: Viewer): StatoModerazioneLista | null {
if (stato !== 'pubblico') {
return null;
}
return viewer.roles.includes('moderatore') ? 'approvato' : 'proposto';
}
// In update, la regola sopra si applica solo quando la lista entra ora in 'pubblico' o
// quando il suo contenuto (voci/sotto-liste) viene modificato mentre è già pubblico: un
// update banale (es. solo il nome) su una lista già pubblica non deve resettare una
// moderazione già decisa.
function risolviStatoModerazioneUpdate(
statoFinale: StatoLista,
statoCambiato: boolean,
contenutoModificato: boolean,
statoModerazioneAttuale: StatoModerazioneLista | null,
viewer: Viewer,
): StatoModerazioneLista | null {
if (statoFinale !== 'pubblico') {
return null;
}
if (statoCambiato || contenutoModificato) {
return risolviStatoModerazione(statoFinale, viewer);
}
return statoModerazioneAttuale;
}
// Una lista può essere allegata come sotto-lista di un'altra solo se è "foglia" (nessuna
// auto-referenza, deve esistere ed essere visibile al viewer secondo le regole di
// ambito, non deve avere a sua volta sotto-liste — un solo livello di nesting, niente
// cicli). Una lista personale può agganciare solo proprie liste personali + liste
// pubbliche approvate; una lista di gruppo può agganciare anche liste di gruppo della
// stessa org.
async function validaSottoListe(
tx: Prisma.TransactionClient,
viewer: Viewer,
listaId: string | undefined,
statoListaCorrente: StatoLista,
sottoListeIds: string[],
): Promise<void> {
if (sottoListeIds.length === 0) {
return;
}
if (listaId && sottoListeIds.includes(listaId)) {
throw new HttpError(400, 'Una lista non può contenere se stessa come sotto-lista');
}
const permettiGruppo = statoListaCorrente === 'gruppo';
const visibili = await listeRepository.trovaVisibiliComeSottoLista(
viewer.userId,
viewer.orgId,
permettiGruppo,
sottoListeIds,
tx,
);
if (visibili.length !== new Set(sottoListeIds).size) {
throw new HttpError(400, 'Una delle sotto-liste indicate non esiste o non è utilizzabile');
}
const conSottoListe = await listeRepository.trovaConSottoListe(sottoListeIds, tx);
if (conSottoListe.length > 0) {
throw new HttpError(400, 'Una sotto-lista non può contenere a sua volta altre sotto-liste');
}
}
// I materiali di una lista pubblica usata come base (voci di primo livello + voci delle
// sue sotto-liste, che per vincolo applicativo sono al massimo un livello) vengono
// appiattiti in un unico elenco, sommando le quantità se lo stesso materiale compare
// più volte.
function flattenInVoci(lista: ListaConVoci): ListaVoceInput[] {
const quantitaPerMateriale = new Map<string, number>();
for (const v of lista.voci) {
quantitaPerMateriale.set(v.materialeId, (quantitaPerMateriale.get(v.materialeId) ?? 0) + v.quantita);
}
for (const sottoLista of lista.sottoListe) {
for (const v of sottoLista.sottoLista.voci) {
quantitaPerMateriale.set(v.materialeId, (quantitaPerMateriale.get(v.materialeId) ?? 0) + v.quantita);
}
}
return Array.from(quantitaPerMateriale, ([materialeId, quantita]) => ({ materialeId, quantita }));
}
// Fork: copia nome/voci/tipoEventoId correnti di una lista pubblica approvata in una
// nuova lista personale bozza, impostando parentId per tracciare la provenienza. Il
// contenuto è sempre "appiattito" dalle eventuali sotto-liste di primo livello della
// lista di origine, perché la lista figlia nasce senza sotto-liste proprie.
async function forkLista(
db: Prisma.TransactionClient | PrismaClient,
sorgenteId: string,
viewer: Viewer,
): Promise<ListaConVoci> {
const sorgente = await listeRepository.findApprovataPerId(sorgenteId, db);
if (!sorgente) {
throw new HttpError(404, 'Lista non trovata');
}
return listeRepository.create(
{
nome: sorgente.nome,
orgId: null,
creataDaUserId: viewer.userId,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: sorgente.tipoEventoId,
parentId: sorgente.id,
voci: flattenInVoci(sorgente),
sottoListeIds: [],
},
db,
);
}
// Endpoint pubblico dietro POST /liste/da-fork/:id — sostituisce forkListaDaModello.
export async function forkListaDaLista(listaOrigineId: string, viewer: Viewer): Promise<ListaView> {
const lista = await forkLista(prisma, listaOrigineId, viewer);
return toView(lista, viewer);
}
export interface SottoListeInput {
// Liste personali già esistenti, visibili al viewer secondo le regole di ambito.
sottoListeIds?: string[];
// Liste pubbliche approvate del catalogo: vengono forkate al volo (stesso
// flattening di forkLista) in nuove liste bozza personali (mai di gruppo, a
// prescindere dallo stato della lista padre), poi agganciate come le altre.
sottoListeModelloIds?: string[];
}
// Valida le sotto-liste esistenti e forka al volo quelle scelte dal catalogo, tutto
// dentro la transazione del chiamante: se la creazione/modifica della lista principale
// fallisce più avanti, anche le liste forkate qui vengono annullate (nessun orfano).
async function risolviSottoListeIds(
tx: Prisma.TransactionClient,
viewer: Viewer,
listaIdCorrente: string | undefined,
statoListaCorrente: StatoLista,
sottoListe: SottoListeInput,
): Promise<string[]> {
const sottoListeIds = sottoListe.sottoListeIds ?? [];
await validaSottoListe(tx, viewer, listaIdCorrente, statoListaCorrente, sottoListeIds);
const forkateIds: string[] = [];
for (const sorgenteId of sottoListe.sottoListeModelloIds ?? []) {
const forkata = await forkLista(tx, sorgenteId, viewer);
forkateIds.push(forkata.id);
}
return [...sottoListeIds, ...forkateIds];
}
export async function creaLista(
nome: string,
stato: StatoLista,
voci: ListaVoceInput[] = [],
sottoListe: SottoListeInput = {},
viewer: Viewer,
tipoEventoId?: string,
): Promise<ListaView> {
const orgId = risolviOrgId(stato, viewer.orgId);
const statoModerazione = risolviStatoModerazione(stato, viewer);
const lista = await prisma.$transaction(async (tx) => {
const sottoListeIds = await risolviSottoListeIds(tx, viewer, undefined, stato, sottoListe);
return listeRepository.create(
{ nome, orgId, creataDaUserId: viewer.userId, stato, statoModerazione, tipoEventoId, voci, sottoListeIds },
tx,
);
});
if (statoModerazione === 'proposto') {
await creaNotificaModerazione(
'LISTA_PROPOSTA',
`Nuova lista proposta: "${lista.nome}"`,
'/tassonomie?tab=liste',
);
}
return toView(lista, viewer);
}
// Autorizza la gestione (update/delete) di una lista: il creatore può sempre gestire le
// proprie liste (qualunque stato); una lista 'gruppo' può essere gestita da chiunque
// abbia quell'organizzazione attiva, non solo da chi l'ha creata (comportamento
// collaborativo). In ogni altro caso, 404 (mai 403, per non rivelare l'esistenza della
// lista a chi non può vederla).
async function assicuraAccessoGestione(id: string, viewer: Viewer): Promise<ListaConVoci> {
const lista = await listeRepository.findById(id);
const autorizzato =
!!lista && (lista.creataDaUserId === viewer.userId || (lista.stato === 'gruppo' && lista.orgId === viewer.orgId));
if (!autorizzato) {
throw new HttpError(404, 'Lista non trovata');
}
return lista!;
}
export interface AggiornaListaInput {
nome?: string;
// Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in
// edit, non solo alla creazione.
stato?: StatoLista;
voci?: ListaVoceInput[];
sottoListe?: SottoListeInput;
// undefined = non toccare il campo, null = rimuovi il tipo evento associato.
tipoEventoId?: string | null;
}
export async function aggiornaLista(id: string, orgId: string, input: AggiornaListaInput): Promise<ListaView> {
await assicuraListaDiOrg(id, orgId);
export async function aggiornaLista(id: string, input: AggiornaListaInput, viewer: Viewer): Promise<ListaView> {
const listaEsistente = await assicuraAccessoGestione(id, viewer);
const statoFinale = input.stato ?? listaEsistente.stato;
const statoCambiato = input.stato !== undefined && input.stato !== listaEsistente.stato;
// Il contenuto di una lista 'gruppo' è collaborativo (chiunque nell'org può
// modificarne voci/sotto-liste/nome), ma togliere la lista dal gruppo (o comunque
// cambiarne lo stato) resta una decisione di chi l'ha creata: altrimenti un membro
// qualsiasi potrebbe far sparire ad altri una lista condivisa cambiandola in
// bozza/privato a loro insaputa.
if (listaEsistente.stato === 'gruppo' && statoCambiato && listaEsistente.creataDaUserId !== viewer.userId) {
throw new HttpError(403, 'Solo chi ha creato la lista può cambiarne lo stato');
}
const contenutoModificato = input.voci !== undefined || input.sottoListe !== undefined;
const orgId = statoCambiato ? risolviOrgId(statoFinale, viewer.orgId) : undefined;
const statoModerazione = risolviStatoModerazioneUpdate(
statoFinale,
statoCambiato,
contenutoModificato,
listaEsistente.statoModerazione,
viewer,
);
try {
const lista = await listeRepository.update(id, input);
return toView(lista);
const lista = await prisma.$transaction(async (tx) => {
const sottoListeIds = input.sottoListe
? await risolviSottoListeIds(tx, viewer, id, statoFinale, input.sottoListe)
: undefined;
return listeRepository.update(
id,
{
nome: input.nome,
stato: input.stato,
orgId,
statoModerazione,
tipoEventoId: input.tipoEventoId,
voci: input.voci,
sottoListeIds,
},
tx,
);
});
if (statoModerazione === 'proposto' && listaEsistente.statoModerazione !== 'proposto') {
await creaNotificaModerazione(
'LISTA_PROPOSTA',
`Nuova lista proposta: "${lista.nome}"`,
'/tassonomie?tab=liste',
);
}
return toView(lista, viewer);
} catch (err) {
throw toHttpError(err, 'Lista non trovata', 'Uno dei materiali indicati non esiste');
}
}
export async function eliminaLista(id: string, orgId: string): Promise<void> {
await assicuraListaDiOrg(id, orgId);
export async function eliminaLista(id: string, viewer: Viewer): Promise<void> {
await assicuraAccessoGestione(id, viewer);
try {
await listeRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Lista non trovata', 'Impossibile eliminare la lista: è referenziata da un evento');
throw toHttpError(
err,
'Lista non trovata',
"Impossibile eliminare la lista: è referenziata da un evento o usata come sotto-lista di un'altra lista",
);
}
}
@@ -1,81 +0,0 @@
import {
CreateListaModelloData,
ListaModelloConVoci,
ListaModelloVoceInput,
UpdateListaModelloData,
listeModelloRepository,
} from '../repositories/listeModello.repository';
import { toHttpError } from '../utils/prisma-errors';
export interface ListaModelloVoceView {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface ListaModelloView {
id: string;
nome: string;
tipoEventoId: string;
voci: ListaModelloVoceView[];
}
function toView(lista: ListaModelloConVoci): ListaModelloView {
return {
id: lista.id,
nome: lista.nome,
tipoEventoId: lista.tipoEventoId,
voci: lista.voci.map((v) => ({
materialeId: v.materialeId,
nome: v.materiale.nome,
unitaMisura: v.materiale.unitaMisura,
quantita: v.quantita,
})),
};
}
export async function listListeModello(tipoEventoId?: string): Promise<ListaModelloView[]> {
const liste = await listeModelloRepository.findAll(tipoEventoId);
return liste.map(toView);
}
export interface CreaListaModelloInput {
nome: string;
tipoEventoId: string;
voci: ListaModelloVoceInput[];
}
export async function creaListaModello(input: CreaListaModelloInput): Promise<ListaModelloView> {
const data: CreateListaModelloData = input;
try {
const lista = await listeModelloRepository.create(data);
return toView(lista);
} catch (err) {
throw toHttpError(err, 'Tipo evento non trovato', 'Uno dei materiali indicati non esiste');
}
}
export interface AggiornaListaModelloInput {
nome?: string;
tipoEventoId?: string;
voci?: ListaModelloVoceInput[];
}
export async function aggiornaListaModello(id: string, input: AggiornaListaModelloInput): Promise<ListaModelloView> {
const data: UpdateListaModelloData = input;
try {
const lista = await listeModelloRepository.update(id, data);
return toView(lista);
} catch (err) {
throw toHttpError(err, 'Lista modello non trovata', 'Riferimento non valido (tipo evento o materiale inesistente)');
}
}
export async function eliminaListaModello(id: string): Promise<void> {
try {
await listeModelloRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Lista modello non trovata', 'Impossibile eliminare la lista modello');
}
}
@@ -6,6 +6,7 @@ import {
magazzinoRepository,
} from '../repositories/magazzino.repository';
import { materialiRepository } from '../repositories/materiali.repository';
import { gruppiMagazzinoRepository } from '../repositories/gruppiMagazzino.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
@@ -19,9 +20,11 @@ export interface MagazzinoVoceView {
stato: StatoMagazzinoVoce;
posizione: string | null;
note: string | null;
gruppoId: string | null;
}
function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView {
// Esportata perché riusata da gruppiMagazzino.service.ts per le voci annidate nei gruppi.
export function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView {
return {
id: voce.id,
orgId: voce.orgId,
@@ -32,6 +35,7 @@ function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView {
stato: voce.stato,
posizione: voce.posizione,
note: voce.note,
gruppoId: voce.gruppoId,
};
}
@@ -52,16 +56,29 @@ export function listMagazzinoPerOrg(orgId: string): Promise<MagazzinoVoceView[]>
return magazzinoRepository.findAllByOrg(orgId).then((voci) => voci.map(toView));
}
// Un gruppoId, se indicato, deve esistere ed essere della stessa org: mai
// un id letto/indovinato che porti in un gruppo di un'altra organizzazione.
async function assicuraGruppoDiOrg(gruppoId: string, orgId: string): Promise<void> {
const gruppo = await gruppiMagazzinoRepository.findByIdAndOrg(gruppoId, orgId);
if (!gruppo) {
throw new HttpError(400, 'Il gruppo indicato non esiste o non appartiene alla tua organizzazione');
}
}
export interface AggiungiVoceInput {
materialeId: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
gruppoId?: string | null;
}
export async function aggiungiVoce(orgId: string, input: AggiungiVoceInput): Promise<MagazzinoVoceView> {
await assicuraMaterialeApprovato(input.materialeId);
if (input.gruppoId) {
await assicuraGruppoDiOrg(input.gruppoId, orgId);
}
const data: CreateMagazzinoVoceData = { ...input, orgId };
const voce = await magazzinoRepository.create(data);
@@ -81,6 +98,7 @@ export interface AggiornaVoceInput {
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
gruppoId?: string | null;
}
export async function aggiornaVoce(id: string, orgId: string, input: AggiornaVoceInput): Promise<MagazzinoVoceView> {
@@ -89,6 +107,9 @@ export async function aggiornaVoce(id: string, orgId: string, input: AggiornaVoc
if (input.materialeId !== undefined) {
await assicuraMaterialeApprovato(input.materialeId);
}
if (input.gruppoId) {
await assicuraGruppoDiOrg(input.gruppoId, orgId);
}
const data: UpdateMagazzinoVoceData = input;
try {
@@ -1,6 +1,8 @@
import { Materiale, StatoMateriale } from '@prisma/client';
import { materialiRepository } from '../repositories/materiali.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
import { creaNotificaModerazione } from './notifiche.service';
export interface MaterialePubblico {
id: string;
@@ -40,8 +42,8 @@ function toProposta(materiale: Materiale): MaterialeProposta {
};
}
export async function listMaterialiApprovati(categoria?: string): Promise<MaterialePubblico[]> {
const materiali = await materialiRepository.findApprovati(categoria);
export async function listMaterialiApprovati(categoria?: string, nome?: string): Promise<MaterialePubblico[]> {
const materiali = await materialiRepository.findApprovati(categoria, nome);
return materiali.map(toPubblico);
}
@@ -59,9 +61,58 @@ export async function proponiMateriale(input: ProponiMaterialeInput): Promise<Ma
unitaMisura: input.unitaMisura,
propostoDaOrgId: input.orgId,
});
await creaNotificaModerazione(
'MATERIALE_PROPOSTO',
`Nuovo materiale proposto: "${materiale.nome}"`,
'/tassonomie?tab=materiali',
);
return toProposta(materiale);
}
export interface CreaMaterialeInput {
nome: string;
categoria: string;
unitaMisura: string;
orgId: string;
}
// Creazione diretta da parte del moderatore: a differenza di proponiMateriale,
// il materiale nasce già 'approvato' (stessa logica di creaCategoria in
// categorie.service.ts), senza passare dal workflow di proposta/decisione.
export async function creaMateriale(input: CreaMaterialeInput): Promise<MaterialePubblico> {
const materiale = await materialiRepository.create({
nome: input.nome,
categoria: input.categoria,
unitaMisura: input.unitaMisura,
propostoDaOrgId: input.orgId,
stato: StatoMateriale.approvato,
});
return toPubblico(materiale);
}
export interface AggiornaMaterialeInput {
nome: string;
categoria: string;
unitaMisura: string;
}
export async function aggiornaMateriale(id: string, input: AggiornaMaterialeInput): Promise<MaterialePubblico> {
try {
const materiale = await materialiRepository.update(id, input);
return toPubblico(materiale);
} catch (err) {
throw toHttpError(err, 'Materiale non trovato', "Conflitto durante l'aggiornamento del materiale");
}
}
export async function eliminaMateriale(id: string): Promise<void> {
try {
await materialiRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Materiale non trovato', 'Impossibile eliminare il materiale: è referenziato altrove');
}
}
export async function listProposte(): Promise<MaterialeProposta[]> {
const materiali = await materialiRepository.findProposte();
return materiali.map(toProposta);
@@ -0,0 +1,55 @@
import { Notifica, TipoNotifica } from '@prisma/client';
import { notificheRepository } from '../repositories/notifiche.repository';
import { HttpError } from '../errors';
export interface NotificaView {
id: number;
tipo: TipoNotifica;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: Date;
}
function toView(notifica: Notifica): NotificaView {
return {
id: notifica.id,
tipo: notifica.tipo,
messaggio: notifica.messaggio,
link: notifica.link,
letta: notifica.letta,
dataCreazione: notifica.dataCreazione,
};
}
export async function listNotifiche(): Promise<NotificaView[]> {
const notifiche = await notificheRepository.findAll();
return notifiche.map(toView);
}
export function countNonLette(): Promise<number> {
return notificheRepository.countNonLette();
}
export async function segnaLetta(id: number): Promise<void> {
const notifica = await notificheRepository.findById(id);
if (!notifica) {
throw new HttpError(404, 'Notifica non trovata');
}
await notificheRepository.segnaLetta(id);
}
export async function segnaTutteLette(): Promise<void> {
await notificheRepository.segnaTutteLette();
}
// Notifica broadcast per la coda di moderazione (visibile solo a chi ha ruolo
// 'moderatore', vedi requireModeratore su notifiche.routes.ts): usata per segnalare
// nuove proposte di materiale/categoria/tipo evento/lista in attesa di revisione.
export async function creaNotificaModerazione(
tipo: TipoNotifica,
messaggio: string,
link?: string,
): Promise<void> {
await notificheRepository.create({ tipo, messaggio, link });
}
@@ -1,20 +1,45 @@
import { TipoEvento } from '@prisma/client';
import { StatoCategoria, TipoEvento } from '@prisma/client';
import { tipiEventoRepository } from '../repositories/tipiEvento.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
import { creaNotificaModerazione } from './notifiche.service';
export function listTipiEventoConfermati(nome?: string): Promise<TipoEvento[]> {
return tipiEventoRepository.findConfermati(nome);
}
export function listTipiEvento(): Promise<TipoEvento[]> {
return tipiEventoRepository.findAll();
}
export function creaTipoEvento(nome: string): Promise<TipoEvento> {
return tipiEventoRepository.create(nome);
return tipiEventoRepository.create({ nome, stato: StatoCategoria.confermata });
}
export interface ProponiTipoEventoInput {
nome: string;
orgId: string;
}
export async function proponiTipoEvento(input: ProponiTipoEventoInput): Promise<TipoEvento> {
const tipoEvento = await tipiEventoRepository.create({
nome: input.nome,
stato: StatoCategoria.da_approvare,
creatoDaOrgId: input.orgId,
});
await creaNotificaModerazione(
'TIPO_EVENTO_PROPOSTO',
`Nuovo tipo evento proposto: "${tipoEvento.nome}"`,
'/tassonomie?tab=tipiEvento',
);
return tipoEvento;
}
export async function aggiornaTipoEvento(id: string, nome: string): Promise<TipoEvento> {
try {
return await tipiEventoRepository.update(id, nome);
} catch (err) {
throw toHttpError(err, 'Tipo evento non trovato', 'Conflitto durante l\'aggiornamento del tipo evento');
throw toHttpError(err, 'Tipo evento non trovato', "Conflitto durante l'aggiornamento del tipo evento");
}
}
@@ -29,3 +54,27 @@ export async function eliminaTipoEvento(id: string): Promise<void> {
);
}
}
export async function approvaTipoEvento(id: string): Promise<TipoEvento> {
const tipoEvento = await tipiEventoRepository.findById(id);
if (!tipoEvento) {
throw new HttpError(404, 'Tipo evento non trovato');
}
if (tipoEvento.stato !== StatoCategoria.da_approvare) {
throw new HttpError(409, 'Il tipo evento è già confermato');
}
return tipiEventoRepository.updateStato(id, StatoCategoria.confermata);
}
export async function rifiutaTipoEvento(id: string): Promise<void> {
const tipoEvento = await tipiEventoRepository.findById(id);
if (!tipoEvento) {
throw new HttpError(404, 'Tipo evento non trovato');
}
if (tipoEvento.stato !== StatoCategoria.da_approvare) {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
await tipiEventoRepository.delete(id);
}
@@ -0,0 +1,237 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const categoriaFindMany = jest.fn();
const categoriaFindUnique = jest.fn();
const categoriaCreate = jest.fn();
const categoriaUpdate = jest.fn();
const categoriaDelete = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
categoria: {
findMany: (...args: unknown[]) => categoriaFindMany(...args),
findUnique: (...args: unknown[]) => categoriaFindUnique(...args),
create: (...args: unknown[]) => categoriaCreate(...args),
update: (...args: unknown[]) => categoriaUpdate(...args),
delete: (...args: unknown[]) => categoriaDelete(...args),
},
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function utenteToken(orgId = 'org-1'): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { 'gruppo-alfa': { id: orgId, roles: [] } },
});
}
function adminCatalogoToken(): string {
return signToken({
sub: 'admin-1',
realm_access: { roles: ['moderatore'] },
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
});
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /categorie', () => {
test('un utente normale non può accedere', async () => {
const response = await request(app).get('/categorie').set('Authorization', `Bearer ${utenteToken()}`);
expect(response.status).toBe(403);
expect(categoriaFindMany).not.toHaveBeenCalled();
});
test('un moderatore vede tutte le categorie', async () => {
categoriaFindMany.mockResolvedValueOnce([{ id: 'c-1', nome: 'Cucina', stato: 'confermata' }]);
const response = await request(app).get('/categorie').set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([{ id: 'c-1', nome: 'Cucina', stato: 'confermata' }]);
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/categorie');
expect(response.status).toBe(401);
expect(categoriaFindMany).not.toHaveBeenCalled();
});
});
describe('POST /categorie', () => {
test('un utente normale non può creare una categoria direttamente', async () => {
const response = await request(app)
.post('/categorie')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ nome: 'Campeggio' });
expect(response.status).toBe(403);
expect(categoriaCreate).not.toHaveBeenCalled();
});
test('un moderatore crea una categoria già confermata', async () => {
categoriaCreate.mockResolvedValueOnce({ id: 'c-2', nome: 'Campeggio', stato: 'confermata' });
const response = await request(app)
.post('/categorie')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Campeggio' });
expect(response.status).toBe(201);
expect(categoriaCreate).toHaveBeenCalledWith({ data: { nome: 'Campeggio', stato: 'confermata' } });
});
});
describe('PUT /categorie/:id', () => {
test('un moderatore può modificare una categoria', async () => {
categoriaUpdate.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina da campo', stato: 'confermata' });
const response = await request(app)
.put('/categorie/c-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Cucina da campo' });
expect(response.status).toBe(200);
expect(categoriaUpdate).toHaveBeenCalledWith({ where: { id: 'c-1' }, data: { nome: 'Cucina da campo' } });
});
});
describe('DELETE /categorie/:id', () => {
test('un moderatore può eliminare una categoria', async () => {
categoriaDelete.mockResolvedValueOnce({ id: 'c-1' });
const response = await request(app).delete('/categorie/c-1').set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(204);
expect(categoriaDelete).toHaveBeenCalledWith({ where: { id: 'c-1' } });
});
});
describe('POST /categorie/proposte', () => {
test("un utente autenticato con un'org propone una categoria, che nasce 'da_approvare'", async () => {
categoriaCreate.mockResolvedValueOnce({
id: 'c-3',
nome: 'Escursionismo',
stato: 'da_approvare',
creatoDaOrgId: 'org-1',
});
const response = await request(app)
.post('/categorie/proposte')
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
.send({ nome: 'Escursionismo' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({ stato: 'da_approvare', creatoDaOrgId: 'org-1' });
expect(categoriaCreate).toHaveBeenCalledWith({
data: { nome: 'Escursionismo', stato: 'da_approvare', creatoDaOrgId: 'org-1' },
});
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/categorie/proposte').send({ nome: 'Escursionismo' });
expect(response.status).toBe(401);
expect(categoriaCreate).not.toHaveBeenCalled();
});
});
describe('POST /categorie/:id/approva', () => {
test('un moderatore può approvare una categoria proposta', async () => {
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'da_approvare' });
categoriaUpdate.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'confermata' });
const response = await request(app)
.post('/categorie/c-3/approva')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(200);
expect(response.body.stato).toBe('confermata');
expect(categoriaUpdate).toHaveBeenCalledWith({ where: { id: 'c-3' }, data: { stato: 'confermata' } });
});
test('risponde 409 se la categoria è già confermata', async () => {
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina', stato: 'confermata' });
const response = await request(app)
.post('/categorie/c-1/approva')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(409);
expect(categoriaUpdate).not.toHaveBeenCalled();
});
test('risponde 404 se la categoria non esiste', async () => {
categoriaFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.post('/categorie/inesistente/approva')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(404);
expect(categoriaUpdate).not.toHaveBeenCalled();
});
});
describe('POST /categorie/:id/rifiuta', () => {
test('un moderatore può rifiutare una categoria proposta, che viene eliminata', async () => {
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-3', nome: 'Escursionismo', stato: 'da_approvare' });
categoriaDelete.mockResolvedValueOnce({ id: 'c-3' });
const response = await request(app)
.post('/categorie/c-3/rifiuta')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(204);
expect(categoriaDelete).toHaveBeenCalledWith({ where: { id: 'c-3' } });
});
test('risponde 409 se la categoria è già confermata', async () => {
categoriaFindUnique.mockResolvedValueOnce({ id: 'c-1', nome: 'Cucina', stato: 'confermata' });
const response = await request(app)
.post('/categorie/c-1/rifiuta')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(409);
expect(categoriaDelete).not.toHaveBeenCalled();
});
});
@@ -15,21 +15,18 @@ const listaCreate = jest.fn();
const listaUpdate = jest.fn();
const listaDelete = jest.fn();
const listaVoceDeleteMany = jest.fn();
const listaSottoListaDeleteMany = jest.fn();
const listaSottoListaFindMany = jest.fn();
const listaModelloFindUnique = jest.fn();
const listaModelloUpdate = jest.fn();
const listaModelloVoceDeleteMany = jest.fn();
// $transaction condiviso da entrambi i repository (liste e liste-modello): il tx
// espone gli stessi metodi mockati usati fuori transazione, così i test possono
// asserire su un'unica lista di chiamate indipendentemente dal fatto che passino
// per una transazione o meno.
// $transaction espone lo stesso client mockato usato fuori transazione, così i test
// possono asserire sull'unica lista di chiamate indipendentemente dal fatto che
// passino per una transazione o meno (creaLista/aggiornaLista aprono sempre una
// propria transazione per orchestrare fork + aggancio sotto-liste atomicamente).
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
callback({
lista: { update: listaUpdate, delete: listaDelete },
lista: { create: listaCreate, update: listaUpdate, delete: listaDelete, findMany: listaFindMany, findFirst: listaFindFirst },
listaVoce: { deleteMany: listaVoceDeleteMany },
listaModello: { update: listaModelloUpdate },
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
listaSottoLista: { deleteMany: listaSottoListaDeleteMany, findMany: listaSottoListaFindMany },
}),
);
@@ -39,16 +36,15 @@ jest.mock('../../src/db/prisma', () => ({
findMany: (...args: unknown[]) => listaFindMany(...args),
findFirst: (...args: unknown[]) => listaFindFirst(...args),
create: (...args: unknown[]) => listaCreate(...args),
update: (...args: unknown[]) => listaUpdate(...args),
delete: (...args: unknown[]) => listaDelete(...args),
},
listaVoce: {
deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args),
},
listaModello: {
findUnique: (...args: unknown[]) => listaModelloFindUnique(...args),
update: (...args: unknown[]) => listaModelloUpdate(...args),
},
listaModelloVoce: {
deleteMany: (...args: unknown[]) => listaModelloVoceDeleteMany(...args),
listaSottoLista: {
deleteMany: (...args: unknown[]) => listaSottoListaDeleteMany(...args),
findMany: (...args: unknown[]) => listaSottoListaFindMany(...args),
},
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
},
@@ -68,14 +64,24 @@ function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function tokenOrg(orgId: string): string {
function tokenOrgUser(orgId: string, sub: string): string {
return signToken({
sub: 'user-1',
sub,
realm_access: { roles: ['censito'] },
organization: { gruppo: { id: orgId, roles: [] } },
});
}
function tokenOrg(orgId: string): string {
return tokenOrgUser(orgId, 'user-1');
}
// Utente autenticato ma senza alcuna organizzazione attiva sul token: deve comunque
// poter creare/gestire le proprie liste personali (bozza/privato/pubblico).
function tokenSenzaOrg(sub = 'user-1'): string {
return signToken({ sub, realm_access: { roles: ['censito'] } });
}
function adminCatalogoToken(): string {
return signToken({
sub: 'admin-1',
@@ -88,6 +94,30 @@ function materialeJoin(id: string, nome: string, unitaMisura: string) {
return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() };
}
// Mirror dell'include usato da liste.repository.ts: le assert sulle chiamate a
// prisma.lista.create/findFirst/findMany lo confrontano per intero.
const INCLUDE_VOCI = {
voci: { include: { materiale: true } },
sottoListe: { include: { sottoLista: { include: { voci: { include: { materiale: true } } } } } },
};
function listaVuota(overrides: Record<string, unknown> = {}) {
return {
id: 'l-1',
nome: 'Lista',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataDaUserId: 'user-1',
creataIl: new Date(),
voci: [],
sottoListe: [],
...overrides,
};
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
@@ -103,24 +133,24 @@ beforeEach(() => {
});
describe('GET /liste', () => {
test('restituisce solo le liste dell\'org corrente, ricavata dal token', async () => {
test('utente con org: filtra su proprie liste (qualunque stato) + liste di gruppo della propria org', async () => {
listaFindMany.mockResolvedValueOnce([]);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { orgId: 'org-a' } }),
expect.objectContaining({
where: { OR: [{ creataDaUserId: 'user-1' }, { stato: 'gruppo', orgId: 'org-a' }] },
}),
);
});
test('org diverse ottengono query filtrate su org_id diversi', async () => {
listaFindMany.mockResolvedValue([]);
test('utente senza org: filtra solo sulle proprie liste', async () => {
listaFindMany.mockResolvedValueOnce([]);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenSenzaOrg()}`);
expect(listaFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
expect(listaFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
expect(listaFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { OR: [{ creataDaUserId: 'user-1' }] } }));
});
test('risponde 401 senza token', async () => {
@@ -129,11 +159,90 @@ describe('GET /liste', () => {
expect(response.status).toBe(401);
expect(listaFindMany).not.toHaveBeenCalled();
});
test('marca inAttesaConferma solo per il creatore della lista, mai per altri membri dell\'org', async () => {
listaFindMany.mockResolvedValueOnce([
listaVuota({
stato: 'gruppo',
orgId: 'org-a',
creataDaUserId: 'user-1',
voci: [{ materialeId: 'm-1', quantita: 1, materiale: { ...materialeJoin('m-1', 'Tenda', 'pz'), stato: 'proposto' } }],
}),
]);
const response = await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(200);
expect(response.body[0].voci[0].inAttesaConferma).toBe(true);
});
test('non marca inAttesaConferma per chi non ha creato la lista e non è moderatore', async () => {
listaFindMany.mockResolvedValueOnce([
listaVuota({
stato: 'gruppo',
orgId: 'org-a',
creataDaUserId: 'un-altro-utente',
voci: [{ materialeId: 'm-1', quantita: 1, materiale: { ...materialeJoin('m-1', 'Tenda', 'pz'), stato: 'proposto' } }],
}),
]);
const response = await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(200);
expect(response.body[0].voci[0].inAttesaConferma).toBeUndefined();
});
});
describe('GET /liste/pubbliche', () => {
test('nessun token richiesto: restituisce le liste pubbliche già approvate', async () => {
listaFindMany.mockResolvedValueOnce([
listaVuota({ id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato', creataDaUserId: 'chiunque' }),
]);
const response = await request(app).get('/liste/pubbliche');
expect(response.status).toBe(200);
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { stato: 'pubblico', statoModerazione: 'approvato' } }),
);
expect(response.body[0].id).toBe('l-pub');
});
test('filtra per tipoEventoId quando richiesto', async () => {
listaFindMany.mockResolvedValueOnce([]);
await request(app).get('/liste/pubbliche').query({ tipoEventoId: 't-1' });
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { stato: 'pubblico', statoModerazione: 'approvato', tipoEventoId: 't-1' } }),
);
});
});
describe('GET /liste/pubbliche/:id', () => {
test('restituisce la lista pubblica approvata (deep-link diretto al catalogo)', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato' }));
const response = await request(app).get('/liste/pubbliche/l-pub');
expect(response.status).toBe(200);
expect(listaFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'l-pub', stato: 'pubblico', statoModerazione: 'approvato' } }),
);
});
test('risponde 404 se non esiste o non è ancora approvata', async () => {
listaFindFirst.mockResolvedValueOnce(null);
const response = await request(app).get('/liste/pubbliche/inesistente');
expect(response.status).toBe(404);
});
});
describe('POST /liste', () => {
test('crea una lista vuota per l\'org corrente, ignorando un org_id eventualmente inviato dal client', async () => {
listaCreate.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista vuota', orgId: 'org-a', creataIl: new Date(), voci: [] });
test('crea una lista bozza personale (senza orgId) ignorando un org_id inviato dal client, anche con org attiva', async () => {
listaCreate.mockResolvedValueOnce(listaVuota({ nome: 'Lista vuota' }));
const response = await request(app)
.post('/liste')
@@ -141,54 +250,279 @@ describe('POST /liste', () => {
.send({ nome: 'Lista vuota', orgId: 'org-spoofed' });
expect(response.status).toBe(201);
expect(response.body.orgId).toBeNull();
expect(listaCreate).toHaveBeenCalledWith({
data: { nome: 'Lista vuota', orgId: 'org-a', voci: { create: [] } },
include: { voci: { include: { materiale: true } } },
data: {
nome: 'Lista vuota',
orgId: null,
creataDaUserId: 'user-1',
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
voci: { create: [] },
sottoListe: { create: [] },
},
include: INCLUDE_VOCI,
});
});
test('crea una lista pubblica anche senza organizzazione attiva sul token, in attesa di approvazione', async () => {
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'proposto', orgId: null }));
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
.send({ nome: 'Campo estivo', stato: 'pubblico', voci: [{ materialeId: 'm-1', quantita: 2 }] });
expect(response.status).toBe(201);
expect(listaCreate).toHaveBeenCalledWith({
data: {
nome: 'Campo estivo',
orgId: null,
creataDaUserId: 'user-1',
stato: 'pubblico',
statoModerazione: 'proposto',
tipoEventoId: null,
parentId: null,
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
sottoListe: { create: [] },
},
include: INCLUDE_VOCI,
});
});
test('un moderatore può creare direttamente una lista pubblica già approvata (bypass della coda)', async () => {
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'approvato' }));
await request(app)
.post('/liste')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Campo estivo', stato: 'pubblico' });
expect(listaCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'approvato' }) }),
);
});
test('risponde 400 se si crea una lista di gruppo senza organizzazione attiva', async () => {
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
.send({ nome: 'Campo estivo', stato: 'gruppo' });
expect(response.status).toBe(400);
expect(listaCreate).not.toHaveBeenCalled();
});
test('crea una lista di gruppo con l\'org attiva sul token', async () => {
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'gruppo', orgId: 'org-a' }));
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo', stato: 'gruppo' });
expect(response.status).toBe(201);
expect(listaCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a', stato: 'gruppo', statoModerazione: null }) }),
);
});
test('risponde 400 se lo stato indicato non è valido', async () => {
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo', stato: 'inesistente' });
expect(response.status).toBe(400);
expect(listaCreate).not.toHaveBeenCalled();
});
});
describe('POST /liste/da-modello/:listaModelloId', () => {
test('copia nome e voci dalla lista modello, senza salvare alcun riferimento verso di essa', async () => {
const vociModello = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }];
listaModelloFindUnique.mockResolvedValueOnce({
id: 'lm-1',
nome: 'Kit campo estivo',
tipoEventoId: 't-1',
pubblica: true,
voci: vociModello,
});
listaCreate.mockResolvedValueOnce({
id: 'l-2',
nome: 'Kit campo estivo',
orgId: 'org-a',
creataIl: new Date(),
voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }],
});
describe('POST /liste — sotto-liste', () => {
test('aggancia una lista personale propria come sotto-lista', async () => {
listaSottoListaFindMany.mockResolvedValueOnce([]); // nessuna con proprie sotto-liste
listaFindMany.mockResolvedValueOnce([{ id: 'l-esistente' }]); // visibile come candidata
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-nuova' }));
const response = await request(app)
.post('/liste/da-modello/lm-1')
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-esistente'] });
expect(response.status).toBe(201);
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: { in: ['l-esistente'] },
OR: [{ creataDaUserId: 'user-1' }, { stato: 'pubblico', statoModerazione: 'approvato' }],
},
}),
);
expect(listaCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ sottoListe: { create: [{ sottoListaId: 'l-esistente' }] } }) }),
);
});
test('una lista personale (non di gruppo) non può agganciare una lista di gruppo come sotto-lista', async () => {
// Il candidato è di gruppo: la query "visibili" con permettiGruppo=false non lo include mai,
// quindi il mock restituisce vuoto per simulare "non trovato/non utilizzabile".
listaFindMany.mockResolvedValueOnce([]);
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-gruppo-altrui'] });
expect(response.status).toBe(400);
expect(listaCreate).not.toHaveBeenCalled();
});
test('una lista di gruppo può agganciare sia una propria lista personale sia una lista di gruppo della stessa org', async () => {
listaSottoListaFindMany.mockResolvedValueOnce([]);
listaFindMany.mockResolvedValueOnce([{ id: 'l-personale' }, { id: 'l-gruppo' }]);
listaCreate.mockResolvedValueOnce(listaVuota({ stato: 'gruppo', orgId: 'org-a' }));
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo', stato: 'gruppo', sottoListeIds: ['l-personale', 'l-gruppo'] });
expect(response.status).toBe(201);
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: { in: ['l-personale', 'l-gruppo'] },
OR: [
{ creataDaUserId: 'user-1' },
{ stato: 'gruppo', orgId: 'org-a' },
{ stato: 'pubblico', statoModerazione: 'approvato' },
],
},
}),
);
});
test('rifiuta una sotto-lista che ha già proprie sotto-liste (nesting a due livelli)', async () => {
listaFindMany.mockResolvedValueOnce([{ id: 'l-annidata' }]);
listaSottoListaFindMany.mockResolvedValueOnce([{ listaId: 'l-annidata' }]);
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo', stato: 'bozza', sottoListeIds: ['l-annidata'] });
expect(response.status).toBe(400);
expect(listaCreate).not.toHaveBeenCalled();
});
test('rifiuta l\'auto-riferimento su update', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' }));
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ sottoListeIds: ['l-1'] });
expect(response.status).toBe(400);
expect(listaUpdate).not.toHaveBeenCalled();
});
});
describe('POST /liste/da-fork/:id', () => {
test('copia nome/voci dalla lista pubblica di origine in una nuova lista bozza personale, salvando parentId', async () => {
const vociSorgente = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }];
listaFindFirst.mockResolvedValueOnce({
id: 'l-pub-1',
nome: 'Kit campo estivo',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 't-1',
parentId: null,
creataDaUserId: 'autore-originale',
creataIl: new Date(),
voci: vociSorgente,
sottoListe: [],
});
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-2', nome: 'Kit campo estivo', parentId: 'l-pub-1' }));
const response = await request(app)
.post('/liste/da-fork/l-pub-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(201);
expect(JSON.stringify(response.body)).not.toMatch(/listaModello/i);
expect(response.body.parentId).toBe('l-pub-1');
expect(listaFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'l-pub-1', stato: 'pubblico', statoModerazione: 'approvato' } }),
);
const callArg = listaCreate.mock.calls[0][0];
expect(callArg.data).toEqual({
nome: 'Kit campo estivo',
orgId: 'org-a',
orgId: null,
creataDaUserId: 'user-1',
stato: 'bozza',
statoModerazione: null,
tipoEventoId: 't-1',
parentId: 'l-pub-1',
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
sottoListe: { create: [] },
});
// Le voci passate a create sono un array nuovo con valori copiati, non lo
// stesso array (né gli stessi oggetti) restituiti dalla lista modello.
expect(callArg.data.voci.create).not.toBe(vociModello);
// stesso array (né gli stessi oggetti) restituiti dalla lista di origine.
expect(callArg.data.voci.create).not.toBe(vociSorgente);
});
test('risponde 404 se la lista modello non esiste', async () => {
listaModelloFindUnique.mockResolvedValueOnce(null);
test('include anche i materiali delle sotto-liste, sommando le quantità con quelli di primo livello', async () => {
listaFindFirst.mockResolvedValueOnce({
id: 'l-pub-1',
nome: 'Kit campo estivo',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 't-1',
parentId: null,
creataDaUserId: 'autore-originale',
creataIl: new Date(),
voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }],
sottoListe: [
{
sottoLista: {
id: 'l-pub-2',
nome: 'Kit pronto soccorso',
voci: [
{ materialeId: 'm-1', quantita: 1, materiale: materialeJoin('m-1', 'Tenda', 'pz') },
{ materialeId: 'm-2', quantita: 3, materiale: materialeJoin('m-2', 'Garza', 'pz') },
],
},
},
],
});
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-2', nome: 'Kit campo estivo' }));
const response = await request(app)
.post('/liste/da-modello/inesistente')
.post('/liste/da-fork/l-pub-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(201);
const callArg = listaCreate.mock.calls[0][0];
expect(callArg.data.voci.create).toEqual(
expect.arrayContaining([
{ materialeId: 'm-1', quantita: 3 },
{ materialeId: 'm-2', quantita: 3 },
]),
);
expect(callArg.data.voci.create).toHaveLength(2);
});
test('risponde 404 se la lista di origine non esiste o non è pubblica approvata', async () => {
listaFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.post('/liste/da-fork/inesistente')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(404);
@@ -196,33 +530,149 @@ describe('POST /liste/da-modello/:listaModelloId', () => {
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/liste/da-modello/lm-1');
const response = await request(app).post('/liste/da-fork/l-pub-1');
expect(response.status).toBe(401);
expect(listaCreate).not.toHaveBeenCalled();
});
});
describe('PUT /liste/:id — isolamento tra org', () => {
test('un\'org non può modificare una lista di un\'altra org (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => {
// La query combina sempre id + orgId: una lista di un'altra org non viene trovata.
listaFindFirst.mockResolvedValueOnce(null);
describe('GET /liste/proposte', () => {
test('risponde 401 senza token', async () => {
const response = await request(app).get('/liste/proposte');
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
.send({ nome: 'Nome modificato' });
expect(response.status).toBe(401);
expect(listaFindMany).not.toHaveBeenCalled();
});
expect(response.status).toBe(404);
expect(listaFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-b' } }),
test('risponde 403 per un utente senza ruolo moderatore', async () => {
const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(403);
expect(listaFindMany).not.toHaveBeenCalled();
});
test('elenca le liste pubbliche in attesa di decisione, in ordine FIFO', async () => {
listaFindMany.mockResolvedValueOnce([listaVuota({ id: 'l-p1', stato: 'pubblico', statoModerazione: 'proposto' })]);
const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(200);
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { stato: 'pubblico', statoModerazione: 'proposto' },
orderBy: { creataIl: 'asc' },
}),
);
});
});
describe('PATCH /liste/proposte/:id', () => {
test('risponde 401 senza token', async () => {
const response = await request(app).patch('/liste/proposte/l-1').send({ decisione: 'approvato' });
expect(response.status).toBe(401);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('l\'org proprietaria può modificare la propria lista', async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Vecchio nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
listaUpdate.mockResolvedValueOnce({ id: 'l-1', nome: 'Nuovo nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
test('risponde 403 per un utente senza ruolo moderatore', async () => {
const response = await request(app)
.patch('/liste/proposte/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(403);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('risponde 404 se la proposta non esiste', async () => {
listaFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.patch('/liste/proposte/inesistente')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(404);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('risponde 409 se la proposta è già stata decisa', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ stato: 'pubblico', statoModerazione: 'approvato' }));
const response = await request(app)
.patch('/liste/proposte/l-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(409);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('approva una proposta', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' }));
const response = await request(app)
.patch('/liste/proposte/l-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalledWith({
where: { id: 'l-1' },
data: { statoModerazione: 'approvato' },
include: INCLUDE_VOCI,
});
});
test('rifiuta una proposta', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'rifiutato' }));
const response = await request(app)
.patch('/liste/proposte/l-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'rifiutato' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: { statoModerazione: 'rifiutato' } }),
);
});
});
describe('PUT /liste/:id — ownership', () => {
test('non si può modificare la lista personale di un altro utente (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', creataDaUserId: 'un-altro-utente' }));
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Nome modificato' });
expect(response.status).toBe(404);
expect(listaFindFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-1' } }));
expect(listaUpdate).not.toHaveBeenCalled();
});
test('non si può modificare una lista di gruppo di un\'altra org', async () => {
listaFindFirst.mockResolvedValueOnce(
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-b', creataDaUserId: 'un-altro-utente' }),
);
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Nome modificato' });
expect(response.status).toBe(404);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('il creatore può modificare la propria lista personale', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', nome: 'Vecchio nome' }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', nome: 'Nuovo nome' }));
const response = await request(app)
.put('/liste/l-1')
@@ -235,80 +685,179 @@ describe('PUT /liste/:id — isolamento tra org', () => {
);
});
test('un membro dell\'org (non il creatore) può modificare una lista di gruppo della propria org', async () => {
listaFindFirst.mockResolvedValueOnce(
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }),
);
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', nome: 'Nuovo nome' }));
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Nuovo nome' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).put('/liste/l-1').send({ nome: 'x' });
expect(response.status).toBe(401);
expect(listaFindFirst).not.toHaveBeenCalled();
});
test('un membro dell\'org (non il creatore) non può cambiare lo stato di una lista di gruppo altrui', async () => {
listaFindFirst.mockResolvedValueOnce(
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }),
);
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ stato: 'privato' });
expect(response.status).toBe(403);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('un membro dell\'org (non il creatore) può modificare voci/nome di una lista di gruppo senza cambiarne lo stato', async () => {
listaFindFirst.mockResolvedValueOnce(
listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'un-altro-utente' }),
);
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', nome: 'Nuovo nome' }));
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Nuovo nome', stato: 'gruppo' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalled();
});
test('il creatore può cambiare lo stato della propria lista di gruppo', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'gruppo', orgId: 'org-a', creataDaUserId: 'user-1' }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'privato', orgId: null }));
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ stato: 'privato' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalled();
});
});
describe('DELETE /liste/:id — isolamento tra org', () => {
test('un\'org non può eliminare una lista di un\'altra org', async () => {
listaFindFirst.mockResolvedValueOnce(null);
describe('PUT /liste/:id — promozione a pubblico e ricalcolo della moderazione', () => {
test('promuovere una bozza a pubblico forza statoModerazione a "proposto"', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'bozza', statoModerazione: null }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
.send({ stato: 'pubblico' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ stato: 'pubblico', statoModerazione: 'proposto' }) }),
);
});
test('un update banale (solo nome) su una lista già pubblica non resetta una moderazione già decisa', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' }));
listaUpdate.mockResolvedValueOnce(
listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato', nome: 'Nuovo nome' }),
);
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
.send({ nome: 'Nuovo nome' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'approvato' }) }),
);
});
test('modificare le voci di una lista già pubblica riporta statoModerazione a "proposto"', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'approvato' }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-1', stato: 'pubblico', statoModerazione: 'proposto' }));
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenSenzaOrg()}`)
.send({ voci: [] });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ statoModerazione: 'proposto' }) }),
);
});
});
describe('DELETE /liste/:id — ownership', () => {
test('non si può eliminare la lista personale di un altro utente', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1', creataDaUserId: 'un-altro-utente' }));
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(404);
expect(listaDelete).not.toHaveBeenCalled();
});
test('l\'org proprietaria può eliminare la propria lista', async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista', orgId: 'org-a', creataIl: new Date(), voci: [] });
test('il creatore può eliminare la propria lista', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' }));
listaDelete.mockResolvedValueOnce({ id: 'l-1' });
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(204);
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
expect(listaSottoListaDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
expect(listaDelete).toHaveBeenCalledWith({ where: { id: 'l-1' } });
});
});
describe('indipendenza tra lista modello e lista forkata', () => {
test('aggiornare la lista modello originale non tocca in alcun modo le tabelle della lista privata forkata', async () => {
listaModelloUpdate.mockResolvedValueOnce({
id: 'lm-1',
nome: 'Kit aggiornato',
tipoEventoId: 't-1',
pubblica: true,
voci: [],
});
const response = await request(app)
.put('/liste-modello/lm-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Kit aggiornato', voci: [] });
expect(response.status).toBe(200);
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
expect(listaModelloUpdate).toHaveBeenCalled();
// Nessuna chiamata sulle tabelle delle liste private: le due entità sono
// completamente disgiunte dopo il fork.
expect(listaVoceDeleteMany).not.toHaveBeenCalled();
expect(listaUpdate).not.toHaveBeenCalled();
expect(listaDelete).not.toHaveBeenCalled();
});
test('aggiornare la lista privata forkata non tocca in alcun modo le tabelle della lista modello originale', async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-2', nome: 'Kit campo estivo', orgId: 'org-a', creataIl: new Date(), voci: [] });
listaUpdate.mockResolvedValueOnce({
id: 'l-2',
nome: 'Kit campo estivo (personalizzato)',
orgId: 'org-a',
describe('fork: lineage e indipendenza tra lista di origine e lista forkata', () => {
test('la lista forkata ha parentId popolato verso la lista pubblica di origine', async () => {
listaFindFirst.mockResolvedValueOnce({
id: 'l-pub-1',
nome: 'Kit',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: null,
parentId: null,
creataDaUserId: 'altro-utente',
creataIl: new Date(),
voci: [],
sottoListe: [],
});
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' }));
const response = await request(app)
.put('/liste/l-2')
.post('/liste/da-fork/l-pub-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(201);
expect(response.body.parentId).toBe('l-pub-1');
});
test('modificare la lista figlia tocca solo la sua riga: nessuna scrittura sulla lista di origine', async () => {
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' }));
listaUpdate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1', nome: 'Personalizzata' }));
const response = await request(app)
.put('/liste/l-figlia')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Kit campo estivo (personalizzato)', voci: [] });
.send({ nome: 'Personalizzata', voci: [] });
expect(response.status).toBe(200);
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-2' } });
expect(listaUpdate).toHaveBeenCalled();
expect(listaModelloVoceDeleteMany).not.toHaveBeenCalled();
expect(listaModelloUpdate).not.toHaveBeenCalled();
expect(listaUpdate).toHaveBeenCalledTimes(1);
expect(listaUpdate).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-figlia' } }));
});
});
@@ -1,252 +0,0 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const listaModelloFindMany = jest.fn();
const listaModelloCreate = jest.fn();
// tx espone gli stessi metodi usati dal repository dentro $transaction; i test su
// update/delete forniscono un tx dedicato via mockImplementationOnce.
const listaModelloVoceDeleteMany = jest.fn();
const listaModelloUpdate = jest.fn();
const listaModelloDelete = jest.fn();
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
callback({
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
listaModello: { update: listaModelloUpdate, delete: listaModelloDelete },
}),
);
jest.mock('../../src/db/prisma', () => ({
prisma: {
listaModello: {
findMany: (...args: unknown[]) => listaModelloFindMany(...args),
create: (...args: unknown[]) => listaModelloCreate(...args),
},
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function utenteToken(): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
});
}
function adminCatalogoToken(): string {
return signToken({
sub: 'admin-1',
realm_access: { roles: ['moderatore'] },
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
});
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /liste-modello', () => {
test('è pubblico e restituisce le liste con le voci (materiale + quantità)', async () => {
listaModelloFindMany.mockResolvedValueOnce([
{
id: 'lm-1',
nome: 'Kit campo estivo',
tipoEventoId: 't-1',
pubblica: true,
voci: [{ materialeId: 'm-1', quantita: 2, materiale: { id: 'm-1', nome: 'Tenda', unitaMisura: 'pz' } }],
},
]);
const response = await request(app).get('/liste-modello');
expect(response.status).toBe(200);
expect(response.body).toEqual([
{
id: 'lm-1',
nome: 'Kit campo estivo',
tipoEventoId: 't-1',
voci: [{ materialeId: 'm-1', nome: 'Tenda', unitaMisura: 'pz', quantita: 2 }],
},
]);
expect(listaModelloFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { pubblica: true } }),
);
});
test('filtra per tipoEventoId quando richiesto', async () => {
listaModelloFindMany.mockResolvedValueOnce([]);
await request(app).get('/liste-modello').query({ tipoEventoId: 't-1' });
expect(listaModelloFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { pubblica: true, tipoEventoId: 't-1' } }),
);
});
});
describe('POST /liste-modello', () => {
const body = { nome: 'Kit bivacco', tipoEventoId: 't-2', voci: [{ materialeId: 'm-1', quantita: 3 }] };
test('un utente normale non può creare una lista modello', async () => {
const response = await request(app)
.post('/liste-modello')
.set('Authorization', `Bearer ${utenteToken()}`)
.send(body);
expect(response.status).toBe(403);
expect(listaModelloCreate).not.toHaveBeenCalled();
});
test('un moderatore può creare una lista modello, sempre pubblica', async () => {
listaModelloCreate.mockResolvedValueOnce({
id: 'lm-2',
nome: 'Kit bivacco',
tipoEventoId: 't-2',
pubblica: true,
voci: [{ materialeId: 'm-1', quantita: 3, materiale: { id: 'm-1', nome: 'Corda', unitaMisura: 'm' } }],
});
const response = await request(app)
.post('/liste-modello')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send(body);
expect(response.status).toBe(201);
expect(listaModelloCreate).toHaveBeenCalledWith({
data: {
nome: 'Kit bivacco',
tipoEventoId: 't-2',
pubblica: true,
voci: { create: [{ materialeId: 'm-1', quantita: 3 }] },
},
include: { voci: { include: { materiale: true } } },
});
});
test('ignora un eventuale pubblica:false inviato dal client, resta sempre true', async () => {
listaModelloCreate.mockResolvedValueOnce({
id: 'lm-3',
nome: 'Kit bivacco',
tipoEventoId: 't-2',
pubblica: true,
voci: [],
});
await request(app)
.post('/liste-modello')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ ...body, voci: [], pubblica: false });
expect(listaModelloCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ pubblica: true }) }),
);
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/liste-modello').send(body);
expect(response.status).toBe(401);
expect(listaModelloCreate).not.toHaveBeenCalled();
});
});
describe('PUT /liste-modello/:id', () => {
test('un utente normale non può modificare una lista modello', async () => {
const response = await request(app)
.put('/liste-modello/lm-1')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ nome: 'Kit aggiornato' });
expect(response.status).toBe(403);
expect(listaModelloUpdate).not.toHaveBeenCalled();
});
test('un moderatore può modificare nome e voci di una lista modello', async () => {
listaModelloUpdate.mockResolvedValueOnce({
id: 'lm-1',
nome: 'Kit aggiornato',
tipoEventoId: 't-1',
pubblica: true,
voci: [{ materialeId: 'm-2', quantita: 1, materiale: { id: 'm-2', nome: 'Torcia', unitaMisura: 'pz' } }],
});
const response = await request(app)
.put('/liste-modello/lm-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Kit aggiornato', voci: [{ materialeId: 'm-2', quantita: 1 }] });
expect(response.status).toBe(200);
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
expect(listaModelloUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'lm-1' },
data: expect.objectContaining({
nome: 'Kit aggiornato',
voci: { create: [{ materialeId: 'm-2', quantita: 1 }] },
}),
}),
);
});
});
describe('DELETE /liste-modello/:id', () => {
test('un utente normale non può eliminare una lista modello', async () => {
const response = await request(app).delete('/liste-modello/lm-1').set('Authorization', `Bearer ${utenteToken()}`);
expect(response.status).toBe(403);
expect(listaModelloDelete).not.toHaveBeenCalled();
});
test('un moderatore può eliminare una lista modello (e le sue voci)', async () => {
listaModelloDelete.mockResolvedValueOnce({ id: 'lm-1' });
const response = await request(app)
.delete('/liste-modello/lm-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(204);
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
expect(listaModelloDelete).toHaveBeenCalledWith({ where: { id: 'lm-1' } });
});
test('risponde 401 senza token', async () => {
const response = await request(app).delete('/liste-modello/lm-1');
expect(response.status).toBe(401);
expect(listaModelloDelete).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,7 @@ process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const tipoEventoFindMany = jest.fn();
const tipoEventoFindUnique = jest.fn();
const tipoEventoCreate = jest.fn();
const tipoEventoUpdate = jest.fn();
const tipoEventoDelete = jest.fn();
@@ -18,6 +19,7 @@ jest.mock('../../src/db/prisma', () => ({
prisma: {
tipoEvento: {
findMany: (...args: unknown[]) => tipoEventoFindMany(...args),
findUnique: (...args: unknown[]) => tipoEventoFindUnique(...args),
create: (...args: unknown[]) => tipoEventoCreate(...args),
update: (...args: unknown[]) => tipoEventoUpdate(...args),
delete: (...args: unknown[]) => tipoEventoDelete(...args),
@@ -39,11 +41,11 @@ function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function utenteToken(): string {
function utenteToken(orgId = 'org-1'): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
organization: { 'gruppo-alfa': { id: orgId, roles: [] } },
});
}
@@ -70,13 +72,49 @@ beforeEach(() => {
});
describe('GET /tipi-evento', () => {
test('è pubblico e restituisce la lista', async () => {
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo' }]);
test('è pubblico e restituisce solo i tipi evento confermati', async () => {
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]);
const response = await request(app).get('/tipi-evento');
expect(response.status).toBe(200);
expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo' }]);
expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]);
expect(tipoEventoFindMany).toHaveBeenCalledWith({
where: { stato: 'confermata' },
orderBy: { nome: 'asc' },
});
});
});
describe('GET /tipi-evento/moderazione', () => {
test('un utente normale non può accedere', async () => {
const response = await request(app)
.get('/tipi-evento/moderazione')
.set('Authorization', `Bearer ${utenteToken()}`);
expect(response.status).toBe(403);
expect(tipoEventoFindMany).not.toHaveBeenCalled();
});
test('un moderatore vede tutti i tipi evento, incluse le proposte', async () => {
tipoEventoFindMany.mockResolvedValueOnce([
{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' },
{ id: 't-2', nome: 'Bivacco', stato: 'da_approvare' },
]);
const response = await request(app)
.get('/tipi-evento/moderazione')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/tipi-evento/moderazione');
expect(response.status).toBe(401);
expect(tipoEventoFindMany).not.toHaveBeenCalled();
});
});
@@ -91,8 +129,8 @@ describe('POST /tipi-evento', () => {
expect(tipoEventoCreate).not.toHaveBeenCalled();
});
test('un moderatore può creare un tipo evento', async () => {
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco' });
test('un moderatore crea un tipo evento già confermato', async () => {
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco', stato: 'confermata' });
const response = await request(app)
.post('/tipi-evento')
@@ -100,7 +138,7 @@ describe('POST /tipi-evento', () => {
.send({ nome: 'Bivacco' });
expect(response.status).toBe(201);
expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco' } });
expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco', stato: 'confermata' } });
});
test('risponde 401 senza token', async () => {
@@ -111,6 +149,35 @@ describe('POST /tipi-evento', () => {
});
});
describe('POST /tipi-evento/proposte', () => {
test("un utente autenticato con un'org propone un tipo evento, che nasce 'da_approvare'", async () => {
tipoEventoCreate.mockResolvedValueOnce({
id: 't-3',
nome: 'Uscita notturna',
stato: 'da_approvare',
creatoDaOrgId: 'org-1',
});
const response = await request(app)
.post('/tipi-evento/proposte')
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
.send({ nome: 'Uscita notturna' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({ stato: 'da_approvare', creatoDaOrgId: 'org-1' });
expect(tipoEventoCreate).toHaveBeenCalledWith({
data: { nome: 'Uscita notturna', stato: 'da_approvare', creatoDaOrgId: 'org-1' },
});
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/tipi-evento/proposte').send({ nome: 'Uscita notturna' });
expect(response.status).toBe(401);
expect(tipoEventoCreate).not.toHaveBeenCalled();
});
});
describe('PUT /tipi-evento/:id', () => {
test('un utente normale non può modificare un tipo evento', async () => {
const response = await request(app)
@@ -159,3 +226,65 @@ describe('DELETE /tipi-evento/:id', () => {
expect(tipoEventoDelete).not.toHaveBeenCalled();
});
});
describe('POST /tipi-evento/:id/approva', () => {
test('un moderatore può approvare un tipo evento proposto', async () => {
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'da_approvare' });
tipoEventoUpdate.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'confermata' });
const response = await request(app)
.post('/tipi-evento/t-3/approva')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(200);
expect(response.body.stato).toBe('confermata');
expect(tipoEventoUpdate).toHaveBeenCalledWith({ where: { id: 't-3' }, data: { stato: 'confermata' } });
});
test('risponde 409 se il tipo evento è già confermato', async () => {
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-1', nome: 'Campo estivo', stato: 'confermata' });
const response = await request(app)
.post('/tipi-evento/t-1/approva')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(409);
expect(tipoEventoUpdate).not.toHaveBeenCalled();
});
test('risponde 404 se il tipo evento non esiste', async () => {
tipoEventoFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.post('/tipi-evento/inesistente/approva')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(404);
expect(tipoEventoUpdate).not.toHaveBeenCalled();
});
});
describe('POST /tipi-evento/:id/rifiuta', () => {
test('un moderatore può rifiutare un tipo evento proposto, che viene eliminato', async () => {
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-3', nome: 'Uscita notturna', stato: 'da_approvare' });
tipoEventoDelete.mockResolvedValueOnce({ id: 't-3' });
const response = await request(app)
.post('/tipi-evento/t-3/rifiuta')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(204);
expect(tipoEventoDelete).toHaveBeenCalledWith({ where: { id: 't-3' } });
});
test('risponde 409 se il tipo evento è già confermato', async () => {
tipoEventoFindUnique.mockResolvedValueOnce({ id: 't-1', nome: 'Campo estivo', stato: 'confermata' });
const response = await request(app)
.post('/tipi-evento/t-1/rifiuta')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(409);
expect(tipoEventoDelete).not.toHaveBeenCalled();
});
});