Sistemato attività
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user