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
@@ -1,8 +1,11 @@
import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors';
import { attivitaSaveSchema } from '../../types/validation';
import { requireRole } from '../../middlewares/requireRole';
import { attivitaSaveSchema, notaAttivitaSchema } from '../../types/validation';
import * as attivitaService from './attivita.service';
const moderazione = requireRole('admin', 'moderatore');
function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
@@ -11,12 +14,21 @@ function asyncHandler(
};
}
function parseIntParam(value: string, next: NextFunction, message: string): number | undefined {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
next(new HttpError(400, message));
return undefined;
}
return parsed;
}
export const attivitaPrivateRouter = Router();
attivitaPrivateRouter.get(
'/get/lista/my',
asyncHandler(async (req, res) => {
const lista = await attivitaService.getListMy(req.auth!.userId);
const lista = await attivitaService.getListMy(req.auth!.userId, req.auth);
res.status(200).json(lista);
}),
);
@@ -49,3 +61,54 @@ attivitaPrivateRouter.post(
res.status(200).end();
}),
);
attivitaPrivateRouter.get(
'/get/lista/moderazione',
moderazione,
asyncHandler(async (req, res) => {
const lista = await attivitaService.getListaModerazione(req.auth!);
res.status(200).json(lista);
}),
);
attivitaPrivateRouter.post(
'/:idAttivita/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const idAttivita = parseIntParam(req.params.idAttivita, next, "l'idAttivita deve essere un numero intero");
if (idAttivita === undefined) return;
await attivitaService.approva(idAttivita, req.auth!);
res.status(200).end();
}),
);
attivitaPrivateRouter.post(
'/:idAttivita/commenta',
moderazione,
asyncHandler(async (req, res, next) => {
const idAttivita = parseIntParam(req.params.idAttivita, next, "l'idAttivita deve essere un numero intero");
if (idAttivita === undefined) return;
const parsed = notaAttivitaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
await attivitaService.commenta(idAttivita, parsed.data.testo, req.auth!);
res.status(200).end();
}),
);
attivitaPrivateRouter.delete(
'/note/:idNota',
moderazione,
asyncHandler(async (req, res, next) => {
const idNota = parseIntParam(req.params.idNota, next, "l'idNota deve essere un numero intero");
if (idNota === undefined) return;
await attivitaService.eliminaNota(idNota);
res.status(204).end();
}),
);
@@ -24,7 +24,7 @@ export const attivitaPublicRouter = Router();
attivitaPublicRouter.get(
'/get/lista/home',
asyncHandler(async (req, res) => {
const lista = await attivitaService.getListaHome();
const lista = await attivitaService.getListaHome(req.auth);
res.status(200).json(lista);
}),
);
@@ -38,7 +38,7 @@ attivitaPublicRouter.post(
return;
}
const lista = await attivitaService.getListaSearch(parsed.data);
const lista = await attivitaService.getListaSearch(parsed.data, req.auth);
res.status(200).json(lista);
}),
);
@@ -52,7 +52,7 @@ attivitaPublicRouter.get(
return;
}
const attivita = await attivitaService.getOne(id);
const attivita = await attivitaService.getOne(id, req.auth);
if (!attivita) {
next(new HttpError(404, 'attività non trovata'));
return;
@@ -2,11 +2,13 @@ import { Prisma, PrismaClient } 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 {
AttivitaDto,
BrancaDto,
CategoriaDto,
MaterialeDto,
NotaDto,
ParagrafoDto,
PeriodoAnnoDto,
SearchObjectDto,
@@ -14,6 +16,10 @@ import {
} from '../../types/dto';
import { AttivitaSaveInput } from '../../types/validation';
const STATO_IN_ATTESA = 'IA';
const STATO_PUBBLICATO = 'PU';
const STATO_BOZZA = 'BO';
const attivitaInclude = {
stato: true,
brancaLinks: { include: { branca: true } },
@@ -21,6 +27,7 @@ const attivitaInclude = {
materialeLinks: { include: { materiale: true } },
periodoAnnoLinks: { include: { periodoAnno: true } },
paragrafi: { include: { tipo: true } },
noteList: { orderBy: { dataCreazione: 'desc' } },
} satisfies Prisma.AttivitaInclude;
type AttivitaWithRelations = Prisma.AttivitaGetPayload<{ include: typeof attivitaInclude }>;
@@ -29,15 +36,55 @@ function toTipologicaDto(entity: { id: string; nome: string }): TipologicaDto {
return { id: entity.id, nome: entity.nome };
}
function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
// Usata sia per decidere se mostrare branca/categoria/periodo ancora in stato DA_APPROVARE,
// sia per le note di moderazione e per la visibilità delle attività non ancora pubblicate:
// in tutti i casi il perimetro di chi può "gestire" l'attività è lo stesso (autore, admin,
// moderatore).
function puoGestire(entity: AttivitaWithRelations, auth?: AuthContext): boolean {
if (!auth) {
return false;
}
return (
auth.userId === entity.autoreId ||
auth.roles.includes('admin') ||
auth.roles.includes('moderatore')
);
}
// Ogni volta che l'autore porta un'attività in stato Pubblicato (da save o da changeStato)
// il valore persistito è in realtà "in attesa di approvazione": diventa Pubblicato per davvero
// solo dopo l'approvazione di admin/moderatore (vedi approva()).
function risolviStatoPersistito(idStato: string): string {
return idStato === STATO_PUBBLICATO ? STATO_IN_ATTESA : idStato;
}
function toAttivitaDto(entity: AttivitaWithRelations, auth?: AuthContext): AttivitaDto {
const mostraProposte = puoGestire(entity, auth);
// Le note servono solo a far sistemare l'attività all'autore mentre è in revisione: una
// volta tornata Pubblicato (approvazione definitiva) non vanno più mostrate, anche se non
// sono ancora state cancellate esplicitamente (vedi eliminaNota).
const mostraNote = mostraProposte && entity.statoId !== STATO_PUBBLICATO;
const noteList: NotaDto[] = mostraNote
? entity.noteList.map((nota) => ({
id: nota.id,
attivitaId: nota.attivitaId,
testo: nota.testo,
autore: nota.autore,
dataCreazione: nota.dataCreazione,
}))
: [];
const brancaList: BrancaDto[] = entity.brancaLinks
.filter((link) => !link.cancellato)
.filter((link) => !link.cancellato && (link.branca.stato === 'CONFERMATA' || mostraProposte))
.map((link) => ({
id: link.branca.id,
nome: link.branca.nome,
inizioEta: link.branca.inizioEta,
fineEta: link.branca.fineEta,
colore: link.branca.colore,
stato: link.branca.stato,
cancellato: false,
dataCreazione: link.branca.dataCreazione,
dataModifica: link.branca.dataModifica,
@@ -45,12 +92,13 @@ function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
}));
const categoriaList: CategoriaDto[] = entity.categoriaLinks
.filter((link) => !link.cancellato)
.filter((link) => !link.cancellato && (link.categoria.stato === 'CONFERMATA' || mostraProposte))
.map((link) => ({
id: link.categoria.id,
nome: link.categoria.nome,
padre: link.categoria.padreId,
tipo: toTipologicaDto(link.categoria.tipo),
stato: link.categoria.stato,
cancellato: false,
dataCreazione: link.categoria.dataCreazione,
dataModifica: link.categoria.dataModifica,
@@ -70,12 +118,15 @@ function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
}));
const periodoAnnoList: PeriodoAnnoDto[] = entity.periodoAnnoLinks
.filter((link) => !link.cancellato)
.filter(
(link) => !link.cancellato && (link.periodoAnno.stato === 'CONFERMATA' || mostraProposte),
)
.map((link) => ({
id: link.periodoAnno.id,
nome: link.periodoAnno.nome,
inizioMese: link.periodoAnno.inizioMese,
fineMese: link.periodoAnno.fineMese,
stato: link.periodoAnno.stato,
cancellato: false,
dataCreazione: link.periodoAnno.dataCreazione,
dataModifica: link.periodoAnno.dataModifica,
@@ -107,42 +158,56 @@ function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
materialeList,
paragrafoList,
periodoAnnoList,
noteList,
dataCreazione: entity.dataCreazione,
dataModifica: entity.dataModifica,
utenteModifica: entity.utenteModifica,
};
}
export async function getListaHome(): Promise<AttivitaDto[]> {
export async function getListaHome(auth?: AuthContext): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({
where: { statoId: 'PU' },
include: attivitaInclude,
orderBy: { dataModifica: 'desc' },
});
return entities.map(toAttivitaDto);
return entities.map((entity) => toAttivitaDto(entity, auth));
}
export async function getOne(id: number): Promise<AttivitaDto | null> {
export async function getOne(id: number, auth?: AuthContext): Promise<AttivitaDto | null> {
const entity = await prisma.attivita.findUnique({
where: { id },
include: attivitaInclude,
});
return entity ? toAttivitaDto(entity) : null;
if (!entity) {
return null;
}
// Un'attività non ancora pubblicata (bozza, privata, o in attesa di approvazione) è
// visibile solo a chi può gestirla: chiunque altro la vede come inesistente.
if (entity.statoId !== STATO_PUBBLICATO && !puoGestire(entity, auth)) {
return null;
}
return toAttivitaDto(entity, auth);
}
export async function getListMy(autoreId: string): Promise<AttivitaDto[]> {
export async function getListMy(autoreId: string, auth?: AuthContext): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({
where: { autoreId },
include: attivitaInclude,
orderBy: { dataModifica: 'desc' },
});
return entities.map(toAttivitaDto);
return entities.map((entity) => toAttivitaDto(entity, auth));
}
export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<AttivitaDto[]> {
export async function getListaSearch(
dtoList: SearchObjectDto[],
auth?: AuthContext,
): 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);
@@ -154,7 +219,7 @@ export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<Attivi
.map((d) => d.nome)
.filter((nome): nome is string => !!nome);
const and: Prisma.AttivitaWhereInput[] = [];
const and: Prisma.AttivitaWhereInput[] = [{ statoId: STATO_PUBBLICATO }];
if (brancaIds.length > 0) {
and.push({
@@ -195,7 +260,7 @@ export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<Attivi
orderBy: { dataModifica: 'desc' },
});
return entities.map(toAttivitaDto);
return entities.map((entity) => toAttivitaDto(entity, auth));
}
export async function changeStato(
@@ -211,18 +276,115 @@ export async function changeStato(
throw new HttpError(403, 'non sei autore di questa attività');
}
const nuovoStatoId = risolviStatoPersistito(idStato);
const updated = await prisma.attivita.update({
where: { id: idAttivita },
data: {
statoId: idStato,
statoId: nuovoStatoId,
utenteModifica: auth.name,
},
include: { stato: true },
});
if (nuovoStatoId === STATO_IN_ATTESA && existing.statoId !== STATO_IN_ATTESA) {
await notificheService.creaBroadcastModerazione(
'ATTIVITA_IN_ATTESA',
`Attività da approvare: "${updated.nome}"`,
`/attivita/dettaglio/${updated.id}`,
);
}
return toTipologicaDto(updated.stato);
}
export async function getListaModerazione(auth: AuthContext): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({
where: { statoId: STATO_IN_ATTESA },
include: attivitaInclude,
orderBy: { dataModifica: 'asc' },
});
return entities.map((entity) => toAttivitaDto(entity, auth));
}
async function trovaAttivitaInAttesa(idAttivita: number): Promise<AttivitaWithRelations> {
const entity = await prisma.attivita.findUnique({
where: { id: idAttivita },
include: attivitaInclude,
});
if (!entity) {
throw new HttpError(404, 'attività non trovata');
}
if (entity.statoId !== STATO_IN_ATTESA) {
throw new HttpError(409, 'attività non in attesa di approvazione');
}
return entity;
}
export async function approva(idAttivita: number, auth: AuthContext): Promise<void> {
const entity = await trovaAttivitaInAttesa(idAttivita);
await prisma.attivita.update({
where: { id: idAttivita },
data: {
statoId: STATO_PUBBLICATO,
utenteModifica: auth.name,
},
});
await notificheService.creaPersonale(
'ATTIVITA_PUBBLICATA',
`La tua attività "${entity.nome}" è stata pubblicata`,
entity.autoreId,
`/attivita/dettaglio/${entity.id}`,
);
}
export async function commenta(idAttivita: number, testo: string, auth: AuthContext): Promise<void> {
const entity = await trovaAttivitaInAttesa(idAttivita);
await prisma.$transaction([
prisma.notaAttivita.create({
data: {
attivitaId: idAttivita,
testo,
autore: auth.name,
autoreId: auth.userId,
},
}),
prisma.attivita.update({
where: { id: idAttivita },
data: {
statoId: STATO_BOZZA,
utenteModifica: auth.name,
},
}),
]);
await notificheService.creaPersonale(
'ATTIVITA_BOZZA_NOTA',
`La tua attività "${entity.nome}" è tornata in bozza con una nota di moderazione`,
entity.autoreId,
`/modifica-attivita/${entity.id}`,
);
}
export async function eliminaNota(idNota: number): Promise<void> {
const nota = await prisma.notaAttivita.findUnique({
where: { id: idNota },
include: { attivita: true },
});
if (!nota) {
throw new HttpError(404, 'nota non trovata');
}
if (nota.attivita.statoId !== STATO_PUBBLICATO) {
throw new HttpError(409, 'le note si possono cancellare solo su attività pubblicate');
}
await prisma.notaAttivita.delete({ where: { id: idNota } });
}
type Tx = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>;
async function upsertBranca(
@@ -439,8 +601,12 @@ async function upsertPeriodoAnno(
}
export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<void> {
let entraInAttesa = false;
let savedAttivitaId!: number;
await prisma.$transaction(async (tx) => {
let attivitaId: number;
const nuovoStatoId = risolviStatoPersistito(dto.stato.id);
if (dto.id) {
const existing = await tx.attivita.findUnique({ where: { id: dto.id } });
@@ -455,23 +621,26 @@ export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<v
where: { id: dto.id },
data: {
nome: dto.nome,
statoId: dto.stato.id,
statoId: nuovoStatoId,
utenteModifica: auth.name,
},
});
attivitaId = dto.id;
entraInAttesa = nuovoStatoId === STATO_IN_ATTESA && existing.statoId !== STATO_IN_ATTESA;
} else {
const created = await tx.attivita.create({
data: {
nome: dto.nome,
autore: auth.name,
autoreId: auth.userId,
statoId: dto.stato.id,
statoId: nuovoStatoId,
utenteModifica: auth.name,
},
});
attivitaId = created.id;
entraInAttesa = nuovoStatoId === STATO_IN_ATTESA;
}
savedAttivitaId = attivitaId;
const brancaIds = new Set<number>();
for (const branca of dto.brancaList) {
@@ -557,4 +726,12 @@ export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<v
where: { attivitaId, id: { notIn: [...paragrafoIds] } },
});
});
if (entraInAttesa) {
await notificheService.creaBroadcastModerazione(
'ATTIVITA_IN_ATTESA',
`Attività da approvare: "${dto.nome}"`,
`/attivita/dettaglio/${savedAttivitaId}`,
);
}
}
@@ -3,7 +3,7 @@ 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' } },
where: { nome: { contains: keyword ?? '', mode: 'insensitive' }, stato: 'CONFERMATA' },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'branca' }));
@@ -11,7 +11,7 @@ export async function getBranca(keyword?: string | null): Promise<SearchObjectDt
export async function getCategoria(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.categoria.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
where: { nome: { contains: keyword ?? '', mode: 'insensitive' }, stato: 'CONFERMATA' },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'categoria' }));
@@ -27,7 +27,7 @@ export async function getMateriale(keyword?: string | null): Promise<SearchObjec
export async function getPeriodoAnno(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.periodoAnno.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
where: { nome: { contains: keyword ?? '', mode: 'insensitive' }, stato: 'CONFERMATA' },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'periodoAnno' }));
@@ -0,0 +1,57 @@
import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors';
import * as notificheService from './notifiche.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 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 notificheRouter = Router();
notificheRouter.get(
'/',
asyncHandler(async (req, res) => {
res.status(200).json(await notificheService.listNotifiche(req.auth!));
}),
);
notificheRouter.get(
'/non-lette/count',
asyncHandler(async (req, res) => {
res.status(200).json({ count: await notificheService.countNonLette(req.auth!) });
}),
);
notificheRouter.put(
'/letta-tutte',
asyncHandler(async (req, res) => {
await notificheService.segnaTutteLette(req.auth!);
res.status(204).end();
}),
);
notificheRouter.put(
'/:id/letta',
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await notificheService.segnaLetta(id, req.auth!);
res.status(204).end();
}),
);
export default notificheRouter;
@@ -0,0 +1,95 @@
import { TipoNotifica } from '@prisma/client';
import { prisma } from '../../db/prisma';
import { HttpError } from '../../errors';
import { AuthContext } from '../../middlewares/auth.types';
import { NotificaDto } from '../../types/dto';
const RUOLI_MODERAZIONE = ['admin', 'moderatore'];
function isModeratore(auth: AuthContext): boolean {
return RUOLI_MODERAZIONE.some((ruolo) => auth.roles.includes(ruolo));
}
function toDto(entity: {
id: number;
tipo: TipoNotifica;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: Date;
}): NotificaDto {
return {
id: entity.id,
tipo: entity.tipo,
messaggio: entity.messaggio,
link: entity.link,
letta: entity.letta,
dataCreazione: entity.dataCreazione,
};
}
// Le notifiche "broadcast" (destinatarioId null) sono la coda di moderazione condivisa tra
// admin/moderatore: sono visibili solo a chi ha uno di questi ruoli, oltre alle proprie
// notifiche personali.
function whereVisibili(auth: AuthContext) {
return isModeratore(auth)
? { OR: [{ destinatarioId: auth.userId }, { destinatarioId: null }] }
: { destinatarioId: auth.userId };
}
export async function listNotifiche(auth: AuthContext): Promise<NotificaDto[]> {
const entities = await prisma.notifica.findMany({
where: whereVisibili(auth),
orderBy: { dataCreazione: 'desc' },
take: 50,
});
return entities.map(toDto);
}
export async function countNonLette(auth: AuthContext): Promise<number> {
return prisma.notifica.count({ where: { ...whereVisibili(auth), letta: false } });
}
export async function segnaLetta(id: number, auth: AuthContext): Promise<void> {
const entity = await prisma.notifica.findUnique({ where: { id } });
if (!entity) {
throw new HttpError(404, 'notifica non trovata');
}
const visibile =
entity.destinatarioId === auth.userId || (entity.destinatarioId === null && isModeratore(auth));
if (!visibile) {
throw new HttpError(403, 'non puoi accedere a questa notifica');
}
await prisma.notifica.update({ where: { id }, data: { letta: true } });
}
export async function segnaTutteLette(auth: AuthContext): Promise<void> {
await prisma.notifica.updateMany({
where: { ...whereVisibili(auth), letta: false },
data: { letta: true },
});
}
// Notifica destinata a chiunque abbia ruolo admin/moderatore (coda di moderazione condivisa,
// vedi whereVisibili): usata per segnalare nuove proposte/attività in attesa di revisione.
export async function creaBroadcastModerazione(
tipo: TipoNotifica,
messaggio: string,
link?: string,
): Promise<void> {
await prisma.notifica.create({
data: { tipo, messaggio, link: link ?? null, destinatarioId: null },
});
}
export async function creaPersonale(
tipo: TipoNotifica,
messaggio: string,
destinatarioId: string,
link?: string,
): Promise<void> {
await prisma.notifica.create({
data: { tipo, messaggio, link: link ?? null, destinatarioId },
});
}
@@ -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;
}