225 lines
7.9 KiB
TypeScript
225 lines
7.9 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";
|
|
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";
|
|
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;
|
|
}) {
|
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:create" });
|
|
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;
|
|
}) {
|
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "buckets:update" });
|
|
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; 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<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));
|
|
}
|
|
|