Add scouthub-attivit-be

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:34:00 +02:00
parent 904cd2ee9b
commit 2ee7d402b6
55 changed files with 9783 additions and 0 deletions
@@ -0,0 +1,51 @@
import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors';
import { attivitaSaveSchema } from '../../types/validation';
import * as attivitaService from './attivita.service';
function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
return (req: Request, res: Response, next: NextFunction): void => {
handler(req, res, next).catch(next);
};
}
export const attivitaPrivateRouter = Router();
attivitaPrivateRouter.get(
'/get/lista/my',
asyncHandler(async (req, res) => {
const lista = await attivitaService.getListMy(req.auth!.userId);
res.status(200).json(lista);
}),
);
attivitaPrivateRouter.get(
'/change/stato/:idAttivita/:idStato',
asyncHandler(async (req, res, next) => {
const idAttivita = Number(req.params.idAttivita);
if (!Number.isInteger(idAttivita)) {
next(new HttpError(400, "l'idAttivita deve essere un numero intero"));
return;
}
const idStato = req.params.idStato;
const stato = await attivitaService.changeStato(idAttivita, idStato, req.auth!);
res.status(200).json(stato);
}),
);
attivitaPrivateRouter.post(
'/save',
asyncHandler(async (req, res, next) => {
const parsed = attivitaSaveSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
await attivitaService.save(parsed.data, req.auth!);
res.status(200).end();
}),
);
@@ -0,0 +1,63 @@
import { Request, Response, NextFunction, Router } from 'express';
import { z } from 'zod';
import { HttpError } from '../../errors';
import * as attivitaService from './attivita.service';
const searchObjectSchema = z.array(
z.object({
id: z.number().nullable(),
nome: z.string().nullable(),
gruppo: z.string(),
}),
);
function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
return (req: Request, res: Response, next: NextFunction): void => {
handler(req, res, next).catch(next);
};
}
export const attivitaPublicRouter = Router();
attivitaPublicRouter.get(
'/get/lista/home',
asyncHandler(async (req, res) => {
const lista = await attivitaService.getListaHome();
res.status(200).json(lista);
}),
);
attivitaPublicRouter.post(
'/get/lista/search',
asyncHandler(async (req, res, next) => {
const parsed = searchObjectSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, 'body non valido: atteso un array di SearchObjectDto'));
return;
}
const lista = await attivitaService.getListaSearch(parsed.data);
res.status(200).json(lista);
}),
);
attivitaPublicRouter.get(
'/get/one/:id',
asyncHandler(async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
next(new HttpError(400, "l'id deve essere un numero intero"));
return;
}
const attivita = await attivitaService.getOne(id);
if (!attivita) {
next(new HttpError(404, 'attività non trovata'));
return;
}
res.status(200).json(attivita);
}),
);
@@ -0,0 +1,560 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { prisma } from '../../db/prisma';
import { HttpError } from '../../errors';
import { AuthContext } from '../../middlewares/auth.types';
import {
AttivitaDto,
BrancaDto,
CategoriaDto,
MaterialeDto,
ParagrafoDto,
PeriodoAnnoDto,
SearchObjectDto,
TipologicaDto,
} from '../../types/dto';
import { AttivitaSaveInput } from '../../types/validation';
const attivitaInclude = {
stato: true,
brancaLinks: { include: { branca: true } },
categoriaLinks: { include: { categoria: { include: { tipo: true } } } },
materialeLinks: { include: { materiale: true } },
periodoAnnoLinks: { include: { periodoAnno: true } },
paragrafi: { include: { tipo: true } },
} satisfies Prisma.AttivitaInclude;
type AttivitaWithRelations = Prisma.AttivitaGetPayload<{ include: typeof attivitaInclude }>;
function toTipologicaDto(entity: { id: string; nome: string }): TipologicaDto {
return { id: entity.id, nome: entity.nome };
}
function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
const brancaList: BrancaDto[] = entity.brancaLinks
.filter((link) => !link.cancellato)
.map((link) => ({
id: link.branca.id,
nome: link.branca.nome,
inizioEta: link.branca.inizioEta,
fineEta: link.branca.fineEta,
colore: link.branca.colore,
cancellato: false,
dataCreazione: link.branca.dataCreazione,
dataModifica: link.branca.dataModifica,
utenteModifica: link.branca.utenteModifica,
}));
const categoriaList: CategoriaDto[] = entity.categoriaLinks
.filter((link) => !link.cancellato)
.map((link) => ({
id: link.categoria.id,
nome: link.categoria.nome,
padre: link.categoria.padreId,
tipo: toTipologicaDto(link.categoria.tipo),
cancellato: false,
dataCreazione: link.categoria.dataCreazione,
dataModifica: link.categoria.dataModifica,
utenteModifica: link.categoria.utenteModifica,
}));
const materialeList: MaterialeDto[] = entity.materialeLinks
.filter((link) => !link.cancellato)
.map((link) => ({
id: link.materiale.id,
nome: link.materiale.nome,
proprieta: link.proprieta,
cancellato: false,
dataCreazione: link.materiale.dataCreazione,
dataModifica: link.materiale.dataModifica,
utenteModifica: link.materiale.utenteModifica,
}));
const periodoAnnoList: PeriodoAnnoDto[] = entity.periodoAnnoLinks
.filter((link) => !link.cancellato)
.map((link) => ({
id: link.periodoAnno.id,
nome: link.periodoAnno.nome,
inizioMese: link.periodoAnno.inizioMese,
fineMese: link.periodoAnno.fineMese,
cancellato: false,
dataCreazione: link.periodoAnno.dataCreazione,
dataModifica: link.periodoAnno.dataModifica,
utenteModifica: link.periodoAnno.utenteModifica,
}));
const paragrafoList: ParagrafoDto[] = [...entity.paragrafi]
.sort((a, b) => a.ordine - b.ordine)
.map((paragrafo) => ({
id: paragrafo.id,
attivitaId: paragrafo.attivitaId,
corpo: paragrafo.corpo,
autore: paragrafo.autore,
tipo: toTipologicaDto(paragrafo.tipo),
ordine: paragrafo.ordine,
dataCreazione: paragrafo.dataCreazione,
dataModifica: paragrafo.dataModifica,
utenteModifica: paragrafo.utenteModifica,
}));
return {
id: entity.id,
nome: entity.nome,
autore: entity.autore,
padre: entity.padreId,
stato: toTipologicaDto(entity.stato),
brancaList,
categoriaList,
materialeList,
paragrafoList,
periodoAnnoList,
dataCreazione: entity.dataCreazione,
dataModifica: entity.dataModifica,
utenteModifica: entity.utenteModifica,
};
}
export async function getListaHome(): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({
where: { statoId: 'PU' },
include: attivitaInclude,
orderBy: { dataModifica: 'desc' },
});
return entities.map(toAttivitaDto);
}
export async function getOne(id: number): Promise<AttivitaDto | null> {
const entity = await prisma.attivita.findUnique({
where: { id },
include: attivitaInclude,
});
return entity ? toAttivitaDto(entity) : null;
}
export async function getListMy(autoreId: string): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({
where: { autoreId },
include: attivitaInclude,
orderBy: { dataModifica: 'desc' },
});
return entities.map(toAttivitaDto);
}
export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<AttivitaDto[]> {
const brancaIds = dtoList.filter((d) => d.gruppo === 'branca').map((d) => d.id as number);
const categoriaIds = dtoList.filter((d) => d.gruppo === 'categoria').map((d) => d.id as number);
const materialeIds = dtoList.filter((d) => d.gruppo === 'materiale').map((d) => d.id as number);
const periodoAnnoIds = dtoList
.filter((d) => d.gruppo === 'periodoAnno')
.map((d) => d.id as number);
const testoList = dtoList
.filter((d) => d.gruppo === 'testo')
.map((d) => d.nome)
.filter((nome): nome is string => !!nome);
const and: Prisma.AttivitaWhereInput[] = [];
if (brancaIds.length > 0) {
and.push({
brancaLinks: { some: { brancaId: { in: brancaIds }, cancellato: false } },
});
}
if (categoriaIds.length > 0) {
and.push({
categoriaLinks: { some: { categoriaId: { in: categoriaIds }, cancellato: false } },
});
}
if (materialeIds.length > 0) {
and.push({
materialeLinks: { some: { materialeId: { in: materialeIds }, cancellato: false } },
});
}
if (periodoAnnoIds.length > 0) {
and.push({
periodoAnnoLinks: { some: { periodoAnnoId: { in: periodoAnnoIds }, cancellato: false } },
});
}
if (testoList.length > 0) {
and.push({
OR: testoList.flatMap((testo) => [
{ nome: { contains: testo, mode: 'insensitive' } },
{ paragrafi: { some: { corpo: { contains: testo, mode: 'insensitive' } } } },
]),
});
}
const entities = await prisma.attivita.findMany({
where: and.length > 0 ? { AND: and } : {},
include: attivitaInclude,
orderBy: { dataModifica: 'desc' },
});
return entities.map(toAttivitaDto);
}
export async function changeStato(
idAttivita: number,
idStato: string,
auth: AuthContext,
): Promise<TipologicaDto> {
const existing = await prisma.attivita.findUnique({ where: { id: idAttivita } });
if (!existing) {
throw new HttpError(404, 'attività non trovata');
}
if (existing.autoreId !== auth.userId) {
throw new HttpError(403, 'non sei autore di questa attività');
}
const updated = await prisma.attivita.update({
where: { id: idAttivita },
data: {
statoId: idStato,
utenteModifica: auth.name,
},
include: { stato: true },
});
return toTipologicaDto(updated.stato);
}
type Tx = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>;
async function upsertBranca(
tx: Tx,
attivitaId: number,
input: AttivitaSaveInput['brancaList'][number],
auth: AuthContext,
): Promise<number> {
let brancaId = input.id;
if (!brancaId) {
const found = await tx.branca.findFirst({
where: { nome: { equals: input.nome, mode: 'insensitive' } },
});
if (found) {
brancaId = found.id;
} else {
const created = await tx.branca.create({
data: {
nome: input.nome,
utenteModifica: auth.name,
},
});
brancaId = created.id;
}
}
const existingLink = await tx.brancaAttivita.findUnique({
where: { attivitaId_brancaId: { attivitaId, brancaId } },
});
if (existingLink) {
await tx.brancaAttivita.update({
where: { attivitaId_brancaId: { attivitaId, brancaId } },
data: {
cancellato: false,
utenteModifica: auth.name,
},
});
} else {
await tx.brancaAttivita.create({
data: {
attivitaId,
brancaId,
cancellato: false,
utenteModifica: auth.name,
},
});
}
return brancaId;
}
async function upsertCategoria(
tx: Tx,
attivitaId: number,
input: AttivitaSaveInput['categoriaList'][number],
auth: AuthContext,
): Promise<number> {
let categoriaId = input.id;
if (!categoriaId) {
const found = await tx.categoria.findFirst({
where: { nome: { equals: input.nome, mode: 'insensitive' } },
});
if (found) {
categoriaId = found.id;
} else {
const created = await tx.categoria.create({
data: {
nome: input.nome,
tipoId: 'A',
utenteModifica: auth.name,
},
});
categoriaId = created.id;
}
}
const existingLink = await tx.categoriaAttivita.findUnique({
where: { attivitaId_categoriaId: { attivitaId, categoriaId } },
});
if (existingLink) {
await tx.categoriaAttivita.update({
where: { attivitaId_categoriaId: { attivitaId, categoriaId } },
data: {
cancellato: false,
utenteModifica: auth.name,
},
});
} else {
await tx.categoriaAttivita.create({
data: {
attivitaId,
categoriaId,
cancellato: false,
utenteModifica: auth.name,
},
});
}
return categoriaId;
}
async function upsertMateriale(
tx: Tx,
attivitaId: number,
input: AttivitaSaveInput['materialeList'][number],
auth: AuthContext,
): Promise<number> {
let materialeId = input.id;
if (!materialeId) {
const found = await tx.materiale.findFirst({
where: { nome: { equals: input.nome, mode: 'insensitive' } },
});
if (found) {
materialeId = found.id;
} else {
const created = await tx.materiale.create({
data: {
nome: input.nome,
utenteModifica: auth.name,
},
});
materialeId = created.id;
}
}
const proprieta = (input.proprieta ?? Prisma.JsonNull) as Prisma.InputJsonValue;
const existingLink = await tx.materialeAttivita.findUnique({
where: { attivitaId_materialeId: { attivitaId, materialeId } },
});
if (existingLink) {
await tx.materialeAttivita.update({
where: { attivitaId_materialeId: { attivitaId, materialeId } },
data: {
cancellato: false,
proprieta,
utenteModifica: auth.name,
},
});
} else {
await tx.materialeAttivita.create({
data: {
attivitaId,
materialeId,
cancellato: false,
proprieta,
utenteModifica: auth.name,
},
});
}
return materialeId;
}
async function upsertPeriodoAnno(
tx: Tx,
attivitaId: number,
input: AttivitaSaveInput['periodoAnnoList'][number],
auth: AuthContext,
): Promise<number> {
let periodoAnnoId = input.id;
if (!periodoAnnoId) {
const found = await tx.periodoAnno.findFirst({
where: { nome: { equals: input.nome, mode: 'insensitive' } },
});
if (found) {
periodoAnnoId = found.id;
} else {
const created = await tx.periodoAnno.create({
data: {
nome: input.nome,
utenteModifica: auth.name,
},
});
periodoAnnoId = created.id;
}
}
const existingLink = await tx.periodoAnnoAttivita.findUnique({
where: { attivitaId_periodoAnnoId: { attivitaId, periodoAnnoId } },
});
if (existingLink) {
await tx.periodoAnnoAttivita.update({
where: { attivitaId_periodoAnnoId: { attivitaId, periodoAnnoId } },
data: {
cancellato: false,
utenteModifica: auth.name,
},
});
} else {
await tx.periodoAnnoAttivita.create({
data: {
attivitaId,
periodoAnnoId,
cancellato: false,
utenteModifica: auth.name,
},
});
}
return periodoAnnoId;
}
export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<void> {
await prisma.$transaction(async (tx) => {
let attivitaId: number;
if (dto.id) {
const existing = await tx.attivita.findUnique({ where: { id: dto.id } });
if (!existing) {
throw new HttpError(404, 'attività non trovata');
}
if (existing.autoreId !== auth.userId) {
throw new HttpError(403, 'non sei autore di questa attività');
}
await tx.attivita.update({
where: { id: dto.id },
data: {
nome: dto.nome,
statoId: dto.stato.id,
utenteModifica: auth.name,
},
});
attivitaId = dto.id;
} else {
const created = await tx.attivita.create({
data: {
nome: dto.nome,
autore: auth.name,
autoreId: auth.userId,
statoId: dto.stato.id,
utenteModifica: auth.name,
},
});
attivitaId = created.id;
}
const brancaIds = new Set<number>();
for (const branca of dto.brancaList) {
brancaIds.add(await upsertBranca(tx, attivitaId, branca, auth));
}
await tx.brancaAttivita.updateMany({
where: { attivitaId, brancaId: { notIn: [...brancaIds] }, cancellato: false },
data: {
cancellato: true,
utenteModifica: auth.name,
},
});
const categoriaIds = new Set<number>();
for (const categoria of dto.categoriaList) {
categoriaIds.add(await upsertCategoria(tx, attivitaId, categoria, auth));
}
await tx.categoriaAttivita.updateMany({
where: { attivitaId, categoriaId: { notIn: [...categoriaIds] }, cancellato: false },
data: {
cancellato: true,
utenteModifica: auth.name,
},
});
const materialeIds = new Set<number>();
for (const materiale of dto.materialeList) {
materialeIds.add(await upsertMateriale(tx, attivitaId, materiale, auth));
}
await tx.materialeAttivita.updateMany({
where: { attivitaId, materialeId: { notIn: [...materialeIds] }, cancellato: false },
data: {
cancellato: true,
utenteModifica: auth.name,
},
});
const periodoAnnoIds = new Set<number>();
for (const periodoAnno of dto.periodoAnnoList) {
periodoAnnoIds.add(await upsertPeriodoAnno(tx, attivitaId, periodoAnno, auth));
}
await tx.periodoAnnoAttivita.updateMany({
where: { attivitaId, periodoAnnoId: { notIn: [...periodoAnnoIds] }, cancellato: false },
data: {
cancellato: true,
utenteModifica: auth.name,
},
});
const paragrafoIds = new Set<number>();
for (const paragrafo of dto.paragrafoList) {
// Il paragrafo non ha un autore proprio nell'API: eredita quello dell'attivita'
// se non esplicitamente indicato nel payload.
const paragrafoAutore = paragrafo.autore ?? auth.name;
if (paragrafo.id) {
await tx.paragrafo.update({
where: { id: paragrafo.id },
data: {
corpo: paragrafo.corpo,
autore: paragrafoAutore,
tipoId: paragrafo.tipo.id,
ordine: paragrafo.ordine,
utenteModifica: auth.name,
},
});
paragrafoIds.add(paragrafo.id);
} else {
const created = await tx.paragrafo.create({
data: {
attivitaId,
corpo: paragrafo.corpo,
autore: paragrafoAutore,
tipoId: paragrafo.tipo.id,
ordine: paragrafo.ordine,
utenteModifica: auth.name,
},
});
paragrafoIds.add(created.id);
}
}
await tx.paragrafo.deleteMany({
where: { attivitaId, id: { notIn: [...paragrafoIds] } },
});
});
}
@@ -0,0 +1,56 @@
import { Request, Response, NextFunction, Router } from 'express';
import * as autocompleteService from './autocomplete.service';
function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
return (req: Request, res: Response, next: NextFunction): void => {
handler(req, res, next).catch(next);
};
}
function extractKeyword(body: unknown): string | undefined {
return typeof body === 'string' ? body : undefined;
}
export const autocompleteRouter = Router();
autocompleteRouter.post(
'/get/search',
asyncHandler(async (req, res) => {
const groups = await autocompleteService.getSearch(extractKeyword(req.body));
res.status(200).json(groups);
}),
);
autocompleteRouter.post(
'/get/branca',
asyncHandler(async (req, res) => {
const lista = await autocompleteService.getBranca(extractKeyword(req.body));
res.status(200).json(lista);
}),
);
autocompleteRouter.post(
'/get/categoria',
asyncHandler(async (req, res) => {
const lista = await autocompleteService.getCategoria(extractKeyword(req.body));
res.status(200).json(lista);
}),
);
autocompleteRouter.post(
'/get/materiale',
asyncHandler(async (req, res) => {
const lista = await autocompleteService.getMateriale(extractKeyword(req.body));
res.status(200).json(lista);
}),
);
autocompleteRouter.post(
'/get/periodoAnno',
asyncHandler(async (req, res) => {
const lista = await autocompleteService.getPeriodoAnno(extractKeyword(req.body));
res.status(200).json(lista);
}),
);
@@ -0,0 +1,62 @@
import { prisma } from '../../db/prisma';
import { SearchGroupDto, SearchObjectDto } from '../../types/dto';
export async function getBranca(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.branca.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'branca' }));
}
export async function getCategoria(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.categoria.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'categoria' }));
}
export async function getMateriale(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.materiale.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'materiale' }));
}
export async function getPeriodoAnno(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.periodoAnno.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } },
});
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'periodoAnno' }));
}
export async function getSearch(keyword?: string | null): Promise<SearchGroupDto[]> {
const groups: SearchGroupDto[] = [
{ label: 'Testo', objectsList: [{ id: null, nome: keyword ?? null, gruppo: 'testo' }] },
];
const brancaList = await getBranca(keyword);
if (brancaList.length > 0) {
groups.push({ label: 'Branca', objectsList: brancaList });
}
const categoriaList = await getCategoria(keyword);
if (categoriaList.length > 0) {
groups.push({ label: 'Categoria', objectsList: categoriaList });
}
const materialeList = await getMateriale(keyword);
if (materialeList.length > 0) {
groups.push({ label: 'Materiale', objectsList: materialeList });
}
const periodoAnnoList = await getPeriodoAnno(keyword);
if (periodoAnnoList.length > 0) {
groups.push({ label: 'Periodo anno', objectsList: periodoAnnoList });
}
return groups;
}