feat(cache): implement a simple caching mechanism with TTL and prefix invalidation
refactor(expenses): integrate caching for periodic expenses and related operations refactor(quotes): add caching for quotes retrieval and history endpoints
This commit is contained in:
@@ -7,6 +7,8 @@ import { startQuoteJob } from "./modules/quotes/quote.job";
|
|||||||
import quotesRouter from "./modules/quotes/quotes.routes";
|
import quotesRouter from "./modules/quotes/quotes.routes";
|
||||||
import expensesRouter from "./modules/expenses/expenses.routes";
|
import expensesRouter from "./modules/expenses/expenses.routes";
|
||||||
import { startExpenseJob } from "./modules/expenses/expense.job";
|
import { startExpenseJob } from "./modules/expenses/expense.job";
|
||||||
|
import { cache } from "./lib/cache";
|
||||||
|
import { eventBus } from "./lib/event-bus";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -32,6 +34,10 @@ app.route("/api", expensesRouter);
|
|||||||
app.use("/assets/*", serveStatic({ root: "./web" }));
|
app.use("/assets/*", serveStatic({ root: "./web" }));
|
||||||
app.get("*", serveStatic({ path: "./web/index.html" }));
|
app.get("*", serveStatic({ path: "./web/index.html" }));
|
||||||
|
|
||||||
|
eventBus.on("quotes-updated", () => {
|
||||||
|
cache.invalidateByPrefix("quotes:");
|
||||||
|
});
|
||||||
|
|
||||||
startQuoteJob();
|
startQuoteJob();
|
||||||
startExpenseJob();
|
startExpenseJob();
|
||||||
|
|
||||||
|
|||||||
42
apps/backend/src/lib/cache.ts
Normal file
42
apps/backend/src/lib/cache.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const TEN_MINUTES = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SimpleCache {
|
||||||
|
private store = new Map<string, CacheEntry<unknown>>();
|
||||||
|
|
||||||
|
get<T>(key: string): T | undefined {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
set<T>(key: string, data: T, ttlMs: number = TEN_MINUTES): void {
|
||||||
|
this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateByPrefix(prefix: string): void {
|
||||||
|
for (const key of this.store.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = new SimpleCache();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "../../lib/prisma";
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { cache } from "../../lib/cache";
|
||||||
import { PaymentStatus } from "../../generated/prisma/client";
|
import { PaymentStatus } from "../../generated/prisma/client";
|
||||||
|
|
||||||
export const createPeriodicExpenseSchema = z.object({
|
export const createPeriodicExpenseSchema = z.object({
|
||||||
@@ -37,28 +38,38 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listPeriodicExpenses() {
|
export async function listPeriodicExpenses() {
|
||||||
return prisma.periodicExpense.findMany({
|
const cached = cache.get("expenses:periodic");
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await prisma.periodicExpense.findMany({
|
||||||
where: { isDeleted: false },
|
where: { isDeleted: false },
|
||||||
orderBy: { description: "asc" },
|
orderBy: { description: "asc" },
|
||||||
});
|
});
|
||||||
|
cache.set("expenses:periodic", data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) {
|
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) {
|
||||||
const result = await prisma.periodicExpense.create({ data });
|
const result = await prisma.periodicExpense.create({ data });
|
||||||
const { year, month } = getCurrentYearMonthUTC();
|
const { year, month } = getCurrentYearMonthUTC();
|
||||||
const currentMonthApplicable = data.periods.includes(month);
|
const currentMonthApplicable = data.periods.includes(month);
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
return { ...result, currentMonthApplicable };
|
return { ...result, currentMonthApplicable };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
|
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
|
||||||
return prisma.periodicExpense.update({ where: { id }, data });
|
const result = await prisma.periodicExpense.update({ where: { id }, data });
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeletePeriodicExpense(id: number) {
|
export async function softDeletePeriodicExpense(id: number) {
|
||||||
return prisma.periodicExpense.update({
|
const result = await prisma.periodicExpense.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isDeleted: true },
|
data: { isDeleted: true },
|
||||||
});
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMonthlyExpense(id: number) {
|
export async function generateMonthlyExpense(id: number) {
|
||||||
@@ -71,7 +82,7 @@ export async function generateMonthlyExpense(id: number) {
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
|
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
|
||||||
return prisma.expense.create({
|
const result = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
description: periodic.description,
|
description: periodic.description,
|
||||||
periodicExpenseId: id,
|
periodicExpenseId: id,
|
||||||
@@ -82,6 +93,8 @@ export async function generateMonthlyExpense(id: number) {
|
|||||||
status: PaymentStatus.PENDING,
|
status: PaymentStatus.PENDING,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listExpenses(params: {
|
export async function listExpenses(params: {
|
||||||
@@ -92,6 +105,11 @@ export async function listExpenses(params: {
|
|||||||
search?: string;
|
search?: string;
|
||||||
}) {
|
}) {
|
||||||
const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params;
|
const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params;
|
||||||
|
|
||||||
|
const cacheKey = `expenses:list:${status ?? "all"}:${page}:${pageSize}:${periodicExpenseId ?? "none"}:${search ?? ""}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (status === "PENDING" || status === "PAYED") {
|
if (status === "PENDING" || status === "PAYED") {
|
||||||
where.status = status;
|
where.status = status;
|
||||||
@@ -112,14 +130,16 @@ export async function listExpenses(params: {
|
|||||||
}),
|
}),
|
||||||
prisma.expense.count({ where }),
|
prisma.expense.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { data, total, page, pageSize };
|
const result = { data, total, page, pageSize };
|
||||||
|
cache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
|
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
|
||||||
const dueDate = new Date(data.dueDate);
|
const dueDate = new Date(data.dueDate);
|
||||||
const year = dueDate.getUTCFullYear();
|
const year = dueDate.getUTCFullYear();
|
||||||
const month = dueDate.getUTCMonth() + 1;
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
return prisma.expense.create({
|
const result = await prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
description: data.description,
|
description: data.description,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
@@ -131,11 +151,13 @@ export async function createNonPeriodicExpense(data: z.infer<typeof createNonPer
|
|||||||
status: PaymentStatus.PAYED,
|
status: PaymentStatus.PAYED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
|
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
|
||||||
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
|
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
|
||||||
return prisma.expense.update({
|
const result = await prisma.expense.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
status: PaymentStatus.PAYED,
|
status: PaymentStatus.PAYED,
|
||||||
@@ -143,36 +165,55 @@ export async function payExpense(id: number, data: z.infer<typeof payExpenseSche
|
|||||||
paymentDate,
|
paymentDate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMonthlyPayedTotal(year: number, month: number) {
|
export async function getMonthlyPayedTotal(year: number, month: number) {
|
||||||
|
const cacheKey = `expenses:monthly-total:${year}:${month}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
|
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
|
||||||
SELECT CAST(SUM("amountPayed") AS NUMERIC) as total
|
SELECT CAST(SUM("amountPayed") AS NUMERIC) as total
|
||||||
FROM expenses
|
FROM expenses
|
||||||
WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus"
|
WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus"
|
||||||
`;
|
`;
|
||||||
return { total: Number(result[0]?.total ?? 0) };
|
const data = { total: Number(result[0]?.total ?? 0) };
|
||||||
|
cache.set(cacheKey, data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMonthlyTotals(year: number, month: number) {
|
export async function getMonthlyTotals(year: number, month: number) {
|
||||||
|
const cacheKey = `expenses:totals:${year}:${month}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<Array<{ payed: string | null; pending: string | null }>>`
|
const result = await prisma.$queryRaw<Array<{ payed: string | null; pending: string | null }>>`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed,
|
(SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed,
|
||||||
(SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
|
(SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
|
||||||
`;
|
`;
|
||||||
return {
|
const data = {
|
||||||
payed: Number(result[0]?.payed ?? 0),
|
payed: Number(result[0]?.payed ?? 0),
|
||||||
pending: Number(result[0]?.pending ?? 0),
|
pending: Number(result[0]?.pending ?? 0),
|
||||||
};
|
};
|
||||||
|
cache.set(cacheKey, data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTotalPending() {
|
export async function getTotalPending() {
|
||||||
|
const cached = cache.get("expenses:pending-total");
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
|
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
|
||||||
SELECT CAST(SUM(amount) AS NUMERIC) as total
|
SELECT CAST(SUM(amount) AS NUMERIC) as total
|
||||||
FROM expenses
|
FROM expenses
|
||||||
WHERE status = ${PaymentStatus.PENDING}::"PaymentStatus"
|
WHERE status = ${PaymentStatus.PENDING}::"PaymentStatus"
|
||||||
`;
|
`;
|
||||||
return { total: Number(result[0]?.total ?? 0) };
|
const data = { total: Number(result[0]?.total ?? 0) };
|
||||||
|
cache.set("expenses:pending-total", data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateExpensesForCurrentMonth() {
|
export async function generateExpensesForCurrentMonth() {
|
||||||
@@ -205,5 +246,6 @@ export async function generateExpensesForCurrentMonth() {
|
|||||||
});
|
});
|
||||||
results.push({ id: periodic.id, status: "created" });
|
results.push({ id: periodic.id, status: "created" });
|
||||||
}
|
}
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
import { logger } from "../../../lib/logger";
|
import { logger } from "../../../lib/logger";
|
||||||
import { PaymentStatus } from "../../../generated/prisma/client";
|
import { PaymentStatus } from "../../../generated/prisma/client";
|
||||||
|
|
||||||
@@ -113,6 +114,8 @@ export async function importExpensesHandler(c: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
imported,
|
imported,
|
||||||
skipped,
|
skipped,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
import { logger } from "../../../lib/logger";
|
import { logger } from "../../../lib/logger";
|
||||||
|
|
||||||
function parseArgentineAmount(raw: string): number {
|
function parseArgentineAmount(raw: string): number {
|
||||||
@@ -84,6 +85,8 @@ export async function importPeriodicExpensesHandler(c: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
imported,
|
imported,
|
||||||
skipped: skippedNoPeriods + skippedDuplicate,
|
skipped: skippedNoPeriods + skippedDuplicate,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
|
|
||||||
export async function getCurrentQuotes(c: Context) {
|
export async function getCurrentQuotes(c: Context) {
|
||||||
|
const cached = cache.get("quotes:current");
|
||||||
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
const quotes = await prisma.quote.findMany();
|
const quotes = await prisma.quote.findMany();
|
||||||
|
cache.set("quotes:current", quotes);
|
||||||
return c.json(quotes);
|
return c.json(quotes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
import type { QuoteType } from "../../../generated/prisma/client";
|
import type { QuoteType } from "../../../generated/prisma/client";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
@@ -26,6 +27,10 @@ export async function getDailyMinMax(c: Context) {
|
|||||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `quotes:minmax:${rawType}:${startDate.toISOString()}:${endDate.toISOString()}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<Array<{
|
const rows = await prisma.$queryRaw<Array<{
|
||||||
date: string;
|
date: string;
|
||||||
minBuy: number;
|
minBuy: number;
|
||||||
@@ -67,5 +72,6 @@ export async function getDailyMinMax(c: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.set(cacheKey, rows);
|
||||||
return c.json(rows);
|
return c.json(rows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ export async function getDailyQuotes(c: Context) {
|
|||||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `quotes:daily:${rawType}:${startDate.toISOString()}:${endDate.toISOString()}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<Array<{
|
const rows = await prisma.$queryRaw<Array<{
|
||||||
date: string;
|
date: string;
|
||||||
buy: number;
|
buy: number;
|
||||||
@@ -41,5 +46,6 @@ export async function getDailyQuotes(c: Context) {
|
|||||||
ORDER BY DATE("timeStamp") ASC
|
ORDER BY DATE("timeStamp") ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
cache.set(cacheKey, rows);
|
||||||
return c.json(rows);
|
return c.json(rows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
import type { QuoteType } from "../../../generated/prisma/client";
|
import type { QuoteType } from "../../../generated/prisma/client";
|
||||||
|
|
||||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
@@ -27,6 +28,10 @@ export async function getQuoteHistory(c: Context) {
|
|||||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `quotes:history:${type}:${startDate.toISOString()}:${endDate.toISOString()}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
const history = await prisma.quoteHistory.findMany({
|
const history = await prisma.quoteHistory.findMany({
|
||||||
where: {
|
where: {
|
||||||
type,
|
type,
|
||||||
@@ -35,5 +40,6 @@ export async function getQuoteHistory(c: Context) {
|
|||||||
orderBy: { timeStamp: "asc" },
|
orderBy: { timeStamp: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cache.set(cacheKey, history);
|
||||||
return c.json(history);
|
return c.json(history);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user