All checks were successful
Deploy production / deploy (push) Successful in 2m40s
179 lines
6.1 KiB
TypeScript
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 };
|
|
}
|
|
};
|