216 lines
8.5 KiB
TypeScript
216 lines
8.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 { listTagsForEntries, normalizeTags, requireExistingTagsForGroup, setEntryTags } from "@/lib/server/tags";
|
|
import { listBucketTags } from "@/lib/server/buckets";
|
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
|
import type { Entry } from "@/lib/shared/types";
|
|
|
|
type EntryRow = {
|
|
id: number;
|
|
entry_type: "SPENDING" | "INCOME";
|
|
amount_dollars: string | number;
|
|
occurred_at: string;
|
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
|
purchase_type: string;
|
|
notes: string | null;
|
|
receipt_id: number | null;
|
|
is_recurring: boolean;
|
|
frequency: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
interval_count: number;
|
|
end_condition: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
end_count: number | null;
|
|
end_date: string | null;
|
|
next_run_at: string | null;
|
|
last_executed_at: string | null;
|
|
};
|
|
|
|
export { requireActiveGroup };
|
|
|
|
export async function listEntries(groupId: number): Promise<Entry[]> {
|
|
const pool = getPool();
|
|
const rows = (await pool.query<EntryRow>(
|
|
`select id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
|
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at
|
|
from entries
|
|
where group_id = $1
|
|
order by occurred_at desc, id desc`,
|
|
[groupId]
|
|
)).rows;
|
|
const entryIds = rows.map((row: EntryRow) => Number(row.id));
|
|
const tagsMap = await listTagsForEntries({ groupId, entryIds });
|
|
return rows.map((row: EntryRow) => ({
|
|
id: Number(row.id),
|
|
entryType: row.entry_type,
|
|
amountDollars: Number(row.amount_dollars),
|
|
occurredAt: row.occurred_at,
|
|
necessity: row.necessity,
|
|
purchaseType: row.purchase_type,
|
|
notes: row.notes,
|
|
receiptId: row.receipt_id,
|
|
bucketId: null,
|
|
tags: tagsMap.get(Number(row.id)) || [],
|
|
isRecurring: row.is_recurring,
|
|
frequency: row.frequency,
|
|
intervalCount: Number(row.interval_count || 1),
|
|
endCondition: row.end_condition,
|
|
endCount: row.end_count,
|
|
endDate: row.end_date,
|
|
nextRunAt: row.next_run_at,
|
|
lastExecutedAt: row.last_executed_at
|
|
}));
|
|
}
|
|
|
|
export async function createEntry(input: {
|
|
groupId: number;
|
|
userId: number;
|
|
entryType: "SPENDING" | "INCOME";
|
|
amountDollars: number;
|
|
occurredAt: string;
|
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
|
purchaseType: string;
|
|
notes?: string;
|
|
tags?: string[];
|
|
isRecurring?: boolean;
|
|
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
intervalCount?: number;
|
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
endCount?: number | null;
|
|
endDate?: string | null;
|
|
nextRunAt?: string | null;
|
|
bucketId?: number | null;
|
|
}) {
|
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:create" });
|
|
const pool = getPool();
|
|
const rows = (await pool.query<EntryRow>(
|
|
`insert into entries(group_id, created_by, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes,
|
|
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at)
|
|
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
|
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at`,
|
|
[
|
|
input.groupId,
|
|
input.userId,
|
|
input.entryType,
|
|
input.amountDollars,
|
|
input.occurredAt,
|
|
input.necessity,
|
|
input.purchaseType,
|
|
input.notes || null,
|
|
Boolean(input.isRecurring),
|
|
input.frequency || null,
|
|
input.intervalCount || 1,
|
|
input.endCondition || null,
|
|
input.endCount ?? null,
|
|
input.endDate || null,
|
|
input.nextRunAt || null
|
|
]
|
|
)).rows;
|
|
const row = rows[0];
|
|
const bucketTags = input.bucketId ? await listBucketTags(input.bucketId, input.groupId) : [];
|
|
const tags = normalizeTags([...(input.tags || []), ...bucketTags]);
|
|
const tagIds = await requireExistingTagsForGroup({ groupId: input.groupId, tags });
|
|
await setEntryTags({ entryId: Number(row.id), groupId: input.groupId, tagIds });
|
|
return {
|
|
id: Number(row.id),
|
|
entryType: row.entry_type,
|
|
amountDollars: Number(row.amount_dollars),
|
|
occurredAt: row.occurred_at,
|
|
necessity: row.necessity,
|
|
purchaseType: row.purchase_type,
|
|
notes: row.notes,
|
|
receiptId: row.receipt_id,
|
|
bucketId: null,
|
|
tags,
|
|
isRecurring: row.is_recurring,
|
|
frequency: row.frequency,
|
|
intervalCount: Number(row.interval_count || 1),
|
|
endCondition: row.end_condition,
|
|
endCount: row.end_count,
|
|
endDate: row.end_date,
|
|
nextRunAt: row.next_run_at,
|
|
lastExecutedAt: row.last_executed_at
|
|
} as Entry;
|
|
}
|
|
|
|
export async function updateEntry(input: {
|
|
id: number;
|
|
groupId: number;
|
|
userId: number;
|
|
entryType: "SPENDING" | "INCOME";
|
|
amountDollars: number;
|
|
occurredAt: string;
|
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
|
purchaseType: string;
|
|
notes?: string;
|
|
tags?: string[];
|
|
isRecurring?: boolean;
|
|
frequency?: "DAILY" | "WEEKLY" | "BIWEEKLY" | "MONTHLY" | "QUARTERLY" | "YEARLY" | null;
|
|
intervalCount?: number;
|
|
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE" | null;
|
|
endCount?: number | null;
|
|
endDate?: string | null;
|
|
nextRunAt?: string | null;
|
|
bucketId?: number | null;
|
|
}) {
|
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:update" });
|
|
const pool = getPool();
|
|
const rows = (await pool.query<EntryRow>(
|
|
`update entries
|
|
set entry_type=$1, amount_dollars=$2, occurred_at=$3, necessity=$4, purchase_type=$5, notes=$6,
|
|
is_recurring=$7, frequency=$8, interval_count=$9, end_condition=$10, end_count=$11, end_date=$12, next_run_at=$13
|
|
where id=$14 and group_id=$15
|
|
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
|
|
is_recurring, frequency, interval_count, end_condition, end_count, end_date, next_run_at, last_executed_at`,
|
|
[
|
|
input.entryType,
|
|
input.amountDollars,
|
|
input.occurredAt,
|
|
input.necessity,
|
|
input.purchaseType,
|
|
input.notes || null,
|
|
Boolean(input.isRecurring),
|
|
input.frequency || null,
|
|
input.intervalCount || 1,
|
|
input.endCondition || null,
|
|
input.endCount ?? null,
|
|
input.endDate || null,
|
|
input.nextRunAt || null,
|
|
input.id,
|
|
input.groupId
|
|
]
|
|
)).rows;
|
|
if (!rows[0]) return null;
|
|
const bucketTags = input.bucketId ? await listBucketTags(input.bucketId, input.groupId) : [];
|
|
const tags = normalizeTags([...(input.tags || []), ...bucketTags]);
|
|
const tagIds = await requireExistingTagsForGroup({ groupId: input.groupId, tags });
|
|
await setEntryTags({ entryId: Number(input.id), groupId: input.groupId, tagIds });
|
|
return {
|
|
id: Number(rows[0].id),
|
|
entryType: rows[0].entry_type,
|
|
amountDollars: Number(rows[0].amount_dollars),
|
|
occurredAt: rows[0].occurred_at,
|
|
necessity: rows[0].necessity,
|
|
purchaseType: rows[0].purchase_type,
|
|
notes: rows[0].notes,
|
|
receiptId: rows[0].receipt_id,
|
|
bucketId: null,
|
|
tags,
|
|
isRecurring: rows[0].is_recurring,
|
|
frequency: rows[0].frequency,
|
|
intervalCount: Number(rows[0].interval_count || 1),
|
|
endCondition: rows[0].end_condition,
|
|
endCount: rows[0].end_count,
|
|
endDate: rows[0].end_date,
|
|
nextRunAt: rows[0].next_run_at,
|
|
lastExecutedAt: rows[0].last_executed_at
|
|
} as Entry;
|
|
}
|
|
|
|
export async function deleteEntry(input: { id: number; groupId: number; userId: number }) {
|
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "entries:delete" });
|
|
const pool = getPool();
|
|
await pool.query("delete from entries where id=$1 and group_id=$2", [input.id, input.groupId]);
|
|
}
|