Sistemato magazzino
This commit is contained in:
@@ -13,6 +13,12 @@
|
|||||||
"displayNameHtml": "Scouthub",
|
"displayNameHtml": "Scouthub",
|
||||||
"loginTheme": "scouthub",
|
"loginTheme": "scouthub",
|
||||||
|
|
||||||
|
"accessTokenLifespan": 3600,
|
||||||
|
"ssoSessionIdleTimeout": 28800,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
"offlineSessionIdleTimeout": 28800,
|
||||||
|
"offlineSessionMaxLifespan": 36000,
|
||||||
|
|
||||||
"roles": {
|
"roles": {
|
||||||
"realm": [
|
"realm": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ RUN npm run build
|
|||||||
|
|
||||||
EXPOSE 8083
|
EXPOSE 8083
|
||||||
|
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
|
CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed && node dist/server.js"]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "prisma db seed",
|
||||||
"test": "jest --runInBand --passWithNoTests",
|
"test": "jest --runInBand --passWithNoTests",
|
||||||
"test:watch": "jest --watch --runInBand"
|
"test:watch": "jest --watch --runInBand"
|
||||||
},
|
},
|
||||||
@@ -38,5 +39,8 @@
|
|||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.5.4"
|
"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
|
-- CreateEnum
|
||||||
CREATE TYPE "stato_magazzino_voce" AS ENUM ('buono', 'da_riparare', 'mancante');
|
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
|
-- CreateTable
|
||||||
CREATE TABLE "materiale" (
|
CREATE TABLE "materiale" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
@@ -21,35 +33,35 @@ CREATE TABLE "materiale" (
|
|||||||
CREATE TABLE "tipo_evento" (
|
CREATE TABLE "tipo_evento" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"nome" 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")
|
CONSTRAINT "tipo_evento_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "lista_modello" (
|
CREATE TABLE "categoria" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"nome" TEXT NOT NULL,
|
"nome" TEXT NOT NULL,
|
||||||
"tipo_evento_id" TEXT NOT NULL,
|
"stato" "stato_categoria" NOT NULL DEFAULT 'confermata',
|
||||||
"pubblica" BOOLEAN NOT NULL DEFAULT true,
|
"creato_da_org_id" TEXT,
|
||||||
|
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "lista_modello_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "categoria_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")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "lista" (
|
CREATE TABLE "lista" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"nome" 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,
|
"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")
|
CONSTRAINT "lista_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -63,6 +75,14 @@ CREATE TABLE "lista_voce" (
|
|||||||
CONSTRAINT "lista_voce_pkey" PRIMARY KEY ("lista_id","materiale_id")
|
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
|
-- CreateTable
|
||||||
CREATE TABLE "magazzino_voce" (
|
CREATE TABLE "magazzino_voce" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
@@ -72,10 +92,22 @@ CREATE TABLE "magazzino_voce" (
|
|||||||
"stato" "stato_magazzino_voce" NOT NULL,
|
"stato" "stato_magazzino_voce" NOT NULL,
|
||||||
"posizione" TEXT,
|
"posizione" TEXT,
|
||||||
"note" TEXT,
|
"note" TEXT,
|
||||||
|
"gruppo_id" TEXT,
|
||||||
|
|
||||||
CONSTRAINT "magazzino_voce_pkey" PRIMARY KEY ("id")
|
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
|
-- CreateTable
|
||||||
CREATE TABLE "evento" (
|
CREATE TABLE "evento" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
@@ -97,14 +129,41 @@ CREATE TABLE "evento_check" (
|
|||||||
CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id")
|
CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- AddForeignKey
|
-- CreateTable
|
||||||
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;
|
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
|
-- 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
|
-- 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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- AddForeignKey
|
||||||
ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,68 @@ enum StatoMagazzinoVoce {
|
|||||||
@@map("stato_magazzino_voce")
|
@@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 {
|
model Materiale {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nome String
|
nome String
|
||||||
@@ -32,56 +94,67 @@ model Materiale {
|
|||||||
propostoDaOrgId String @map("proposto_da_org_id")
|
propostoDaOrgId String @map("proposto_da_org_id")
|
||||||
creatoIl DateTime @default(now()) @map("creato_il")
|
creatoIl DateTime @default(now()) @map("creato_il")
|
||||||
|
|
||||||
listaModelloVoci ListaModelloVoce[]
|
listaVoci ListaVoce[]
|
||||||
listaVoci ListaVoce[]
|
magazzinoVoci MagazzinoVoce[]
|
||||||
magazzinoVoci MagazzinoVoce[]
|
eventoCheck EventoCheck[]
|
||||||
eventoCheck EventoCheck[]
|
|
||||||
|
|
||||||
@@map("materiale")
|
@@map("materiale")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TipoEvento {
|
model TipoEvento {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nome String
|
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")
|
@@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 {
|
model Lista {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nome String
|
nome String
|
||||||
orgId String @map("org_id")
|
// Valorizzato solo per le liste in stato 'gruppo' (condivise con l'organizzazione):
|
||||||
creataIl DateTime @default(now()) @map("creata_il")
|
// 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[]
|
voci ListaVoce[]
|
||||||
eventi Evento[]
|
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")
|
@@map("lista")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +170,35 @@ model ListaVoce {
|
|||||||
@@map("lista_voce")
|
@@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 {
|
model MagazzinoVoce {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
orgId String @map("org_id")
|
orgId String @map("org_id")
|
||||||
@@ -105,9 +207,14 @@ model MagazzinoVoce {
|
|||||||
stato StatoMagazzinoVoce
|
stato StatoMagazzinoVoce
|
||||||
posizione String?
|
posizione String?
|
||||||
note String?
|
note String?
|
||||||
|
// Nullo finché la giacenza non viene organizzata in un gruppo (scaffale/kit):
|
||||||
|
// eliminare il gruppo non elimina la voce, la riporta solo tra le "non organizzate".
|
||||||
|
gruppoId String? @map("gruppo_id")
|
||||||
|
|
||||||
materiale Materiale @relation(fields: [materialeId], references: [id])
|
materiale Materiale @relation(fields: [materialeId], references: [id])
|
||||||
|
gruppo GruppoMagazzino? @relation(fields: [gruppoId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([gruppoId])
|
||||||
@@map("magazzino_voce")
|
@@map("magazzino_voce")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { PrismaClient, StatoMateriale } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Placeholder di orgId per il materiale di catalogo seedato manualmente (non riconducibile
|
||||||
|
// a nessuna organizzazione reale), analogo a UTENTE_MODIFICA="MANUALE" nel seed di attivita-be.
|
||||||
|
const ORG_SISTEMA = "SISTEMA";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const materiali = [
|
||||||
|
{ id: "materiale-corda", nome: "Corda", categoria: "Attrezzatura", unitaMisura: "pz" },
|
||||||
|
{ id: "materiale-torcia", nome: "Torcia", categoria: "Attrezzatura", unitaMisura: "pz" },
|
||||||
|
{
|
||||||
|
id: "materiale-kit-primo-soccorso",
|
||||||
|
nome: "Kit primo soccorso",
|
||||||
|
categoria: "Sicurezza",
|
||||||
|
unitaMisura: "kit",
|
||||||
|
},
|
||||||
|
{ id: "materiale-tenda", nome: "Tenda", categoria: "Campeggio", unitaMisura: "pz" },
|
||||||
|
{
|
||||||
|
id: "materiale-fornello-campo",
|
||||||
|
nome: "Fornello da campo",
|
||||||
|
categoria: "Campeggio",
|
||||||
|
unitaMisura: "pz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "materiale-zaino",
|
||||||
|
nome: "Zaino",
|
||||||
|
categoria: "Equipaggiamento personale",
|
||||||
|
unitaMisura: "pz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "materiale-sacco-a-pelo",
|
||||||
|
nome: "Sacco a pelo",
|
||||||
|
categoria: "Equipaggiamento personale",
|
||||||
|
unitaMisura: "pz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "materiale-borraccia",
|
||||||
|
nome: "Borraccia",
|
||||||
|
categoria: "Equipaggiamento personale",
|
||||||
|
unitaMisura: "pz",
|
||||||
|
},
|
||||||
|
{ id: "materiale-garze", nome: "Garze", categoria: "Sicurezza", unitaMisura: "pz" },
|
||||||
|
{ id: "materiale-cerotti", nome: "Cerotti", categoria: "Sicurezza", unitaMisura: "pz" },
|
||||||
|
{
|
||||||
|
id: "materiale-disinfettante",
|
||||||
|
nome: "Disinfettante",
|
||||||
|
categoria: "Sicurezza",
|
||||||
|
unitaMisura: "flacone",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await Promise.all(
|
||||||
|
materiali.map((materiale) =>
|
||||||
|
prisma.materiale.upsert({
|
||||||
|
where: { id: materiale.id },
|
||||||
|
update: {
|
||||||
|
...materiale,
|
||||||
|
stato: StatoMateriale.approvato,
|
||||||
|
propostoDaOrgId: ORG_SISTEMA,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...materiale,
|
||||||
|
stato: StatoMateriale.approvato,
|
||||||
|
propostoDaOrgId: ORG_SISTEMA,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const tipiEvento = [
|
||||||
|
{ id: "tipo-evento-uscita-giorno", nome: "Uscita di un giorno" },
|
||||||
|
{ id: "tipo-evento-campo-estivo", nome: "Campo estivo" },
|
||||||
|
{ id: "tipo-evento-riunione-settimanale", nome: "Riunione settimanale" },
|
||||||
|
];
|
||||||
|
await Promise.all(
|
||||||
|
tipiEvento.map((tipoEvento) =>
|
||||||
|
prisma.tipoEvento.upsert({
|
||||||
|
where: { id: tipoEvento.id },
|
||||||
|
update: { nome: tipoEvento.nome },
|
||||||
|
create: tipoEvento,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Le ex "liste modello" sono ora liste unificate: pubbliche, già approvate dalla
|
||||||
|
// moderazione, senza autore/org (scritte direttamente dal seed, come farebbe un
|
||||||
|
// moderatore in bypass della coda di proposte).
|
||||||
|
const listeModello = [
|
||||||
|
{
|
||||||
|
id: "lista-modello-uscita-giorno",
|
||||||
|
nome: "Materiale base uscita giornaliera",
|
||||||
|
tipoEventoId: "tipo-evento-uscita-giorno",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lista-modello-campo-estivo",
|
||||||
|
nome: "Materiale base campo estivo",
|
||||||
|
tipoEventoId: "tipo-evento-campo-estivo",
|
||||||
|
},
|
||||||
|
// Sotto-lista "foglia" (nessuna sotto-lista propria): allegata come sotto-lista sia a
|
||||||
|
// "uscita di un giorno" sia a "campo estivo", al posto del vecchio materiale generico
|
||||||
|
// "materiale-kit-primo-soccorso", per verificare il nesting a un livello.
|
||||||
|
{
|
||||||
|
id: "lista-modello-kit-primo-soccorso",
|
||||||
|
nome: "Kit di pronto soccorso",
|
||||||
|
tipoEventoId: "tipo-evento-uscita-giorno",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const listaModello of listeModello) {
|
||||||
|
await prisma.lista.upsert({
|
||||||
|
where: { id: listaModello.id },
|
||||||
|
update: { ...listaModello, stato: "pubblico", statoModerazione: "approvato" },
|
||||||
|
create: {
|
||||||
|
...listaModello,
|
||||||
|
orgId: null,
|
||||||
|
creataDaUserId: null,
|
||||||
|
stato: "pubblico",
|
||||||
|
statoModerazione: "approvato",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeModelloVoci = [
|
||||||
|
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-zaino", quantita: 1 },
|
||||||
|
{
|
||||||
|
listaId: "lista-modello-uscita-giorno",
|
||||||
|
materialeId: "materiale-borraccia",
|
||||||
|
quantita: 1,
|
||||||
|
},
|
||||||
|
{ listaId: "lista-modello-uscita-giorno", materialeId: "materiale-torcia", quantita: 1 },
|
||||||
|
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-tenda", quantita: 1 },
|
||||||
|
{
|
||||||
|
listaId: "lista-modello-campo-estivo",
|
||||||
|
materialeId: "materiale-sacco-a-pelo",
|
||||||
|
quantita: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
listaId: "lista-modello-campo-estivo",
|
||||||
|
materialeId: "materiale-fornello-campo",
|
||||||
|
quantita: 1,
|
||||||
|
},
|
||||||
|
{ listaId: "lista-modello-campo-estivo", materialeId: "materiale-corda", quantita: 2 },
|
||||||
|
// Voci della sotto-lista "Kit di pronto soccorso" (foglia, allegata sotto a due liste diverse).
|
||||||
|
{ listaId: "lista-modello-kit-primo-soccorso", materialeId: "materiale-garze", quantita: 3 },
|
||||||
|
{
|
||||||
|
listaId: "lista-modello-kit-primo-soccorso",
|
||||||
|
materialeId: "materiale-cerotti",
|
||||||
|
quantita: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
listaId: "lista-modello-kit-primo-soccorso",
|
||||||
|
materialeId: "materiale-disinfettante",
|
||||||
|
quantita: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rimuove il vecchio materiale generico "kit primo soccorso" piazzato direttamente nelle due
|
||||||
|
// liste padre: da questo seed in poi il kit è la sotto-lista dedicata, non più una singola voce.
|
||||||
|
await prisma.listaVoce.deleteMany({
|
||||||
|
where: {
|
||||||
|
materialeId: "materiale-kit-primo-soccorso",
|
||||||
|
listaId: { in: ["lista-modello-uscita-giorno", "lista-modello-campo-estivo"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const voce of listeModelloVoci) {
|
||||||
|
await prisma.listaVoce.upsert({
|
||||||
|
where: {
|
||||||
|
listaId_materialeId: {
|
||||||
|
listaId: voce.listaId,
|
||||||
|
materialeId: voce.materialeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { quantita: voce.quantita },
|
||||||
|
create: voce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sottoListe = [
|
||||||
|
{ listaId: "lista-modello-uscita-giorno", sottoListaId: "lista-modello-kit-primo-soccorso" },
|
||||||
|
{ listaId: "lista-modello-campo-estivo", sottoListaId: "lista-modello-kit-primo-soccorso" },
|
||||||
|
];
|
||||||
|
for (const sottoLista of sottoListe) {
|
||||||
|
await prisma.listaSottoLista.upsert({
|
||||||
|
where: {
|
||||||
|
listaId_sottoListaId: {
|
||||||
|
listaId: sottoLista.listaId,
|
||||||
|
sottoListaId: sottoLista.sottoListaId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: sottoLista,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -2,25 +2,34 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { healthRouter } from './routes/health.routes';
|
import { healthRouter } from './routes/health.routes';
|
||||||
import { materialiRouter } from './routes/materiali.routes';
|
import { materialiRouter } from './routes/materiali.routes';
|
||||||
|
import { categorieRouter } from './routes/categorie.routes';
|
||||||
import { tipiEventoRouter } from './routes/tipiEvento.routes';
|
import { tipiEventoRouter } from './routes/tipiEvento.routes';
|
||||||
import { listeModelloRouter } from './routes/listeModello.routes';
|
|
||||||
import { listeRouter } from './routes/liste.routes';
|
import { listeRouter } from './routes/liste.routes';
|
||||||
import { magazzinoRouter } from './routes/magazzino.routes';
|
import { magazzinoRouter } from './routes/magazzino.routes';
|
||||||
|
import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes';
|
||||||
import { eventiRouter } from './routes/eventi.routes';
|
import { eventiRouter } from './routes/eventi.routes';
|
||||||
|
import { autocompleteRouter } from './routes/autocomplete.routes';
|
||||||
|
import { notificheRouter } from './routes/notifiche.routes';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
export const app = express();
|
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(cors());
|
||||||
|
|
||||||
app.use(healthRouter);
|
app.use(healthRouter);
|
||||||
app.use(materialiRouter);
|
app.use(materialiRouter);
|
||||||
|
app.use(categorieRouter);
|
||||||
app.use(tipiEventoRouter);
|
app.use(tipiEventoRouter);
|
||||||
app.use(listeModelloRouter);
|
|
||||||
app.use(listeRouter);
|
app.use(listeRouter);
|
||||||
app.use(magazzinoRouter);
|
app.use(magazzinoRouter);
|
||||||
|
app.use(gruppiMagazzinoRouter);
|
||||||
app.use(eventiRouter);
|
app.use(eventiRouter);
|
||||||
|
app.use(autocompleteRouter);
|
||||||
|
app.use(notificheRouter);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ message: 'not found' });
|
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 { Request, Response, NextFunction } from 'express';
|
||||||
import { ListaVoceInput } from '../repositories/liste.repository';
|
import { ListaVoceInput } from '../repositories/liste.repository';
|
||||||
import {
|
import {
|
||||||
aggiornaLista,
|
aggiornaLista,
|
||||||
creaListaVuota,
|
creaLista,
|
||||||
|
DecisioneProposta,
|
||||||
|
decidiProposta,
|
||||||
eliminaLista,
|
eliminaLista,
|
||||||
forkListaDaModello,
|
forkListaDaLista,
|
||||||
listListePerOrg,
|
listListePubbliche,
|
||||||
|
listMieListe,
|
||||||
|
listProposte,
|
||||||
|
SottoListeInput,
|
||||||
|
trovaListaPubblicaPerId,
|
||||||
|
Viewer,
|
||||||
} from '../services/liste.service';
|
} from '../services/liste.service';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
|
const STATI_LISTA: StatoLista[] = ['bozza', 'privato', 'gruppo', 'pubblico'];
|
||||||
|
|
||||||
interface VoceBody {
|
interface VoceBody {
|
||||||
materialeId?: unknown;
|
materialeId?: unknown;
|
||||||
quantita?: 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 {
|
function parseNome(body: { nome?: unknown }): string {
|
||||||
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
|
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
|
||||||
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
|
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;
|
return body.nome;
|
||||||
}
|
}
|
||||||
|
|
||||||
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
|
// Se assente, la lista nasce come 'bozza' (stato iniziale di default nel wizard di creazione).
|
||||||
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
|
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> {
|
export async function getListe(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const liste = await listListePerOrg(req.auth!.orgId!);
|
const liste = await listMieListe(viewerOf(req));
|
||||||
res.status(200).json(liste);
|
res.status(200).json(liste);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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> {
|
export async function postLista(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const nome = parseNome(req.body ?? {});
|
const body = req.body ?? {};
|
||||||
const lista = await creaListaVuota(req.auth!.orgId!, nome);
|
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);
|
res.status(201).json(lista);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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 {
|
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);
|
res.status(201).json(lista);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -69,24 +211,56 @@ export async function postListaDaModello(req: Request, res: Response, next: Next
|
|||||||
|
|
||||||
interface PutListaBody {
|
interface PutListaBody {
|
||||||
nome?: unknown;
|
nome?: unknown;
|
||||||
|
stato?: unknown;
|
||||||
voci?: 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)) {
|
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");
|
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nome: body.nome as string | undefined,
|
nome: body.nome as string | undefined,
|
||||||
|
stato: parseStatoOpzionale(body),
|
||||||
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
|
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> {
|
export async function putLista(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const input = parseUpdateBody(req.body ?? {});
|
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);
|
res.status(200).json(lista);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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> {
|
export async function deleteLista(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await eliminaLista(req.params.id, req.auth!.orgId!);
|
await eliminaLista(req.params.id, viewerOf(req));
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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;
|
stato?: unknown;
|
||||||
posizione?: unknown;
|
posizione?: unknown;
|
||||||
note?: unknown;
|
note?: unknown;
|
||||||
|
gruppoId?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
|
function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
|
||||||
@@ -33,6 +34,9 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
|
|||||||
if (body.note !== undefined && typeof body.note !== 'string') {
|
if (body.note !== undefined && typeof body.note !== 'string') {
|
||||||
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa");
|
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 {
|
return {
|
||||||
materialeId: body.materialeId,
|
materialeId: body.materialeId,
|
||||||
@@ -40,6 +44,7 @@ function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
|
|||||||
stato: body.stato,
|
stato: body.stato,
|
||||||
posizione: body.posizione as string | undefined,
|
posizione: body.posizione as string | undefined,
|
||||||
note: body.note as string | undefined,
|
note: body.note as string | undefined,
|
||||||
|
gruppoId: body.gruppoId as string | null | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ interface PutVoceBody {
|
|||||||
stato?: unknown;
|
stato?: unknown;
|
||||||
posizione?: unknown;
|
posizione?: unknown;
|
||||||
note?: unknown;
|
note?: unknown;
|
||||||
|
gruppoId?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
|
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') {
|
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");
|
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 {
|
return {
|
||||||
materialeId: body.materialeId as string | undefined,
|
materialeId: body.materialeId as string | undefined,
|
||||||
@@ -77,6 +86,7 @@ function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
|
|||||||
stato: body.stato as StatoMagazzinoVoce | undefined,
|
stato: body.stato as StatoMagazzinoVoce | undefined,
|
||||||
posizione: body.posizione as string | null | undefined,
|
posizione: body.posizione as string | null | undefined,
|
||||||
note: body.note 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 { Request, Response, NextFunction } from 'express';
|
||||||
import {
|
import {
|
||||||
DecisioneProposta,
|
DecisioneProposta,
|
||||||
|
aggiornaMateriale,
|
||||||
|
creaMateriale,
|
||||||
decidiProposta,
|
decidiProposta,
|
||||||
|
eliminaMateriale,
|
||||||
listMaterialiApprovati,
|
listMaterialiApprovati,
|
||||||
listProposte,
|
listProposte,
|
||||||
proponiMateriale,
|
proponiMateriale,
|
||||||
} from '../services/materiali.service';
|
} from '../services/materiali.service';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
interface PostPropostaBody {
|
interface MaterialeBody {
|
||||||
nome?: unknown;
|
nome?: unknown;
|
||||||
categoria?: unknown;
|
categoria?: unknown;
|
||||||
unitaMisura?: 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) {
|
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
|
||||||
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
|
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;
|
return body.decisione;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query pubblica: unico filtro accettato dal client è "categoria". Lo stato
|
// Query pubblica: filtri accettati dal client sono "categoria" e "nome" (ricerca
|
||||||
// non è mai un parametro esposto: il catalogo pubblico mostra solo i
|
// per sottostringa, case-insensitive, usata dall'autocomplete). Lo stato non è mai
|
||||||
// materiali con stato "approvato".
|
// 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> {
|
export async function getMaterialiPubblici(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { categoria } = req.query;
|
const { categoria, nome } = req.query;
|
||||||
const filtro = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined;
|
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);
|
res.status(200).json(materiali);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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> {
|
export async function postProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const input = parsePropostaBody(req.body ?? {});
|
const input = parseMaterialeBody(req.body ?? {});
|
||||||
const proposta = await proponiMateriale({ ...input, orgId: req.auth!.orgId! });
|
const proposta = await proponiMateriale({ ...input, orgId: req.auth!.orgId! });
|
||||||
res.status(201).json(proposta);
|
res.status(201).json(proposta);
|
||||||
} catch (err) {
|
} 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> {
|
export async function getProposte(_req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const proposte = await listProposte();
|
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 { 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';
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
function parseNome(body: { nome?: unknown }): string {
|
function parseNome(body: { nome?: unknown }): string {
|
||||||
@@ -9,7 +18,22 @@ function parseNome(body: { nome?: unknown }): string {
|
|||||||
return body.nome;
|
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 {
|
try {
|
||||||
const tipiEvento = await listTipiEvento();
|
const tipiEvento = await listTipiEvento();
|
||||||
res.status(200).json(tipiEvento);
|
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> {
|
export async function putTipoEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const nome = parseNome(req.body ?? {});
|
const nome = parseNome(req.body ?? {});
|
||||||
@@ -46,3 +80,21 @@ export async function deleteTipoEvento(req: Request, res: Response, next: NextFu
|
|||||||
next(err);
|
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';
|
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 = {
|
const includeVoci = {
|
||||||
voci: { include: { materiale: true } },
|
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;
|
} satisfies Prisma.ListaInclude;
|
||||||
|
|
||||||
export type ListaConVoci = Prisma.ListaGetPayload<{ include: typeof includeVoci }>;
|
export type ListaConVoci = Prisma.ListaGetPayload<{ include: typeof includeVoci }>;
|
||||||
@@ -14,66 +22,214 @@ export interface ListaVoceInput {
|
|||||||
|
|
||||||
export interface CreateListaData {
|
export interface CreateListaData {
|
||||||
nome: string;
|
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[];
|
voci: ListaVoceInput[];
|
||||||
|
sottoListeIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateListaData {
|
export interface UpdateListaData {
|
||||||
nome?: string;
|
nome?: string;
|
||||||
|
stato?: StatoLista;
|
||||||
|
orgId?: string | null;
|
||||||
|
statoModerazione?: StatoModerazioneLista | null;
|
||||||
|
tipoEventoId?: string | null;
|
||||||
voci?: ListaVoceInput[];
|
voci?: ListaVoceInput[];
|
||||||
|
sottoListeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListeRepository {
|
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({
|
return prisma.lista.findMany({
|
||||||
where: { orgId },
|
where: {
|
||||||
|
OR: [{ creataDaUserId: userId }, ...(orgId ? [{ stato: 'gruppo' as StatoLista, orgId }] : [])],
|
||||||
|
},
|
||||||
include: includeVoci,
|
include: includeVoci,
|
||||||
orderBy: { creataIl: 'desc' },
|
orderBy: { creataIl: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// id + orgId nella stessa where: una lista di un'altra org risulta
|
// Catalogo pubblico: liste 'pubblico' E già approvate dalla moderazione. Sostituisce
|
||||||
// semplicemente "non trovata", mai un 403 che ne rivela l'esistenza.
|
// 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> {
|
findByIdAndOrg(id: string, orgId: string): Promise<ListaConVoci | null> {
|
||||||
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
|
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data: CreateListaData): Promise<ListaConVoci> {
|
create(data: CreateListaData, db: Db = prisma): Promise<ListaConVoci> {
|
||||||
return prisma.lista.create({
|
return db.lista.create({
|
||||||
data: {
|
data: {
|
||||||
nome: data.nome,
|
nome: data.nome,
|
||||||
orgId: data.orgId,
|
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 })) },
|
voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) },
|
||||||
|
sottoListe: { create: data.sottoListeIds.map((sottoListaId) => ({ sottoListaId })) },
|
||||||
},
|
},
|
||||||
include: includeVoci,
|
include: includeVoci,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, data: UpdateListaData): Promise<ListaConVoci> {
|
// Se il chiamante passa già un client di transazione (orchestrazione a monte nel
|
||||||
return prisma.$transaction(async (tx) => {
|
// service), le due operazioni (delete + update) vengono semplicemente eseguite su
|
||||||
if (data.voci) {
|
// quel client, atomiche insieme al resto della transazione esterna. Se invece non
|
||||||
await tx.listaVoce.deleteMany({ where: { listaId: id } });
|
// 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> {
|
||||||
where: { id },
|
if (data.voci) {
|
||||||
data: {
|
await db.listaVoce.deleteMany({ where: { listaId: id } });
|
||||||
...(data.nome !== undefined ? { nome: data.nome } : {}),
|
}
|
||||||
...(data.voci
|
if (data.sottoListeIds) {
|
||||||
? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } }
|
await db.listaSottoLista.deleteMany({ where: { listaId: id } });
|
||||||
: {}),
|
}
|
||||||
},
|
|
||||||
include: includeVoci,
|
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> {
|
async delete(id: string): Promise<void> {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.listaVoce.deleteMany({ where: { listaId: id } });
|
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 } });
|
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();
|
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;
|
stato: StatoMagazzinoVoce;
|
||||||
posizione?: string;
|
posizione?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
gruppoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMagazzinoVoceData {
|
export interface UpdateMagazzinoVoceData {
|
||||||
@@ -22,6 +23,7 @@ export interface UpdateMagazzinoVoceData {
|
|||||||
stato?: StatoMagazzinoVoce;
|
stato?: StatoMagazzinoVoce;
|
||||||
posizione?: string | null;
|
posizione?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
gruppoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuantitaPosseduta {
|
export interface QuantitaPosseduta {
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ export interface CreateMaterialeData {
|
|||||||
categoria: string;
|
categoria: string;
|
||||||
unitaMisura: string;
|
unitaMisura: string;
|
||||||
propostoDaOrgId: string;
|
propostoDaOrgId: string;
|
||||||
|
stato?: StatoMateriale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMaterialeData {
|
||||||
|
nome: string;
|
||||||
|
categoria: string;
|
||||||
|
unitaMisura: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MaterialiRepository {
|
export class MaterialiRepository {
|
||||||
findApprovati(categoria?: string): Promise<Materiale[]> {
|
findApprovati(categoria?: string, nome?: string): Promise<Materiale[]> {
|
||||||
return prisma.materiale.findMany({
|
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' },
|
orderBy: { nome: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -29,13 +40,21 @@ export class MaterialiRepository {
|
|||||||
|
|
||||||
create(data: CreateMaterialeData): Promise<Materiale> {
|
create(data: CreateMaterialeData): Promise<Materiale> {
|
||||||
return prisma.materiale.create({
|
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> {
|
updateStato(id: string, stato: StatoMateriale): Promise<Materiale> {
|
||||||
return prisma.materiale.update({ where: { id }, data: { stato } });
|
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();
|
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';
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export interface CreateTipoEventoData {
|
||||||
|
nome: string;
|
||||||
|
stato: StatoCategoria;
|
||||||
|
creatoDaOrgId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class TipiEventoRepository {
|
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[]> {
|
findAll(): Promise<TipoEvento[]> {
|
||||||
return prisma.tipoEvento.findMany({ orderBy: { nome: 'asc' } });
|
return prisma.tipoEvento.findMany({ orderBy: { nome: 'asc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
create(nome: string): Promise<TipoEvento> {
|
findById(id: string): Promise<TipoEvento | null> {
|
||||||
return prisma.tipoEvento.create({ data: { nome } });
|
return prisma.tipoEvento.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: CreateTipoEventoData): Promise<TipoEvento> {
|
||||||
|
return prisma.tipoEvento.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, nome: string): Promise<TipoEvento> {
|
update(id: string, nome: string): Promise<TipoEvento> {
|
||||||
return prisma.tipoEvento.update({ where: { id }, data: { nome } });
|
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> {
|
async delete(id: string): Promise<void> {
|
||||||
await prisma.tipoEvento.delete({ where: { id } });
|
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 { Router } from 'express';
|
||||||
import { verifyToken } from '../auth/verify-token.middleware';
|
import { verifyToken } from '../auth/verify-token.middleware';
|
||||||
import { requireOrgId } from '../auth/require-org-id.middleware';
|
import { requireModeratore } from '../auth/require-moderatore.middleware';
|
||||||
import { deleteLista, getListe, postLista, postListaDaModello, putLista } from '../controllers/liste.controller';
|
import {
|
||||||
|
deleteLista,
|
||||||
|
getListaPubblicaById,
|
||||||
|
getListe,
|
||||||
|
getListePubbliche,
|
||||||
|
getListeProposte,
|
||||||
|
patchListaProposta,
|
||||||
|
postLista,
|
||||||
|
postListaDaFork,
|
||||||
|
putLista,
|
||||||
|
} from '../controllers/liste.controller';
|
||||||
|
|
||||||
export const listeRouter = Router();
|
export const listeRouter = Router();
|
||||||
|
|
||||||
listeRouter.get('/liste', verifyToken, requireOrgId, getListe);
|
listeRouter.get('/liste', verifyToken, getListe);
|
||||||
listeRouter.post('/liste', verifyToken, requireOrgId, postLista);
|
// Nessun middleware: le liste 'pubblico' approvate sono visibili anche senza
|
||||||
listeRouter.post('/liste/da-modello/:listaModelloId', verifyToken, requireOrgId, postListaDaModello);
|
// autenticazione. Le due route sotto vanno dichiarate prima di eventuali futuri
|
||||||
listeRouter.put('/liste/:id', verifyToken, requireOrgId, putLista);
|
// GET /liste/:id generici, per evitare ambiguità di matching.
|
||||||
listeRouter.delete('/liste/:id', verifyToken, requireOrgId, deleteLista);
|
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 { verifyToken } from '../auth/verify-token.middleware';
|
||||||
import { requireOrgId } from '../auth/require-org-id.middleware';
|
import { requireOrgId } from '../auth/require-org-id.middleware';
|
||||||
import { requireModeratore } from '../auth/require-moderatore.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();
|
export const materialiRouter = Router();
|
||||||
|
|
||||||
materialiRouter.get('/materiali', getMaterialiPubblici);
|
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.post('/materiali/proposte', verifyToken, requireOrgId, postProposta);
|
||||||
materialiRouter.get('/materiali/proposte', verifyToken, requireModeratore, getProposte);
|
materialiRouter.get('/materiali/proposte', verifyToken, requireModeratore, getProposte);
|
||||||
materialiRouter.patch('/materiali/proposte/:id', verifyToken, requireModeratore, patchProposta);
|
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 { Router } from 'express';
|
||||||
import { verifyToken } from '../auth/verify-token.middleware';
|
import { verifyToken } from '../auth/verify-token.middleware';
|
||||||
|
import { requireOrgId } from '../auth/require-org-id.middleware';
|
||||||
import { requireModeratore } from '../auth/require-moderatore.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();
|
export const tipiEventoRouter = Router();
|
||||||
|
|
||||||
tipiEventoRouter.get('/tipi-evento', getTipiEvento);
|
tipiEventoRouter.get('/tipi-evento', getTipiEvento);
|
||||||
|
tipiEventoRouter.get('/tipi-evento/moderazione', verifyToken, requireModeratore, getTipiEventoModerazione);
|
||||||
tipiEventoRouter.post('/tipi-evento', verifyToken, requireModeratore, postTipoEvento);
|
tipiEventoRouter.post('/tipi-evento', verifyToken, requireModeratore, postTipoEvento);
|
||||||
|
tipiEventoRouter.post('/tipi-evento/proposte', verifyToken, requireOrgId, postTipoEventoProposta);
|
||||||
tipiEventoRouter.put('/tipi-evento/:id', verifyToken, requireModeratore, putTipoEvento);
|
tipiEventoRouter.put('/tipi-evento/:id', verifyToken, requireModeratore, putTipoEvento);
|
||||||
tipiEventoRouter.delete('/tipi-evento/:id', verifyToken, requireModeratore, deleteTipoEvento);
|
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 { ListaConVoci, ListaVoceInput, listeRepository } from '../repositories/liste.repository';
|
||||||
import { listeModelloRepository } from '../repositories/listeModello.repository';
|
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
import { toHttpError } from '../utils/prisma-errors';
|
import { toHttpError } from '../utils/prisma-errors';
|
||||||
|
import { creaNotificaModerazione } from './notifiche.service';
|
||||||
|
|
||||||
export interface ListaVoceView {
|
export interface ListaVoceView {
|
||||||
materialeId: string;
|
materialeId: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
unitaMisura: string;
|
unitaMisura: string;
|
||||||
quantita: number;
|
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 {
|
export interface ListaView {
|
||||||
id: string;
|
id: string;
|
||||||
nome: 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;
|
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[];
|
voci: ListaVoceView[];
|
||||||
|
sottoListe: SottoListaView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toView(lista: ListaConVoci): ListaView {
|
// Chi sta leggendo/scrivendo la lista. orgId è l'organizzazione attiva sul token (null
|
||||||
|
// se l'utente non ne ha una: le liste personali bozza/privato/pubblico non richiedono
|
||||||
|
// un'org). Serve sia per decidere se mostrare inAttesaConferma sia per l'ownership.
|
||||||
|
export interface Viewer {
|
||||||
|
userId: string;
|
||||||
|
orgId: string | null;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerAnonimo: Viewer = { userId: '', orgId: null, roles: [] };
|
||||||
|
// Usato solo per costruire la ListaView di ritorno delle route di moderazione (già
|
||||||
|
// protette da requireModeratore a livello router): garantisce che inAttesaConferma sui
|
||||||
|
// materiali dentro la lista sia visibile a chi la sta decidendo.
|
||||||
|
const viewerModeratore: Viewer = { userId: '', orgId: null, roles: ['moderatore'] };
|
||||||
|
|
||||||
|
function toVoceView(v: ListaConVoci['voci'][number], puoVedereAttesaConferma: boolean): ListaVoceView {
|
||||||
|
return {
|
||||||
|
materialeId: v.materialeId,
|
||||||
|
nome: v.materiale.nome,
|
||||||
|
unitaMisura: v.materiale.unitaMisura,
|
||||||
|
quantita: v.quantita,
|
||||||
|
...(puoVedereAttesaConferma && v.materiale.stato === 'proposto' ? { inAttesaConferma: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toView(lista: ListaConVoci, viewer: Viewer): ListaView {
|
||||||
|
const puoVedereAttesaConferma = lista.creataDaUserId === viewer.userId || viewer.roles.includes('moderatore');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: lista.id,
|
id: lista.id,
|
||||||
nome: lista.nome,
|
nome: lista.nome,
|
||||||
orgId: lista.orgId,
|
orgId: lista.orgId,
|
||||||
|
stato: lista.stato,
|
||||||
|
statoModerazione: lista.statoModerazione,
|
||||||
|
tipoEventoId: lista.tipoEventoId,
|
||||||
|
parentId: lista.parentId,
|
||||||
creataIl: lista.creataIl,
|
creataIl: lista.creataIl,
|
||||||
voci: lista.voci.map((v) => ({
|
creataDaMe: lista.creataDaUserId === viewer.userId,
|
||||||
materialeId: v.materialeId,
|
voci: lista.voci.map((v) => toVoceView(v, puoVedereAttesaConferma)),
|
||||||
nome: v.materiale.nome,
|
sottoListe: lista.sottoListe.map((sl) => ({
|
||||||
unitaMisura: v.materiale.unitaMisura,
|
id: sl.sottoLista.id,
|
||||||
quantita: v.quantita,
|
nome: sl.sottoLista.nome,
|
||||||
|
voci: sl.sottoLista.voci.map((v) => toVoceView(v, puoVedereAttesaConferma)),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listListePerOrg(orgId: string): Promise<ListaView[]> {
|
// "Le mie liste": le proprie (qualunque stato) + le liste di gruppo della propria org,
|
||||||
const liste = await listeRepository.findAllByOrg(orgId);
|
// create da chiunque nell'org (comportamento collaborativo).
|
||||||
return liste.map(toView);
|
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> {
|
// Catalogo pubblico: liste 'pubblico' già approvate dalla moderazione, visibili anche
|
||||||
const lista = await listeRepository.create({ nome, orgId, voci: [] });
|
// senza autenticazione. Nessun inAttesaConferma per un chiamante anonimo.
|
||||||
return toView(lista);
|
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
|
// Dettaglio pubblico per singolo id (deep-link diretto al catalogo, a parità con il
|
||||||
// dell'org. Da qui in poi le due entità non hanno più alcun legame: nessun
|
// vecchio GET /liste-modello/:id).
|
||||||
// listaModelloId viene salvato sulla lista creata.
|
export async function trovaListaPubblicaPerId(id: string): Promise<ListaView> {
|
||||||
export async function forkListaDaModello(orgId: string, listaModelloId: string): Promise<ListaView> {
|
const lista = await listeRepository.findApprovataPerId(id);
|
||||||
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);
|
|
||||||
if (!lista) {
|
if (!lista) {
|
||||||
throw new HttpError(404, 'Lista non trovata');
|
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 {
|
export interface AggiornaListaInput {
|
||||||
nome?: string;
|
nome?: string;
|
||||||
|
// Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in
|
||||||
|
// edit, non solo alla creazione.
|
||||||
|
stato?: StatoLista;
|
||||||
voci?: ListaVoceInput[];
|
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> {
|
export async function aggiornaLista(id: string, input: AggiornaListaInput, viewer: Viewer): Promise<ListaView> {
|
||||||
await assicuraListaDiOrg(id, orgId);
|
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 {
|
try {
|
||||||
const lista = await listeRepository.update(id, input);
|
const lista = await prisma.$transaction(async (tx) => {
|
||||||
return toView(lista);
|
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) {
|
} catch (err) {
|
||||||
throw toHttpError(err, 'Lista non trovata', 'Uno dei materiali indicati non esiste');
|
throw toHttpError(err, 'Lista non trovata', 'Uno dei materiali indicati non esiste');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function eliminaLista(id: string, orgId: string): Promise<void> {
|
export async function eliminaLista(id: string, viewer: Viewer): Promise<void> {
|
||||||
await assicuraListaDiOrg(id, orgId);
|
await assicuraAccessoGestione(id, viewer);
|
||||||
try {
|
try {
|
||||||
await listeRepository.delete(id);
|
await listeRepository.delete(id);
|
||||||
} catch (err) {
|
} 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,
|
magazzinoRepository,
|
||||||
} from '../repositories/magazzino.repository';
|
} from '../repositories/magazzino.repository';
|
||||||
import { materialiRepository } from '../repositories/materiali.repository';
|
import { materialiRepository } from '../repositories/materiali.repository';
|
||||||
|
import { gruppiMagazzinoRepository } from '../repositories/gruppiMagazzino.repository';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
import { toHttpError } from '../utils/prisma-errors';
|
import { toHttpError } from '../utils/prisma-errors';
|
||||||
|
|
||||||
@@ -19,9 +20,11 @@ export interface MagazzinoVoceView {
|
|||||||
stato: StatoMagazzinoVoce;
|
stato: StatoMagazzinoVoce;
|
||||||
posizione: string | null;
|
posizione: string | null;
|
||||||
note: 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 {
|
return {
|
||||||
id: voce.id,
|
id: voce.id,
|
||||||
orgId: voce.orgId,
|
orgId: voce.orgId,
|
||||||
@@ -32,6 +35,7 @@ function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView {
|
|||||||
stato: voce.stato,
|
stato: voce.stato,
|
||||||
posizione: voce.posizione,
|
posizione: voce.posizione,
|
||||||
note: voce.note,
|
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));
|
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 {
|
export interface AggiungiVoceInput {
|
||||||
materialeId: string;
|
materialeId: string;
|
||||||
quantitaPosseduta: number;
|
quantitaPosseduta: number;
|
||||||
stato: StatoMagazzinoVoce;
|
stato: StatoMagazzinoVoce;
|
||||||
posizione?: string;
|
posizione?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
gruppoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function aggiungiVoce(orgId: string, input: AggiungiVoceInput): Promise<MagazzinoVoceView> {
|
export async function aggiungiVoce(orgId: string, input: AggiungiVoceInput): Promise<MagazzinoVoceView> {
|
||||||
await assicuraMaterialeApprovato(input.materialeId);
|
await assicuraMaterialeApprovato(input.materialeId);
|
||||||
|
if (input.gruppoId) {
|
||||||
|
await assicuraGruppoDiOrg(input.gruppoId, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
const data: CreateMagazzinoVoceData = { ...input, orgId };
|
const data: CreateMagazzinoVoceData = { ...input, orgId };
|
||||||
const voce = await magazzinoRepository.create(data);
|
const voce = await magazzinoRepository.create(data);
|
||||||
@@ -81,6 +98,7 @@ export interface AggiornaVoceInput {
|
|||||||
stato?: StatoMagazzinoVoce;
|
stato?: StatoMagazzinoVoce;
|
||||||
posizione?: string | null;
|
posizione?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
gruppoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function aggiornaVoce(id: string, orgId: string, input: AggiornaVoceInput): Promise<MagazzinoVoceView> {
|
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) {
|
if (input.materialeId !== undefined) {
|
||||||
await assicuraMaterialeApprovato(input.materialeId);
|
await assicuraMaterialeApprovato(input.materialeId);
|
||||||
}
|
}
|
||||||
|
if (input.gruppoId) {
|
||||||
|
await assicuraGruppoDiOrg(input.gruppoId, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
const data: UpdateMagazzinoVoceData = input;
|
const data: UpdateMagazzinoVoceData = input;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Materiale, StatoMateriale } from '@prisma/client';
|
import { Materiale, StatoMateriale } from '@prisma/client';
|
||||||
import { materialiRepository } from '../repositories/materiali.repository';
|
import { materialiRepository } from '../repositories/materiali.repository';
|
||||||
import { HttpError } from '../errors';
|
import { HttpError } from '../errors';
|
||||||
|
import { toHttpError } from '../utils/prisma-errors';
|
||||||
|
import { creaNotificaModerazione } from './notifiche.service';
|
||||||
|
|
||||||
export interface MaterialePubblico {
|
export interface MaterialePubblico {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,8 +42,8 @@ function toProposta(materiale: Materiale): MaterialeProposta {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMaterialiApprovati(categoria?: string): Promise<MaterialePubblico[]> {
|
export async function listMaterialiApprovati(categoria?: string, nome?: string): Promise<MaterialePubblico[]> {
|
||||||
const materiali = await materialiRepository.findApprovati(categoria);
|
const materiali = await materialiRepository.findApprovati(categoria, nome);
|
||||||
return materiali.map(toPubblico);
|
return materiali.map(toPubblico);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +61,58 @@ export async function proponiMateriale(input: ProponiMaterialeInput): Promise<Ma
|
|||||||
unitaMisura: input.unitaMisura,
|
unitaMisura: input.unitaMisura,
|
||||||
propostoDaOrgId: input.orgId,
|
propostoDaOrgId: input.orgId,
|
||||||
});
|
});
|
||||||
|
await creaNotificaModerazione(
|
||||||
|
'MATERIALE_PROPOSTO',
|
||||||
|
`Nuovo materiale proposto: "${materiale.nome}"`,
|
||||||
|
'/tassonomie?tab=materiali',
|
||||||
|
);
|
||||||
return toProposta(materiale);
|
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[]> {
|
export async function listProposte(): Promise<MaterialeProposta[]> {
|
||||||
const materiali = await materialiRepository.findProposte();
|
const materiali = await materialiRepository.findProposte();
|
||||||
return materiali.map(toProposta);
|
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 { tipiEventoRepository } from '../repositories/tipiEvento.repository';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
import { toHttpError } from '../utils/prisma-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[]> {
|
export function listTipiEvento(): Promise<TipoEvento[]> {
|
||||||
return tipiEventoRepository.findAll();
|
return tipiEventoRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function creaTipoEvento(nome: string): Promise<TipoEvento> {
|
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> {
|
export async function aggiornaTipoEvento(id: string, nome: string): Promise<TipoEvento> {
|
||||||
try {
|
try {
|
||||||
return await tipiEventoRepository.update(id, nome);
|
return await tipiEventoRepository.update(id, nome);
|
||||||
} catch (err) {
|
} 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 listaUpdate = jest.fn();
|
||||||
const listaDelete = jest.fn();
|
const listaDelete = jest.fn();
|
||||||
const listaVoceDeleteMany = jest.fn();
|
const listaVoceDeleteMany = jest.fn();
|
||||||
|
const listaSottoListaDeleteMany = jest.fn();
|
||||||
|
const listaSottoListaFindMany = jest.fn();
|
||||||
|
|
||||||
const listaModelloFindUnique = jest.fn();
|
// $transaction espone lo stesso client mockato usato fuori transazione, così i test
|
||||||
const listaModelloUpdate = jest.fn();
|
// possono asserire sull'unica lista di chiamate indipendentemente dal fatto che
|
||||||
const listaModelloVoceDeleteMany = jest.fn();
|
// passino per una transazione o meno (creaLista/aggiornaLista aprono sempre una
|
||||||
|
// propria transazione per orchestrare fork + aggancio sotto-liste atomicamente).
|
||||||
// $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.
|
|
||||||
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
|
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
|
||||||
callback({
|
callback({
|
||||||
lista: { update: listaUpdate, delete: listaDelete },
|
lista: { create: listaCreate, update: listaUpdate, delete: listaDelete, findMany: listaFindMany, findFirst: listaFindFirst },
|
||||||
listaVoce: { deleteMany: listaVoceDeleteMany },
|
listaVoce: { deleteMany: listaVoceDeleteMany },
|
||||||
listaModello: { update: listaModelloUpdate },
|
listaSottoLista: { deleteMany: listaSottoListaDeleteMany, findMany: listaSottoListaFindMany },
|
||||||
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -39,16 +36,15 @@ jest.mock('../../src/db/prisma', () => ({
|
|||||||
findMany: (...args: unknown[]) => listaFindMany(...args),
|
findMany: (...args: unknown[]) => listaFindMany(...args),
|
||||||
findFirst: (...args: unknown[]) => listaFindFirst(...args),
|
findFirst: (...args: unknown[]) => listaFindFirst(...args),
|
||||||
create: (...args: unknown[]) => listaCreate(...args),
|
create: (...args: unknown[]) => listaCreate(...args),
|
||||||
|
update: (...args: unknown[]) => listaUpdate(...args),
|
||||||
|
delete: (...args: unknown[]) => listaDelete(...args),
|
||||||
},
|
},
|
||||||
listaVoce: {
|
listaVoce: {
|
||||||
deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args),
|
deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args),
|
||||||
},
|
},
|
||||||
listaModello: {
|
listaSottoLista: {
|
||||||
findUnique: (...args: unknown[]) => listaModelloFindUnique(...args),
|
deleteMany: (...args: unknown[]) => listaSottoListaDeleteMany(...args),
|
||||||
update: (...args: unknown[]) => listaModelloUpdate(...args),
|
findMany: (...args: unknown[]) => listaSottoListaFindMany(...args),
|
||||||
},
|
|
||||||
listaModelloVoce: {
|
|
||||||
deleteMany: (...args: unknown[]) => listaModelloVoceDeleteMany(...args),
|
|
||||||
},
|
},
|
||||||
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
|
$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' });
|
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({
|
return signToken({
|
||||||
sub: 'user-1',
|
sub,
|
||||||
realm_access: { roles: ['censito'] },
|
realm_access: { roles: ['censito'] },
|
||||||
organization: { gruppo: { id: orgId, roles: [] } },
|
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 {
|
function adminCatalogoToken(): string {
|
||||||
return signToken({
|
return signToken({
|
||||||
sub: 'admin-1',
|
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() };
|
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(() => {
|
beforeAll(() => {
|
||||||
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||||
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||||
@@ -103,24 +133,24 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /liste', () => {
|
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([]);
|
listaFindMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||||
|
|
||||||
expect(listaFindMany).toHaveBeenCalledWith(
|
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 () => {
|
test('utente senza org: filtra solo sulle proprie liste', async () => {
|
||||||
listaFindMany.mockResolvedValue([]);
|
listaFindMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
await request(app).get('/liste').set('Authorization', `Bearer ${tokenSenzaOrg()}`);
|
||||||
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
|
||||||
|
|
||||||
expect(listaFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
|
expect(listaFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { OR: [{ creataDaUserId: 'user-1' }] } }));
|
||||||
expect(listaFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('risponde 401 senza token', async () => {
|
test('risponde 401 senza token', async () => {
|
||||||
@@ -129,11 +159,90 @@ describe('GET /liste', () => {
|
|||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(listaFindMany).not.toHaveBeenCalled();
|
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', () => {
|
describe('POST /liste', () => {
|
||||||
test('crea una lista vuota per l\'org corrente, ignorando un org_id eventualmente inviato dal client', async () => {
|
test('crea una lista bozza personale (senza orgId) ignorando un org_id inviato dal client, anche con org attiva', async () => {
|
||||||
listaCreate.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista vuota', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
listaCreate.mockResolvedValueOnce(listaVuota({ nome: 'Lista vuota' }));
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/liste')
|
.post('/liste')
|
||||||
@@ -141,54 +250,279 @@ describe('POST /liste', () => {
|
|||||||
.send({ nome: 'Lista vuota', orgId: 'org-spoofed' });
|
.send({ nome: 'Lista vuota', orgId: 'org-spoofed' });
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.orgId).toBeNull();
|
||||||
expect(listaCreate).toHaveBeenCalledWith({
|
expect(listaCreate).toHaveBeenCalledWith({
|
||||||
data: { nome: 'Lista vuota', orgId: 'org-a', voci: { create: [] } },
|
data: {
|
||||||
include: { voci: { include: { materiale: true } } },
|
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', () => {
|
describe('POST /liste — sotto-liste', () => {
|
||||||
test('copia nome e voci dalla lista modello, senza salvare alcun riferimento verso di essa', async () => {
|
test('aggancia una lista personale propria come sotto-lista', async () => {
|
||||||
const vociModello = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }];
|
listaSottoListaFindMany.mockResolvedValueOnce([]); // nessuna con proprie sotto-liste
|
||||||
listaModelloFindUnique.mockResolvedValueOnce({
|
listaFindMany.mockResolvedValueOnce([{ id: 'l-esistente' }]); // visibile come candidata
|
||||||
id: 'lm-1',
|
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-nuova' }));
|
||||||
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') }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(app)
|
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')}`);
|
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
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];
|
const callArg = listaCreate.mock.calls[0][0];
|
||||||
expect(callArg.data).toEqual({
|
expect(callArg.data).toEqual({
|
||||||
nome: 'Kit campo estivo',
|
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 }] },
|
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
|
||||||
|
sottoListe: { create: [] },
|
||||||
});
|
});
|
||||||
// Le voci passate a create sono un array nuovo con valori copiati, non lo
|
// Le voci passate a create sono un array nuovo con valori copiati, non lo
|
||||||
// stesso array (né gli stessi oggetti) restituiti dalla lista modello.
|
// stesso array (né gli stessi oggetti) restituiti dalla lista di origine.
|
||||||
expect(callArg.data.voci.create).not.toBe(vociModello);
|
expect(callArg.data.voci.create).not.toBe(vociSorgente);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('risponde 404 se la lista modello non esiste', async () => {
|
test('include anche i materiali delle sotto-liste, sommando le quantità con quelli di primo livello', async () => {
|
||||||
listaModelloFindUnique.mockResolvedValueOnce(null);
|
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)
|
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')}`);
|
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
@@ -196,33 +530,149 @@ describe('POST /liste/da-modello/:listaModelloId', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('risponde 401 senza token', async () => {
|
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(response.status).toBe(401);
|
||||||
expect(listaCreate).not.toHaveBeenCalled();
|
expect(listaCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /liste/:id — isolamento tra org', () => {
|
describe('GET /liste/proposte', () => {
|
||||||
test('un\'org non può modificare una lista di un\'altra org (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => {
|
test('risponde 401 senza token', async () => {
|
||||||
// La query combina sempre id + orgId: una lista di un'altra org non viene trovata.
|
const response = await request(app).get('/liste/proposte');
|
||||||
listaFindFirst.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
const response = await request(app)
|
expect(response.status).toBe(401);
|
||||||
.put('/liste/l-1')
|
expect(listaFindMany).not.toHaveBeenCalled();
|
||||||
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
|
});
|
||||||
.send({ nome: 'Nome modificato' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
test('risponde 403 per un utente senza ruolo moderatore', async () => {
|
||||||
expect(listaFindFirst).toHaveBeenCalledWith(
|
const response = await request(app).get('/liste/proposte').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||||
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-b' } }),
|
|
||||||
|
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();
|
expect(listaUpdate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('l\'org proprietaria può modificare la propria lista', async () => {
|
test('risponde 403 per un utente senza ruolo moderatore', async () => {
|
||||||
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Vecchio nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
const response = await request(app)
|
||||||
listaUpdate.mockResolvedValueOnce({ id: 'l-1', nome: 'Nuovo nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
.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)
|
const response = await request(app)
|
||||||
.put('/liste/l-1')
|
.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 () => {
|
test('risponde 401 senza token', async () => {
|
||||||
const response = await request(app).put('/liste/l-1').send({ nome: 'x' });
|
const response = await request(app).put('/liste/l-1').send({ nome: 'x' });
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(listaFindFirst).not.toHaveBeenCalled();
|
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', () => {
|
describe('PUT /liste/:id — promozione a pubblico e ricalcolo della moderazione', () => {
|
||||||
test('un\'org non può eliminare una lista di un\'altra org', async () => {
|
test('promuovere una bozza a pubblico forza statoModerazione a "proposto"', async () => {
|
||||||
listaFindFirst.mockResolvedValueOnce(null);
|
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(response.status).toBe(404);
|
||||||
expect(listaDelete).not.toHaveBeenCalled();
|
expect(listaDelete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('l\'org proprietaria può eliminare la propria lista', async () => {
|
test('il creatore può eliminare la propria lista', async () => {
|
||||||
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
listaFindFirst.mockResolvedValueOnce(listaVuota({ id: 'l-1' }));
|
||||||
listaDelete.mockResolvedValueOnce({ id: 'l-1' });
|
listaDelete.mockResolvedValueOnce({ id: 'l-1' });
|
||||||
|
|
||||||
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
|
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
|
||||||
|
expect(listaSottoListaDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
|
||||||
expect(listaDelete).toHaveBeenCalledWith({ where: { id: 'l-1' } });
|
expect(listaDelete).toHaveBeenCalledWith({ where: { id: 'l-1' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('indipendenza tra lista modello e lista forkata', () => {
|
describe('fork: lineage e indipendenza tra lista di origine e lista forkata', () => {
|
||||||
test('aggiornare la lista modello originale non tocca in alcun modo le tabelle della lista privata forkata', async () => {
|
test('la lista forkata ha parentId popolato verso la lista pubblica di origine', async () => {
|
||||||
listaModelloUpdate.mockResolvedValueOnce({
|
listaFindFirst.mockResolvedValueOnce({
|
||||||
id: 'lm-1',
|
id: 'l-pub-1',
|
||||||
nome: 'Kit aggiornato',
|
nome: 'Kit',
|
||||||
tipoEventoId: 't-1',
|
orgId: null,
|
||||||
pubblica: true,
|
stato: 'pubblico',
|
||||||
voci: [],
|
statoModerazione: 'approvato',
|
||||||
});
|
tipoEventoId: null,
|
||||||
|
parentId: null,
|
||||||
const response = await request(app)
|
creataDaUserId: 'altro-utente',
|
||||||
.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',
|
|
||||||
creataIl: new Date(),
|
creataIl: new Date(),
|
||||||
voci: [],
|
voci: [],
|
||||||
|
sottoListe: [],
|
||||||
});
|
});
|
||||||
|
listaCreate.mockResolvedValueOnce(listaVuota({ id: 'l-figlia', parentId: 'l-pub-1' }));
|
||||||
|
|
||||||
const response = await request(app)
|
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')}`)
|
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||||
.send({ nome: 'Kit campo estivo (personalizzato)', voci: [] });
|
.send({ nome: 'Personalizzata', voci: [] });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-2' } });
|
expect(listaUpdate).toHaveBeenCalledTimes(1);
|
||||||
expect(listaUpdate).toHaveBeenCalled();
|
expect(listaUpdate).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'l-figlia' } }));
|
||||||
expect(listaModelloVoceDeleteMany).not.toHaveBeenCalled();
|
|
||||||
expect(listaModelloUpdate).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
|
||||||
|
|
||||||
const tipoEventoFindMany = jest.fn();
|
const tipoEventoFindMany = jest.fn();
|
||||||
|
const tipoEventoFindUnique = jest.fn();
|
||||||
const tipoEventoCreate = jest.fn();
|
const tipoEventoCreate = jest.fn();
|
||||||
const tipoEventoUpdate = jest.fn();
|
const tipoEventoUpdate = jest.fn();
|
||||||
const tipoEventoDelete = jest.fn();
|
const tipoEventoDelete = jest.fn();
|
||||||
@@ -18,6 +19,7 @@ jest.mock('../../src/db/prisma', () => ({
|
|||||||
prisma: {
|
prisma: {
|
||||||
tipoEvento: {
|
tipoEvento: {
|
||||||
findMany: (...args: unknown[]) => tipoEventoFindMany(...args),
|
findMany: (...args: unknown[]) => tipoEventoFindMany(...args),
|
||||||
|
findUnique: (...args: unknown[]) => tipoEventoFindUnique(...args),
|
||||||
create: (...args: unknown[]) => tipoEventoCreate(...args),
|
create: (...args: unknown[]) => tipoEventoCreate(...args),
|
||||||
update: (...args: unknown[]) => tipoEventoUpdate(...args),
|
update: (...args: unknown[]) => tipoEventoUpdate(...args),
|
||||||
delete: (...args: unknown[]) => tipoEventoDelete(...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' });
|
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function utenteToken(): string {
|
function utenteToken(orgId = 'org-1'): string {
|
||||||
return signToken({
|
return signToken({
|
||||||
sub: 'user-1',
|
sub: 'user-1',
|
||||||
realm_access: { roles: ['censito'] },
|
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', () => {
|
describe('GET /tipi-evento', () => {
|
||||||
test('è pubblico e restituisce la lista', async () => {
|
test('è pubblico e restituisce solo i tipi evento confermati', async () => {
|
||||||
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo' }]);
|
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo', stato: 'confermata' }]);
|
||||||
|
|
||||||
const response = await request(app).get('/tipi-evento');
|
const response = await request(app).get('/tipi-evento');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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();
|
expect(tipoEventoCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('un moderatore può creare un tipo evento', async () => {
|
test('un moderatore crea un tipo evento già confermato', async () => {
|
||||||
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco' });
|
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco', stato: 'confermata' });
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/tipi-evento')
|
.post('/tipi-evento')
|
||||||
@@ -100,7 +138,7 @@ describe('POST /tipi-evento', () => {
|
|||||||
.send({ nome: 'Bivacco' });
|
.send({ nome: 'Bivacco' });
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
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 () => {
|
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', () => {
|
describe('PUT /tipi-evento/:id', () => {
|
||||||
test('un utente normale non può modificare un tipo evento', async () => {
|
test('un utente normale non può modificare un tipo evento', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
@@ -159,3 +226,65 @@ describe('DELETE /tipi-evento/:id', () => {
|
|||||||
expect(tipoEventoDelete).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/material-theme.scss", "src/styles.css"]
|
"styles": ["src/styles.css"]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
<router-outlet />
|
<div class="app-shell">
|
||||||
|
<app-header />
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ export const routes: Routes = [
|
|||||||
loadChildren: () => import('./magazzino/magazzino.routes').then((m) => m.MAGAZZINO_ROUTES)
|
loadChildren: () => import('./magazzino/magazzino.routes').then((m) => m.MAGAZZINO_ROUTES)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'eventi',
|
path: 'tassonomie',
|
||||||
loadChildren: () => import('./eventi/eventi.routes').then((m) => m.EVENTI_ROUTES)
|
loadChildren: () => import('./tassonomie/tassonomie.routes').then((m) => m.TASSONOMIE_ROUTES)
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'moderazione',
|
|
||||||
loadChildren: () => import('./moderazione/moderazione.routes').then((m) => m.MODERAZIONE_ROUTES)
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
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 { App } from './app';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: [
|
||||||
|
provideRouter(routes),
|
||||||
|
{ provide: Keycloak, useValue: { authenticated: false, tokenParsed: {} } },
|
||||||
|
{
|
||||||
|
provide: KEYCLOAK_EVENT_SIGNAL,
|
||||||
|
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true })
|
||||||
|
}
|
||||||
|
]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import { Header } from './shared/header/header';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, Header],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css'
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-6
@@ -1,24 +1,95 @@
|
|||||||
.catalogo-liste-modello__header {
|
.catalogo-liste-modello__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: 12px;
|
||||||
margin-bottom: 0;
|
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 {
|
.catalogo-liste-modello__error {
|
||||||
color: var(--color-accent-800);
|
color: var(--color-accent-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipo-evento-gruppo {
|
.catalogo-liste-modello__grid {
|
||||||
margin-bottom: var(--space-8);
|
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 {
|
.lista-modello-card__voci {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.1rem;
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.8;
|
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 {
|
.lista-modello-card__avviso {
|
||||||
|
|||||||
+67
-36
@@ -1,53 +1,84 @@
|
|||||||
<section class="catalogo-liste-modello om-page">
|
<section class="catalogo-liste-modello om-page">
|
||||||
<div class="catalogo-liste-modello__header">
|
<div class="catalogo-liste-modello__header">
|
||||||
<h1 class="om-section-title">Liste modello per tipo evento</h1>
|
<h1 class="catalogo-liste-modello__title">Liste materiali pubblicate</h1>
|
||||||
<a class="btn btn-ghost" routerLink="/">Catalogo materiali</a>
|
<a class="catalogo-liste-modello__search-link" routerLink="/cerca-lista">
|
||||||
|
<span>🔍</span><span>Cerca lista...</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="om-section-sub">Liste standard, pronte da usare come base per una lista della tua organizzazione.</p>
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<p class="om-empty">Caricamento liste modello…</p>
|
<p class="om-empty">Caricamento liste modello…</p>
|
||||||
} @else if (loadError(); as message) {
|
} @else if (loadError(); as message) {
|
||||||
<p class="catalogo-liste-modello__error" role="alert">{{ message }}</p>
|
<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>
|
<p class="om-empty">Nessuna lista modello disponibile.</p>
|
||||||
} @else {
|
} @else {
|
||||||
@for (gruppo of gruppi(); track gruppo.tipoEvento.id) {
|
<div class="catalogo-liste-modello__grid">
|
||||||
<section class="tipo-evento-gruppo">
|
@for (lista of liste(); track lista.id) {
|
||||||
<h2>{{ gruppo.tipoEvento.nome }}</h2>
|
<div
|
||||||
|
class="card lista-modello-card"
|
||||||
<div class="tipo-evento-gruppo__liste om-grid">
|
role="link"
|
||||||
@for (lista of gruppo.liste; track lista.id) {
|
tabindex="0"
|
||||||
<div class="card lista-modello-card">
|
(click)="onCardClick($event, lista.id)"
|
||||||
<div class="card-title">{{ lista.nome }}</div>
|
(keydown.enter)="apriDettaglio(lista.id)"
|
||||||
<ul class="lista-modello-card__voci card-body">
|
>
|
||||||
@for (voce of lista.voci; track voce.materialeId) {
|
<div class="card-title">{{ lista.nome }}</div>
|
||||||
<li>{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}</li>
|
@if (lista.tipoEventoNome) {
|
||||||
}
|
<span class="lista-modello-card__chip">{{ lista.tipoEventoNome }}</span>
|
||||||
</ul>
|
}
|
||||||
|
<ul class="lista-modello-card__voci card-body">
|
||||||
@if (!isAuthenticated) {
|
@for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) {
|
||||||
<p class="lista-modello-card__avviso">Accedi per usare questa lista come base.</p>
|
@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 (creazioneErroreId() === lista.id) {
|
@if (!isAuthenticated) {
|
||||||
<p class="lista-modello-card__errore" role="alert">
|
<p class="lista-modello-card__avviso">Accedi per usare questa lista come base.</p>
|
||||||
Impossibile creare la lista. Riprova più tardi.
|
}
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
@if (creazioneErroreId() === lista.id) {
|
||||||
type="button"
|
<p class="lista-modello-card__errore" role="alert">
|
||||||
class="btn btn-secondary btn-block"
|
Impossibile creare la lista. Riprova più tardi.
|
||||||
[disabled]="creazioneInCorsoId() === lista.id"
|
</p>
|
||||||
(click)="usaComeBase(lista.id)"
|
}
|
||||||
>
|
|
||||||
Usa come base
|
@if (isAuthenticated) {
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
class="btn btn-secondary btn-block"
|
||||||
|
[disabled]="creazioneInCorsoId() === lista.id"
|
||||||
|
(click)="onUsaComeBase(lista.id)"
|
||||||
|
>
|
||||||
|
Usa come base
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
}
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+183
-33
@@ -3,8 +3,7 @@ import { provideRouter, Router } from '@angular/router';
|
|||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import Keycloak from 'keycloak-js';
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
import { Lista, ListeApiService } from '../liste-api.service';
|
import { Lista, ListeApiService } from '../../liste/liste-api.service';
|
||||||
import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service';
|
|
||||||
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
|
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
|
||||||
import { CatalogoListeModello } from './catalogo-liste-modello';
|
import { CatalogoListeModello } from './catalogo-liste-modello';
|
||||||
|
|
||||||
@@ -12,28 +11,52 @@ describe('CatalogoListeModello', () => {
|
|||||||
let component: CatalogoListeModello;
|
let component: CatalogoListeModello;
|
||||||
let fixture: ComponentFixture<CatalogoListeModello>;
|
let fixture: ComponentFixture<CatalogoListeModello>;
|
||||||
let tipiEventoApi: { getTipiEvento: ReturnType<typeof vi.fn> };
|
let tipiEventoApi: { getTipiEvento: ReturnType<typeof vi.fn> };
|
||||||
let listeModelloApi: { getListeModello: ReturnType<typeof vi.fn> };
|
let listeApi: { getListePubbliche: ReturnType<typeof vi.fn>; creaListaDaFork: ReturnType<typeof vi.fn> };
|
||||||
let listeApi: { creaListaDaModello: ReturnType<typeof vi.fn> };
|
|
||||||
let keycloak: { authenticated: boolean | undefined; login: ReturnType<typeof vi.fn> };
|
let keycloak: { authenticated: boolean | undefined; login: ReturnType<typeof vi.fn> };
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
const tipiEvento: TipoEvento[] = [
|
const tipiEvento: TipoEvento[] = [
|
||||||
{ id: 'te-1', nome: 'Uscita' },
|
{ id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' },
|
||||||
{ id: 'te-2', nome: 'Campo estivo' }
|
{ 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',
|
id: 'lm-1',
|
||||||
nome: 'Uscita di un giorno',
|
nome: 'Uscita di un giorno',
|
||||||
|
orgId: null,
|
||||||
|
stato: 'pubblico',
|
||||||
|
statoModerazione: 'approvato',
|
||||||
tipoEventoId: 'te-1',
|
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',
|
id: 'lm-2',
|
||||||
nome: 'Campo estivo standard',
|
nome: 'Campo estivo standard',
|
||||||
|
orgId: null,
|
||||||
|
stato: 'pubblico',
|
||||||
|
statoModerazione: 'approvato',
|
||||||
tipoEventoId: 'te-2',
|
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: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
|
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
|
||||||
{ provide: ListeModelloApiService, useValue: listeModelloApi },
|
|
||||||
{ provide: ListeApiService, useValue: listeApi },
|
{ provide: ListeApiService, useValue: listeApi },
|
||||||
{ provide: Keycloak, useValue: keycloak }
|
{ provide: Keycloak, useValue: keycloak }
|
||||||
]
|
]
|
||||||
@@ -53,6 +75,7 @@ describe('CatalogoListeModello', () => {
|
|||||||
|
|
||||||
router = TestBed.inject(Router);
|
router = TestBed.inject(Router);
|
||||||
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
|
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
|
||||||
|
vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
fixture = TestBed.createComponent(CatalogoListeModello);
|
fixture = TestBed.createComponent(CatalogoListeModello);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
@@ -62,12 +85,12 @@ describe('CatalogoListeModello', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
|
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
|
||||||
listeModelloApi = { getListeModello: vi.fn().mockReturnValue(of(listeModello)) };
|
listeApi = { getListePubbliche: vi.fn().mockReturnValue(of(listeModello)), creaListaDaFork: vi.fn() };
|
||||||
listeApi = { creaListaDaModello: 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() };
|
keycloak = { authenticated: false, login: vi.fn() };
|
||||||
await setup();
|
await setup();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -75,13 +98,108 @@ describe('CatalogoListeModello', () => {
|
|||||||
expect(component.loading()).toBe(false);
|
expect(component.loading()).toBe(false);
|
||||||
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
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');
|
const cards = compiled.querySelectorAll('.lista-modello-card');
|
||||||
expect(cards.length).toBe(2);
|
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 () => {
|
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');
|
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');
|
await component.usaComeBase('lm-1');
|
||||||
|
|
||||||
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
|
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
|
||||||
expect(listeApi.creaListaDaModello).not.toHaveBeenCalled();
|
expect(listeApi.creaListaDaFork).not.toHaveBeenCalled();
|
||||||
expect(router.navigateByUrl).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', () => {
|
describe('pulsante "Usa come base" — utente autenticato', () => {
|
||||||
@@ -132,25 +247,60 @@ describe('CatalogoListeModello', () => {
|
|||||||
fixture.detectChanges();
|
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', () => {
|
it('non mostra l\'avviso di accesso', () => {
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('.lista-modello-card__avviso')).toBeNull();
|
expect(compiled.querySelector('.lista-modello-card__avviso')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('chiama POST /liste/da-modello/:id e reindirizza alla lista privata creata', async () => {
|
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: 'org-1', creataIl: '2026-01-01', voci: [] };
|
const listaCreata: Lista = {
|
||||||
listeApi.creaListaDaModello.mockReturnValue(of(listaCreata));
|
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');
|
await component.usaComeBase('lm-1');
|
||||||
|
|
||||||
expect(keycloak.login).not.toHaveBeenCalled();
|
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(router.navigateByUrl).toHaveBeenCalledWith('/liste/lista-privata-1');
|
||||||
expect(component.creazioneInCorsoId()).toBeNull();
|
expect(component.creazioneInCorsoId()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mostra un messaggio di errore sulla lista interessata se la creazione fallisce', async () => {
|
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');
|
await component.usaComeBase('lm-1');
|
||||||
|
|
||||||
|
|||||||
+67
-17
@@ -5,13 +5,23 @@ import { Router, RouterLink } from '@angular/router';
|
|||||||
import Keycloak from 'keycloak-js';
|
import Keycloak from 'keycloak-js';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { ListeApiService } from '../liste-api.service';
|
import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service';
|
||||||
import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service';
|
|
||||||
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
|
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
|
||||||
|
import { VoceCheckStorageService } from '../voce-check-storage.service';
|
||||||
|
|
||||||
interface GruppoTipoEvento {
|
const VOCI_PREVIEW_LIMIT = 5;
|
||||||
tipoEvento: TipoEvento;
|
|
||||||
liste: ListaModello[];
|
// 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({
|
@Component({
|
||||||
@@ -22,26 +32,32 @@ interface GruppoTipoEvento {
|
|||||||
})
|
})
|
||||||
export class CatalogoListeModello implements OnInit {
|
export class CatalogoListeModello implements OnInit {
|
||||||
private readonly tipiEventoApi = inject(TipiEventoApiService);
|
private readonly tipiEventoApi = inject(TipiEventoApiService);
|
||||||
private readonly listeModelloApi = inject(ListeModelloApiService);
|
|
||||||
private readonly listeApi = inject(ListeApiService);
|
private readonly listeApi = inject(ListeApiService);
|
||||||
private readonly keycloak = inject(Keycloak);
|
private readonly keycloak = inject(Keycloak);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly voceCheckStorage = inject(VoceCheckStorageService);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly loadError = signal<string | null>(null);
|
readonly loadError = signal<string | null>(null);
|
||||||
readonly tipiEvento = signal<TipoEvento[]>([]);
|
readonly tipiEvento = signal<TipoEvento[]>([]);
|
||||||
readonly listeModello = signal<ListaModello[]>([]);
|
readonly listeModello = signal<Lista[]>([]);
|
||||||
readonly creazioneInCorsoId = signal<string | null>(null);
|
readonly creazioneInCorsoId = signal<string | null>(null);
|
||||||
readonly creazioneErroreId = signal<string | null>(null);
|
readonly creazioneErroreId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly gruppi = computed<GruppoTipoEvento[]>(() => {
|
readonly liste = computed<ListaModelloVista[]>(() => {
|
||||||
const liste = this.listeModello();
|
const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome]));
|
||||||
return this.tipiEvento()
|
return this.listeModello().map((lista) => {
|
||||||
.map((tipoEvento) => ({
|
const items: ItemAnteprima[] = [
|
||||||
tipoEvento,
|
...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })),
|
||||||
liste: liste.filter((lista) => lista.tipoEventoId === tipoEvento.id)
|
...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista }))
|
||||||
}))
|
];
|
||||||
.filter((gruppo) => gruppo.liste.length > 0);
|
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 {
|
get isAuthenticated(): boolean {
|
||||||
@@ -52,6 +68,40 @@ export class CatalogoListeModello implements OnInit {
|
|||||||
await this.carica();
|
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> {
|
async usaComeBase(listaModelloId: string): Promise<void> {
|
||||||
if (!this.isAuthenticated) {
|
if (!this.isAuthenticated) {
|
||||||
this.keycloak.login({ redirectUri: window.location.href });
|
this.keycloak.login({ redirectUri: window.location.href });
|
||||||
@@ -62,7 +112,7 @@ export class CatalogoListeModello implements OnInit {
|
|||||||
this.creazioneInCorsoId.set(listaModelloId);
|
this.creazioneInCorsoId.set(listaModelloId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lista = await firstValueFrom(this.listeApi.creaListaDaModello(listaModelloId));
|
const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId));
|
||||||
this.creazioneInCorsoId.set(null);
|
this.creazioneInCorsoId.set(null);
|
||||||
await this.router.navigateByUrl(`/liste/${lista.id}`);
|
await this.router.navigateByUrl(`/liste/${lista.id}`);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -77,7 +127,7 @@ export class CatalogoListeModello implements OnInit {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
|
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.tipiEvento.set(tipiEvento);
|
||||||
this.listeModello.set(listeModello);
|
this.listeModello.set(listeModello);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,12 +6,6 @@
|
|||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalogo-materiali__area-privata {
|
|
||||||
margin-bottom: var(--space-5);
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalogo-materiali__filtri {
|
.catalogo-materiali__filtri {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-5);
|
||||||
|
|||||||
@@ -4,21 +4,9 @@
|
|||||||
<h1 class="om-section-title">Catalogo materiali</h1>
|
<h1 class="om-section-title">Catalogo materiali</h1>
|
||||||
<p class="om-section-sub">Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.</p>
|
<p class="om-section-sub">Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.</p>
|
||||||
</div>
|
</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>
|
</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()) {
|
@if (loading()) {
|
||||||
<p class="om-empty">Caricamento catalogo…</p>
|
<p class="om-empty">Caricamento catalogo…</p>
|
||||||
} @else if (loadError(); as message) {
|
} @else if (loadError(); as message) {
|
||||||
|
|||||||
+1
-49
@@ -1,7 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import Keycloak from 'keycloak-js';
|
|
||||||
|
|
||||||
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
|
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
|
||||||
import { CatalogoMateriali } from './catalogo-materiali';
|
import { CatalogoMateriali } from './catalogo-materiali';
|
||||||
@@ -10,10 +9,6 @@ describe('CatalogoMateriali', () => {
|
|||||||
let component: CatalogoMateriali;
|
let component: CatalogoMateriali;
|
||||||
let fixture: ComponentFixture<CatalogoMateriali>;
|
let fixture: ComponentFixture<CatalogoMateriali>;
|
||||||
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
|
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
|
||||||
let keycloak: {
|
|
||||||
authenticated: boolean | undefined;
|
|
||||||
tokenParsed?: { realm_access?: { roles?: string[] } };
|
|
||||||
};
|
|
||||||
|
|
||||||
const materiali: MaterialePubblico[] = [
|
const materiali: MaterialePubblico[] = [
|
||||||
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
|
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
|
||||||
@@ -24,11 +19,7 @@ describe('CatalogoMateriali', () => {
|
|||||||
async function setup(): Promise<void> {
|
async function setup(): Promise<void> {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CatalogoMateriali],
|
imports: [CatalogoMateriali],
|
||||||
providers: [
|
providers: [provideRouter([]), { provide: MaterialiApiService, useValue: materialiApi }]
|
||||||
provideRouter([]),
|
|
||||||
{ provide: MaterialiApiService, useValue: materialiApi },
|
|
||||||
{ provide: Keycloak, useValue: keycloak }
|
|
||||||
]
|
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(CatalogoMateriali);
|
fixture = TestBed.createComponent(CatalogoMateriali);
|
||||||
@@ -38,10 +29,6 @@ describe('CatalogoMateriali', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
keycloak = { authenticated: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
|
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
|
||||||
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
|
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
|
||||||
|
|
||||||
@@ -89,39 +76,4 @@ describe('CatalogoMateriali', () => {
|
|||||||
expect(component.loading()).toBe(false);
|
expect(component.loading()).toBe(false);
|
||||||
expect(component.loadError()).toBe('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
|
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 { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import Keycloak from 'keycloak-js';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
|
|
||||||
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
|
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -16,7 +14,6 @@ import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service
|
|||||||
})
|
})
|
||||||
export class CatalogoMateriali implements OnInit {
|
export class CatalogoMateriali implements OnInit {
|
||||||
private readonly materialiApi = inject(MaterialiApiService);
|
private readonly materialiApi = inject(MaterialiApiService);
|
||||||
private readonly keycloak = inject(Keycloak);
|
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly loadError = signal<string | null>(null);
|
readonly loadError = signal<string | null>(null);
|
||||||
@@ -34,14 +31,6 @@ export class CatalogoMateriali implements OnInit {
|
|||||||
return categoria ? materiali.filter((materiale) => materiale.categoria === categoria) : materiali;
|
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> {
|
async ngOnInit(): Promise<void> {
|
||||||
await this.caricaMateriali();
|
await this.caricaMateriali();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
import { requireAuthGuard } from '../core/require-auth.guard';
|
|
||||||
|
|
||||||
export const CATALOGO_ROUTES: Routes = [
|
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;
|
// Pubblica: le liste modello sono consultabili da chi non è autenticato;
|
||||||
// solo la creazione della lista privata a partire dal modello richiede il login.
|
// solo la creazione della lista privata a partire dal modello richiede il login.
|
||||||
path: 'liste-modello',
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello)
|
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Privata: proporre un materiale richiede un'org sul token (POST /materiali/proposte).
|
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
|
||||||
path: 'proponi-materiale',
|
path: 'catalogo-materiali',
|
||||||
loadComponent: () => import('./proponi-materiale/proponi-materiale').then((m) => m.ProponiMateriale),
|
loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali)
|
||||||
canActivate: [requireAuthGuard]
|
},
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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;
|
||||||
|
}
|
||||||
+80
@@ -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>
|
||||||
|
}
|
||||||
+212
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+116
@@ -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 { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
@@ -17,6 +17,12 @@ export interface ProponiMaterialeInput {
|
|||||||
unitaMisura: string;
|
unitaMisura: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MaterialeInput {
|
||||||
|
nome: string;
|
||||||
|
categoria: string;
|
||||||
|
unitaMisura: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MaterialeProposta {
|
export interface MaterialeProposta {
|
||||||
id: string;
|
id: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
@@ -31,11 +37,24 @@ export interface MaterialeProposta {
|
|||||||
export class MaterialiApiService {
|
export class MaterialiApiService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
getMateriali(): Observable<MaterialePubblico[]> {
|
getMateriali(nome?: string): Observable<MaterialePubblico[]> {
|
||||||
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`);
|
const params = nome ? new HttpParams().set('nome', nome) : undefined;
|
||||||
|
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
proponiMateriale(input: ProponiMaterialeInput): Observable<MaterialeProposta> {
|
proponiMateriale(input: ProponiMaterialeInput): Observable<MaterialeProposta> {
|
||||||
return this.http.post<MaterialeProposta>(`${environment.magazzinoApiBaseUrl}/materiali/proposte`, input);
|
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 { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export type StatoTipoEvento = 'confermata' | 'da_approvare';
|
||||||
|
|
||||||
export interface TipoEvento {
|
export interface TipoEvento {
|
||||||
id: string;
|
id: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
|
stato: StatoTipoEvento;
|
||||||
|
creatoDaOrgId: string | null;
|
||||||
|
creatoIl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TipiEventoApiService {
|
export class TipiEventoApiService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
getTipiEvento(): Observable<TipoEvento[]> {
|
// Pubblico: solo i tipi evento confermati (catalogo liste, autocomplete wizard).
|
||||||
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento`);
|
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;
|
nome: string;
|
||||||
unitaMisura: string;
|
unitaMisura: string;
|
||||||
quantita: number;
|
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 {
|
export interface Lista {
|
||||||
id: string;
|
id: string;
|
||||||
nome: 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;
|
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[];
|
voci: ListaVoce[];
|
||||||
|
sottoListe: SottoLista[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListaVoceInput {
|
export interface ListaVoceInput {
|
||||||
@@ -24,9 +48,28 @@ export interface ListaVoceInput {
|
|||||||
quantita: number;
|
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 {
|
export interface AggiornaListaInput {
|
||||||
nome?: string;
|
nome?: string;
|
||||||
|
// Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in
|
||||||
|
// edit, non solo alla creazione.
|
||||||
|
stato?: string;
|
||||||
voci?: ListaVoceInput[];
|
voci?: ListaVoceInput[];
|
||||||
|
sottoListeIds?: string[];
|
||||||
|
sottoListeModelloIds?: string[];
|
||||||
|
// undefined = non toccare il campo, null = rimuovi il tipo evento associato.
|
||||||
|
tipoEventoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -37,8 +80,26 @@ export class ListeApiService {
|
|||||||
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste`);
|
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste`);
|
||||||
}
|
}
|
||||||
|
|
||||||
creaListaVuota(nome: string): Observable<Lista> {
|
// Catalogo pubblico: liste già approvate dalla moderazione, usabili come base
|
||||||
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste`, { nome });
|
// ("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> {
|
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;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: baseline;
|
||||||
gap: var(--space-4);
|
justify-content: space-between;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste-list__field {
|
.page-title {
|
||||||
flex: 1;
|
font-size: 30px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste-list__campo-errore {
|
.page-title--sm {
|
||||||
margin: 4px 0 0;
|
font-size: 28px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-accent-800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste-list__error {
|
.liste-list__error {
|
||||||
@@ -20,21 +30,95 @@
|
|||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste-list__elenco {
|
.lista {
|
||||||
margin-top: var(--space-5);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste-list__voce {
|
.riga {
|
||||||
text-decoration: none;
|
background: var(--color-surface);
|
||||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
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 {
|
.riga-info {
|
||||||
box-shadow: var(--shadow-sm);
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liste-list__voci-count {
|
.riga-titolo {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 17px;
|
||||||
color: var(--color-text);
|
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-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">
|
<div class="page">
|
||||||
<h1 class="om-section-title">Le tue liste</h1>
|
<div class="page-header">
|
||||||
<p class="om-section-sub">Liste materiale della tua organizzazione.</p>
|
<h1 class="page-title page-title--sm">{{ titolo() }}</h1>
|
||||||
|
<a class="btn btn-primary" routerLink="/liste/nuova" [queryParams]="nuovaListaQueryParams">+ Nuova lista</a>
|
||||||
<form class="liste-list__form" (submit)="$event.preventDefault(); creaLista()" novalidate>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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()) {
|
@if (loading()) {
|
||||||
<p class="om-empty">Caricamento liste…</p>
|
<p class="om-empty">Caricamento liste…</p>
|
||||||
} @else if (loadError(); as message) {
|
} @else if (loadError(); as message) {
|
||||||
<p class="liste-list__error" role="alert">{{ message }}</p>
|
<p class="liste-list__error" role="alert">{{ message }}</p>
|
||||||
} @else if (liste().length === 0) {
|
} @else if (liste().length > 0) {
|
||||||
<p class="om-empty">Non hai ancora nessuna lista. Creane una qui sopra.</p>
|
<div class="lista">
|
||||||
} @else {
|
|
||||||
<div class="om-grid liste-list__elenco">
|
|
||||||
@for (lista of liste(); track lista.id) {
|
@for (lista of liste(); track lista.id) {
|
||||||
<a class="card liste-list__voce" [routerLink]="['/liste', lista.id]">
|
<div class="riga">
|
||||||
<div class="card-title">{{ lista.nome }}</div>
|
<div class="riga-info">
|
||||||
<span class="liste-list__voci-count">{{ lista.voci.length }} voci</span>
|
<a class="riga-titolo" [routerLink]="dettaglioLink(lista)">{{ lista.nome }}</a>
|
||||||
</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>
|
||||||
|
}
|
||||||
|
@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>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📋</div>
|
||||||
|
<div class="empty-title">{{ titoloVuoto() }}</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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 { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
import { Lista, ListeApiService } from '../liste-api.service';
|
import { Lista, ListeApiService } from '../liste-api.service';
|
||||||
@@ -8,29 +8,73 @@ import { ListeList } from './liste-list';
|
|||||||
describe('ListeList', () => {
|
describe('ListeList', () => {
|
||||||
let component: ListeList;
|
let component: ListeList;
|
||||||
let fixture: ComponentFixture<ListeList>;
|
let fixture: ComponentFixture<ListeList>;
|
||||||
let listeApi: { getListe: ReturnType<typeof vi.fn>; creaListaVuota: ReturnType<typeof vi.fn> };
|
let listeApi: { getListe: ReturnType<typeof vi.fn> };
|
||||||
let router: Router;
|
|
||||||
|
|
||||||
const liste: Lista[] = [
|
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',
|
id: 'lista-2',
|
||||||
nome: 'Uscita di un giorno',
|
nome: 'Uscita di un giorno',
|
||||||
orgId: 'org-1',
|
orgId: 'org-1',
|
||||||
|
stato: 'pubblico',
|
||||||
|
statoModerazione: 'approvato',
|
||||||
|
tipoEventoId: null,
|
||||||
|
parentId: null,
|
||||||
creataIl: '2026-01-02',
|
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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ListeList],
|
imports: [ListeList],
|
||||||
providers: [provideRouter([]), { provide: ListeApiService, useValue: listeApi }]
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ListeApiService, useValue: listeApi },
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { data: { scope } } } }
|
||||||
|
]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
router = TestBed.inject(Router);
|
|
||||||
vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ListeList);
|
fixture = TestBed.createComponent(ListeList);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -38,73 +82,98 @@ describe('ListeList', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('mostra l\'elenco delle liste dopo il caricamento', async () => {
|
it('mostra le liste personali più le proprie liste di gruppo, escludendo quelle di gruppo altrui', async () => {
|
||||||
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)), creaListaVuota: vi.fn() };
|
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
|
||||||
|
|
||||||
await setup();
|
await setup();
|
||||||
|
|
||||||
expect(component.loading()).toBe(false);
|
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 compiled = fixture.nativeElement as HTMLElement;
|
||||||
const links = compiled.querySelectorAll('.liste-list__elenco a');
|
const links = compiled.querySelectorAll('.riga-titolo');
|
||||||
expect(links.length).toBe(2);
|
expect(links.length).toBe(3);
|
||||||
expect(links[0].getAttribute('href')).toBe('/liste/lista-1');
|
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 () => {
|
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();
|
await setup();
|
||||||
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
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 () => {
|
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();
|
await setup();
|
||||||
|
|
||||||
expect(component.loadError()).toBe('Impossibile caricare le tue liste. Riprova più tardi.');
|
expect(component.loadError()).toBe('Impossibile caricare le 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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,81 +1,125 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
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 { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { Lista, ListeApiService } from '../liste-api.service';
|
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({
|
@Component({
|
||||||
selector: 'app-liste-list',
|
selector: 'app-liste-list',
|
||||||
imports: [
|
imports: [RouterLink],
|
||||||
ReactiveFormsModule,
|
|
||||||
RouterLink,
|
|
||||||
MatButtonModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatProgressSpinnerModule
|
|
||||||
],
|
|
||||||
templateUrl: './liste-list.html',
|
templateUrl: './liste-list.html',
|
||||||
styleUrl: './liste-list.css'
|
styleUrl: './liste-list.css'
|
||||||
})
|
})
|
||||||
export class ListeList implements OnInit {
|
export class ListeList implements OnInit {
|
||||||
private readonly listeApi = inject(ListeApiService);
|
private readonly listeApi = inject(ListeApiService);
|
||||||
private readonly router = inject(Router);
|
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 loading = signal(true);
|
||||||
readonly loadError = signal<string | null>(null);
|
readonly loadError = signal<string | null>(null);
|
||||||
readonly liste = signal<Lista[]>([]);
|
readonly liste = signal<Lista[]>([]);
|
||||||
|
readonly stati = STATI_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);
|
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
await this.caricaListe();
|
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> {
|
private async caricaListe(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.loadError.set(null);
|
this.loadError.set(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const liste = await firstValueFrom(this.listeApi.getListe());
|
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 {
|
} catch {
|
||||||
this.loadError.set('Impossibile caricare le tue liste. Riprova più tardi.');
|
this.loadError.set('Impossibile caricare le liste. Riprova più tardi.');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading.set(false);
|
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: '',
|
path: '',
|
||||||
loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList),
|
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]
|
canActivate: [requireAuthGuard]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: ':id',
|
||||||
loadComponent: () => import('./lista-editor/lista-editor').then((m) => m.ListaEditor),
|
loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista),
|
||||||
canActivate: [requireAuthGuard]
|
canActivate: [requireAuthGuard]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user