added api logic, vitest, minimal testing ui
This commit is contained in:
@@ -1,10 +1,516 @@
|
||||
// api/src/server.ts
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { env } from "./env.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { allocateIncome } from "./allocator.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
app.listen({ port, host: "0.0.0.0" }).catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance { prisma: PrismaClient }
|
||||
interface FastifyRequest { userId: string }
|
||||
}
|
||||
|
||||
const toBig = (n: number | string | bigint) => BigInt(n);
|
||||
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
const isoStart = (d: string) => new Date(`${d}T00:00:00.000Z`);
|
||||
const isoEnd = (d: string) => new Date(`${d}T23:59:59.999Z`);
|
||||
|
||||
function jsonBigIntSafe(obj: unknown) {
|
||||
return JSON.parse(JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)));
|
||||
}
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
requestIdHeader: "x-request-id",
|
||||
genReqId: (req) => {
|
||||
const hdr = req.headers["x-request-id"];
|
||||
if (typeof hdr === "string" && hdr.length <= 64) return hdr;
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
},
|
||||
});
|
||||
|
||||
// CORS
|
||||
await app.register(cors, {
|
||||
origin: (() => {
|
||||
if (!env.CORS_ORIGIN) return true; // dev: allow all
|
||||
const allow = env.CORS_ORIGIN.split(",").map(s => s.trim()).filter(Boolean);
|
||||
return (origin, cb) => {
|
||||
if (!origin) return cb(null, true); // curl/health
|
||||
cb(null, allow.includes(origin));
|
||||
};
|
||||
})(),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Rate limit (light)
|
||||
await app.register(rateLimit, {
|
||||
max: env.RATE_LIMIT_MAX,
|
||||
timeWindow: env.RATE_LIMIT_WINDOW_MS,
|
||||
hook: "onRequest",
|
||||
allowList: (req) => {
|
||||
const ip = (req.ip || "").replace("::ffff:", "");
|
||||
return ip === "127.0.0.1" || ip === "::1";
|
||||
},
|
||||
});
|
||||
|
||||
// Prisma
|
||||
{
|
||||
const prisma = new PrismaClient();
|
||||
app.decorate("prisma", prisma);
|
||||
app.addHook("onClose", async () => prisma.$disconnect());
|
||||
}
|
||||
|
||||
// Auth stub + ensure user exists + set x-request-id header ONCE
|
||||
app.addHook("onRequest", async (req, reply) => {
|
||||
const headerId = req.headers["x-user-id"];
|
||||
if (typeof headerId === "string" && headerId.trim()) req.userId = headerId.trim();
|
||||
else req.userId = "demo-user-1";
|
||||
|
||||
// echo the request id (no per-request hook registration)
|
||||
if (req.id) reply.header("x-request-id", String(req.id));
|
||||
|
||||
await app.prisma.user.upsert({
|
||||
where: { id: req.userId },
|
||||
update: {},
|
||||
create: { id: req.userId, email: `${req.userId}@demo.local` },
|
||||
});
|
||||
});
|
||||
|
||||
// BigInt-safe JSON (single onSend)
|
||||
app.addHook("preSerialization", (_req, _reply, payload, done) => {
|
||||
try {
|
||||
if (payload && typeof payload === "object") {
|
||||
const safe = JSON.parse(
|
||||
JSON.stringify(payload, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
|
||||
);
|
||||
return done(null, safe);
|
||||
}
|
||||
return done(null, payload);
|
||||
} catch {
|
||||
// If anything goes sideways, keep the original payload
|
||||
return done(null, payload);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
// Map prisma/validation-ish errors to 400 by default
|
||||
const status =
|
||||
(typeof (err as any).statusCode === "number" && (err as any).statusCode) ||
|
||||
(typeof (err as any).status === "number" && (err as any).status) ||
|
||||
(typeof (err as any).code === "string" && (err as any).code.startsWith("P2") ? 400 : 500);
|
||||
|
||||
// Never leak stacks to client
|
||||
const body = {
|
||||
ok: false,
|
||||
code: (err as any).code ?? "INTERNAL",
|
||||
message:
|
||||
status >= 500
|
||||
? "Something went wrong"
|
||||
: (err as any).message ?? "Bad request",
|
||||
requestId: String(req.id ?? ""),
|
||||
};
|
||||
|
||||
// Log full error with request context
|
||||
req.log.error({ err, requestId: req.id }, "request failed");
|
||||
reply.code(status).send(body);
|
||||
});
|
||||
|
||||
// 404 JSON
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
reply.code(404).send({
|
||||
ok: false,
|
||||
code: "NOT_FOUND",
|
||||
message: `No route: ${req.method} ${req.url}`,
|
||||
requestId: String(req.id ?? ""),
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────── Health ─────────────
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
app.get("/health/db", async () => {
|
||||
const start = Date.now();
|
||||
const [{ now }] = await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now");
|
||||
const latencyMs = Date.now() - start;
|
||||
return { ok: true, nowISO: now.toISOString(), latencyMs };
|
||||
});
|
||||
|
||||
// ───────────── Dashboard ─────────────
|
||||
app.get("/dashboard", async (req) => {
|
||||
const userId = req.userId;
|
||||
|
||||
const [cats, plans, txs, agg] = await Promise.all([
|
||||
app.prisma.variableCategory.findMany({
|
||||
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }]
|
||||
}),
|
||||
app.prisma.fixedPlan.findMany({
|
||||
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }]
|
||||
}),
|
||||
app.prisma.transaction.findMany({
|
||||
where: { userId }, orderBy: { occurredAt: "desc" }, take: 50,
|
||||
select: { id: true, kind: true, amountCents: true, occurredAt: true }
|
||||
}),
|
||||
app.prisma.incomeEvent.aggregate({
|
||||
where: { userId }, _sum: { amountCents: true }
|
||||
}),
|
||||
]);
|
||||
|
||||
const totals = {
|
||||
incomeCents: Number(agg._sum?.amountCents ?? 0n),
|
||||
variableBalanceCents: Number(cats.reduce((s, c) => s + (c.balanceCents ?? 0n), 0n)),
|
||||
fixedRemainingCents: Number(plans.reduce((s, p) => {
|
||||
const rem = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
|
||||
return s + (rem > 0n ? rem : 0n);
|
||||
}, 0n)),
|
||||
};
|
||||
const percentTotal = cats.reduce((s, c) => s + c.percent, 0);
|
||||
|
||||
return { totals, variableCategories: cats, fixedPlans: plans, recentTransactions: txs, percentTotal };
|
||||
});
|
||||
|
||||
// ───────────── Income (allocate) ─────────────
|
||||
app.post("/income", async (req, reply) => {
|
||||
const Body = z.object({ amountCents: z.number().int().nonnegative() });
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
|
||||
|
||||
const userId = req.userId;
|
||||
const nowISO = new Date().toISOString();
|
||||
const amountCentsNum = parsed.data.amountCents;
|
||||
|
||||
const income = await app.prisma.incomeEvent.create({
|
||||
data: { userId, postedAt: new Date(nowISO), amountCents: toBig(amountCentsNum) },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const result = await allocateIncome(app.prisma, userId, amountCentsNum, nowISO, income.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
// ───────────── Transactions: create (strict overdraft) ─────────────
|
||||
app.post("/transactions", async (req, reply) => {
|
||||
const Body = z.object({
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]),
|
||||
amountCents: z.number().int().positive(),
|
||||
occurredAtISO: z.string().datetime(),
|
||||
categoryId: z.string().optional(),
|
||||
planId: z.string().optional(),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
|
||||
|
||||
const { kind, amountCents, occurredAtISO, categoryId, planId } = parsed.data;
|
||||
const userId = req.userId;
|
||||
const amt = toBig(amountCents);
|
||||
|
||||
return await app.prisma.$transaction(async (tx) => {
|
||||
if (kind === "variable_spend") {
|
||||
if (!categoryId) return reply.code(400).send({ message: "categoryId required" });
|
||||
const cat = await tx.variableCategory.findFirst({ where: { id: categoryId, userId } });
|
||||
if (!cat) return reply.code(404).send({ message: "Category not found" });
|
||||
|
||||
const bal = cat.balanceCents ?? 0n;
|
||||
if (amt > bal) {
|
||||
const err: any = new Error("Insufficient category balance");
|
||||
err.statusCode = 400; err.code = "OVERDRAFT_CATEGORY";
|
||||
throw err;
|
||||
}
|
||||
await tx.variableCategory.update({ where: { id: cat.id }, data: { balanceCents: bal - amt } });
|
||||
} else {
|
||||
if (!planId) return reply.code(400).send({ message: "planId required" });
|
||||
const plan = await tx.fixedPlan.findFirst({ where: { id: planId, userId } });
|
||||
if (!plan) return reply.code(404).send({ message: "Plan not found" });
|
||||
|
||||
const funded = plan.fundedCents ?? 0n;
|
||||
if (amt > funded) {
|
||||
const err: any = new Error("Insufficient plan funds");
|
||||
err.statusCode = 400; err.code = "OVERDRAFT_PLAN";
|
||||
throw err;
|
||||
}
|
||||
await tx.fixedPlan.update({ where: { id: plan.id }, data: { fundedCents: funded - amt } });
|
||||
}
|
||||
|
||||
const row = await tx.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
occurredAt: new Date(occurredAtISO),
|
||||
kind,
|
||||
amountCents: amt,
|
||||
categoryId: kind === "variable_spend" ? categoryId ?? null : null,
|
||||
planId: kind === "fixed_payment" ? planId ?? null : null,
|
||||
},
|
||||
select: { id: true, kind: true, amountCents: true, occurredAt: true },
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────── Transactions: list ─────────────
|
||||
app.get("/transactions", async (req, reply) => {
|
||||
const Query = z.object({
|
||||
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
|
||||
to: z.string().refine(isDate, "YYYY-MM-DD").optional(),
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
|
||||
q: z.string().trim().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Invalid query", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { from, to, kind, q, page, limit } = parsed.data;
|
||||
const userId = req.userId;
|
||||
|
||||
const where: any = { userId };
|
||||
|
||||
if (from || to) {
|
||||
where.occurredAt = {};
|
||||
if (from) where.occurredAt.gte = isoStart(from);
|
||||
if (to) where.occurredAt.lte = isoEnd(to);
|
||||
}
|
||||
if (kind) where.kind = kind;
|
||||
|
||||
// 💡 Only add OR if we actually have predicates
|
||||
if (typeof q === "string" && q.trim() !== "") {
|
||||
const ors: any[] = [];
|
||||
const asNumber = Number(q);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
ors.push({ amountCents: toBig(asNumber) });
|
||||
}
|
||||
// (When you add text fields later, push them here too)
|
||||
if (ors.length > 0) {
|
||||
where.OR = ors;
|
||||
}
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [total, items] = await Promise.all([
|
||||
app.prisma.transaction.count({ where }),
|
||||
app.prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
select: { id: true, kind: true, amountCents: true, occurredAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { items, page, limit, total };
|
||||
});
|
||||
|
||||
// ───────────── Variable Categories CRUD (sum=100 guard) ─────────────
|
||||
const CatBody = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
percent: z.number().int().min(0).max(100),
|
||||
isSavings: z.boolean(),
|
||||
priority: z.number().int().min(0),
|
||||
});
|
||||
|
||||
app.post("/variable-categories", async (req, reply) => {
|
||||
const parsed = CatBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
|
||||
const userId = req.userId;
|
||||
|
||||
return await app.prisma.$transaction(async (tx) => {
|
||||
await tx.variableCategory.create({ data: { userId, balanceCents: 0n, ...parsed.data } });
|
||||
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
|
||||
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
|
||||
return { ok: true };
|
||||
});
|
||||
});
|
||||
|
||||
app.patch("/variable-categories/:id", async (req, reply) => {
|
||||
const patch = CatBody.partial().safeParse(req.body);
|
||||
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
|
||||
const id = String((req.params as any).id);
|
||||
const userId = req.userId;
|
||||
|
||||
return await app.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
|
||||
if (!exists) return reply.code(404).send({ message: "Not found" });
|
||||
|
||||
await tx.variableCategory.update({ where: { id }, data: patch.data });
|
||||
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
|
||||
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
|
||||
return { ok: true };
|
||||
});
|
||||
});
|
||||
|
||||
app.delete("/variable-categories/:id", async (req, reply) => {
|
||||
const id = String((req.params as any).id);
|
||||
const userId = req.userId;
|
||||
|
||||
return await app.prisma.$transaction(async (tx) => {
|
||||
const exists = await tx.variableCategory.findFirst({ where: { id, userId } });
|
||||
if (!exists) return reply.code(404).send({ message: "Not found" });
|
||||
|
||||
await tx.variableCategory.delete({ where: { id } });
|
||||
const total = await tx.variableCategory.aggregate({ where: { userId }, _sum: { percent: true } });
|
||||
if ((total._sum?.percent ?? 0) !== 100) throw reply.code(400).send({ message: "Percents must sum to 100" });
|
||||
return { ok: true };
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────── Fixed Plans CRUD (funded ≤ total) ─────────────
|
||||
const PlanBody = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
totalCents: z.number().int().min(0),
|
||||
fundedCents: z.number().int().min(0).optional(),
|
||||
priority: z.number().int().min(0),
|
||||
dueOn: z.string().datetime(),
|
||||
cycleStart: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
app.post("/fixed-plans", async (req, reply) => {
|
||||
const parsed = PlanBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid payload" });
|
||||
const userId = req.userId;
|
||||
|
||||
const totalBig = toBig(parsed.data.totalCents);
|
||||
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
|
||||
if (fundedBig > totalBig) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
|
||||
|
||||
await app.prisma.fixedPlan.create({
|
||||
data: {
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
totalCents: totalBig,
|
||||
fundedCents: fundedBig,
|
||||
priority: parsed.data.priority,
|
||||
dueOn: new Date(parsed.data.dueOn),
|
||||
cycleStart: new Date(parsed.data.cycleStart ?? parsed.data.dueOn),
|
||||
fundingMode: "auto-on-deposit",
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.patch("/fixed-plans/:id", async (req, reply) => {
|
||||
const patch = PlanBody.partial().safeParse(req.body);
|
||||
if (!patch.success) return reply.code(400).send({ message: "Invalid payload" });
|
||||
const id = String((req.params as any).id);
|
||||
const userId = req.userId;
|
||||
|
||||
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
|
||||
if (!plan) return reply.code(404).send({ message: "Not found" });
|
||||
|
||||
const total = "totalCents" in patch.data ? toBig(patch.data.totalCents as number) : (plan.totalCents ?? 0n);
|
||||
const funded = "fundedCents" in patch.data ? toBig(patch.data.fundedCents as number) : (plan.fundedCents ?? 0n);
|
||||
if (funded > total) return reply.code(400).send({ message: "fundedCents cannot exceed totalCents" });
|
||||
|
||||
await app.prisma.fixedPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...patch.data,
|
||||
...(patch.data.totalCents !== undefined ? { totalCents: total } : {}),
|
||||
...(patch.data.fundedCents !== undefined ? { fundedCents: funded } : {}),
|
||||
...(patch.data.dueOn ? { dueOn: new Date(patch.data.dueOn) } : {}),
|
||||
...(patch.data.cycleStart ? { cycleStart: new Date(patch.data.cycleStart) } : {}),
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete("/fixed-plans/:id", async (req, reply) => {
|
||||
const id = String((req.params as any).id);
|
||||
const userId = req.userId;
|
||||
const plan = await app.prisma.fixedPlan.findFirst({ where: { id, userId } });
|
||||
if (!plan) return reply.code(404).send({ message: "Not found" });
|
||||
await app.prisma.fixedPlan.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ───────────── Income Preview (server-side; mirrors FE preview) ─────────────
|
||||
app.post("/income/preview", async (req, reply) => {
|
||||
const Body = z.object({ amountCents: z.number().int().nonnegative() });
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" });
|
||||
|
||||
const userId = req.userId;
|
||||
let remaining = Math.max(0, parsed.data.amountCents | 0);
|
||||
|
||||
const [plans, cats] = await Promise.all([
|
||||
app.prisma.fixedPlan.findMany({
|
||||
where: { userId }, orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
||||
select: { id: true, name: true, totalCents: true, fundedCents: true, priority: true, dueOn: true },
|
||||
}),
|
||||
app.prisma.variableCategory.findMany({
|
||||
where: { userId }, orderBy: [{ priority: "asc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, percent: true, isSavings: true, priority: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Fixed pass
|
||||
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
for (const p of plans) {
|
||||
if (remaining <= 0) break;
|
||||
const needBig = (p.totalCents ?? 0n) - (p.fundedCents ?? 0n);
|
||||
const need = Number(needBig > 0n ? needBig : 0n);
|
||||
if (need <= 0) continue;
|
||||
const give = Math.min(need, remaining);
|
||||
fixed.push({ id: p.id, name: p.name, amountCents: give });
|
||||
remaining -= give;
|
||||
}
|
||||
|
||||
// Variable pass (largest remainder w/ savings-first tie)
|
||||
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
if (remaining > 0 && cats.length > 0) {
|
||||
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
|
||||
const normCats = totalPercent === 100
|
||||
? cats
|
||||
: cats.map(c => ({ ...c, percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0 }));
|
||||
|
||||
const base: number[] = new Array(normCats.length).fill(0);
|
||||
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
|
||||
let sumBase = 0;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const exact = (remaining * c.percent) / 100;
|
||||
const floor = Math.floor(exact);
|
||||
base[idx] = floor; sumBase += floor;
|
||||
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
|
||||
});
|
||||
|
||||
let leftovers = remaining - sumBase;
|
||||
tie.sort((a, b) => {
|
||||
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
|
||||
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const give = base[idx] || 0;
|
||||
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
|
||||
});
|
||||
|
||||
remaining = leftovers;
|
||||
}
|
||||
|
||||
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
|
||||
});
|
||||
|
||||
// ───────────── Start ─────────────
|
||||
const PORT = env.PORT;
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
|
||||
export default app; // <-- add this
|
||||
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
app.listen({ port: PORT, host: HOST }).catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user