From 71435de9724cc24b8254681eac8ab721ac322741 Mon Sep 17 00:00:00 2001 From: Jim Burbridge Date: Thu, 18 Sep 2025 14:53:39 -0700 Subject: [PATCH 01/21] feat: begin move to remote functions --- apps/website/package.json | 15 +- .../lib/components/drawerify/drawerify.svelte | 28 +- .../monthDropdown/monthDropdown.svelte | 1 + apps/website/src/lib/remotes/bills.remote.ts | 45 ++ apps/website/src/lib/remotes/common.remote.ts | 41 ++ .../src/lib/remotes/dashboard.remote.ts | 43 ++ apps/website/src/lib/remotes/images.remote.ts | 35 ++ .../src/lib/remotes/payments.remote.ts | 309 +++++++++++ apps/website/src/lib/types.test.ts | 34 ++ apps/website/src/lib/typesValidators.ts | 5 + apps/website/src/lib/util/ark-utils.ts | 21 - .../src/routes/dashboard/+page.server.ts | 39 +- .../website/src/routes/dashboard/+page.svelte | 44 +- .../src/routes/dashboard/bills/+page.svelte | 6 +- .../routes/dashboard/payments/+page.server.ts | 50 +- .../routes/dashboard/payments/+page.svelte | 153 +++--- .../payments/create/[id=ulid]/+page.svelte | 43 +- apps/website/svelte.config.js | 16 +- package.json | 9 +- pnpm-lock.yaml | 478 +++++++++++------- 20 files changed, 964 insertions(+), 451 deletions(-) create mode 100644 apps/website/src/lib/remotes/bills.remote.ts create mode 100644 apps/website/src/lib/remotes/common.remote.ts create mode 100644 apps/website/src/lib/remotes/dashboard.remote.ts create mode 100644 apps/website/src/lib/remotes/images.remote.ts create mode 100644 apps/website/src/lib/remotes/payments.remote.ts create mode 100644 apps/website/src/lib/types.test.ts create mode 100644 apps/website/src/lib/typesValidators.ts delete mode 100644 apps/website/src/lib/util/ark-utils.ts diff --git a/apps/website/package.json b/apps/website/package.json index 605d2bb3..24b75403 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -35,13 +35,13 @@ "@storybook/test-runner": "^0.23.0", "@sungmanito/skeleton-plugin": "workspace:*", "@sveltejs/adapter-vercel": "^4.0.5", - "@sveltejs/kit": "^2.24.0", - "@sveltejs/vite-plugin-svelte": "^4.0.4", + "@sveltejs/kit": "^2.47.0", + "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.8", - "@types/pg": "^8.15.5", + "@types/pg": "^8.15.5", "@typescript-eslint/eslint-plugin": "^8.37.0", "@typescript-eslint/parser": "^8.37.0", "autoprefixer": "^10.4.21", @@ -63,7 +63,7 @@ "tailwindcss": "^3.4.17", "tslib": "^2.8.1", "typescript": "^5.8.3", - "vite": "^5.4.19", + "vite": "^7.1.10", "vitest": "^1.6.1" }, "type": "module", @@ -75,9 +75,9 @@ "@sungmanito/db": "workspace:*", "@supabase/ssr": "^0.0.10", "@supabase/supabase-js": "^2.51.0", - "@tanstack/svelte-query": "^5.83.0", + "@tanstack/svelte-query": "^6.0.0", "@vercel/edge-config": "^0.4.1", - "arktype": "2.0.4", + "arktype": "2.1.23", "chart.js": "^4.5.0", "class-variance-authority": "^0.7.1", "drizzle-orm": "^0.44.4", @@ -85,6 +85,7 @@ "pg": "^8.16.3", "posthog-js": "^1.257.0", "posthog-node": "^4.18.0", - "ulidx": "^2.4.1" + "ulidx": "^2.4.1", + "valibot": "^1.1.0" } } diff --git a/apps/website/src/lib/components/drawerify/drawerify.svelte b/apps/website/src/lib/components/drawerify/drawerify.svelte index 6bcda4f0..d00735fe 100644 --- a/apps/website/src/lib/components/drawerify/drawerify.svelte +++ b/apps/website/src/lib/components/drawerify/drawerify.svelte @@ -31,19 +31,17 @@ loading, }: DrawerifyProps = $props(); - const query = $derived( - createQuery({ - queryKey: ['drawerify', url], - queryFn: async () => { - const response = await preloadData(url); - if (response.type === 'loaded' && response.status === 200) { - return response.data as Data; - } - }, - staleTime: 5000, - enabled: open, - }), - ); + const query = createQuery(() => ({ + queryKey: ['drawerify', url], + queryFn: async () => { + const response = await preloadData(url); + if (response.type === 'loaded' && response.status === 200) { + return response.data as Data; + } + }, + staleTime: 5000, + enabled: open, + })); {#snippet children({ close: closeDrawer })} - {#if $query.isLoading || !$query.isSuccess} + {#if query.isLoading || !query.isSuccess} {#if loading} {@render loading()} {:else} @@ -67,7 +65,7 @@ {:else} {@const Component = component} diff --git a/apps/website/src/lib/components/monthDropdown/monthDropdown.svelte b/apps/website/src/lib/components/monthDropdown/monthDropdown.svelte index d1a3f887..c58d7960 100644 --- a/apps/website/src/lib/components/monthDropdown/monthDropdown.svelte +++ b/apps/website/src/lib/components/monthDropdown/monthDropdown.svelte @@ -2,6 +2,7 @@ let props = $props(); const options = Array.from({ length: 12 }, (_, i) => { const d = new Date(); + d.setDate(1); d.setMonth(i); return d.toLocaleDateString(undefined, { month: 'long', diff --git a/apps/website/src/lib/remotes/bills.remote.ts b/apps/website/src/lib/remotes/bills.remote.ts new file mode 100644 index 00000000..18667f50 --- /dev/null +++ b/apps/website/src/lib/remotes/bills.remote.ts @@ -0,0 +1,45 @@ +import { query, form, command } from '$app/server'; +import { db } from '$lib/server/db'; +import { exportedSchema as schema } from '@sungmanito/db'; +import { and, eq, getTableColumns } from 'drizzle-orm'; +import { getUser } from './common.remote'; +import { type } from 'arktype'; +import { ulid } from 'ulidx'; + +export const getUserBills = query(async () => { + const user = await getUser(); + + return ( + db + .select({ + ...getTableColumns(schema.bills), + householdName: schema.households.name, + }) + // Selecting from bills + .from(schema.bills) + // Joining in on the households table to get the household name + .innerJoin( + schema.households, + eq(schema.households.id, schema.bills.householdId), + ) + // Means we need to get the households this user is a member of. + .innerJoin( + schema.usersToHouseholds, + and( + eq(schema.usersToHouseholds.householdId, schema.households.id), + eq(schema.usersToHouseholds.userId, user.id), + ), + ) + // Some ordering to more normalize the results. + .orderBy(schema.bills.dueDate) + ); +}); + +export type billValidator = {}; + +export const createOrUpdateBill = command( + type({ + 'id?': ulid, + }), + async () => {}, +); diff --git a/apps/website/src/lib/remotes/common.remote.ts b/apps/website/src/lib/remotes/common.remote.ts new file mode 100644 index 00000000..96996b46 --- /dev/null +++ b/apps/website/src/lib/remotes/common.remote.ts @@ -0,0 +1,41 @@ +import { query, getRequestEvent, form } from '$app/server'; +import schema from '@sungmanito/db'; +import { db } from '../server/db'; +import { eq, getTableColumns } from 'drizzle-orm'; +import { type } from 'arktype'; + +/** + * This file is used for common things, like getting the current user, + * the current user's households, etc. + */ + +export const getUser = query(async () => { + const event = getRequestEvent(); + const session = await event.locals.getSession(); + + if (!session || !session.user) { + throw new Error('User not authenticated'); + } + + return { + ...session.user, + households: event.locals.userHouseholds, + }; +}); + +export const getUserHouseholds = query(async () => { + const event = getRequestEvent(); + const session = await event.locals.getSession(); + if (session === null) return []; + + return db + .select({ + ...getTableColumns(schema.households), + }) + .from(schema.households) + .innerJoin( + schema.usersToHouseholds, + eq(schema.households.id, schema.usersToHouseholds.householdId), + ) + .where(eq(schema.usersToHouseholds.userId, session.user.id)); +}); diff --git a/apps/website/src/lib/remotes/dashboard.remote.ts b/apps/website/src/lib/remotes/dashboard.remote.ts new file mode 100644 index 00000000..986381c8 --- /dev/null +++ b/apps/website/src/lib/remotes/dashboard.remote.ts @@ -0,0 +1,43 @@ +import { query } from '$app/server'; +import schema from '@sungmanito/db'; +import { and, asc, eq, getTableColumns, sql } from 'drizzle-orm'; +import { getUser } from './common.remote'; +import { db } from '$lib/server/db'; + +export const getUserHouseholdBills = query(async () => { + const user = await getUser(); + const today = new Date(); + + return db + .select({ + ...getTableColumns(schema.bills), + householdName: schema.households.name, + status: sql< + 'overdue' | 'upcoming' | 'paid' + >`case when ${schema.payments.paidAt} is not null then 'paid'::text when ${schema.bills.dueDate} < ${today.getDate()} then 'overdue'::text else 'upcoming'::text end`, + payment: schema.payments, + }) + .from(schema.bills) + .innerJoin( + schema.households, + eq(schema.bills.householdId, schema.households.id), + ) + .innerJoin( + schema.usersToHouseholds, + and( + eq(schema.usersToHouseholds.userId, user.id), + eq(schema.usersToHouseholds.householdId, schema.households.id), + ), + ) + .leftJoin( + schema.payments, + and( + eq( + sql`extract('month' from ${schema.payments.forMonthD})`, + today.getMonth() + 1, + ), + eq(schema.payments.billId, schema.bills.id), + ), + ) + .orderBy(asc(schema.bills.dueDate)); +}); diff --git a/apps/website/src/lib/remotes/images.remote.ts b/apps/website/src/lib/remotes/images.remote.ts new file mode 100644 index 00000000..be1a05a8 --- /dev/null +++ b/apps/website/src/lib/remotes/images.remote.ts @@ -0,0 +1,35 @@ +import { command, getRequestEvent, query } from '$app/server'; +import { db } from '$lib/server/db'; +import { type } from 'arktype'; +import { PAYMENT_BUCKET_NAME } from '$env/static/private'; +import { ulidValidator } from '$lib/typesValidators'; +import { error } from '@sveltejs/kit'; + +/** + * @description Given a path, fetch the image ID from the database + */ +export const getImageIdByPath = query(type('string'), async (path) => { + const image = await db.query.objects.findFirst({ + where: ({ bucketId, name }, { eq, and }) => + and(eq(bucketId, PAYMENT_BUCKET_NAME), eq(name, path)), + columns: { + id: true, + }, + }); + return image?.id; +}); + +/** + * @description Remove an image by its path + */ +export const removeImageByPath = command(ulidValidator, async (id) => { + const event = await getRequestEvent(); + const res = await event.locals.supabase.storage + .from(PAYMENT_BUCKET_NAME) + .remove([]); + + if (res.error) { + console.error('Failed to remove image from storage:', res.error); + error(500, 'Failed to remove image from storage'); + } +}); diff --git a/apps/website/src/lib/remotes/payments.remote.ts b/apps/website/src/lib/remotes/payments.remote.ts new file mode 100644 index 00000000..afbf4699 --- /dev/null +++ b/apps/website/src/lib/remotes/payments.remote.ts @@ -0,0 +1,309 @@ +import { command, form, getRequestEvent, query } from '$app/server'; +import { PAYMENT_BUCKET_NAME } from '$env/static/private'; +import { db } from '$lib/server/db'; +import { ulidValidator } from '$lib/typesValidators'; +import { allowedImageTypes } from '$utils/images'; +import schema from '@sungmanito/db'; +import { error } from '@sveltejs/kit'; +import { type } from 'arktype'; +import { and, desc, eq, getTableColumns, inArray, sql } from 'drizzle-orm'; +import { getUser, getUserHouseholds } from './common.remote'; +import { getImageIdByPath } from './images.remote'; + +export const getCurrentPayments = query(async () => { + const userHouseholds = await getUserHouseholds(); + await new Promise((r) => setTimeout(r, 1500)); // artificial delay for demo purposes + return db + .select({ + ...getTableColumns(schema.payments), + billName: schema.bills.billName, + household: schema.households, + }) + .from(schema.payments) + .innerJoin(schema.bills, eq(schema.bills.id, schema.payments.billId)) + .innerJoin( + schema.households, + eq(schema.households.id, schema.payments.householdId), + ) + .where( + and( + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + eq( + sql`extract('month' from ${schema.payments.forMonthD})`, + new Date().getMonth() + 1, + ), + ), + ) + .orderBy(schema.payments.forMonthD); +}); + +export const unmarkPayment = command(ulidValidator, async (id) => { + const userHouseholds = await getUserHouseholds(); + const payment = await db + .update(schema.payments) + .set({ paidAt: null, proofImage: null, notes: null, updatedBy: null }) + .where( + and( + eq(schema.payments.id, id), + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .returning(); + + getCurrentPayments().refresh(); + return payment[0]; +}); + +const uploadImageValidator = type({ + paymentId: ulidValidator, + householdId: ulidValidator, + proofFile: 'File', + amount: type("number>=0 | ''").pipe((s) => s === '' && 0), + 'proof?': 'string>=0', +}); + +export const uploadImage = form( + uploadImageValidator, + async ({ paymentId, householdId, proofFile: image, amount = 0, proof }) => { + // need this to update the paidBy field + const user = await getUser(); + // Need this for our where clause to ensure we aren't uploading an image to the wrong place + const userHouseholds = await getUserHouseholds(); + + const { locals } = await getRequestEvent(); + + if (image.type && !allowedImageTypes.has(image.type)) { + throw error(400, 'Invalid image type'); + } + + if (image.size > 5 * 1024 * 1024) { + throw error(400, 'Image too large (max 5MB)'); + } + + let imageId: string | null = null; + + if (image.size > 0) { + const fileName = `${householdId}/${paymentId}.${image.name.split('.').at(-1)}`; + const bucket = locals.supabase.storage.from(PAYMENT_BUCKET_NAME); + const { data: url, error: signingErr } = + await bucket.createSignedUploadUrl(fileName, { + upsert: true, + }); + + if (signingErr) { + error(500, 'Error creating upload URL'); + } + + const uploadResult = await bucket.uploadToSignedUrl( + url.path, + url.token, + image, + ); + + if (uploadResult.data) { + imageId = (await getImageIdByPath(uploadResult.data.path)) || null; + } + } + + const [updated] = await db + .update(schema.payments) + .set({ + updatedBy: user.id, + proofImage: imageId || undefined, + paidAt: new Date(), + notes: proof ? (proof.length > 0 ? proof : null) : undefined, + amount: + typeof amount === 'number' && amount > 0 + ? amount.toString() + : undefined, + }) + .where( + and( + eq(schema.payments.id, paymentId), + eq(schema.payments.householdId, householdId), + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .returning(); + + if (updated) { + // reset the payments cache + getCurrentPayments().refresh(); + // reset the cache for this specific payment + getPayment(paymentId).refresh(); + return updated; + } + + return null; + }, +); + +/** + * + * Gets a singular payment by ID + */ +export const getPayment = query(ulidValidator, async (id) => { + const userHouseholds = await getUserHouseholds(); + + return db + .select({ + ...getTableColumns(schema.payments), + billName: schema.bills.billName, + household: schema.households, + }) + .from(schema.payments) + .innerJoin(schema.bills, eq(schema.bills.id, schema.payments.billId)) + .innerJoin( + schema.households, + eq(schema.households.id, schema.payments.householdId), + ) + .where( + and( + eq(schema.payments.id, id), + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .orderBy(desc(schema.payments.forMonthD)) + .limit(1) + .then((r) => r[0]); +}); + +export const togglePayment = form( + type({ + paymentId: ulidValidator, + }), + async ({ paymentId }) => { + const { locals } = await getRequestEvent(); + const user = await getUser(); + const userHouseholds = await getUserHouseholds(); + + /** + * 1. determine if the payment has an associated image with it + * 2. if it does, remove that image + * 3. toggle the paidAt field and updatedBy field + */ + + const txResult = await db.transaction(async (tx) => { + const [image] = await tx + .select() + .from(schema.objects) + .innerJoin( + schema.payments, + eq(schema.objects.id, schema.payments.proofImage), + ) + .where(eq(schema.payments.id, paymentId)); + console.info('IMAGE', image); + if (image) { + // We must remove the image from storage. + const res = await locals.supabase.storage + .from(PAYMENT_BUCKET_NAME) + .remove([image.objects.name]); + + if (res.error) { + console.error('Failed to remove image from storage:', res.error); + tx.rollback(); + error(500, 'Failed to remove image from storage'); + } + } + const [res] = await tx + .update(schema.payments) + .set({ + paidAt: sql`case when ${schema.payments.paidAt} is null then now() else null end`, + updatedBy: sql`case when ${schema.payments.paidAt} is null then ${user.id}::uuid else null end`, + proofImage: image ? null : undefined, + }) + .where( + and( + eq(schema.payments.id, paymentId), + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .returning(); + + if (!res) { + tx.rollback(); + error(400, 'Failed to update payment'); + } else { + getCurrentPayments().refresh(); + getPayment(paymentId).refresh(); + } + + return { + image: image?.objects, + payment: res, + }; + }); + + return null; + }, +); + +export const markPayment = form( + type({ + paymentId: ulidValidator, + }), + async (data) => { + const user = await getUser(); + const userHouseholds = await getUserHouseholds(); + + const [updated] = await db + .update(schema.payments) + .set({ + paidAt: new Date(), + updatedBy: user.id, + }) + .where( + and( + eq(schema.payments.id, data.paymentId), + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .returning(); + + if (updated) { + // reset the payments cache + getCurrentPayments().refresh(); + // reset the cache for this specific payment + getPayment(data.paymentId).refresh(); + return updated; + } + return null; + }, +); + +export const getPaymentHistoryMonths = query(async () => { + const userHouseholds = await getUserHouseholds(); + return ( + db + .selectDistinct({ + month: sql`date_trunc('month', ${schema.payments.forMonthD})::date`, + }) + .from(schema.payments) + .where( + inArray( + schema.payments.householdId, + userHouseholds.map((h) => h.id), + ), + ) + // Used to get the fields + .orderBy((fields) => desc(fields.month)) + ); +}); diff --git a/apps/website/src/lib/types.test.ts b/apps/website/src/lib/types.test.ts new file mode 100644 index 00000000..2e96dcb0 --- /dev/null +++ b/apps/website/src/lib/types.test.ts @@ -0,0 +1,34 @@ +import { ulidValidator as ulid } from './typesValidators'; +import { describe, it, expect } from 'vitest'; + +describe('ulid arktype validator', () => { + it('accepts valid ULID ids', () => { + // Some valid ULIDs + const valid = [ + '01H7FQY9ZJ8Q2X4V7K2Q8YB6QG', + '01ARYZ6S41TSV4RRFFQ69G5FAV', + '7ZZZZZZZZZZZZZZZZZZZZZZZZZ', + '00000000000000000000000000', + ]; + for (const id of valid) { + expect(() => ulid.assert(id)).not.toThrow(); + } + }); + + it('rejects invalid ULID ids', () => { + // Too short, too long, invalid chars, lowercase, ambiguous chars + const invalid = [ + '01H7FQY9ZJ8Q2X4V7K2Q8YB6Q', // 25 chars + '01H7FQY9ZJ8Q2X4V7K2Q8YB6QGG', // 27 chars + '01H7FQY9ZJ8Q2X4V7K2Q8YB6Q!', // invalid char ! + '01h7fqy9zj8q2x4v7k2q8yb6qg', // lowercase + '01H7FQY9ZJ8Q2X4V7K2Q8YB6QI', // contains I + '01H7FQY9ZJ8Q2X4V7K2Q8YB6QL', // contains L + '01H7FQY9ZJ8Q2X4V7K2Q8YB6QO', // contains O + '01H7FQY9ZJ8Q2X4V7K2Q8YB6QU', // contains U + ]; + for (const id of invalid) { + expect(() => ulid.assert(id)).toThrow(); + } + }); +}); diff --git a/apps/website/src/lib/typesValidators.ts b/apps/website/src/lib/typesValidators.ts new file mode 100644 index 00000000..99ba3c93 --- /dev/null +++ b/apps/website/src/lib/typesValidators.ts @@ -0,0 +1,5 @@ +import { type } from 'arktype'; + +export const ulidValidator = type('string & /^[0-9A-HJKMNP-TV-Z]{26}$/'); + +export const dueDate = type('1<=number.integer<=28'); diff --git a/apps/website/src/lib/util/ark-utils.ts b/apps/website/src/lib/util/ark-utils.ts deleted file mode 100644 index 613b298f..00000000 --- a/apps/website/src/lib/util/ark-utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { Problems, Type } from 'arktype'; - -/** - * - * @throws {Problems} - * @param value an unknown value - * @param validator an arktype validator - * @returns - */ -export function validate< - T, - V extends Type, - R = V extends Type ? R : never, ->(value: T, validator: V): R { - const { data, problems } = validator(value); - - if (problems) throw problems; - - return data as R; -} diff --git a/apps/website/src/routes/dashboard/+page.server.ts b/apps/website/src/routes/dashboard/+page.server.ts index 706c38fd..f45c1872 100644 --- a/apps/website/src/routes/dashboard/+page.server.ts +++ b/apps/website/src/routes/dashboard/+page.server.ts @@ -4,6 +4,7 @@ import { validateFormData } from '@jhecht/arktype-utils'; import { redirect } from '@sveltejs/kit'; import { type } from 'arktype'; import { and, eq, sql, getTableColumns, asc } from 'drizzle-orm'; +import { getUserHouseholdBills } from '$lib/remotes/dashboard.remote.js'; export const load = async ({ locals, depends }) => { const session = await locals.getSession(); @@ -15,41 +16,6 @@ export const load = async ({ locals, depends }) => { depends('household:payments'); depends('household:bills'); - const today = new Date(); - - const fuckers = db - .select({ - ...getTableColumns(schema.bills), - householdName: schema.households.name, - status: sql< - 'overdue' | 'upcoming' | 'paid' - >`case when ${schema.payments.paidAt} is not null then 'paid'::text when ${schema.bills.dueDate} < ${today.getDate()} then 'overdue'::text else 'upcoming'::text end`, - payment: schema.payments, - }) - .from(schema.bills) - .innerJoin( - schema.households, - eq(schema.bills.householdId, schema.households.id), - ) - .innerJoin( - schema.usersToHouseholds, - and( - eq(schema.usersToHouseholds.userId, session.user.id), - eq(schema.usersToHouseholds.householdId, schema.households.id), - ), - ) - .leftJoin( - schema.payments, - and( - eq( - sql`extract('month' from ${schema.payments.forMonthD})`, - today.getMonth() + 1, - ), - eq(schema.payments.billId, schema.bills.id), - ), - ) - .orderBy(asc(schema.bills.dueDate)); - const userHouseholds = await db .select({ id: schema.households.id, @@ -71,8 +37,7 @@ export const load = async ({ locals, depends }) => { return { households: userHouseholds, - fuckers, - groupings: await fuckers.then((bills) => + groupings: await getUserHouseholdBills().then((bills) => bills.reduce( (acc, bill) => { if (bill.status === 'paid') { diff --git a/apps/website/src/routes/dashboard/+page.svelte b/apps/website/src/routes/dashboard/+page.svelte index db226584..47af6ac7 100644 --- a/apps/website/src/routes/dashboard/+page.svelte +++ b/apps/website/src/routes/dashboard/+page.svelte @@ -15,6 +15,7 @@ import CreatePaymentsComponent from './payments/create/+page.svelte'; import { hijack } from '$lib/attachments/hijack.svelte'; import { ClockAlertIcon } from 'lucide-svelte'; + import { getUserHouseholdBills } from '$lib/remotes/dashboard.remote'; let { data } = $props(); @@ -37,6 +38,8 @@ }; }; + $inspect(data.groupings); + let summary = $derived([ { label: 'Paid This Month', @@ -64,20 +67,17 @@ }, ]); - let billsWithStatus = $derived( - createQuery({ - queryKey: ['fuckers'], - queryFn: async () => { - // Simulate fetching data - const da = await data.fuckers; - return da; - }, - staleTime: 1000 * 60 * 5, // 5 minutes - }), - ); + let billsWithStatus = createQuery(() => ({ + queryKey: ['fuckers'], + queryFn: async () => { + // Simulate fetching data + return getUserHouseholdBills(); + }, + staleTime: 1000 * 60 * 5, // 5 minutes + })); const totalOutstanding = $derived( - $billsWithStatus.data + billsWithStatus.data ?.filter((p) => p.payment === null || p.payment?.paidAt === null) .reduce((acc, bill) => acc + bill.amount, 0), ); @@ -85,14 +85,14 @@ let filter: 'all' | 'overdue' | 'paid' = $state('all'); let filteredBills = $derived.by(() => { - if (!$billsWithStatus.isSuccess) - return [] as NonNullable; - if (filter === 'all') return $billsWithStatus.data; + if (!billsWithStatus.isSuccess) + return [] as NonNullable; + if (filter === 'all') return billsWithStatus.data; if (filter === 'overdue') { - return $billsWithStatus.data.filter((bill) => bill.status === 'overdue'); + return billsWithStatus.data.filter((bill) => bill.status === 'overdue'); } if (filter === 'paid') { - return $billsWithStatus.data.filter((bill) => bill.status === 'paid'); + return billsWithStatus.data.filter((bill) => bill.status === 'paid'); } }); @@ -203,12 +203,12 @@

Recent Bills

- {#if $billsWithStatus.isStale} + {#if billsWithStatus.isStale}
- {#if $billsWithStatus.isSuccess} + {#if billsWithStatus.isSuccess} {#each filteredBills ?? [] as bill}
+ {:else} +

There are no bills that match this filter

{/each} - {:else if $billsWithStatus.isLoading} + {:else if billsWithStatus.isLoading}
 
{/if} diff --git a/apps/website/src/routes/dashboard/bills/+page.svelte b/apps/website/src/routes/dashboard/bills/+page.svelte index d5fd09a2..aaa81f61 100644 --- a/apps/website/src/routes/dashboard/bills/+page.svelte +++ b/apps/website/src/routes/dashboard/bills/+page.svelte @@ -1,4 +1,6 @@ + +{#snippet defaultChildren()} +
Card content
+{/snippet} + + + + + + {#snippet header()} +
Summary info
+
+ +
+ {/snippet} +
0
+
+
+ + + + {#snippet header()} + Placekitten + {/snippet} + + {#snippet footer()} +
+ +
+ {/snippet} + +
This is Whiskers. He's a wanted criminal in 7 states.
+
+
diff --git a/apps/website/src/lib/components/card/card.svelte b/apps/website/src/lib/components/card/card.svelte new file mode 100644 index 00000000..bb818f0c --- /dev/null +++ b/apps/website/src/lib/components/card/card.svelte @@ -0,0 +1,48 @@ + + + + +
+ {#if header} +
+ {@render header()} +
+ {/if} + {#if children} + {@render children()} + {/if} + {#if footer} +
+ {@render footer()} +
+ {/if} +
diff --git a/apps/website/src/lib/util/asyncStore.svelte.ts b/apps/website/src/lib/util/asyncStore.svelte.ts index 86956efa..44884fff 100644 --- a/apps/website/src/lib/util/asyncStore.svelte.ts +++ b/apps/website/src/lib/util/asyncStore.svelte.ts @@ -13,6 +13,7 @@ export type StoreReturnType = error: E; isLoading: boolean; }; + export function asyncStore( asyncFn: () => Promise, ): StoreReturnType { diff --git a/apps/website/src/routes/actions/+server.ts b/apps/website/src/routes/actions/+server.ts index cc4dd600..398258f6 100644 --- a/apps/website/src/routes/actions/+server.ts +++ b/apps/website/src/routes/actions/+server.ts @@ -25,6 +25,10 @@ export const GET: RequestHandler = async () => { sql`extract('month' from ${schema.payments.forMonthD})`, sql`extract('month' from now())`, ), + eq( + sql`extract(YEAR from ${schema.payments.forMonthD})`, + sql`extract(YEAR from now())`, + ), ), ); diff --git a/apps/website/src/routes/dashboard/+layout.svelte b/apps/website/src/routes/dashboard/+layout.svelte index 82ed96c0..b1b91db7 100644 --- a/apps/website/src/routes/dashboard/+layout.svelte +++ b/apps/website/src/routes/dashboard/+layout.svelte @@ -1,6 +1,7 @@ @@ -142,125 +82,158 @@ component: boolean; onclose: () => void; }>} - onopen={() => { - console.info('OPENED'); - }} - onclose={() => { - console.info('OKAY'); - }} /> -
- -
-

-
🧾
-
Dashboard
-

-
-
Total Outstanding
-
- {(totalOutstanding || 0).toLocaleString(undefined, { - style: 'currency', - currency: 'USD', - })} + + {#snippet pending()} +
+
+
+ {#each Array(3) as _} +
+ {/each} +
+
+
+ {#each Array(5) as _} +
+ {/each} +
+
-
+ {/snippet} - -
- {#each summary as s} -
-

- {s.label} -
- {#if typeof s.icon === 'string'} - {s.icon} - {:else} + {@const allBills = await getUserHouseholdBills()} + {@const paidBills = allBills.filter((b) => b.status === 'paid')} + {@const overdueBills = allBills.filter((b) => b.status === 'overdue')} + {@const thisWeekBills = allBills.filter((b) => b.status === 'upcoming')} + {@const totalOutstanding = allBills + .filter((p) => p.payment === null || p.payment?.paidAt === null) + .reduce((acc, bill) => acc + bill.amount, 0)} + {@const filteredBills = + filter === 'all' ? allBills : allBills.filter((b) => b.status === filter)} + {@const summary = [ + { + label: 'Paid This Month', + value: paidBills.length, + icon: CheckIcon, + iconBg: 'bg-green-500', + iconText: 'text-green-700', + }, + { + label: 'Overdue', + value: overdueBills.length, + icon: TriangleAlertIcon, + iconBg: 'bg-red-500', + iconText: 'text-red-700', + }, + { + label: 'Due This Week', + value: thisWeekBills.length, + icon: WatchIcon, + iconBg: 'bg-blue-500', + iconText: 'text-blue-500', + }, + ]} + +
+ +
+

+
🧾
+
Dashboard
+

+
+
Total Outstanding
+
+ {(totalOutstanding || 0).toLocaleString(undefined, { + style: 'currency', + currency: 'USD', + })} +
+
+
+ + +
+ {#each summary as s} +
+

+ {s.label} +
- {/if} +
+

+
+ {s.value}
-

-
- {s.value}
-
- {/each} -
+ {/each} +
- -
- -
-
-
-

Recent Bills

-
- {#if billsWithStatus.isStale} + +
+ +
+
+
+

Recent Bills

+
- {/if} - + -
- - - +
+ + + +
-
-
- {#if billsWithStatus.isSuccess} - {#each filteredBills ?? [] as bill} +
+ {#each filteredBills as bill}
{ - console.info(bill, e.target.href); - makeOrUpdatePayment.url = e.target.href; + const target = e.target as HTMLAnchorElement; + makeOrUpdatePayment.url = target.href; makeOrUpdatePayment.show = true; }, })} - href={`/dashboard/payments/create/${bill.payment.id}`} + href={`/dashboard/payments/create/${bill.payment?.id}`} > {#if bill.payment?.paidAt !== null} Update @@ -317,57 +290,54 @@ {:else}

There are no bills that match this filter

{/each} - {:else if billsWithStatus.isLoading} -
 
- {/if} +
-
- -
-
-

- Quick Actions -

- -
- - + ➕ Add New Bill + +
+ + +
-
+ From 37598e252192c4e3c671d86ff23be6955b2709a7 Mon Sep 17 00:00:00 2001 From: Jim Burbridge Date: Sun, 22 Mar 2026 22:19:02 -0700 Subject: [PATCH 05/21] feat: migrate bills pages to async remote functions Replace all bills +page.server.ts files with remote functions in bills.remote.ts. All bills pages now use async Svelte with and {#snippet pending()} skeleton loading states. - Add getBill, getBillsByIds, getBillWithPayments query remotes - Add createBill, updateBill, updateBills, deleteBills form remotes - Delete bills +page.server.ts, create, edit, and [id=ulid] server files - Delete src/lib/server/actions/bills.actions.ts (absorbed into remotes) - Rewrite bills list, create, detail, single edit, and bulk edit pages Co-Authored-By: Claude Sonnet 4.6 --- apps/website/src/lib/remotes/bills.remote.ts | 306 +++++++++++- .../src/lib/server/actions/bills.actions.ts | 94 ---- .../routes/dashboard/bills/+page.server.ts | 243 ---------- .../src/routes/dashboard/bills/+page.svelte | 434 +++++++++--------- .../dashboard/bills/[id=ulid]/+page.server.ts | 25 - .../dashboard/bills/[id=ulid]/+page.svelte | 205 ++++----- .../bills/[id=ulid]/edit/+page.server.ts | 17 - .../bills/[id=ulid]/edit/+page.svelte | 228 ++++----- .../dashboard/bills/create/+page.server.ts | 29 -- .../dashboard/bills/create/+page.svelte | 348 +++++++------- .../bills/edit/[ids=ulids]/+page.server.ts | 120 ----- .../bills/edit/[ids=ulids]/+page.svelte | 192 ++++---- 12 files changed, 1009 insertions(+), 1232 deletions(-) delete mode 100644 apps/website/src/lib/server/actions/bills.actions.ts delete mode 100644 apps/website/src/routes/dashboard/bills/+page.server.ts delete mode 100644 apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts delete mode 100644 apps/website/src/routes/dashboard/bills/[id=ulid]/edit/+page.server.ts delete mode 100644 apps/website/src/routes/dashboard/bills/create/+page.server.ts delete mode 100644 apps/website/src/routes/dashboard/bills/edit/[ids=ulids]/+page.server.ts diff --git a/apps/website/src/lib/remotes/bills.remote.ts b/apps/website/src/lib/remotes/bills.remote.ts index 18667f50..e24fd735 100644 --- a/apps/website/src/lib/remotes/bills.remote.ts +++ b/apps/website/src/lib/remotes/bills.remote.ts @@ -1,10 +1,12 @@ import { query, form, command } from '$app/server'; import { db } from '$lib/server/db'; import { exportedSchema as schema } from '@sungmanito/db'; -import { and, eq, getTableColumns } from 'drizzle-orm'; -import { getUser } from './common.remote'; +import { and, eq, getTableColumns, inArray } from 'drizzle-orm'; +import { getUser, getUserHouseholds } from './common.remote'; +import { getUserHouseholdBills } from './dashboard.remote'; import { type } from 'arktype'; import { ulid } from 'ulidx'; +import { ulidValidator } from '$lib/typesValidators'; export const getUserBills = query(async () => { const user = await getUser(); @@ -35,11 +37,297 @@ export const getUserBills = query(async () => { ); }); -export type billValidator = {}; +/** + * Fetches a single bill by ID, ensuring it belongs to the current user's households. + */ +export const getBill = query(ulidValidator, async (id) => { + const userHouseholds = await getUserHouseholds(); + return db + .select({ ...getTableColumns(schema.bills), household: schema.households }) + .from(schema.bills) + .innerJoin( + schema.households, + eq(schema.bills.householdId, schema.households.id), + ) + .where( + and( + eq(schema.bills.id, id), + inArray( + schema.bills.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .limit(1) + .then((r) => r[0]); +}); + +/** + * Fetches a single bill with its 12-month payment history. + */ +export const getBillWithPayments = query(ulidValidator, async (id) => { + const userHouseholds = await getUserHouseholds(); + + const [bill, payments] = await Promise.all([ + db + .select({ ...getTableColumns(schema.bills), household: schema.households }) + .from(schema.bills) + .innerJoin( + schema.households, + eq(schema.bills.householdId, schema.households.id), + ) + .where( + and( + eq(schema.bills.id, id), + inArray( + schema.bills.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .limit(1) + .then((r) => r[0]), + + db.query.payments.findMany({ + where: (fields, { eq }) => eq(fields.billId, id), + orderBy: (fields, { desc }) => desc(fields.forMonthD), + limit: 12, + }), + ]); + + return { ...bill, payments }; +}); + +/** + * Fetches multiple bills by IDs, scoped to the current user's households. + */ +export const getBillsByIds = query(type('string[]'), async (ids) => { + const userHouseholds = await getUserHouseholds(); + return db + .select({ + ...getTableColumns(schema.bills), + householdName: schema.households.name, + }) + .from(schema.bills) + .innerJoin( + schema.households, + eq(schema.bills.householdId, schema.households.id), + ) + .where( + and( + inArray(schema.bills.id, ids), + inArray( + schema.bills.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ); +}); + +const billCreateValidator = type({ + 'name[]': 'string[]', + 'household-id[]': 'string[]', + 'due-date[]': 'string[]', + 'amount[]': 'string[]', + 'currency[]': 'string[]', +}); + +/** + * Creates one or more bills with an initial payment for the current month. + */ +export const createBill = form(billCreateValidator, async (data) => { + const userHouseholds = await getUserHouseholds(); + const householdIds = userHouseholds.map((h) => h.id); + + const names = data['name[]']; + const householdIdsInput = data['household-id[]']; + const dueDates = data['due-date[]'].map(Number); + const amounts = data['amount[]'].map(Number); + const currencies = data['currency[]']; + + // Validate all submitted households belong to the user + if (!householdIdsInput.every((id) => householdIds.includes(id))) { + throw new Error('Cannot add bill to a household you are not a member of'); + } + + const today = new Date(); + + const bills = await db.transaction(async (tx) => { + const newBills = await tx + .insert(schema.bills) + .values( + Array.from({ length: names.length }, (_, i) => ({ + billName: names[i], + householdId: householdIdsInput[i], + dueDate: dueDates[i], + amount: amounts[i] > 0 ? amounts[i] : undefined, + currency: currencies[i] || 'USD', + })), + ) + .returning(); + + // Create initial payment for bills due later in the current month + const paymentsToInsert = newBills + .filter((bill) => bill.dueDate >= today.getDate()) + .map((bill) => ({ + billId: bill.id, + forMonthD: new Date( + today.getFullYear(), + today.getMonth(), + bill.dueDate, + ), + householdId: bill.householdId, + })); + + if (paymentsToInsert.length > 0) { + await tx.insert(schema.payments).values(paymentsToInsert); + } + + return newBills; + }); -export const createOrUpdateBill = command( - type({ - 'id?': ulid, - }), - async () => {}, -); + getUserBills().refresh(); + getUserHouseholdBills().refresh(); + + return bills; +}); + +const billUpdateValidator = type({ + 'bill-id': 'string', + 'bill-name': 'string', + 'household-id': 'string', + 'due-date': 'string', + 'amount?': 'string', + 'currency?': 'string', +}); + +/** + * Updates a single bill. + */ +export const updateBill = form(billUpdateValidator, async (data) => { + const userHouseholds = await getUserHouseholds(); + + if (!userHouseholds.some((h) => h.id === data['household-id'])) { + throw new Error('Not authorized to update this bill'); + } + + const dueDate = Number(data['due-date']); + if (dueDate < 1 || dueDate > 28) { + throw new Error('Due date must be between 1 and 28'); + } + + const [updated] = await db + .update(schema.bills) + .set({ + billName: data['bill-name'], + dueDate, + householdId: data['household-id'], + amount: data['amount'] ? Number(data['amount']) : undefined, + currency: data['currency'] ? data['currency'].toUpperCase() : undefined, + }) + .where( + and( + eq(schema.bills.id, data['bill-id']), + inArray( + schema.bills.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .returning(); + + getUserBills().refresh(); + getUserHouseholdBills().refresh(); + if (updated) getBill(updated.id).refresh(); + + return updated; +}); + +const billsBatchUpdateValidator = type({ + 'bills[].id': 'string[]', + 'bills[].name': 'string[]', + 'bills[].householdId': 'string[]', + 'bills[].dueDate': 'string[]', + 'bills[].amount': 'string[]', +}); + +/** + * Batch-updates multiple bills. + */ +export const updateBills = form(billsBatchUpdateValidator, async (data) => { + const userHouseholds = await getUserHouseholds(); + const userHouseholdIds = userHouseholds.map((h) => h.id); + + const ids = data['bills[].id']; + const names = data['bills[].name']; + const householdIds = data['bills[].householdId']; + const dueDates = data['bills[].dueDate'].map(Number); + const amounts = data['bills[].amount'].map(Number); + + // Validate all household IDs belong to the user + if (!householdIds.every((id) => userHouseholdIds.includes(id))) { + throw new Error('Invalid household for one or more bills'); + } + + const result = await db.transaction(async (tx) => { + const updated = []; + for (let i = 0; i < ids.length; i++) { + const rows = await tx + .update(schema.bills) + .set({ + billName: names[i], + householdId: householdIds[i], + dueDate: dueDates[i], + amount: amounts[i], + }) + .where( + and( + eq(schema.bills.id, ids[i]), + inArray(schema.bills.householdId, userHouseholdIds), + ), + ) + .returning(); + updated.push(...rows); + } + return updated; + }); + + getUserBills().refresh(); + getUserHouseholdBills().refresh(); + + return { updatedBills: result, updatedCount: result.length }; +}); + +const deleteBillsValidator = type({ + 'bill-id[]': 'string[]', +}); + +/** + * Deletes one or more bills. + */ +export const deleteBills = form(deleteBillsValidator, async (data) => { + const userHouseholds = await getUserHouseholds(); + const billIds = [...new Set(data['bill-id[]'])]; + + if (billIds.length === 0) { + throw new Error('No bill IDs provided'); + } + + const deleted = await db + .delete(schema.bills) + .where( + and( + inArray(schema.bills.id, billIds), + inArray( + schema.bills.householdId, + userHouseholds.map((h) => h.id), + ), + ), + ) + .returning(); + + getUserBills().refresh(); + getUserHouseholdBills().refresh(); + + return { deletedBills: deleted, deletedCount: deleted.length }; +}); diff --git a/apps/website/src/lib/server/actions/bills.actions.ts b/apps/website/src/lib/server/actions/bills.actions.ts deleted file mode 100644 index 61cfd8b8..00000000 --- a/apps/website/src/lib/server/actions/bills.actions.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { db } from '$lib/server/db'; -import { exportedSchema as schema } from '@sungmanito/db'; -import { and, eq, getTableColumns } from 'drizzle-orm'; -import { NotFound } from '../errors'; - -export type Bill = typeof schema.bills.$inferSelect; -export type BillInsertArgs = typeof schema.bills.$inferInsert; -export type BillUpdateArgs = Partial>; - -// Should this be where we check for the row stuff... or should we just turn that on in supabase? -export async function updateBill(billId: Bill['id'], obj: BillUpdateArgs) { - return db - .update(schema.bills) - .set(obj) - .where(eq(schema.bills.id, billId)) - .returning() - .then(([r]) => r); -} -/** - * @description Creates a new bill in the database - * @param bill Bill args - * @returns the created bill - * @throws an error if the bill date does not sit in a normal billing date range. - */ -export async function createBill(bill: BillInsertArgs) { - if (bill.dueDate && (bill.dueDate > 28 || bill.dueDate < 1)) - throw new RangeError( - 'Date exceeds most bill bounds (must be <=28 and >=1)', - ); - - return db - .insert(schema.bills) - .values(bill) - .returning() - .then(([f]) => f); -} - -/** - * - * @param billId The ID of the bill we are fetching - * @returns the bill - * @throws an error when an error isn't found. - */ -export async function getBill(billId: Bill['id']) { - return db - .select({ ...getTableColumns(schema.bills), household: schema.households }) - .from(schema.bills) - .where(eq(schema.bills.id, billId)) - .innerJoin( - schema.households, - eq(schema.bills.householdId, schema.households.id), - ) - .then((r) => { - if (r.length > 1 || r.length === 0) - throw new NotFound(`Bill not found (id: ${billId})`); - return r[0]; - }); -} - -/** - * - * @param billId The ID of the bill we are deleting - * @returns - */ -export async function deleteBill(billId: Bill['id']) { - return db.delete(schema.bills).where(eq(schema.bills.id, billId)).returning(); -} - -export async function getUserBills(userId: string) { - return ( - db - .select({ - ...getTableColumns(schema.bills), - householdName: schema.households.name, - }) - // Selecting from bills - .from(schema.bills) - // Joining in on the households table to get the household name - .innerJoin( - schema.households, - eq(schema.households.id, schema.bills.householdId), - ) - // Means we need to get the households this user is a member of. - .innerJoin( - schema.usersToHouseholds, - and( - eq(schema.usersToHouseholds.householdId, schema.households.id), - eq(schema.usersToHouseholds.userId, userId), - ), - ) - // Some ordering to more normalize the results. - .orderBy(schema.bills.dueDate) - ); -} diff --git a/apps/website/src/routes/dashboard/bills/+page.server.ts b/apps/website/src/routes/dashboard/bills/+page.server.ts deleted file mode 100644 index f2a2be50..00000000 --- a/apps/website/src/routes/dashboard/bills/+page.server.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { - getUserBills, - type BillInsertArgs, -} from '$lib/server/actions/bills.actions.js'; -import { db } from '$lib/server/db'; -import { validateUserSession } from '$lib/util/session.js'; -import { validateFormData } from '@jhecht/arktype-utils'; -import { exportedSchema } from '@sungmanito/db'; -import { error, fail, redirect } from '@sveltejs/kit'; -import { scope, type } from 'arktype'; -import { and, eq, inArray } from 'drizzle-orm'; - -function padCalendar(inp: string | number) { - return `0${inp}`.slice(-2); -} - -export const load = async ({ locals, depends }) => { - const session = await locals.getSession(); - - depends('user:bills'); - - if (!validateUserSession(session)) { - redirect(300, '/login'); - } - - const bills = await getUserBills(session.user.id); - - return { - bills: bills, - households: locals.userHouseholds, - }; -}; - -export const actions = { - addBill: async ({ locals, request }) => { - const session = await locals.getSession(); - if (!validateUserSession(session)) error(401); - - const validator = scope({ - DueDate: '1<=number<=28', - formData: { - name: 'string[]', - 'household-id': 'string[]', - 'due-date': 'DueDate[]', - }, - }).type('formData'); - - let formData: typeof validator.infer; - - try { - formData = validateFormData(await request.formData(), validator); - } catch (e) { - if (e instanceof type.errors) - return fail(400, { - message: e.issues, - }); - console.error(e); - return fail(500); - } - - // convert the households to a set to thin out repeated entries. - // convert back to array to utilize the .every method - const submittedHouseholds = Array.from( - new Set(formData['household-id']), - ); - - // If the user is not a member of the household, yeet an error - if ( - !submittedHouseholds.every( - (id) => - locals.userHouseholds.findIndex((h) => h.households.id === id) !== -1, - ) - ) - return fail(401, { - message: 'Cannot add bill to household you are not a member of', - }); - - const insertBills: BillInsertArgs[] = Array.from( - { length: formData['household-id'].length }, - (_, i) => ({ - billName: formData.name[i], - dueDate: formData['due-date'][i], - householdId: formData['household-id'][i], - }), - ); - - const bills = await db.transaction(async (tx) => { - const newBills = await tx - .insert(exportedSchema.bills) - .values(insertBills) - .returning(); - - const rightNow = new Date(); - - const payments = await tx - .insert(exportedSchema.payments) - .values( - newBills.map((bill) => ({ - billId: bill.id, - forMonthD: new Date( - `${rightNow.getFullYear()}-${padCalendar(rightNow.getMonth() + 1)}-${padCalendar(bill.dueDate).slice(-2)}T00:00:00Z`, - ), - householdId: bill.householdId, - })), - ) - .returning(); - - if (newBills.length === 0 || payments.length === 0) { - tx.rollback(); - } - - return newBills; - }); - - return { - bills, - }; - }, - updateBill: async ({ request, locals }) => { - const session = await locals.getSession(); - - if (!session || !session?.user) error(401, 'nope'); - - const data = validateFormData( - await request.formData(), - type({ - 'bill-id': 'string', - 'bill-name': 'string', - 'household-id': 'string', - 'due-date': '1<=number<=28', - 'amount?': 'number>=0', - 'currency?': 'string>=3 & /[A-Z]{3}/', - }), - ); - - const userHouseholds = locals.userHouseholds; - - if ( - userHouseholds.findIndex( - (uh) => uh.households.id === data['household-id'], - ) === -1 - ) - error(400, 'Not authorized'); - - const [response] = await db - .update(exportedSchema.bills) - .set({ - billName: data['bill-name'], - dueDate: data['due-date'], - householdId: data['household-id'], - amount: data['amount'] - ? Math.max(0, Number(data['amount'])) - : undefined, - currency: data['currency'] ? data['currency'].toUpperCase() : undefined, - }) - .where(eq(exportedSchema.bills.id, data['bill-id'])) - .returning(); - - if (response === undefined) error(400); - - return { - status: 200, - bill: response, - }; - }, - deleteBill: async ({ request, locals }) => { - const session = await locals.getSession(); - - if (!validateUserSession(session)) error(401); - - const data = validateFormData( - await request.formData(), - type({ - 'bill-id': 'string[]', - }), - ); - - const billIds = [...new Set(data['bill-id'])]; - if (billIds.length === 0) - return fail(400, { message: 'No bill ids provided' }); - - const [deleted] = await db - .delete(exportedSchema.bills) - .where( - and( - inArray(exportedSchema.bills.id, billIds), - inArray( - exportedSchema.bills.householdId, - locals.userHouseholds.map((h) => h.households.id), - ), - ), - ) - .returning(); - if (!deleted) error(400, 'Bill not found'); - - return { - bill: deleted, - }; - }, - deleteMultipleBills: async ({ request, locals }) => { - const session = await locals.getSession(); - - if (!validateUserSession(session)) error(401); - - const data = validateFormData( - await request.formData(), - type({ - 'bill-ids': 'string[]', - }), - ); - - // Normalize and validate billIds: ensure array of non-empty strings, dedupe, fail if empty - let billIds = data['bill-ids']; - if (!Array.isArray(billIds)) billIds = [billIds]; - billIds = billIds - .map((id) => (typeof id === 'string' ? id.trim() : '')) - .filter((id) => id.length > 0); - - billIds = Array.from(new Set(billIds)); - - if (billIds.length === 0) { - return fail(400, { message: 'No valid bill ids provided' }); - } - - const deletedBills = await db - .delete(exportedSchema.bills) - .where( - and( - inArray(exportedSchema.bills.id, billIds), - inArray( - exportedSchema.bills.householdId, - locals.userHouseholds.map((h) => h.households.id), - ), - ), - ) - .returning(); - - return { - deletedBills, - deletedCount: deletedBills.length, - }; - }, -}; diff --git a/apps/website/src/routes/dashboard/bills/+page.svelte b/apps/website/src/routes/dashboard/bills/+page.svelte index aaa81f61..50edef8d 100644 --- a/apps/website/src/routes/dashboard/bills/+page.svelte +++ b/apps/website/src/routes/dashboard/bills/+page.svelte @@ -1,5 +1,5 @@ @@ -88,11 +71,8 @@ onclose={async () => { editBillStore.show = false; replaceState('/dashboard/bills', {}); - selectedBills = []; - await queryClient.invalidateQueries({ - queryKey: ['drawerify', editBillStore.url], - }); - await invalidateAll(); + selectedBillIds = []; + getUserBills().refresh(); }} component={ShowBillDetailsComponent as Component<{ data: unknown; @@ -101,195 +81,227 @@ }>} /> - - { - deleteModalOpen = false; - selectedBills = []; - }} - action="?/deleteBill" -> - {#snippet header()} - Delete Bills? - {/snippet} - {#snippet footer()} - + + {#snippet pending()} +
+
+
+ {#each Array(3) as _} +
+ {#each Array(4) as _} +
+ {/each} + {/each} +
{/snippet} - {#each selectedBills as bill (bill.id)} - - {/each} -
-

- Are you sure you want to delete {selectedBills.length} bills? -

-

- This action cannot be undone, and will delete all payment history - associated with this bill. -

-
-
- { - showBillDetailsStore.show = false; - history.replaceState(null, '', '/dashboard/bills'); - }} - component={ShowBillDetailsComponent as Component<{ - data: unknown; - component: boolean; - onclose: () => void; - }>} -/> + {@const bills = await getUserBills()} + {@const byHousehold = bills.reduce( + (acc, bill) => { + if (!acc[bill.householdName]) { + acc[bill.householdName] = []; + } + acc[bill.householdName].push(bill); + return acc; + }, + {} as Record, + )} -
- +
{ + await submit(); + deleteModalOpen = false; + selectedBillIds = []; + })} + > + { + deleteModalOpen = false; + selectedBillIds = []; + }} + > + {#snippet header()} + Delete Bills? + {/snippet} + {#snippet footer()} + + {/snippet} + {#each selectedBillIds as id} + + {/each} +
+

+ Are you sure you want to delete {selectedBillIds.length} bills? +

+

+ This action cannot be undone, and will delete all payment history + associated with this bill. +

+
+
+
+ + { + showBillDetailsStore.show = false; + history.replaceState(null, '', '/dashboard/bills'); + }} + component={ShowBillDetailsComponent as Component<{ + data: unknown; + component: boolean; + onclose: () => void; + }>} /> -
- Bills - {#snippet actions()} - - - - {/snippet} -
-
- {#each Object.entries(byHousehold) as [householdName, bills]} -
- {#snippet actions()} - {@const isIndeterminate = (() => { - const selectedBillsSet = new Set(selectedBills); - const theseBills = new Set(bills); - const difference = theseBills.difference(selectedBillsSet); - const intersection = selectedBillsSet.intersection(theseBills); - return ( - intersection.size > 0 && - selectedBillsSet.size > 0 && - difference.size > 0 - ); - })()} - {@const checked = bills.every((bill) => selectedBills.includes(bill))} - { - const selectedBillsSet = new Set(selectedBills); - const theseBills = new Set(bills); - const diff = theseBills.difference(selectedBillsSet); - if (diff.size === 0) { - // All bills are selected, so we need to deselect them - bills.forEach((bill) => selectedBillsSet.delete(bill)); - } else { - // Some or none of the bills are selected, so we need to select them - diff.forEach((bill) => selectedBillsSet.add(bill)); - } - selectedBills = Array.from(selectedBillsSet); - }} - indeterminate={isIndeterminate} - {checked} - /> - {/snippet} - {householdName} -
- {#each bills as bill (bill.id)} -
+ +
+ Bills + {#snippet actions()} + + + + {/snippet} +
+
+ {#each Object.entries(byHousehold) as [householdName, householdBills]} +
+ {#snippet actions()} + {@const isIndeterminate = (() => { + const selectedSet = new Set(selectedBillIds); + const theseBillIds = new Set(householdBills.map((b) => b.id)); + const difference = theseBillIds.difference(selectedSet); + const intersection = selectedSet.intersection(theseBillIds); + return ( + intersection.size > 0 && selectedSet.size > 0 && difference.size > 0 + ); + })()} + {@const checked = householdBills.every((bill) => + selectedBillIds.includes(bill.id), )} -

-
- - -
-
+ indeterminate={isIndeterminate} + {checked} + /> + {/snippet} + + {householdName} + + {#each householdBills as bill (bill.id)} +
+
+ { + e.preventDefault(); + history.pushState(null, '', `/dashboard/bills/${bill.id}`); + }, + })} + > + {bill.billName} + + + {#snippet actions()} + { + const set = new Set(selectedBillIds); + if (set.has(bill.id)) { + set.delete(bill.id); + } else { + set.add(bill.id); + } + selectedBillIds = Array.from(set); + }} + checked={selectedBillIds.includes(bill.id)} + aria-label={`Select ${bill.billName}`} + /> + {/snippet} +
+

+ {bill.householdName} – {bill.dueDate}{ordinalSuffix( + bill.dueDate, + )} +

+
+ + +
+
+ {/each} {/each} - {/each} +
-
+
diff --git a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts b/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts deleted file mode 100644 index 00b8d7b9..00000000 --- a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getBill } from '$lib/server/actions/bills.actions'; -import { db } from '$lib/server/db/client'; -import { validateUserSession } from '$lib/util/session'; -import { error } from '@sveltejs/kit'; - -export const load = async ({ locals, params }) => { - const session = await locals.getSession(); - if (!validateUserSession(session)) error(401); - - const bill = await getBill(params.id); - - return { - bill, - payments: db.query.payments.findMany({ - where(fields, operators) { - return operators.eq(fields.billId, params.id); - }, - orderBy(fields, operators) { - return operators.desc(fields.forMonthD); - }, - // Full year's worth - limit: 12, - }), - }; -}; diff --git a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte b/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte index 8dcf02ac..c0bd9c98 100644 --- a/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte +++ b/apps/website/src/routes/dashboard/bills/[id=ulid]/+page.svelte @@ -1,24 +1,23 @@ - - - Dashboard – {data.bill.billName} - - - (paymentDetails.url = '')} /> -
-
- {#if !component} - - {/if} -
- {data.bill.billName} - {#snippet actions()} - {#if component} - - {/if} - {/snippet} -
+ + {#snippet pending()} +
+
+
+
+
+ {#each Array(5) as _} +
+ {/each} +
+
+ {/snippet} - -
Details
-
-
Household
- -
Due Date
-
{data.bill.dueDate}{ordinalSuffix(data.bill.dueDate)}
-
Notes
-
- {#if data.bill.notes} - {data.bill.notes} - {:else} - N/A + {@const billData = await getBillWithPayments(page.params.id)} + + + Dashboard – {billData.billName} + + +
+
+ {#if !component} + + {/if} +
+ {billData.billName} + {#snippet actions()} + {#if component} + {/if} + {/snippet} +
+ + +
Details
+
+
Household
+ +
Due Date
+
{billData.dueDate}{ordinalSuffix(billData.dueDate)}
+
Notes
+
+ {#if billData.notes} + {billData.notes} + {:else} + N/A + {/if} +
+
Amount
+
{formatNumber(billData.amount)}
+
Currency
+
{billData.currency}
-
Amount
-
- {formatNumber(data.bill.amount)} -
-
Currency
-
- {data.bill.currency} -
-
- + - - {#snippet header()} -
Payment History
- {/snippet} - {#await data.payments} -
- {:then payments} - {@const labels = payments + + {#snippet header()} +
Payment History
+ {/snippet} + {@const labels = billData.payments + .slice() .sort((a, b) => a.forMonthD.getTime() - b.forMonthD.getTime()) .map((p) => p.forMonthD.toLocaleDateString(undefined, { @@ -126,16 +127,20 @@ Number(p.amount) ?? null), - label: `${data.bill.billName} (Actual)`, + data: billData.payments + .slice() + .sort((a, b) => a.forMonthD.getTime() - b.forMonthD.getTime()) + .map((p) => Number(p.amount) ?? null), + label: `${billData.billName} (Actual)`, type: 'line', }, { data: Array.from( - { length: payments.length }, - () => data.bill.amount || 0, + { length: billData.payments.length }, + () => billData.amount || 0, ), label: 'Minimum', type: 'line', @@ -144,40 +149,22 @@ ]} options={{ scales: { - y: { - grid: { - color: '#e9e9e950', - }, - }, - x: { - grid: { - color: '#eee', - }, - }, + y: { grid: { color: '#e9e9e950' } }, + x: { grid: { color: '#eee' } }, }, }} /> - {/await} -
+
- {#await data.payments} -
-
-
-
-
- {:then payments}
- {#each payments as payment (payment.id)} + {#each billData.payments as payment (payment.id)}
{ e.preventDefault(); - // @ts-expect-error can't turn this shit off rn - - pushState(e.target.href, {}); + pushState(`/dashboard/payments/${payment.id}`, {}); showPaymentDetails(payment.id); }} > @@ -214,9 +201,9 @@ No payment history yet {/each}
- {/await} +
-
+