feat: category import from CSV + budget auto-assign rules
Category: - Add categoryColumn to NormalizerConfig and NormalizedRow - Map 'Category' column for Discover CC profile - Write category to Transaction on upload Budget rules: - Add BudgetRule model (userId, budgetId, pattern) - API: GET/POST /api/budget-rules, DELETE /api/budget-rules/:id - Apply rules during upload (first case-insensitive match wins) - Budgets page fetches and passes rules per budget - BudgetCard 'Rules' menu item opens BudgetRulesDialog for add/delete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
budgets Budget[]
|
budgets Budget[]
|
||||||
|
budgetRules BudgetRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountType {
|
enum AccountType {
|
||||||
@@ -80,10 +81,24 @@ model Budget {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
rules BudgetRule[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BudgetRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
budgetId String
|
||||||
|
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade)
|
||||||
|
pattern String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([budgetId])
|
||||||
|
}
|
||||||
|
|
||||||
model CsvUpload {
|
model CsvUpload {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
accountId String
|
accountId String
|
||||||
|
|||||||
@@ -6,29 +6,41 @@ export default async function BudgetsPage() {
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return null
|
if (!session?.user?.id) return null
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||||
|
|
||||||
const [budgets, spendRows] = await Promise.all([
|
const [budgets, spendRows, rules] = await Promise.all([
|
||||||
prisma.budget.findMany({
|
prisma.budget.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
}),
|
}),
|
||||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||||
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
||||||
FROM "Transaction" t
|
FROM "Transaction" t
|
||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${session.user.id}
|
WHERE a."userId" = ${userId}
|
||||||
AND t."budgetId" IS NOT NULL
|
AND t."budgetId" IS NOT NULL
|
||||||
AND t.type = 'DEBIT'
|
AND t.type = 'DEBIT'
|
||||||
AND t.date >= ${monthStart}
|
AND t.date >= ${monthStart}
|
||||||
AND t.date <= ${monthEnd}
|
AND t.date <= ${monthEnd}
|
||||||
GROUP BY t."budgetId"
|
GROUP BY t."budgetId"
|
||||||
`,
|
`,
|
||||||
|
prisma.budgetRule.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, budgetId: true, pattern: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
|
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
|
||||||
|
const rulesMap = new Map<string, { id: string; pattern: string }[]>()
|
||||||
|
for (const rule of rules) {
|
||||||
|
const existing = rulesMap.get(rule.budgetId) ?? []
|
||||||
|
existing.push({ id: rule.id, pattern: rule.pattern })
|
||||||
|
rulesMap.set(rule.budgetId, existing)
|
||||||
|
}
|
||||||
|
|
||||||
const budgetsWithSpend = budgets.map((b) => ({
|
const budgetsWithSpend = budgets.map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
@@ -37,6 +49,7 @@ export default async function BudgetsPage() {
|
|||||||
color: b.color,
|
color: b.color,
|
||||||
isActive: b.isActive,
|
isActive: b.isActive,
|
||||||
spendCents: spendMap.get(b.id) ?? 0,
|
spendCents: spendMap.get(b.id) ?? 0,
|
||||||
|
rules: rulesMap.get(b.id) ?? [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
16
src/app/api/budget-rules/[id]/route.ts
Normal file
16
src/app/api/budget-rules/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const rule = await prisma.budgetRule.findFirst({ where: { id, userId: session.user.id } })
|
||||||
|
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.budgetRule.delete({ where: { id } })
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
37
src/app/api/budget-rules/route.ts
Normal file
37
src/app/api/budget-rules/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createBudgetRuleSchema } from '@/lib/validations/budget-rule'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const rules = await prisma.budgetRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, budgetId: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const parsed = createBudgetRuleSchema.safeParse(body)
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const { budgetId, pattern } = parsed.data
|
||||||
|
|
||||||
|
// Verify budget belongs to this user
|
||||||
|
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
|
||||||
|
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const rule = await prisma.budgetRule.create({
|
||||||
|
data: { userId: session.user.id, budgetId, pattern },
|
||||||
|
select: { id: true, budgetId: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rule, { status: 201 })
|
||||||
|
}
|
||||||
@@ -76,6 +76,22 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const normalized = normalizeRows(allRows, accountId, config)
|
const normalized = normalizeRows(allRows, accountId, config)
|
||||||
|
|
||||||
|
// Apply budget auto-assign rules (first match wins)
|
||||||
|
const budgetRules = await prisma.budgetRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { pattern: true, budgetId: true },
|
||||||
|
})
|
||||||
|
const rowsWithBudgets = normalized.map((row) => {
|
||||||
|
const desc = row.description.toLowerCase()
|
||||||
|
for (const rule of budgetRules) {
|
||||||
|
if (desc.includes(rule.pattern.toLowerCase())) {
|
||||||
|
return { ...row, budgetId: rule.budgetId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...row, budgetId: null }
|
||||||
|
})
|
||||||
|
|
||||||
const upload = await prisma.csvUpload.create({
|
const upload = await prisma.csvUpload.create({
|
||||||
data: {
|
data: {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -89,13 +105,15 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { count: importedCount } = await prisma.transaction.createMany({
|
const { count: importedCount } = await prisma.transaction.createMany({
|
||||||
data: normalized.map((r) => ({
|
data: rowsWithBudgets.map((r) => ({
|
||||||
accountId,
|
accountId,
|
||||||
uploadId: upload.id,
|
uploadId: upload.id,
|
||||||
date: r.date,
|
date: r.date,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
amountCents: r.amountCents,
|
amountCents: r.amountCents,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
category: r.category ?? null,
|
||||||
|
budgetId: r.budgetId,
|
||||||
dedupeHash: r.dedupeHash,
|
dedupeHash: r.dedupeHash,
|
||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
@@ -118,7 +136,7 @@ export async function POST(req: Request) {
|
|||||||
// Upsert balance snapshots for each affected month
|
// Upsert balance snapshots for each affected month
|
||||||
const months = [
|
const months = [
|
||||||
...new Map(
|
...new Map(
|
||||||
normalized.map((r) => {
|
rowsWithBudgets.map((r) => {
|
||||||
const y = r.date.getFullYear()
|
const y = r.date.getFullYear()
|
||||||
const m = r.date.getMonth() + 1
|
const m = r.date.getMonth() + 1
|
||||||
return [`${y}-${m}`, { year: y, month: m }]
|
return [`${y}-${m}`, { year: y, month: m }]
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import {
|
|||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye } from 'lucide-react'
|
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, ListFilter } from 'lucide-react'
|
||||||
import { BudgetProgress } from './BudgetProgress'
|
import { BudgetProgress } from './BudgetProgress'
|
||||||
import { CreateBudgetDialog } from './CreateBudgetDialog'
|
import { CreateBudgetDialog } from './CreateBudgetDialog'
|
||||||
|
import { BudgetRulesDialog } from './BudgetRulesDialog'
|
||||||
import { formatCents } from '@/lib/utils/currency'
|
import { formatCents } from '@/lib/utils/currency'
|
||||||
|
|
||||||
|
export interface BudgetRule {
|
||||||
|
id: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BudgetWithSpend {
|
export interface BudgetWithSpend {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -19,11 +25,13 @@ export interface BudgetWithSpend {
|
|||||||
color: string | null
|
color: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
spendCents: number
|
spendCents: number
|
||||||
|
rules?: BudgetRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [rulesOpen, setRulesOpen] = useState(false)
|
||||||
|
|
||||||
async function handleToggle() {
|
async function handleToggle() {
|
||||||
await fetch(`/api/budgets/${budget.id}`, {
|
await fetch(`/api/budgets/${budget.id}`, {
|
||||||
@@ -62,6 +70,9 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
|||||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||||
<Pencil className="h-4 w-4 mr-2" />Edit
|
<Pencil className="h-4 w-4 mr-2" />Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
|
||||||
|
<ListFilter className="h-4 w-4 mr-2" />Rules
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleToggle}>
|
<DropdownMenuItem onClick={handleToggle}>
|
||||||
{budget.isActive
|
{budget.isActive
|
||||||
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
||||||
@@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
|
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
|
||||||
|
<BudgetRulesDialog
|
||||||
|
open={rulesOpen}
|
||||||
|
onOpenChange={setRulesOpen}
|
||||||
|
budgetId={budget.id}
|
||||||
|
budgetName={budget.name}
|
||||||
|
rules={budget.rules ?? []}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Trash2, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
budgetId: string
|
||||||
|
budgetName: string
|
||||||
|
rules: Rule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, rules }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [pattern, setPattern] = useState('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!pattern.trim()) return
|
||||||
|
setAdding(true)
|
||||||
|
await fetch('/api/budget-rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ budgetId, pattern: pattern.trim() }),
|
||||||
|
})
|
||||||
|
setPattern('')
|
||||||
|
setAdding(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await fetch(`/api/budget-rules/${id}`, { method: 'DELETE' })
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Auto-assign rules — {budgetName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Transactions whose description contains a pattern (case-insensitive) are automatically
|
||||||
|
assigned to this budget on upload. First matching rule wins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<li
|
||||||
|
key={rule.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono truncate">{rule.pattern}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
aria-label="Delete rule"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Amazon, Netflix, Starbucks"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export interface NormalizerConfig {
|
|||||||
strategy: ParseStrategy
|
strategy: ParseStrategy
|
||||||
dateColumn: string
|
dateColumn: string
|
||||||
descriptionColumn: string
|
descriptionColumn: string
|
||||||
|
categoryColumn?: string
|
||||||
// Strategy A
|
// Strategy A
|
||||||
amountColumn?: string
|
amountColumn?: string
|
||||||
invertAmountSign?: boolean
|
invertAmountSign?: boolean
|
||||||
@@ -40,6 +41,7 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount',
|
amountColumn: 'Amount',
|
||||||
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
|
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
|
||||||
|
categoryColumn: 'Category',
|
||||||
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
|
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface NormalizedRow {
|
|||||||
description: string
|
description: string
|
||||||
amountCents: number
|
amountCents: number
|
||||||
type: 'DEBIT' | 'CREDIT'
|
type: 'DEBIT' | 'CREDIT'
|
||||||
|
category?: string
|
||||||
dedupeHash: string
|
dedupeHash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +93,13 @@ export function normalizeRows(
|
|||||||
|
|
||||||
if (amountCents === 0) continue
|
if (amountCents === 0) continue
|
||||||
|
|
||||||
|
const rawCategory = config.categoryColumn ? (row[config.categoryColumn] ?? '').trim() : ''
|
||||||
out.push({
|
out.push({
|
||||||
date,
|
date,
|
||||||
description,
|
description,
|
||||||
amountCents,
|
amountCents,
|
||||||
type,
|
type,
|
||||||
|
category: rawCategory || undefined,
|
||||||
dedupeHash: dedupeHash(accountId, date, description, amountCents),
|
dedupeHash: dedupeHash(accountId, date, description, amountCents),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
6
src/lib/validations/budget-rule.ts
Normal file
6
src/lib/validations/budget-rule.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const createBudgetRuleSchema = z.object({
|
||||||
|
budgetId: z.string().cuid(),
|
||||||
|
pattern: z.string().min(1).max(200).trim(),
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user