fiddy/apps/web/lib/server/buckets.ts
2026-02-11 23:45:15 -08:00

221 lines
7.5 KiB
TypeScript

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";
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";
is_recurring: boolean;
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<UsageEntryRow>(
`select e.id, e.amount_dollars, e.occurred_at, e.necessity, e.is_recurring,
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, e.is_recurring`,
[groupId]
)).rows;
return rows.map(row => ({
amountDollars: Number(row.amount_dollars),
occurredAt: row.occurred_at,
necessity: row.necessity,
isRecurring: row.is_recurring,
tags: row.tags || [],
entryType: "SPENDING" as const
}));
}
export async function listBuckets(groupId: number): Promise<Bucket[]> {
const pool = getPool();
const rows = (await pool.query<BucketRow>(
`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;
}) {
const pool = getPool();
const rows = (await pool.query<BucketRow>(
`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;
}) {
const pool = getPool();
const rows = (await pool.query<BucketRow>(
`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 }) {
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<BucketTagRow>(
`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));
}