Add scouthub-attivit-be
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { env } from './config/env';
|
||||
import { authenticate } from './middlewares/authenticate';
|
||||
import { errorHandler } from './middlewares/errorHandler';
|
||||
import { attivitaPublicRouter } from './modules/attivita/attivita.router.public';
|
||||
import { attivitaPrivateRouter } from './modules/attivita/attivita.router.private';
|
||||
import { autocompleteRouter } from './modules/autocomplete/autocomplete.router';
|
||||
|
||||
export const app = express();
|
||||
|
||||
// strict: false perché gli endpoint di autocomplete (vedi extractKeyword)
|
||||
// accettano un body JSON che è una stringa "nuda" (es. `"gioco"`), non solo
|
||||
// oggetti/array: con lo strict mode di default body-parser la rifiuterebbe.
|
||||
app.use(express.json({ strict: false }));
|
||||
app.use(cors({ origin: env.corsOrigin }));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/public/attivita', attivitaPublicRouter);
|
||||
app.use('/private/attivita', authenticate, attivitaPrivateRouter);
|
||||
app.use('/public/autocomplete', autocompleteRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ message: 'not found' });
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
@@ -0,0 +1,21 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Variabile d'ambiente mancante: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT) || 8080,
|
||||
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:4200',
|
||||
databaseUrl: requireEnv('DATABASE_URL'),
|
||||
keycloak: {
|
||||
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
|
||||
realm: requireEnv('KEYCLOAK_REALM'),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.__prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
name: string;
|
||||
roles: string[];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ message: 'Token mancante' });
|
||||
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);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: 'Token non valido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
|
||||
const hasStatusCode =
|
||||
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
|
||||
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
|
||||
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { app } from './app';
|
||||
import { env } from './config/env';
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`Server avviato su porta ${env.port} (CORS origin: ${env.corsOrigin})`);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface TipologicaDto {
|
||||
id: string;
|
||||
nome: string;
|
||||
}
|
||||
|
||||
export interface BaseDto {
|
||||
dataCreazione: Date;
|
||||
dataModifica: Date;
|
||||
utenteModifica: string;
|
||||
}
|
||||
|
||||
export interface BrancaDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioEta: number | null;
|
||||
fineEta: number | null;
|
||||
colore: string | null;
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface CategoriaDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
padre: number | null;
|
||||
tipo: TipologicaDto;
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialeDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
proprieta: unknown;
|
||||
categoriaList?: CategoriaDto[];
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface ParagrafoDto extends BaseDto {
|
||||
id: number;
|
||||
attivitaId: number;
|
||||
corpo: string;
|
||||
autore: string;
|
||||
tipo: TipologicaDto;
|
||||
ordine: number;
|
||||
}
|
||||
|
||||
export interface PeriodoAnnoDto extends BaseDto {
|
||||
id: number;
|
||||
nome: string;
|
||||
inizioMese: number | null;
|
||||
fineMese: number | null;
|
||||
cancellato: boolean;
|
||||
}
|
||||
|
||||
export interface AttivitaDto extends BaseDto {
|
||||
id: number | null;
|
||||
nome: string;
|
||||
autore: string;
|
||||
padre: number | null;
|
||||
stato: TipologicaDto;
|
||||
brancaList: BrancaDto[];
|
||||
categoriaList: CategoriaDto[];
|
||||
materialeList: MaterialeDto[];
|
||||
paragrafoList: ParagrafoDto[];
|
||||
periodoAnnoList: PeriodoAnnoDto[];
|
||||
}
|
||||
|
||||
export interface SearchObjectDto {
|
||||
id: number | null;
|
||||
nome: string | null;
|
||||
gruppo: string;
|
||||
}
|
||||
|
||||
export interface SearchGroupDto {
|
||||
label: string;
|
||||
objectsList: SearchObjectDto[];
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { AuthContext } from '../middlewares/auth.types';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const tipologicaSchema = z.object({
|
||||
id: z.string(),
|
||||
nome: z.string(),
|
||||
});
|
||||
|
||||
export const paragrafoInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
corpo: z.string().min(1).max(500),
|
||||
// Un paragrafo non ha un autore proprio nell'API/frontend: eredita quello
|
||||
// dell'attivita' se non esplicitamente indicato (vedi attivita.service.ts).
|
||||
autore: z.string().min(1).optional(),
|
||||
tipo: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
ordine: z.number().int(),
|
||||
});
|
||||
|
||||
export const brancaInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
});
|
||||
|
||||
export const categoriaInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
});
|
||||
|
||||
export const materialeInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
proprieta: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const periodoAnnoInputSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
});
|
||||
|
||||
export const attivitaSaveSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
nome: z.string().min(1),
|
||||
stato: tipologicaSchema,
|
||||
brancaList: z.array(brancaInputSchema),
|
||||
categoriaList: z.array(categoriaInputSchema),
|
||||
materialeList: z.array(materialeInputSchema),
|
||||
periodoAnnoList: z.array(periodoAnnoInputSchema),
|
||||
paragrafoList: z.array(paragrafoInputSchema),
|
||||
});
|
||||
|
||||
export type TipologicaInput = z.infer<typeof tipologicaSchema>;
|
||||
export type ParagrafoInput = z.infer<typeof paragrafoInputSchema>;
|
||||
export type BrancaInput = z.infer<typeof brancaInputSchema>;
|
||||
export type CategoriaInput = z.infer<typeof categoriaInputSchema>;
|
||||
export type MaterialeInput = z.infer<typeof materialeInputSchema>;
|
||||
export type PeriodoAnnoInput = z.infer<typeof periodoAnnoInputSchema>;
|
||||
export type AttivitaSaveInput = z.infer<typeof attivitaSaveSchema>;
|
||||
Reference in New Issue
Block a user