if (process.env.NODE_ENV !== "test") require("server-only"); import getPool from "@/lib/server/db"; import { requireActiveGroup } from "@/lib/server/groups"; import { ensureTagsForGroup, listTagsForBuckets, normalizeTags, setBucketTags } from "@/lib/server/tags"; import { calculateBucketUsage } from "@/lib/shared/bucket-usage"; import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; export { requireActiveGroup }; type BucketRow = { id: number; name: string; description: string | null; icon_key: string | null; budget_limit_dollars: string | number | null; position: number; necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; window_days: number; }; type BucketTagRow = { name: string }; type UsageEntryRow = { id: number; amount_dollars: string | number; occurred_at: string; necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; tags: string[] | null; }; export type Bucket = { id: number; name: string; description: string | null; iconKey: string | null; budgetLimitDollars: number | null; position: number; necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; windowDays: number; tags: string[]; totalUsage: number; matchedCount: number; }; async function listUsageEntries(groupId: number) { const pool = getPool(); const rows = (await pool.query( `select e.id, e.amount_dollars, e.occurred_at, e.necessity, array_remove(array_agg(distinct t.name), null) as tags from entries e left join entry_tags et on et.entry_id = e.id left join tags t on t.id = et.tag_id and t.group_id = $1 where e.group_id = $1 and e.entry_type = 'SPENDING' group by e.id, e.amount_dollars, e.occurred_at, e.necessity`, [groupId] )).rows; return rows.map(row => ({ amountDollars: Number(row.amount_dollars), occurredAt: row.occurred_at, necessity: row.necessity, tags: row.tags || [], entryType: "SPENDING" as const })); } export async function listBuckets(groupId: number): Promise { const pool = getPool(); const rows = (await pool.query( `select id, name, description, icon_key, budget_limit_dollars, position, necessity, window_days from buckets where group_id=$1 order by position asc, name asc`, [groupId] )).rows; const bucketIds = rows.map(row => Number(row.id)); const tagsMap = await listTagsForBuckets({ groupId, bucketIds }); const usageEntries = await listUsageEntries(groupId); return rows.map(row => ({ id: Number(row.id), name: row.name, description: row.description, iconKey: row.icon_key, budgetLimitDollars: row.budget_limit_dollars == null ? null : Number(row.budget_limit_dollars), position: Number(row.position || 0), necessity: row.necessity, windowDays: Number(row.window_days || 0), tags: tagsMap.get(Number(row.id)) || [], ...calculateBucketUsage({ tags: tagsMap.get(Number(row.id)) || [], necessity: row.necessity, windowDays: Number(row.window_days || 0) }, usageEntries, new Date()) })); } export async function createBucket(input: { groupId: number; userId: number; name: string; description?: string; iconKey?: string | null; budgetLimitDollars?: number | null; position?: number; tags?: string[]; necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY"; windowDays?: number; }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:create" }); const pool = getPool(); const rows = (await pool.query( `insert into buckets(group_id, created_by, name, description, icon_key, budget_limit_dollars, position, necessity, window_days) values($1,$2,$3,$4,$5,$6,$7,$8,$9) returning id, name, description, icon_key, budget_limit_dollars, position, necessity, window_days`, [ input.groupId, input.userId, input.name, input.description || null, input.iconKey || null, input.budgetLimitDollars ?? null, input.position ?? 0, input.necessity || "BOTH", input.windowDays ?? 30 ] )).rows; const row = rows[0]; const tags = normalizeTags(input.tags || []); const tagIds = await ensureTagsForGroup({ userId: input.userId, groupId: input.groupId, tags }); await setBucketTags({ bucketId: Number(row.id), tagIds }); return { id: Number(row.id), name: row.name, description: row.description, iconKey: row.icon_key, budgetLimitDollars: row.budget_limit_dollars == null ? null : Number(row.budget_limit_dollars), position: Number(row.position || 0), necessity: row.necessity, windowDays: Number(row.window_days || 0), tags, totalUsage: 0, matchedCount: 0 } as Bucket; } export async function updateBucket(input: { id: number; groupId: number; userId: number; name: string; description?: string; iconKey?: string | null; budgetLimitDollars?: number | null; position?: number; tags?: string[]; necessity?: "NECESSARY" | "BOTH" | "UNNECESSARY"; windowDays?: number; }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:update" }); const pool = getPool(); const rows = (await pool.query( `update buckets set name=$1, description=$2, icon_key=$3, budget_limit_dollars=$4, position=$5, necessity=$6, window_days=$7 where id=$8 and group_id=$9 returning id, name, description, icon_key, budget_limit_dollars, position, necessity, window_days`, [ input.name, input.description || null, input.iconKey || null, input.budgetLimitDollars ?? null, input.position ?? 0, input.necessity || "BOTH", input.windowDays ?? 30, input.id, input.groupId ] )).rows; if (!rows[0]) return null; const tags = normalizeTags(input.tags || []); const tagIds = await ensureTagsForGroup({ userId: input.userId, groupId: input.groupId, tags }); await setBucketTags({ bucketId: Number(input.id), tagIds }); const usageEntries = await listUsageEntries(input.groupId); const usage = calculateBucketUsage({ tags, necessity: rows[0].necessity, windowDays: Number(rows[0].window_days || 0) }, usageEntries, new Date()); return { id: Number(rows[0].id), name: rows[0].name, description: rows[0].description, iconKey: rows[0].icon_key, budgetLimitDollars: rows[0].budget_limit_dollars == null ? null : Number(rows[0].budget_limit_dollars), position: Number(rows[0].position || 0), necessity: rows[0].necessity, windowDays: Number(rows[0].window_days || 0), tags, totalUsage: usage.totalUsage, matchedCount: usage.matchedCount } as Bucket; } export async function deleteBucket(input: { id: number; groupId: number; userId: number }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:delete" }); const pool = getPool(); await pool.query("delete from buckets where id=$1 and group_id=$2", [input.id, input.groupId]); } export async function listBucketTags(bucketId: number, groupId: number) { const pool = getPool(); const rows = (await pool.query( `select t.name from bucket_tags bt join tags t on t.id = bt.tag_id where bt.bucket_id=$1 and t.group_id=$2 order by t.name asc`, [bucketId, groupId] )).rows; return rows.map(row => String(row.name)); }