Sistemato magazzino

This commit is contained in:
Lorenzo Sanesi
2026-08-01 03:12:23 +02:00
parent de921abcd6
commit 0fdf395733
128 changed files with 9307 additions and 3076 deletions
+6
View File
@@ -13,6 +13,12 @@
"displayNameHtml": "Scouthub",
"loginTheme": "scouthub",
"accessTokenLifespan": 3600,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 28800,
"offlineSessionMaxLifespan": 36000,
"roles": {
"realm": [
{
+1 -1
View File
@@ -13,4 +13,4 @@ RUN npm run build
EXPOSE 8083
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed && node dist/server.js"]
+4
View File
@@ -12,6 +12,7 @@
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"test": "jest --runInBand --passWithNoTests",
"test:watch": "jest --watch --runInBand"
},
@@ -38,5 +39,8 @@
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
@@ -4,6 +4,18 @@ CREATE TYPE "stato_materiale" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_magazzino_voce" AS ENUM ('buono', 'da_riparare', 'mancante');
-- CreateEnum
CREATE TYPE "stato_lista" AS ENUM ('bozza', 'privato', 'gruppo', 'pubblico');
-- CreateEnum
CREATE TYPE "stato_moderazione_lista" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_categoria" AS ENUM ('confermata', 'da_approvare');
-- CreateEnum
CREATE TYPE "tipo_notifica" AS ENUM ('MATERIALE_PROPOSTO', 'CATEGORIA_PROPOSTA', 'TIPO_EVENTO_PROPOSTO', 'LISTA_PROPOSTA');
-- CreateTable
CREATE TABLE "materiale" (
"id" TEXT NOT NULL,
@@ -21,35 +33,35 @@ CREATE TABLE "materiale" (
CREATE TABLE "tipo_evento" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
"creato_da_org_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tipo_evento_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello" (
CREATE TABLE "categoria" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"tipo_evento_id" TEXT NOT NULL,
"pubblica" BOOLEAN NOT NULL DEFAULT true,
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
"creato_da_org_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lista_modello_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello_voce" (
"lista_modello_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"quantita" INTEGER NOT NULL,
CONSTRAINT "lista_modello_voce_pkey" PRIMARY KEY ("lista_modello_id","materiale_id")
CONSTRAINT "categoria_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"org_id" TEXT,
"creata_da_user_id" TEXT,
"stato" "stato_lista" NOT NULL DEFAULT 'bozza',
"creata_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"stato_moderazione" "stato_moderazione_lista",
"tipo_evento_id" TEXT,
"parent_id" TEXT,
CONSTRAINT "lista_pkey" PRIMARY KEY ("id")
);
@@ -63,6 +75,14 @@ CREATE TABLE "lista_voce" (
CONSTRAINT "lista_voce_pkey" PRIMARY KEY ("lista_id","materiale_id")
);
-- CreateTable
CREATE TABLE "lista_sotto_lista" (
"lista_id" TEXT NOT NULL,
"sotto_lista_id" TEXT NOT NULL,
CONSTRAINT "lista_sotto_lista_pkey" PRIMARY KEY ("lista_id","sotto_lista_id")
);
-- CreateTable
CREATE TABLE "magazzino_voce" (
"id" TEXT NOT NULL,
@@ -72,10 +92,22 @@ CREATE TABLE "magazzino_voce" (
"stato" "stato_magazzino_voce" NOT NULL,
"posizione" TEXT,
"note" TEXT,
"gruppo_id" TEXT,
CONSTRAINT "magazzino_voce_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "gruppo_magazzino" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"parent_id" TEXT,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gruppo_magazzino_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evento" (
"id" TEXT NOT NULL,
@@ -97,14 +129,41 @@ CREATE TABLE "evento_check" (
CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id")
);
-- AddForeignKey
ALTER TABLE "lista_modello" ADD CONSTRAINT "lista_modello_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "notifica" (
"id" SERIAL NOT NULL,
"tipo" "tipo_notifica" NOT NULL,
"messaggio" TEXT NOT NULL,
"link" TEXT,
"letta" BOOLEAN NOT NULL DEFAULT false,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifica_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "lista_stato_stato_moderazione_idx" ON "lista"("stato", "stato_moderazione");
-- CreateIndex
CREATE INDEX "lista_tipo_evento_id_idx" ON "lista"("tipo_evento_id");
-- CreateIndex
CREATE INDEX "lista_parent_id_idx" ON "lista"("parent_id");
-- CreateIndex
CREATE INDEX "gruppo_magazzino_org_id_idx" ON "gruppo_magazzino"("org_id");
-- CreateIndex
CREATE INDEX "gruppo_magazzino_parent_id_idx" ON "gruppo_magazzino"("parent_id");
-- CreateIndex
CREATE INDEX "magazzino_voce_gruppo_id_idx" ON "magazzino_voce"("gruppo_id");
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_lista_modello_id_fkey" FOREIGN KEY ("lista_modello_id") REFERENCES "lista_modello"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "lista" ADD CONSTRAINT "lista_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "lista" ADD CONSTRAINT "lista_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "lista"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -112,9 +171,21 @@ ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY (
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_sotto_lista" ADD CONSTRAINT "lista_sotto_lista_sotto_lista_id_fkey" FOREIGN KEY ("sotto_lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_gruppo_id_fkey" FOREIGN KEY ("gruppo_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "gruppo_magazzino" ADD CONSTRAINT "gruppo_magazzino_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "gruppo_magazzino"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+134 -27
View File
@@ -23,6 +23,68 @@ enum StatoMagazzinoVoce {
@@map("stato_magazzino_voce")
}
enum StatoLista {
bozza
privato
gruppo
pubblico
@@map("stato_lista")
}
enum StatoModerazioneLista {
proposto
approvato
rifiutato
@@map("stato_moderazione_lista")
}
enum StatoCategoria {
confermata
da_approvare
@@map("stato_categoria")
}
enum TipoNotifica {
MATERIALE_PROPOSTO
CATEGORIA_PROPOSTA
TIPO_EVENTO_PROPOSTO
LISTA_PROPOSTA
@@map("tipo_notifica")
}
// Coda di notifiche di moderazione (nuove proposte di materiale/categoria/tipo
// evento/lista in attesa di revisione): visibile solo a chi ha ruolo 'moderatore',
// non ci sono ancora notifiche personali nel dominio magazzino.
model Notifica {
id Int @id @default(autoincrement())
tipo TipoNotifica
messaggio String
link String?
letta Boolean @default(false)
dataCreazione DateTime @default(now()) @map("data_creazione")
@@map("notifica")
}
// Tassonomia delle categorie di materiale, gestita dal moderatore (CRUD diretto,
// sempre creata "confermata") con un flusso di proposta aperto a qualsiasi org
// autenticata (crea in "da_approvare", poi approvata/rifiutata dal moderatore).
// Materiale.categoria resta per ora un campo stringa libero, non collegato a questa
// tabella: introdurla come FK è un passo successivo, fuori scope qui.
model Categoria {
id String @id @default(uuid())
nome String
stato StatoCategoria @default(confermata)
creatoDaOrgId String? @map("creato_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
@@map("categoria")
}
model Materiale {
id String @id @default(uuid())
nome String
@@ -32,7 +94,6 @@ model Materiale {
propostoDaOrgId String @map("proposto_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
listaModelloVoci ListaModelloVoce[]
listaVoci ListaVoce[]
magazzinoVoci MagazzinoVoce[]
eventoCheck EventoCheck[]
@@ -43,45 +104,57 @@ model Materiale {
model TipoEvento {
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")
// 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])
gruppo GruppoMagazzino? @relation(fields: [gruppoId], references: [id], onDelete: SetNull)
@@index([gruppoId])
@@map("magazzino_voce")
}
+204
View File
@@ -0,0 +1,204 @@
import { PrismaClient, StatoMateriale } from "@prisma/client";
const prisma = new PrismaClient();
// Placeholder di orgId per il materiale di catalogo seedato manualmente (non riconducibile
// a nessuna organizzazione reale), analogo a UTENTE_MODIFICA="MANUALE" nel seed di attivita-be.
const ORG_SISTEMA = "SISTEMA";
async function main() {
const materiali = [
{ id: "materiale-corda", nome: "Corda", categoria: "Attrezzatura", unitaMisura: "pz" },
{ id: "materiale-torcia", nome: "Torcia", categoria: "Attrezzatura", unitaMisura: "pz" },
{
id: "materiale-kit-primo-soccorso",
nome: "Kit primo soccorso",
categoria: "Sicurezza",
unitaMisura: "kit",
},
{ id: "materiale-tenda", nome: "Tenda", categoria: "Campeggio", unitaMisura: "pz" },
{
id: "materiale-fornello-campo",
nome: "Fornello da campo",
categoria: "Campeggio",
unitaMisura: "pz",
},
{
id: "materiale-zaino",
nome: "Zaino",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{
id: "materiale-sacco-a-pelo",
nome: "Sacco a pelo",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{
id: "materiale-borraccia",
nome: "Borraccia",
categoria: "Equipaggiamento personale",
unitaMisura: "pz",
},
{ id: "materiale-garze", nome: "Garze", categoria: "Sicurezza", unitaMisura: "pz" },
{ id: "materiale-cerotti", nome: "Cerotti", categoria: "Sicurezza", unitaMisura: "pz" },
{
id: "materiale-disinfettante",
nome: "Disinfettante",
categoria: "Sicurezza",
unitaMisura: "flacone",
},
];
await Promise.all(
materiali.map((materiale) =>
prisma.materiale.upsert({
where: { id: materiale.id },
update: {
...materiale,
stato: StatoMateriale.approvato,
propostoDaOrgId: ORG_SISTEMA,
},
create: {
...materiale,
stato: StatoMateriale.approvato,
propostoDaOrgId: ORG_SISTEMA,
},
})
)
);
const tipiEvento = [
{ id: "tipo-evento-uscita-giorno", nome: "Uscita di un giorno" },
{ id: "tipo-evento-campo-estivo", nome: "Campo estivo" },
{ id: "tipo-evento-riunione-settimanale", nome: "Riunione settimanale" },
];
await Promise.all(
tipiEvento.map((tipoEvento) =>
prisma.tipoEvento.upsert({
where: { id: tipoEvento.id },
update: { nome: tipoEvento.nome },
create: tipoEvento,
})
)
);
// Le ex "liste modello" sono ora liste unificate: pubbliche, già approvate dalla
// moderazione, senza autore/org (scritte direttamente dal seed, come farebbe un
// moderatore in bypass della coda di proposte).
const listeModello = [
{
id: "lista-modello-uscita-giorno",
nome: "Materiale base uscita giornaliera",
tipoEventoId: "tipo-evento-uscita-giorno",
},
{
id: "lista-modello-campo-estivo",
nome: "Materiale base campo estivo",
tipoEventoId: "tipo-evento-campo-estivo",
},
// Sotto-lista "foglia" (nessuna sotto-lista propria): allegata come sotto-lista sia a
// "uscita di un giorno" sia a "campo estivo", al posto del vecchio materiale generico
// "materiale-kit-primo-soccorso", per verificare il nesting a un livello.
{
id: "lista-modello-kit-primo-soccorso",
nome: "Kit di pronto soccorso",
tipoEventoId: "tipo-evento-uscita-giorno",
},
];
for (const listaModello of listeModello) {
await prisma.lista.upsert({
where: { id: listaModello.id },
update: { ...listaModello, stato: "pubblico", statoModerazione: "approvato" },
create: {
...listaModello,
orgId: null,
creataDaUserId: null,
stato: "pubblico",
statoModerazione: "approvato",
},
});
}
const listeModelloVoci = [
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-zaino", quantita: 1 },
{
listaId: "lista-modello-uscita-giorno",
materialeId: "materiale-borraccia",
quantita: 1,
},
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-torcia", quantita: 1 },
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-tenda", quantita: 1 },
{
listaId: "lista-modello-campo-estivo",
materialeId: "materiale-sacco-a-pelo",
quantita: 1,
},
{
listaId: "lista-modello-campo-estivo",
materialeId: "materiale-fornello-campo",
quantita: 1,
},
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-corda", quantita: 2 },
// Voci della sotto-lista "Kit di pronto soccorso" (foglia, allegata sotto a due liste diverse).
{ listaId: "lista-modello-kit-primo-soccorso", materialeId: "materiale-garze", quantita: 3 },
{
listaId: "lista-modello-kit-primo-soccorso",
materialeId: "materiale-cerotti",
quantita: 10,
},
{
listaId: "lista-modello-kit-primo-soccorso",
materialeId: "materiale-disinfettante",
quantita: 1,
},
];
// Rimuove il vecchio materiale generico "kit primo soccorso" piazzato direttamente nelle due
// liste padre: da questo seed in poi il kit è la sotto-lista dedicata, non più una singola voce.
await prisma.listaVoce.deleteMany({
where: {
materialeId: "materiale-kit-primo-soccorso",
listaId: { in: ["lista-modello-uscita-giorno", "lista-modello-campo-estivo"] },
},
});
for (const voce of listeModelloVoci) {
await prisma.listaVoce.upsert({
where: {
listaId_materialeId: {
listaId: voce.listaId,
materialeId: voce.materialeId,
},
},
update: { quantita: voce.quantita },
create: voce,
});
}
const sottoListe = [
{ listaId: "lista-modello-uscita-giorno", sottoListaId: "lista-modello-kit-primo-soccorso" },
{ listaId: "lista-modello-campo-estivo", sottoListaId: "lista-modello-kit-primo-soccorso" },
];
for (const sottoLista of sottoListe) {
await prisma.listaSottoLista.upsert({
where: {
listaId_sottoListaId: {
listaId: sottoLista.listaId,
sottoListaId: sottoLista.sottoListaId,
},
},
update: {},
create: sottoLista,
});
}
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+12 -3
View File
@@ -2,25 +2,34 @@ import express from 'express';
import cors from 'cors';
import { healthRouter } from './routes/health.routes';
import { materialiRouter } from './routes/materiali.routes';
import { categorieRouter } from './routes/categorie.routes';
import { tipiEventoRouter } from './routes/tipiEvento.routes';
import { listeModelloRouter } from './routes/listeModello.routes';
import { listeRouter } from './routes/liste.routes';
import { magazzinoRouter } from './routes/magazzino.routes';
import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes';
import { eventiRouter } from './routes/eventi.routes';
import { autocompleteRouter } from './routes/autocomplete.routes';
import { notificheRouter } from './routes/notifiche.routes';
import { errorHandler } from './middleware/errorHandler';
export const app = express();
app.use(express.json());
// strict: false perché l'endpoint di autocomplete (vedi autocomplete.controller.ts) accetta un
// body JSON che è una stringa "nuda" (es. `"gioco"`), non solo oggetti/array: con lo strict mode
// di default body-parser la rifiuterebbe.
app.use(express.json({ strict: false }));
app.use(cors());
app.use(healthRouter);
app.use(materialiRouter);
app.use(categorieRouter);
app.use(tipiEventoRouter);
app.use(listeModelloRouter);
app.use(listeRouter);
app.use(magazzinoRouter);
app.use(gruppiMagazzinoRouter);
app.use(eventiRouter);
app.use(autocompleteRouter);
app.use(notificheRouter);
app.use((req, res) => {
res.status(404).json({ message: 'not found' });
@@ -0,0 +1,16 @@
import { Request, Response, NextFunction } from 'express';
import { search } from '../services/autocomplete.service';
function extractKeyword(body: unknown): string | undefined {
return typeof body === 'string' ? body : undefined;
}
export async function postSearch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const keyword = extractKeyword(req.body);
const gruppi = await search(keyword);
res.status(200).json(gruppi);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,99 @@
import { Request, Response, NextFunction } from 'express';
import {
aggiornaCategoria,
approvaCategoria,
creaCategoria,
eliminaCategoria,
listCategorie,
listCategorieConfermate,
proponiCategoria,
rifiutaCategoria,
} from '../services/categorie.service';
import { HttpError } from '../errors';
function parseNome(body: { nome?: unknown }): string {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
return body.nome;
}
export async function getCategorie(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const categorie = await listCategorie();
res.status(200).json(categorie);
} catch (err) {
next(err);
}
}
// Query pubblica, usata dall'autocomplete: mostra solo le categorie confermate,
// filtrabili per sottostringa del nome (case-insensitive).
export async function getCategoriePubbliche(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { nome } = req.query;
const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined;
const categorie = await listCategorieConfermate(filtroNome);
res.status(200).json(categorie);
} catch (err) {
next(err);
}
}
export async function postCategoria(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const categoria = await creaCategoria(nome);
res.status(201).json(categoria);
} catch (err) {
next(err);
}
}
export async function putCategoria(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const categoria = await aggiornaCategoria(req.params.id, nome);
res.status(200).json(categoria);
} catch (err) {
next(err);
}
}
export async function deleteCategoria(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaCategoria(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function postCategoriaProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const categoria = await proponiCategoria({ nome, orgId: req.auth!.orgId! });
res.status(201).json(categoria);
} catch (err) {
next(err);
}
}
export async function postCategoriaApprova(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const categoria = await approvaCategoria(req.params.id);
res.status(200).json(categoria);
} catch (err) {
next(err);
}
}
export async function postCategoriaRifiuta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await rifiutaCategoria(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,88 @@
import { Request, Response, NextFunction } from 'express';
import {
AggiornaGruppoInput,
CreaGruppoInput,
aggiornaGruppo,
creaGruppo,
eliminaGruppo,
listGruppiPerOrg,
} from '../services/gruppiMagazzino.service';
import { HttpError } from '../errors';
interface PostGruppoBody {
nome?: unknown;
parentId?: unknown;
}
function parseCreateBody(body: PostGruppoBody): CreaGruppoInput {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (body.parentId !== undefined && body.parentId !== null && typeof body.parentId !== 'string') {
throw new HttpError(400, "Il campo 'parentId', se presente, deve essere una stringa o null");
}
return {
nome: body.nome,
parentId: body.parentId as string | null | undefined,
};
}
interface PutGruppoBody {
nome?: unknown;
parentId?: unknown;
}
function parseUpdateBody(body: PutGruppoBody): AggiornaGruppoInput {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
if (body.parentId !== undefined && body.parentId !== null && typeof body.parentId !== 'string') {
throw new HttpError(400, "Il campo 'parentId', se presente, deve essere una stringa o null");
}
return {
nome: body.nome as string | undefined,
parentId: body.parentId as string | null | undefined,
};
}
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
export async function getGruppiMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const gruppi = await listGruppiPerOrg(req.auth!.orgId!);
res.status(200).json(gruppi);
} catch (err) {
next(err);
}
}
export async function postGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const gruppo = await creaGruppo(req.auth!.orgId!, input);
res.status(201).json(gruppo);
} catch (err) {
next(err);
}
}
export async function putGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const gruppo = await aggiornaGruppo(req.params.id, req.auth!.orgId!, input);
res.status(200).json(gruppo);
} catch (err) {
next(err);
}
}
export async function deleteGruppoMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaGruppo(req.params.id, req.auth!.orgId!);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -1,14 +1,24 @@
import { StatoLista } from '@prisma/client';
import { Request, Response, NextFunction } from 'express';
import { ListaVoceInput } from '../repositories/liste.repository';
import {
aggiornaLista,
creaListaVuota,
creaLista,
DecisioneProposta,
decidiProposta,
eliminaLista,
forkListaDaModello,
listListePerOrg,
forkListaDaLista,
listListePubbliche,
listMieListe,
listProposte,
SottoListeInput,
trovaListaPubblicaPerId,
Viewer,
} from '../services/liste.service';
import { HttpError } from '../errors';
const STATI_LISTA: StatoLista[] = ['bozza', 'privato', 'gruppo', 'pubblico'];
interface VoceBody {
materialeId?: unknown;
quantita?: unknown;
@@ -30,6 +40,42 @@ function parseVoci(voci: unknown): ListaVoceInput[] {
});
}
function parseStringArray(value: unknown, campo: string): string[] {
if (!Array.isArray(value)) {
throw new HttpError(400, `Il campo '${campo}' deve essere un array`);
}
return value.map((id: unknown) => {
if (typeof id !== 'string' || id.trim().length === 0) {
throw new HttpError(400, `Ogni elemento di '${campo}' deve essere una stringa non vuota`);
}
return id;
});
}
interface SottoListeBody {
sottoListeIds?: unknown;
sottoListeModelloIds?: unknown;
}
function parseSottoListe(body: SottoListeBody): SottoListeInput {
return {
sottoListeIds: body.sottoListeIds !== undefined ? parseStringArray(body.sottoListeIds, 'sottoListeIds') : [],
sottoListeModelloIds:
body.sottoListeModelloIds !== undefined
? parseStringArray(body.sottoListeModelloIds, 'sottoListeModelloIds')
: [],
};
}
// In update, sottoListe resta undefined (nessuna modifica) se il client non manda
// nessuno dei due campi — stessa semantica di update parziale già usata per 'voci'.
function parseSottoListeOpzionale(body: SottoListeBody): SottoListeInput | undefined {
if (body.sottoListeIds === undefined && body.sottoListeModelloIds === undefined) {
return undefined;
}
return parseSottoListe(body);
}
function parseNome(body: { nome?: unknown }): string {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
@@ -37,30 +83,126 @@ function parseNome(body: { nome?: unknown }): string {
return body.nome;
}
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
// Se assente, la lista nasce come 'bozza' (stato iniziale di default nel wizard di creazione).
function parseStato(body: { stato?: unknown }): StatoLista {
if (body.stato === undefined) {
return 'bozza';
}
if (typeof body.stato !== 'string' || !STATI_LISTA.includes(body.stato as StatoLista)) {
throw new HttpError(400, `Il campo 'stato', se presente, deve essere uno tra: ${STATI_LISTA.join(', ')}`);
}
return body.stato as StatoLista;
}
function parseStatoOpzionale(body: { stato?: unknown }): StatoLista | undefined {
if (body.stato === undefined) {
return undefined;
}
if (typeof body.stato !== 'string' || !STATI_LISTA.includes(body.stato as StatoLista)) {
throw new HttpError(400, `Il campo 'stato', se presente, deve essere uno tra: ${STATI_LISTA.join(', ')}`);
}
return body.stato as StatoLista;
}
function parseTipoEventoIdOpzionale(body: { tipoEventoId?: unknown }): string | undefined {
if (body.tipoEventoId === undefined) {
return undefined;
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota");
}
return body.tipoEventoId;
}
// req.auth.orgId può essere null (utente senza organizzazione attiva): le liste
// personali (bozza/privato/pubblico) non ne richiedono una, solo quelle 'gruppo'.
function viewerOf(req: Request): Viewer {
return { userId: req.auth!.userId, orgId: req.auth!.orgId ?? null, roles: req.auth!.roles };
}
// "Le mie liste": le proprie (qualunque stato) + le liste 'gruppo' della propria org,
// create da chiunque nell'org.
export async function getListe(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const liste = await listListePerOrg(req.auth!.orgId!);
const liste = await listMieListe(viewerOf(req));
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
// Nessuna autenticazione richiesta: le liste 'pubblico' già approvate sono visibili a
// chiunque. Filtro opzionale per tipo evento, mirror del vecchio GET /liste-modello.
export async function getListePubbliche(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tipoEventoId } = req.query;
const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined;
const liste = await listListePubbliche(filtro);
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
// Dettaglio pubblico per singolo id, mirror del vecchio GET /liste-modello/:id.
export async function getListaPubblicaById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const lista = await trovaListaPubblicaPerId(req.params.id);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
// Coda di moderazione: solo admin/moderatore (vedi routes).
export async function getListeProposte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const proposte = await listProposte();
res.status(200).json(proposte);
} catch (err) {
next(err);
}
}
interface PatchPropostaBody {
decisione?: unknown;
}
function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta {
if (body.decisione !== 'approvato' && body.decisione !== 'rifiutato') {
throw new HttpError(400, "Il campo 'decisione' deve valere 'approvato' o 'rifiutato'");
}
return body.decisione;
}
export async function patchListaProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const decisione = parseDecisioneBody(req.body ?? {});
const lista = await decidiProposta(req.params.id, decisione);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
export async function postLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const lista = await creaListaVuota(req.auth!.orgId!, nome);
const body = req.body ?? {};
const nome = parseNome(body);
const stato = parseStato(body);
const voci = body.voci !== undefined ? parseVoci(body.voci) : [];
const sottoListe = parseSottoListe(body);
const tipoEventoId = parseTipoEventoIdOpzionale(body);
const lista = await creaLista(nome, stato, voci, sottoListe, viewerOf(req), tipoEventoId);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
export async function postListaDaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
export async function postListaDaFork(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const lista = await forkListaDaModello(req.auth!.orgId!, req.params.listaModelloId);
const lista = await forkListaDaLista(req.params.id, viewerOf(req));
res.status(201).json(lista);
} catch (err) {
next(err);
@@ -69,24 +211,56 @@ export async function postListaDaModello(req: Request, res: Response, next: Next
interface PutListaBody {
nome?: unknown;
stato?: unknown;
voci?: unknown;
sottoListeIds?: unknown;
sottoListeModelloIds?: unknown;
tipoEventoId?: unknown;
}
function parseUpdateBody(body: PutListaBody): { nome?: string; voci?: ListaVoceInput[] } {
// A differenza di parseTipoEventoIdOpzionale (solo creazione, dove 'non presente' è
// l'unico modo per dire 'nessun tipo evento'), in update body.tipoEventoId: null è un
// valore esplicito ('rimuovi il tipo evento associato'), distinto da undefined ('non
// toccare il campo').
function parseTipoEventoIdUpdate(body: { tipoEventoId?: unknown }): string | null | undefined {
if (body.tipoEventoId === undefined) {
return undefined;
}
if (body.tipoEventoId === null) {
return null;
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota o null");
}
return body.tipoEventoId;
}
function parseUpdateBody(
body: PutListaBody,
): {
nome?: string;
stato?: StatoLista;
voci?: ListaVoceInput[];
sottoListe?: SottoListeInput;
tipoEventoId?: string | null;
} {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
return {
nome: body.nome as string | undefined,
stato: parseStatoOpzionale(body),
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
sottoListe: parseSottoListeOpzionale(body),
tipoEventoId: parseTipoEventoIdUpdate(body),
};
}
export async function putLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const lista = await aggiornaLista(req.params.id, req.auth!.orgId!, input);
const lista = await aggiornaLista(req.params.id, input, viewerOf(req));
res.status(200).json(lista);
} catch (err) {
next(err);
@@ -95,7 +269,7 @@ export async function putLista(req: Request, res: Response, next: NextFunction):
export async function deleteLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaLista(req.params.id, req.auth!.orgId!);
await eliminaLista(req.params.id, viewerOf(req));
res.status(204).send();
} catch (err) {
next(err);
@@ -1,111 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { ListaModelloVoceInput } from '../repositories/listeModello.repository';
import {
aggiornaListaModello,
creaListaModello,
eliminaListaModello,
listListeModello,
} from '../services/listeModello.service';
import { HttpError } from '../errors';
interface VoceBody {
materialeId?: unknown;
quantita?: unknown;
}
function parseVoci(voci: unknown): ListaModelloVoceInput[] {
if (!Array.isArray(voci)) {
throw new HttpError(400, "Il campo 'voci' deve essere un array");
}
return voci.map((voce: VoceBody) => {
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
}
if (typeof voce.quantita !== 'number' || !Number.isInteger(voce.quantita) || voce.quantita <= 0) {
throw new HttpError(400, "Ogni voce deve avere una 'quantita' intera positiva");
}
return { materialeId: voce.materialeId, quantita: voce.quantita };
});
}
interface PostListaModelloBody {
nome?: unknown;
tipoEventoId?: unknown;
voci?: unknown;
}
function parseCreateBody(body: PostListaModelloBody): { nome: string; tipoEventoId: string; voci: ListaModelloVoceInput[] } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId' è obbligatorio ed è una stringa non vuota");
}
return { nome: body.nome, tipoEventoId: body.tipoEventoId, voci: parseVoci(body.voci ?? []) };
}
interface PutListaModelloBody {
nome?: unknown;
tipoEventoId?: unknown;
voci?: unknown;
}
function parseUpdateBody(body: PutListaModelloBody): { nome?: string; tipoEventoId?: string; voci?: ListaModelloVoceInput[] } {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
if (body.tipoEventoId !== undefined && (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0)) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota");
}
return {
nome: body.nome as string | undefined,
tipoEventoId: body.tipoEventoId as string | undefined,
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
};
}
// Query pubblica: unico filtro accettato è "tipoEventoId". "pubblica" non è mai
// un parametro esposto al client: le liste modello sono per definizione pubbliche.
export async function getListeModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tipoEventoId } = req.query;
const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined;
const liste = await listListeModello(filtro);
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
export async function postListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const lista = await creaListaModello(input);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
export async function putListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const lista = await aggiornaListaModello(req.params.id, input);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
export async function deleteListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaListaModello(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -15,6 +15,7 @@ interface PostVoceBody {
stato?: unknown;
posizione?: unknown;
note?: unknown;
gruppoId?: unknown;
}
function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
@@ -33,6 +34,9 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
if (body.note !== undefined && typeof body.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa");
}
if (body.gruppoId !== undefined && body.gruppoId !== null && typeof body.gruppoId !== 'string') {
throw new HttpError(400, "Il campo 'gruppoId', se presente, deve essere una stringa o null");
}
return {
materialeId: body.materialeId,
@@ -40,6 +44,7 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
stato: body.stato,
posizione: body.posizione as string | undefined,
note: body.note as string | undefined,
gruppoId: body.gruppoId as string | null | undefined,
};
}
@@ -49,6 +54,7 @@ interface PutVoceBody {
stato?: unknown;
posizione?: unknown;
note?: unknown;
gruppoId?: unknown;
}
function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
@@ -70,6 +76,9 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
if (body.note !== undefined && body.note !== null && typeof body.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null");
}
if (body.gruppoId !== undefined && body.gruppoId !== null && typeof body.gruppoId !== 'string') {
throw new HttpError(400, "Il campo 'gruppoId', se presente, deve essere una stringa o null");
}
return {
materialeId: body.materialeId as string | undefined,
@@ -77,6 +86,7 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
stato: body.stato as StatoMagazzinoVoce | undefined,
posizione: body.posizione as string | null | undefined,
note: body.note as string | null | undefined,
gruppoId: body.gruppoId as string | null | undefined,
};
}
@@ -1,20 +1,23 @@
import { Request, Response, NextFunction } from 'express';
import {
DecisioneProposta,
aggiornaMateriale,
creaMateriale,
decidiProposta,
eliminaMateriale,
listMaterialiApprovati,
listProposte,
proponiMateriale,
} from '../services/materiali.service';
import { HttpError } from '../errors';
interface PostPropostaBody {
interface MaterialeBody {
nome?: unknown;
categoria?: unknown;
unitaMisura?: unknown;
}
function parsePropostaBody(body: PostPropostaBody): { nome: string; categoria: string; unitaMisura: string } {
function parseMaterialeBody(body: MaterialeBody): { nome: string; categoria: string; unitaMisura: string } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
@@ -39,15 +42,16 @@ function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta {
return body.decisione;
}
// Query pubblica: unico filtro accettato dal client è "categoria". Lo stato
// non è mai un parametro esposto: il catalogo pubblico mostra solo i
// materiali con stato "approvato".
// Query pubblica: filtri accettati dal client sono "categoria" e "nome" (ricerca
// per sottostringa, case-insensitive, usata dall'autocomplete). Lo stato non è mai
// un parametro esposto: il catalogo pubblico mostra solo i materiali con stato "approvato".
export async function getMaterialiPubblici(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { categoria } = req.query;
const filtro = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined;
const { categoria, nome } = req.query;
const filtroCategoria = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined;
const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined;
const materiali = await listMaterialiApprovati(filtro);
const materiali = await listMaterialiApprovati(filtroCategoria, filtroNome);
res.status(200).json(materiali);
} catch (err) {
next(err);
@@ -56,7 +60,7 @@ export async function getMaterialiPubblici(req: Request, res: Response, next: Ne
export async function postProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parsePropostaBody(req.body ?? {});
const input = parseMaterialeBody(req.body ?? {});
const proposta = await proponiMateriale({ ...input, orgId: req.auth!.orgId! });
res.status(201).json(proposta);
} catch (err) {
@@ -64,6 +68,35 @@ export async function postProposta(req: Request, res: Response, next: NextFuncti
}
}
export async function postMateriale(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseMaterialeBody(req.body ?? {});
const materiale = await creaMateriale({ ...input, orgId: req.auth!.orgId! });
res.status(201).json(materiale);
} catch (err) {
next(err);
}
}
export async function putMateriale(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseMaterialeBody(req.body ?? {});
const materiale = await aggiornaMateriale(req.params.id, input);
res.status(200).json(materiale);
} catch (err) {
next(err);
}
}
export async function deleteMateriale(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaMateriale(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function getProposte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const proposte = await listProposte();
@@ -0,0 +1,45 @@
import { NextFunction, Request, Response } from 'express';
import { HttpError } from '../errors';
import { countNonLette, listNotifiche, segnaLetta, segnaTutteLette } from '../services/notifiche.service';
function parseId(req: Request): number {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
throw new HttpError(400, "Il campo 'id' deve essere un numero intero");
}
return id;
}
export async function getNotifiche(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
res.status(200).json(await listNotifiche());
} catch (err) {
next(err);
}
}
export async function getCountNonLette(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
res.status(200).json({ count: await countNonLette() });
} catch (err) {
next(err);
}
}
export async function putLetta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await segnaLetta(parseId(req));
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function putLetteTutte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await segnaTutteLette();
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -1,5 +1,14 @@
import { Request, Response, NextFunction } from 'express';
import { aggiornaTipoEvento, creaTipoEvento, eliminaTipoEvento, listTipiEvento } from '../services/tipiEvento.service';
import {
aggiornaTipoEvento,
approvaTipoEvento,
creaTipoEvento,
eliminaTipoEvento,
listTipiEvento,
listTipiEventoConfermati,
proponiTipoEvento,
rifiutaTipoEvento,
} from '../services/tipiEvento.service';
import { HttpError } from '../errors';
function parseNome(body: { nome?: unknown }): string {
@@ -9,7 +18,22 @@ function parseNome(body: { nome?: unknown }): string {
return body.nome;
}
export async function getTipiEvento(_req: Request, res: Response, next: NextFunction): Promise<void> {
// Pubblica: usata sia dal filtro del catalogo liste sia dall'autocomplete nel wizard
// di creazione lista. Mostra solo i tipi evento confermati.
export async function getTipiEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { nome } = req.query;
const filtroNome = typeof nome === 'string' && nome.trim().length > 0 ? nome : undefined;
const tipiEvento = await listTipiEventoConfermati(filtroNome);
res.status(200).json(tipiEvento);
} catch (err) {
next(err);
}
}
// Moderazione: tutti gli stati, incluse le proposte in attesa di approvazione.
export async function getTipiEventoModerazione(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tipiEvento = await listTipiEvento();
res.status(200).json(tipiEvento);
@@ -28,6 +52,16 @@ export async function postTipoEvento(req: Request, res: Response, next: NextFunc
}
}
export async function postTipoEventoProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const tipoEvento = await proponiTipoEvento({ nome, orgId: req.auth!.orgId! });
res.status(201).json(tipoEvento);
} catch (err) {
next(err);
}
}
export async function putTipoEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
@@ -46,3 +80,21 @@ export async function deleteTipoEvento(req: Request, res: Response, next: NextFu
next(err);
}
}
export async function postTipoEventoApprova(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tipoEvento = await approvaTipoEvento(req.params.id);
res.status(200).json(tipoEvento);
} catch (err) {
next(err);
}
}
export async function postTipoEventoRifiuta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await rifiutaTipoEvento(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,46 @@
import { Categoria, StatoCategoria } from '@prisma/client';
import { prisma } from '../db/prisma';
export interface CreateCategoriaData {
nome: string;
stato: StatoCategoria;
creatoDaOrgId?: string | null;
}
export class CategorieRepository {
findAll(): Promise<Categoria[]> {
return prisma.categoria.findMany({ orderBy: { nome: 'asc' } });
}
findConfermate(nome?: string): Promise<Categoria[]> {
return prisma.categoria.findMany({
where: {
stato: StatoCategoria.confermata,
...(nome ? { nome: { contains: nome, mode: 'insensitive' } } : {}),
},
orderBy: { nome: 'asc' },
});
}
findById(id: string): Promise<Categoria | null> {
return prisma.categoria.findUnique({ where: { id } });
}
create(data: CreateCategoriaData): Promise<Categoria> {
return prisma.categoria.create({ data });
}
update(id: string, nome: string): Promise<Categoria> {
return prisma.categoria.update({ where: { id }, data: { nome } });
}
updateStato(id: string, stato: StatoCategoria): Promise<Categoria> {
return prisma.categoria.update({ where: { id }, data: { stato } });
}
async delete(id: string): Promise<void> {
await prisma.categoria.delete({ where: { id } });
}
}
export const categorieRepository = new CategorieRepository();
@@ -0,0 +1,60 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeAlbero = {
voci: { include: { materiale: true } },
figli: { include: { voci: { include: { materiale: true } } } },
} satisfies Prisma.GruppoMagazzinoInclude;
export type GruppoMagazzinoConAlbero = Prisma.GruppoMagazzinoGetPayload<{ include: typeof includeAlbero }>;
export interface CreateGruppoMagazzinoData {
orgId: string;
nome: string;
parentId?: string | null;
}
export interface UpdateGruppoMagazzinoData {
nome?: string;
parentId?: string | null;
}
export class GruppiMagazzinoRepository {
// Solo i gruppi radice (parentId null): i figli arrivano annidati via include,
// un solo livello come da vincolo applicativo.
findRadiceByOrg(orgId: string): Promise<GruppoMagazzinoConAlbero[]> {
return prisma.gruppoMagazzino.findMany({
where: { orgId, parentId: null },
include: includeAlbero,
orderBy: { creatoIl: 'asc' },
});
}
findByIdAndOrg(id: string, orgId: string): Promise<GruppoMagazzinoConAlbero | null> {
return prisma.gruppoMagazzino.findFirst({ where: { id, orgId }, include: includeAlbero });
}
// Usato per validare che un parentId indicato esista, sia della stessa org e sia
// esso stesso un gruppo radice (nessun nesting a più di un livello).
findRadiceByIdAndOrg(id: string, orgId: string): Promise<{ id: string } | null> {
return prisma.gruppoMagazzino.findFirst({ where: { id, orgId, parentId: null }, select: { id: true } });
}
countFigli(id: string): Promise<number> {
return prisma.gruppoMagazzino.count({ where: { parentId: id } });
}
create(data: CreateGruppoMagazzinoData): Promise<GruppoMagazzinoConAlbero> {
return prisma.gruppoMagazzino.create({ data, include: includeAlbero });
}
update(id: string, data: UpdateGruppoMagazzinoData): Promise<GruppoMagazzinoConAlbero> {
return prisma.gruppoMagazzino.update({ where: { id }, data, include: includeAlbero });
}
async delete(id: string): Promise<void> {
await prisma.gruppoMagazzino.delete({ where: { id } });
}
}
export const gruppiMagazzinoRepository = new GruppiMagazzinoRepository();
@@ -1,8 +1,16 @@
import { Prisma } from '@prisma/client';
import { Prisma, PrismaClient, StatoLista, StatoModerazioneLista } from '@prisma/client';
import { prisma } from '../db/prisma';
// Alcuni metodi vengono invocati sia standalone sia dentro una transazione orchestrata
// dal service (fork-e-aggancio atomico delle sotto-liste, vedi liste.service.ts): il
// chiamante può passare il client di transazione al posto del PrismaClient globale.
type Db = PrismaClient | Prisma.TransactionClient;
const includeVoci = {
voci: { include: { materiale: true } },
// Le sotto-liste sono a un solo livello: qui basta espandere le voci materiale della
// sotto-lista, non le sue eventuali (vietate) sotto-liste.
sottoListe: { include: { sottoLista: { include: { voci: { include: { materiale: true } } } } } },
} satisfies Prisma.ListaInclude;
export type ListaConVoci = Prisma.ListaGetPayload<{ include: typeof includeVoci }>;
@@ -14,66 +22,214 @@ export interface ListaVoceInput {
export interface CreateListaData {
nome: string;
orgId: string;
// Valorizzato solo per le liste 'gruppo': per bozza/privato/pubblico è sempre null,
// anche se il chiamante ha un'org attiva (vedi liste.service.ts::risolviOrgId).
orgId: string | null;
creataDaUserId: string;
stato: StatoLista;
// Nullo per bozza/privato/gruppo, valorizzato solo se stato='pubblico' (vedi
// liste.service.ts::risolviStatoModerazione).
statoModerazione: StatoModerazioneLista | null;
tipoEventoId?: string | null;
// Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base").
parentId?: string | null;
voci: ListaVoceInput[];
sottoListeIds: string[];
}
export interface UpdateListaData {
nome?: string;
stato?: StatoLista;
orgId?: string | null;
statoModerazione?: StatoModerazioneLista | null;
tipoEventoId?: string | null;
voci?: ListaVoceInput[];
sottoListeIds?: string[];
}
export class ListeRepository {
findAllByOrg(orgId: string): Promise<ListaConVoci[]> {
// "Visibili" a un utente: le proprie liste (qualunque stato) + le liste 'gruppo'
// della sua organizzazione attiva, create da chiunque nell'org (comportamento
// collaborativo: una lista di gruppo è condivisa da tutti i membri, non solo da chi
// l'ha creata). Se l'utente non ha un'org attiva, solo le proprie.
findVisibiliPerUtente(userId: string, orgId: string | null): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: { orgId },
where: {
OR: [{ creataDaUserId: userId }, ...(orgId ? [{ stato: 'gruppo' as StatoLista, orgId }] : [])],
},
include: includeVoci,
orderBy: { creataIl: 'desc' },
});
}
// id + orgId nella stessa where: una lista di un'altra org risulta
// semplicemente "non trovata", mai un 403 che ne rivela l'esistenza.
// Catalogo pubblico: liste 'pubblico' E già approvate dalla moderazione. Sostituisce
// sia il vecchio findPubbliche() (che filtrava solo stato) sia ListeModelloRepository
// .findAll() (che filtrava solo pubblica:true) — ora un'unica condizione a due assi.
findApprovatePubbliche(tipoEventoId?: string): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: {
stato: 'pubblico',
statoModerazione: 'approvato',
...(tipoEventoId ? { tipoEventoId } : {}),
},
include: includeVoci,
orderBy: { creataIl: 'desc' },
});
}
// Dettaglio pubblico per singolo id (usato dal deep-link al catalogo e dal fork):
// stesso filtro a due assi di findApprovatePubbliche, ma per un id preciso.
findApprovataPerId(id: string, db: Db = prisma): Promise<ListaConVoci | null> {
return db.lista.findFirst({
where: { id, stato: 'pubblico', statoModerazione: 'approvato' },
include: includeVoci,
});
}
// Coda di moderazione: liste 'pubblico' ancora in attesa di decisione, FIFO per data
// di creazione — mirror di materiali.repository.ts::findProposte.
findProposte(): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: { stato: 'pubblico', statoModerazione: 'proposto' },
include: includeVoci,
orderBy: { creataIl: 'asc' },
});
}
// Unica transizione di stato di moderazione, mirror di
// materiali.repository.ts::updateStato.
updateStatoModerazione(id: string, statoModerazione: StatoModerazioneLista): Promise<ListaConVoci> {
return prisma.lista.update({
where: { id },
data: { statoModerazione },
include: includeVoci,
});
}
// Lookup senza alcun filtro di org: il controllo di chi può gestire questa lista
// (creatore, o membro dell'org se è di gruppo) è applicativo, vedi
// liste.service.ts::assicuraAccessoGestione.
findById(id: string): Promise<ListaConVoci | null> {
return prisma.lista.findFirst({ where: { id }, include: includeVoci });
}
// Usato solo da eventi.service.ts (un Evento, sempre org-scoped, può collegarsi solo
// a una lista 'gruppo' della propria org: le altre liste hanno orgId null e non
// potranno mai combaciare). Non toccare la firma: è un contratto tra i due moduli.
findByIdAndOrg(id: string, orgId: string): Promise<ListaConVoci | null> {
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
}
create(data: CreateListaData): Promise<ListaConVoci> {
return prisma.lista.create({
create(data: CreateListaData, db: Db = prisma): Promise<ListaConVoci> {
return db.lista.create({
data: {
nome: data.nome,
orgId: data.orgId,
creataDaUserId: data.creataDaUserId,
stato: data.stato,
statoModerazione: data.statoModerazione,
tipoEventoId: data.tipoEventoId ?? null,
parentId: data.parentId ?? null,
voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) },
sottoListe: { create: data.sottoListeIds.map((sottoListaId) => ({ sottoListaId })) },
},
include: includeVoci,
});
}
update(id: string, data: UpdateListaData): Promise<ListaConVoci> {
return prisma.$transaction(async (tx) => {
if (data.voci) {
await tx.listaVoce.deleteMany({ where: { listaId: id } });
// Se il chiamante passa già un client di transazione (orchestrazione a monte nel
// service), le due operazioni (delete + update) vengono semplicemente eseguite su
// quel client, atomiche insieme al resto della transazione esterna. Se invece non
// viene passato nulla, questo metodo resta atomico da solo aprendo la propria
// transazione, come faceva prima di introdurre le sotto-liste.
update(id: string, data: UpdateListaData, db?: Db): Promise<ListaConVoci> {
if (db) {
return this.eseguiUpdate(db, id, data);
}
return prisma.$transaction((tx) => this.eseguiUpdate(tx, id, data));
}
return tx.lista.update({
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 {
id: lista.id,
nome: lista.nome,
orgId: lista.orgId,
creataIl: lista.creataIl,
voci: lista.voci.map((v) => ({
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,
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();
});
});
+1 -1
View File
@@ -24,7 +24,7 @@
"input": "public"
}
],
"styles": ["src/material-theme.scss", "src/styles.css"]
"styles": ["src/styles.css"]
},
"configurations": {
"production": {
+3
View File
@@ -0,0 +1,3 @@
.app-shell {
min-height: 100vh;
}
+5 -1
View File
@@ -1 +1,5 @@
<router-outlet />
<div class="app-shell">
<app-header />
<router-outlet />
</div>
+2 -6
View File
@@ -15,11 +15,7 @@ export const routes: Routes = [
loadChildren: () => import('./magazzino/magazzino.routes').then((m) => m.MAGAZZINO_ROUTES)
},
{
path: 'eventi',
loadChildren: () => import('./eventi/eventi.routes').then((m) => m.EVENTI_ROUTES)
},
{
path: 'moderazione',
loadChildren: () => import('./moderazione/moderazione.routes').then((m) => m.MODERAZIONE_ROUTES)
path: 'tassonomie',
loadChildren: () => import('./tassonomie/tassonomie.routes').then((m) => m.TASSONOMIE_ROUTES)
}
];
+14
View File
@@ -1,10 +1,24 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEvent, KeycloakEventType } from 'keycloak-angular';
import { App } from './app';
import { routes } from './app.routes';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter(routes),
{ provide: Keycloak, useValue: { authenticated: false, tokenParsed: {} } },
{
provide: KEYCLOAK_EVENT_SIGNAL,
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true })
}
]
}).compileComponents();
});
+3 -1
View File
@@ -1,9 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Header } from './shared/header/header';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, Header],
templateUrl: './app.html',
styleUrl: './app.css'
})
@@ -0,0 +1,37 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
const JSON_STRING_HEADERS = new HttpHeaders({ 'Content-Type': 'application/json' });
export type GruppoAutocomplete = 'tipoEvento' | 'materiale';
export interface AutocompleteObject {
id: string;
nome: string;
gruppo: GruppoAutocomplete;
}
export interface AutocompleteGroup {
label: string;
gruppo: GruppoAutocomplete;
objectsList: AutocompleteObject[];
}
@Injectable({ providedIn: 'root' })
export class AutocompleteApiService {
private readonly http = inject(HttpClient);
search(keyword: string): Observable<AutocompleteGroup[]> {
// Il backend si aspetta come body una stringa JSON nuda (es. "corda"), non un oggetto:
// va serializzata esplicitamente per evitare che HttpClient la invii come text/plain non
// incapsulata (stesso pattern di scouthub-attivita-fe).
return this.http.post<AutocompleteGroup[]>(
`${environment.magazzinoApiBaseUrl}/autocomplete/search`,
JSON.stringify(keyword),
{ headers: JSON_STRING_HEADERS }
);
}
}
@@ -1,24 +1,95 @@
.catalogo-liste-modello__header {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 0;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.catalogo-liste-modello__title {
font-size: 30px;
margin: 0;
font-weight: 700;
}
.catalogo-liste-modello__search-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
padding: 10px 16px;
color: var(--color-text);
opacity: 0.7;
min-width: 220px;
text-decoration: none;
}
.catalogo-liste-modello__error {
color: var(--color-accent-800);
}
.tipo-evento-gruppo {
margin-bottom: var(--space-8);
.catalogo-liste-modello__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.lista-modello-card {
cursor: pointer;
padding: 18px;
gap: 10px;
border-radius: 14px;
}
.lista-modello-card:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.lista-modello-card__altre {
opacity: 0.7;
font-style: italic;
}
.lista-modello-card__chip {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
background: var(--color-header);
border: 1px solid var(--color-divider);
font-size: 12px;
font-weight: 600;
}
.lista-modello-card__voci {
margin: 0;
padding-left: 1.1rem;
padding-left: 0;
list-style: none;
font-size: 13px;
opacity: 0.8;
display: flex;
flex-direction: column;
gap: 4px;
}
.lista-modello-card__voci li {
display: flex;
align-items: center;
gap: 6px;
}
.lista-modello-card__finta-checkbox {
font-size: 12px;
line-height: 1;
}
.lista-modello-card__voce-testo--spuntata {
text-decoration: line-through;
opacity: 0.6;
}
.lista-modello-card__avviso {
@@ -1,28 +1,59 @@
<section class="catalogo-liste-modello om-page">
<div class="catalogo-liste-modello__header">
<h1 class="om-section-title">Liste modello per tipo evento</h1>
<a class="btn btn-ghost" routerLink="/">Catalogo materiali</a>
<h1 class="catalogo-liste-modello__title">Liste materiali pubblicate</h1>
<a class="catalogo-liste-modello__search-link" routerLink="/cerca-lista">
<span>🔍</span><span>Cerca lista...</span>
</a>
</div>
<p class="om-section-sub">Liste standard, pronte da usare come base per una lista della tua organizzazione.</p>
@if (loading()) {
<p class="om-empty">Caricamento liste modello…</p>
} @else if (loadError(); as message) {
<p class="catalogo-liste-modello__error" role="alert">{{ message }}</p>
} @else if (gruppi().length === 0) {
} @else if (liste().length === 0) {
<p class="om-empty">Nessuna lista modello disponibile.</p>
} @else {
@for (gruppo of gruppi(); track gruppo.tipoEvento.id) {
<section class="tipo-evento-gruppo">
<h2>{{ gruppo.tipoEvento.nome }}</h2>
<div class="tipo-evento-gruppo__liste om-grid">
@for (lista of gruppo.liste; track lista.id) {
<div class="card lista-modello-card">
<div class="catalogo-liste-modello__grid">
@for (lista of liste(); track lista.id) {
<div
class="card lista-modello-card"
role="link"
tabindex="0"
(click)="onCardClick($event, lista.id)"
(keydown.enter)="apriDettaglio(lista.id)"
>
<div class="card-title">{{ lista.nome }}</div>
@if (lista.tipoEventoNome) {
<span class="lista-modello-card__chip">{{ lista.tipoEventoNome }}</span>
}
<ul class="lista-modello-card__voci card-body">
@for (voce of lista.voci; track voce.materialeId) {
<li>{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}</li>
@for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) {
@if (item.tipo === 'materiale') {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isVoceSpuntata(lista.id, item.materialeId) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isVoceSpuntata(lista.id, item.materialeId)">
{{ item.nome }} — {{ item.quantita }} {{ item.unitaMisura }}
</span>
</li>
} @else {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isSottoListaSpuntata(lista.id, item) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isSottoListaSpuntata(lista.id, item)">
{{ item.nome }}
</span>
</li>
}
}
@if (lista.vociRestanti > 0) {
<li class="lista-modello-card__altre">
+{{ lista.vociRestanti }} altr{{ lista.vociRestanti === 1 ? 'a' : 'e' }} voc{{
lista.vociRestanti === 1 ? 'e' : 'i'
}}
</li>
}
</ul>
@@ -36,18 +67,18 @@
</p>
}
@if (isAuthenticated) {
<button
type="button"
class="btn btn-secondary btn-block"
[disabled]="creazioneInCorsoId() === lista.id"
(click)="usaComeBase(lista.id)"
(click)="onUsaComeBase(lista.id)"
>
Usa come base
</button>
}
</div>
}
</div>
</section>
}
}
</section>
@@ -3,8 +3,7 @@ import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { Lista, ListeApiService } from '../liste-api.service';
import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { CatalogoListeModello } from './catalogo-liste-modello';
@@ -12,28 +11,52 @@ describe('CatalogoListeModello', () => {
let component: CatalogoListeModello;
let fixture: ComponentFixture<CatalogoListeModello>;
let tipiEventoApi: { getTipiEvento: ReturnType<typeof vi.fn> };
let listeModelloApi: { getListeModello: ReturnType<typeof vi.fn> };
let listeApi: { creaListaDaModello: ReturnType<typeof vi.fn> };
let listeApi: { getListePubbliche: ReturnType<typeof vi.fn>; creaListaDaFork: ReturnType<typeof vi.fn> };
let keycloak: { authenticated: boolean | undefined; login: ReturnType<typeof vi.fn> };
let router: Router;
const tipiEvento: TipoEvento[] = [
{ id: 'te-1', nome: 'Uscita' },
{ id: 'te-2', nome: 'Campo estivo' }
{ id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' },
{ id: 'te-2', nome: 'Campo estivo', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }
];
const listeModello: ListaModello[] = [
const listeModello: Lista[] = [
{
id: 'lm-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 'te-1',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }],
sottoListe: [
{
id: 'lm-kit-ps',
nome: 'Kit di pronto soccorso',
voci: [{ materialeId: 'mat-ps-1', nome: 'Garze', unitaMisura: 'pz', quantita: 5 }]
}
]
},
{
id: 'lm-2',
nome: 'Campo estivo standard',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 'te-2',
voci: [{ materialeId: 'mat-2', nome: 'Telo cerato', unitaMisura: 'pz', quantita: 5 }]
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: Array.from({ length: 7 }, (_, i) => ({
materialeId: `mat-${i + 2}`,
nome: `Materiale ${i + 2}`,
unitaMisura: 'pz',
quantita: 1
})),
sottoListe: []
}
];
@@ -45,7 +68,6 @@ describe('CatalogoListeModello', () => {
providers: [
provideRouter([]),
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
{ provide: ListeModelloApiService, useValue: listeModelloApi },
{ provide: ListeApiService, useValue: listeApi },
{ provide: Keycloak, useValue: keycloak }
]
@@ -53,6 +75,7 @@ describe('CatalogoListeModello', () => {
router = TestBed.inject(Router);
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(CatalogoListeModello);
component = fixture.componentInstance;
@@ -62,12 +85,12 @@ describe('CatalogoListeModello', () => {
}
beforeEach(() => {
sessionStorage.clear();
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
listeModelloApi = { getListeModello: vi.fn().mockReturnValue(of(listeModello)) };
listeApi = { creaListaDaModello: vi.fn() };
listeApi = { getListePubbliche: vi.fn().mockReturnValue(of(listeModello)), creaListaDaFork: vi.fn() };
});
it('mostra le liste modello raggruppate per tipo evento con voci e quantità', async () => {
it('mostra tutte le liste modello insieme, con il tipo evento come chip nella card', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
@@ -75,13 +98,108 @@ describe('CatalogoListeModello', () => {
expect(component.loading()).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const gruppi = compiled.querySelectorAll('.tipo-evento-gruppo');
expect(gruppi.length).toBe(2);
expect(gruppi[0].querySelector('h2')?.textContent).toContain('Uscita');
expect(gruppi[0].textContent).toContain('Corda — 2 pz');
const cards = compiled.querySelectorAll('.lista-modello-card');
expect(cards.length).toBe(2);
expect(cards[0].querySelector('.lista-modello-card__chip')?.textContent).toContain('Uscita');
expect(cards[0].textContent).toContain('Corda — 2 pz');
expect(cards[1].querySelector('.lista-modello-card__chip')?.textContent).toContain('Campo estivo');
});
it('mostra solo le prime 5 voci di una lista con più voci, con il conteggio delle restanti', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.lista-modello-card');
const voci = cards[1].querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
expect(voci.length).toBe(5);
expect(cards[1].querySelector('.lista-modello-card__altre')?.textContent).toContain('+2 altre voci');
});
it('il click sulla card naviga al dettaglio della lista modello', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const card = compiled.querySelector('.lista-modello-card') as HTMLElement;
card.click();
expect(router.navigate).toHaveBeenCalledWith(['/lista-modello', 'lm-1']);
});
it('le voci non sono interattive: il click su una voce apre comunque il dettaglio', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card__voci input')).toBeNull();
const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement;
voce.click();
expect(router.navigate).toHaveBeenCalledWith(['/lista-modello', 'lm-1']);
});
it('mostra una finta checkbox per ogni voce, spuntata e barrata se già segnata (es. dal dettaglio)', async () => {
keycloak = { authenticated: false, login: vi.fn() };
sessionStorage.setItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1', JSON.stringify(['mat-1']));
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement;
expect(voce.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('☑️');
expect(voce.querySelector('.lista-modello-card__voce-testo--spuntata')).not.toBeNull();
});
it('mostra una finta checkbox vuota per una voce non segnata', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement;
expect(voce.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('⬜');
expect(voce.querySelector('.lista-modello-card__voce-testo--spuntata')).toBeNull();
});
it('mostra una sotto-lista allegata come una voce in più, con solo il titolo (non le sue voci)', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.lista-modello-card');
const voci = cards[0].querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
expect(voci.length).toBe(2);
expect(cards[0].textContent).toContain('Kit di pronto soccorso');
expect(cards[0].textContent).not.toContain('Garze');
});
it('mostra la sotto-lista come spuntata solo se tutte le sue voci sono spuntate', async () => {
keycloak = { authenticated: false, login: vi.fn() };
sessionStorage.setItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1', JSON.stringify(['mat-ps-1']));
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voci = compiled.querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
const vocesottoLista = Array.from(voci).find((li) => li.textContent?.includes('Kit di pronto soccorso'));
expect(vocesottoLista?.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('☑️');
});
it('mostra la sotto-lista come non spuntata se manca almeno una delle sue voci', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voci = compiled.querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
const vocesottoLista = Array.from(voci).find((li) => li.textContent?.includes('Kit di pronto soccorso'));
expect(vocesottoLista?.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('⬜');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
@@ -108,21 +226,18 @@ describe('CatalogoListeModello', () => {
expect(avvisi[0].textContent).toContain('Accedi per usare questa lista come base');
});
it('avvia il login e non chiama l\'API al click sul pulsante', async () => {
it('non mostra il pulsante "Usa come base"', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card button')).toBeNull();
});
it('avvia il login e non chiama l\'API se invocato comunque', async () => {
await component.usaComeBase('lm-1');
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
expect(listeApi.creaListaDaModello).not.toHaveBeenCalled();
expect(listeApi.creaListaDaFork).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it('il click sul pulsante nel DOM avvia il login', () => {
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('.lista-modello-card button') as HTMLButtonElement;
button.click();
expect(keycloak.login).toHaveBeenCalled();
});
});
describe('pulsante "Usa come base" — utente autenticato', () => {
@@ -132,25 +247,60 @@ describe('CatalogoListeModello', () => {
fixture.detectChanges();
});
it('il click sul pulsante "Usa come base" non apre anche il dettaglio della card', () => {
listeApi.creaListaDaFork.mockReturnValue(
of({
id: 'lp-1',
nome: 'x',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: 'lm-1',
creataIl: '2026-01-01',
voci: [],
sottoListe: []
})
);
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('.lista-modello-card button') as HTMLButtonElement;
button.click();
expect(router.navigate).not.toHaveBeenCalled();
});
it('non mostra l\'avviso di accesso', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card__avviso')).toBeNull();
});
it('chiama POST /liste/da-modello/:id e reindirizza alla lista privata creata', async () => {
const listaCreata: Lista = { id: 'lista-privata-1', nome: 'Uscita di un giorno', orgId: 'org-1', creataIl: '2026-01-01', voci: [] };
listeApi.creaListaDaModello.mockReturnValue(of(listaCreata));
it('chiama POST /liste/da-fork/:id e reindirizza alla lista privata creata', async () => {
const listaCreata: Lista = {
id: 'lista-privata-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: 'lm-1',
creataDaMe: true,
creataIl: '2026-01-01',
voci: [],
sottoListe: []
};
listeApi.creaListaDaFork.mockReturnValue(of(listaCreata));
await component.usaComeBase('lm-1');
expect(keycloak.login).not.toHaveBeenCalled();
expect(listeApi.creaListaDaModello).toHaveBeenCalledWith('lm-1');
expect(listeApi.creaListaDaFork).toHaveBeenCalledWith('lm-1');
expect(router.navigateByUrl).toHaveBeenCalledWith('/liste/lista-privata-1');
expect(component.creazioneInCorsoId()).toBeNull();
});
it('mostra un messaggio di errore sulla lista interessata se la creazione fallisce', async () => {
listeApi.creaListaDaModello.mockReturnValue(throwError(() => new Error('server error')));
listeApi.creaListaDaFork.mockReturnValue(throwError(() => new Error('server error')));
await component.usaComeBase('lm-1');
@@ -5,13 +5,23 @@ import { Router, RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { ListeApiService } from '../liste-api.service';
import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service';
import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { VoceCheckStorageService } from '../voce-check-storage.service';
interface GruppoTipoEvento {
tipoEvento: TipoEvento;
liste: ListaModello[];
const VOCI_PREVIEW_LIMIT = 5;
// In home una sotto-lista allegata compare come una voce in più tra le altre (non come un
// chip a parte): questo tipo unisce voci materiale e sotto-liste in un'unica sequenza per la
// preview troncata a VOCI_PREVIEW_LIMIT.
type ItemAnteprima =
| ({ tipo: 'materiale' } & ListaVoce)
| ({ tipo: 'sottoLista' } & SottoLista);
interface ListaModelloVista extends Lista {
tipoEventoNome: string;
vociPreview: ItemAnteprima[];
vociRestanti: number;
}
@Component({
@@ -22,26 +32,32 @@ interface GruppoTipoEvento {
})
export class CatalogoListeModello implements OnInit {
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly listeModelloApi = inject(ListeModelloApiService);
private readonly listeApi = inject(ListeApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
private readonly voceCheckStorage = inject(VoceCheckStorageService);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly tipiEvento = signal<TipoEvento[]>([]);
readonly listeModello = signal<ListaModello[]>([]);
readonly listeModello = signal<Lista[]>([]);
readonly creazioneInCorsoId = signal<string | null>(null);
readonly creazioneErroreId = signal<string | null>(null);
readonly gruppi = computed<GruppoTipoEvento[]>(() => {
const liste = this.listeModello();
return this.tipiEvento()
.map((tipoEvento) => ({
tipoEvento,
liste: liste.filter((lista) => lista.tipoEventoId === tipoEvento.id)
}))
.filter((gruppo) => gruppo.liste.length > 0);
readonly liste = computed<ListaModelloVista[]>(() => {
const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome]));
return this.listeModello().map((lista) => {
const items: ItemAnteprima[] = [
...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })),
...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista }))
];
return {
...lista,
tipoEventoNome: (lista.tipoEventoId && nomiPerTipoEvento.get(lista.tipoEventoId)) ?? '',
vociPreview: items.slice(0, VOCI_PREVIEW_LIMIT),
vociRestanti: Math.max(0, items.length - VOCI_PREVIEW_LIMIT)
};
});
});
get isAuthenticated(): boolean {
@@ -52,6 +68,40 @@ export class CatalogoListeModello implements OnInit {
await this.carica();
}
onCardClick(event: Event, listaModelloId: string): void {
// Le voci nella home non sono più interattive: l'unico elemento cliccabile dentro la
// card, oltre alla card stessa, è il pulsante "Usa come base", che non deve anche aprire
// il dettaglio.
const target = event.target as HTMLElement;
if (target.closest('button')) {
return;
}
this.apriDettaglio(listaModelloId);
}
apriDettaglio(listaModelloId: string): void {
this.router.navigate(['/lista-modello', listaModelloId]);
}
// Le voci in home sono di sola lettura (il click apre il dettaglio): la spunta è solo una
// finta checkbox che riflette lo stato salvato dal dettaglio, non è cliccabile qui.
isVoceSpuntata(listaModelloId: string, materialeId: string): boolean {
return this.voceCheckStorage.isSpuntata(listaModelloId, materialeId);
}
// Una sotto-lista risulta spuntata in home solo se lo sono tutte le sue voci materiale
// (spuntate nel dettaglio, nello stesso namespace della lista padre).
isSottoListaSpuntata(listaModelloId: string, sottoLista: SottoLista): boolean {
return (
sottoLista.voci.length > 0 &&
sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaModelloId, voce.materialeId))
);
}
onUsaComeBase(listaModelloId: string): void {
void this.usaComeBase(listaModelloId);
}
async usaComeBase(listaModelloId: string): Promise<void> {
if (!this.isAuthenticated) {
this.keycloak.login({ redirectUri: window.location.href });
@@ -62,7 +112,7 @@ export class CatalogoListeModello implements OnInit {
this.creazioneInCorsoId.set(listaModelloId);
try {
const lista = await firstValueFrom(this.listeApi.creaListaDaModello(listaModelloId));
const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId));
this.creazioneInCorsoId.set(null);
await this.router.navigateByUrl(`/liste/${lista.id}`);
} catch {
@@ -77,7 +127,7 @@ export class CatalogoListeModello implements OnInit {
try {
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
const listeModello = await firstValueFrom(this.listeModelloApi.getListeModello());
const listeModello = await firstValueFrom(this.listeApi.getListePubbliche());
this.tipiEvento.set(tipiEvento);
this.listeModello.set(listeModello);
} catch {
@@ -6,12 +6,6 @@
margin-bottom: var(--space-4);
}
.catalogo-materiali__area-privata {
margin-bottom: var(--space-5);
padding-left: 0;
padding-right: 0;
}
.catalogo-materiali__filtri {
flex-wrap: wrap;
margin-bottom: var(--space-5);
@@ -4,21 +4,9 @@
<h1 class="om-section-title">Catalogo materiali</h1>
<p class="om-section-sub">Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.</p>
</div>
<a class="btn btn-ghost" routerLink="/liste-modello">Liste modello per evento</a>
<a class="btn btn-ghost" routerLink="/">Liste modello per evento</a>
</div>
@if (isAuthenticated) {
<nav class="nav catalogo-materiali__area-privata">
<a routerLink="/liste">Le tue liste</a>
<a routerLink="/magazzino">Magazzino</a>
<a routerLink="/eventi">Crea evento</a>
<a routerLink="/proponi-materiale">Proponi materiale</a>
@if (isModeratore) {
<a routerLink="/moderazione">Moderazione catalogo</a>
}
</nav>
}
@if (loading()) {
<p class="om-empty">Caricamento catalogo…</p>
} @else if (loadError(); as message) {
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
import { CatalogoMateriali } from './catalogo-materiali';
@@ -10,10 +9,6 @@ describe('CatalogoMateriali', () => {
let component: CatalogoMateriali;
let fixture: ComponentFixture<CatalogoMateriali>;
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
let keycloak: {
authenticated: boolean | undefined;
tokenParsed?: { realm_access?: { roles?: string[] } };
};
const materiali: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
@@ -24,11 +19,7 @@ describe('CatalogoMateriali', () => {
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CatalogoMateriali],
providers: [
provideRouter([]),
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: Keycloak, useValue: keycloak }
]
providers: [provideRouter([]), { provide: MaterialiApiService, useValue: materialiApi }]
}).compileComponents();
fixture = TestBed.createComponent(CatalogoMateriali);
@@ -38,10 +29,6 @@ describe('CatalogoMateriali', () => {
fixture.detectChanges();
}
beforeEach(() => {
keycloak = { authenticated: false };
});
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
@@ -89,39 +76,4 @@ describe('CatalogoMateriali', () => {
expect(component.loading()).toBe(false);
expect(component.loadError()).toBe('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
});
describe('area privata e voce di menu "Moderazione catalogo"', () => {
beforeEach(() => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
});
it('non mostra la nav dell\'area privata per un utente non autenticato', async () => {
keycloak = { authenticated: false };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.catalogo-materiali__area-privata')).toBeNull();
});
it('mostra la nav dell\'area privata ma non "Moderazione catalogo" per un utente senza il ruolo moderatore', async () => {
keycloak = { authenticated: true, tokenParsed: { realm_access: { roles: ['censito'] } } };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.catalogo-materiali__area-privata')).toBeTruthy();
const link = Array.from(compiled.querySelectorAll('.catalogo-materiali__area-privata a')).find((a) =>
a.textContent?.includes('Moderazione catalogo')
);
expect(link).toBeUndefined();
});
it('mostra "Moderazione catalogo" per un utente con il ruolo moderatore', async () => {
keycloak = { authenticated: true, tokenParsed: { realm_access: { roles: ['moderatore'] } } };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.catalogo-materiali__area-privata a[href="/moderazione"]');
expect(link).toBeTruthy();
});
});
});
@@ -2,10 +2,8 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
@Component({
@@ -16,7 +14,6 @@ import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service
})
export class CatalogoMateriali implements OnInit {
private readonly materialiApi = inject(MaterialiApiService);
private readonly keycloak = inject(Keycloak);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
@@ -34,14 +31,6 @@ export class CatalogoMateriali implements OnInit {
return categoria ? materiali.filter((materiale) => materiale.categoria === categoria) : materiali;
});
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
get isModeratore(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
}
async ngOnInit(): Promise<void> {
await this.caricaMateriali();
}
@@ -1,24 +1,27 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const CATALOGO_ROUTES: Routes = [
{
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
path: '',
loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali)
},
{
// Pubblica: le liste modello sono consultabili da chi non è autenticato;
// solo la creazione della lista privata a partire dal modello richiede il login.
path: 'liste-modello',
path: '',
loadComponent: () =>
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello)
},
{
// Privata: proporre un materiale richiede un'org sul token (POST /materiali/proposte).
path: 'proponi-materiale',
loadComponent: () => import('./proponi-materiale/proponi-materiale').then((m) => m.ProponiMateriale),
canActivate: [requireAuthGuard]
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
path: 'catalogo-materiali',
loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali)
},
{
// Pubblica: la ricerca delle liste modello è consultabile anche da chi non è autenticato.
path: 'cerca-lista',
loadComponent: () => import('./cerca-lista/cerca-lista').then((m) => m.CercaLista)
},
{
// Pubblica: il dettaglio di una lista modello è consultabile anche da chi non è autenticato.
path: 'lista-modello/:id',
loadComponent: () =>
import('./lista-modello-dettaglio/lista-modello-dettaglio').then((m) => m.ListaModelloDettaglio)
}
];
@@ -0,0 +1,155 @@
.page-title {
font-size: 28px;
margin: 0 0 18px;
font-weight: 700;
}
.search-input-wrap {
position: relative;
max-width: 640px;
}
.search-input {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
font-size: 16px;
border-radius: 10px;
border: 1px solid var(--color-divider);
background: var(--color-surface);
color: var(--color-text);
}
.suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
box-shadow: var(--shadow-md);
z-index: 5;
max-height: 360px;
overflow: auto;
}
.suggestion-group-label {
padding: 10px 16px 4px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
font-size: 15px;
}
.suggestion-item:hover {
background: var(--color-accent-100);
}
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0 28px;
}
.filter-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--color-accent-200);
border: 1px solid var(--color-accent-300);
font-size: 14px;
font-weight: 600;
color: var(--color-accent-700);
}
.filter-remove {
cursor: pointer;
font-weight: 700;
}
.cerca-lista__error {
color: var(--color-accent-800);
}
.cerca-lista__results-label {
font-weight: 700;
opacity: 0.7;
font-size: 14px;
margin-bottom: var(--space-3);
}
.lista-modello-card {
cursor: pointer;
padding: 18px;
gap: 10px;
border-radius: 14px;
}
.lista-modello-card:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.lista-modello-card__chip {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
background: var(--color-header);
border: 1px solid var(--color-divider);
font-size: 12px;
font-weight: 600;
}
.lista-modello-card__voci {
margin: 0;
padding-left: 0;
list-style: none;
font-size: 13px;
opacity: 0.8;
display: flex;
flex-direction: column;
gap: 4px;
}
.lista-modello-card__voci li {
display: flex;
align-items: center;
gap: 6px;
}
.lista-modello-card__finta-checkbox {
font-size: 12px;
line-height: 1;
}
.lista-modello-card__voce-testo--spuntata {
text-decoration: line-through;
opacity: 0.6;
}
.lista-modello-card__altre {
opacity: 0.7;
font-style: italic;
}
.lista-modello-card__avviso {
color: var(--color-text);
opacity: 0.6;
font-size: 12px;
}
.lista-modello-card__errore {
color: var(--color-accent-800);
font-size: 12px;
}
@@ -0,0 +1,115 @@
<section class="cerca-lista om-page">
<h1 class="page-title">Cerca lista</h1>
<div class="search-input-wrap">
<input
class="search-input"
type="text"
[value]="searchQuery()"
(input)="onQueryChange($event)"
(keydown)="onQueryKeyDown($event)"
placeholder="Cerca per nome, tipo evento, materiale..."
/>
@if (showSuggestions()) {
<div class="suggestions">
@for (group of suggestionGroups(); track group.label) {
<div class="suggestion-group-label">{{ group.label }}</div>
@for (suggestion of group.objectsList; track suggestion.id) {
<div class="suggestion-item" (click)="selectSuggestion(group.gruppo, suggestion)">
{{ suggestion.nome }}
</div>
}
}
</div>
}
</div>
<div class="active-filters">
@for (filtro of activeFilters(); track $index) {
<div class="filter-chip">
<span>{{ filtroLabel(filtro.gruppo) }}: {{ filtro.nome }}</span>
<span class="filter-remove" (click)="removeFiltro($index)">×</span>
</div>
}
</div>
@if (loading()) {
<p class="om-empty">Caricamento liste modello…</p>
} @else if (loadError(); as message) {
<p class="cerca-lista__error" role="alert">{{ message }}</p>
} @else {
<div class="cerca-lista__results-label">{{ resultsLabel() }}</div>
@if (risultati().length === 0) {
<p class="om-empty">Nessuna lista trovata con questi filtri.</p>
} @else {
<div class="om-grid">
@for (lista of risultati(); track lista.id) {
<div
class="card lista-modello-card"
role="link"
tabindex="0"
(click)="onCardClick($event, lista.id)"
(keydown.enter)="apriDettaglio(lista.id)"
>
<div class="card-title">{{ lista.nome }}</div>
@if (lista.tipoEventoNome) {
<span class="lista-modello-card__chip">{{ lista.tipoEventoNome }}</span>
}
<ul class="lista-modello-card__voci card-body">
@for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) {
@if (item.tipo === 'materiale') {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isVoceSpuntata(lista.id, item.materialeId) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isVoceSpuntata(lista.id, item.materialeId)">
{{ item.nome }} — {{ item.quantita }} {{ item.unitaMisura }}
</span>
</li>
} @else {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isSottoListaSpuntata(lista.id, item) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isSottoListaSpuntata(lista.id, item)">
{{ item.nome }}
</span>
</li>
}
}
@if (lista.vociRestanti > 0) {
<li class="lista-modello-card__altre">
+{{ lista.vociRestanti }} altr{{ lista.vociRestanti === 1 ? 'a' : 'e' }} voc{{
lista.vociRestanti === 1 ? 'e' : 'i'
}}
</li>
}
</ul>
@if (!isAuthenticated) {
<p class="lista-modello-card__avviso">Accedi per usare questa lista come base.</p>
}
@if (creazioneErroreId() === lista.id) {
<p class="lista-modello-card__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
@if (isAuthenticated) {
<button
type="button"
class="btn btn-secondary btn-block"
[disabled]="creazioneInCorsoId() === lista.id"
(click)="onUsaComeBase(lista.id)"
>
Usa come base
</button>
}
</div>
}
</div>
}
}
</section>
@@ -0,0 +1,231 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, of, switchMap } from 'rxjs';
import { AutocompleteApiService, AutocompleteGroup } from '../autocomplete-api.service';
import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { VoceCheckStorageService } from '../voce-check-storage.service';
const VOCI_PREVIEW_LIMIT = 5;
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
// Stessa preview usata in home: voci materiale e sotto-liste unite in un'unica sequenza
// troncata a VOCI_PREVIEW_LIMIT, per avere le card identiche tra home e ricerca.
type ItemAnteprima =
| ({ tipo: 'materiale' } & ListaVoce)
| ({ tipo: 'sottoLista' } & SottoLista);
interface ListaModelloVista extends Lista {
tipoEventoNome: string;
vociPreview: ItemAnteprima[];
vociRestanti: number;
}
// Stesso modello di ricerca a filtri combinabili di scouthub-attivita-fe (Search + GruppoFiltro/
// SearchObjectDto): l'utente digita, sceglie un suggerimento (o preme Invio per un filtro testuale
// libero) e ottiene un "chip" che si combina in AND con gli altri filtri attivi.
type GruppoFiltro = 'testo' | 'tipoEvento' | 'materiale';
interface FiltroAttivo {
id: string | null;
nome: string;
gruppo: GruppoFiltro;
}
@Component({
selector: 'app-cerca-lista',
imports: [],
templateUrl: './cerca-lista.html',
styleUrl: './cerca-lista.css'
})
export class CercaLista implements OnInit {
private readonly autocompleteApi = inject(AutocompleteApiService);
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly listeApi = inject(ListeApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
private readonly voceCheckStorage = inject(VoceCheckStorageService);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly tipiEvento = signal<TipoEvento[]>([]);
readonly listeModello = signal<Lista[]>([]);
readonly creazioneInCorsoId = signal<string | null>(null);
readonly creazioneErroreId = signal<string | null>(null);
readonly searchQuery = signal('');
readonly activeFilters = signal<FiltroAttivo[]>([]);
readonly suggestionGroups = signal<AutocompleteGroup[]>([]);
readonly showSuggestions = computed(() => this.suggestionGroups().length > 0);
private readonly liste = computed<ListaModelloVista[]>(() => {
const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome]));
return this.listeModello().map((lista) => {
const items: ItemAnteprima[] = [
...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })),
...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista }))
];
return {
...lista,
tipoEventoNome: (lista.tipoEventoId && nomiPerTipoEvento.get(lista.tipoEventoId)) ?? '',
vociPreview: items.slice(0, VOCI_PREVIEW_LIMIT),
vociRestanti: Math.max(0, items.length - VOCI_PREVIEW_LIMIT)
};
});
});
readonly risultati = computed<ListaModelloVista[]>(() => {
const filtri = this.activeFilters();
if (filtri.length === 0) {
return this.liste();
}
return this.liste().filter((lista) => filtri.every((filtro) => this.listaMatchFiltro(lista, filtro)));
});
readonly resultsLabel = computed(() => {
const n = this.risultati().length;
return `${n} risultat${n === 1 ? 'o' : 'i'}`;
});
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
constructor() {
// Come in scouthub-attivita-fe: aspetta che l'utente smetta di scrivere per
// AUTOCOMPLETE_DEBOUNCE_MS e poi interroga un'unica search di autocomplete lato backend
// (non liste già precaricate in memoria).
toObservable(this.searchQuery)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
const testo = query.trim();
if (!testo) {
return of<AutocompleteGroup[]>([]);
}
return this.autocompleteApi.search(testo).pipe(catchError(() => of<AutocompleteGroup[]>([])));
}),
takeUntilDestroyed()
)
.subscribe((groups) => this.suggestionGroups.set(groups));
}
async ngOnInit(): Promise<void> {
await this.carica();
}
onQueryChange(event: Event): void {
this.searchQuery.set((event.target as HTMLInputElement).value);
}
onQueryKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && this.searchQuery().trim()) {
this.addFiltro({ id: null, nome: this.searchQuery().trim(), gruppo: 'testo' });
}
}
selectSuggestion(gruppo: GruppoFiltro, suggestion: { id: string; nome: string }): void {
this.addFiltro({ id: suggestion.id, nome: suggestion.nome, gruppo });
}
removeFiltro(index: number): void {
this.activeFilters.update((filtri) => filtri.filter((_, i) => i !== index));
}
filtroLabel(gruppo: GruppoFiltro): string {
const etichette: Record<GruppoFiltro, string> = {
testo: 'Testo',
tipoEvento: 'Tipo evento',
materiale: 'Materiale'
};
return etichette[gruppo];
}
onCardClick(event: Event, listaModelloId: string): void {
// Come in home: l'unico elemento cliccabile dentro la card, oltre alla card stessa, è il
// pulsante "Usa come base", che non deve anche aprire il dettaglio.
const target = event.target as HTMLElement;
if (target.closest('button')) {
return;
}
this.apriDettaglio(listaModelloId);
}
apriDettaglio(listaModelloId: string): void {
this.router.navigate(['/lista-modello', listaModelloId]);
}
isVoceSpuntata(listaModelloId: string, materialeId: string): boolean {
return this.voceCheckStorage.isSpuntata(listaModelloId, materialeId);
}
isSottoListaSpuntata(listaModelloId: string, sottoLista: SottoLista): boolean {
return (
sottoLista.voci.length > 0 &&
sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaModelloId, voce.materialeId))
);
}
onUsaComeBase(listaModelloId: string): void {
void this.usaComeBase(listaModelloId);
}
async usaComeBase(listaModelloId: string): Promise<void> {
if (!this.isAuthenticated) {
this.keycloak.login({ redirectUri: window.location.href });
return;
}
this.creazioneErroreId.set(null);
this.creazioneInCorsoId.set(listaModelloId);
try {
const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId));
this.creazioneInCorsoId.set(null);
await this.router.navigateByUrl(`/liste/${lista.id}`);
} catch {
this.creazioneInCorsoId.set(null);
this.creazioneErroreId.set(listaModelloId);
}
}
private addFiltro(filtro: FiltroAttivo): void {
this.activeFilters.update((filtri) => [...filtri, filtro]);
this.searchQuery.set('');
}
private listaMatchFiltro(lista: ListaModelloVista, filtro: FiltroAttivo): boolean {
switch (filtro.gruppo) {
case 'tipoEvento':
return lista.tipoEventoId === filtro.id;
case 'materiale':
return lista.voci.some((voce) => voce.materialeId === filtro.id);
case 'testo': {
const testo = filtro.nome.toLowerCase();
return lista.nome.toLowerCase().includes(testo) || lista.voci.some((voce) => voce.nome.toLowerCase().includes(testo));
}
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
const listeModello = await firstValueFrom(this.listeApi.getListePubbliche());
this.tipiEvento.set(tipiEvento);
this.listeModello.set(listeModello);
} catch {
this.loadError.set('Impossibile caricare le liste modello. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,69 @@
.lista-modello-dettaglio {
max-width: 640px;
}
.lista-modello-dettaglio__titolo {
font-size: 32px;
margin: 0 0 var(--space-3);
font-weight: 700;
}
.lista-modello-dettaglio__chip {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: var(--color-accent-100);
color: var(--color-accent-800);
font-size: 12px;
font-weight: 600;
margin-bottom: var(--space-5);
}
.lista-modello-dettaglio__voci {
margin: 0 0 var(--space-5);
padding-left: 0;
list-style: none;
font-size: 15px;
display: flex;
flex-direction: column;
gap: 8px;
}
.lista-modello-dettaglio__voce {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.lista-modello-dettaglio__voce-testo--spuntata {
text-decoration: line-through;
opacity: 0.6;
}
.lista-modello-dettaglio__sotto-lista {
margin: 0 0 var(--space-5);
}
.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce {
margin-bottom: var(--space-2);
}
.lista-modello-dettaglio__sotto-lista-titolo {
font-size: 17px;
font-weight: 600;
}
.lista-modello-dettaglio__voci--indentate {
padding-left: 28px;
}
.lista-modello-dettaglio__avviso {
opacity: 0.6;
font-size: 13px;
}
.lista-modello-dettaglio__errore {
color: var(--color-accent-800);
font-size: 13px;
}
@@ -0,0 +1,80 @@
@if (lista(); as l) {
<div class="lista-modello-dettaglio om-page">
<h1 class="lista-modello-dettaglio__titolo">{{ l.nome }}</h1>
@if (tipoEventoNome()) {
<span class="lista-modello-dettaglio__chip">{{ tipoEventoNome() }}</span>
}
<ul class="lista-modello-dettaglio__voci">
@for (voce of l.voci; track voce.materialeId) {
<li>
<label class="lista-modello-dettaglio__voce">
<input
type="checkbox"
[checked]="isVoceSpuntata(voce.materialeId)"
(change)="toggleVoce(voce.materialeId)"
/>
<span [class.lista-modello-dettaglio__voce-testo--spuntata]="isVoceSpuntata(voce.materialeId)">
{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}
</span>
</label>
</li>
}
</ul>
@for (sottoLista of l.sottoListe; track sottoLista.id) {
<div class="lista-modello-dettaglio__sotto-lista">
<label class="lista-modello-dettaglio__voce">
<input
type="checkbox"
[checked]="isSottoListaSpuntata(sottoLista)"
(change)="toggleSottoLista(sottoLista)"
/>
<span
class="lista-modello-dettaglio__sotto-lista-titolo"
[class.lista-modello-dettaglio__voce-testo--spuntata]="isSottoListaSpuntata(sottoLista)"
>
{{ sottoLista.nome }}
</span>
</label>
<ul class="lista-modello-dettaglio__voci lista-modello-dettaglio__voci--indentate">
@for (voce of sottoLista.voci; track voce.materialeId) {
<li>
<label class="lista-modello-dettaglio__voce">
<input
type="checkbox"
[checked]="isVoceSpuntata(voce.materialeId)"
(change)="toggleVoce(voce.materialeId)"
/>
<span [class.lista-modello-dettaglio__voce-testo--spuntata]="isVoceSpuntata(voce.materialeId)">
{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}
</span>
</label>
</li>
}
</ul>
</div>
}
@if (!isAuthenticated) {
<p class="lista-modello-dettaglio__avviso">Accedi per usare questa lista come base.</p>
}
@if (creazioneErrore()) {
<p class="lista-modello-dettaglio__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
@if (isAuthenticated) {
<button type="button" class="btn btn-primary" [disabled]="creazioneInCorso()" (click)="usaComeBase()">
Usa come base
</button>
}
</div>
}
@if (!lista() && !loading()) {
<div class="lista-modello-dettaglio om-page om-empty">Lista non trovata.</div>
}
@@ -0,0 +1,212 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { ListaModelloDettaglio } from './lista-modello-dettaglio';
describe('ListaModelloDettaglio', () => {
let component: ListaModelloDettaglio;
let fixture: ComponentFixture<ListaModelloDettaglio>;
let tipiEventoApi: { getTipiEvento: ReturnType<typeof vi.fn> };
let listeApi: { getListaPubblica: ReturnType<typeof vi.fn>; creaListaDaFork: ReturnType<typeof vi.fn> };
let keycloak: { authenticated: boolean | undefined; login: ReturnType<typeof vi.fn> };
let router: Router;
const tipiEvento: TipoEvento[] = [
{ id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }
];
async function flushUntil(predicate: () => boolean, maxTentativi = 20): Promise<void> {
for (let tentativi = 0; !predicate() && tentativi < maxTentativi; tentativi++) {
await Promise.resolve();
fixture.detectChanges();
}
}
const lista: Lista = {
id: 'lm-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 'te-1',
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: [
{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 },
{ materialeId: 'mat-2', nome: 'Torcia', unitaMisura: 'pz', quantita: 1 }
],
sottoListe: [
{
id: 'lm-kit-ps',
nome: 'Kit di pronto soccorso',
voci: [
{ materialeId: 'mat-3', nome: 'Garze', unitaMisura: 'pz', quantita: 5 },
{ materialeId: 'mat-4', nome: 'Cerotti', unitaMisura: 'pz', quantita: 10 }
]
}
]
};
async function setup(id: string | null = 'lm-1'): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListaModelloDettaglio],
providers: [
provideRouter([]),
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
{ provide: ListeApiService, useValue: listeApi },
{ provide: Keycloak, useValue: keycloak },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => id } } } }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
fixture = TestBed.createComponent(ListaModelloDettaglio);
component = fixture.componentInstance;
fixture.detectChanges();
// whenStable() non intercetta in modo affidabile la Promise avviata da ngOnInit in questo
// componente (a differenza di altri con lo stesso pattern) — attendiamo esplicitamente che
// il caricamento finisca invece di fidarci del tracking automatico dello zone di test.
await flushUntil(() => !component.loading());
}
beforeEach(() => {
sessionStorage.clear();
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
listeApi = { getListaPubblica: vi.fn().mockReturnValue(of(lista)), creaListaDaFork: vi.fn() };
});
it('mostra nome, chip tipo evento e voci della lista', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-dettaglio__titolo')?.textContent).toContain('Uscita di un giorno');
expect(compiled.querySelector('.lista-modello-dettaglio__chip')?.textContent).toContain('Uscita');
expect(compiled.textContent).toContain('Corda — 2 pz');
expect(compiled.textContent).toContain('Torcia — 1 pz');
});
it('mostra le sotto-liste con titolo e voci indentate', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-dettaglio__sotto-lista-titolo')?.textContent).toContain(
'Kit di pronto soccorso'
);
expect(compiled.textContent).toContain('Garze — 5 pz');
expect(compiled.querySelector('.lista-modello-dettaglio__voci--indentate')).not.toBeNull();
});
it('il checkbox della sotto-lista non è spuntato se manca almeno una voce interna', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkboxSottoLista = compiled.querySelector(
'.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input'
) as HTMLInputElement;
expect(checkboxSottoLista.checked).toBe(false);
});
it('cliccando il checkbox della sotto-lista si spuntano automaticamente tutte le sue voci', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkboxSottoLista = compiled.querySelector(
'.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input'
) as HTMLInputElement;
checkboxSottoLista.click();
fixture.detectChanges();
expect(component.isVoceSpuntata('mat-3')).toBe(true);
expect(component.isVoceSpuntata('mat-4')).toBe(true);
const voci = compiled.querySelectorAll('.lista-modello-dettaglio__voci--indentate input');
voci.forEach((input) => expect((input as HTMLInputElement).checked).toBe(true));
});
it('cliccando di nuovo il checkbox della sotto-lista (tutta spuntata) la despunta tutta', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkboxSottoLista = compiled.querySelector(
'.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input'
) as HTMLInputElement;
checkboxSottoLista.click();
fixture.detectChanges();
checkboxSottoLista.click();
fixture.detectChanges();
expect(component.isVoceSpuntata('mat-3')).toBe(false);
expect(component.isVoceSpuntata('mat-4')).toBe(false);
});
it('mostra "Lista non trovata" se il caricamento fallisce', async () => {
listeApi = { getListaPubblica: vi.fn().mockReturnValue(throwError(() => new Error('404'))), creaListaDaFork: vi.fn() };
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Lista non trovata');
});
it('spunta una voce al click sulla checkbox e barra il testo, persistendo in sessionStorage', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkbox = compiled.querySelector('.lista-modello-dettaglio__voce input') as HTMLInputElement;
checkbox.click();
fixture.detectChanges();
const testo = compiled.querySelector('.lista-modello-dettaglio__voce span');
expect(testo?.classList.contains('lista-modello-dettaglio__voce-testo--spuntata')).toBe(true);
expect(sessionStorage.getItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1')).toContain('mat-1');
});
it('nasconde il pulsante "Usa come base" se non autenticato', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('button')).toBeNull();
expect(compiled.textContent).toContain('Accedi per usare questa lista come base');
});
it('crea la lista privata e reindirizza quando autenticato', async () => {
keycloak = { authenticated: true, login: vi.fn() };
const listaCreata: Lista = {
id: 'lp-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: 'lm-1',
creataIl: '2026-01-01',
creataDaMe: true,
voci: [],
sottoListe: []
};
listeApi.creaListaDaFork.mockReturnValue(of(listaCreata));
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('button') as HTMLButtonElement;
button.click();
await flushUntil(() => (router.navigateByUrl as ReturnType<typeof vi.fn>).mock.calls.length > 0);
expect(listeApi.creaListaDaFork).toHaveBeenCalledWith('lm-1');
expect(router.navigateByUrl).toHaveBeenCalledWith('/liste/lp-1');
});
});
@@ -0,0 +1,116 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService, SottoLista } from '../../liste/liste-api.service';
import { TipiEventoApiService } from '../tipi-evento-api.service';
import { VoceCheckStorageService } from '../voce-check-storage.service';
@Component({
selector: 'app-lista-modello-dettaglio',
imports: [],
templateUrl: './lista-modello-dettaglio.html',
styleUrl: './lista-modello-dettaglio.css'
})
export class ListaModelloDettaglio implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly listeApi = inject(ListeApiService);
private readonly keycloak = inject(Keycloak);
private readonly voceCheckStorage = inject(VoceCheckStorageService);
readonly loading = signal(true);
readonly lista = signal<Lista | null>(null);
readonly tipoEventoNome = signal('');
readonly creazioneInCorso = signal(false);
readonly creazioneErrore = signal(false);
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
async ngOnInit(): Promise<void> {
const id = this.route.snapshot.paramMap.get('id');
if (!id) {
this.loading.set(false);
return;
}
await this.carica(id);
}
isVoceSpuntata(materialeId: string): boolean {
const listaId = this.lista()?.id;
return !!listaId && this.voceCheckStorage.isSpuntata(listaId, materialeId);
}
toggleVoce(materialeId: string): void {
const listaId = this.lista()?.id;
if (!listaId) {
return;
}
this.voceCheckStorage.toggle(listaId, materialeId);
}
// Una sotto-lista è spuntata solo se lo sono tutte le sue voci materiale.
isSottoListaSpuntata(sottoLista: SottoLista): boolean {
const listaId = this.lista()?.id;
return (
!!listaId &&
sottoLista.voci.length > 0 &&
sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaId, voce.materialeId))
);
}
// Il checkbox sul nome della sotto-lista spunta/despunta in blocco tutte le sue voci.
toggleSottoLista(sottoLista: SottoLista): void {
const listaId = this.lista()?.id;
if (!listaId) {
return;
}
const nuovoStato = !this.isSottoListaSpuntata(sottoLista);
for (const voce of sottoLista.voci) {
this.voceCheckStorage.setSpuntata(listaId, voce.materialeId, nuovoStato);
}
}
async usaComeBase(): Promise<void> {
const lista = this.lista();
if (!lista) {
return;
}
if (!this.isAuthenticated) {
this.keycloak.login({ redirectUri: window.location.href });
return;
}
this.creazioneErrore.set(false);
this.creazioneInCorso.set(true);
try {
const nuovaLista = await firstValueFrom(this.listeApi.creaListaDaFork(lista.id));
this.creazioneInCorso.set(false);
await this.router.navigateByUrl(`/liste/${nuovaLista.id}`);
} catch {
this.creazioneInCorso.set(false);
this.creazioneErrore.set(true);
}
}
private async carica(id: string): Promise<void> {
this.loading.set(true);
try {
const lista = await firstValueFrom(this.listeApi.getListaPubblica(id));
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
this.lista.set(lista);
this.tipoEventoNome.set(tipiEvento.find((tipoEvento) => tipoEvento.id === lista.tipoEventoId)?.nome ?? '');
} catch {
this.lista.set(null);
} finally {
this.loading.set(false);
}
}
}
@@ -1,29 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface ListaVoce {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface Lista {
id: string;
nome: string;
orgId: string;
creataIl: string;
voci: ListaVoce[];
}
@Injectable({ providedIn: 'root' })
export class ListeApiService {
private readonly http = inject(HttpClient);
creaListaDaModello(listaModelloId: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste/da-modello/${listaModelloId}`, {});
}
}
@@ -1,28 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface ListaModelloVoce {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface ListaModello {
id: string;
nome: string;
tipoEventoId: string;
voci: ListaModelloVoce[];
}
@Injectable({ providedIn: 'root' })
export class ListeModelloApiService {
private readonly http = inject(HttpClient);
getListeModello(): Observable<ListaModello[]> {
return this.http.get<ListaModello[]>(`${environment.magazzinoApiBaseUrl}/liste-modello`);
}
}
@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
@@ -17,6 +17,12 @@ export interface ProponiMaterialeInput {
unitaMisura: string;
}
export interface MaterialeInput {
nome: string;
categoria: string;
unitaMisura: string;
}
export interface MaterialeProposta {
id: string;
nome: string;
@@ -31,11 +37,24 @@ export interface MaterialeProposta {
export class MaterialiApiService {
private readonly http = inject(HttpClient);
getMateriali(): Observable<MaterialePubblico[]> {
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`);
getMateriali(nome?: string): Observable<MaterialePubblico[]> {
const params = nome ? new HttpParams().set('nome', nome) : undefined;
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`, { params });
}
proponiMateriale(input: ProponiMaterialeInput): Observable<MaterialeProposta> {
return this.http.post<MaterialeProposta>(`${environment.magazzinoApiBaseUrl}/materiali/proposte`, input);
}
creaMateriale(input: MaterialeInput): Observable<MaterialePubblico> {
return this.http.post<MaterialePubblico>(`${environment.magazzinoApiBaseUrl}/materiali`, input);
}
aggiornaMateriale(id: string, input: MaterialeInput): Observable<MaterialePubblico> {
return this.http.put<MaterialePubblico>(`${environment.magazzinoApiBaseUrl}/materiali/${id}`, input);
}
eliminaMateriale(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/materiali/${id}`);
}
}
@@ -1,21 +0,0 @@
.proponi-materiale {
max-width: 520px;
}
.proponi-materiale__field {
width: 100%;
}
.proponi-materiale__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.proponi-materiale__error {
color: var(--color-accent-800);
}
.proponi-materiale__successo {
margin-bottom: var(--space-4);
}
@@ -1,58 +0,0 @@
<section class="proponi-materiale om-page">
<h1 class="om-section-title">Proponi un nuovo materiale</h1>
<p class="om-section-sub">
La proposta entra in coda di moderazione: il materiale verrà aggiunto al catalogo pubblico
solo dopo l'approvazione.
</p>
@if (successo()) {
<p class="tag tag-accent-2 proponi-materiale__successo" role="status">
Proposta inviata: in attesa di moderazione
</p>
}
<form class="proponi-materiale__form om-stack" (submit)="$event.preventDefault(); submit()" novalidate>
<div class="field proponi-materiale__field">
<label for="pm-nome">Nome del materiale</label>
<input class="input" id="pm-nome" [formControl]="nome" placeholder="Es. Fune da bucato" />
@if (nome.hasError('required')) {
<p class="proponi-materiale__campo-errore">Il nome è obbligatorio.</p>
} @else if (nome.hasError('minlength')) {
<p class="proponi-materiale__campo-errore">Il nome deve avere almeno 2 caratteri.</p>
} @else if (nome.hasError('maxlength')) {
<p class="proponi-materiale__campo-errore">Il nome non può superare i 100 caratteri.</p>
}
</div>
<div class="field proponi-materiale__field">
<label for="pm-categoria">Categoria</label>
<input class="input" id="pm-categoria" [formControl]="categoria" placeholder="Es. Attrezzatura" />
@if (categoria.hasError('required')) {
<p class="proponi-materiale__campo-errore">La categoria è obbligatoria.</p>
} @else if (categoria.hasError('maxlength')) {
<p class="proponi-materiale__campo-errore">La categoria non può superare i 100 caratteri.</p>
}
</div>
<div class="field proponi-materiale__field">
<label for="pm-unita">Unità di misura</label>
<input class="input" id="pm-unita" [formControl]="unitaMisura" placeholder="Es. pz" />
@if (unitaMisura.hasError('required')) {
<p class="proponi-materiale__campo-errore">L'unità di misura è obbligatoria.</p>
} @else if (unitaMisura.hasError('maxlength')) {
<p class="proponi-materiale__campo-errore">L'unità di misura non può superare i 20 caratteri.</p>
}
</div>
@if (errorMessage(); as message) {
<p class="proponi-materiale__error" role="alert">{{ message }}</p>
}
<div class="om-row">
<button type="submit" class="btn btn-primary" [disabled]="submitting()">
{{ submitting() ? 'Invio in corso…' : 'Invia proposta' }}
</button>
<a class="btn btn-ghost" routerLink="/">Annulla</a>
</div>
</form>
</section>
@@ -1,96 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialeProposta, MaterialiApiService } from '../materiali-api.service';
import { ProponiMateriale } from './proponi-materiale';
describe('ProponiMateriale', () => {
let component: ProponiMateriale;
let fixture: ComponentFixture<ProponiMateriale>;
let materialiApi: { proponiMateriale: ReturnType<typeof vi.fn> };
async function setup(nomeSuggerito: string | null = null): Promise<void> {
materialiApi = { proponiMateriale: vi.fn() };
await TestBed.configureTestingModule({
imports: [ProponiMateriale],
providers: [
provideRouter([]),
{ provide: MaterialiApiService, useValue: materialiApi },
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParamMap: convertToParamMap(nomeSuggerito ? { nome: nomeSuggerito } : {}) }
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(ProponiMateriale);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('precompila il nome dal query param', async () => {
await setup('Fune da bucato');
expect(component.nome.value).toBe('Fune da bucato');
});
it('non chiama l\'API se il form non è valido e marca i controlli come touched', async () => {
await setup();
component.nome.setValue('');
await component.submit();
expect(materialiApi.proponiMateriale).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
expect(component.categoria.touched).toBe(true);
expect(component.unitaMisura.touched).toBe(true);
});
it('invia la proposta e mostra un messaggio di successo', async () => {
await setup();
component.nome.setValue('Fune da bucato');
component.categoria.setValue('Campeggio');
component.unitaMisura.setValue('pz');
const proposta: MaterialeProposta = {
id: 'mat-nuovo',
nome: 'Fune da bucato',
categoria: 'Campeggio',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: '2026-01-01T00:00:00.000Z'
};
materialiApi.proponiMateriale.mockReturnValue(of(proposta));
await component.submit();
expect(materialiApi.proponiMateriale).toHaveBeenCalledWith({
nome: 'Fune da bucato',
categoria: 'Campeggio',
unitaMisura: 'pz'
});
expect(component.successo()).toBe(true);
expect(component.submitting()).toBe(false);
expect(component.nome.value).toBe('');
});
it('mostra un messaggio di errore se l\'invio fallisce', async () => {
await setup();
component.nome.setValue('Fune da bucato');
component.categoria.setValue('Campeggio');
component.unitaMisura.setValue('pz');
materialiApi.proponiMateriale.mockReturnValue(throwError(() => new Error('server error')));
await component.submit();
expect(component.errorMessage()).toBe('Impossibile inviare la proposta. Riprova più tardi.');
expect(component.successo()).toBe(false);
});
});
@@ -1,72 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialiApiService } from '../materiali-api.service';
@Component({
selector: 'app-proponi-materiale',
imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './proponi-materiale.html',
styleUrl: './proponi-materiale.css'
})
export class ProponiMateriale {
private readonly route = inject(ActivatedRoute);
private readonly materialiApi = inject(MaterialiApiService);
readonly nome = new FormControl(this.route.snapshot.queryParamMap.get('nome') ?? '', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(2), Validators.maxLength(100)]
});
readonly categoria = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(100)]
});
readonly unitaMisura = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(20)]
});
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly successo = signal(false);
async submit(): Promise<void> {
if (this.submitting()) {
return;
}
if (this.nome.invalid || this.categoria.invalid || this.unitaMisura.invalid) {
this.nome.markAsTouched();
this.categoria.markAsTouched();
this.unitaMisura.markAsTouched();
return;
}
this.errorMessage.set(null);
this.successo.set(false);
this.submitting.set(true);
try {
await firstValueFrom(
this.materialiApi.proponiMateriale({
nome: this.nome.value.trim(),
categoria: this.categoria.value.trim(),
unitaMisura: this.unitaMisura.value.trim()
})
);
this.submitting.set(false);
this.successo.set(true);
this.nome.reset('');
this.categoria.reset('');
this.unitaMisura.reset('');
} catch {
this.submitting.set(false);
this.errorMessage.set('Impossibile inviare la proposta. Riprova più tardi.');
}
}
}
@@ -1,19 +1,55 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type StatoTipoEvento = 'confermata' | 'da_approvare';
export interface TipoEvento {
id: string;
nome: string;
stato: StatoTipoEvento;
creatoDaOrgId: string | null;
creatoIl: string;
}
@Injectable({ providedIn: 'root' })
export class TipiEventoApiService {
private readonly http = inject(HttpClient);
getTipiEvento(): Observable<TipoEvento[]> {
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento`);
// Pubblico: solo i tipi evento confermati (catalogo liste, autocomplete wizard).
getTipiEvento(nome?: string): Observable<TipoEvento[]> {
const params = nome ? new HttpParams().set('nome', nome) : undefined;
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento`, { params });
}
// Moderazione: tutti gli stati, incluse le proposte in attesa.
getTipiEventoModerazione(): Observable<TipoEvento[]> {
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento/moderazione`);
}
createTipoEvento(nome: string): Observable<TipoEvento> {
return this.http.post<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento`, { nome });
}
proponiTipoEvento(nome: string): Observable<TipoEvento> {
return this.http.post<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento/proposte`, { nome });
}
updateTipoEvento(id: string, nome: string): Observable<TipoEvento> {
return this.http.put<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}`, { nome });
}
deleteTipoEvento(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}`);
}
approvaTipoEvento(id: string): Observable<TipoEvento> {
return this.http.post<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}/approva`, {});
}
rifiutaTipoEvento(id: string): Observable<void> {
return this.http.post<void>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}/rifiuta`, {});
}
}
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
// Le spunte sulle voci di una lista modello sono solo un promemoria visivo lato utente
// (niente checklist reale come per gli Eventi): non vanno al backend, restano nella
// sessionStorage del browser così sopravvivono alla navigazione tra home/dettaglio/ricerca
// ma si azzerano alla chiusura della scheda.
const STORAGE_KEY_PREFIX = 'scouthub-magazzino:lista-modello-voci-spuntate:';
@Injectable({ providedIn: 'root' })
export class VoceCheckStorageService {
isSpuntata(listaModelloId: string, materialeId: string): boolean {
return this.leggi(listaModelloId).has(materialeId);
}
toggle(listaModelloId: string, materialeId: string): void {
this.setSpuntata(listaModelloId, materialeId, !this.isSpuntata(listaModelloId, materialeId));
}
// Usato per il check/uncheck massivo di una sotto-lista dal suo checkbox riassuntivo:
// a differenza di toggle() imposta uno stato preciso invece di invertirlo.
setSpuntata(listaModelloId: string, materialeId: string, spuntata: boolean): void {
const spuntate = this.leggi(listaModelloId);
if (spuntata) {
spuntate.add(materialeId);
} else {
spuntate.delete(materialeId);
}
this.scrivi(listaModelloId, spuntate);
}
private leggi(listaModelloId: string): Set<string> {
try {
const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + listaModelloId);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
} catch {
return new Set();
}
}
private scrivi(listaModelloId: string, spuntate: Set<string>): void {
try {
sessionStorage.setItem(STORAGE_KEY_PREFIX + listaModelloId, JSON.stringify([...spuntate]));
} catch {
// sessionStorage non disponibile (es. modalità privata restrittiva): la spunta
// resta solo per la sessione corrente del componente, nessun errore da mostrare.
}
}
}
@@ -0,0 +1,14 @@
export type TipoNotifica =
| 'MATERIALE_PROPOSTO'
| 'CATEGORIA_PROPOSTA'
| 'TIPO_EVENTO_PROPOSTO'
| 'LISTA_PROPOSTA';
export interface Notifica {
id: number;
tipo: TipoNotifica;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: string;
}
@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Notifica } from '../models/notifica.model';
@Injectable({
providedIn: 'root',
})
export class NotificheService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.magazzinoApiBaseUrl}/notifiche`;
getLista(): Observable<Notifica[]> {
return this.http.get<Notifica[]>(`${this.baseUrl}`);
}
getCountNonLette(): Observable<{ count: number }> {
return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`);
}
segnaLetta(id: number): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${id}/letta`, {});
}
segnaTutteLette(): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/letta-tutte`, {});
}
}
@@ -1,17 +0,0 @@
.crea-evento {
max-width: 520px;
}
.crea-evento__field {
width: 100%;
}
.crea-evento__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.crea-evento__error {
color: var(--color-accent-800);
}
@@ -1,57 +0,0 @@
<section class="crea-evento om-page">
<h1 class="om-section-title">Nuovo evento</h1>
<p class="om-section-sub">Crea un evento a partire da una lista della tua organizzazione.</p>
@if (loading()) {
<p class="om-empty">Caricamento liste…</p>
} @else if (loadError(); as message) {
<p class="crea-evento__error" role="alert">{{ message }}</p>
} @else if (liste().length === 0) {
<p class="om-empty">
Non hai ancora nessuna lista da collegare a un evento. Creane una nella sezione
<a routerLink="/liste">Le tue liste</a>.
</p>
} @else {
<form class="crea-evento__form om-stack" (submit)="$event.preventDefault(); submit()" novalidate>
<div class="field crea-evento__field">
<label for="ce-nome">Nome evento</label>
<input class="input" id="ce-nome" [formControl]="nome" placeholder="Es. Campo estivo 2026" />
@if (nome.hasError('required')) {
<p class="crea-evento__campo-errore">Il nome dell'evento è obbligatorio.</p>
} @else if (nome.hasError('minlength')) {
<p class="crea-evento__campo-errore">Il nome deve avere almeno 3 caratteri.</p>
} @else if (nome.hasError('maxlength')) {
<p class="crea-evento__campo-errore">Il nome non può superare i 100 caratteri.</p>
}
</div>
<div class="field crea-evento__field">
<label for="ce-data">Data</label>
<input class="input" id="ce-data" type="date" [formControl]="data" />
@if (data.hasError('required')) {
<p class="crea-evento__campo-errore">La data è obbligatoria.</p>
}
</div>
<div class="field crea-evento__field">
<label for="ce-lista">Lista materiale</label>
<select class="input" id="ce-lista" [formControl]="listaId">
@for (lista of liste(); track lista.id) {
<option [value]="lista.id">{{ lista.nome }}</option>
}
</select>
@if (listaId.hasError('required')) {
<p class="crea-evento__campo-errore">Seleziona una lista.</p>
}
</div>
@if (errorMessage(); as message) {
<p class="crea-evento__error" role="alert">{{ message }}</p>
}
<button type="submit" class="btn btn-primary" [disabled]="submitting()">
{{ submitting() ? 'Creazione in corso…' : 'Crea evento' }}
</button>
</form>
}
</section>
@@ -1,122 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { EventiApiService, EventoDettaglio } from '../eventi-api.service';
import { CreaEvento } from './crea-evento';
describe('CreaEvento', () => {
let component: CreaEvento;
let fixture: ComponentFixture<CreaEvento>;
let listeApi: { getListe: ReturnType<typeof vi.fn> };
let eventiApi: { creaEvento: ReturnType<typeof vi.fn> };
let router: Router;
const liste: Lista[] = [
{ id: 'lista-1', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] },
{ id: 'lista-2', nome: 'Uscita di un giorno', orgId: 'org-1', creataIl: '2026-01-02', voci: [] }
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CreaEvento],
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: EventiApiService, useValue: eventiApi }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(CreaEvento);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
eventiApi = { creaEvento: vi.fn() };
});
it('mostra le liste disponibili nel select', async () => {
await setup();
expect(component.loading()).toBe(false);
expect(component.liste()).toEqual(liste);
});
it('mostra un messaggio se non ci sono liste disponibili', async () => {
listeApi.getListe.mockReturnValue(of([]));
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Non hai ancora nessuna lista');
});
it('mostra un messaggio di errore se il caricamento delle liste fallisce', async () => {
listeApi.getListe.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare le liste disponibili. Riprova più tardi.');
});
describe('validazione e invio del form', () => {
beforeEach(async () => {
await setup();
});
it('non chiama l\'API se il form non è valido e marca i controlli come touched', async () => {
await component.submit();
expect(eventiApi.creaEvento).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
expect(component.data.touched).toBe(true);
expect(component.listaId.touched).toBe(true);
});
it('crea l\'evento e reindirizza al suo dettaglio', async () => {
component.nome.setValue('Campo estivo 2026');
component.data.setValue('2026-08-01');
component.listaId.setValue('lista-1');
const evento: EventoDettaglio = {
id: 'evento-1',
orgId: 'org-1',
nome: 'Campo estivo 2026',
listaId: 'lista-1',
data: '2026-08-01T00:00:00.000Z',
voci: []
};
eventiApi.creaEvento.mockReturnValue(of(evento));
await component.submit();
expect(eventiApi.creaEvento).toHaveBeenCalledWith({
nome: 'Campo estivo 2026',
data: '2026-08-01',
listaId: 'lista-1'
});
expect(router.navigate).toHaveBeenCalledWith(['/eventi', 'evento-1']);
expect(component.submitting()).toBe(false);
});
it('mostra un messaggio di errore se la creazione fallisce', async () => {
component.nome.setValue('Campo estivo 2026');
component.data.setValue('2026-08-01');
component.listaId.setValue('lista-1');
eventiApi.creaEvento.mockReturnValue(throwError(() => new Error('server error')));
await component.submit();
expect(component.errorMessage()).toBe("Impossibile creare l'evento. Riprova più tardi.");
expect(router.navigate).not.toHaveBeenCalled();
});
});
});
@@ -1,86 +0,0 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { EventiApiService } from '../eventi-api.service';
@Component({
selector: 'app-crea-evento',
imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './crea-evento.html',
styleUrl: './crea-evento.css'
})
export class CreaEvento implements OnInit {
private readonly listeApi = inject(ListeApiService);
private readonly eventiApi = inject(EventiApiService);
private readonly router = inject(Router);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly liste = signal<Lista[]>([]);
readonly nome = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
});
readonly data = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly listaId = new FormControl<string | null>(null, { validators: [Validators.required] });
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
async ngOnInit(): Promise<void> {
await this.caricaListe();
}
async submit(): Promise<void> {
if (this.submitting()) {
return;
}
if (this.nome.invalid || this.data.invalid || this.listaId.invalid) {
this.nome.markAsTouched();
this.data.markAsTouched();
this.listaId.markAsTouched();
return;
}
this.errorMessage.set(null);
this.submitting.set(true);
try {
const evento = await firstValueFrom(
this.eventiApi.creaEvento({
nome: this.nome.value.trim(),
data: this.data.value,
listaId: this.listaId.value!
})
);
this.submitting.set(false);
await this.router.navigate(['/eventi', evento.id]);
} catch {
this.submitting.set(false);
this.errorMessage.set("Impossibile creare l'evento. Riprova più tardi.");
}
}
private async caricaListe(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const liste = await firstValueFrom(this.listeApi.getListe());
this.liste.set(liste);
} catch {
this.loadError.set('Impossibile caricare le liste disponibili. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -1,53 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface EventoVoce {
materialeId: string;
nome: string;
unitaMisura: string;
quantitaRichiesta: number;
quantitaPosseduta: number;
portato: boolean;
note: string | null;
}
export interface EventoDettaglio {
id: string;
orgId: string;
nome: string;
listaId: string;
data: string;
voci: EventoVoce[];
}
export interface CreaEventoInput {
nome: string;
listaId: string;
data: string;
}
export interface CheckVoceInput {
materialeId: string;
portato?: boolean;
note?: string | null;
}
@Injectable({ providedIn: 'root' })
export class EventiApiService {
private readonly http = inject(HttpClient);
creaEvento(input: CreaEventoInput): Observable<EventoDettaglio> {
return this.http.post<EventoDettaglio>(`${environment.magazzinoApiBaseUrl}/eventi`, input);
}
getEvento(id: string): Observable<EventoDettaglio> {
return this.http.get<EventoDettaglio>(`${environment.magazzinoApiBaseUrl}/eventi/${id}`);
}
aggiornaCheck(id: string, voci: CheckVoceInput[]): Observable<EventoDettaglio> {
return this.http.patch<EventoDettaglio>(`${environment.magazzinoApiBaseUrl}/eventi/${id}/check`, { voci });
}
}
@@ -1,16 +0,0 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const EVENTI_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./crea-evento/crea-evento').then((m) => m.CreaEvento),
canActivate: [requireAuthGuard]
},
{
path: ':id',
loadComponent: () => import('./evento-dettaglio/evento-dettaglio').then((m) => m.EventoDettaglioComponent),
canActivate: [requireAuthGuard]
}
];
@@ -1,52 +0,0 @@
.evento-dettaglio {
max-width: 800px;
}
.evento-dettaglio__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-2);
}
.evento-dettaglio__data {
color: var(--color-text);
opacity: 0.7;
margin: 0;
}
.evento-dettaglio__tabella {
margin-top: var(--space-5);
margin-bottom: var(--space-5);
}
.evento-dettaglio__quantita--insufficiente {
color: var(--color-accent-800);
font-weight: 600;
}
.evento-dettaglio__badge-manca {
margin-left: var(--space-2);
}
.evento-dettaglio__checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-accent);
cursor: pointer;
}
.evento-dettaglio__note {
width: 100%;
min-height: 32px;
}
.evento-dettaglio__error {
color: var(--color-accent-800);
}
.evento-dettaglio__successo {
display: inline-flex;
margin-bottom: var(--space-3);
}
@@ -1,74 +0,0 @@
<section class="evento-dettaglio om-page">
@if (loading()) {
<p class="om-empty">Caricamento evento…</p>
} @else if (loadError(); as message) {
<p class="evento-dettaglio__error" role="alert">{{ message }}</p>
} @else if (evento(); as ev) {
<div class="evento-dettaglio__header">
<div>
<h1 class="om-section-title">{{ ev.nome }}</h1>
<p class="evento-dettaglio__data">{{ ev.data | slice: 0 : 10 }}</p>
</div>
<a class="btn btn-ghost" routerLink="/eventi">Crea un altro evento</a>
</div>
<table class="table evento-dettaglio__tabella">
<thead>
<tr>
<th>Materiale</th>
<th>Richiesta</th>
<th>In magazzino</th>
<th>Portato</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (voce of voci(); track voce.materialeId) {
<tr>
<td>{{ voce.nome }}</td>
<td>{{ voce.quantitaRichiesta }} {{ voce.unitaMisura }}</td>
<td [class.evento-dettaglio__quantita--insufficiente]="quantitaInsufficiente(voce)">
{{ voce.quantitaPosseduta }} {{ voce.unitaMisura }}
@if (quantitaInsufficiente(voce)) {
<span class="tag tag-accent evento-dettaglio__badge-manca">manca</span>
}
</td>
<td>
<input
type="checkbox"
class="evento-dettaglio__checkbox"
[checked]="voce.portato"
(change)="togglePortato(voce.materialeId, $any($event.target).checked)"
[attr.aria-label]="'Portato: ' + voce.nome"
/>
</td>
<td>
<input
type="text"
class="input evento-dettaglio__note"
[value]="voce.note"
(change)="modificaNote(voce.materialeId, $any($event.target).value)"
[attr.aria-label]="'Note per ' + voce.nome"
/>
</td>
</tr>
} @empty {
<tr>
<td colspan="5">La lista collegata a questo evento non ha voci.</td>
</tr>
}
</tbody>
</table>
@if (salvataggioErrore(); as message) {
<p class="evento-dettaglio__error" role="alert">{{ message }}</p>
}
@if (salvataggioOk()) {
<p class="tag tag-accent-2 evento-dettaglio__successo" role="status">Check salvato</p>
}
<button type="button" class="btn btn-primary" [disabled]="salvataggioInCorso()" (click)="salva()">
{{ salvataggioInCorso() ? 'Salvataggio in corso…' : 'Salva check' }}
</button>
}
</section>
@@ -1,148 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { EventiApiService, EventoDettaglio } from '../eventi-api.service';
import { EventoDettaglioComponent } from './evento-dettaglio';
describe('EventoDettaglioComponent', () => {
let component: EventoDettaglioComponent;
let fixture: ComponentFixture<EventoDettaglioComponent>;
let eventiApi: { getEvento: ReturnType<typeof vi.fn>; aggiornaCheck: ReturnType<typeof vi.fn> };
const eventoIniziale: EventoDettaglio = {
id: 'evento-1',
orgId: 'org-1',
nome: 'Campo estivo 2026',
listaId: 'lista-1',
data: '2026-08-01T00:00:00.000Z',
voci: [
{
materialeId: 'mat-1',
nome: 'Corda',
unitaMisura: 'pz',
quantitaRichiesta: 5,
quantitaPosseduta: 2,
portato: false,
note: null
},
{
materialeId: 'mat-2',
nome: 'Telo cerato',
unitaMisura: 'pz',
quantitaRichiesta: 1,
quantitaPosseduta: 3,
portato: true,
note: 'Controllato'
}
]
};
async function setup(eventoId = 'evento-1'): Promise<void> {
await TestBed.configureTestingModule({
imports: [EventoDettaglioComponent],
providers: [
provideRouter([]),
{ provide: EventiApiService, useValue: eventiApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => eventoId } } } }
]
}).compileComponents();
fixture = TestBed.createComponent(EventoDettaglioComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
eventiApi = { getEvento: vi.fn().mockReturnValue(of(eventoIniziale)), aggiornaCheck: vi.fn() };
});
it('mostra le voci con quantità richiesta e disponibile in magazzino', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.evento-dettaglio__tabella tbody tr');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Corda');
expect(righe[0].textContent).toContain('5 pz');
expect(righe[0].textContent).toContain('2 pz');
});
it('evidenzia con un badge la quantità insufficiente in magazzino', async () => {
await setup();
expect(component.quantitaInsufficiente(component.voci()[0])).toBe(true);
expect(component.quantitaInsufficiente(component.voci()[1])).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const badge = compiled.querySelector('.evento-dettaglio__badge-manca');
expect(badge).toBeTruthy();
expect(compiled.querySelectorAll('.evento-dettaglio__badge-manca').length).toBe(1);
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
eventiApi.getEvento.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe("Impossibile caricare l'evento. Riprova più tardi.");
});
describe('modifica del check locale', () => {
beforeEach(async () => {
await setup();
});
it('aggiorna lo stato "portato" di una voce', () => {
component.togglePortato('mat-1', true);
expect(component.voci().find((v) => v.materialeId === 'mat-1')?.portato).toBe(true);
});
it('aggiorna le note di una voce', () => {
component.modificaNote('mat-1', 'Verificare prima di partire');
expect(component.voci().find((v) => v.materialeId === 'mat-1')?.note).toBe('Verificare prima di partire');
});
});
describe('salvataggio del check', () => {
beforeEach(async () => {
await setup();
});
it('invia tutte le voci a aggiornaCheck con note vuote normalizzate a null', async () => {
component.togglePortato('mat-1', true);
component.modificaNote('mat-1', ' ');
const eventoAggiornato: EventoDettaglio = {
...eventoIniziale,
voci: eventoIniziale.voci.map((v) => (v.materialeId === 'mat-1' ? { ...v, portato: true } : v))
};
eventiApi.aggiornaCheck.mockReturnValue(of(eventoAggiornato));
await component.salva();
expect(eventiApi.aggiornaCheck).toHaveBeenCalledWith('evento-1', [
{ materialeId: 'mat-1', portato: true, note: null },
{ materialeId: 'mat-2', portato: true, note: 'Controllato' }
]);
expect(component.salvataggioOk()).toBe(true);
expect(component.voci()[0].portato).toBe(true);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
eventiApi.aggiornaCheck.mockReturnValue(throwError(() => new Error('server error')));
await component.salva();
expect(component.salvataggioErrore()).toBe('Impossibile salvare il check della lista. Riprova più tardi.');
expect(component.salvataggioOk()).toBe(false);
});
});
});
@@ -1,102 +0,0 @@
import { SlicePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { CheckVoceInput, EventiApiService, EventoDettaglio } from '../eventi-api.service';
interface VoceCheckEditor {
materialeId: string;
nome: string;
unitaMisura: string;
quantitaRichiesta: number;
quantitaPosseduta: number;
portato: boolean;
note: string;
}
function toEditor(voci: EventoDettaglio['voci']): VoceCheckEditor[] {
return voci.map((v) => ({ ...v, note: v.note ?? '' }));
}
@Component({
selector: 'app-evento-dettaglio',
imports: [SlicePipe, RouterLink, MatButtonModule, MatCheckboxModule, MatInputModule],
templateUrl: './evento-dettaglio.html',
styleUrl: './evento-dettaglio.css'
})
export class EventoDettaglioComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly eventiApi = inject(EventiApiService);
private readonly eventoId = this.route.snapshot.paramMap.get('id') ?? '';
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly evento = signal<EventoDettaglio | null>(null);
readonly voci = signal<VoceCheckEditor[]>([]);
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly salvataggioOk = signal(false);
async ngOnInit(): Promise<void> {
await this.carica();
}
quantitaInsufficiente(voce: VoceCheckEditor): boolean {
return voce.quantitaPosseduta < voce.quantitaRichiesta;
}
togglePortato(materialeId: string, portato: boolean): void {
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, portato } : v)));
}
modificaNote(materialeId: string, note: string): void {
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, note } : v)));
}
async salva(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
this.salvataggioErrore.set(null);
this.salvataggioOk.set(false);
this.salvataggioInCorso.set(true);
try {
const input: CheckVoceInput[] = this.voci().map((v) => ({
materialeId: v.materialeId,
portato: v.portato,
note: v.note.trim().length > 0 ? v.note.trim() : null
}));
const eventoAggiornato = await firstValueFrom(this.eventiApi.aggiornaCheck(this.eventoId, input));
this.evento.set(eventoAggiornato);
this.voci.set(toEditor(eventoAggiornato.voci));
this.salvataggioOk.set(true);
} catch {
this.salvataggioErrore.set('Impossibile salvare il check della lista. Riprova più tardi.');
} finally {
this.salvataggioInCorso.set(false);
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const evento = await firstValueFrom(this.eventiApi.getEvento(this.eventoId));
this.evento.set(evento);
this.voci.set(toEditor(evento.voci));
} catch {
this.loadError.set('Impossibile caricare l\'evento. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -1,59 +0,0 @@
.lista-editor {
max-width: 720px;
}
.lista-editor__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 0;
}
.lista-editor__tabella {
margin-bottom: var(--space-5);
}
.lista-editor__quantita {
width: 4.5rem;
min-height: 32px;
padding: 4px 10px;
}
.lista-editor__ricerca {
margin-bottom: var(--space-5);
}
.lista-editor__ricerca-field {
width: 100%;
}
.lista-editor__risultati {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
}
.lista-editor__risultati li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-divider);
}
.lista-editor__nessun-risultato {
color: var(--color-text);
opacity: 0.7;
font-size: 14px;
}
.lista-editor__error {
color: var(--color-accent-800);
}
.lista-editor__successo {
display: inline-flex;
margin-bottom: var(--space-3);
}
@@ -1,92 +0,0 @@
<section class="lista-editor om-page">
@if (loading()) {
<p class="om-empty">Caricamento lista…</p>
} @else if (loadError(); as message) {
<p class="lista-editor__error" role="alert">{{ message }}</p>
} @else {
<div class="lista-editor__header">
<h1 class="om-section-title">{{ lista()?.nome }}</h1>
<a class="btn btn-ghost" routerLink="/liste">Torna alle tue liste</a>
</div>
<p class="om-section-sub">Le liste filtrano sempre per la tua organizzazione.</p>
<table class="table lista-editor__tabella">
<thead>
<tr>
<th>Materiale</th>
<th>Quantità</th>
<th>Unità</th>
<th></th>
</tr>
</thead>
<tbody>
@for (voce of voci(); track voce.materialeId) {
<tr>
<td>{{ voce.nome }}</td>
<td>
<input
type="number"
min="1"
step="1"
class="input lista-editor__quantita"
[value]="voce.quantita"
(change)="modificaQuantita(voce.materialeId, $any($event.target).value)"
/>
</td>
<td>{{ voce.unitaMisura }}</td>
<td>
<button
type="button"
class="btn btn-ghost"
(click)="rimuoviVoce(voce.materialeId)"
[attr.aria-label]="'Rimuovi ' + voce.nome"
>
Rimuovi
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="4">Nessun materiale in questa lista.</td>
</tr>
}
</tbody>
</table>
<div class="lista-editor__ricerca">
<div class="field lista-editor__ricerca-field">
<label for="le-ricerca">Cerca materiale da aggiungere</label>
<input class="input" id="le-ricerca" [formControl]="ricerca" placeholder="Es. corda" />
</div>
@if (risultatiRicerca().length > 0) {
<ul class="lista-editor__risultati">
@for (materiale of risultatiRicerca(); track materiale.id) {
<li>
<span>{{ materiale.nome }} ({{ materiale.categoria }})</span>
<button type="button" class="btn btn-secondary" (click)="aggiungiMateriale(materiale)">Aggiungi</button>
</li>
}
</ul>
} @else if (nessunRisultato()) {
<p class="lista-editor__nessun-risultato">
Nessun materiale trovato per "{{ ricerca.value }}".
<a [routerLink]="['/proponi-materiale']" [queryParams]="{ nome: ricerca.value }">
Proponi un nuovo materiale
</a>
</p>
}
</div>
@if (salvataggioErrore(); as message) {
<p class="lista-editor__error" role="alert">{{ message }}</p>
}
@if (salvataggioOk()) {
<p class="tag tag-accent-2 lista-editor__successo" role="status">Modifiche salvate</p>
}
<button type="button" class="btn btn-primary" [disabled]="salvataggioInCorso()" (click)="salva()">
{{ salvataggioInCorso() ? 'Salvataggio in corso…' : 'Salva modifiche' }}
</button>
}
</section>
@@ -1,181 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service';
import { Lista, ListeApiService } from '../liste-api.service';
import { ListaEditor } from './lista-editor';
describe('ListaEditor', () => {
let component: ListaEditor;
let fixture: ComponentFixture<ListaEditor>;
let listeApi: { getListe: ReturnType<typeof vi.fn>; aggiornaLista: ReturnType<typeof vi.fn> };
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
const listaIniziale: Lista = {
id: 'lista-1',
nome: 'Campo estivo 2026',
orgId: 'org-1',
creataIl: '2026-01-01',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
};
const materialiCatalogo: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
{ id: 'mat-2', nome: 'Telo cerato', categoria: 'Campeggio', unitaMisura: 'pz' },
{ id: 'mat-3', nome: 'Torcia', categoria: 'Attrezzatura', unitaMisura: 'pz' }
];
async function setup(listaId = 'lista-1'): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListaEditor],
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => listaId } } } }
]
}).compileComponents();
fixture = TestBed.createComponent(ListaEditor);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
listeApi = { getListe: vi.fn().mockReturnValue(of([listaIniziale])), aggiornaLista: vi.fn() };
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) };
});
it('mostra le voci della lista dopo il caricamento', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
expect(component.voci()).toEqual(listaIniziale.voci);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Campo estivo 2026');
const righe = compiled.querySelectorAll('.lista-editor__tabella tbody tr');
expect(righe.length).toBe(1);
expect(righe[0].textContent).toContain('Corda');
});
it('mostra un errore se la lista non viene trovata', async () => {
listeApi.getListe.mockReturnValue(of([]));
await setup();
expect(component.loadError()).toBe('Lista non trovata.');
});
it('mostra un errore se il caricamento fallisce', async () => {
listeApi.getListe.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare la lista. Riprova più tardi.');
});
describe('ricerca e aggiunta materiali dal catalogo', () => {
beforeEach(async () => {
await setup();
});
it('esclude dai risultati i materiali già presenti in lista', () => {
component.ricerca.setValue('corda');
fixture.detectChanges();
expect(component.risultatiRicerca()).toEqual([]);
});
it('trova un materiale non ancora in lista e lo aggiunge al click su "Aggiungi"', () => {
component.ricerca.setValue('telo');
fixture.detectChanges();
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-2']);
component.aggiungiMateriale(materialiCatalogo[1]);
fixture.detectChanges();
expect(component.voci().map((v) => v.materialeId)).toEqual(['mat-1', 'mat-2']);
expect(component.ricerca.value).toBe('');
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.lista-editor__tabella tbody tr');
expect(righe.length).toBe(2);
});
it('rimuove una voce dalla lista', () => {
component.rimuoviVoce('mat-1');
fixture.detectChanges();
expect(component.voci()).toEqual([]);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-editor__tabella tbody')?.textContent).toContain(
'Nessun materiale in questa lista.'
);
});
it('modifica la quantità di una voce esistente', () => {
component.modificaQuantita('mat-1', '5');
expect(component.voci()[0].quantita).toBe(5);
});
it('ignora una quantità non valida e mantiene almeno 1', () => {
component.modificaQuantita('mat-1', 'abc');
expect(component.voci()[0].quantita).toBe(1);
});
it('mostra il link per proporre un nuovo materiale se la ricerca non trova risultati', () => {
component.ricerca.setValue('materiale inesistente');
fixture.detectChanges();
expect(component.nessunRisultato()).toBe(true);
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.lista-editor__nessun-risultato a') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/proponi-materiale?nome=materiale%20inesistente');
});
});
describe('salvataggio delle modifiche', () => {
beforeEach(async () => {
await setup();
});
it('invia le voci correnti a aggiornaLista e mostra un messaggio di successo', async () => {
component.rimuoviVoce('mat-1');
component.aggiungiMateriale(materialiCatalogo[2]);
const listaAggiornata: Lista = {
...listaIniziale,
voci: [{ materialeId: 'mat-3', nome: 'Torcia', unitaMisura: 'pz', quantita: 1 }]
};
listeApi.aggiornaLista.mockReturnValue(of(listaAggiornata));
await component.salva();
expect(listeApi.aggiornaLista).toHaveBeenCalledWith('lista-1', {
voci: [{ materialeId: 'mat-3', quantita: 1 }]
});
expect(component.salvataggioOk()).toBe(true);
expect(component.voci()).toEqual(listaAggiornata.voci);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
listeApi.aggiornaLista.mockReturnValue(throwError(() => new Error('server error')));
await component.salva();
expect(component.salvataggioErrore()).toBe('Impossibile salvare le modifiche. Riprova più tardi.');
expect(component.salvataggioOk()).toBe(false);
});
});
});
@@ -1,133 +0,0 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service';
import { Lista, ListeApiService } from '../liste-api.service';
interface VoceEditor {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
@Component({
selector: 'app-lista-editor',
imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './lista-editor.html',
styleUrl: './lista-editor.css'
})
export class ListaEditor implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly listeApi = inject(ListeApiService);
private readonly materialiApi = inject(MaterialiApiService);
private readonly listaId = this.route.snapshot.paramMap.get('id') ?? '';
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly lista = signal<Lista | null>(null);
readonly voci = signal<VoceEditor[]>([]);
readonly materialiCatalogo = signal<MaterialePubblico[]>([]);
readonly ricerca = new FormControl('', { nonNullable: true });
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' });
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly salvataggioOk = signal(false);
readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
const termine = this.ricercaValue().trim().toLowerCase();
if (termine.length === 0) {
return [];
}
const idGiaPresenti = new Set(this.voci().map((v) => v.materialeId));
return this.materialiCatalogo().filter(
(materiale) => !idGiaPresenti.has(materiale.id) && materiale.nome.toLowerCase().includes(termine)
);
});
readonly nessunRisultato = computed(
() => this.ricercaValue().trim().length > 0 && this.risultatiRicerca().length === 0
);
async ngOnInit(): Promise<void> {
await this.carica();
}
aggiungiMateriale(materiale: MaterialePubblico): void {
if (this.voci().some((v) => v.materialeId === materiale.id)) {
return;
}
this.voci.update((voci) => [
...voci,
{ materialeId: materiale.id, nome: materiale.nome, unitaMisura: materiale.unitaMisura, quantita: 1 }
]);
this.ricerca.setValue('');
}
rimuoviVoce(materialeId: string): void {
this.voci.update((voci) => voci.filter((v) => v.materialeId !== materialeId));
}
modificaQuantita(materialeId: string, valore: string): void {
const parsed = Number.parseInt(valore, 10);
const quantita = Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, quantita } : v)));
}
async salva(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
this.salvataggioErrore.set(null);
this.salvataggioOk.set(false);
this.salvataggioInCorso.set(true);
try {
const input = { voci: this.voci().map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) };
const listaAggiornata = await firstValueFrom(this.listeApi.aggiornaLista(this.listaId, input));
this.lista.set(listaAggiornata);
this.voci.set(listaAggiornata.voci);
this.salvataggioOk.set(true);
} catch {
this.salvataggioErrore.set('Impossibile salvare le modifiche. Riprova più tardi.');
} finally {
this.salvataggioInCorso.set(false);
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const liste = await firstValueFrom(this.listeApi.getListe());
const lista = liste.find((l) => l.id === this.listaId) ?? null;
if (!lista) {
this.loadError.set('Lista non trovata.');
return;
}
this.lista.set(lista);
this.voci.set(lista.voci);
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materialiCatalogo.set(materiali);
} catch {
this.loadError.set('Impossibile caricare la lista. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,34 @@
export interface StatoLista {
id: string;
nome: string;
}
export const STATO_BOZZA: StatoLista = { id: 'bozza', nome: 'Bozza' };
export const STATO_PRIVATO: StatoLista = { id: 'privato', nome: 'Privato' };
export const STATO_GRUPPO: StatoLista = { id: 'gruppo', nome: 'Gruppo' };
export const STATO_PUBBLICO: StatoLista = { id: 'pubblico', nome: 'Pubblico' };
export const STATI_LISTA: StatoLista[] = [STATO_BOZZA, STATO_PRIVATO, STATO_GRUPPO, STATO_PUBBLICO];
export interface StatoListaStyle {
bg: string;
color: string;
border: string;
}
export function statoListaStyle(idStato: string): StatoListaStyle {
if (idStato === 'gruppo') {
return { bg: 'var(--color-accent-100)', color: 'var(--color-accent-800)', border: 'var(--color-accent-300)' };
}
if (idStato === 'pubblico') {
return {
bg: 'var(--color-accent-2-100)',
color: 'var(--color-accent-2-800)',
border: 'var(--color-accent-2-300)'
};
}
if (idStato === 'privato') {
return { bg: 'var(--color-neutral-300)', color: 'var(--color-neutral-900)', border: 'var(--color-neutral-400)' };
}
return { bg: 'var(--color-neutral-100)', color: 'var(--color-neutral-700)', border: 'var(--color-neutral-300)' };
}
@@ -9,14 +9,38 @@ export interface ListaVoce {
nome: string;
unitaMisura: string;
quantita: number;
// Presente solo se il chiamante può vederlo (creatore della lista o admin/moderatore):
// il materiale è stato proposto da poco e non è ancora stato approvato.
inAttesaConferma?: boolean;
}
export interface SottoLista {
id: string;
nome: string;
voci: ListaVoce[];
}
export type StatoModerazioneLista = 'proposto' | 'approvato' | 'rifiutato';
export interface Lista {
id: string;
nome: string;
orgId: string;
// Valorizzato solo per le liste in stato 'gruppo': le liste bozza/privato/pubblico
// sono personali e non hanno alcuna organizzazione.
orgId: string | null;
stato: string;
// Nullo per bozza/privato/gruppo: la moderazione si applica solo quando stato =
// pubblico.
statoModerazione: StatoModerazioneLista | null;
tipoEventoId: string | null;
// Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base").
parentId: string | null;
creataIl: string;
// Il chiamante è l'autore: per le liste 'gruppo' (visibili a tutta l'org, non solo a
// chi le ha create) serve a distinguere "le mie liste di gruppo" dalle altrui.
creataDaMe: boolean;
voci: ListaVoce[];
sottoListe: SottoLista[];
}
export interface ListaVoceInput {
@@ -24,9 +48,28 @@ export interface ListaVoceInput {
quantita: number;
}
export interface CreaListaInput {
nome: string;
stato: string;
voci?: ListaVoceInput[];
// Liste personali/di gruppo già esistenti, agganciate direttamente come sotto-lista.
sottoListeIds?: string[];
// Liste pubbliche approvate del catalogo: vengono forkate al volo dal backend in
// nuove liste bozza personali, poi agganciate come le altre.
sottoListeModelloIds?: string[];
tipoEventoId?: string;
}
export interface AggiornaListaInput {
nome?: string;
// Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in
// edit, non solo alla creazione.
stato?: string;
voci?: ListaVoceInput[];
sottoListeIds?: string[];
sottoListeModelloIds?: string[];
// undefined = non toccare il campo, null = rimuovi il tipo evento associato.
tipoEventoId?: string | null;
}
@Injectable({ providedIn: 'root' })
@@ -37,8 +80,26 @@ export class ListeApiService {
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste`);
}
creaListaVuota(nome: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste`, { nome });
// Catalogo pubblico: liste già approvate dalla moderazione, usabili come base
// ("usa come base") per crearne di nuove. Filtro opzionale per tipo evento.
getListePubbliche(tipoEventoId?: string): Observable<Lista[]> {
const params = tipoEventoId ? { tipoEventoId } : undefined;
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste/pubbliche`, { params });
}
// Dettaglio pubblico per singolo id (deep-link diretto al catalogo).
getListaPubblica(id: string): Observable<Lista> {
return this.http.get<Lista>(`${environment.magazzinoApiBaseUrl}/liste/pubbliche/${id}`);
}
creaLista(input: CreaListaInput): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste`, input);
}
// Fork: crea una nuova lista bozza personale a partire da una lista pubblica
// approvata ("usa come base").
creaListaDaFork(listaOrigineId: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste/da-fork/${listaOrigineId}`, {});
}
aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> {
@@ -1,18 +1,28 @@
.liste-list__form {
/* Header replicato 1:1 da scouthub-attivita-fe (pagina "Le mie attività"):
stesso font, stessa dimensione testo, stessa spaziatura. */
.page {
max-width: 1080px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-2);
align-items: baseline;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.liste-list__field {
flex: 1;
.page-title {
font-size: 30px;
margin: 0;
font-weight: 700;
}
.liste-list__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
.page-title--sm {
font-size: 28px;
}
.liste-list__error {
@@ -20,21 +30,95 @@
margin-bottom: var(--space-3);
}
.liste-list__elenco {
margin-top: var(--space-5);
.lista {
display: flex;
flex-direction: column;
gap: 14px;
}
.liste-list__voce {
text-decoration: none;
transition: box-shadow 0.15s ease, transform 0.15s ease;
.riga {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.liste-list__voce:hover {
box-shadow: var(--shadow-sm);
.riga-info {
flex: 1;
min-width: 220px;
}
.liste-list__voci-count {
.riga-titolo {
cursor: pointer;
font-weight: 700;
font-size: 17px;
color: var(--color-text);
opacity: 0.6;
text-decoration: none;
}
.riga-data {
font-size: 13px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
margin-top: 2px;
}
.riga-badge {
display: inline-block;
margin-top: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid;
}
.riga-nota {
display: block;
margin-top: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-accent-800);
}
.stato-options {
display: flex;
gap: 6px;
}
.stato-option {
cursor: pointer;
padding: 7px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--color-divider);
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-divider);
font-weight: 600;
font-size: 14px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
}
@@ -1,43 +1,71 @@
<section class="liste-list om-page">
<h1 class="om-section-title">Le tue liste</h1>
<p class="om-section-sub">Liste materiale della tua organizzazione.</p>
<form class="liste-list__form" (submit)="$event.preventDefault(); creaLista()" novalidate>
<div class="field liste-list__field">
<label for="ll-nome">Nome della nuova lista</label>
<input class="input" id="ll-nome" [formControl]="nome" placeholder="Es. Campo estivo 2026" />
@if (nome.hasError('required')) {
<p class="liste-list__campo-errore">Il nome della lista è obbligatorio.</p>
} @else if (nome.hasError('minlength')) {
<p class="liste-list__campo-errore">Il nome deve avere almeno 3 caratteri.</p>
} @else if (nome.hasError('maxlength')) {
<p class="liste-list__campo-errore">Il nome non può superare i 100 caratteri.</p>
}
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">{{ titolo() }}</h1>
<a class="btn btn-primary" routerLink="/liste/nuova" [queryParams]="nuovaListaQueryParams">+ Nuova lista</a>
</div>
<button type="submit" class="btn btn-primary" [disabled]="creazioneInCorso()">
{{ creazioneInCorso() ? 'Creazione in corso…' : 'Crea lista vuota' }}
</button>
</form>
@if (creazioneErrore(); as message) {
<p class="liste-list__error" role="alert">{{ message }}</p>
}
@if (loading()) {
<p class="om-empty">Caricamento liste…</p>
} @else if (loadError(); as message) {
<p class="liste-list__error" role="alert">{{ message }}</p>
} @else if (liste().length === 0) {
<p class="om-empty">Non hai ancora nessuna lista. Creane una qui sopra.</p>
} @else {
<div class="om-grid liste-list__elenco">
} @else if (liste().length > 0) {
<div class="lista">
@for (lista of liste(); track lista.id) {
<a class="card liste-list__voce" [routerLink]="['/liste', lista.id]">
<div class="card-title">{{ lista.nome }}</div>
<span class="liste-list__voci-count">{{ lista.voci.length }} voci</span>
</a>
}
<div class="riga">
<div class="riga-info">
<a class="riga-titolo" [routerLink]="dettaglioLink(lista)">{{ lista.nome }}</a>
<div class="riga-data">Creata il {{ dataCreazioneFmt(lista) }} · {{ lista.voci.length }} voci</div>
<div
class="riga-badge"
[style.color]="statoStile(lista.stato).color"
[style.background]="statoStile(lista.stato).bg"
[style.border-color]="statoStile(lista.stato).border"
>
{{ statoNome(lista.stato) }}
</div>
@if (haVociInAttesa(lista)) {
<div class="riga-nota" title="Contiene materiali in attesa di conferma da un moderatore">
⚠️ materiali in attesa di conferma
</div>
}
</section>
@if (inAttesaApprovazione(lista)) {
<div class="riga-nota" title="In attesa di approvazione da parte di un moderatore">
⏳ in attesa di approvazione
</div>
} @else if (rifiutata(lista)) {
<div class="riga-nota" title="Proposta rifiutata da un moderatore">
❌ proposta rifiutata
</div>
}
</div>
@if (puoCambiareStato(lista)) {
<div class="stato-options">
@for (stato of stati; track stato.id) {
<div
class="stato-option"
[class.stato-option--attivo]="isStatoAttivo(lista, stato)"
[style.background]="isStatoAttivo(lista, stato) ? statoStile(stato.id).bg : 'transparent'"
[style.color]="isStatoAttivo(lista, stato) ? statoStile(stato.id).color : 'color-mix(in srgb, var(--color-text) 55%, transparent)'"
[style.border-color]="isStatoAttivo(lista, stato) ? statoStile(stato.id).border : 'var(--color-divider)'"
(click)="cambiaStato(lista, stato)"
>
{{ stato.nome }}
</div>
}
</div>
} @else {
<div class="riga-nota" title="Solo chi ha creato la lista può cambiarne lo stato">
🔒 solo il creatore può cambiare lo stato
</div>
}
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">{{ titoloVuoto() }}</div>
</div>
}
</div>
@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { Lista, ListeApiService } from '../liste-api.service';
@@ -8,29 +8,73 @@ import { ListeList } from './liste-list';
describe('ListeList', () => {
let component: ListeList;
let fixture: ComponentFixture<ListeList>;
let listeApi: { getListe: ReturnType<typeof vi.fn>; creaListaVuota: ReturnType<typeof vi.fn> };
let router: Router;
let listeApi: { getListe: ReturnType<typeof vi.fn> };
const liste: Lista[] = [
{ id: 'lista-1', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] },
{
id: 'lista-1',
nome: 'Campo estivo 2026',
orgId: 'org-1',
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-01',
creataDaMe: true,
voci: [],
sottoListe: []
},
{
id: 'lista-2',
nome: 'Uscita di un giorno',
orgId: 'org-1',
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-02',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
creataDaMe: true,
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }],
sottoListe: []
},
{
id: 'lista-3',
nome: 'Materiale sede',
orgId: 'org-1',
stato: 'gruppo',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-03',
creataDaMe: false,
voci: [],
sottoListe: []
},
{
id: 'lista-4',
nome: 'Materiale creato da me per il gruppo',
orgId: 'org-1',
stato: 'gruppo',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-04',
creataDaMe: true,
voci: [],
sottoListe: []
}
];
async function setup(): Promise<void> {
async function setup(scope: 'mie' | 'gruppo' = 'mie'): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListeList],
providers: [provideRouter([]), { provide: ListeApiService, useValue: listeApi }]
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: ActivatedRoute, useValue: { snapshot: { data: { scope } } } }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(ListeList);
component = fixture.componentInstance;
fixture.detectChanges();
@@ -38,73 +82,98 @@ describe('ListeList', () => {
fixture.detectChanges();
}
it('mostra l\'elenco delle liste dopo il caricamento', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)), creaListaVuota: vi.fn() };
it('mostra le liste personali più le proprie liste di gruppo, escludendo quelle di gruppo altrui', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
await setup();
expect(component.loading()).toBe(false);
expect(component.liste()).toEqual(liste);
expect(component.liste()).toEqual(liste.filter((l) => l.stato !== 'gruppo' || l.creataDaMe));
const compiled = fixture.nativeElement as HTMLElement;
const links = compiled.querySelectorAll('.liste-list__elenco a');
expect(links.length).toBe(2);
const links = compiled.querySelectorAll('.riga-titolo');
expect(links.length).toBe(3);
expect(links[0].getAttribute('href')).toBe('/liste/lista-1');
expect(links[2].getAttribute('href')).toBe('/liste/lista-4');
});
it('con scope "gruppo" mostra tutte le liste di gruppo dell\'org, comprese quelle create da altri', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
await setup('gruppo');
expect(component.liste()).toEqual(liste.filter((l) => l.stato === 'gruppo'));
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Le nostre liste');
const links = compiled.querySelectorAll('.riga-titolo');
expect(links.length).toBe(2);
expect(links[0].getAttribute('href')).toBe('/liste/lista-3');
expect(links[1].getAttribute('href')).toBe('/liste/lista-4');
});
it('con scope "gruppo" nasconde il selettore di stato per le liste altrui e lo mostra per le proprie', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
await setup('gruppo');
expect(component.puoCambiareStato(liste[2])).toBe(false); // lista-3, altrui
expect(component.puoCambiareStato(liste[3])).toBe(true); // lista-4, mia
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.riga');
expect(righe[0].querySelector('.stato-options')).toBeNull();
expect(righe[0].textContent).toContain('solo il creatore può cambiare lo stato');
expect(righe[1].querySelector('.stato-options')).not.toBeNull();
});
it('non invia la richiesta di cambio stato se non si è proprietari della lista di gruppo', async () => {
listeApi = {
getListe: vi.fn().mockReturnValue(of(liste)),
aggiornaLista: vi.fn()
} as unknown as typeof listeApi;
await setup('gruppo');
component.cambiaStato(liste[2], { id: 'privato', nome: 'Privato' });
expect((listeApi as unknown as { aggiornaLista: ReturnType<typeof vi.fn> }).aggiornaLista).not.toHaveBeenCalled();
});
it('mostra un bottone che porta alla pagina di creazione di una nuova lista', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])) };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.page-header a');
expect(link?.getAttribute('href')).toBe('/liste/nuova');
});
it('con scope "gruppo" il bottone "+ Nuova lista" forza lo stato di partenza a gruppo', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])) };
await setup('gruppo');
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.page-header a');
expect(link?.getAttribute('href')).toBe('/liste/nuova?stato=gruppo');
});
it('mostra un messaggio se non ci sono liste', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() };
listeApi = { getListe: vi.fn().mockReturnValue(of([])) };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Non hai ancora nessuna lista');
expect(compiled.textContent).toContain('Non hai ancora creato nessuna lista');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))), creaListaVuota: vi.fn() };
listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
await setup();
expect(component.loadError()).toBe('Impossibile caricare le tue liste. Riprova più tardi.');
});
describe('creazione di una nuova lista vuota', () => {
beforeEach(async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() };
await setup();
});
it('non chiama l\'API se il nome non è valido e marca il controllo come touched', async () => {
component.nome.setValue('');
await component.creaLista();
expect(listeApi.creaListaVuota).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
});
it('crea la lista e reindirizza al suo editor', async () => {
component.nome.setValue('Campo estivo 2026');
listeApi.creaListaVuota.mockReturnValue(
of({ id: 'lista-nuova', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] })
);
await component.creaLista();
expect(listeApi.creaListaVuota).toHaveBeenCalledWith('Campo estivo 2026');
expect(router.navigate).toHaveBeenCalledWith(['/liste', 'lista-nuova']);
expect(component.creazioneInCorso()).toBe(false);
});
it('mostra un messaggio di errore se la creazione fallisce', async () => {
component.nome.setValue('Campo estivo 2026');
listeApi.creaListaVuota.mockReturnValue(throwError(() => new Error('server error')));
await component.creaLista();
expect(component.creazioneErrore()).toBe('Impossibile creare la lista. Riprova più tardi.');
expect(router.navigate).not.toHaveBeenCalled();
});
expect(component.loadError()).toBe('Impossibile caricare le liste. Riprova più tardi.');
});
});
@@ -1,81 +1,125 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router, RouterLink } from '@angular/router';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService } from '../liste-api.service';
import { STATI_LISTA, StatoLista, statoListaStyle } from '../lista.model';
// Route data['scope']: 'mie' (default, /liste) mostra le liste personali dell'utente
// più le proprie liste 'gruppo' (create da lui, anche se visibili a tutta l'org);
// 'gruppo' (/liste/gruppo) mostra tutte le liste 'gruppo' della sua organizzazione,
// comprese quelle create da altri membri, modificabili da chiunque ne faccia parte.
// Una lista 'gruppo' creata dall'utente compare quindi in entrambe le viste. GET
// /liste restituisce già l'unione di tutto (vedi liste.service.ts::listMieListe), qui
// si filtra solo client-side.
type Scope = 'mie' | 'gruppo';
@Component({
selector: 'app-liste-list',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule
],
imports: [RouterLink],
templateUrl: './liste-list.html',
styleUrl: './liste-list.css'
})
export class ListeList implements OnInit {
private readonly listeApi = inject(ListeApiService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly scope: Scope = (this.route.snapshot.data['scope'] as Scope | undefined) ?? 'mie';
readonly titolo = computed(() => (this.scope === 'gruppo' ? 'Le nostre liste' : 'Le mie liste'));
readonly titoloVuoto = computed(() =>
this.scope === 'gruppo'
? 'La tua organizzazione non ha ancora creato nessuna lista di gruppo'
: 'Non hai ancora creato nessuna lista'
);
// Da "Le nostre liste" la nuova lista nasce già 'gruppo' (vedi nuova-lista.ts, che
// legge questo query param per bloccare la scelta dello stato in creazione).
readonly nuovaListaQueryParams: Record<string, string> = this.scope === 'gruppo' ? { stato: 'gruppo' } : {};
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly liste = signal<Lista[]>([]);
readonly nome = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
});
readonly creazioneInCorso = signal(false);
readonly creazioneErrore = signal<string | null>(null);
readonly stati = STATI_LISTA;
async ngOnInit(): Promise<void> {
await this.caricaListe();
}
async creaLista(): Promise<void> {
if (this.creazioneInCorso()) {
return;
}
if (this.nome.invalid) {
this.nome.markAsTouched();
return;
}
this.creazioneErrore.set(null);
this.creazioneInCorso.set(true);
try {
const lista = await firstValueFrom(this.listeApi.creaListaVuota(this.nome.value.trim()));
this.creazioneInCorso.set(false);
await this.router.navigate(['/liste', lista.id]);
} catch {
this.creazioneInCorso.set(false);
this.creazioneErrore.set('Impossibile creare la lista. Riprova più tardi.');
}
}
private async caricaListe(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const liste = await firstValueFrom(this.listeApi.getListe());
this.liste.set(liste);
this.liste.set(
liste.filter((l) => (this.scope === 'gruppo' ? l.stato === 'gruppo' : l.stato !== 'gruppo' || l.creataDaMe))
);
} catch {
this.loadError.set('Impossibile caricare le tue liste. Riprova più tardi.');
this.loadError.set('Impossibile caricare le liste. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
dataCreazioneFmt(lista: Lista): string {
return new Intl.DateTimeFormat('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(
new Date(lista.creataIl),
);
}
modifica(lista: Lista): void {
this.router.navigate(['/liste', lista.id]);
}
// Le liste pubbliche approvate hanno una vista dedicata (stessa usata dal catalogo,
// GET /liste/pubbliche/:id); le altre non sono raggiungibili da quella route (404),
// quindi il click sul nome apre comunque l'editor.
dettaglioLink(lista: Lista): string[] {
return lista.stato === 'pubblico' && lista.statoModerazione === 'approvato'
? ['/lista-modello', lista.id]
: ['/liste', lista.id];
}
statoStile(idStato: string) {
return statoListaStyle(idStato);
}
statoNome(idStato: string): string {
return STATI_LISTA.find((s) => s.id === idStato)?.nome ?? idStato;
}
haVociInAttesa(lista: Lista): boolean {
return lista.voci.some((v) => v.inAttesaConferma);
}
inAttesaApprovazione(lista: Lista): boolean {
return lista.stato === 'pubblico' && lista.statoModerazione === 'proposto';
}
rifiutata(lista: Lista): boolean {
return lista.stato === 'pubblico' && lista.statoModerazione === 'rifiutato';
}
isStatoAttivo(lista: Lista, stato: StatoLista): boolean {
return lista.stato === stato.id;
}
// Il contenuto di una lista 'gruppo' è collaborativo, ma solo chi l'ha creata può
// cambiarne lo stato (es. toglierla dal gruppo): altrimenti un membro qualsiasi
// potrebbe far sparire ad altri una lista condivisa. Vedi liste.service.ts::aggiornaLista.
puoCambiareStato(lista: Lista): boolean {
return lista.stato !== 'gruppo' || lista.creataDaMe;
}
cambiaStato(lista: Lista, stato: StatoLista): void {
if (lista.stato === stato.id || !this.puoCambiareStato(lista)) {
return;
}
this.listeApi.aggiornaLista(lista.id, { stato: stato.id }).subscribe({
next: () => this.caricaListe(),
error: () => this.loadError.set('Impossibile cambiare stato alla lista. Riprova più tardi.')
});
}
}
@@ -6,11 +6,25 @@ export const LISTE_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList),
canActivate: [requireAuthGuard],
data: { scope: 'mie' }
},
{
// Dichiarata prima di ':id' (come 'nuova' sotto) per non essere intercettata dal
// catch-all di modifica.
path: 'gruppo',
loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList),
canActivate: [requireAuthGuard],
data: { scope: 'gruppo' }
},
{
path: 'nuova',
loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista),
canActivate: [requireAuthGuard]
},
{
path: ':id',
loadComponent: () => import('./lista-editor/lista-editor').then((m) => m.ListaEditor),
loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista),
canActivate: [requireAuthGuard]
}
];

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