Sistemato magazzino
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user