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,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;
@@ -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"
+47
View File
@@ -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")
+14
View File
@@ -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()
+6 -1
View File
@@ -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' });
@@ -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<void> {
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();
}
@@ -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();
};
}
@@ -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;
}
+25
View File
@@ -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 {
@@ -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<typeof brancaAdminSchema>;
export type CategoriaAdminInput = z.infer<typeof categoriaAdminSchema>;
export type PeriodoAnnoAdminInput = z.infer<typeof periodoAnnoAdminSchema>;
export type ProponiTassonomiaInput = z.infer<typeof proponiTassonomiaSchema>;
export type NotaAttivitaInput = z.infer<typeof notaAttivitaSchema>;
export type TipologicaInput = z.infer<typeof tipologicaSchema>;
export type ParagrafoInput = z.infer<typeof paragrafoInputSchema>;
export type BrancaInput = z.infer<typeof brancaInputSchema>;
+1 -1
View File
@@ -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"
+3 -2
View File
@@ -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: '' },
];
@@ -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('/');
};
@@ -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 ?? [];
}
@@ -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;
@@ -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;
}
@@ -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'
>;
@@ -37,4 +37,20 @@ export class AttivitaService {
save(attivita: Attivita): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/save`, attivita);
}
getListModerazione(): Observable<Attivita[]> {
return this.http.get<Attivita[]>(`${this.privateUrl}/get/lista/moderazione`);
}
approva(idAttivita: number): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/${idAttivita}/approva`, {});
}
commenta(idAttivita: number, testo: string): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/${idAttivita}/commenta`, { testo });
}
eliminaNota(idNota: number): Observable<void> {
return this.http.delete<void>(`${this.privateUrl}/note/${idNota}`);
}
}
@@ -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<Notifica[]> {
return this.http.get<Notifica[]>(`${this.baseUrl}/`);
}
getCountNonLette(): Observable<{ count: number }> {
return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`);
}
segnaLetta(id: number): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${id}/letta`, {});
}
segnaTutteLette(): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/letta-tutte`, {});
}
}
@@ -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<Branca[]> {
return this.http.get<Branca[]>(`${this.baseUrl}/branca`);
}
createBranca(input: BrancaInput): Observable<Branca> {
return this.http.post<Branca>(`${this.baseUrl}/branca`, input);
}
updateBranca(id: number, input: BrancaInput): Observable<Branca> {
return this.http.put<Branca>(`${this.baseUrl}/branca/${id}`, input);
}
deleteBranca(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/branca/${id}`);
}
proponiBranca(nome: string): Observable<Branca> {
return this.http.post<Branca>(`${this.baseUrl}/proposte/branca`, { nome });
}
approvaBranca(id: number): Observable<Branca> {
return this.http.post<Branca>(`${this.baseUrl}/branca/${id}/approva`, {});
}
rifiutaBranca(id: number): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/branca/${id}/rifiuta`, {});
}
listCategoria(): Observable<Categoria[]> {
return this.http.get<Categoria[]>(`${this.baseUrl}/categoria`);
}
listTipoCategoria(): Observable<TipoCategoria[]> {
return this.http.get<TipoCategoria[]>(`${this.baseUrl}/tipo-categoria`);
}
createCategoria(input: CategoriaInput): Observable<Categoria> {
return this.http.post<Categoria>(`${this.baseUrl}/categoria`, input);
}
updateCategoria(id: number, input: CategoriaInput): Observable<Categoria> {
return this.http.put<Categoria>(`${this.baseUrl}/categoria/${id}`, input);
}
deleteCategoria(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/categoria/${id}`);
}
proponiCategoria(nome: string): Observable<Categoria> {
return this.http.post<Categoria>(`${this.baseUrl}/proposte/categoria`, { nome });
}
approvaCategoria(id: number): Observable<Categoria> {
return this.http.post<Categoria>(`${this.baseUrl}/categoria/${id}/approva`, {});
}
rifiutaCategoria(id: number): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/categoria/${id}/rifiuta`, {});
}
listPeriodoAnno(): Observable<PeriodoAnno[]> {
return this.http.get<PeriodoAnno[]>(`${this.baseUrl}/periodoAnno`);
}
createPeriodoAnno(input: PeriodoAnnoInput): Observable<PeriodoAnno> {
return this.http.post<PeriodoAnno>(`${this.baseUrl}/periodoAnno`, input);
}
updatePeriodoAnno(id: number, input: PeriodoAnnoInput): Observable<PeriodoAnno> {
return this.http.put<PeriodoAnno>(`${this.baseUrl}/periodoAnno/${id}`, input);
}
deletePeriodoAnno(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/periodoAnno/${id}`);
}
proponiPeriodoAnno(nome: string): Observable<PeriodoAnno> {
return this.http.post<PeriodoAnno>(`${this.baseUrl}/proposte/periodoAnno`, { nome });
}
approvaPeriodoAnno(id: number): Observable<PeriodoAnno> {
return this.http.post<PeriodoAnno>(`${this.baseUrl}/periodoAnno/${id}/approva`, {});
}
rifiutaPeriodoAnno(id: number): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/periodoAnno/${id}/rifiuta`, {});
}
}
@@ -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)' };
}
@@ -44,6 +44,11 @@
gap: 24px;
}
.errore-inline {
color: var(--color-danger);
font-size: 13px;
}
.field {
font-weight: 700;
font-size: 14px;
@@ -47,29 +47,38 @@
@if (step() === 1) {
<div class="step-content step-content--gap">
@if (classificazioneErrore()) {
<div class="errore-inline">{{ classificazioneErrore() }}</div>
}
<app-chip-field
label="Branca"
[chips]="brancaList()"
[(query)]="brancaQuery"
[suggestions]="brancaSuggestions()"
[allowCreate]="true"
(select)="addBranca($event)"
(remove)="removeBranca($event)"
(create)="onCreateBranca($event)"
/>
<app-chip-field
label="Categoria"
[chips]="categoriaList()"
[(query)]="categoriaQuery"
[suggestions]="categoriaSuggestions()"
[allowCreate]="true"
(select)="addCategoria($event)"
(remove)="removeCategoria($event)"
(create)="onCreateCategoria($event)"
/>
<app-chip-field
label="Periodo dell'anno"
[chips]="periodoAnnoList()"
[(query)]="periodoQuery"
[suggestions]="periodoSuggestions()"
[allowCreate]="true"
(select)="addPeriodo($event)"
(remove)="removePeriodo($event)"
(create)="onCreatePeriodo($event)"
/>
</div>
}
@@ -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<number | null>(null);
@@ -59,6 +64,8 @@ export class ActivityForm {
readonly periodoSuggestions = signal<ChipOption[]>([]);
readonly materialeSuggestions = signal<ChipOption[]>([]);
readonly classificazioneErrore = signal<string | null>(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<string>,
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<number>
@@ -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: '' }]);
@@ -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;
}
@@ -3,6 +3,9 @@
<div class="branca-row">
@for (branca of a.brancaList; track branca.id) {
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
@if (branca.stato === 'DA_APPROVARE') {
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
}
}
<span class="branca-nomi">{{ brancaNomi() }}</span>
</div>
@@ -13,10 +16,20 @@
</div>
<div class="chips-row">
@for (categoria of a.categoriaList; track categoria.id) {
<span class="chip chip--categoria">{{ categoria.nome }}</span>
<span class="chip chip--categoria">
{{ categoria.nome }}
@if (categoria.stato === 'DA_APPROVARE') {
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
}
</span>
}
@for (periodo of a.periodoAnnoList; track periodo.id) {
<span class="chip chip--periodo">{{ periodo.nome }}</span>
<span class="chip chip--periodo">
{{ periodo.nome }}
@if (periodo.stato === 'DA_APPROVARE') {
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
}
</span>
}
</div>
@@ -50,6 +63,43 @@
}
}
</div>
@if (puoModerare()) {
<div class="moderazione-box">
<div class="moderazione-title">Moderazione</div>
<div class="moderazione-azioni">
<button class="btn-primary" [disabled]="azioneInCorso()" (click)="approva()">✓ Approva e pubblica</button>
</div>
<textarea
class="moderazione-textarea"
placeholder="Scrivi una nota per l'autore: l'attività tornerà in bozza per essere sistemata..."
[value]="testoCommento()"
(input)="testoCommento.set($any($event.target).value)"
></textarea>
<button
class="btn-secondary"
[disabled]="azioneInCorso() || !testoCommento().trim()"
(click)="commenta()"
>
Commenta e rimanda all'autore
</button>
</div>
}
@if (a.noteList && a.noteList.length > 0) {
<div class="note-box">
<div class="note-title">Note di moderazione</div>
@for (nota of a.noteList; track nota.id) {
<div class="nota">
<div class="nota-meta">{{ nota.autore }} · {{ formatNotaData(nota.dataCreazione) }}</div>
<div class="nota-testo">{{ nota.testo }}</div>
@if (puoCancellareNote()) {
<button class="nota-elimina" (click)="eliminaNota(nota.id)">Elimina nota</button>
}
</div>
}
</div>
}
</div>
}
@@ -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<Attivita | null>(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());
}
}
@@ -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;
}
@@ -1,4 +1,15 @@
<div class="page">
@if (isModeratore() && daApprovare().length > 0) {
<div class="approvazione-box">
<div class="approvazione-title">⏳ Attività da approvare ({{ daApprovare().length }})</div>
<div class="grid">
@for (attivita of daApprovare(); track attivita.id) {
<app-activity-card [attivita]="attivita" />
}
</div>
</div>
}
<div class="page-header">
<h1 class="page-title">Attività pubblicate</h1>
<a class="search-bar" routerLink="/ricerca">
@@ -1,6 +1,9 @@
import { Component, inject, signal } from '@angular/core';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { RouterLink } 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 } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { ActivityCard } from '../../shared/activity-card/activity-card';
@@ -13,9 +16,17 @@ import { ActivityCard } from '../../shared/activity-card/activity-card';
})
export class Home {
private readonly attivitaService = inject(AttivitaService);
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
readonly attivitaList = signal<Attivita[]>([]);
readonly caricamento = signal(true);
readonly daApprovare = signal<Attivita[]>([]);
readonly isModeratore = computed(() => {
this.keycloakEvent();
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
});
constructor() {
this.attivitaService.getListHome().subscribe({
@@ -25,5 +36,16 @@ export class Home {
},
error: () => this.caricamento.set(false),
});
// Il ruolo moderatore/admin arriva dal token Keycloak in modo asincrono: quando diventa
// disponibile carichiamo la lista delle attività in attesa di approvazione da mostrare
// in cima alla home.
effect(() => {
if (this.isModeratore()) {
this.attivitaService.getListModerazione().subscribe((lista) => this.daApprovare.set(lista));
} else {
this.daApprovare.set([]);
}
});
}
}
@@ -33,6 +33,24 @@
margin-top: 2px;
}
.riga-badge {
display: inline-block;
margin-top: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid;
}
.riga-note {
display: block;
margin-top: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-danger);
}
.stato-options {
display: flex;
gap: 6px;
@@ -11,6 +11,14 @@
<div class="riga-info">
<a class="riga-titolo" [routerLink]="['/attivita/dettaglio', attivita.id]">{{ attivita.nome }}</a>
<div class="riga-data">Aggiornata il {{ dataModificaFmt(attivita) }}</div>
@if (isInAttesa(attivita)) {
<div class="riga-badge" [style.color]="stileStato('IA').color" [style.background]="stileStato('IA').bg" [style.border-color]="stileStato('IA').border">
In attesa di approvazione
</div>
}
@if (attivita.noteList && attivita.noteList.length > 0) {
<a class="riga-note" [routerLink]="['/attivita/dettaglio', attivita.id]">📝 Note del moderatore</a>
}
</div>
<div class="stato-options">
@for (stato of stati; track stato.id) {
@@ -45,6 +45,10 @@ export class MyActivity {
return attivita.stato.id === stato.id;
}
isInAttesa(attivita: Attivita): boolean {
return attivita.stato.id === 'IA';
}
cambiaStato(attivita: Attivita, stato: Stato): void {
if (!attivita.id || attivita.stato.id === stato.id) {
return;
@@ -1,30 +0,0 @@
.profile-page {
max-width: 520px;
padding-top: 60px;
text-align: center;
}
.avatar {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--color-primary-soft);
color: var(--color-primary-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
font-weight: 700;
margin: 0 auto 18px;
}
.titolo {
font-size: 24px;
margin: 0 0 6px;
font-weight: 700;
}
.sottotitolo {
color: var(--color-text-muted);
margin: 0 0 24px;
}
@@ -1,8 +0,0 @@
<div class="page profile-page">
<div class="avatar">👤</div>
<h1 class="titolo">Il tuo profilo</h1>
<p class="sottotitolo">
La gestione account arriverà con l'autenticazione. Per ora, accedi alle tue attività qui sotto.
</p>
<a class="btn-primary" routerLink="/mie-attivita">Vai a Le mie attività</a>
</div>
@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-profile',
imports: [RouterLink],
templateUrl: './profile.html',
styleUrl: './profile.css',
})
export class Profile {}
@@ -1,4 +1,6 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
import { Attivita } from '../../core/models/attivita.model';
import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model';
@@ -7,6 +9,8 @@ import { AutocompleteService } from '../../core/services/autocomplete';
import { NavigationService } from '../../core/services/navigation';
import { ActivityCard } from '../../shared/activity-card/activity-card';
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
@Component({
selector: 'app-search',
imports: [ActivityCard],
@@ -38,19 +42,26 @@ export class Search {
error: () => this.results.set([]),
});
});
// Aspetta che l'utente smetta di scrivere per AUTOCOMPLETE_DEBOUNCE_MS prima di
// interrogare il backend, invece di chiamarlo ad ogni singola lettera digitata.
toObservable(this.searchQuery)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
if (!query.trim()) {
return of([]);
}
return this.autocompleteService.search(query).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(),
)
.subscribe((groups) => this.suggestionGroups.set(groups));
}
onQueryChange(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.searchQuery.set(value);
if (!value.trim()) {
this.suggestionGroups.set([]);
return;
}
this.autocompleteService.search(value).subscribe({
next: (groups) => this.suggestionGroups.set(groups),
error: () => this.suggestionGroups.set([]),
});
this.searchQuery.set((event.target as HTMLInputElement).value);
}
onQueryKeyDown(event: KeyboardEvent): void {
@@ -0,0 +1,155 @@
.tabs {
display: flex;
gap: 6px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.tab {
cursor: pointer;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.tab--attivo {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 20px;
}
.form-riga {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.field {
font-weight: 700;
font-size: 14px;
flex: 1;
min-width: 160px;
}
.text-input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
font-size: 16px;
font-family: inherit;
background: var(--color-bg);
color: var(--color-text);
}
.text-input--sm {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
.form-azioni {
display: flex;
gap: 10px;
margin-top: 16px;
}
.errore-inline {
color: var(--color-danger);
font-size: 13px;
margin-top: 10px;
}
.lista {
display: flex;
flex-direction: column;
gap: 14px;
}
.riga {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.riga-info {
flex: 1;
min-width: 220px;
}
.riga-titolo {
font-weight: 700;
font-size: 17px;
color: var(--color-text);
}
.riga-data {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 2px;
}
.riga-azioni {
display: flex;
gap: 8px;
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-border);
font-weight: 600;
font-size: 14px;
}
.riga-elimina {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-danger);
color: var(--color-danger);
font-weight: 600;
font-size: 14px;
}
.proposte-box {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
.proposte-titolo {
font-weight: 700;
font-size: 14px;
color: var(--color-text-muted);
text-transform: uppercase;
}
.badge-attesa {
cursor: default;
font-size: 14px;
margin-left: 4px;
}
@@ -0,0 +1,269 @@
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">Tassonomie</h1>
</div>
<div class="tabs">
<div class="tab" [class.tab--attivo]="tab() === 'branca'" (click)="setTab('branca')">Branca</div>
<div class="tab" [class.tab--attivo]="tab() === 'categoria'" (click)="setTab('categoria')">Categoria</div>
<div class="tab" [class.tab--attivo]="tab() === 'periodoAnno'" (click)="setTab('periodoAnno')">Periodo dell'anno</div>
</div>
@if (caricamento()) {
<div class="empty-state">Caricamento...</div>
} @else if (erroreCaricamento()) {
<div class="empty-state">{{ erroreCaricamento() }}</div>
} @else {
@if (tab() === 'branca') {
@if (brancaProposte().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (branca of brancaProposte(); track branca.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ branca.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (approvazioneErroreId() === branca.id) {
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaBranca(branca)">Approva</div>
<div class="riga-elimina" (click)="rifiutaBranca(branca)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input class="text-input text-input--sm" type="text" [value]="brancaNome()" (input)="brancaNome.set($any($event.target).value)" placeholder="Es. Lupetti" />
</label>
<label class="field">
Inizio età
<input class="text-input text-input--sm" type="number" [value]="brancaInizioEta() ?? ''" (input)="brancaInizioEta.set(numeroONull($any($event.target).value))" />
</label>
<label class="field">
Fine età
<input class="text-input text-input--sm" type="number" [value]="brancaFineEta() ?? ''" (input)="brancaFineEta.set(numeroONull($any($event.target).value))" />
</label>
<label class="field">
Colore
<input class="text-input text-input--sm" type="text" [value]="brancaColore() ?? ''" (input)="brancaColore.set($any($event.target).value)" placeholder="Es. #f4a300" />
</label>
</div>
@if (brancaSalvataggioErrore()) {
<div class="errore-inline">{{ brancaSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaBranca()">{{ brancaEditId() ? 'Salva modifiche' : '+ Aggiungi branca' }}</div>
@if (brancaEditId()) {
<div class="btn-secondary" (click)="annullaBranca()">Annulla</div>
}
</div>
</div>
@if (brancaConfermate().length > 0) {
<div class="lista">
@for (branca of brancaConfermate(); track branca.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ branca.nome }}</div>
<div class="riga-data">
@if (branca.inizioEta !== null || branca.fineEta !== null) {
Età {{ branca.inizioEta ?? '?' }}-{{ branca.fineEta ?? '?' }}
}
@if (branca.colore) {
· {{ branca.colore }}
}
</div>
@if (eliminaErroreId() === branca.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaBranca(branca)">Modifica</div>
<div class="riga-elimina" (click)="eliminaBranca(branca)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessuna branca presente</div>
</div>
}
}
@if (tab() === 'categoria') {
@if (categoriaProposte().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (categoria of categoriaProposte(); track categoria.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ categoria.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (approvazioneErroreId() === categoria.id) {
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaCategoria(categoria)">Approva</div>
<div class="riga-elimina" (click)="rifiutaCategoria(categoria)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input class="text-input text-input--sm" type="text" [value]="categoriaNome()" (input)="categoriaNome.set($any($event.target).value)" placeholder="Es. Giochi" />
</label>
<label class="field">
Tipo
<select class="text-input text-input--sm" [value]="categoriaTipoId()" (change)="categoriaTipoId.set($any($event.target).value)">
<option value="" disabled>Seleziona un tipo</option>
@for (tipo of tipoCategoriaList(); track tipo.id) {
<option [value]="tipo.id">{{ tipo.nome }}</option>
}
</select>
</label>
<label class="field">
Categoria padre
<select class="text-input text-input--sm" [value]="categoriaPadreId() ?? ''" (change)="categoriaPadreId.set(numeroONull($any($event.target).value))">
<option value="">Nessuna</option>
@for (categoria of categoriaPadreOpzioni(); track categoria.id) {
<option [value]="categoria.id">{{ categoria.nome }}</option>
}
</select>
</label>
</div>
@if (categoriaSalvataggioErrore()) {
<div class="errore-inline">{{ categoriaSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaCategoria()">{{ categoriaEditId() ? 'Salva modifiche' : '+ Aggiungi categoria' }}</div>
@if (categoriaEditId()) {
<div class="btn-secondary" (click)="annullaCategoria()">Annulla</div>
}
</div>
</div>
@if (categoriaConfermate().length > 0) {
<div class="lista">
@for (categoria of categoriaConfermate(); track categoria.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ categoria.nome }}</div>
<div class="riga-data">
{{ tipoCategoriaNome(categoria.tipoId) }}
@if (categoriaPadreNome(categoria.padreId); as padreNome) {
· figlia di {{ padreNome }}
}
</div>
@if (eliminaErroreId() === categoria.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaCategoria(categoria)">Modifica</div>
<div class="riga-elimina" (click)="eliminaCategoria(categoria)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessuna categoria presente</div>
</div>
}
}
@if (tab() === 'periodoAnno') {
@if (periodoProposti().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (periodo of periodoProposti(); track periodo.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ periodo.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (approvazioneErroreId() === periodo.id) {
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaPeriodo(periodo)">Approva</div>
<div class="riga-elimina" (click)="rifiutaPeriodo(periodo)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input class="text-input text-input--sm" type="text" [value]="periodoNome()" (input)="periodoNome.set($any($event.target).value)" placeholder="Es. Estate" />
</label>
<label class="field">
Mese inizio
<input class="text-input text-input--sm" type="number" min="1" max="12" [value]="periodoInizioMese() ?? ''" (input)="periodoInizioMese.set(numeroONull($any($event.target).value))" />
</label>
<label class="field">
Mese fine
<input class="text-input text-input--sm" type="number" min="1" max="12" [value]="periodoFineMese() ?? ''" (input)="periodoFineMese.set(numeroONull($any($event.target).value))" />
</label>
</div>
@if (periodoSalvataggioErrore()) {
<div class="errore-inline">{{ periodoSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaPeriodo()">{{ periodoEditId() ? 'Salva modifiche' : '+ Aggiungi periodo' }}</div>
@if (periodoEditId()) {
<div class="btn-secondary" (click)="annullaPeriodo()">Annulla</div>
}
</div>
</div>
@if (periodoConfermati().length > 0) {
<div class="lista">
@for (periodo of periodoConfermati(); track periodo.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ periodo.nome }}</div>
<div class="riga-data">
@if (periodo.inizioMese !== null || periodo.fineMese !== null) {
Mese {{ periodo.inizioMese ?? '?' }}-{{ periodo.fineMese ?? '?' }}
}
</div>
@if (eliminaErroreId() === periodo.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaPeriodo(periodo)">Modifica</div>
<div class="riga-elimina" (click)="eliminaPeriodo(periodo)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessun periodo presente</div>
</div>
}
}
}
</div>
@@ -0,0 +1,375 @@
import { Component, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TassonomieService } from '../../core/services/tassonomie';
import {
Branca,
Categoria,
PeriodoAnno,
TipoCategoria,
} from '../../core/models/tassonomia.model';
type Tab = 'branca' | 'categoria' | 'periodoAnno';
@Component({
selector: 'app-tassonomie',
templateUrl: './tassonomie.html',
styleUrl: './tassonomie.css',
})
export class Tassonomie {
private readonly tassonomieService = inject(TassonomieService);
readonly tab = signal<Tab>('branca');
readonly caricamento = signal(true);
readonly erroreCaricamento = signal<string | null>(null);
readonly brancaList = signal<Branca[]>([]);
readonly categoriaList = signal<Categoria[]>([]);
readonly tipoCategoriaList = signal<TipoCategoria[]>([]);
readonly periodoAnnoList = signal<PeriodoAnno[]>([]);
readonly brancaEditId = signal<number | null>(null);
readonly brancaNome = signal('');
readonly brancaInizioEta = signal<number | null>(null);
readonly brancaFineEta = signal<number | null>(null);
readonly brancaColore = signal<string | null>(null);
readonly brancaSalvataggioErrore = signal<string | null>(null);
readonly categoriaEditId = signal<number | null>(null);
readonly categoriaNome = signal('');
readonly categoriaPadreId = signal<number | null>(null);
readonly categoriaTipoId = signal('');
readonly categoriaSalvataggioErrore = signal<string | null>(null);
readonly periodoEditId = signal<number | null>(null);
readonly periodoNome = signal('');
readonly periodoInizioMese = signal<number | null>(null);
readonly periodoFineMese = signal<number | null>(null);
readonly periodoSalvataggioErrore = signal<string | null>(null);
readonly eliminaErroreId = signal<number | null>(null);
readonly eliminaErroreMsg = signal<string | null>(null);
readonly categoriaPadreOpzioni = computed(() =>
this.categoriaList().filter((c) => c.id !== this.categoriaEditId()),
);
readonly brancaConfermate = computed(() => this.brancaList().filter((b) => b.stato === 'CONFERMATA'));
readonly brancaProposte = computed(() => this.brancaList().filter((b) => b.stato === 'DA_APPROVARE'));
readonly categoriaConfermate = computed(() => this.categoriaList().filter((c) => c.stato === 'CONFERMATA'));
readonly categoriaProposte = computed(() => this.categoriaList().filter((c) => c.stato === 'DA_APPROVARE'));
readonly periodoConfermati = computed(() => this.periodoAnnoList().filter((p) => p.stato === 'CONFERMATA'));
readonly periodoProposti = computed(() => this.periodoAnnoList().filter((p) => p.stato === 'DA_APPROVARE'));
readonly approvazioneErroreId = signal<number | null>(null);
readonly approvazioneErroreMsg = signal<string | null>(null);
constructor() {
this.caricaTutto();
}
setTab(tab: Tab): void {
this.tab.set(tab);
this.annullaBranca();
this.annullaCategoria();
this.annullaPeriodo();
}
private async caricaTutto(): Promise<void> {
this.caricamento.set(true);
this.erroreCaricamento.set(null);
try {
const [branche, categorie, tipiCategoria, periodi] = await Promise.all([
firstValueFrom(this.tassonomieService.listBranca()),
firstValueFrom(this.tassonomieService.listCategoria()),
firstValueFrom(this.tassonomieService.listTipoCategoria()),
firstValueFrom(this.tassonomieService.listPeriodoAnno()),
]);
this.brancaList.set(branche);
this.categoriaList.set(categorie);
this.tipoCategoriaList.set(tipiCategoria);
this.periodoAnnoList.set(periodi);
} catch {
this.erroreCaricamento.set('Impossibile caricare le tassonomie. Riprova più tardi.');
} finally {
this.caricamento.set(false);
}
}
tipoCategoriaNome(tipoId: string): string {
return this.tipoCategoriaList().find((t) => t.id === tipoId)?.nome ?? tipoId;
}
categoriaPadreNome(padreId: number | null): string | null {
if (padreId === null) {
return null;
}
return this.categoriaList().find((c) => c.id === padreId)?.nome ?? null;
}
modificaBranca(branca: Branca): void {
this.brancaEditId.set(branca.id);
this.brancaNome.set(branca.nome);
this.brancaInizioEta.set(branca.inizioEta);
this.brancaFineEta.set(branca.fineEta);
this.brancaColore.set(branca.colore);
this.brancaSalvataggioErrore.set(null);
}
annullaBranca(): void {
this.brancaEditId.set(null);
this.brancaNome.set('');
this.brancaInizioEta.set(null);
this.brancaFineEta.set(null);
this.brancaColore.set(null);
this.brancaSalvataggioErrore.set(null);
}
async salvaBranca(): Promise<void> {
const nome = this.brancaNome().trim();
if (!nome) {
return;
}
const input = {
nome,
inizioEta: this.brancaInizioEta(),
fineEta: this.brancaFineEta(),
colore: this.brancaColore() || null,
};
this.brancaSalvataggioErrore.set(null);
try {
const editId = this.brancaEditId();
const salvata = editId
? await firstValueFrom(this.tassonomieService.updateBranca(editId, input))
: await firstValueFrom(this.tassonomieService.createBranca(input));
this.brancaList.update((lista) => {
const senzaVecchia = lista.filter((b) => b.id !== salvata.id);
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaBranca();
} catch {
this.brancaSalvataggioErrore.set('Impossibile salvare la branca.');
}
}
async eliminaBranca(branca: Branca): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.deleteBranca(branca.id));
this.brancaList.update((lista) => lista.filter((b) => b.id !== branca.id));
} catch (err) {
this.eliminaErroreId.set(branca.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaBranca(branca: Branca): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
const approvata = await firstValueFrom(this.tassonomieService.approvaBranca(branca.id));
this.brancaList.update((lista) => lista.map((b) => (b.id === approvata.id ? approvata : b)));
} catch (err) {
this.approvazioneErroreId.set(branca.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaBranca(branca: Branca): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.rifiutaBranca(branca.id));
this.brancaList.update((lista) => lista.filter((b) => b.id !== branca.id));
} catch (err) {
this.approvazioneErroreId.set(branca.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
modificaCategoria(categoria: Categoria): void {
this.categoriaEditId.set(categoria.id);
this.categoriaNome.set(categoria.nome);
this.categoriaPadreId.set(categoria.padreId);
this.categoriaTipoId.set(categoria.tipoId);
this.categoriaSalvataggioErrore.set(null);
}
annullaCategoria(): void {
this.categoriaEditId.set(null);
this.categoriaNome.set('');
this.categoriaPadreId.set(null);
this.categoriaTipoId.set('');
this.categoriaSalvataggioErrore.set(null);
}
async salvaCategoria(): Promise<void> {
const nome = this.categoriaNome().trim();
const tipoId = this.categoriaTipoId();
if (!nome || !tipoId) {
return;
}
const input = {
nome,
padreId: this.categoriaPadreId(),
tipoId,
};
this.categoriaSalvataggioErrore.set(null);
try {
const editId = this.categoriaEditId();
const salvata = editId
? await firstValueFrom(this.tassonomieService.updateCategoria(editId, input))
: await firstValueFrom(this.tassonomieService.createCategoria(input));
this.categoriaList.update((lista) => {
const senzaVecchia = lista.filter((c) => c.id !== salvata.id);
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaCategoria();
} catch {
this.categoriaSalvataggioErrore.set('Impossibile salvare la categoria.');
}
}
async eliminaCategoria(categoria: Categoria): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.deleteCategoria(categoria.id));
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
} catch (err) {
this.eliminaErroreId.set(categoria.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaCategoria(categoria: Categoria): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
const approvata = await firstValueFrom(this.tassonomieService.approvaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.map((c) => (c.id === approvata.id ? approvata : c)));
} catch (err) {
this.approvazioneErroreId.set(categoria.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaCategoria(categoria: Categoria): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.rifiutaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
} catch (err) {
this.approvazioneErroreId.set(categoria.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
modificaPeriodo(periodo: PeriodoAnno): void {
this.periodoEditId.set(periodo.id);
this.periodoNome.set(periodo.nome);
this.periodoInizioMese.set(periodo.inizioMese);
this.periodoFineMese.set(periodo.fineMese);
this.periodoSalvataggioErrore.set(null);
}
annullaPeriodo(): void {
this.periodoEditId.set(null);
this.periodoNome.set('');
this.periodoInizioMese.set(null);
this.periodoFineMese.set(null);
this.periodoSalvataggioErrore.set(null);
}
async salvaPeriodo(): Promise<void> {
const nome = this.periodoNome().trim();
if (!nome) {
return;
}
const input = {
nome,
inizioMese: this.periodoInizioMese(),
fineMese: this.periodoFineMese(),
};
this.periodoSalvataggioErrore.set(null);
try {
const editId = this.periodoEditId();
const salvato = editId
? await firstValueFrom(this.tassonomieService.updatePeriodoAnno(editId, input))
: await firstValueFrom(this.tassonomieService.createPeriodoAnno(input));
this.periodoAnnoList.update((lista) => {
const senzaVecchia = lista.filter((p) => p.id !== salvato.id);
return [...senzaVecchia, salvato].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaPeriodo();
} catch {
this.periodoSalvataggioErrore.set('Impossibile salvare il periodo.');
}
}
async eliminaPeriodo(periodo: PeriodoAnno): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.deletePeriodoAnno(periodo.id));
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== periodo.id));
} catch (err) {
this.eliminaErroreId.set(periodo.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaPeriodo(periodo: PeriodoAnno): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
const approvato = await firstValueFrom(this.tassonomieService.approvaPeriodoAnno(periodo.id));
this.periodoAnnoList.update((lista) => lista.map((p) => (p.id === approvato.id ? approvato : p)));
} catch (err) {
this.approvazioneErroreId.set(periodo.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaPeriodo(periodo: PeriodoAnno): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.rifiutaPeriodoAnno(periodo.id));
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== periodo.id));
} catch (err) {
this.approvazioneErroreId.set(periodo.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
private estraiMessaggioErrore(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err) {
const body = (err as { error?: unknown }).error;
if (body && typeof body === 'object' && 'message' in body && typeof (body as { message?: unknown }).message === 'string') {
return (body as { message: string }).message;
}
}
return 'Impossibile eliminare l\'elemento.';
}
numeroONull(value: string): number | null {
if (value.trim() === '') {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
}
@@ -48,6 +48,17 @@
background: var(--color-primary-soft);
}
.suggestion-item--create {
color: var(--color-primary-text);
font-weight: 600;
border-top: 1px solid var(--color-border);
}
.chip-badge {
cursor: default;
font-size: 13px;
}
.chips {
display: flex;
gap: 8px;
@@ -8,11 +8,16 @@
[value]="query()"
(input)="onQueryInput($event)"
/>
@if (suggestions().length > 0) {
@if (suggestions().length > 0 || showCreateOption()) {
<div class="suggestions">
@for (suggestion of suggestions(); track suggestion.id) {
<div class="suggestion-item" (click)="select.emit(suggestion)">{{ suggestion.nome }}</div>
}
@if (showCreateOption()) {
<div class="suggestion-item suggestion-item--create" (click)="onCreate()">
+ Crea "{{ query() }}"
</div>
}
</div>
}
</div>
@@ -20,6 +25,9 @@
@for (chip of chips(); track chip.id) {
<div class="chip">
<span>{{ chip.nome }}</span>
@if (chip.stato === 'DA_APPROVARE') {
<span class="chip-badge" title="In attesa di conferma">⚠️</span>
}
<span class="chip-remove" (click)="remove.emit(chip)">×</span>
</div>
}
@@ -1,8 +1,9 @@
import { Component, input, model, output } from '@angular/core';
import { Component, computed, input, model, output } from '@angular/core';
export interface ChipOption {
id: number;
nome: string;
stato?: 'CONFERMATA' | 'DA_APPROVARE';
}
@Component({
@@ -15,12 +16,33 @@ export class ChipField {
readonly label = input.required<string>();
readonly chips = input.required<ChipOption[]>();
readonly suggestions = input<ChipOption[]>([]);
readonly allowCreate = input(false);
readonly query = model('');
readonly remove = output<ChipOption>();
readonly select = output<ChipOption>();
readonly create = output<string>();
readonly showCreateOption = computed(() => {
const query = this.query().trim();
if (!this.allowCreate() || !query) {
return false;
}
const queryLower = query.toLowerCase();
const giaPresente = [...this.suggestions(), ...this.chips()].some(
(opzione) => opzione.nome.toLowerCase() === queryLower,
);
return !giaPresente;
});
onQueryInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
onCreate(): void {
const nome = this.query().trim();
if (nome) {
this.create.emit(nome);
}
}
}
@@ -97,3 +97,119 @@
padding: 6px 12px;
font-size: 13px;
}
.notifiche {
position: relative;
}
.campanellina {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 18px;
cursor: pointer;
}
.campanellina:hover {
background: var(--color-primary-soft);
}
.campanellina-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--color-error, #d64545);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notifiche-overlay {
position: fixed;
inset: 0;
z-index: 19;
}
.notifiche-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 320px;
max-height: 420px;
overflow-y: auto;
background: var(--color-header);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.notifiche-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
font-weight: 700;
font-size: 14px;
color: var(--color-text);
border-bottom: 1px solid var(--color-border);
}
.notifiche-segna-tutte {
cursor: pointer;
font-weight: 600;
font-size: 12px;
color: var(--color-primary-text-strong);
}
.notifiche-vuoto {
padding: 20px 14px;
text-align: center;
font-size: 13px;
color: var(--color-text);
opacity: 0.7;
}
.notifica-item {
cursor: pointer;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
color: var(--color-text);
}
.notifica-item:last-child {
border-bottom: none;
}
.notifica-item:hover {
background: var(--color-primary-soft);
}
.notifica-item--non-letta {
font-weight: 600;
background: var(--color-primary-soft);
}
.notifica-item--non-letta::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 8px;
border-radius: 999px;
background: var(--color-primary);
}
@@ -16,7 +16,7 @@
<span class="brand-name">Scouthub</span>
</a>
<nav class="nav-items">
@for (item of navItems; track item.path) {
@for (item of navItems(); track item.path) {
<a
class="nav-item"
[class.nav-item--active]="isActive(item.path)"
@@ -28,6 +28,45 @@
</nav>
<div class="auth-section">
@if (isAuthenticated()) {
<div class="notifiche">
<button
type="button"
class="campanellina"
(click)="toggleCampanellina()"
aria-label="Notifiche"
>
🔔
@if (countNonLette() > 0) {
<span class="campanellina-badge">{{ countBadge() }}</span>
}
</button>
@if (campanellinaAperta()) {
<div class="notifiche-overlay" (click)="chiudiCampanellina()"></div>
<div class="notifiche-dropdown">
<div class="notifiche-header">
<span>Notifiche</span>
@if (countNonLette() > 0) {
<span class="notifiche-segna-tutte" (click)="segnaTutteLette()">
Segna tutte come lette
</span>
}
</div>
@if (notifiche().length === 0) {
<div class="notifiche-vuoto">Nessuna notifica</div>
} @else {
@for (notifica of notifiche(); track notifica.id) {
<div
class="notifica-item"
[class.notifica-item--non-letta]="!notifica.letta"
(click)="apriNotifica(notifica)"
>
<span class="notifica-messaggio">{{ notifica.messaggio }}</span>
</div>
}
}
</div>
}
</div>
<span class="auth-name">{{ displayName() }}</span>
<div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
} @else {
@@ -1,15 +1,21 @@
import { Location } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { NavigationEnd, Router, RouterLink } from '@angular/router';
import { filter } from 'rxjs';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { Notifica } from '../../core/models/notifica.model';
import { NotificheService } from '../../core/services/notifiche';
interface NavItem {
path: string;
label: string;
}
const INTERVALLO_POLLING_MS = 30000;
@Component({
selector: 'app-header',
imports: [RouterLink],
@@ -21,9 +27,20 @@ export class Header {
private readonly location = inject(Location);
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
private readonly notificheService = inject(NotificheService);
private readonly destroyRef = inject(DestroyRef);
readonly currentUrl = signal(this.router.url);
readonly notifiche = signal<Notifica[]>([]);
readonly countNonLette = signal(0);
readonly campanellinaAperta = signal(false);
readonly countBadge = computed(() => {
const count = this.countNonLette();
return count > 9 ? '9+' : String(count);
});
readonly isAuthenticated = computed(() => {
this.keycloakEvent();
return !!this.keycloak.authenticated;
@@ -40,17 +57,46 @@ export class Header {
return url.startsWith('/attivita/dettaglio') || url.startsWith('/nuova-attivita') || url.startsWith('/modifica-attivita');
});
readonly navItems: NavItem[] = [
{ path: '/', label: 'Home' },
{ path: '/ricerca', label: 'Cerca' },
{ path: '/mie-attivita', label: 'Le mie attività' },
{ path: '/profilo', label: 'Profilo' },
];
readonly isModeratore = computed(() => {
this.keycloakEvent();
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
});
readonly navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{ path: '/', label: 'Home' },
{ path: '/ricerca', label: 'Cerca' },
{ path: '/mie-attivita', label: 'Le mie attività' },
];
if (this.isModeratore()) {
items.push({ path: '/tassonomie', label: 'Tassonomie' });
}
return items;
});
constructor() {
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
this.currentUrl.set(this.router.url);
});
// Il polling parte/si ferma seguendo lo stato di autenticazione: non ha senso interrogare
// l'endpoint privato delle notifiche per un utente non loggato.
let intervalId: ReturnType<typeof setInterval> | undefined;
effect(() => {
if (this.isAuthenticated()) {
this.aggiornaCountNonLette();
intervalId = setInterval(() => this.aggiornaCountNonLette(), INTERVALLO_POLLING_MS);
} else {
this.notifiche.set([]);
this.countNonLette.set(0);
}
});
this.destroyRef.onDestroy(() => {
if (intervalId !== undefined) {
clearInterval(intervalId);
}
});
}
isActive(path: string): boolean {
@@ -68,4 +114,50 @@ export class Header {
logout(): void {
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
}
private aggiornaCountNonLette(): void {
this.notificheService.getCountNonLette().subscribe({
next: ({ count }) => this.countNonLette.set(count),
});
}
toggleCampanellina(): void {
const apri = !this.campanellinaAperta();
this.campanellinaAperta.set(apri);
if (apri) {
this.notificheService.getLista().subscribe({
next: (lista) => this.notifiche.set(lista),
});
}
}
chiudiCampanellina(): void {
this.campanellinaAperta.set(false);
}
apriNotifica(notifica: Notifica): void {
if (!notifica.letta) {
this.notificheService.segnaLetta(notifica.id).subscribe({
next: () => {
this.notifiche.update((lista) =>
lista.map((n) => (n.id === notifica.id ? { ...n, letta: true } : n)),
);
this.countNonLette.update((count) => Math.max(0, count - 1));
},
});
}
this.campanellinaAperta.set(false);
if (notifica.link) {
this.router.navigateByUrl(notifica.link);
}
}
segnaTutteLette(): void {
this.notificheService.segnaTutteLette().subscribe({
next: () => {
this.notifiche.update((lista) => lista.map((n) => ({ ...n, letta: true })));
this.countNonLette.set(0);
},
});
}
}
+3
View File
@@ -18,6 +18,9 @@
--color-periodo-border: oklch(80% 0.04 260);
--color-periodo-text: oklch(35% 0.05 260);
--color-danger: oklch(50% 0.15 25);
--color-attesa-bg: oklch(93% 0.06 90);
--color-attesa-border: oklch(80% 0.1 90);
--color-attesa-text: oklch(40% 0.1 90);
--color-neutral-bg: oklch(92% 0.005 60);
--color-neutral-text: oklch(45% 0.02 55);
--color-neutral-border: oklch(85% 0.01 60);
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --port 7003",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --port 7000",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --port 7002",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"