diff --git a/scouthub-attivita-be/prisma/migrations/20260725000000_init/migration.sql b/scouthub-attivita-be/prisma/migrations/20260725000000_init/migration.sql index 26ebb0f..11f98bb 100644 --- a/scouthub-attivita-be/prisma/migrations/20260725000000_init/migration.sql +++ b/scouthub-attivita-be/prisma/migrations/20260725000000_init/migration.sql @@ -1,3 +1,9 @@ +-- CreateEnum +CREATE TYPE "StatoTassonomia" AS ENUM ('CONFERMATA', 'DA_APPROVARE'); + +-- CreateEnum +CREATE TYPE "TipoNotifica" AS ENUM ('TASSONOMIA_PROPOSTA', 'ATTIVITA_IN_ATTESA', 'ATTIVITA_PUBBLICATA', 'ATTIVITA_BOZZA_NOTA'); + -- CreateTable CREATE TABLE "stato_attivita" ( "id" VARCHAR(2) NOT NULL, @@ -56,6 +62,8 @@ CREATE TABLE "branca" ( "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_modifica" TIMESTAMP(3) NOT NULL, "utente_modifica" TEXT NOT NULL, + "creato_da_id" TEXT, + "stato" "StatoTassonomia" NOT NULL DEFAULT 'CONFERMATA', CONSTRAINT "branca_pkey" PRIMARY KEY ("id") ); @@ -81,6 +89,8 @@ CREATE TABLE "periodo_anno" ( "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_modifica" TIMESTAMP(3) NOT NULL, "utente_modifica" TEXT NOT NULL, + "creato_da_id" TEXT, + "stato" "StatoTassonomia" NOT NULL DEFAULT 'CONFERMATA', CONSTRAINT "periodo_anno_pkey" PRIMARY KEY ("id") ); @@ -121,6 +131,8 @@ CREATE TABLE "categoria" ( "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_modifica" TIMESTAMP(3) NOT NULL, "utente_modifica" TEXT NOT NULL, + "creato_da_id" TEXT, + "stato" "StatoTassonomia" NOT NULL DEFAULT 'CONFERMATA', CONSTRAINT "categoria_pkey" PRIMARY KEY ("id") ); @@ -174,6 +186,34 @@ CREATE TABLE "categoria_materiale" ( CONSTRAINT "categoria_materiale_pkey" PRIMARY KEY ("materiale_id","categoria_id") ); +-- CreateTable +CREATE TABLE "notifica" ( + "id" SERIAL NOT NULL, + "tipo" "TipoNotifica" NOT NULL, + "messaggio" TEXT NOT NULL, + "link" TEXT, + "destinatario_id" TEXT, + "letta" BOOLEAN NOT NULL DEFAULT false, + "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifica_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notifica_destinatario_id_idx" ON "notifica"("destinatario_id"); + +-- CreateTable +CREATE TABLE "nota_attivita" ( + "id" SERIAL NOT NULL, + "attivita_id" INTEGER NOT NULL, + "testo" VARCHAR(1000) NOT NULL, + "autore" TEXT NOT NULL, + "autore_id" TEXT NOT NULL, + "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "nota_attivita_pkey" PRIMARY KEY ("id") +); + -- AddForeignKey ALTER TABLE "attivita" ADD CONSTRAINT "attivita_padre_fkey" FOREIGN KEY ("padre") REFERENCES "attivita"("id") ON DELETE SET NULL ON UPDATE CASCADE; @@ -222,3 +262,6 @@ ALTER TABLE "categoria_materiale" ADD CONSTRAINT "categoria_materiale_materiale_ -- 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; +-- AddForeignKey +ALTER TABLE "nota_attivita" ADD CONSTRAINT "nota_attivita_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/scouthub-attivita-be/prisma/migrations/migration_lock.toml b/scouthub-attivita-be/prisma/migrations/migration_lock.toml index 99e4f20..fbffa92 100644 --- a/scouthub-attivita-be/prisma/migrations/migration_lock.toml +++ b/scouthub-attivita-be/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "postgresql" +provider = "postgresql" \ No newline at end of file diff --git a/scouthub-attivita-be/prisma/schema.prisma b/scouthub-attivita-be/prisma/schema.prisma index f00f980..c188e21 100644 --- a/scouthub-attivita-be/prisma/schema.prisma +++ b/scouthub-attivita-be/prisma/schema.prisma @@ -7,6 +7,33 @@ datasource db { url = env("DATABASE_URL") } +enum StatoTassonomia { + CONFERMATA + DA_APPROVARE +} + +enum TipoNotifica { + TASSONOMIA_PROPOSTA + ATTIVITA_IN_ATTESA + ATTIVITA_PUBBLICATA + ATTIVITA_BOZZA_NOTA +} + +// destinatarioId null = notifica "broadcast" visibile a chiunque abbia ruolo admin/moderatore +// (usata per segnalare nuove proposte/attività da revisionare); altrimenti è personale. +model Notifica { + id Int @id @default(autoincrement()) + tipo TipoNotifica + messaggio String + link String? + destinatarioId String? @map("destinatario_id") + letta Boolean @default(false) + dataCreazione DateTime @default(now()) @map("data_creazione") + + @@index([destinatarioId]) + @@map("notifica") +} + model StatoAttivita { id String @id @db.VarChar(2) nome String @@ -63,16 +90,32 @@ model Attivita { categoriaLinks CategoriaAttivita[] materialeLinks MaterialeAttivita[] periodoAnnoLinks PeriodoAnnoAttivita[] + noteList NotaAttivita[] @@map("attivita") } +model NotaAttivita { + id Int @id @default(autoincrement()) + attivitaId Int @map("attivita_id") + testo String @db.VarChar(1000) + autore String + autoreId String @map("autore_id") + dataCreazione DateTime @default(now()) @map("data_creazione") + + attivita Attivita @relation(fields: [attivitaId], references: [id]) + + @@map("nota_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) + stato StatoTassonomia @default(CONFERMATA) + creatoDaId String? @map("creato_da_id") dataCreazione DateTime @default(now()) @map("data_creazione") dataModifica DateTime @updatedAt @map("data_modifica") utenteModifica String @map("utente_modifica") @@ -102,6 +145,8 @@ model PeriodoAnno { nome String inizioMese Int? @map("inizio_mese") fineMese Int? @map("fine_mese") + stato StatoTassonomia @default(CONFERMATA) + creatoDaId String? @map("creato_da_id") dataCreazione DateTime @default(now()) @map("data_creazione") dataModifica DateTime @updatedAt @map("data_modifica") utenteModifica String @map("utente_modifica") @@ -148,6 +193,8 @@ model Categoria { nome String padreId Int? @map("padre") tipoId String @map("tipo") + stato StatoTassonomia @default(CONFERMATA) + creatoDaId String? @map("creato_da_id") dataCreazione DateTime @default(now()) @map("data_creazione") dataModifica DateTime @updatedAt @map("data_modifica") utenteModifica String @map("utente_modifica") diff --git a/scouthub-attivita-be/prisma/seed.ts b/scouthub-attivita-be/prisma/seed.ts index 222eb6d..a4fe1e5 100644 --- a/scouthub-attivita-be/prisma/seed.ts +++ b/scouthub-attivita-be/prisma/seed.ts @@ -10,6 +10,7 @@ async function main() { { id: "PU", nome: "Pubblicato" }, { id: "BO", nome: "Bozza" }, { id: "PR", nome: "Privato" }, + { id: "IA", nome: "In attesa di approvazione" }, ].map((stato) => prisma.statoAttivita.upsert({ where: { id: stato.id }, @@ -102,6 +103,19 @@ async function main() { create: { ...categoria, utenteModifica: UTENTE_MODIFICA }, }); } + + // Branca/Categoria/PeriodoAnno sopra sono inserite con id espliciti: l'upsert non passa + // mai dal DEFAULT della colonna, quindi la sequence Postgres dell'id non avanza e resta + // disallineata rispetto al MAX(id) reale. Se non la si risincronizza qui, la prima riga + // creata in seguito con id auto-generato (es. una tassonomia proposta da un utente) va in + // conflitto su un id già esistente. + await Promise.all( + ["branca", "categoria", "periodo_anno", "materiale"].map((tabella) => + prisma.$executeRawUnsafe( + `SELECT setval(pg_get_serial_sequence('"${tabella}"', 'id'), COALESCE((SELECT MAX(id) FROM "${tabella}"), 1))` + ) + ) + ); } main() diff --git a/scouthub-attivita-be/src/app.ts b/scouthub-attivita-be/src/app.ts index 9796510..5a8f3be 100644 --- a/scouthub-attivita-be/src/app.ts +++ b/scouthub-attivita-be/src/app.ts @@ -3,9 +3,12 @@ import cors from 'cors'; import { env } from './config/env'; import { authenticate } from './middlewares/authenticate'; import { errorHandler } from './middlewares/errorHandler'; +import { optionalAuthenticate } from './middlewares/optionalAuthenticate'; import { attivitaPublicRouter } from './modules/attivita/attivita.router.public'; import { attivitaPrivateRouter } from './modules/attivita/attivita.router.private'; import { autocompleteRouter } from './modules/autocomplete/autocomplete.router'; +import { notificheRouter } from './modules/notifiche/notifiche.router'; +import { tassonomieRouter } from './modules/tassonomie/tassonomie.router'; export const app = express(); @@ -19,9 +22,11 @@ app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); -app.use('/public/attivita', attivitaPublicRouter); +app.use('/public/attivita', optionalAuthenticate, attivitaPublicRouter); app.use('/private/attivita', authenticate, attivitaPrivateRouter); app.use('/public/autocomplete', autocompleteRouter); +app.use('/private/tassonomie', authenticate, tassonomieRouter); +app.use('/private/notifiche', authenticate, notificheRouter); app.use((req, res) => { res.status(404).json({ message: 'not found' }); diff --git a/scouthub-attivita-be/src/middlewares/optionalAuthenticate.ts b/scouthub-attivita-be/src/middlewares/optionalAuthenticate.ts new file mode 100644 index 0000000..30c90f3 --- /dev/null +++ b/scouthub-attivita-be/src/middlewares/optionalAuthenticate.ts @@ -0,0 +1,71 @@ +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 ?? [], + }; +} + +// Come authenticate.ts, ma se il token manca o non è valido lascia proseguire la richiesta +// senza req.auth invece di rispondere 401: serve sulle route pubbliche che devono comunque +// sapere chi è l'utente quando è loggato (es. per mostrargli le proprie tassonomie in attesa +// di approvazione), senza per questo richiedere l'autenticazione a chi non è loggato. +export async function optionalAuthenticate(req: Request, res: Response, next: NextFunction): Promise { + const token = extractBearerToken(req); + if (!token) { + next(); + 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); + } catch { + // token presente ma non valido: procediamo comunque come utente anonimo + } + next(); +} diff --git a/scouthub-attivita-be/src/middlewares/requireRole.ts b/scouthub-attivita-be/src/middlewares/requireRole.ts new file mode 100644 index 0000000..e4981af --- /dev/null +++ b/scouthub-attivita-be/src/middlewares/requireRole.ts @@ -0,0 +1,14 @@ +import { Request, Response, NextFunction } from 'express'; + +export function requireRole(...ruoliAmmessi: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + const roles = req.auth?.roles ?? []; + const autorizzato = ruoliAmmessi.some((ruolo) => roles.includes(ruolo)); + + if (!autorizzato) { + res.status(403).json({ message: 'Ruolo non autorizzato' }); + return; + } + next(); + }; +} diff --git a/scouthub-attivita-be/src/modules/attivita/attivita.router.private.ts b/scouthub-attivita-be/src/modules/attivita/attivita.router.private.ts index b29f8c5..b039254 100644 --- a/scouthub-attivita-be/src/modules/attivita/attivita.router.private.ts +++ b/scouthub-attivita-be/src/modules/attivita/attivita.router.private.ts @@ -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, ) { @@ -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(); + }), +); diff --git a/scouthub-attivita-be/src/modules/attivita/attivita.router.public.ts b/scouthub-attivita-be/src/modules/attivita/attivita.router.public.ts index 963b77f..82f3b86 100644 --- a/scouthub-attivita-be/src/modules/attivita/attivita.router.public.ts +++ b/scouthub-attivita-be/src/modules/attivita/attivita.router.public.ts @@ -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; diff --git a/scouthub-attivita-be/src/modules/attivita/attivita.service.ts b/scouthub-attivita-be/src/modules/attivita/attivita.service.ts index de1055b..8b53fa1 100644 --- a/scouthub-attivita-be/src/modules/attivita/attivita.service.ts +++ b/scouthub-attivita-be/src/modules/attivita/attivita.service.ts @@ -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 { +export async function getListaHome(auth?: AuthContext): Promise { 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 { +export async function getOne(id: number, auth?: AuthContext): Promise { 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 { +export async function getListMy(autoreId: string, auth?: AuthContext): Promise { 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 { +export async function getListaSearch( + dtoList: SearchObjectDto[], + auth?: AuthContext, +): Promise { 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 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 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 { + 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 { + 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 { + 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 { + 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 { + 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; async function upsertBranca( @@ -439,8 +601,12 @@ async function upsertPeriodoAnno( } export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise { + 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(); for (const branca of dto.brancaList) { @@ -557,4 +726,12 @@ export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise { 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 { 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 { 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' })); diff --git a/scouthub-attivita-be/src/modules/notifiche/notifiche.router.ts b/scouthub-attivita-be/src/modules/notifiche/notifiche.router.ts new file mode 100644 index 0000000..7033826 --- /dev/null +++ b/scouthub-attivita-be/src/modules/notifiche/notifiche.router.ts @@ -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, +) { + 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; diff --git a/scouthub-attivita-be/src/modules/notifiche/notifiche.service.ts b/scouthub-attivita-be/src/modules/notifiche/notifiche.service.ts new file mode 100644 index 0000000..5ed3a55 --- /dev/null +++ b/scouthub-attivita-be/src/modules/notifiche/notifiche.service.ts @@ -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 { + 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 { + return prisma.notifica.count({ where: { ...whereVisibili(auth), letta: false } }); +} + +export async function segnaLetta(id: number, auth: AuthContext): Promise { + 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 { + 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 { + 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 { + await prisma.notifica.create({ + data: { tipo, messaggio, link: link ?? null, destinatarioId }, + }); +} diff --git a/scouthub-attivita-be/src/modules/tassonomie/tassonomie.router.ts b/scouthub-attivita-be/src/modules/tassonomie/tassonomie.router.ts new file mode 100644 index 0000000..48c1967 --- /dev/null +++ b/scouthub-attivita-be/src/modules/tassonomie/tassonomie.router.ts @@ -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, +) { + 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); + }), +); diff --git a/scouthub-attivita-be/src/modules/tassonomie/tassonomie.service.ts b/scouthub-attivita-be/src/modules/tassonomie/tassonomie.service.ts new file mode 100644 index 0000000..23e2240 --- /dev/null +++ b/scouthub-attivita-be/src/modules/tassonomie/tassonomie.service.ts @@ -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 { + return prisma.branca.findMany({ orderBy: { nome: 'asc' } }); +} + +export async function createBranca(input: BrancaAdminInput, auth: AuthContext): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return prisma.categoria.findMany({ orderBy: { nome: 'asc' } }); +} + +export async function listTipoCategoria(): Promise { + return prisma.tipoCategoria.findMany({ orderBy: { nome: 'asc' } }); +} + +export async function createCategoria( + input: CategoriaAdminInput, + auth: AuthContext, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return prisma.periodoAnno.findMany({ orderBy: { nome: 'asc' } }); +} + +export async function createPeriodoAnno( + input: PeriodoAnnoAdminInput, + auth: AuthContext, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(promise: Promise, message: string): Promise { + const entity = await promise; + if (!entity) { + throw new HttpError(404, message); + } + return entity; +} diff --git a/scouthub-attivita-be/src/types/dto.ts b/scouthub-attivita-be/src/types/dto.ts index af188ab..3e2aba1 100644 --- a/scouthub-attivita-be/src/types/dto.ts +++ b/scouthub-attivita-be/src/types/dto.ts @@ -3,6 +3,8 @@ export interface TipologicaDto { nome: string; } +export type StatoTassonomiaDto = 'CONFERMATA' | 'DA_APPROVARE'; + export interface BaseDto { dataCreazione: Date; dataModifica: Date; @@ -15,6 +17,7 @@ export interface BrancaDto extends BaseDto { inizioEta: number | null; fineEta: number | null; colore: string | null; + stato: StatoTassonomiaDto; cancellato: boolean; } @@ -23,6 +26,7 @@ export interface CategoriaDto extends BaseDto { nome: string; padre: number | null; tipo: TipologicaDto; + stato: StatoTassonomiaDto; cancellato: boolean; } @@ -48,9 +52,18 @@ export interface PeriodoAnnoDto extends BaseDto { nome: string; inizioMese: number | null; fineMese: number | null; + stato: StatoTassonomiaDto; cancellato: boolean; } +export interface NotaDto { + id: number; + attivitaId: number; + testo: string; + autore: string; + dataCreazione: Date; +} + export interface AttivitaDto extends BaseDto { id: number | null; nome: string; @@ -62,6 +75,18 @@ export interface AttivitaDto extends BaseDto { materialeList: MaterialeDto[]; paragrafoList: ParagrafoDto[]; periodoAnnoList: PeriodoAnnoDto[]; + // Popolata solo se il richiedente è l'autore o ha ruolo admin/moderatore (vedi puoGestire + // in attivita.service.ts); per chiunque altro arriva sempre vuota, anche se esistono note. + noteList: NotaDto[]; +} + +export interface NotificaDto { + id: number; + tipo: string; + messaggio: string; + link: string | null; + letta: boolean; + dataCreazione: Date; } export interface SearchObjectDto { diff --git a/scouthub-attivita-be/src/types/validation.ts b/scouthub-attivita-be/src/types/validation.ts index 1cc13df..b16bf61 100644 --- a/scouthub-attivita-be/src/types/validation.ts +++ b/scouthub-attivita-be/src/types/validation.ts @@ -49,6 +49,39 @@ export const attivitaSaveSchema = z.object({ paragrafoList: z.array(paragrafoInputSchema), }); +export const brancaAdminSchema = z.object({ + nome: z.string().min(1), + inizioEta: z.number().int().nullable().optional(), + fineEta: z.number().int().nullable().optional(), + colore: z.string().max(10).nullable().optional(), +}); + +export const categoriaAdminSchema = z.object({ + nome: z.string().min(1), + padreId: z.number().int().nullable().optional(), + tipoId: z.string().min(1), +}); + +export const periodoAnnoAdminSchema = z.object({ + nome: z.string().min(1), + inizioMese: z.number().int().min(1).max(12).nullable().optional(), + fineMese: z.number().int().min(1).max(12).nullable().optional(), +}); + +export const proponiTassonomiaSchema = z.object({ + nome: z.string().min(1), +}); + +export const notaAttivitaSchema = z.object({ + testo: z.string().min(1).max(1000), +}); + +export type BrancaAdminInput = z.infer; +export type CategoriaAdminInput = z.infer; +export type PeriodoAnnoAdminInput = z.infer; +export type ProponiTassonomiaInput = z.infer; +export type NotaAttivitaInput = z.infer; + export type TipologicaInput = z.infer; export type ParagrafoInput = z.infer; export type BrancaInput = z.infer; diff --git a/scouthub-attivita-fe/package.json b/scouthub-attivita-fe/package.json index 3f0eac8..1c29acb 100644 --- a/scouthub-attivita-fe/package.json +++ b/scouthub-attivita-fe/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "ng serve --port 7001", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test" diff --git a/scouthub-attivita-fe/src/app/app.routes.ts b/scouthub-attivita-fe/src/app/app.routes.ts index 6a700f8..6f18376 100644 --- a/scouthub-attivita-fe/src/app/app.routes.ts +++ b/scouthub-attivita-fe/src/app/app.routes.ts @@ -1,20 +1,21 @@ import { Routes } from '@angular/router'; import { authGuard } from './core/auth/auth.guard'; +import { requireModeratoreGuard } from './core/auth/require-moderatore.guard'; import { ActivityForm } from './pages/activity-form/activity-form'; import { Detail } from './pages/detail/detail'; import { Home } from './pages/home/home'; import { MyActivity } from './pages/my-activity/my-activity'; -import { Profile } from './pages/profile/profile'; import { Search } from './pages/search/search'; +import { Tassonomie } from './pages/tassonomie/tassonomie'; export const routes: Routes = [ { path: '', component: Home }, { path: 'ricerca', component: Search }, { path: 'attivita/dettaglio/:idAttivita', component: Detail }, { path: 'mie-attivita', component: MyActivity, canActivate: [authGuard] }, - { path: 'profilo', component: Profile, canActivate: [authGuard] }, { path: 'nuova-attivita', component: ActivityForm, canActivate: [authGuard] }, { path: 'modifica-attivita/:idAttivita', component: ActivityForm, canActivate: [authGuard] }, + { path: 'tassonomie', component: Tassonomie, canActivate: [authGuard, requireModeratoreGuard] }, { path: '**', redirectTo: '' }, ]; diff --git a/scouthub-attivita-fe/src/app/core/auth/require-moderatore.guard.ts b/scouthub-attivita-fe/src/app/core/auth/require-moderatore.guard.ts new file mode 100644 index 0000000..af4e76c --- /dev/null +++ b/scouthub-attivita-fe/src/app/core/auth/require-moderatore.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import Keycloak from 'keycloak-js'; + +import { MODERATORE_ROLE, extractRealmRoles } from './roles'; + +// La gestione delle tassonomie (Branca/Categoria/Periodo dell'anno) è riservata al ruolo +// realm moderatore (admin lo include come composite role). Da usare insieme a authGuard. +export const requireModeratoreGuard: CanActivateFn = () => { + const router = inject(Router); + const keycloak = inject(Keycloak); + + const roles = extractRealmRoles(keycloak.tokenParsed); + return roles.includes(MODERATORE_ROLE) ? true : router.parseUrl('/'); +}; diff --git a/scouthub-attivita-fe/src/app/core/auth/roles.ts b/scouthub-attivita-fe/src/app/core/auth/roles.ts new file mode 100644 index 0000000..a1fda51 --- /dev/null +++ b/scouthub-attivita-fe/src/app/core/auth/roles.ts @@ -0,0 +1,7 @@ +import { KeycloakTokenParsed } from 'keycloak-js'; + +export const MODERATORE_ROLE = 'moderatore'; + +export function extractRealmRoles(tokenParsed: KeycloakTokenParsed | undefined): string[] { + return tokenParsed?.realm_access?.roles ?? []; +} diff --git a/scouthub-attivita-fe/src/app/core/models/attivita.model.ts b/scouthub-attivita-fe/src/app/core/models/attivita.model.ts index 357444f..f00f9f9 100644 --- a/scouthub-attivita-fe/src/app/core/models/attivita.model.ts +++ b/scouthub-attivita-fe/src/app/core/models/attivita.model.ts @@ -8,6 +8,10 @@ export interface Stato extends BaseTipologica {} export const STATO_BOZZA: Stato = { id: 'BO', nome: 'Bozza' }; export const STATO_PUBBLICATO: Stato = { id: 'PU', nome: 'Pubblicato' }; export const STATO_PRIVATO: Stato = { id: 'PR', nome: 'Privato' }; +// Non selezionabile direttamente dall'autore: è lo stato in cui il backend porta +// un'attività quando l'autore sceglie "Pubblicato", in attesa dell'approvazione di +// admin/moderatore (vedi risolviStatoPersistito in attivita.service.ts sul backend). +export const STATO_IN_ATTESA: Stato = { id: 'IA', nome: 'In attesa di approvazione' }; export const STATI: Stato[] = [STATO_BOZZA, STATO_PUBBLICATO, STATO_PRIVATO]; export interface TipoParagrafo extends BaseTipologica {} @@ -17,12 +21,15 @@ export const TIPO_PARAGRAFO: TipoParagrafo = { id: 'PARAGRAFO', nome: 'Paragrafo export interface TipoCategoria extends BaseTipologica {} +export type StatoTassonomia = 'CONFERMATA' | 'DA_APPROVARE'; + export interface Branca { id: number; nome: string; inizioEta?: number; fineEta?: number; colore?: string; + stato?: StatoTassonomia; } export interface Categoria { @@ -30,6 +37,7 @@ export interface Categoria { nome: string; tipo?: TipoCategoria; padre?: number | null; + stato?: StatoTassonomia; } export interface Materiale { @@ -43,6 +51,7 @@ export interface PeriodoAnno { nome: string; inizioMese?: number | null; fineMese?: number | null; + stato?: StatoTassonomia; } export interface Paragrafo { @@ -52,6 +61,14 @@ export interface Paragrafo { ordine: number; } +export interface Nota { + id: number; + attivitaId: number; + testo: string; + autore: string; + dataCreazione: string; +} + export interface Attivita { id?: number; nome: string; @@ -63,6 +80,9 @@ export interface Attivita { categoriaList: Categoria[]; materialeList: Materiale[]; periodoAnnoList: PeriodoAnno[]; + // Popolata dal backend solo per l'autore o per admin/moderatore (vedi puoGestire su + // attivita.service.ts); vuota per chiunque altro anche quando esistono note. + noteList?: Nota[]; dataCreazione?: string; dataModifica?: string; utenteModifica?: string; diff --git a/scouthub-attivita-fe/src/app/core/models/notifica.model.ts b/scouthub-attivita-fe/src/app/core/models/notifica.model.ts new file mode 100644 index 0000000..d85773c --- /dev/null +++ b/scouthub-attivita-fe/src/app/core/models/notifica.model.ts @@ -0,0 +1,14 @@ +export type TipoNotifica = + | 'TASSONOMIA_PROPOSTA' + | 'ATTIVITA_IN_ATTESA' + | 'ATTIVITA_PUBBLICATA' + | 'ATTIVITA_BOZZA_NOTA'; + +export interface Notifica { + id: number; + tipo: TipoNotifica; + messaggio: string; + link: string | null; + letta: boolean; + dataCreazione: string; +} diff --git a/scouthub-attivita-fe/src/app/core/models/tassonomia.model.ts b/scouthub-attivita-fe/src/app/core/models/tassonomia.model.ts new file mode 100644 index 0000000..390c126 --- /dev/null +++ b/scouthub-attivita-fe/src/app/core/models/tassonomia.model.ts @@ -0,0 +1,56 @@ +export type StatoTassonomia = 'CONFERMATA' | 'DA_APPROVARE'; + +export interface Branca { + id: number; + nome: string; + inizioEta: number | null; + fineEta: number | null; + colore: string | null; + stato: StatoTassonomia; + creatoDaId: string | null; + dataCreazione: string; + dataModifica: string; + utenteModifica: string; +} + +export interface TipoCategoria { + id: string; + nome: string; +} + +export interface Categoria { + id: number; + nome: string; + padreId: number | null; + tipoId: string; + stato: StatoTassonomia; + creatoDaId: string | null; + dataCreazione: string; + dataModifica: string; + utenteModifica: string; +} + +export interface PeriodoAnno { + id: number; + nome: string; + inizioMese: number | null; + fineMese: number | null; + stato: StatoTassonomia; + creatoDaId: string | null; + dataCreazione: string; + dataModifica: string; + utenteModifica: string; +} + +export type BrancaInput = Omit< + Branca, + 'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica' +>; +export type CategoriaInput = Omit< + Categoria, + 'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica' +>; +export type PeriodoAnnoInput = Omit< + PeriodoAnno, + 'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica' +>; diff --git a/scouthub-attivita-fe/src/app/core/services/attivita.ts b/scouthub-attivita-fe/src/app/core/services/attivita.ts index 350c403..7f7c5dd 100644 --- a/scouthub-attivita-fe/src/app/core/services/attivita.ts +++ b/scouthub-attivita-fe/src/app/core/services/attivita.ts @@ -37,4 +37,20 @@ export class AttivitaService { save(attivita: Attivita): Observable { return this.http.post(`${this.privateUrl}/save`, attivita); } + + getListModerazione(): Observable { + return this.http.get(`${this.privateUrl}/get/lista/moderazione`); + } + + approva(idAttivita: number): Observable { + return this.http.post(`${this.privateUrl}/${idAttivita}/approva`, {}); + } + + commenta(idAttivita: number, testo: string): Observable { + return this.http.post(`${this.privateUrl}/${idAttivita}/commenta`, { testo }); + } + + eliminaNota(idNota: number): Observable { + return this.http.delete(`${this.privateUrl}/note/${idNota}`); + } } diff --git a/scouthub-attivita-fe/src/app/core/services/notifiche.ts b/scouthub-attivita-fe/src/app/core/services/notifiche.ts new file mode 100644 index 0000000..2fc8dca --- /dev/null +++ b/scouthub-attivita-fe/src/app/core/services/notifiche.ts @@ -0,0 +1,30 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { Notifica } from '../models/notifica.model'; + +@Injectable({ + providedIn: 'root', +}) +export class NotificheService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/private/notifiche`; + + getLista(): Observable { + return this.http.get(`${this.baseUrl}/`); + } + + getCountNonLette(): Observable<{ count: number }> { + return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`); + } + + segnaLetta(id: number): Observable { + return this.http.put(`${this.baseUrl}/${id}/letta`, {}); + } + + segnaTutteLette(): Observable { + return this.http.put(`${this.baseUrl}/letta-tutte`, {}); + } +} diff --git a/scouthub-attivita-fe/src/app/core/services/tassonomie.ts b/scouthub-attivita-fe/src/app/core/services/tassonomie.ts new file mode 100644 index 0000000..518c3a9 --- /dev/null +++ b/scouthub-attivita-fe/src/app/core/services/tassonomie.ts @@ -0,0 +1,110 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { + Branca, + BrancaInput, + Categoria, + CategoriaInput, + PeriodoAnno, + PeriodoAnnoInput, + TipoCategoria, +} from '../models/tassonomia.model'; + +@Injectable({ + providedIn: 'root', +}) +export class TassonomieService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/private/tassonomie`; + + listBranca(): Observable { + return this.http.get(`${this.baseUrl}/branca`); + } + + createBranca(input: BrancaInput): Observable { + return this.http.post(`${this.baseUrl}/branca`, input); + } + + updateBranca(id: number, input: BrancaInput): Observable { + return this.http.put(`${this.baseUrl}/branca/${id}`, input); + } + + deleteBranca(id: number): Observable { + return this.http.delete(`${this.baseUrl}/branca/${id}`); + } + + proponiBranca(nome: string): Observable { + return this.http.post(`${this.baseUrl}/proposte/branca`, { nome }); + } + + approvaBranca(id: number): Observable { + return this.http.post(`${this.baseUrl}/branca/${id}/approva`, {}); + } + + rifiutaBranca(id: number): Observable { + return this.http.post(`${this.baseUrl}/branca/${id}/rifiuta`, {}); + } + + listCategoria(): Observable { + return this.http.get(`${this.baseUrl}/categoria`); + } + + listTipoCategoria(): Observable { + return this.http.get(`${this.baseUrl}/tipo-categoria`); + } + + createCategoria(input: CategoriaInput): Observable { + return this.http.post(`${this.baseUrl}/categoria`, input); + } + + updateCategoria(id: number, input: CategoriaInput): Observable { + return this.http.put(`${this.baseUrl}/categoria/${id}`, input); + } + + deleteCategoria(id: number): Observable { + return this.http.delete(`${this.baseUrl}/categoria/${id}`); + } + + proponiCategoria(nome: string): Observable { + return this.http.post(`${this.baseUrl}/proposte/categoria`, { nome }); + } + + approvaCategoria(id: number): Observable { + return this.http.post(`${this.baseUrl}/categoria/${id}/approva`, {}); + } + + rifiutaCategoria(id: number): Observable { + return this.http.post(`${this.baseUrl}/categoria/${id}/rifiuta`, {}); + } + + listPeriodoAnno(): Observable { + return this.http.get(`${this.baseUrl}/periodoAnno`); + } + + createPeriodoAnno(input: PeriodoAnnoInput): Observable { + return this.http.post(`${this.baseUrl}/periodoAnno`, input); + } + + updatePeriodoAnno(id: number, input: PeriodoAnnoInput): Observable { + return this.http.put(`${this.baseUrl}/periodoAnno/${id}`, input); + } + + deletePeriodoAnno(id: number): Observable { + return this.http.delete(`${this.baseUrl}/periodoAnno/${id}`); + } + + proponiPeriodoAnno(nome: string): Observable { + return this.http.post(`${this.baseUrl}/proposte/periodoAnno`, { nome }); + } + + approvaPeriodoAnno(id: number): Observable { + return this.http.post(`${this.baseUrl}/periodoAnno/${id}/approva`, {}); + } + + rifiutaPeriodoAnno(id: number): Observable { + return this.http.post(`${this.baseUrl}/periodoAnno/${id}/rifiuta`, {}); + } +} diff --git a/scouthub-attivita-fe/src/app/core/utils/stato-style.ts b/scouthub-attivita-fe/src/app/core/utils/stato-style.ts index 1be0c7f..f23b177 100644 --- a/scouthub-attivita-fe/src/app/core/utils/stato-style.ts +++ b/scouthub-attivita-fe/src/app/core/utils/stato-style.ts @@ -11,6 +11,9 @@ export function statoStyle(idStato: string): StatoStyle { if (idStato === 'PR') { return { bg: 'var(--color-periodo-bg)', color: 'var(--color-periodo-text)', border: 'var(--color-periodo-border)' }; } + if (idStato === 'IA') { + return { bg: 'var(--color-attesa-bg)', color: 'var(--color-attesa-text)', border: 'var(--color-attesa-border)' }; + } return { bg: 'var(--color-neutral-bg)', color: 'var(--color-neutral-text)', border: 'var(--color-neutral-border)' }; } diff --git a/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.css b/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.css index 8049f74..983c2d6 100644 --- a/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.css +++ b/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.css @@ -44,6 +44,11 @@ gap: 24px; } +.errore-inline { + color: var(--color-danger); + font-size: 13px; +} + .field { font-weight: 700; font-size: 14px; diff --git a/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.html b/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.html index b1a37da..4482922 100644 --- a/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.html +++ b/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.html @@ -47,29 +47,38 @@ @if (step() === 1) {
+ @if (classificazioneErrore()) { +
{{ classificazioneErrore() }}
+ }
} diff --git a/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.ts b/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.ts index 49637d0..95ac332 100644 --- a/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.ts +++ b/scouthub-attivita-fe/src/app/pages/activity-form/activity-form.ts @@ -1,6 +1,8 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Component, Signal, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import Keycloak from 'keycloak-js'; +import { Observable, catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs'; import { Attivita, @@ -17,10 +19,12 @@ import { } from '../../core/models/attivita.model'; import { AttivitaService } from '../../core/services/attivita'; import { AutocompleteService } from '../../core/services/autocomplete'; +import { TassonomieService } from '../../core/services/tassonomie'; import { statoStyle } from '../../core/utils/stato-style'; import { ChipField, ChipOption } from '../../shared/chip-field/chip-field'; const STEP_LABELS = ['Info base', 'Classificazione', 'Materiale', 'Paragrafi', 'Riepilogo']; +const AUTOCOMPLETE_DEBOUNCE_MS = 300; @Component({ selector: 'app-activity-form', @@ -33,6 +37,7 @@ export class ActivityForm { private readonly router = inject(Router); private readonly attivitaService = inject(AttivitaService); private readonly autocompleteService = inject(AutocompleteService); + private readonly tassonomieService = inject(TassonomieService); private readonly keycloak = inject(Keycloak); readonly editingId = signal(null); @@ -59,6 +64,8 @@ export class ActivityForm { readonly periodoSuggestions = signal([]); readonly materialeSuggestions = signal([]); + readonly classificazioneErrore = signal(null); + readonly wizardTitle = computed(() => (this.editingId() ? 'Modifica attività' : 'Nuova attività')); readonly isLastStep = computed(() => this.step() === 4); readonly nonTitoloCount = computed(() => this.paragrafoList().filter((p) => p.tipo.id !== 'TITOLO').length); @@ -75,56 +82,55 @@ export class ActivityForm { this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita)); } - effect(() => { - const query = this.brancaQuery(); + this.autocompleteQuery( + this.brancaQuery, + (query) => this.autocompleteService.branca(query), + ).subscribe((r) => { const selezionati = new Set(this.brancaList().map((b) => b.id)); - if (!query.trim()) { - this.brancaSuggestions.set([]); - return; - } - this.autocompleteService.branca(query).subscribe({ - next: (r) => this.brancaSuggestions.set(this.toChipOptions(r, selezionati)), - error: () => this.brancaSuggestions.set([]), - }); + this.brancaSuggestions.set(this.toChipOptions(r, selezionati)); }); - effect(() => { - const query = this.categoriaQuery(); + this.autocompleteQuery( + this.categoriaQuery, + (query) => this.autocompleteService.categoria(query), + ).subscribe((r) => { const selezionati = new Set(this.categoriaList().map((c) => c.id)); - if (!query.trim()) { - this.categoriaSuggestions.set([]); - return; - } - this.autocompleteService.categoria(query).subscribe({ - next: (r) => this.categoriaSuggestions.set(this.toChipOptions(r, selezionati)), - error: () => this.categoriaSuggestions.set([]), - }); + this.categoriaSuggestions.set(this.toChipOptions(r, selezionati)); }); - effect(() => { - const query = this.periodoQuery(); + this.autocompleteQuery( + this.periodoQuery, + (query) => this.autocompleteService.periodoAnno(query), + ).subscribe((r) => { const selezionati = new Set(this.periodoAnnoList().map((p) => p.id)); - if (!query.trim()) { - this.periodoSuggestions.set([]); - return; - } - this.autocompleteService.periodoAnno(query).subscribe({ - next: (r) => this.periodoSuggestions.set(this.toChipOptions(r, selezionati)), - error: () => this.periodoSuggestions.set([]), - }); + this.periodoSuggestions.set(this.toChipOptions(r, selezionati)); }); - effect(() => { - const query = this.materialeQuery(); + this.autocompleteQuery( + this.materialeQuery, + (query) => this.autocompleteService.materiale(query), + ).subscribe((r) => { const selezionati = new Set(this.materialeList().map((m) => m.id)); - if (!query.trim()) { - this.materialeSuggestions.set([]); - return; - } - this.autocompleteService.materiale(query).subscribe({ - next: (r) => this.materialeSuggestions.set(this.toChipOptions(r, selezionati)), - error: () => this.materialeSuggestions.set([]), - }); + this.materialeSuggestions.set(this.toChipOptions(r, selezionati)); }); } + // Aspetta che l'utente smetta di scrivere per AUTOCOMPLETE_DEBOUNCE_MS prima di + // interrogare il backend, invece di chiamarlo ad ogni singola lettera digitata. + private autocompleteQuery( + querySignal: Signal, + richiedi: (query: string) => Observable<{ id: number | null; nome: string | null }[]>, + ) { + return toObservable(querySignal).pipe( + debounceTime(AUTOCOMPLETE_DEBOUNCE_MS), + distinctUntilChanged(), + switchMap((query) => { + if (!query.trim()) { + return of([]); + } + return richiedi(query).pipe(catchError(() => of([]))); + }), + takeUntilDestroyed(), + ); + } + private toChipOptions( risultati: { id: number | null; nome: string | null }[], esclusi: Set @@ -165,6 +171,26 @@ export class ActivityForm { removeBranca(chip: ChipOption): void { this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id)); } + onCreateBranca(nome: string): void { + this.classificazioneErrore.set(null); + this.tassonomieService.proponiBranca(nome).subscribe({ + next: (branca) => { + this.brancaList.update((lista) => [ + ...lista, + { + id: branca.id, + nome: branca.nome, + inizioEta: branca.inizioEta ?? undefined, + fineEta: branca.fineEta ?? undefined, + colore: branca.colore ?? undefined, + stato: branca.stato, + }, + ]); + this.brancaQuery.set(''); + }, + error: () => this.classificazioneErrore.set('Impossibile proporre la branca.'), + }); + } addCategoria(chip: ChipOption): void { this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]); @@ -173,6 +199,19 @@ export class ActivityForm { removeCategoria(chip: ChipOption): void { this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id)); } + onCreateCategoria(nome: string): void { + this.classificazioneErrore.set(null); + this.tassonomieService.proponiCategoria(nome).subscribe({ + next: (categoria) => { + this.categoriaList.update((lista) => [ + ...lista, + { id: categoria.id, nome: categoria.nome, stato: categoria.stato }, + ]); + this.categoriaQuery.set(''); + }, + error: () => this.classificazioneErrore.set('Impossibile proporre la categoria.'), + }); + } addPeriodo(chip: ChipOption): void { this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]); @@ -181,6 +220,25 @@ export class ActivityForm { removePeriodo(chip: ChipOption): void { this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id)); } + onCreatePeriodo(nome: string): void { + this.classificazioneErrore.set(null); + this.tassonomieService.proponiPeriodoAnno(nome).subscribe({ + next: (periodo) => { + this.periodoAnnoList.update((lista) => [ + ...lista, + { + id: periodo.id, + nome: periodo.nome, + inizioMese: periodo.inizioMese, + fineMese: periodo.fineMese, + stato: periodo.stato, + }, + ]); + this.periodoQuery.set(''); + }, + error: () => this.classificazioneErrore.set('Impossibile proporre il periodo.'), + }); + } addMateriale(chip: ChipOption): void { this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]); diff --git a/scouthub-attivita-fe/src/app/pages/detail/detail.css b/scouthub-attivita-fe/src/app/pages/detail/detail.css index 6f467d4..a24994e 100644 --- a/scouthub-attivita-fe/src/app/pages/detail/detail.css +++ b/scouthub-attivita-fe/src/app/pages/detail/detail.css @@ -64,6 +64,12 @@ color: var(--color-periodo-text); } +.badge-attesa { + cursor: default; + font-size: 13px; + margin-left: 2px; +} + .materiale-box { background: var(--color-surface); border: 1px solid var(--color-border); @@ -123,3 +129,79 @@ margin: 0; white-space: pre-wrap; } + +.moderazione-box { + margin-top: 32px; + background: var(--color-attesa-bg); + border: 1px solid var(--color-attesa-border); + border-radius: 14px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.moderazione-title { + font-weight: 700; + font-size: 15px; + color: var(--color-attesa-text); +} + +.moderazione-azioni { + display: flex; +} + +.moderazione-textarea { + width: 100%; + min-height: 90px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-size: 14px; + resize: vertical; +} + +.note-box { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.note-title { + font-weight: 700; + font-size: 15px; + color: var(--color-text-muted); +} + +.nota { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 12px 16px; +} + +.nota-meta { + font-size: 13px; + color: var(--color-text-muted); + margin-bottom: 6px; +} + +.nota-testo { + font-size: 15px; + white-space: pre-wrap; +} + +.nota-elimina { + cursor: pointer; + margin-top: 8px; + background: none; + border: none; + padding: 0; + color: var(--color-danger); + font-size: 13px; + font-weight: 600; + font-family: inherit; +} diff --git a/scouthub-attivita-fe/src/app/pages/detail/detail.html b/scouthub-attivita-fe/src/app/pages/detail/detail.html index abb7927..9ed50e5 100644 --- a/scouthub-attivita-fe/src/app/pages/detail/detail.html +++ b/scouthub-attivita-fe/src/app/pages/detail/detail.html @@ -3,6 +3,9 @@
@for (branca of a.brancaList; track branca.id) { + @if (branca.stato === 'DA_APPROVARE') { + ⚠️ + } } {{ brancaNomi() }}
@@ -13,10 +16,20 @@
@for (categoria of a.categoriaList; track categoria.id) { - {{ categoria.nome }} + + {{ categoria.nome }} + @if (categoria.stato === 'DA_APPROVARE') { + ⚠️ + } + } @for (periodo of a.periodoAnnoList; track periodo.id) { - {{ periodo.nome }} + + {{ periodo.nome }} + @if (periodo.stato === 'DA_APPROVARE') { + ⚠️ + } + }
@@ -50,6 +63,43 @@ } } + + @if (puoModerare()) { +
+
Moderazione
+
+ +
+ + +
+ } + + @if (a.noteList && a.noteList.length > 0) { +
+
Note di moderazione
+ @for (nota of a.noteList; track nota.id) { +
+
{{ nota.autore }} · {{ formatNotaData(nota.dataCreazione) }}
+
{{ nota.testo }}
+ @if (puoCancellareNote()) { + + } +
+ } +
+ } } diff --git a/scouthub-attivita-fe/src/app/pages/detail/detail.ts b/scouthub-attivita-fe/src/app/pages/detail/detail.ts index 0464693..87601c0 100644 --- a/scouthub-attivita-fe/src/app/pages/detail/detail.ts +++ b/scouthub-attivita-fe/src/app/pages/detail/detail.ts @@ -1,6 +1,9 @@ import { Component, computed, inject, signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import Keycloak from 'keycloak-js'; +import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular'; +import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles'; import { Attivita, Paragrafo } from '../../core/models/attivita.model'; import { AttivitaService } from '../../core/services/attivita'; import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style'; @@ -14,9 +17,13 @@ import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-st export class Detail { private readonly route = inject(ActivatedRoute); private readonly attivitaService = inject(AttivitaService); + private readonly keycloak = inject(Keycloak); + private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL); readonly attivita = signal(null); readonly caricamento = signal(true); + readonly testoCommento = signal(''); + readonly azioneInCorso = signal(false); readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica)); readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color); @@ -25,8 +32,26 @@ export class Detail { [...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine) ); + readonly isModeratore = computed(() => { + this.keycloakEvent(); + return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE); + }); + + readonly puoModerare = computed( + () => this.isModeratore() && this.attivita()?.stato.id === 'IA', + ); + + readonly puoCancellareNote = computed( + () => this.isModeratore() && this.attivita()?.stato.id === 'PU', + ); + constructor() { + this.caricaAttivita(); + } + + private caricaAttivita(): void { const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita')); + this.caricamento.set(true); this.attivitaService.getOne(idAttivita).subscribe({ next: (attivita) => { this.attivita.set(attivita); @@ -43,4 +68,42 @@ export class Detail { tokens(paragrafo: Paragrafo) { return parseMarkdown(paragrafo.corpo); } + + formatNotaData(data: string): string { + return formatData(data); + } + + approva(): void { + const attivita = this.attivita(); + if (!attivita?.id || this.azioneInCorso()) return; + + this.azioneInCorso.set(true); + this.attivitaService.approva(attivita.id).subscribe({ + next: () => { + this.azioneInCorso.set(false); + this.caricaAttivita(); + }, + error: () => this.azioneInCorso.set(false), + }); + } + + commenta(): void { + const attivita = this.attivita(); + const testo = this.testoCommento().trim(); + if (!attivita?.id || !testo || this.azioneInCorso()) return; + + this.azioneInCorso.set(true); + this.attivitaService.commenta(attivita.id, testo).subscribe({ + next: () => { + this.azioneInCorso.set(false); + this.testoCommento.set(''); + this.caricaAttivita(); + }, + error: () => this.azioneInCorso.set(false), + }); + } + + eliminaNota(idNota: number): void { + this.attivitaService.eliminaNota(idNota).subscribe(() => this.caricaAttivita()); + } } diff --git a/scouthub-attivita-fe/src/app/pages/home/home.css b/scouthub-attivita-fe/src/app/pages/home/home.css index e69de29..15204dd 100644 --- a/scouthub-attivita-fe/src/app/pages/home/home.css +++ b/scouthub-attivita-fe/src/app/pages/home/home.css @@ -0,0 +1,14 @@ +.approvazione-box { + background: var(--color-attesa-bg); + border: 1px solid var(--color-attesa-border); + border-radius: 14px; + padding: 20px; + margin-bottom: 32px; +} + +.approvazione-title { + font-weight: 700; + font-size: 17px; + color: var(--color-attesa-text); + margin-bottom: 14px; +} diff --git a/scouthub-attivita-fe/src/app/pages/home/home.html b/scouthub-attivita-fe/src/app/pages/home/home.html index ac6d17c..4cc8190 100644 --- a/scouthub-attivita-fe/src/app/pages/home/home.html +++ b/scouthub-attivita-fe/src/app/pages/home/home.html @@ -1,4 +1,15 @@
+ @if (isModeratore() && daApprovare().length > 0) { +
+
⏳ Attività da approvare ({{ daApprovare().length }})
+
+ @for (attivita of daApprovare(); track attivita.id) { + + } +
+
+ } +