fiddy/apps/web/lib/server/entries.ts
Nico f8e426542d
Some checks failed
Build & Deploy Fiddy (Dokploy) / build (push) Has been cancelled
Build & Deploy Fiddy (Dokploy) / deploy (push) Has been cancelled
feat: implement schedules pivot, scheduler service, and dokploy deploy flow
2026-02-15 17:10:58 -08:00

161 lines
6.0 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;
source_schedule_id: number | 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,
source_schedule_id
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)) || [],
sourceScheduleId: row.source_schedule_id == null ? null : Number(row.source_schedule_id)
}));
}
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[];
bucketId?: number | null;
sourceScheduleId?: 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,
source_schedule_id)
values($1,$2,$3,$4,$5,$6,$7,$8,$9)
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
source_schedule_id`,
[
input.groupId,
input.userId,
input.entryType,
input.amountDollars,
input.occurredAt,
input.necessity,
input.purchaseType,
input.notes || null,
input.sourceScheduleId ?? 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,
sourceScheduleId: row.source_schedule_id == null ? null : Number(row.source_schedule_id)
} 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[];
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
where id=$7 and group_id=$8
returning id, entry_type, amount_dollars, occurred_at, necessity, purchase_type, notes, receipt_id,
source_schedule_id`,
[
input.entryType,
input.amountDollars,
input.occurredAt,
input.necessity,
input.purchaseType,
input.notes || 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,
sourceScheduleId: rows[0].source_schedule_id == null ? null : Number(rows[0].source_schedule_id)
} 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]);
}