Add scouthub-attivit-be

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:34:00 +02:00
parent 904cd2ee9b
commit 2ee7d402b6
55 changed files with 9783 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.env
*.log
coverage
.git
.idea
+13
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.env
*.log
coverage/
/attivita-be.iml
/.idea/
/bash.exe.stackdump
+240
View File
@@ -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.
+16
View File
@@ -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"]
+53
View File
@@ -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.
+16
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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"
+223
View File
@@ -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")
}
+114
View File
@@ -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();
});
+30
View File
@@ -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);
+21
View File
@@ -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'),
},
};
+12
View File
@@ -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;
}
+9
View File
@@ -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;
}
+6
View File
@@ -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})`);
});
+76
View File
@@ -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
View File
@@ -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>;
View File
@@ -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'],
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"declaration": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+19
View File
@@ -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"]
}