Add scouthub-attivit-be
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
coverage
|
||||
.git
|
||||
.idea
|
||||
@@ -0,0 +1,13 @@
|
||||
PORT=8080
|
||||
CORS_ORIGIN=http://localhost:4200
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub?schema=public
|
||||
|
||||
# Keycloak (realm condiviso con scouthub-home-be/-fe, vedi keycloak/realm-export.json
|
||||
# alla radice del monorepo): usato solo per verificare i token, nessun client
|
||||
# id/secret necessario qui.
|
||||
KEYCLOAK_BASE_URL=http://localhost:8081
|
||||
KEYCLOAK_REALM=scouthub
|
||||
|
||||
# Database dedicato ai test automatici (Jest): tenuto separato dal DB di sviluppo
|
||||
# perche' la suite lo pulisce (TRUNCATE) tra un test e l'altro.
|
||||
DATABASE_URL_TEST=postgresql://postgres:postgres@localhost:5432/scouthub_test?schema=public
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
/attivita-be.iml
|
||||
/.idea/
|
||||
/bash.exe.stackdump
|
||||
@@ -0,0 +1,240 @@
|
||||
-- **************************************************** stato_attivita
|
||||
|
||||
create table stato_attivita
|
||||
(
|
||||
id varchar(2) not null primary key,
|
||||
nome varchar(250) not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null
|
||||
);
|
||||
|
||||
-- **************************************************** tipo_categoria
|
||||
|
||||
create table tipo_categoria
|
||||
(
|
||||
id varchar(2) not null primary key,
|
||||
nome varchar(250) not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null
|
||||
);
|
||||
|
||||
-- **************************************************** tipo_paragrafo
|
||||
|
||||
create table tipo_paragrafo
|
||||
(
|
||||
id varchar(10) not null primary key,
|
||||
nome varchar(250) not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null
|
||||
);
|
||||
|
||||
-- **************************************************** attivita
|
||||
|
||||
create table attivita
|
||||
(
|
||||
id int not null primary key auto_increment,
|
||||
nome varchar(250) not null,
|
||||
autore varchar(250) not null,
|
||||
padre int null,
|
||||
stato varchar(2) not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
constraint fk_attivita_attivita foreign key (padre) references attivita (id),
|
||||
constraint fk_attivita_stato_attivita foreign key (stato) references stato_attivita (id)
|
||||
);
|
||||
|
||||
create index fk_attivita_attivita_idx on attivita (padre);
|
||||
|
||||
create index fk_attivita_stato_attivita_idx on attivita (stato);
|
||||
|
||||
-- **************************************************** branca
|
||||
|
||||
create table branca
|
||||
(
|
||||
id int not null primary key auto_increment,
|
||||
nome varchar(250) not null,
|
||||
inizio_eta int null,
|
||||
fine_eta int null,
|
||||
colore varchar(10) null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null
|
||||
);
|
||||
|
||||
-- **************************************************** branca_attivita
|
||||
|
||||
create table branca_attivita
|
||||
(
|
||||
attivita_id int not null,
|
||||
branca_id int not null,
|
||||
cancellato tinyint not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
primary key (attivita_id, branca_id),
|
||||
|
||||
constraint fk_branca_attivita_attivita foreign key (attivita_id) references attivita (id),
|
||||
constraint fk_branca_attivita_branca foreign key (branca_id) references branca (id)
|
||||
);
|
||||
|
||||
create index fk_branca_attivita_attivita_idx on branca_attivita (attivita_id);
|
||||
|
||||
create index fk_branca_attivita_branca_idx on branca_attivita (branca_id);
|
||||
|
||||
-- **************************************************** periodo_anno
|
||||
|
||||
create table periodo_anno
|
||||
(
|
||||
id int not null primary key auto_increment,
|
||||
nome varchar(250) not null,
|
||||
inizio_mese int null,
|
||||
fine_mese int null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null
|
||||
);
|
||||
|
||||
-- **************************************************** periodo_anno_attivita
|
||||
|
||||
create table periodo_anno_attivita
|
||||
(
|
||||
attivita_id int not null,
|
||||
periodo_anno_id int not null,
|
||||
cancellato tinyint not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
primary key (attivita_id, periodo_anno_id),
|
||||
|
||||
constraint fk_periodo_anno_attivita_attivita foreign key (attivita_id) references attivita (id),
|
||||
constraint fk_periodo_anno_attivita_periodo_anno foreign key (periodo_anno_id) references periodo_anno (id)
|
||||
);
|
||||
|
||||
create index fk_periodo_anno_attivita_attivita_idx on periodo_anno_attivita (attivita_id);
|
||||
|
||||
create index fkperiodo_anno_attivita_periodo_anno_idx on periodo_anno_attivita (periodo_anno_id);
|
||||
|
||||
-- **************************************************** paragrafo
|
||||
|
||||
create table paragrafo
|
||||
(
|
||||
id int not null primary key auto_increment,
|
||||
attivita_id int not null,
|
||||
corpo varchar(500) not null,
|
||||
autore varchar(250) not null,
|
||||
tipo varchar(10) not null,
|
||||
ordine int not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
constraint fk_paragrafo_tipo_paragrafo foreign key (tipo) references tipo_paragrafo (id),
|
||||
constraint fk_paragrafo_attivita foreign key (attivita_id) references attivita (id)
|
||||
);
|
||||
|
||||
create index fk_paragrafo_tipo_paragrafo_idx on paragrafo (tipo);
|
||||
|
||||
-- **************************************************** categoria
|
||||
|
||||
create table categoria
|
||||
(
|
||||
id int not null primary key auto_increment,
|
||||
nome varchar(250) not null,
|
||||
padre int null,
|
||||
tipo varchar(2) not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
constraint fk_categoria_categoria foreign key (padre) references categoria (id),
|
||||
constraint fk_categoria_tipo_categoria foreign key (tipo) references tipo_categoria (id)
|
||||
);
|
||||
|
||||
create index fk_categoria_categoria_idx on categoria (padre);
|
||||
|
||||
create index fk_categoria_tipo_categoria_idx on categoria (tipo);
|
||||
|
||||
-- **************************************************** categoria_attivita
|
||||
|
||||
create table categoria_attivita
|
||||
(
|
||||
attivita_id int not null,
|
||||
categoria_id int not null,
|
||||
cancellato tinyint not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
primary key (attivita_id, categoria_id),
|
||||
|
||||
constraint fk_categoria_attivita_attivita foreign key (attivita_id) references attivita (id),
|
||||
constraint fk_categoria_attivita_categoria foreign key (categoria_id) references categoria (id)
|
||||
);
|
||||
|
||||
create index fk_categoria_attivita_attivita_idx on categoria_attivita (attivita_id);
|
||||
|
||||
create index fk_categoria_attivita_categoria_idx on categoria_attivita (categoria_id);
|
||||
|
||||
-- **************************************************** materiale
|
||||
|
||||
create table materiale
|
||||
(
|
||||
id int not null primary key auto_increment,
|
||||
nome varchar(250) not null,
|
||||
proprieta json,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null
|
||||
);
|
||||
|
||||
-- **************************************************** materiale_attivita
|
||||
|
||||
create table materiale_attivita
|
||||
(
|
||||
attivita_id int not null,
|
||||
materiale_id int not null,
|
||||
proprieta json null,
|
||||
cancellato tinyint not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
primary key (attivita_id, materiale_id),
|
||||
|
||||
constraint fk_materiale_attivita_attivita foreign key (attivita_id) references attivita (id),
|
||||
constraint fk_materiale_attivita_materiale foreign key (materiale_id) references materiale (id)
|
||||
);
|
||||
|
||||
create index fk_materiale_attivita_attivita_idx on materiale_attivita (attivita_id);
|
||||
|
||||
create index fk_materiale_attivita_materiale_idx on materiale_attivita (materiale_id);
|
||||
|
||||
-- **************************************************** categoria_materiale
|
||||
|
||||
create table categoria_materiale
|
||||
(
|
||||
materiale_id int not null,
|
||||
categoria_id int not null,
|
||||
cancellato tinyint not null,
|
||||
data_creazione datetime not null,
|
||||
data_modifica datetime not null,
|
||||
utente_modifica varchar(50) not null,
|
||||
|
||||
primary key (materiale_id, categoria_id),
|
||||
|
||||
constraint fk_categoria_materiale_categoria foreign key (categoria_id) references categoria (id),
|
||||
constraint fk_categoria_materiale_materiale foreign key (materiale_id) references materiale (id)
|
||||
);
|
||||
|
||||
create index fk_categoria_materiale_categoria_idx on categoria_materiale (categoria_id);
|
||||
|
||||
create index fk_categoria_materiale_materiale_idx on categoria_materiale (materiale_id);
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO scouthub.stato_attivita (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('PU', 'Pubblicato', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.stato_attivita (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('BO', 'Bozza', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.stato_attivita (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('PR', 'Privato', sysdate(), sysdate(), 'MANUALE');
|
||||
@@ -0,0 +1,2 @@
|
||||
INSERT INTO scouthub.tipo_categoria (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('A', 'Attività', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.tipo_categoria (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('M', 'Materiale', sysdate(), sysdate(), 'MANUALE');
|
||||
@@ -0,0 +1,2 @@
|
||||
INSERT INTO scouthub.tipo_paragrafo (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('TITOLO', 'Titolo attività', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.tipo_paragrafo (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('PARAGRAFO', 'Paragrafo attività', sysdate(), sysdate(), 'MANUALE');
|
||||
@@ -0,0 +1,4 @@
|
||||
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (1, 'L/C', 8, 10, '#FDD835', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (2, 'E/G', 11, 16, '#43A047', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (3, 'R/S', 17, 21, '#E53935', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (4, 'Co.Ca.', 22, 99, '#8E24AA', sysdate(), sysdate(), 'MANUALE');
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO scouthub.periodo_anno (id, nome, inizio_mese, fine_mese, data_creazione, data_modifica, utente_modifica) VALUES (1, 'promessa', 1, 3, sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.periodo_anno (id, nome, inizio_mese, fine_mese, data_creazione, data_modifica, utente_modifica) VALUES (2, 'campo estivo', 6, 9, sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.periodo_anno (id, nome, inizio_mese, fine_mese, data_creazione, data_modifica, utente_modifica) VALUES (3, 'campo invernale', 12, 1, sysdate(), sysdate(), 'MANUALE');
|
||||
@@ -0,0 +1,9 @@
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (1, 'attività', null, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (2, 'gioco', null, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (3, 'danza', null, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (4, 'gioco d''acqua', 2, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (5, 'gioco notturno', 2, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (6, 'grande gioco', 2, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (7, 'torneo', 2, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (8, 'olimpiadi', 2, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (9, 'gioco giungla', 2, 'A', sysdate(), sysdate(), 'MANUALE');
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
FROM node:18-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed && node dist/server.js"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# scouthub-attivita-be
|
||||
|
||||
Backend Node.js/TypeScript/Express/Prisma per la gestione di attivita scout —
|
||||
sostituisce il precedente backend Java/Spring Boot.
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
- Node.js 18+
|
||||
- Un'istanza PostgreSQL raggiungibile (locale o remota)
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
Copiare `.env.example` in `.env` e valorizzare le variabili richieste
|
||||
(`PORT`, `CORS_ORIGIN`, `DATABASE_URL`, `DATABASE_URL_TEST`). Vedere
|
||||
`.env.example` per il formato atteso e i valori di default usati in sviluppo.
|
||||
|
||||
## Sviluppo locale
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma migrate dev
|
||||
npm run db:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Il server si avvia sulla porta definita da `PORT` (default 8080) e risponde
|
||||
su `GET /health`.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Richiede `DATABASE_URL_TEST` configurato in `.env` e puntato a un database
|
||||
PostgreSQL dedicato ai test, con le migration gia' applicate: la suite
|
||||
esegue un `TRUNCATE` delle tabelle tra un test e l'altro, quindi va tenuto
|
||||
separato dal database di sviluppo.
|
||||
|
||||
## Note
|
||||
|
||||
- L'autenticazione reale non e' ancora implementata: l'autore delle attivita'
|
||||
e' ancora hardcoded a `"Movio"` nel router privato, e il middleware
|
||||
`authPlaceholder` e' un pass-through.
|
||||
- Porta e CORS sono gia' letti da variabili d'ambiente in previsione di un
|
||||
futuro `docker-compose.yml` (non incluso in questa feature).
|
||||
|
||||
## Riferimento storico
|
||||
|
||||
La cartella `DDL/` contiene gli script SQL dello schema originale del
|
||||
backend Java (MySQL), mantenuti come riferimento storico. Lo schema attuale
|
||||
(PostgreSQL) e' definito in `prisma/schema.prisma` e gestito tramite Prisma
|
||||
Migrate.
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
testMatch: ['<rootDir>/tests/unit/**/*.test.ts', '<rootDir>/tests/integration/**/*.test.ts'],
|
||||
globalSetup: '<rootDir>/tests/setup/globalSetup.ts',
|
||||
globalTeardown: '<rootDir>/tests/setup/globalTeardown.ts',
|
||||
testTimeout: 15000,
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Generated
+6908
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "scouthub-attivita-be",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Backend Node.js/TypeScript per la gestione di attivita scout",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"db:seed": "prisma db seed",
|
||||
"test": "jest --runInBand --passWithNoTests",
|
||||
"test:watch": "jest --watch --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.16.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^3.2.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"jest": "^30.4.2",
|
||||
"nock": "^13.5.4",
|
||||
"prisma": "^5.16.1",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "stato_attivita" (
|
||||
"id" VARCHAR(2) NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "stato_attivita_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tipo_categoria" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "tipo_categoria_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tipo_paragrafo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "tipo_paragrafo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "attivita" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"autore" TEXT NOT NULL,
|
||||
"padre" INTEGER,
|
||||
"stato" TEXT NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "attivita_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "branca" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"inizio_eta" INTEGER,
|
||||
"fine_eta" INTEGER,
|
||||
"colore" VARCHAR(10),
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "branca_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "branca_attivita" (
|
||||
"attivita_id" INTEGER NOT NULL,
|
||||
"branca_id" INTEGER NOT NULL,
|
||||
"cancellato" BOOLEAN NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "branca_attivita_pkey" PRIMARY KEY ("attivita_id","branca_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "periodo_anno" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"inizio_mese" INTEGER,
|
||||
"fine_mese" INTEGER,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "periodo_anno_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "periodo_anno_attivita" (
|
||||
"attivita_id" INTEGER NOT NULL,
|
||||
"periodo_anno_id" INTEGER NOT NULL,
|
||||
"cancellato" BOOLEAN NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "periodo_anno_attivita_pkey" PRIMARY KEY ("attivita_id","periodo_anno_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "paragrafo" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"attivita_id" INTEGER NOT NULL,
|
||||
"corpo" VARCHAR(500) NOT NULL,
|
||||
"autore" TEXT NOT NULL,
|
||||
"tipo" TEXT NOT NULL,
|
||||
"ordine" INTEGER NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "paragrafo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categoria" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"padre" INTEGER,
|
||||
"tipo" TEXT NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "categoria_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categoria_attivita" (
|
||||
"attivita_id" INTEGER NOT NULL,
|
||||
"categoria_id" INTEGER NOT NULL,
|
||||
"cancellato" BOOLEAN NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "categoria_attivita_pkey" PRIMARY KEY ("attivita_id","categoria_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "materiale" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"nome" TEXT NOT NULL,
|
||||
"proprieta" JSONB,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "materiale_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "materiale_attivita" (
|
||||
"attivita_id" INTEGER NOT NULL,
|
||||
"materiale_id" INTEGER NOT NULL,
|
||||
"proprieta" JSONB,
|
||||
"cancellato" BOOLEAN NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "materiale_attivita_pkey" PRIMARY KEY ("attivita_id","materiale_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categoria_materiale" (
|
||||
"materiale_id" INTEGER NOT NULL,
|
||||
"categoria_id" INTEGER NOT NULL,
|
||||
"cancellato" BOOLEAN NOT NULL,
|
||||
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data_modifica" TIMESTAMP(3) NOT NULL,
|
||||
"utente_modifica" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "categoria_materiale_pkey" PRIMARY KEY ("materiale_id","categoria_id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attivita" ADD CONSTRAINT "attivita_padre_fkey" FOREIGN KEY ("padre") REFERENCES "attivita"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attivita" ADD CONSTRAINT "attivita_stato_fkey" FOREIGN KEY ("stato") REFERENCES "stato_attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "branca_attivita" ADD CONSTRAINT "branca_attivita_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "branca_attivita" ADD CONSTRAINT "branca_attivita_branca_id_fkey" FOREIGN KEY ("branca_id") REFERENCES "branca"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "periodo_anno_attivita" ADD CONSTRAINT "periodo_anno_attivita_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "periodo_anno_attivita" ADD CONSTRAINT "periodo_anno_attivita_periodo_anno_id_fkey" FOREIGN KEY ("periodo_anno_id") REFERENCES "periodo_anno"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "paragrafo" ADD CONSTRAINT "paragrafo_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "paragrafo" ADD CONSTRAINT "paragrafo_tipo_fkey" FOREIGN KEY ("tipo") REFERENCES "tipo_paragrafo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categoria" ADD CONSTRAINT "categoria_padre_fkey" FOREIGN KEY ("padre") REFERENCES "categoria"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categoria" ADD CONSTRAINT "categoria_tipo_fkey" FOREIGN KEY ("tipo") REFERENCES "tipo_categoria"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categoria_attivita" ADD CONSTRAINT "categoria_attivita_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categoria_attivita" ADD CONSTRAINT "categoria_attivita_categoria_id_fkey" FOREIGN KEY ("categoria_id") REFERENCES "categoria"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materiale_attivita" ADD CONSTRAINT "materiale_attivita_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materiale_attivita" ADD CONSTRAINT "materiale_attivita_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categoria_materiale" ADD CONSTRAINT "categoria_materiale_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categoria_materiale" ADD CONSTRAINT "categoria_materiale_categoria_id_fkey" FOREIGN KEY ("categoria_id") REFERENCES "categoria"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
-- Le righe esistenti (dati di sviluppo/seed, precedenti all'introduzione
|
||||
-- dell'autenticazione Keycloak) non hanno un autore reale: valorizzate con
|
||||
-- stringa vuota, poi il default viene rimosso perche' da qui in avanti
|
||||
-- autoreId e' sempre valorizzato dal token (sub) al momento della creazione.
|
||||
ALTER TABLE "attivita" ADD COLUMN "autore_id" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "attivita" ALTER COLUMN "autore_id" DROP DEFAULT;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,223 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model StatoAttivita {
|
||||
id String @id @db.VarChar(2)
|
||||
nome String
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivita Attivita[]
|
||||
|
||||
@@map("stato_attivita")
|
||||
}
|
||||
|
||||
model TipoCategoria {
|
||||
id String @id
|
||||
nome String
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
categorie Categoria[]
|
||||
|
||||
@@map("tipo_categoria")
|
||||
}
|
||||
|
||||
model TipoParagrafo {
|
||||
id String @id
|
||||
nome String
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
paragrafi Paragrafo[]
|
||||
|
||||
@@map("tipo_paragrafo")
|
||||
}
|
||||
|
||||
model Attivita {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
autore String
|
||||
autoreId String @map("autore_id")
|
||||
padreId Int? @map("padre")
|
||||
statoId String @map("stato")
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
padre Attivita? @relation("AttivitaPadre", fields: [padreId], references: [id])
|
||||
figli Attivita[] @relation("AttivitaPadre")
|
||||
stato StatoAttivita @relation(fields: [statoId], references: [id])
|
||||
|
||||
paragrafi Paragrafo[]
|
||||
brancaLinks BrancaAttivita[]
|
||||
categoriaLinks CategoriaAttivita[]
|
||||
materialeLinks MaterialeAttivita[]
|
||||
periodoAnnoLinks PeriodoAnnoAttivita[]
|
||||
|
||||
@@map("attivita")
|
||||
}
|
||||
|
||||
model Branca {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
inizioEta Int? @map("inizio_eta")
|
||||
fineEta Int? @map("fine_eta")
|
||||
colore String? @db.VarChar(10)
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivitaLinks BrancaAttivita[]
|
||||
|
||||
@@map("branca")
|
||||
}
|
||||
|
||||
model BrancaAttivita {
|
||||
attivitaId Int @map("attivita_id")
|
||||
brancaId Int @map("branca_id")
|
||||
cancellato Boolean
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivita Attivita @relation(fields: [attivitaId], references: [id])
|
||||
branca Branca @relation(fields: [brancaId], references: [id])
|
||||
|
||||
@@id([attivitaId, brancaId])
|
||||
@@map("branca_attivita")
|
||||
}
|
||||
|
||||
model PeriodoAnno {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
inizioMese Int? @map("inizio_mese")
|
||||
fineMese Int? @map("fine_mese")
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivitaLinks PeriodoAnnoAttivita[]
|
||||
|
||||
@@map("periodo_anno")
|
||||
}
|
||||
|
||||
model PeriodoAnnoAttivita {
|
||||
attivitaId Int @map("attivita_id")
|
||||
periodoAnnoId Int @map("periodo_anno_id")
|
||||
cancellato Boolean
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivita Attivita @relation(fields: [attivitaId], references: [id])
|
||||
periodoAnno PeriodoAnno @relation(fields: [periodoAnnoId], references: [id])
|
||||
|
||||
@@id([attivitaId, periodoAnnoId])
|
||||
@@map("periodo_anno_attivita")
|
||||
}
|
||||
|
||||
model Paragrafo {
|
||||
id Int @id @default(autoincrement())
|
||||
attivitaId Int @map("attivita_id")
|
||||
corpo String @db.VarChar(500)
|
||||
autore String
|
||||
tipoId String @map("tipo")
|
||||
ordine Int
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivita Attivita @relation(fields: [attivitaId], references: [id])
|
||||
tipo TipoParagrafo @relation(fields: [tipoId], references: [id])
|
||||
|
||||
@@map("paragrafo")
|
||||
}
|
||||
|
||||
model Categoria {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
padreId Int? @map("padre")
|
||||
tipoId String @map("tipo")
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
padre Categoria? @relation("CategoriaPadre", fields: [padreId], references: [id])
|
||||
figli Categoria[] @relation("CategoriaPadre")
|
||||
tipo TipoCategoria @relation(fields: [tipoId], references: [id])
|
||||
|
||||
attivitaLinks CategoriaAttivita[]
|
||||
materialeLinks CategoriaMateriale[]
|
||||
|
||||
@@map("categoria")
|
||||
}
|
||||
|
||||
model CategoriaAttivita {
|
||||
attivitaId Int @map("attivita_id")
|
||||
categoriaId Int @map("categoria_id")
|
||||
cancellato Boolean
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivita Attivita @relation(fields: [attivitaId], references: [id])
|
||||
categoria Categoria @relation(fields: [categoriaId], references: [id])
|
||||
|
||||
@@id([attivitaId, categoriaId])
|
||||
@@map("categoria_attivita")
|
||||
}
|
||||
|
||||
model Materiale {
|
||||
id Int @id @default(autoincrement())
|
||||
nome String
|
||||
proprieta Json?
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivitaLinks MaterialeAttivita[]
|
||||
categoriaLinks CategoriaMateriale[]
|
||||
|
||||
@@map("materiale")
|
||||
}
|
||||
|
||||
model MaterialeAttivita {
|
||||
attivitaId Int @map("attivita_id")
|
||||
materialeId Int @map("materiale_id")
|
||||
proprieta Json?
|
||||
cancellato Boolean
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
attivita Attivita @relation(fields: [attivitaId], references: [id])
|
||||
materiale Materiale @relation(fields: [materialeId], references: [id])
|
||||
|
||||
@@id([attivitaId, materialeId])
|
||||
@@map("materiale_attivita")
|
||||
}
|
||||
|
||||
model CategoriaMateriale {
|
||||
materialeId Int @map("materiale_id")
|
||||
categoriaId Int @map("categoria_id")
|
||||
cancellato Boolean
|
||||
dataCreazione DateTime @default(now()) @map("data_creazione")
|
||||
dataModifica DateTime @updatedAt @map("data_modifica")
|
||||
utenteModifica String @map("utente_modifica")
|
||||
|
||||
materiale Materiale @relation(fields: [materialeId], references: [id])
|
||||
categoria Categoria @relation(fields: [categoriaId], references: [id])
|
||||
|
||||
@@id([materialeId, categoriaId])
|
||||
@@map("categoria_materiale")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const UTENTE_MODIFICA = "MANUALE";
|
||||
|
||||
async function main() {
|
||||
await Promise.all(
|
||||
[
|
||||
{ id: "PU", nome: "Pubblicato" },
|
||||
{ id: "BO", nome: "Bozza" },
|
||||
{ id: "PR", nome: "Privato" },
|
||||
].map((stato) =>
|
||||
prisma.statoAttivita.upsert({
|
||||
where: { id: stato.id },
|
||||
update: { nome: stato.nome, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...stato, utenteModifica: UTENTE_MODIFICA },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
{ id: "A", nome: "Attività" },
|
||||
{ id: "M", nome: "Materiale" },
|
||||
].map((tipo) =>
|
||||
prisma.tipoCategoria.upsert({
|
||||
where: { id: tipo.id },
|
||||
update: { nome: tipo.nome, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...tipo, utenteModifica: UTENTE_MODIFICA },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
{ id: "TITOLO", nome: "Titolo attività" },
|
||||
{ id: "PARAGRAFO", nome: "Paragrafo attività" },
|
||||
].map((tipo) =>
|
||||
prisma.tipoParagrafo.upsert({
|
||||
where: { id: tipo.id },
|
||||
update: { nome: tipo.nome, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...tipo, utenteModifica: UTENTE_MODIFICA },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
{ id: 1, nome: "L/C", inizioEta: 8, fineEta: 10, colore: "#FDD835" },
|
||||
{ id: 2, nome: "E/G", inizioEta: 11, fineEta: 16, colore: "#43A047" },
|
||||
{ id: 3, nome: "R/S", inizioEta: 17, fineEta: 21, colore: "#E53935" },
|
||||
{ id: 4, nome: "Co.Ca.", inizioEta: 22, fineEta: 99, colore: "#8E24AA" },
|
||||
].map((branca) =>
|
||||
prisma.branca.upsert({
|
||||
where: { id: branca.id },
|
||||
update: { ...branca, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...branca, utenteModifica: UTENTE_MODIFICA },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
{ id: 1, nome: "promessa", inizioMese: 1, fineMese: 3 },
|
||||
{ id: 2, nome: "campo estivo", inizioMese: 6, fineMese: 9 },
|
||||
{ id: 3, nome: "campo invernale", inizioMese: 12, fineMese: 1 },
|
||||
].map((periodo) =>
|
||||
prisma.periodoAnno.upsert({
|
||||
where: { id: periodo.id },
|
||||
update: { ...periodo, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...periodo, utenteModifica: UTENTE_MODIFICA },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const categorieSenzaPadre = [
|
||||
{ id: 1, nome: "attività", tipoId: "A" },
|
||||
{ id: 2, nome: "gioco", tipoId: "A" },
|
||||
{ id: 3, nome: "danza", tipoId: "A" },
|
||||
];
|
||||
for (const categoria of categorieSenzaPadre) {
|
||||
await prisma.categoria.upsert({
|
||||
where: { id: categoria.id },
|
||||
update: { ...categoria, padreId: null, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...categoria, utenteModifica: UTENTE_MODIFICA },
|
||||
});
|
||||
}
|
||||
|
||||
const categorieConPadre = [
|
||||
{ id: 4, nome: "gioco d'acqua", tipoId: "A", padreId: 2 },
|
||||
{ id: 5, nome: "gioco notturno", tipoId: "A", padreId: 2 },
|
||||
{ id: 6, nome: "grande gioco", tipoId: "A", padreId: 2 },
|
||||
{ id: 7, nome: "torneo", tipoId: "A", padreId: 2 },
|
||||
{ id: 8, nome: "olimpiadi", tipoId: "A", padreId: 2 },
|
||||
{ id: 9, nome: "gioco giungla", tipoId: "A", padreId: 2 },
|
||||
];
|
||||
for (const categoria of categorieConPadre) {
|
||||
await prisma.categoria.upsert({
|
||||
where: { id: categoria.id },
|
||||
update: { ...categoria, utenteModifica: UTENTE_MODIFICA },
|
||||
create: { ...categoria, utenteModifica: UTENTE_MODIFICA },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { env } from './config/env';
|
||||
import { authenticate } from './middlewares/authenticate';
|
||||
import { errorHandler } from './middlewares/errorHandler';
|
||||
import { attivitaPublicRouter } from './modules/attivita/attivita.router.public';
|
||||
import { attivitaPrivateRouter } from './modules/attivita/attivita.router.private';
|
||||
import { autocompleteRouter } from './modules/autocomplete/autocomplete.router';
|
||||
|
||||
export const app = express();
|
||||
|
||||
// strict: false perché gli endpoint di autocomplete (vedi extractKeyword)
|
||||
// accettano 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({ origin: env.corsOrigin }));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/public/attivita', attivitaPublicRouter);
|
||||
app.use('/private/attivita', authenticate, attivitaPrivateRouter);
|
||||
app.use('/public/autocomplete', autocompleteRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ message: 'not found' });
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
@@ -0,0 +1,21 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Variabile d'ambiente mancante: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT) || 8080,
|
||||
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:4200',
|
||||
databaseUrl: requireEnv('DATABASE_URL'),
|
||||
keycloak: {
|
||||
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
|
||||
realm: requireEnv('KEYCLOAK_REALM'),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.__prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
name: string;
|
||||
roles: string[];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import jwksClient from 'jwks-rsa';
|
||||
import { env } from '../config/env';
|
||||
import { AuthContext } from './auth.types';
|
||||
|
||||
const client = jwksClient({
|
||||
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
});
|
||||
|
||||
interface KeycloakTokenPayload extends JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
realm_access?: { roles?: string[] };
|
||||
}
|
||||
|
||||
function extractBearerToken(req: Request): string | null {
|
||||
const header = req.headers.authorization;
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email ?? null,
|
||||
name: payload.name ?? payload.preferred_username ?? payload.email ?? payload.sub,
|
||||
roles: payload.realm_access?.roles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ message: 'Token mancante' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || !decoded.header.kid) {
|
||||
throw new Error("Header del token privo di 'kid'");
|
||||
}
|
||||
|
||||
const signingKey = await client.getSigningKey(decoded.header.kid);
|
||||
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
throw new Error('Payload del token non valido');
|
||||
}
|
||||
|
||||
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: 'Token non valido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
|
||||
const hasStatusCode =
|
||||
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
|
||||
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
|
||||
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { HttpError } from '../../errors';
|
||||
import { attivitaSaveSchema } from '../../types/validation';
|
||||
import * as attivitaService from './attivita.service';
|
||||
|
||||
function asyncHandler(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
handler(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export const attivitaPrivateRouter = Router();
|
||||
|
||||
attivitaPrivateRouter.get(
|
||||
'/get/lista/my',
|
||||
asyncHandler(async (req, res) => {
|
||||
const lista = await attivitaService.getListMy(req.auth!.userId);
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
|
||||
attivitaPrivateRouter.get(
|
||||
'/change/stato/:idAttivita/:idStato',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const idAttivita = Number(req.params.idAttivita);
|
||||
if (!Number.isInteger(idAttivita)) {
|
||||
next(new HttpError(400, "l'idAttivita deve essere un numero intero"));
|
||||
return;
|
||||
}
|
||||
|
||||
const idStato = req.params.idStato;
|
||||
const stato = await attivitaService.changeStato(idAttivita, idStato, req.auth!);
|
||||
res.status(200).json(stato);
|
||||
}),
|
||||
);
|
||||
|
||||
attivitaPrivateRouter.post(
|
||||
'/save',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const parsed = attivitaSaveSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
|
||||
return;
|
||||
}
|
||||
|
||||
await attivitaService.save(parsed.data, req.auth!);
|
||||
res.status(200).end();
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '../../errors';
|
||||
import * as attivitaService from './attivita.service';
|
||||
|
||||
const searchObjectSchema = z.array(
|
||||
z.object({
|
||||
id: z.number().nullable(),
|
||||
nome: z.string().nullable(),
|
||||
gruppo: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
function asyncHandler(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
handler(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export const attivitaPublicRouter = Router();
|
||||
|
||||
attivitaPublicRouter.get(
|
||||
'/get/lista/home',
|
||||
asyncHandler(async (req, res) => {
|
||||
const lista = await attivitaService.getListaHome();
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
|
||||
attivitaPublicRouter.post(
|
||||
'/get/lista/search',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const parsed = searchObjectSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
next(new HttpError(400, 'body non valido: atteso un array di SearchObjectDto'));
|
||||
return;
|
||||
}
|
||||
|
||||
const lista = await attivitaService.getListaSearch(parsed.data);
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
|
||||
attivitaPublicRouter.get(
|
||||
'/get/one/:id',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
next(new HttpError(400, "l'id deve essere un numero intero"));
|
||||
return;
|
||||
}
|
||||
|
||||
const attivita = await attivitaService.getOne(id);
|
||||
if (!attivita) {
|
||||
next(new HttpError(404, 'attività non trovata'));
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(attivita);
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,560 @@
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../../db/prisma';
|
||||
import { HttpError } from '../../errors';
|
||||
import { AuthContext } from '../../middlewares/auth.types';
|
||||
import {
|
||||
AttivitaDto,
|
||||
BrancaDto,
|
||||
CategoriaDto,
|
||||
MaterialeDto,
|
||||
ParagrafoDto,
|
||||
PeriodoAnnoDto,
|
||||
SearchObjectDto,
|
||||
TipologicaDto,
|
||||
} from '../../types/dto';
|
||||
import { AttivitaSaveInput } from '../../types/validation';
|
||||
|
||||
const attivitaInclude = {
|
||||
stato: true,
|
||||
brancaLinks: { include: { branca: true } },
|
||||
categoriaLinks: { include: { categoria: { include: { tipo: true } } } },
|
||||
materialeLinks: { include: { materiale: true } },
|
||||
periodoAnnoLinks: { include: { periodoAnno: true } },
|
||||
paragrafi: { include: { tipo: true } },
|
||||
} satisfies Prisma.AttivitaInclude;
|
||||
|
||||
type AttivitaWithRelations = Prisma.AttivitaGetPayload<{ include: typeof attivitaInclude }>;
|
||||
|
||||
function toTipologicaDto(entity: { id: string; nome: string }): TipologicaDto {
|
||||
return { id: entity.id, nome: entity.nome };
|
||||
}
|
||||
|
||||
function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
|
||||
const brancaList: BrancaDto[] = entity.brancaLinks
|
||||
.filter((link) => !link.cancellato)
|
||||
.map((link) => ({
|
||||
id: link.branca.id,
|
||||
nome: link.branca.nome,
|
||||
inizioEta: link.branca.inizioEta,
|
||||
fineEta: link.branca.fineEta,
|
||||
colore: link.branca.colore,
|
||||
cancellato: false,
|
||||
dataCreazione: link.branca.dataCreazione,
|
||||
dataModifica: link.branca.dataModifica,
|
||||
utenteModifica: link.branca.utenteModifica,
|
||||
}));
|
||||
|
||||
const categoriaList: CategoriaDto[] = entity.categoriaLinks
|
||||
.filter((link) => !link.cancellato)
|
||||
.map((link) => ({
|
||||
id: link.categoria.id,
|
||||
nome: link.categoria.nome,
|
||||
padre: link.categoria.padreId,
|
||||
tipo: toTipologicaDto(link.categoria.tipo),
|
||||
cancellato: false,
|
||||
dataCreazione: link.categoria.dataCreazione,
|
||||
dataModifica: link.categoria.dataModifica,
|
||||
utenteModifica: link.categoria.utenteModifica,
|
||||
}));
|
||||
|
||||
const materialeList: MaterialeDto[] = entity.materialeLinks
|
||||
.filter((link) => !link.cancellato)
|
||||
.map((link) => ({
|
||||
id: link.materiale.id,
|
||||
nome: link.materiale.nome,
|
||||
proprieta: link.proprieta,
|
||||
cancellato: false,
|
||||
dataCreazione: link.materiale.dataCreazione,
|
||||
dataModifica: link.materiale.dataModifica,
|
||||
utenteModifica: link.materiale.utenteModifica,
|
||||
}));
|
||||
|
||||
const periodoAnnoList: PeriodoAnnoDto[] = entity.periodoAnnoLinks
|
||||
.filter((link) => !link.cancellato)
|
||||
.map((link) => ({
|
||||
id: link.periodoAnno.id,
|
||||
nome: link.periodoAnno.nome,
|
||||
inizioMese: link.periodoAnno.inizioMese,
|
||||
fineMese: link.periodoAnno.fineMese,
|
||||
cancellato: false,
|
||||
dataCreazione: link.periodoAnno.dataCreazione,
|
||||
dataModifica: link.periodoAnno.dataModifica,
|
||||
utenteModifica: link.periodoAnno.utenteModifica,
|
||||
}));
|
||||
|
||||
const paragrafoList: ParagrafoDto[] = [...entity.paragrafi]
|
||||
.sort((a, b) => a.ordine - b.ordine)
|
||||
.map((paragrafo) => ({
|
||||
id: paragrafo.id,
|
||||
attivitaId: paragrafo.attivitaId,
|
||||
corpo: paragrafo.corpo,
|
||||
autore: paragrafo.autore,
|
||||
tipo: toTipologicaDto(paragrafo.tipo),
|
||||
ordine: paragrafo.ordine,
|
||||
dataCreazione: paragrafo.dataCreazione,
|
||||
dataModifica: paragrafo.dataModifica,
|
||||
utenteModifica: paragrafo.utenteModifica,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
nome: entity.nome,
|
||||
autore: entity.autore,
|
||||
padre: entity.padreId,
|
||||
stato: toTipologicaDto(entity.stato),
|
||||
brancaList,
|
||||
categoriaList,
|
||||
materialeList,
|
||||
paragrafoList,
|
||||
periodoAnnoList,
|
||||
dataCreazione: entity.dataCreazione,
|
||||
dataModifica: entity.dataModifica,
|
||||
utenteModifica: entity.utenteModifica,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getListaHome(): Promise<AttivitaDto[]> {
|
||||
const entities = await prisma.attivita.findMany({
|
||||
where: { statoId: 'PU' },
|
||||
include: attivitaInclude,
|
||||
orderBy: { dataModifica: 'desc' },
|
||||
});
|
||||
|
||||
return entities.map(toAttivitaDto);
|
||||
}
|
||||
|
||||
export async function getOne(id: number): Promise<AttivitaDto | null> {
|
||||
const entity = await prisma.attivita.findUnique({
|
||||
where: { id },
|
||||
include: attivitaInclude,
|
||||
});
|
||||
|
||||
return entity ? toAttivitaDto(entity) : null;
|
||||
}
|
||||
|
||||
export async function getListMy(autoreId: string): Promise<AttivitaDto[]> {
|
||||
const entities = await prisma.attivita.findMany({
|
||||
where: { autoreId },
|
||||
include: attivitaInclude,
|
||||
orderBy: { dataModifica: 'desc' },
|
||||
});
|
||||
|
||||
return entities.map(toAttivitaDto);
|
||||
}
|
||||
|
||||
export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<AttivitaDto[]> {
|
||||
const brancaIds = dtoList.filter((d) => d.gruppo === 'branca').map((d) => d.id as number);
|
||||
const categoriaIds = dtoList.filter((d) => d.gruppo === 'categoria').map((d) => d.id as number);
|
||||
const materialeIds = dtoList.filter((d) => d.gruppo === 'materiale').map((d) => d.id as number);
|
||||
const periodoAnnoIds = dtoList
|
||||
.filter((d) => d.gruppo === 'periodoAnno')
|
||||
.map((d) => d.id as number);
|
||||
const testoList = dtoList
|
||||
.filter((d) => d.gruppo === 'testo')
|
||||
.map((d) => d.nome)
|
||||
.filter((nome): nome is string => !!nome);
|
||||
|
||||
const and: Prisma.AttivitaWhereInput[] = [];
|
||||
|
||||
if (brancaIds.length > 0) {
|
||||
and.push({
|
||||
brancaLinks: { some: { brancaId: { in: brancaIds }, cancellato: false } },
|
||||
});
|
||||
}
|
||||
|
||||
if (categoriaIds.length > 0) {
|
||||
and.push({
|
||||
categoriaLinks: { some: { categoriaId: { in: categoriaIds }, cancellato: false } },
|
||||
});
|
||||
}
|
||||
|
||||
if (materialeIds.length > 0) {
|
||||
and.push({
|
||||
materialeLinks: { some: { materialeId: { in: materialeIds }, cancellato: false } },
|
||||
});
|
||||
}
|
||||
|
||||
if (periodoAnnoIds.length > 0) {
|
||||
and.push({
|
||||
periodoAnnoLinks: { some: { periodoAnnoId: { in: periodoAnnoIds }, cancellato: false } },
|
||||
});
|
||||
}
|
||||
|
||||
if (testoList.length > 0) {
|
||||
and.push({
|
||||
OR: testoList.flatMap((testo) => [
|
||||
{ nome: { contains: testo, mode: 'insensitive' } },
|
||||
{ paragrafi: { some: { corpo: { contains: testo, mode: 'insensitive' } } } },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
const entities = await prisma.attivita.findMany({
|
||||
where: and.length > 0 ? { AND: and } : {},
|
||||
include: attivitaInclude,
|
||||
orderBy: { dataModifica: 'desc' },
|
||||
});
|
||||
|
||||
return entities.map(toAttivitaDto);
|
||||
}
|
||||
|
||||
export async function changeStato(
|
||||
idAttivita: number,
|
||||
idStato: string,
|
||||
auth: AuthContext,
|
||||
): Promise<TipologicaDto> {
|
||||
const existing = await prisma.attivita.findUnique({ where: { id: idAttivita } });
|
||||
if (!existing) {
|
||||
throw new HttpError(404, 'attività non trovata');
|
||||
}
|
||||
if (existing.autoreId !== auth.userId) {
|
||||
throw new HttpError(403, 'non sei autore di questa attività');
|
||||
}
|
||||
|
||||
const updated = await prisma.attivita.update({
|
||||
where: { id: idAttivita },
|
||||
data: {
|
||||
statoId: idStato,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
include: { stato: true },
|
||||
});
|
||||
|
||||
return toTipologicaDto(updated.stato);
|
||||
}
|
||||
|
||||
type Tx = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>;
|
||||
|
||||
async function upsertBranca(
|
||||
tx: Tx,
|
||||
attivitaId: number,
|
||||
input: AttivitaSaveInput['brancaList'][number],
|
||||
auth: AuthContext,
|
||||
): Promise<number> {
|
||||
let brancaId = input.id;
|
||||
|
||||
if (!brancaId) {
|
||||
const found = await tx.branca.findFirst({
|
||||
where: { nome: { equals: input.nome, mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
if (found) {
|
||||
brancaId = found.id;
|
||||
} else {
|
||||
const created = await tx.branca.create({
|
||||
data: {
|
||||
nome: input.nome,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
brancaId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const existingLink = await tx.brancaAttivita.findUnique({
|
||||
where: { attivitaId_brancaId: { attivitaId, brancaId } },
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
await tx.brancaAttivita.update({
|
||||
where: { attivitaId_brancaId: { attivitaId, brancaId } },
|
||||
data: {
|
||||
cancellato: false,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.brancaAttivita.create({
|
||||
data: {
|
||||
attivitaId,
|
||||
brancaId,
|
||||
cancellato: false,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return brancaId;
|
||||
}
|
||||
|
||||
async function upsertCategoria(
|
||||
tx: Tx,
|
||||
attivitaId: number,
|
||||
input: AttivitaSaveInput['categoriaList'][number],
|
||||
auth: AuthContext,
|
||||
): Promise<number> {
|
||||
let categoriaId = input.id;
|
||||
|
||||
if (!categoriaId) {
|
||||
const found = await tx.categoria.findFirst({
|
||||
where: { nome: { equals: input.nome, mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
if (found) {
|
||||
categoriaId = found.id;
|
||||
} else {
|
||||
const created = await tx.categoria.create({
|
||||
data: {
|
||||
nome: input.nome,
|
||||
tipoId: 'A',
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
categoriaId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const existingLink = await tx.categoriaAttivita.findUnique({
|
||||
where: { attivitaId_categoriaId: { attivitaId, categoriaId } },
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
await tx.categoriaAttivita.update({
|
||||
where: { attivitaId_categoriaId: { attivitaId, categoriaId } },
|
||||
data: {
|
||||
cancellato: false,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.categoriaAttivita.create({
|
||||
data: {
|
||||
attivitaId,
|
||||
categoriaId,
|
||||
cancellato: false,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return categoriaId;
|
||||
}
|
||||
|
||||
async function upsertMateriale(
|
||||
tx: Tx,
|
||||
attivitaId: number,
|
||||
input: AttivitaSaveInput['materialeList'][number],
|
||||
auth: AuthContext,
|
||||
): Promise<number> {
|
||||
let materialeId = input.id;
|
||||
|
||||
if (!materialeId) {
|
||||
const found = await tx.materiale.findFirst({
|
||||
where: { nome: { equals: input.nome, mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
if (found) {
|
||||
materialeId = found.id;
|
||||
} else {
|
||||
const created = await tx.materiale.create({
|
||||
data: {
|
||||
nome: input.nome,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
materialeId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const proprieta = (input.proprieta ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
|
||||
const existingLink = await tx.materialeAttivita.findUnique({
|
||||
where: { attivitaId_materialeId: { attivitaId, materialeId } },
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
await tx.materialeAttivita.update({
|
||||
where: { attivitaId_materialeId: { attivitaId, materialeId } },
|
||||
data: {
|
||||
cancellato: false,
|
||||
proprieta,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.materialeAttivita.create({
|
||||
data: {
|
||||
attivitaId,
|
||||
materialeId,
|
||||
cancellato: false,
|
||||
proprieta,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return materialeId;
|
||||
}
|
||||
|
||||
async function upsertPeriodoAnno(
|
||||
tx: Tx,
|
||||
attivitaId: number,
|
||||
input: AttivitaSaveInput['periodoAnnoList'][number],
|
||||
auth: AuthContext,
|
||||
): Promise<number> {
|
||||
let periodoAnnoId = input.id;
|
||||
|
||||
if (!periodoAnnoId) {
|
||||
const found = await tx.periodoAnno.findFirst({
|
||||
where: { nome: { equals: input.nome, mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
if (found) {
|
||||
periodoAnnoId = found.id;
|
||||
} else {
|
||||
const created = await tx.periodoAnno.create({
|
||||
data: {
|
||||
nome: input.nome,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
periodoAnnoId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const existingLink = await tx.periodoAnnoAttivita.findUnique({
|
||||
where: { attivitaId_periodoAnnoId: { attivitaId, periodoAnnoId } },
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
await tx.periodoAnnoAttivita.update({
|
||||
where: { attivitaId_periodoAnnoId: { attivitaId, periodoAnnoId } },
|
||||
data: {
|
||||
cancellato: false,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.periodoAnnoAttivita.create({
|
||||
data: {
|
||||
attivitaId,
|
||||
periodoAnnoId,
|
||||
cancellato: false,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return periodoAnnoId;
|
||||
}
|
||||
|
||||
export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
let attivitaId: number;
|
||||
|
||||
if (dto.id) {
|
||||
const existing = await tx.attivita.findUnique({ where: { id: dto.id } });
|
||||
if (!existing) {
|
||||
throw new HttpError(404, 'attività non trovata');
|
||||
}
|
||||
if (existing.autoreId !== auth.userId) {
|
||||
throw new HttpError(403, 'non sei autore di questa attività');
|
||||
}
|
||||
|
||||
await tx.attivita.update({
|
||||
where: { id: dto.id },
|
||||
data: {
|
||||
nome: dto.nome,
|
||||
statoId: dto.stato.id,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
attivitaId = dto.id;
|
||||
} else {
|
||||
const created = await tx.attivita.create({
|
||||
data: {
|
||||
nome: dto.nome,
|
||||
autore: auth.name,
|
||||
autoreId: auth.userId,
|
||||
statoId: dto.stato.id,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
attivitaId = created.id;
|
||||
}
|
||||
|
||||
const brancaIds = new Set<number>();
|
||||
for (const branca of dto.brancaList) {
|
||||
brancaIds.add(await upsertBranca(tx, attivitaId, branca, auth));
|
||||
}
|
||||
await tx.brancaAttivita.updateMany({
|
||||
where: { attivitaId, brancaId: { notIn: [...brancaIds] }, cancellato: false },
|
||||
data: {
|
||||
cancellato: true,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
|
||||
const categoriaIds = new Set<number>();
|
||||
for (const categoria of dto.categoriaList) {
|
||||
categoriaIds.add(await upsertCategoria(tx, attivitaId, categoria, auth));
|
||||
}
|
||||
await tx.categoriaAttivita.updateMany({
|
||||
where: { attivitaId, categoriaId: { notIn: [...categoriaIds] }, cancellato: false },
|
||||
data: {
|
||||
cancellato: true,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
|
||||
const materialeIds = new Set<number>();
|
||||
for (const materiale of dto.materialeList) {
|
||||
materialeIds.add(await upsertMateriale(tx, attivitaId, materiale, auth));
|
||||
}
|
||||
await tx.materialeAttivita.updateMany({
|
||||
where: { attivitaId, materialeId: { notIn: [...materialeIds] }, cancellato: false },
|
||||
data: {
|
||||
cancellato: true,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
|
||||
const periodoAnnoIds = new Set<number>();
|
||||
for (const periodoAnno of dto.periodoAnnoList) {
|
||||
periodoAnnoIds.add(await upsertPeriodoAnno(tx, attivitaId, periodoAnno, auth));
|
||||
}
|
||||
await tx.periodoAnnoAttivita.updateMany({
|
||||
where: { attivitaId, periodoAnnoId: { notIn: [...periodoAnnoIds] }, cancellato: false },
|
||||
data: {
|
||||
cancellato: true,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
|
||||
const paragrafoIds = new Set<number>();
|
||||
for (const paragrafo of dto.paragrafoList) {
|
||||
// Il paragrafo non ha un autore proprio nell'API: eredita quello dell'attivita'
|
||||
// se non esplicitamente indicato nel payload.
|
||||
const paragrafoAutore = paragrafo.autore ?? auth.name;
|
||||
|
||||
if (paragrafo.id) {
|
||||
await tx.paragrafo.update({
|
||||
where: { id: paragrafo.id },
|
||||
data: {
|
||||
corpo: paragrafo.corpo,
|
||||
autore: paragrafoAutore,
|
||||
tipoId: paragrafo.tipo.id,
|
||||
ordine: paragrafo.ordine,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
paragrafoIds.add(paragrafo.id);
|
||||
} else {
|
||||
const created = await tx.paragrafo.create({
|
||||
data: {
|
||||
attivitaId,
|
||||
corpo: paragrafo.corpo,
|
||||
autore: paragrafoAutore,
|
||||
tipoId: paragrafo.tipo.id,
|
||||
ordine: paragrafo.ordine,
|
||||
utenteModifica: auth.name,
|
||||
},
|
||||
});
|
||||
paragrafoIds.add(created.id);
|
||||
}
|
||||
}
|
||||
await tx.paragrafo.deleteMany({
|
||||
where: { attivitaId, id: { notIn: [...paragrafoIds] } },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import * as autocompleteService from './autocomplete.service';
|
||||
|
||||
function asyncHandler(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
handler(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
function extractKeyword(body: unknown): string | undefined {
|
||||
return typeof body === 'string' ? body : undefined;
|
||||
}
|
||||
|
||||
export const autocompleteRouter = Router();
|
||||
|
||||
autocompleteRouter.post(
|
||||
'/get/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const groups = await autocompleteService.getSearch(extractKeyword(req.body));
|
||||
res.status(200).json(groups);
|
||||
}),
|
||||
);
|
||||
|
||||
autocompleteRouter.post(
|
||||
'/get/branca',
|
||||
asyncHandler(async (req, res) => {
|
||||
const lista = await autocompleteService.getBranca(extractKeyword(req.body));
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
|
||||
autocompleteRouter.post(
|
||||
'/get/categoria',
|
||||
asyncHandler(async (req, res) => {
|
||||
const lista = await autocompleteService.getCategoria(extractKeyword(req.body));
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
|
||||
autocompleteRouter.post(
|
||||
'/get/materiale',
|
||||
asyncHandler(async (req, res) => {
|
||||
const lista = await autocompleteService.getMateriale(extractKeyword(req.body));
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
|
||||
autocompleteRouter.post(
|
||||
'/get/periodoAnno',
|
||||
asyncHandler(async (req, res) => {
|
||||
const lista = await autocompleteService.getPeriodoAnno(extractKeyword(req.body));
|
||||
res.status(200).json(lista);
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { prisma } from '../../db/prisma';
|
||||
import { SearchGroupDto, SearchObjectDto } from '../../types/dto';
|
||||
|
||||
export async function getBranca(keyword?: string | null): Promise<SearchObjectDto[]> {
|
||||
const entities = await prisma.branca.findMany({
|
||||
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'branca' }));
|
||||
}
|
||||
|
||||
export async function getCategoria(keyword?: string | null): Promise<SearchObjectDto[]> {
|
||||
const entities = await prisma.categoria.findMany({
|
||||
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'categoria' }));
|
||||
}
|
||||
|
||||
export async function getMateriale(keyword?: string | null): Promise<SearchObjectDto[]> {
|
||||
const entities = await prisma.materiale.findMany({
|
||||
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'materiale' }));
|
||||
}
|
||||
|
||||
export async function getPeriodoAnno(keyword?: string | null): Promise<SearchObjectDto[]> {
|
||||
const entities = await prisma.periodoAnno.findMany({
|
||||
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
|
||||
});
|
||||
|
||||
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'periodoAnno' }));
|
||||
}
|
||||
|
||||
export async function getSearch(keyword?: string | null): Promise<SearchGroupDto[]> {
|
||||
const groups: SearchGroupDto[] = [
|
||||
{ label: 'Testo', objectsList: [{ id: null, nome: keyword ?? null, gruppo: 'testo' }] },
|
||||
];
|
||||
|
||||
const brancaList = await getBranca(keyword);
|
||||
if (brancaList.length > 0) {
|
||||
groups.push({ label: 'Branca', objectsList: brancaList });
|
||||
}
|
||||
|
||||
const categoriaList = await getCategoria(keyword);
|
||||
if (categoriaList.length > 0) {
|
||||
groups.push({ label: 'Categoria', objectsList: categoriaList });
|
||||
}
|
||||
|
||||
const materialeList = await getMateriale(keyword);
|
||||
if (materialeList.length > 0) {
|
||||
groups.push({ label: 'Materiale', objectsList: materialeList });
|
||||
}
|
||||
|
||||
const periodoAnnoList = await getPeriodoAnno(keyword);
|
||||
if (periodoAnnoList.length > 0) {
|
||||
groups.push({ label: 'Periodo anno', objectsList: periodoAnnoList });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { app } from './app';
|
||||
import { env } from './config/env';
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`Server avviato su porta ${env.port} (CORS origin: ${env.corsOrigin})`);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface TipologicaDto {
|
||||
id: string;
|
||||
nome: string;
|
||||
}
|
||||
|
||||
export interface BaseDto {
|
||||
dataCreazione: Date;
|
||||
dataModifica: Date;
|
||||
utenteModifica: string;
|
||||
}
|
||||
|
||||
export interface BrancaDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioEta: number | null;
|
||||
fineEta: number | null;
|
||||
colore: string | null;
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface CategoriaDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
padre: number | null;
|
||||
tipo: TipologicaDto;
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialeDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
proprieta: unknown;
|
||||
categoriaList?: CategoriaDto[];
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface ParagrafoDto extends BaseDto {
|
||||
id: number;
|
||||
attivitaId: number;
|
||||
corpo: string;
|
||||
autore: string;
|
||||
tipo: TipologicaDto;
|
||||
ordine: number;
|
||||
}
|
||||
|
||||
export interface PeriodoAnnoDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioMese: number | null;
|
||||
fineMese: number | null;
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface AttivitaDto extends BaseDto {
|
||||
id: number | null;
|
||||
nome: string;
|
||||
autore: string;
|
||||
padre: number | null;
|
||||
stato: TipologicaDto;
|
||||
brancaList: BrancaDto[];
|
||||
categoriaList: CategoriaDto[];
|
||||
materialeList: MaterialeDto[];
|
||||
paragrafoList: ParagrafoDto[];
|
||||
periodoAnnoList: PeriodoAnnoDto[];
|
||||
}
|
||||
|
||||
export interface SearchObjectDto {
|
||||
id: number | null;
|
||||
nome: string | null;
|
||||
gruppo: string;
|
||||
}
|
||||
|
||||
export interface SearchGroupDto {
|
||||
label: string;
|
||||
objectsList: SearchObjectDto[];
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { AuthContext } from '../middlewares/auth.types';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const tipologicaSchema = z.object({
|
||||
id: z.string(),
|
||||
nome: z.string(),
|
||||
});
|
||||
|
||||
export const paragrafoInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
corpo: z.string().min(1).max(500),
|
||||
// Un paragrafo non ha un autore proprio nell'API/frontend: eredita quello
|
||||
// dell'attivita' se non esplicitamente indicato (vedi attivita.service.ts).
|
||||
autore: z.string().min(1).optional(),
|
||||
tipo: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
ordine: z.number().int(),
|
||||
});
|
||||
|
||||
export const brancaInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
});
|
||||
|
||||
export const categoriaInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
});
|
||||
|
||||
export const materialeInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
proprieta: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const periodoAnnoInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
});
|
||||
|
||||
export const attivitaSaveSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
stato: tipologicaSchema,
|
||||
brancaList: z.array(brancaInputSchema),
|
||||
categoriaList: z.array(categoriaInputSchema),
|
||||
materialeList: z.array(materialeInputSchema),
|
||||
periodoAnnoList: z.array(periodoAnnoInputSchema),
|
||||
paragrafoList: z.array(paragrafoInputSchema),
|
||||
});
|
||||
|
||||
export type TipologicaInput = z.infer<typeof tipologicaSchema>;
|
||||
export type ParagrafoInput = z.infer<typeof paragrafoInputSchema>;
|
||||
export type BrancaInput = z.infer<typeof brancaInputSchema>;
|
||||
export type CategoriaInput = z.infer<typeof categoriaInputSchema>;
|
||||
export type MaterialeInput = z.infer<typeof materialeInputSchema>;
|
||||
export type PeriodoAnnoInput = z.infer<typeof periodoAnnoInputSchema>;
|
||||
export type AttivitaSaveInput = z.infer<typeof attivitaSaveSchema>;
|
||||
@@ -0,0 +1,192 @@
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { app } from '../../src/app';
|
||||
import { prisma } from '../../src/db/prisma';
|
||||
import { resetDatabase } from '../setup/resetDatabase';
|
||||
import { bearer, mockKeycloakJwks } from '../setup/authTestHelper';
|
||||
|
||||
const AUTORE = { sub: 'user-movio', email: 'movio@example.com', name: 'Movio' };
|
||||
const ALTRO_AUTORE = { sub: 'user-altro', email: 'altro@example.com', name: 'Altro Utente' };
|
||||
const AUTH_HEADER = bearer(AUTORE);
|
||||
const ALTRO_AUTH_HEADER = bearer(ALTRO_AUTORE);
|
||||
|
||||
function attivitaPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
nome: 'Caccia al tesoro',
|
||||
stato: { id: 'BO', nome: 'Bozza' },
|
||||
brancaList: [{ nome: 'E/G' }],
|
||||
categoriaList: [{ nome: 'grande gioco' }],
|
||||
materialeList: [],
|
||||
periodoAnnoList: [],
|
||||
paragrafoList: [{ corpo: 'Introduzione al gioco', tipo: { id: 'PARAGRAFO' }, ordine: 1 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mockKeycloakJwks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
describe('attivita endpoints', () => {
|
||||
test('GET /health risponde 200 con { status: "ok" }', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('GET /public/attivita/get/lista/home su DB vuoto restituisce []', async () => {
|
||||
const res = await request(app).get('/public/attivita/get/lista/home');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
test('GET /public/attivita/get/one/:id con id non numerico restituisce 400', async () => {
|
||||
const res = await request(app).get('/public/attivita/get/one/abc');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /public/attivita/get/one/:id con id inesistente restituisce 404', async () => {
|
||||
const res = await request(app).get('/public/attivita/get/one/999999');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('endpoint privati senza token restituiscono 401', async () => {
|
||||
const saveRes = await request(app).post('/private/attivita/save').send(attivitaPayload());
|
||||
expect(saveRes.status).toBe(401);
|
||||
|
||||
const myRes = await request(app).get('/private/attivita/get/lista/my');
|
||||
expect(myRes.status).toBe(401);
|
||||
|
||||
const changeRes = await request(app).get('/private/attivita/change/stato/1/PU');
|
||||
expect(changeRes.status).toBe(401);
|
||||
});
|
||||
|
||||
test('POST /private/attivita/save senza nome restituisce 400 con informazioni di validazione', async () => {
|
||||
const { nome, ...payloadSenzaNome } = attivitaPayload();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/private/attivita/save')
|
||||
.set('Authorization', AUTH_HEADER)
|
||||
.send(payloadSenzaNome);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(typeof res.body.message).toBe('string');
|
||||
expect(res.body.message.length).toBeGreaterThan(0);
|
||||
expect(res.body.message).toContain('nome');
|
||||
});
|
||||
|
||||
test("POST /private/attivita/save con payload valido crea l'attività, visibile poi via get/lista/my", async () => {
|
||||
const saveRes = await request(app)
|
||||
.post('/private/attivita/save')
|
||||
.set('Authorization', AUTH_HEADER)
|
||||
.send(attivitaPayload());
|
||||
expect([200, 204]).toContain(saveRes.status);
|
||||
|
||||
const myRes = await request(app)
|
||||
.get('/private/attivita/get/lista/my')
|
||||
.set('Authorization', AUTH_HEADER);
|
||||
expect(myRes.status).toBe(200);
|
||||
expect(myRes.body).toHaveLength(1);
|
||||
expect(myRes.body[0]).toMatchObject({ nome: 'Caccia al tesoro', autore: 'Movio' });
|
||||
expect(myRes.body[0].brancaList.map((b: { nome: string }) => b.nome)).toEqual(['E/G']);
|
||||
expect(myRes.body[0].categoriaList.map((c: { nome: string }) => c.nome)).toEqual([
|
||||
'grande gioco',
|
||||
]);
|
||||
});
|
||||
|
||||
test('get/lista/my non restituisce le attività di un altro autore', async () => {
|
||||
await request(app).post('/private/attivita/save').set('Authorization', AUTH_HEADER).send(attivitaPayload());
|
||||
|
||||
const myRes = await request(app)
|
||||
.get('/private/attivita/get/lista/my')
|
||||
.set('Authorization', ALTRO_AUTH_HEADER);
|
||||
|
||||
expect(myRes.status).toBe(200);
|
||||
expect(myRes.body).toEqual([]);
|
||||
});
|
||||
|
||||
test("POST /private/attivita/save su un'attività di un altro autore restituisce 403", async () => {
|
||||
await request(app).post('/private/attivita/save').set('Authorization', AUTH_HEADER).send(attivitaPayload());
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/private/attivita/save')
|
||||
.set('Authorization', ALTRO_AUTH_HEADER)
|
||||
.send(attivitaPayload({ id: created.id, nome: 'Modificata da altri' }));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("change/stato aggiorna lo stato e rende visibile l'attività in home", async () => {
|
||||
await request(app).post('/private/attivita/save').set('Authorization', AUTH_HEADER).send(attivitaPayload());
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
const changeRes = await request(app)
|
||||
.get(`/private/attivita/change/stato/${created.id}/PU`)
|
||||
.set('Authorization', AUTH_HEADER);
|
||||
expect(changeRes.status).toBe(200);
|
||||
expect(changeRes.body).toEqual({ id: 'PU', nome: 'Pubblicato' });
|
||||
|
||||
const homeRes = await request(app).get('/public/attivita/get/lista/home');
|
||||
expect(homeRes.status).toBe(200);
|
||||
expect(homeRes.body.map((a: { id: number }) => a.id)).toContain(created.id);
|
||||
});
|
||||
|
||||
test('change/stato su id inesistente restituisce 404', async () => {
|
||||
const res = await request(app)
|
||||
.get('/private/attivita/change/stato/999999/PU')
|
||||
.set('Authorization', AUTH_HEADER);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("change/stato su un'attività di un altro autore restituisce 403", async () => {
|
||||
await request(app).post('/private/attivita/save').set('Authorization', AUTH_HEADER).send(attivitaPayload());
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/private/attivita/change/stato/${created.id}/PU`)
|
||||
.set('Authorization', ALTRO_AUTH_HEADER);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('CORS: risponde con Access-Control-Allow-Origin per un Origin autorizzato', async () => {
|
||||
const res = await request(app).get('/health').set('Origin', 'http://localhost:4200');
|
||||
|
||||
expect(res.headers['access-control-allow-origin']).toBe('http://localhost:4200');
|
||||
});
|
||||
|
||||
test('POST /public/attivita/get/lista/search con [] restituisce tutte le attività presenti', async () => {
|
||||
await request(app)
|
||||
.post('/private/attivita/save')
|
||||
.set('Authorization', AUTH_HEADER)
|
||||
.send(attivitaPayload({ nome: 'Prima' }));
|
||||
await request(app)
|
||||
.post('/private/attivita/save')
|
||||
.set('Authorization', AUTH_HEADER)
|
||||
.send(attivitaPayload({ nome: 'Seconda', stato: { id: 'PU', nome: 'Pubblicato' } }));
|
||||
|
||||
const res = await request(app).post('/public/attivita/get/lista/search').send([]);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body.map((a: { nome: string }) => a.nome).sort()).toEqual(['Prima', 'Seconda']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import request from 'supertest';
|
||||
import { app } from '../../src/app';
|
||||
import { prisma } from '../../src/db/prisma';
|
||||
|
||||
// L'unica tabella che questi endpoint (read-only sulle anagrafiche) potrebbero
|
||||
// trovare "sporca" e' `materiale` (il seed non ne inserisce nessuno): la
|
||||
// puliamo prima di ogni test per non dipendere dall'ordine di esecuzione
|
||||
// rispetto alle altre suite.
|
||||
beforeEach(async () => {
|
||||
await prisma.materiale.deleteMany();
|
||||
});
|
||||
|
||||
describe('autocomplete endpoints', () => {
|
||||
test('POST /public/autocomplete/get/search senza body contiene il gruppo Testo', async () => {
|
||||
const res = await request(app).post('/public/autocomplete/get/search');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.some((g: { label: string }) => g.label === 'Testo')).toBe(true);
|
||||
});
|
||||
|
||||
test('POST /public/autocomplete/get/search con "gioco" contiene Categoria con le sotto-categorie', async () => {
|
||||
const res = await request(app)
|
||||
.post('/public/autocomplete/get/search')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('"gioco"');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const categoria = res.body.find((g: { label: string }) => g.label === 'Categoria');
|
||||
expect(categoria).toBeDefined();
|
||||
expect(categoria.objectsList.map((o: { nome: string }) => o.nome).sort()).toEqual(
|
||||
["gioco", "gioco d'acqua", 'gioco giungla', 'gioco notturno', 'grande gioco'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('POST /public/autocomplete/get/branca con "e/g" restituisce un solo elemento E/G', async () => {
|
||||
const res = await request(app)
|
||||
.post('/public/autocomplete/get/branca')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('"e/g"');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0]).toMatchObject({ nome: 'E/G', gruppo: 'branca' });
|
||||
});
|
||||
|
||||
test('POST /public/autocomplete/get/materiale senza body restituisce array vuoto', async () => {
|
||||
const res = await request(app).post('/public/autocomplete/get/materiale');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import nock from 'nock';
|
||||
import { env } from '../../src/config/env';
|
||||
|
||||
const KID = 'test-kid';
|
||||
const CERTS_PATH = `/realms/${env.keycloak.realm}/protocol/openid-connect/certs`;
|
||||
|
||||
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;
|
||||
|
||||
const { privateKey: rogueKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
export const rogueKeyPem = rogueKey.export({ type: 'pkcs1', format: 'pem' }) as string;
|
||||
|
||||
export function mockKeycloakJwks(): void {
|
||||
nock(env.keycloak.baseUrl)
|
||||
.persist()
|
||||
.get(CERTS_PATH)
|
||||
.reply(200, { keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }] });
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
realm_access?: { roles?: string[] };
|
||||
}
|
||||
|
||||
export function signValidToken(payload: TokenPayload, signOptions: jwt.SignOptions = {}): string {
|
||||
return jwt.sign(payload, privateKeyPem, {
|
||||
algorithm: 'RS256',
|
||||
keyid: KID,
|
||||
expiresIn: '5m',
|
||||
...signOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function bearer(payload: TokenPayload, signOptions: jwt.SignOptions = {}): string {
|
||||
return `Bearer ${signValidToken(payload, signOptions)}`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { execSync } from 'child_process';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
dotenv.config();
|
||||
|
||||
const databaseUrlTest = process.env.DATABASE_URL_TEST;
|
||||
if (!databaseUrlTest) {
|
||||
throw new Error("Variabile d'ambiente mancante: DATABASE_URL_TEST");
|
||||
}
|
||||
|
||||
// Fa puntare tutto il codice applicativo (incluso src/db/prisma.ts) al DB di test,
|
||||
// senza bisogno di modifiche altrove: dotenv.config() non sovrascrive DATABASE_URL
|
||||
// gia' impostata qui.
|
||||
process.env.DATABASE_URL = databaseUrlTest;
|
||||
|
||||
execSync('npx prisma migrate deploy', {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
execSync('npx prisma db seed', {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { prisma } from '../../src/db/prisma';
|
||||
|
||||
export default async function globalTeardown(): Promise<void> {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { prisma } from '../../src/db/prisma';
|
||||
|
||||
// Solo le tabelle "transazionali" popolate dai test: le anagrafiche di base
|
||||
// seedate una volta sola (stato_attivita, tipo_categoria, tipo_paragrafo,
|
||||
// branca, periodo_anno, categoria, materiale) restano intatte tra un test e l'altro.
|
||||
const TABLES_TO_RESET = [
|
||||
'paragrafo',
|
||||
'branca_attivita',
|
||||
'categoria_attivita',
|
||||
'materiale_attivita',
|
||||
'periodo_anno_attivita',
|
||||
'attivita',
|
||||
];
|
||||
|
||||
export async function resetDatabase(): Promise<void> {
|
||||
const tables = TABLES_TO_RESET.map((table) => `"${table}"`).join(', ');
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { prisma } from '../../src/db/prisma';
|
||||
import { resetDatabase } from '../setup/resetDatabase';
|
||||
import {
|
||||
changeStato,
|
||||
getListaHome,
|
||||
getListaSearch,
|
||||
getListMy,
|
||||
getOne,
|
||||
save,
|
||||
} from '../../src/modules/attivita/attivita.service';
|
||||
import { AuthContext } from '../../src/middlewares/auth.types';
|
||||
import { AttivitaSaveInput } from '../../src/types/validation';
|
||||
|
||||
// Questi test girano contro un DB Postgres di test reale (vedi tests/setup),
|
||||
// non contro dei mock: il dominio ha query relazionali/JSON troppo specifiche
|
||||
// per essere simulate in modo affidabile.
|
||||
|
||||
const AUTH: AuthContext = { userId: 'user-test', email: 'test@example.com', name: 'Autore Test', roles: [] };
|
||||
const MARIO: AuthContext = { userId: 'user-mario', email: 'mario@example.com', name: 'Mario', roles: [] };
|
||||
const LUIGI: AuthContext = { userId: 'user-luigi', email: 'luigi@example.com', name: 'Luigi', roles: [] };
|
||||
|
||||
function baseAttivita(overrides: Partial<AttivitaSaveInput> = {}): AttivitaSaveInput {
|
||||
return {
|
||||
nome: 'Attività di test',
|
||||
stato: { id: 'BO', nome: 'Bozza' },
|
||||
brancaList: [],
|
||||
categoriaList: [],
|
||||
materialeList: [],
|
||||
periodoAnnoList: [],
|
||||
paragrafoList: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
describe('attivita.service', () => {
|
||||
test('save collega una branca esistente per nome senza duplicarla', async () => {
|
||||
await save(baseAttivita({ brancaList: [{ nome: 'E/G' }] }), AUTH);
|
||||
|
||||
const brancheEG = await prisma.branca.findMany({ where: { nome: 'E/G' } });
|
||||
expect(brancheEG).toHaveLength(1);
|
||||
|
||||
const attivita = await prisma.attivita.findFirstOrThrow();
|
||||
const link = await prisma.brancaAttivita.findUnique({
|
||||
where: { attivitaId_brancaId: { attivitaId: attivita.id, brancaId: brancheEG[0].id } },
|
||||
});
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.cancellato).toBe(false);
|
||||
|
||||
const dto = await getOne(attivita.id);
|
||||
expect(dto?.brancaList.map((b) => b.nome)).toEqual(['E/G']);
|
||||
});
|
||||
|
||||
test('save crea branca/categoria/materiale/periodoAnno nuovi quando non esistono per nome', async () => {
|
||||
await save(
|
||||
baseAttivita({
|
||||
materialeList: [{ nome: 'Corda 10m', proprieta: { lunghezza: 10 } }],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
|
||||
const materiale = await prisma.materiale.findFirst({ where: { nome: 'Corda 10m' } });
|
||||
expect(materiale).not.toBeNull();
|
||||
|
||||
const attivita = await prisma.attivita.findFirstOrThrow();
|
||||
const link = await prisma.materialeAttivita.findUnique({
|
||||
where: { attivitaId_materialeId: { attivitaId: attivita.id, materialeId: materiale!.id } },
|
||||
});
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.cancellato).toBe(false);
|
||||
expect(link?.proprieta).toEqual({ lunghezza: 10 });
|
||||
});
|
||||
|
||||
test('save in aggiornamento marca come cancellato (soft-delete) il collegamento non più presente', async () => {
|
||||
const lc = await prisma.branca.findFirstOrThrow({ where: { nome: 'L/C' } });
|
||||
const eg = await prisma.branca.findFirstOrThrow({ where: { nome: 'E/G' } });
|
||||
|
||||
await save(baseAttivita({ brancaList: [{ nome: 'L/C' }, { nome: 'E/G' }] }), AUTH);
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
await save(baseAttivita({ id: created.id, brancaList: [{ nome: 'E/G' }] }), AUTH);
|
||||
|
||||
const linkLc = await prisma.brancaAttivita.findUniqueOrThrow({
|
||||
where: { attivitaId_brancaId: { attivitaId: created.id, brancaId: lc.id } },
|
||||
});
|
||||
const linkEg = await prisma.brancaAttivita.findUniqueOrThrow({
|
||||
where: { attivitaId_brancaId: { attivitaId: created.id, brancaId: eg.id } },
|
||||
});
|
||||
|
||||
expect(linkLc.cancellato).toBe(true);
|
||||
expect(linkEg.cancellato).toBe(false);
|
||||
});
|
||||
|
||||
test('save sostituisce correttamente i paragrafi (modifica, rimozione, aggiunta)', async () => {
|
||||
await save(
|
||||
baseAttivita({
|
||||
paragrafoList: [
|
||||
{ corpo: 'Paragrafo uno', tipo: { id: 'PARAGRAFO' }, ordine: 1 },
|
||||
{ corpo: 'Paragrafo due', tipo: { id: 'PARAGRAFO' }, ordine: 2 },
|
||||
],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
const paragrafi = await prisma.paragrafo.findMany({
|
||||
where: { attivitaId: created.id },
|
||||
orderBy: { ordine: 'asc' },
|
||||
});
|
||||
expect(paragrafi).toHaveLength(2);
|
||||
const [primo, secondo] = paragrafi;
|
||||
|
||||
await save(
|
||||
baseAttivita({
|
||||
id: created.id,
|
||||
paragrafoList: [
|
||||
{
|
||||
id: primo.id,
|
||||
corpo: 'Paragrafo uno modificato',
|
||||
tipo: { id: 'PARAGRAFO' },
|
||||
ordine: 1,
|
||||
},
|
||||
{ corpo: 'Paragrafo nuovo', tipo: { id: 'PARAGRAFO' }, ordine: 2 },
|
||||
],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
|
||||
const paragrafiFinali = await prisma.paragrafo.findMany({
|
||||
where: { attivitaId: created.id },
|
||||
orderBy: { ordine: 'asc' },
|
||||
});
|
||||
|
||||
expect(paragrafiFinali).toHaveLength(2);
|
||||
expect(paragrafiFinali.map((p) => p.corpo)).toEqual(['Paragrafo uno modificato', 'Paragrafo nuovo']);
|
||||
expect(paragrafiFinali.find((p) => p.id === secondo.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('save su id inesistente lancia HttpError 404', async () => {
|
||||
await expect(save(baseAttivita({ id: 999999 }), AUTH)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
test("save su un'attività di un altro autore lancia HttpError 403", async () => {
|
||||
await save(baseAttivita(), MARIO);
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
await expect(save(baseAttivita({ id: created.id }), LUIGI)).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
test('getListaHome restituisce solo attività pubblicate', async () => {
|
||||
await save(baseAttivita({ nome: 'Bozza', stato: { id: 'BO', nome: 'Bozza' } }), AUTH);
|
||||
await save(baseAttivita({ nome: 'Pubblicata', stato: { id: 'PU', nome: 'Pubblicato' } }), AUTH);
|
||||
await save(baseAttivita({ nome: 'Privata', stato: { id: 'PR', nome: 'Privato' } }), AUTH);
|
||||
|
||||
const home = await getListaHome();
|
||||
expect(home).toHaveLength(1);
|
||||
expect(home[0].nome).toBe('Pubblicata');
|
||||
});
|
||||
|
||||
test('getListMy filtra per autore', async () => {
|
||||
await save(baseAttivita({ nome: 'Di Mario' }), MARIO);
|
||||
await save(baseAttivita({ nome: 'Di Luigi' }), LUIGI);
|
||||
|
||||
const mie = await getListMy(MARIO.userId);
|
||||
expect(mie).toHaveLength(1);
|
||||
expect(mie[0].nome).toBe('Di Mario');
|
||||
expect(mie[0].autore).toBe('Mario');
|
||||
});
|
||||
|
||||
test('changeStato su id inesistente lancia HttpError 404', async () => {
|
||||
await expect(changeStato(999999, 'PU', AUTH)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
test("changeStato su un'attività di un altro autore lancia HttpError 403", async () => {
|
||||
await save(baseAttivita(), MARIO);
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
await expect(changeStato(created.id, 'PU', LUIGI)).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
test('changeStato aggiorna lo stato e lo restituisce come TipologicaDto', async () => {
|
||||
await save(baseAttivita(), AUTH);
|
||||
const created = await prisma.attivita.findFirstOrThrow();
|
||||
|
||||
const stato = await changeStato(created.id, 'PU', AUTH);
|
||||
expect(stato).toEqual({ id: 'PU', nome: 'Pubblicato' });
|
||||
|
||||
const aggiornata = await prisma.attivita.findUniqueOrThrow({ where: { id: created.id } });
|
||||
expect(aggiornata.statoId).toBe('PU');
|
||||
});
|
||||
|
||||
test('getListaSearch combina filtri di gruppi diversi in AND e valori dello stesso gruppo in OR', async () => {
|
||||
const lc = await prisma.branca.findFirstOrThrow({ where: { nome: 'L/C' } });
|
||||
const gioco = await prisma.categoria.findFirstOrThrow({ where: { nome: 'gioco' } });
|
||||
|
||||
await save(
|
||||
baseAttivita({
|
||||
nome: 'L/C gioco',
|
||||
brancaList: [{ nome: 'L/C' }],
|
||||
categoriaList: [{ nome: 'gioco' }],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
await save(
|
||||
baseAttivita({
|
||||
nome: 'E/G gioco',
|
||||
brancaList: [{ nome: 'E/G' }],
|
||||
categoriaList: [{ nome: 'gioco' }],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
await save(
|
||||
baseAttivita({
|
||||
nome: 'L/C danza',
|
||||
brancaList: [{ nome: 'L/C' }],
|
||||
categoriaList: [{ nome: 'danza' }],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
|
||||
const risultati = await getListaSearch([
|
||||
{ gruppo: 'branca', id: lc.id, nome: null },
|
||||
{ gruppo: 'categoria', id: gioco.id, nome: null },
|
||||
]);
|
||||
|
||||
expect(risultati).toHaveLength(1);
|
||||
expect(risultati[0].nome).toBe('L/C gioco');
|
||||
});
|
||||
|
||||
test('getListaSearch con filtro testo trova sia nel nome sia nei paragrafi', async () => {
|
||||
await save(baseAttivita({ nome: 'Caccia al tesoro' }), AUTH);
|
||||
await save(
|
||||
baseAttivita({
|
||||
nome: 'Attività generica',
|
||||
paragrafoList: [
|
||||
{
|
||||
corpo: "C'è un tesoro nascosto nel bosco",
|
||||
tipo: { id: 'PARAGRAFO' },
|
||||
ordine: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
AUTH,
|
||||
);
|
||||
|
||||
const risultati = await getListaSearch([{ gruppo: 'testo', nome: 'tesoro', id: null }]);
|
||||
|
||||
expect(risultati.map((r) => r.nome).sort()).toEqual(['Attività generica', 'Caccia al tesoro'].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { prisma } from '../../src/db/prisma';
|
||||
import {
|
||||
getBranca,
|
||||
getCategoria,
|
||||
getMateriale,
|
||||
getPeriodoAnno,
|
||||
getSearch,
|
||||
} from '../../src/modules/autocomplete/autocomplete.service';
|
||||
|
||||
// Questo servizio legge solo le anagrafiche di base (branca, categoria,
|
||||
// periodo_anno, materiale) gia' popolate dal seed: niente resetDatabase()
|
||||
// completo qui, altrimenti perderemmo quei dati. L'unica tabella che questa
|
||||
// suite potrebbe "sporcare" e' `materiale` (il seed non ne inserisce
|
||||
// nessuno), quindi la puliamo prima di ogni test.
|
||||
beforeEach(async () => {
|
||||
await prisma.materiale.deleteMany();
|
||||
});
|
||||
|
||||
function nomi(list: { nome: string | null }[]): string[] {
|
||||
return list.map((item) => item.nome ?? '').sort();
|
||||
}
|
||||
|
||||
describe('autocomplete.service', () => {
|
||||
test('getBranca è case-insensitive e restituisce il gruppo "branca"', async () => {
|
||||
const risultati = await getBranca('e/g');
|
||||
|
||||
expect(risultati).toHaveLength(1);
|
||||
expect(risultati[0]).toMatchObject({ nome: 'E/G', gruppo: 'branca' });
|
||||
});
|
||||
|
||||
test('getCategoria("gioco") trova la categoria e le sue sotto-categorie che contengono "gioco"', async () => {
|
||||
const risultati = await getCategoria('gioco');
|
||||
|
||||
expect(nomi(risultati)).toEqual(
|
||||
['gioco', "gioco d'acqua", 'gioco giungla', 'gioco notturno', 'grande gioco'].sort(),
|
||||
);
|
||||
expect(risultati.every((r) => r.gruppo === 'categoria')).toBe(true);
|
||||
expect(nomi(risultati)).not.toContain('torneo');
|
||||
expect(nomi(risultati)).not.toContain('olimpiadi');
|
||||
});
|
||||
|
||||
test('getPeriodoAnno("campo") trova i periodi "campo" ma non "promessa"', async () => {
|
||||
const risultati = await getPeriodoAnno('campo');
|
||||
|
||||
expect(nomi(risultati)).toEqual(['campo estivo', 'campo invernale'].sort());
|
||||
expect(risultati.every((r) => r.gruppo === 'periodoAnno')).toBe(true);
|
||||
});
|
||||
|
||||
test('getMateriale su un database senza materiali restituisce array vuoto', async () => {
|
||||
const risultati = await getMateriale('corda');
|
||||
|
||||
expect(risultati).toEqual([]);
|
||||
});
|
||||
|
||||
test('getSearch(null) contiene sempre il gruppo Testo, popola Branca/Categoria/Periodo anno con tutte le righe e omette Materiale (vuoto)', async () => {
|
||||
const gruppi = await getSearch(null);
|
||||
|
||||
expect(gruppi[0]).toEqual({
|
||||
label: 'Testo',
|
||||
objectsList: [{ id: null, nome: null, gruppo: 'testo' }],
|
||||
});
|
||||
|
||||
const branca = gruppi.find((g) => g.label === 'Branca');
|
||||
const categoria = gruppi.find((g) => g.label === 'Categoria');
|
||||
const periodoAnno = gruppi.find((g) => g.label === 'Periodo anno');
|
||||
const materiale = gruppi.find((g) => g.label === 'Materiale');
|
||||
|
||||
expect(branca?.objectsList).toHaveLength(await prisma.branca.count());
|
||||
expect(categoria?.objectsList).toHaveLength(await prisma.categoria.count());
|
||||
expect(periodoAnno?.objectsList).toHaveLength(await prisma.periodoAnno.count());
|
||||
expect(materiale).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getSearch("gioco") restituisce Testo e Categoria ma non Branca, Periodo anno o Materiale', async () => {
|
||||
const gruppi = await getSearch('gioco');
|
||||
|
||||
const labels = gruppi.map((g) => g.label);
|
||||
expect(labels).toContain('Testo');
|
||||
expect(labels).toContain('Categoria');
|
||||
expect(labels).not.toContain('Branca');
|
||||
expect(labels).not.toContain('Periodo anno');
|
||||
expect(labels).not.toContain('Materiale');
|
||||
|
||||
const categoria = gruppi.find((g) => g.label === 'Categoria');
|
||||
expect(nomi(categoria!.objectsList)).toEqual(
|
||||
['gioco', "gioco d'acqua", 'gioco giungla', 'gioco notturno', 'grande gioco'].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import nock from 'nock';
|
||||
import { authenticate } from '../../../src/middlewares/authenticate';
|
||||
import { bearer, mockKeycloakJwks, rogueKeyPem, signValidToken } from '../../setup/authTestHelper';
|
||||
|
||||
const CAPO_PAYLOAD = {
|
||||
sub: 'user-123',
|
||||
email: 'capo@example.com',
|
||||
name: 'Capo Unità',
|
||||
realm_access: { roles: ['capo-unita'] },
|
||||
};
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.get('/protetta', authenticate, (req, res) => {
|
||||
res.json({ auth: req.auth });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mockKeycloakJwks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
test('restituisce 401 se manca il token', async () => {
|
||||
const app = buildApp();
|
||||
|
||||
const response = await request(app).get('/protetta');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('restituisce 401 se il token è scaduto', async () => {
|
||||
const app = buildApp();
|
||||
const token = signValidToken(CAPO_PAYLOAD, { expiresIn: '-10s' });
|
||||
|
||||
const response = await request(app).get('/protetta').set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('restituisce 401 se il token ha una firma non valida', async () => {
|
||||
const app = buildApp();
|
||||
const token = jwt.sign(CAPO_PAYLOAD, rogueKeyPem, {
|
||||
algorithm: 'RS256',
|
||||
keyid: 'test-kid',
|
||||
expiresIn: '5m',
|
||||
});
|
||||
|
||||
const response = await request(app).get('/protetta').set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('restituisce 200 e popola req.auth se il token è valido', async () => {
|
||||
const app = buildApp();
|
||||
|
||||
const response = await request(app).get('/protetta').set('Authorization', bearer(CAPO_PAYLOAD));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.auth).toEqual({
|
||||
userId: 'user-123',
|
||||
email: 'capo@example.com',
|
||||
name: 'Capo Unità',
|
||||
roles: ['capo-unita'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2021"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user