Files
website/src/routes/trackers/groceries/+page.server.ts

179 lines
6.1 KiB
TypeScript

import { db } from '#lib/server/db/index.js';
import { groceryPrices, groceryProducts, groceryStores } from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { parseSize, priceInput, productInput, unitPriceCents } from '#lib/server/groceries.js';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const [stores, products, prices] = await Promise.all([
db
.select()
.from(groceryStores)
.where(isNull(groceryStores.archivedAt))
.orderBy(asc(groceryStores.sortOrder), asc(groceryStores.name)),
db
.select()
.from(groceryProducts)
.where(isNull(groceryProducts.archivedAt))
.orderBy(desc(groceryProducts.favorite), asc(sql`lower(${groceryProducts.name})`)),
db
.select({
id: groceryPrices.id,
productId: groceryPrices.productId,
storeId: groceryPrices.storeId,
priceCents: groceryPrices.priceCents,
unitPriceCents: groceryPrices.unitPriceCents,
promo: groceryPrices.promo,
observedAt: groceryPrices.observedAt,
source: groceryPrices.source,
url: groceryPrices.url,
notes: groceryPrices.notes
})
.from(groceryPrices)
.orderBy(desc(groceryPrices.observedAt), desc(groceryPrices.id))
]);
return {
stores,
products,
prices,
seo: { title: 'Groceries', description: 'Private grocery and price tracker.', index: false }
};
};
const id = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
const formObject = (form: FormData) => {
const o: Record<string, unknown> = {};
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
return o;
};
const firstIssue = (e: { issues: { path: PropertyKey[]; message: string }[] }) => {
const i = e.issues[0];
return i ? `${i.path.join('.') || 'input'}: ${i.message}` : 'Invalid input';
};
function dbError(e: unknown): string {
const cause = (e as { cause?: { message?: string; constraint_name?: string } })?.cause;
if (cause?.constraint_name === 'products_name_brand_key')
return 'That product (name + brand) already exists';
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
return cause?.message || 'Could not save';
}
export const actions: Actions = {
/** Create or edit a product. */
saveProduct: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('id'));
const parsed = productInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const p = parsed.data;
const size = parseSize(p.size);
const values = {
name: p.name,
brand: p.brand ?? null,
category: p.category ?? null,
size: p.size ?? null,
unitAmount: size?.amount ?? null,
unit: size?.unit ?? null,
notes: p.notes ?? null,
...(form.has('favorite') ? { favorite: form.get('favorite') === 'on' } : {})
};
try {
if (productId) {
await db.transaction(async (tx) => {
await tx.update(groceryProducts).set(values).where(eq(groceryProducts.id, productId));
// Pack size changed: recompute unit prices of this product's history.
await tx
.update(groceryPrices)
.set({
unitPriceCents: values.unitAmount
? sql`round(${groceryPrices.priceCents} / ${values.unitAmount}::numeric)::int`
: null
})
.where(eq(groceryPrices.productId, productId));
});
return { saved: productId };
}
const [row] = await db
.insert(groceryProducts)
.values(values)
.returning({ id: groceryProducts.id });
return { saved: row.id };
} catch (e) {
return fail(400, { message: dbError(e) });
}
},
favorite: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('id'));
if (!productId) return fail(400, { message: 'Invalid product' });
await db
.update(groceryProducts)
.set({ favorite: form.get('favorite') === 'true' })
.where(eq(groceryProducts.id, productId));
return { updated: productId };
},
deleteProduct: async (event) => {
requireOwner(event);
const productId = id((await event.request.formData()).get('id'));
if (!productId) return fail(400, { message: 'Invalid product' });
await db.delete(groceryProducts).where(eq(groceryProducts.id, productId));
return { deleted: productId };
},
/** Record a price observation for a product at a store. */
addPrice: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('productId'));
const storeId = id(form.get('storeId'));
if (!productId || !storeId) return fail(400, { message: 'Pick a product and a store' });
const parsed = priceInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const [product] = await db
.select({ unitAmount: groceryProducts.unitAmount })
.from(groceryProducts)
.where(eq(groceryProducts.id, productId));
if (!product) return fail(404, { message: 'Product not found' });
const p = parsed.data;
try {
await db.insert(groceryPrices).values({
productId,
storeId,
priceCents: p.price,
unitPriceCents: unitPriceCents(p.price, product.unitAmount),
promo: form.get('promo') === 'on',
// A plain date is stored as noon UTC; the exact time does not matter for prices.
observedAt: p.observedOn ? new Date(`${p.observedOn}T12:00:00Z`) : new Date(),
source: 'manual',
url: p.url ?? null,
notes: p.notes ?? null
});
} catch (e) {
return fail(400, { message: dbError(e) });
}
return { priced: productId };
},
deletePrice: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const priceId = id(form.get('id'));
const productId = id(form.get('productId'));
if (!priceId || !productId) return fail(400, { message: 'Invalid price' });
await db
.delete(groceryPrices)
.where(and(eq(groceryPrices.id, priceId), eq(groceryPrices.productId, productId)));
return { deleted: priceId };
}
};