From d485c2adcf8fd7b7e5739fa0bfc13744b3ba41ca Mon Sep 17 00:00:00 2001 From: Sandra Modzelewska Date: Thu, 30 Jan 2025 05:11:45 +0000 Subject: [PATCH] Functional account summaries --- .vscode/launch.json | 9 + drizzle/0001_old_leopardon.sql | 28 +++ drizzle/meta/0001_snapshot.json | 338 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/lib/server/accounting.ts | 31 +++ src/lib/server/schema.ts | 81 +++++++- src/lib/server/user.ts | 77 +++++--- src/routes/+page.server.ts | 12 ++ src/routes/+page.svelte | 8 + 9 files changed, 559 insertions(+), 32 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 drizzle/0001_old_leopardon.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/lib/server/accounting.ts create mode 100644 src/routes/+page.server.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..82e6d33 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,9 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + ] +} \ No newline at end of file diff --git a/drizzle/0001_old_leopardon.sql b/drizzle/0001_old_leopardon.sql new file mode 100644 index 0000000..c1c629e --- /dev/null +++ b/drizzle/0001_old_leopardon.sql @@ -0,0 +1,28 @@ +CREATE TABLE "accounts" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "accounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "owner_id" integer NOT NULL, + "name" text NOT NULL, + "currency" text NOT NULL, + "starting_date" timestamp with time zone NOT NULL, + "starting_balance" numeric DEFAULT '0' NOT NULL +); +--> statement-breakpoint +CREATE TABLE "receipt_items" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "receipt_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "receipt_id" integer NOT NULL, + "amount" numeric DEFAULT '0' NOT NULL, + "category" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "receipts" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "receipts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "date" timestamp with time zone NOT NULL, + "account_from_id" integer NOT NULL, + "account_to_id" integer, + CONSTRAINT "no_account_loops" CHECK ("receipts"."account_from_id" <> "receipts"."account_to_id") +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "receipt_items" ADD CONSTRAINT "receipt_items_receipt_id_receipts_id_fk" FOREIGN KEY ("receipt_id") REFERENCES "public"."receipts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_account_from_id_accounts_id_fk" FOREIGN KEY ("account_from_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_account_to_id_accounts_id_fk" FOREIGN KEY ("account_to_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b74c72f --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,338 @@ +{ + "id": "812f446c-4ff5-43a4-bad1-9e4cbb821b35", + "prevId": "9f31c19a-9e3c-41db-a08b-27d9916265cb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "accounts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starting_date": { + "name": "starting_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "starting_balance": { + "name": "starting_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_owner_id_users_id_fk": { + "name": "accounts_owner_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.receipt_items": { + "name": "receipt_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "receipt_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "receipt_id": { + "name": "receipt_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "receipt_items_receipt_id_receipts_id_fk": { + "name": "receipt_items_receipt_id_receipts_id_fk", + "tableFrom": "receipt_items", + "tableTo": "receipts", + "columnsFrom": [ + "receipt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "receipts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "account_from_id": { + "name": "account_from_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_to_id": { + "name": "account_to_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "receipts_account_from_id_accounts_id_fk": { + "name": "receipts_account_from_id_accounts_id_fk", + "tableFrom": "receipts", + "tableTo": "accounts", + "columnsFrom": [ + "account_from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipts_account_to_id_accounts_id_fk": { + "name": "receipts_account_to_id_accounts_id_fk", + "tableFrom": "receipts", + "tableTo": "accounts", + "columnsFrom": [ + "account_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "no_account_loops": { + "name": "no_account_loops", + "value": "\"receipts\".\"account_from_id\" <> \"receipts\".\"account_to_id\"" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 411e94a..f6f51df 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1738197417069, "tag": "0000_users-and-sessions", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1738208377219, + "tag": "0001_old_leopardon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/accounting.ts b/src/lib/server/accounting.ts new file mode 100644 index 0000000..09de5e8 --- /dev/null +++ b/src/lib/server/accounting.ts @@ -0,0 +1,31 @@ +import { and, eq, gt, sql } from "drizzle-orm"; +import { db } from "./db"; +import { accounts, receiptItems, receipts } from "./schema"; + +export interface AccountSummary { + id: number, + name: string, + balance: bigint, + currency: string, + lastReceipt?: Date, +} + +export async function getUserAccountSummary(userId: number) { + return (await db + .select({ + id: accounts.id, + name: accounts.name, + currency: accounts.currency, + balance: sql`${accounts.startingBalance} + COALESCE(sum(${receiptItems.amount}), 0)`, + lastReceipt: sql`max(${receipts.date})`, + }) + .from(accounts) + .where(eq(accounts.ownerId, userId)) + .leftJoin(receipts, and(eq(receipts.accountFromId, accounts.id), gt(receipts.date, accounts.startingDate))) + .leftJoin(receiptItems, eq(receiptItems.receiptId, receipts.id)) + .groupBy(accounts.id)).map((row) => ({ + ...row, + balance: BigInt(row.balance), + lastReceipt: row.lastReceipt ?? undefined, + } satisfies AccountSummary)) +} diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts index 706af26..68c2757 100644 --- a/src/lib/server/schema.ts +++ b/src/lib/server/schema.ts @@ -1,10 +1,12 @@ -import { relations } from "drizzle-orm"; +import { ne, relations } from "drizzle-orm"; import { + check, integer, + numeric, pgTable, - varchar, text, timestamp, + varchar, } from "drizzle-orm/pg-core"; export const users = pgTable("users", { @@ -14,6 +16,10 @@ export const users = pgTable("users", { displayName: text(), }); +export const userRelations = relations(users, ({ many }) => ({ + accounts: many(accounts), +})); + export const sessions = pgTable("sessions", { id: text().primaryKey(), userId: integer() @@ -23,8 +29,75 @@ export const sessions = pgTable("sessions", { }); export const sessionRelations = relations(sessions, ({ one }) => ({ - user: one(users, { - fields: [ sessions.userId ], + user: one(users, { + fields: [sessions.userId], references: [users.id], }), })); + +export const accounts = pgTable("accounts", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + ownerId: integer() + .notNull() + .references(() => users.id), + name: text().notNull(), + currency: text().notNull(), + startingDate: timestamp({ withTimezone: true }).notNull(), + startingBalance: numeric().notNull().default("0"), +}); + +export const accountRelations = relations(accounts, ({ one, many }) => ({ + owner: one(users, { + fields: [accounts.ownerId], + references: [users.id], + }), + credits: many(receipts, { relationName: "account_from" }), + debits: many(receipts, { relationName: "account_to" }), +})); + +export const receipts = pgTable( + "receipts", + { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + date: timestamp({ withTimezone: true }).notNull(), + + accountFromId: integer() + .notNull() + .references(() => accounts.id), + + accountToId: integer().references(() => accounts.id), + }, + (table) => [ + check("no_account_loops", ne(table.accountFromId, table.accountToId)), + ], +); + +export const receiptRelations = relations(receipts, ({ one, many }) => ({ + accountFrom: one(accounts, { + fields: [receipts.accountFromId], + references: [accounts.id], + relationName: "account_from", + }), + accountTo: one(accounts, { + fields: [receipts.accountToId], + references: [accounts.id], + relationName: "account_to", + }), + receipts: many(receipts), +})); + +export const receiptItems = pgTable("receipt_items", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + receiptId: integer() + .notNull() + .references(() => receipts.id), + amount: numeric().notNull().default("0"), + category: text().notNull(), +}); + +export const receiptItemRelations = relations(receiptItems, ({ one }) => ({ + receipt: one(receipts, { + fields: [receiptItems.receiptId], + references: [receipts.id], + }), +})); diff --git a/src/lib/server/user.ts b/src/lib/server/user.ts index ea34cd1..5d850af 100644 --- a/src/lib/server/user.ts +++ b/src/lib/server/user.ts @@ -1,45 +1,66 @@ import { eq } from "drizzle-orm"; import { db } from "./db"; import { users } from "./schema"; +import type { RequestEvent } from "../../routes/$types"; +import { redirect } from "@sveltejs/kit"; export interface User { - id: number; - username: string; - displayName?: string; + id: number; + username: string; + displayName?: string; } - -export async function getUserFromUsername(username: string): Promise { - const user = await db.query.users.findFirst({ - columns: { - id: true, - username: true, - displayName: true - }, - where: eq(users.username, username) - }); +export async function getUserFromUsername( + username: string, +): Promise { + const user = await db.query.users.findFirst({ + columns: { + id: true, + username: true, + displayName: true, + }, + where: eq(users.username, username), + }); if (!user) { return null; } return { - ...user, - displayName: user.displayName || undefined, - }; + ...user, + displayName: user.displayName || undefined, + }; } -export async function getUserPasswordHash(userId: number): Promise { - const user = (await db.query.users.findFirst({ - columns: { - passwordHash: true, - }, - where: eq(users.id, userId) - })); +export async function getUserPasswordHash( + userId: number, +): Promise { + const user = await db.query.users.findFirst({ + columns: { + passwordHash: true, + }, + where: eq(users.id, userId), + }); - if (user === undefined) { - throw new Error("Invalid user ID"); - } + if (user === undefined) { + throw new Error("Invalid user ID"); + } - return user.passwordHash; -} \ No newline at end of file + return user.passwordHash; +} + +export function ensureUser( + event: RequestEvent, + defaultAction: (() => never) | undefined = undefined, +): User { + const user = event.locals.user; + if (user !== null) { + return user; + } + + if (defaultAction === undefined) { + redirect(303, "/login"); + } + + return defaultAction(); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..5362129 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,12 @@ +import { getUserAccountSummary } from "$lib/server/accounting"; +import { ensureUser } from "$lib/server/user"; +import type { PageServerLoad } from "./$types"; + + +export const load: PageServerLoad = async (event) => { + const user = ensureUser(event); + + return { + accountSummaries: await getUserAccountSummary(user.id), + } +}; \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fdb18b3..21212a5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,3 +6,11 @@

Welcome to SvelteKit

Visit svelte.dev/docs/kit to read the documentation

+ +Your accounts: +
    + {#each data.accountSummaries as summary } +
  • {summary.name} - {summary.lastReceipt ?? "never"} - {summary.balance}{summary.currency}
  • + + {/each} +
\ No newline at end of file