Sistemato attività

This commit is contained in:
Lorenzo Sanesi
2026-07-26 03:19:11 +02:00
parent 8d1a3e0d18
commit de921abcd6
57 changed files with 3063 additions and 144 deletions
@@ -0,0 +1,305 @@
import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors';
import { requireRole } from '../../middlewares/requireRole';
import {
brancaAdminSchema,
categoriaAdminSchema,
periodoAnnoAdminSchema,
proponiTassonomiaSchema,
} from '../../types/validation';
import * as tassonomieService from './tassonomie.service';
const moderazione = requireRole('admin', 'moderatore');
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 parseId(req: Request, next: NextFunction): number | undefined {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
next(new HttpError(400, "l'id deve essere un numero intero"));
return undefined;
}
return id;
}
export const tassonomieRouter = Router();
tassonomieRouter.get(
'/branca',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listBranca());
}),
);
tassonomieRouter.post(
'/branca',
moderazione,
asyncHandler(async (req, res, next) => {
const parsed = brancaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.createBranca(parsed.data, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.put(
'/branca/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const parsed = brancaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const updated = await tassonomieService.updateBranca(id, parsed.data, req.auth!);
res.status(200).json(updated);
}),
);
tassonomieRouter.delete(
'/branca/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.deleteBranca(id);
res.status(204).end();
}),
);
tassonomieRouter.post(
'/branca/:id/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const approvata = await tassonomieService.approvaBranca(id);
res.status(200).json(approvata);
}),
);
tassonomieRouter.post(
'/branca/:id/rifiuta',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.rifiutaBranca(id);
res.status(204).end();
}),
);
tassonomieRouter.get(
'/categoria',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listCategoria());
}),
);
tassonomieRouter.get(
'/tipo-categoria',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listTipoCategoria());
}),
);
tassonomieRouter.post(
'/categoria',
moderazione,
asyncHandler(async (req, res, next) => {
const parsed = categoriaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.createCategoria(parsed.data, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.put(
'/categoria/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const parsed = categoriaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const updated = await tassonomieService.updateCategoria(id, parsed.data, req.auth!);
res.status(200).json(updated);
}),
);
tassonomieRouter.delete(
'/categoria/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.deleteCategoria(id);
res.status(204).end();
}),
);
tassonomieRouter.post(
'/categoria/:id/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const approvata = await tassonomieService.approvaCategoria(id);
res.status(200).json(approvata);
}),
);
tassonomieRouter.post(
'/categoria/:id/rifiuta',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.rifiutaCategoria(id);
res.status(204).end();
}),
);
tassonomieRouter.get(
'/periodoAnno',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listPeriodoAnno());
}),
);
tassonomieRouter.post(
'/periodoAnno',
moderazione,
asyncHandler(async (req, res, next) => {
const parsed = periodoAnnoAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.createPeriodoAnno(parsed.data, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.put(
'/periodoAnno/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const parsed = periodoAnnoAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const updated = await tassonomieService.updatePeriodoAnno(id, parsed.data, req.auth!);
res.status(200).json(updated);
}),
);
tassonomieRouter.delete(
'/periodoAnno/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.deletePeriodoAnno(id);
res.status(204).end();
}),
);
tassonomieRouter.post(
'/periodoAnno/:id/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const approvato = await tassonomieService.approvaPeriodoAnno(id);
res.status(200).json(approvato);
}),
);
tassonomieRouter.post(
'/periodoAnno/:id/rifiuta',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.rifiutaPeriodoAnno(id);
res.status(204).end();
}),
);
// Route di proposta: qualunque utente autenticato può proporre una nuova tassonomia,
// che viene creata con stato DA_APPROVARE (vedi tassonomie.service.ts).
tassonomieRouter.post(
'/proposte/branca',
asyncHandler(async (req, res, next) => {
const parsed = proponiTassonomiaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.proponiBranca(parsed.data.nome, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.post(
'/proposte/categoria',
asyncHandler(async (req, res, next) => {
const parsed = proponiTassonomiaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.proponiCategoria(parsed.data.nome, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.post(
'/proposte/periodoAnno',
asyncHandler(async (req, res, next) => {
const parsed = proponiTassonomiaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.proponiPeriodoAnno(parsed.data.nome, req.auth!);
res.status(201).json(created);
}),
);
@@ -0,0 +1,301 @@
import { Branca, Categoria, PeriodoAnno, TipoCategoria } from '@prisma/client';
import { prisma } from '../../db/prisma';
import { HttpError } from '../../errors';
import { AuthContext } from '../../middlewares/auth.types';
import * as notificheService from '../notifiche/notifiche.service';
import { BrancaAdminInput, CategoriaAdminInput, PeriodoAnnoAdminInput } from '../../types/validation';
export async function listBranca(): Promise<Branca[]> {
return prisma.branca.findMany({ orderBy: { nome: 'asc' } });
}
export async function createBranca(input: BrancaAdminInput, auth: AuthContext): Promise<Branca> {
return prisma.branca.create({
data: {
nome: input.nome,
inizioEta: input.inizioEta ?? null,
fineEta: input.fineEta ?? null,
colore: input.colore ?? null,
utenteModifica: auth.name,
},
});
}
export async function updateBranca(
id: number,
input: BrancaAdminInput,
auth: AuthContext,
): Promise<Branca> {
await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
return prisma.branca.update({
where: { id },
data: {
nome: input.nome,
inizioEta: input.inizioEta ?? null,
fineEta: input.fineEta ?? null,
colore: input.colore ?? null,
utenteModifica: auth.name,
},
});
}
export async function deleteBranca(id: number): Promise<void> {
await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
const linkCount = await prisma.brancaAttivita.count({ where: { brancaId: id } });
if (linkCount > 0) {
throw new HttpError(409, `In uso da ${linkCount} attività, impossibile eliminare`);
}
await prisma.branca.delete({ where: { id } });
}
export async function proponiBranca(nome: string, auth: AuthContext): Promise<Branca> {
const created = await prisma.branca.create({
data: {
nome,
stato: 'DA_APPROVARE',
creatoDaId: auth.userId,
utenteModifica: auth.name,
},
});
await notificheService.creaBroadcastModerazione(
'TASSONOMIA_PROPOSTA',
`Nuova branca proposta da approvare: "${created.nome}"`,
'/tassonomie',
);
return created;
}
export async function approvaBranca(id: number): Promise<Branca> {
const entity = await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Branca già confermata');
}
return prisma.branca.update({ where: { id }, data: { stato: 'CONFERMATA' } });
}
export async function rifiutaBranca(id: number): Promise<void> {
const entity = await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
await prisma.$transaction([
prisma.brancaAttivita.deleteMany({ where: { brancaId: id } }),
prisma.branca.delete({ where: { id } }),
]);
}
export async function listCategoria(): Promise<Categoria[]> {
return prisma.categoria.findMany({ orderBy: { nome: 'asc' } });
}
export async function listTipoCategoria(): Promise<TipoCategoria[]> {
return prisma.tipoCategoria.findMany({ orderBy: { nome: 'asc' } });
}
export async function createCategoria(
input: CategoriaAdminInput,
auth: AuthContext,
): Promise<Categoria> {
return prisma.categoria.create({
data: {
nome: input.nome,
padreId: input.padreId ?? null,
tipoId: input.tipoId,
utenteModifica: auth.name,
},
});
}
export async function updateCategoria(
id: number,
input: CategoriaAdminInput,
auth: AuthContext,
): Promise<Categoria> {
await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
if (input.padreId === id) {
throw new HttpError(400, 'Una categoria non può essere padre di se stessa');
}
return prisma.categoria.update({
where: { id },
data: {
nome: input.nome,
padreId: input.padreId ?? null,
tipoId: input.tipoId,
utenteModifica: auth.name,
},
});
}
export async function deleteCategoria(id: number): Promise<void> {
await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
const [linkCount, figliCount, materialeLinkCount] = await Promise.all([
prisma.categoriaAttivita.count({ where: { categoriaId: id } }),
prisma.categoria.count({ where: { padreId: id } }),
prisma.categoriaMateriale.count({ where: { categoriaId: id } }),
]);
const usi = linkCount + figliCount + materialeLinkCount;
if (usi > 0) {
throw new HttpError(409, `In uso da ${usi} elementi collegati, impossibile eliminare`);
}
await prisma.categoria.delete({ where: { id } });
}
export async function proponiCategoria(nome: string, auth: AuthContext): Promise<Categoria> {
const created = await prisma.categoria.create({
data: {
nome,
tipoId: 'A',
stato: 'DA_APPROVARE',
creatoDaId: auth.userId,
utenteModifica: auth.name,
},
});
await notificheService.creaBroadcastModerazione(
'TASSONOMIA_PROPOSTA',
`Nuova categoria proposta da approvare: "${created.nome}"`,
'/tassonomie',
);
return created;
}
export async function approvaCategoria(id: number): Promise<Categoria> {
const entity = await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Categoria già confermata');
}
return prisma.categoria.update({ where: { id }, data: { stato: 'CONFERMATA' } });
}
export async function rifiutaCategoria(id: number): Promise<void> {
const entity = await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
const figliCount = await prisma.categoria.count({ where: { padreId: id } });
if (figliCount > 0) {
throw new HttpError(409, 'Ha sotto-categorie collegate, impossibile rifiutare');
}
await prisma.$transaction([
prisma.categoriaAttivita.deleteMany({ where: { categoriaId: id } }),
prisma.categoriaMateriale.deleteMany({ where: { categoriaId: id } }),
prisma.categoria.delete({ where: { id } }),
]);
}
export async function listPeriodoAnno(): Promise<PeriodoAnno[]> {
return prisma.periodoAnno.findMany({ orderBy: { nome: 'asc' } });
}
export async function createPeriodoAnno(
input: PeriodoAnnoAdminInput,
auth: AuthContext,
): Promise<PeriodoAnno> {
return prisma.periodoAnno.create({
data: {
nome: input.nome,
inizioMese: input.inizioMese ?? null,
fineMese: input.fineMese ?? null,
utenteModifica: auth.name,
},
});
}
export async function updatePeriodoAnno(
id: number,
input: PeriodoAnnoAdminInput,
auth: AuthContext,
): Promise<PeriodoAnno> {
await assertExists(prisma.periodoAnno.findUnique({ where: { id } }), 'Periodo anno non trovato');
return prisma.periodoAnno.update({
where: { id },
data: {
nome: input.nome,
inizioMese: input.inizioMese ?? null,
fineMese: input.fineMese ?? null,
utenteModifica: auth.name,
},
});
}
export async function deletePeriodoAnno(id: number): Promise<void> {
await assertExists(prisma.periodoAnno.findUnique({ where: { id } }), 'Periodo anno non trovato');
const linkCount = await prisma.periodoAnnoAttivita.count({ where: { periodoAnnoId: id } });
if (linkCount > 0) {
throw new HttpError(409, `In uso da ${linkCount} attività, impossibile eliminare`);
}
await prisma.periodoAnno.delete({ where: { id } });
}
export async function proponiPeriodoAnno(nome: string, auth: AuthContext): Promise<PeriodoAnno> {
const created = await prisma.periodoAnno.create({
data: {
nome,
stato: 'DA_APPROVARE',
creatoDaId: auth.userId,
utenteModifica: auth.name,
},
});
await notificheService.creaBroadcastModerazione(
'TASSONOMIA_PROPOSTA',
`Nuovo periodo dell'anno proposto da approvare: "${created.nome}"`,
'/tassonomie',
);
return created;
}
export async function approvaPeriodoAnno(id: number): Promise<PeriodoAnno> {
const entity = await assertExists(
prisma.periodoAnno.findUnique({ where: { id } }),
'Periodo anno non trovato',
);
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Periodo anno già confermato');
}
return prisma.periodoAnno.update({ where: { id }, data: { stato: 'CONFERMATA' } });
}
export async function rifiutaPeriodoAnno(id: number): Promise<void> {
const entity = await assertExists(
prisma.periodoAnno.findUnique({ where: { id } }),
'Periodo anno non trovato',
);
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
await prisma.$transaction([
prisma.periodoAnnoAttivita.deleteMany({ where: { periodoAnnoId: id } }),
prisma.periodoAnno.delete({ where: { id } }),
]);
}
async function assertExists<T>(promise: Promise<T | null>, message: string): Promise<T> {
const entity = await promise;
if (!entity) {
throw new HttpError(404, message);
}
return entity;
}