From d934e2d43a113647b5466f12c1397f9b3d965c80 Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 25 Feb 2026 10:18:45 -0500 Subject: [PATCH 001/180] Add ambassador shipping rate estimation endpoint This is to estimate prices for warehouse. Adds POST /api/shipping-rates endpoint that allows ambassadors to get Canada Post shipping rate quotes by providing destination address, package type (envelope/box), dimensions (inches), and weight (grams). --- resolution-frontend/.env.example | 8 + resolution-frontend/package-lock.json | 55 +++- resolution-frontend/package.json | 2 + .../src/lib/server/validation/schemas.ts | 23 ++ .../src/routes/api/shipping-rates/+server.ts | 271 ++++++++++++++++++ 5 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 resolution-frontend/src/routes/api/shipping-rates/+server.ts diff --git a/resolution-frontend/.env.example b/resolution-frontend/.env.example index b250393..b258180 100644 --- a/resolution-frontend/.env.example +++ b/resolution-frontend/.env.example @@ -10,6 +10,14 @@ AIRTABLE_API_TOKEN=your_airtable_api_token AIRTABLE_BASE_ID=your_base_id AIRTABLE_TABLE_ID=your_table_id +# Canada Post API (for shipping rate estimates) +CP_API_USERNAME=your_cp_api_username +CP_API_PASSWORD=your_cp_api_password +CP_CUSTOMER_NUMBER=your_cp_customer_number +CP_CONTRACT_ID= # optional +CP_ORIGIN_POSTAL_CODE=A1A1A1 +CP_ENVIRONMENT=development # or "production" + # Season Configuration # Only SEASON_STARTS is required - everything else is auto-calculated SEASON_STARTS="2026-01-01" diff --git a/resolution-frontend/package-lock.json b/resolution-frontend/package-lock.json index 15fbc91..8b4351e 100644 --- a/resolution-frontend/package-lock.json +++ b/resolution-frontend/package-lock.json @@ -30,6 +30,7 @@ "tsparticles": "^3.9.1", "tsparticles-preset-fireworks": "^2.12.0", "uuid": "^13.0.0", + "xml2js": "^0.6.2", "zod": "^4.3.5" }, "devDependencies": { @@ -42,6 +43,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/pg": "^8.16.0", "@types/simple-oauth2": "^5.0.8", + "@types/xml2js": "^0.4.14", "drizzle-kit": "^0.31.8", "svelte": "^5.46.4", "svelte-check": "^4.3.4", @@ -2111,7 +2113,6 @@ "integrity": "sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2153,7 +2154,6 @@ "version": "6.2.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -2910,7 +2910,6 @@ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2942,6 +2941,16 @@ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "license": "MIT" }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2964,7 +2973,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3647,7 +3655,6 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -3788,7 +3795,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4097,7 +4103,6 @@ "integrity": "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==", "deprecated": "This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" @@ -4277,7 +4282,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -4491,7 +4495,6 @@ "version": "4.54.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4559,6 +4562,15 @@ ], "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4664,7 +4676,6 @@ "integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4997,7 +5008,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5030,7 +5040,6 @@ "version": "7.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5134,6 +5143,28 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/resolution-frontend/package.json b/resolution-frontend/package.json index e65a7b5..2840d28 100644 --- a/resolution-frontend/package.json +++ b/resolution-frontend/package.json @@ -21,6 +21,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/pg": "^8.16.0", "@types/simple-oauth2": "^5.0.8", + "@types/xml2js": "^0.4.14", "drizzle-kit": "^0.31.8", "svelte": "^5.46.4", "svelte-check": "^4.3.4", @@ -50,6 +51,7 @@ "tsparticles": "^3.9.1", "tsparticles-preset-fireworks": "^2.12.0", "uuid": "^13.0.0", + "xml2js": "^0.6.2", "zod": "^4.3.5" } } diff --git a/resolution-frontend/src/lib/server/validation/schemas.ts b/resolution-frontend/src/lib/server/validation/schemas.ts index 5662f68..80ab09a 100644 --- a/resolution-frontend/src/lib/server/validation/schemas.ts +++ b/resolution-frontend/src/lib/server/validation/schemas.ts @@ -36,6 +36,29 @@ export const workshopIdSchema = z.object({ workshopId: z.string().min(1, 'Workshop ID is required') }); +const envelopeSchema = z.object({ + packageType: z.literal('envelope'), + length: z.number().positive('Length must be positive'), + width: z.number().positive('Width must be positive') +}); + +const boxSchema = z.object({ + packageType: z.literal('box'), + length: z.number().positive('Length must be positive'), + width: z.number().positive('Width must be positive'), + height: z.number().positive('Height must be positive') +}); + +export const shippingRateSchema = z.object({ + country: z.string().length(2, 'Country must be a 2-letter ISO code').toUpperCase(), + street: z.string().min(1, 'Street is required'), + city: z.string().min(1, 'City is required'), + province: z.string().min(1, 'Province/State is required'), + postalCode: z.string().optional(), + weight: z.number().positive('Weight must be positive') +}).and(z.discriminatedUnion('packageType', [envelopeSchema, boxSchema])); + +export type ShippingRateInput = z.infer; export type CreateShipInput = z.infer; export type MarkShippedInput = z.infer; export type UpdateShipStatusInput = z.infer; diff --git a/resolution-frontend/src/routes/api/shipping-rates/+server.ts b/resolution-frontend/src/routes/api/shipping-rates/+server.ts new file mode 100644 index 0000000..4bd5ff3 --- /dev/null +++ b/resolution-frontend/src/routes/api/shipping-rates/+server.ts @@ -0,0 +1,271 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { requireAuth } from '$lib/server/auth/guard'; +import { validateJson, shippingRateSchema } from '$lib/server/validation'; +import { db } from '$lib/server/db'; +import { ambassadorPathway } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import xml2js from 'xml2js'; + +const INCHES_TO_CM = 2.54; +const GRAMS_TO_KG = 0.001; + +function inchesToCm(inches: number): number { + return Math.round(inches * INCHES_TO_CM * 10) / 10; +} + +function buildDestinationXML(country: string, postalCode?: string): string { + if (country === 'CA') { + return ` + ${(postalCode ?? '').replace(/\s/g, '').toUpperCase()} + `; + } else if (country === 'US') { + return ` + ${(postalCode ?? '').replace(/\s/g, '')} + `; + } else { + if (postalCode) { + return ` + ${country} + ${postalCode} + `; + } + return ` + ${country} + `; + } +} + +function buildRateRequestXML( + originPostal: string, + country: string, + postalCode: string | undefined, + weightKg: number, + lengthCm: number, + widthCm: number, + heightCm: number +): string { + return ` + + ${env.CP_CUSTOMER_NUMBER} + ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} + + ${Math.round(weightKg * 100) / 100} + + ${lengthCm} + ${widthCm} + ${heightCm} + + + ${originPostal.replace(/\s/g, '').toUpperCase()} + + ${buildDestinationXML(country, postalCode)} + +`; +} + +interface PriceQuote { + 'service-name': string; + 'service-code': string; + 'price-details': { + base?: string; + due?: string; + taxes?: { + gst?: string | { $: string }; + pst?: string | { $: string }; + hst?: string | { $: string }; + }; + }; + 'service-standard'?: { + 'expected-delivery-date'?: string; + 'expected-transit-time'?: string; + }; +} + +function getTaxValue(tax: string | { $: string } | undefined): number { + if (!tax) return 0; + if (typeof tax === 'string') return parseFloat(tax) || 0; + return parseFloat(tax.$) || 0; +} + +function formatRatesResponse(parsedXml: Record, cadToUsd: number) { + const priceQuotes = parsedXml['price-quotes'] as { 'price-quote'?: PriceQuote | PriceQuote[] } | undefined; + if (!priceQuotes?.['price-quote']) return []; + + let quotes = priceQuotes['price-quote']; + if (!Array.isArray(quotes)) quotes = [quotes]; + + return quotes.map((quote) => { + const priceDetails = quote['price-details']; + const taxes = priceDetails.taxes ?? {}; + const baseTotalCAD = parseFloat(priceDetails.due ?? '0'); + const handlingFee = 2.0; + const totalCAD = baseTotalCAD + handlingFee; + const totalUSD = Math.round(totalCAD * cadToUsd * 100) / 100; + + return { + serviceName: quote['service-name'], + serviceCode: quote['service-code'], + priceDetails: { + base: Math.round(parseFloat(priceDetails.base ?? '0') * cadToUsd * 100) / 100, + gst: Math.round(getTaxValue(taxes.gst) * cadToUsd * 100) / 100, + pst: Math.round(getTaxValue(taxes.pst) * cadToUsd * 100) / 100, + hst: Math.round(getTaxValue(taxes.hst) * cadToUsd * 100) / 100, + total: totalUSD + }, + deliveryDate: quote['service-standard']?.['expected-delivery-date'] ?? 'N/A', + transitDays: quote['service-standard']?.['expected-transit-time'] ?? 'N/A', + currency: 'USD' + }; + }); +} + +function getLetterMailOptions(weightGrams: number, lengthCm: number, widthCm: number, heightCm: number, country: string) { + const options: Array<{ + serviceName: string; + serviceCode: string; + priceDetails: { base: number; gst: number; pst: number; hst: number; total: number }; + deliveryDate: string; + transitDays: string; + isLettermail: boolean; + note: string; + }> = []; + + const lengthMm = lengthCm * 10; + const widthMm = widthCm * 10; + const heightMm = heightCm * 10; + + const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; + const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; + const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; + + if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { + let price: number; + if (country === 'CA') price = 1.75; + else if (country === 'US') price = 2.0; + else price = 3.5; + + const countryLabel = country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'; + options.push({ + serviceName: `Lettermail ${countryLabel} (up to 30g)`, + serviceCode: 'LETTERMAIL.STD', + priceDetails: { base: price, gst: 0, pst: 0, hst: 0, total: price }, + deliveryDate: 'N/A', + transitDays: country === 'CA' ? '2-4' : country === 'US' ? '4-7' : '7-14', + isLettermail: true, + note: 'Max: 245mm x 156mm x 5mm' + }); + } + + if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { + let price: number; + const countryLabel = country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'; + + if (country === 'CA') { + if (weightGrams <= 100) price = 3.11; + else if (weightGrams <= 200) price = 4.51; + else if (weightGrams <= 300) price = 5.91; + else if (weightGrams <= 400) price = 6.62; + else price = 7.05; + } else if (country === 'US') { + if (weightGrams <= 100) price = 4.51; + else if (weightGrams <= 200) price = 7.16; + else price = 13.38; + } else { + if (weightGrams <= 100) price = 8.08; + else if (weightGrams <= 200) price = 13.38; + else price = 25.8; + } + + options.push({ + serviceName: `Bubble Packet ${countryLabel} (up to 500g)`, + serviceCode: 'BUBBLE.PACKET', + priceDetails: { base: price, gst: 0, pst: 0, hst: 0, total: price }, + deliveryDate: 'N/A', + transitDays: country === 'CA' ? '2-5' : country === 'US' ? '5-10' : '10-21', + isLettermail: true, + note: 'Max: 380mm x 270mm x 20mm' + }); + } + + return options; +} + +export const POST: RequestHandler = async (event) => { + const { user } = requireAuth(event); + + const assignments = await db + .select() + .from(ambassadorPathway) + .where(eq(ambassadorPathway.userId, user.id)); + + if (assignments.length === 0 && !user.isAdmin) { + throw error(403, 'You are not an ambassador'); + } + + const data = await validateJson(shippingRateSchema, event.request); + + if ((data.country === 'CA' || data.country === 'US') && !data.postalCode) { + throw error(400, 'Postal/ZIP code is required for Canadian and US destinations'); + } + + const originPostal = env.CP_ORIGIN_POSTAL_CODE; + if (!originPostal || !env.CP_API_USERNAME || !env.CP_API_PASSWORD || !env.CP_CUSTOMER_NUMBER) { + throw error(500, 'Canada Post API not configured'); + } + + const weightKg = data.weight * GRAMS_TO_KG; + const lengthCm = inchesToCm(data.length); + const widthCm = inchesToCm(data.width); + const heightCm = data.packageType === 'box' ? inchesToCm(data.height) : 0.5; + + const lettermailOptions = getLetterMailOptions(data.weight, lengthCm, widthCm, heightCm, data.country); + + let parcelRates: ReturnType = []; + try { + const cpEndpoint = env.CP_ENVIRONMENT === 'production' + ? 'https://soa-gw.canadapost.ca/rs/ship/price' + : 'https://ct.soa-gw.canadapost.ca/rs/ship/price'; + + const authString = btoa(`${env.CP_API_USERNAME}:${env.CP_API_PASSWORD}`); + const xmlBody = buildRateRequestXML(originPostal, data.country, data.postalCode, weightKg, lengthCm, widthCm, heightCm); + + const response = await fetch(cpEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', + Accept: 'application/vnd.cpc.ship.rate-v4+xml', + Authorization: `Basic ${authString}`, + 'Accept-language': 'en-CA' + }, + body: xmlBody + }); + + const xmlResponse = await response.text(); + + if (!response.ok) { + console.error('Canada Post API error:', xmlResponse); + } else { + const parser = new xml2js.Parser({ explicitArray: false }); + const result = await parser.parseStringPromise(xmlResponse); + const cadToUsd = 0.73; + parcelRates = formatRatesResponse(result, cadToUsd); + } + } catch (err) { + console.error('Parcel rate lookup failed:', err); + } + + const allRates = [...lettermailOptions, ...parcelRates]; + + return json({ + rates: allRates, + origin: originPostal, + destination: { + country: data.country, + city: data.city, + province: data.province, + postalCode: data.postalCode + } + }); +}; From 4ecb222476d37fbfd5a5defd3899e7ef94874580 Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 25 Feb 2026 12:25:19 -0500 Subject: [PATCH 002/180] Add warehouse inventory management page and schema --- .../src/lib/server/db/schema.ts | 13 + .../src/routes/app/+page.svelte | 14 +- .../src/routes/app/warehouse/+page.server.ts | 83 ++++ .../src/routes/app/warehouse/+page.svelte | 372 ++++++++++++++++++ 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 resolution-frontend/src/routes/app/warehouse/+page.server.ts create mode 100644 resolution-frontend/src/routes/app/warehouse/+page.svelte diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index c245e67..693c001 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -271,3 +271,16 @@ export const referralSignupRelations = relations(referralSignup, ({ one }) => ({ referralLink: one(referralLink, { fields: [referralSignup.referralLinkId], references: [referralLink.id] }), user: one(user, { fields: [referralSignup.userId], references: [user.id] }) })); + +// Warehouse items - inventory managed by admins +export const warehouseItem = pgTable('warehouse_item', { + id: text('id').primaryKey().$defaultFn(() => createId()), + name: text('name').notNull(), + sku: text('sku').notNull().unique(), + sizing: text('sizing'), + weightOz: real('weight_oz').notNull(), + costCents: integer('cost_cents').notNull(), + quantity: integer('quantity').notNull().default(0), + createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow() +}); diff --git a/resolution-frontend/src/routes/app/+page.svelte b/resolution-frontend/src/routes/app/+page.svelte index 4bec070..16fd864 100644 --- a/resolution-frontend/src/routes/app/+page.svelte +++ b/resolution-frontend/src/routes/app/+page.svelte @@ -52,6 +52,9 @@

Welcome, {data.user.firstName || data.user.email}!

+ {#if data.isAmbassador || data.user.isAdmin} + Warehouse + {/if} {#if data.isAmbassador} Ambassador {/if} @@ -201,7 +204,8 @@ } .admin-btn, - .ambassador-btn { + .ambassador-btn, + .warehouse-btn { padding: 0.5rem 1rem; background: rgba(255, 255, 255, 0.8); border-radius: 20px; @@ -219,8 +223,14 @@ color: #a633d6; } + .warehouse-btn { + border: 1px solid #338eda; + color: #338eda; + } + .admin-btn:hover, - .ambassador-btn:hover { + .ambassador-btn:hover, + .warehouse-btn:hover { background: rgba(255, 255, 255, 1); } diff --git a/resolution-frontend/src/routes/app/warehouse/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/+page.server.ts new file mode 100644 index 0000000..e0aa77a --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/+page.server.ts @@ -0,0 +1,83 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { warehouseItem, ambassadorPathway } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + const ambassadorCheck = await db + .select({ userId: ambassadorPathway.userId }) + .from(ambassadorPathway) + .where(eq(ambassadorPathway.userId, user.id)) + .limit(1); + + const isAmbassador = ambassadorCheck.length > 0; + + if (!user.isAdmin && !isAmbassador) { + throw error(403, 'Access denied'); + } + + const items = await db + .select() + .from(warehouseItem) + .orderBy(desc(warehouseItem.createdAt)); + + return { + items, + isAdmin: user.isAdmin + }; +}; + +export const actions: Actions = { + addItem: async ({ request, locals }) => { + if (!locals.user?.isAdmin) { + return fail(403, { error: 'Only admins can add items' }); + } + + const formData = await request.formData(); + const name = formData.get('name') as string; + const sku = formData.get('sku') as string; + const sizing = formData.get('sizing') as string | null; + const weightOz = parseFloat(formData.get('weightOz') as string); + const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); + const quantity = parseInt(formData.get('quantity') as string) || 0; + + if (!name || !sku || isNaN(weightOz) || isNaN(costCents)) { + return fail(400, { error: 'Name, SKU, weight, and cost are required' }); + } + + try { + await db.insert(warehouseItem).values({ + name, + sku, + sizing: sizing || null, + weightOz, + costCents, + quantity + }); + } catch { + return fail(400, { error: 'SKU already exists' }); + } + + return { success: true }; + }, + + deleteItem: async ({ request, locals }) => { + if (!locals.user?.isAdmin) { + return fail(403, { error: 'Only admins can delete items' }); + } + + const formData = await request.formData(); + const itemId = formData.get('itemId') as string; + + if (!itemId) { + return fail(400, { error: 'Item ID required' }); + } + + await db.delete(warehouseItem).where(eq(warehouseItem.id, itemId)); + + return { success: true }; + } +}; diff --git a/resolution-frontend/src/routes/app/warehouse/+page.svelte b/resolution-frontend/src/routes/app/warehouse/+page.svelte new file mode 100644 index 0000000..56faf8b --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/+page.svelte @@ -0,0 +1,372 @@ + + + + Warehouse - Resolution + + + +
+ + Back + Back to Dashboard + + +
+
+
+

Warehouse

+

Inventory management

+
+ {#if data.isAdmin} + + {/if} +
+
+ + {#if showAddForm && data.isAdmin} +
+

Add New Item

+
{ + isSubmitting = true; + return async ({ update, result }) => { + await update(); + isSubmitting = false; + if (result.type === 'success') showAddForm = false; + }; + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {/if} + + {#if data.items.length === 0} +
+

No items in the warehouse yet.

+ {#if data.isAdmin} +

Click "Add Item" to start building your inventory.

+ {:else} +

Items will appear here once an admin adds them.

+ {/if} +
+ {:else} +
+
+ + + + + + + + + + {#if data.isAdmin} + + {/if} + + + + {#each data.items as item (item.id)} + + + + + + + + {#if data.isAdmin} + + {/if} + + {/each} + +
NameSKUSizingWeightCostQtyActions
{item.name}{item.sku}{item.sizing || '—'}{item.weightOz} oz{formatCost(item.costCents)}{item.quantity} + {#if confirmDelete === item.id} +
{ + return async ({ update }) => { + await update(); + confirmDelete = null; + }; + }}> + + + +
+ {:else} + + {/if} +
+
+
+ {/if} +
+
+ + From ad4a41081dcbee29cfce088ba459949158387a1a Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 25 Feb 2026 12:46:51 -0500 Subject: [PATCH 003/180] feat: add staging mode to bypass OAuth for local/staging testing --- resolution-frontend/.env.example | 1 + .../src/routes/api/auth/login/+server.ts | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/resolution-frontend/.env.example b/resolution-frontend/.env.example index b258180..c05a947 100644 --- a/resolution-frontend/.env.example +++ b/resolution-frontend/.env.example @@ -1,5 +1,6 @@ # App BASE_URL=http://localhost:5173 +STAGING_MODE=false # Hack Club Auth (https://auth.hackclub.com/developer/apps) HACK_CLUB_CLIENT_ID=your_client_id diff --git a/resolution-frontend/src/routes/api/auth/login/+server.ts b/resolution-frontend/src/routes/api/auth/login/+server.ts index a8a8255..ecb9a5f 100644 --- a/resolution-frontend/src/routes/api/auth/login/+server.ts +++ b/resolution-frontend/src/routes/api/auth/login/+server.ts @@ -1,14 +1,46 @@ import { redirect } from '@sveltejs/kit'; -import { hackClubAuth } from '$lib/server/auth'; +import { hackClubAuth, lucia } from '$lib/server/auth'; import { env } from '$env/dynamic/private'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; import type { RequestHandler } from './$types'; -export const GET: RequestHandler = async ({ locals }) => { +const STAGING_USER_ID = 'staging-admin-123'; + +export const GET: RequestHandler = async ({ locals, cookies }) => { // If user already has a valid session, skip OAuth and go straight to app if (locals.user && locals.session) { throw redirect(302, '/app'); } + if (env.STAGING_MODE === 'true') { + // Bypass OAuth: upsert a staging admin user and create a session + const existing = await db.query.user.findFirst({ + where: eq(user.id, STAGING_USER_ID) + }); + + if (!existing) { + await db.insert(user).values({ + id: STAGING_USER_ID, + email: 'admin@staging.local', + hackClubId: 'staging-hack-club-id', + firstName: 'Staging', + lastName: 'Admin', + isAdmin: true + }); + } + + const session = await lucia.createSession(STAGING_USER_ID, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '.', + ...sessionCookie.attributes + }); + + throw redirect(302, '/app'); + } + const authorizationUri = hackClubAuth.authorizeURL({ redirect_uri: `${env.BASE_URL}/api/auth/callback`, scope: 'openid profile email name slack_id verification_status' From b8a4daf3f8f50cc05ded0a99355219ea67953f15 Mon Sep 17 00:00:00 2001 From: Jenin Date: Thu, 26 Feb 2026 12:59:25 -0500 Subject: [PATCH 004/180] Add drizzle-kit push to container startup for auto-migrations --- resolution-frontend/Dockerfile | 13 +++++++++++-- resolution-frontend/entrypoint.sh | 8 ++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 resolution-frontend/entrypoint.sh diff --git a/resolution-frontend/Dockerfile b/resolution-frontend/Dockerfile index b96fb98..5ce1186 100644 --- a/resolution-frontend/Dockerfile +++ b/resolution-frontend/Dockerfile @@ -30,11 +30,20 @@ COPY --from=builder /app/build ./build COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +# Copy drizzle files needed for migrations +COPY --from=builder /app/drizzle ./drizzle +COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts +COPY --from=builder /app/src/lib/server/db/schema.ts ./src/lib/server/db/schema.ts + +# Copy entrypoint script +COPY entrypoint.sh ./entrypoint.sh +RUN chmod +x ./entrypoint.sh + # Expose the port the app runs on (SvelteKit node adapter starts on 3000 by default) EXPOSE 3000 # Set Node environment to production ENV NODE_ENV=production -# Start the application -CMD ["node", "build"] +# Start the application with migrations +CMD ["./entrypoint.sh"] diff --git a/resolution-frontend/entrypoint.sh b/resolution-frontend/entrypoint.sh new file mode 100644 index 0000000..3125d2c --- /dev/null +++ b/resolution-frontend/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +npx drizzle-kit push --force +echo "Migrations complete." + +exec node build From 546763679cdeee0a4eac7926ec0307a16f20a912 Mon Sep 17 00:00:00 2001 From: Jenin Date: Fri, 27 Feb 2026 09:06:55 -0500 Subject: [PATCH 005/180] Install drizzle-kit in production image for runtime schema push --- resolution-frontend/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/Dockerfile b/resolution-frontend/Dockerfile index 5ce1186..64f3071 100644 --- a/resolution-frontend/Dockerfile +++ b/resolution-frontend/Dockerfile @@ -30,11 +30,14 @@ COPY --from=builder /app/build ./build COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json -# Copy drizzle files needed for migrations +# Copy drizzle files needed for push COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts COPY --from=builder /app/src/lib/server/db/schema.ts ./src/lib/server/db/schema.ts +# Install drizzle-kit for runtime schema push (pruned as devDependency) +RUN npm install drizzle-kit + # Copy entrypoint script COPY entrypoint.sh ./entrypoint.sh RUN chmod +x ./entrypoint.sh From 718b9ac18205f4faf49d675f1c80e7aeba64b076 Mon Sep 17 00:00:00 2001 From: Jenin Date: Fri, 27 Feb 2026 10:34:44 -0500 Subject: [PATCH 006/180] feat: add warehouse item photo uploads via Hack Club CDN --- resolution-frontend/.env.example | 3 + resolution-frontend/.gitignore | 1 + .../drizzle/meta/0002_snapshot.json | 1595 +++++++++++++++++ .../drizzle/meta/_journal.json | 7 + .../src/lib/server/db/schema.ts | 3 +- .../src/routes/app/warehouse/+page.server.ts | 47 +- .../src/routes/app/warehouse/+page.svelte | 114 +- 7 files changed, 1760 insertions(+), 10 deletions(-) create mode 100644 resolution-frontend/drizzle/meta/0002_snapshot.json diff --git a/resolution-frontend/.env.example b/resolution-frontend/.env.example index c05a947..729e821 100644 --- a/resolution-frontend/.env.example +++ b/resolution-frontend/.env.example @@ -19,6 +19,9 @@ CP_CONTRACT_ID= # optional CP_ORIGIN_POSTAL_CODE=A1A1A1 CP_ENVIRONMENT=development # or "production" +# Hack Club CDN (for warehouse image uploads) +HACK_CLUB_CDN_API_KEY=sk_cdn_your_key_here + # Season Configuration # Only SEASON_STARTS is required - everything else is auto-calculated SEASON_STARTS="2026-01-01" diff --git a/resolution-frontend/.gitignore b/resolution-frontend/.gitignore index fbd6ae0..6adf7e6 100644 --- a/resolution-frontend/.gitignore +++ b/resolution-frontend/.gitignore @@ -27,3 +27,4 @@ vite.config.ts.timestamp-* # Prisma *.db *.db-journal + diff --git a/resolution-frontend/drizzle/meta/0002_snapshot.json b/resolution-frontend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..5658ddf --- /dev/null +++ b/resolution-frontend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1595 @@ +{ + "id": "0fa6b235-2de1-4477-8997-b6da9895316e", + "prevId": "becd34cd-7692-4ef3-a912-458e58e190ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ambassador_pathway": { + "name": "ambassador_pathway", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ambassador_pathway_unique_idx": { + "name": "ambassador_pathway_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ambassador_pathway_user_id_user_id_fk": { + "name": "ambassador_pathway_user_id_user_id_fk", + "tableFrom": "ambassador_pathway", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ambassador_pathway_assigned_by_user_id_fk": { + "name": "ambassador_pathway_assigned_by_user_id_fk", + "tableFrom": "ambassador_pathway", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ambassador_payout": { + "name": "ambassador_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ambassador_id": { + "name": "ambassador_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payout_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ambassador_payout_ambassador_id_user_id_fk": { + "name": "ambassador_payout_ambassador_id_user_id_fk", + "tableFrom": "ambassador_payout", + "tableTo": "user", + "columnsFrom": [ + "ambassador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ambassador_payout_season_id_program_season_id_fk": { + "name": "ambassador_payout_season_id_program_season_id_fk", + "tableFrom": "ambassador_payout", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ambassador_payout_item": { + "name": "ambassador_payout_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "payout_id": { + "name": "payout_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completion_count": { + "name": "completion_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rate_cents_per_completion": { + "name": "rate_cents_per_completion", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ambassador_payout_item_payout_id_ambassador_payout_id_fk": { + "name": "ambassador_payout_item_payout_id_ambassador_payout_id_fk", + "tableFrom": "ambassador_payout_item", + "tableTo": "ambassador_payout", + "columnsFrom": [ + "payout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ambassador_payout_item_workshop_id_workshop_id_fk": { + "name": "ambassador_payout_item_workshop_id_workshop_id_fk", + "tableFrom": "ambassador_payout_item", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pathway_week_content": { + "name": "pathway_week_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "week_number": { + "name": "week_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_edited_by": { + "name": "last_edited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pathway_week_content_unique_idx": { + "name": "pathway_week_content_unique_idx", + "columns": [ + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pathway_week_content_last_edited_by_user_id_fk": { + "name": "pathway_week_content_last_edited_by_user_id_fk", + "tableFrom": "pathway_week_content", + "tableTo": "user", + "columnsFrom": [ + "last_edited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program_enrollment": { + "name": "program_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "enrollment_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "starting_week": { + "name": "starting_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrollment_user_season_role_idx": { + "name": "enrollment_user_season_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "program_enrollment_user_id_user_id_fk": { + "name": "program_enrollment_user_id_user_id_fk", + "tableFrom": "program_enrollment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "program_enrollment_season_id_program_season_id_fk": { + "name": "program_enrollment_season_id_program_season_id_fk", + "tableFrom": "program_enrollment", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program_season": { + "name": "program_season", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signup_opens_at": { + "name": "signup_opens_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "signup_closes_at": { + "name": "signup_closes_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "total_weeks": { + "name": "total_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 8 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "program_season_slug_unique": { + "name": "program_season_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_link": { + "name": "referral_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ambassador_id": { + "name": "ambassador_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "referral_link_ambassador_id_user_id_fk": { + "name": "referral_link_ambassador_id_user_id_fk", + "tableFrom": "referral_link", + "tableTo": "user", + "columnsFrom": [ + "ambassador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_link_code_unique": { + "name": "referral_link_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_signup": { + "name": "referral_signup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "referral_link_id": { + "name": "referral_link_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_signup_unique_idx": { + "name": "referral_signup_unique_idx", + "columns": [ + { + "expression": "referral_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_signup_referral_link_id_referral_link_id_fk": { + "name": "referral_signup_referral_link_id_referral_link_id_fk", + "tableFrom": "referral_signup", + "tableTo": "referral_link", + "columnsFrom": [ + "referral_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_signup_user_id_user_id_fk": { + "name": "referral_signup_user_id_user_id_fk", + "tableFrom": "referral_signup", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hack_club_id": { + "name": "hack_club_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_id": { + "name": "slack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verification_status": { + "name": "verification_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ysws_eligible": { + "name": "ysws_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_hack_club_id_unique": { + "name": "user_hack_club_id_unique", + "nullsNotDistinct": false, + "columns": [ + "hack_club_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_pathway": { + "name": "user_pathway", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_pathway_unique_idx": { + "name": "user_pathway_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_pathway_user_id_user_id_fk": { + "name": "user_pathway_user_id_user_id_fk", + "tableFrom": "user_pathway", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.warehouse_item": { + "name": "warehouse_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sizing": { + "name": "sizing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "warehouse_item_sku_unique": { + "name": "warehouse_item_sku_unique", + "nullsNotDistinct": false, + "columns": [ + "sku" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weekly_ship": { + "name": "weekly_ship", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "week_number": { + "name": "week_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_text": { + "name": "goal_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ship_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PLANNED'" + }, + "proof_url": { + "name": "proof_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipped_at": { + "name": "shipped_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ship_user_season_week_idx": { + "name": "ship_user_season_week_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "weekly_ship_user_id_user_id_fk": { + "name": "weekly_ship_user_id_user_id_fk", + "tableFrom": "weekly_ship", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weekly_ship_season_id_program_season_id_fk": { + "name": "weekly_ship_season_id_program_season_id_fk", + "tableFrom": "weekly_ship", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weekly_ship_workshop_id_workshop_id_fk": { + "name": "weekly_ship_workshop_id_workshop_id_fk", + "tableFrom": "weekly_ship", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workshop": { + "name": "workshop", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "estimated_hours": { + "name": "estimated_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workshop_author_id_user_id_fk": { + "name": "workshop_author_id_user_id_fk", + "tableFrom": "workshop", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workshop_season_id_program_season_id_fk": { + "name": "workshop_season_id_program_season_id_fk", + "tableFrom": "workshop", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workshop_analytics": { + "name": "workshop_analytics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "starts": { + "name": "starts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completions": { + "name": "completions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "avg_completion_mins": { + "name": "avg_completion_mins", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workshop_analytics_workshop_id_workshop_id_fk": { + "name": "workshop_analytics_workshop_id_workshop_id_fk", + "tableFrom": "workshop_analytics", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workshop_analytics_workshop_id_unique": { + "name": "workshop_analytics_workshop_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workshop_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workshop_completion": { + "name": "workshop_completion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_url": { + "name": "project_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "completion_workshop_participant_season_idx": { + "name": "completion_workshop_participant_season_idx", + "columns": [ + { + "expression": "workshop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workshop_completion_workshop_id_workshop_id_fk": { + "name": "workshop_completion_workshop_id_workshop_id_fk", + "tableFrom": "workshop_completion", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workshop_completion_participant_id_user_id_fk": { + "name": "workshop_completion_participant_id_user_id_fk", + "tableFrom": "workshop_completion", + "tableTo": "user", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workshop_completion_season_id_program_season_id_fk": { + "name": "workshop_completion_season_id_program_season_id_fk", + "tableFrom": "workshop_completion", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "BEGINNER", + "INTERMEDIATE", + "ADVANCED" + ] + }, + "public.enrollment_role": { + "name": "enrollment_role", + "schema": "public", + "values": [ + "PARTICIPANT", + "AMBASSADOR" + ] + }, + "public.enrollment_status": { + "name": "enrollment_status", + "schema": "public", + "values": [ + "ACTIVE", + "DROPPED", + "COMPLETED" + ] + }, + "public.pathway": { + "name": "pathway", + "schema": "public", + "values": [ + "PYTHON", + "WEB_DEV", + "GAME_DEV", + "HARDWARE", + "DESIGN", + "GENERAL_CODING" + ] + }, + "public.payout_status": { + "name": "payout_status", + "schema": "public", + "values": [ + "DRAFT", + "PENDING", + "PAID", + "CANCELED" + ] + }, + "public.ship_status": { + "name": "ship_status", + "schema": "public", + "values": [ + "PLANNED", + "IN_PROGRESS", + "SHIPPED", + "MISSED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/resolution-frontend/drizzle/meta/_journal.json b/resolution-frontend/drizzle/meta/_journal.json index ab2d7e0..5fa43ce 100644 --- a/resolution-frontend/drizzle/meta/_journal.json +++ b/resolution-frontend/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1771512581182, "tag": "0001_burly_caretaker", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1772206185519, + "tag": "0002_violet_nighthawk", + "breakpoints": true } ] } \ No newline at end of file diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index 693c001..cbca2de 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -278,9 +278,10 @@ export const warehouseItem = pgTable('warehouse_item', { name: text('name').notNull(), sku: text('sku').notNull().unique(), sizing: text('sizing'), - weightOz: real('weight_oz').notNull(), + weightGrams: real('weight_grams').notNull(), costCents: integer('cost_cents').notNull(), quantity: integer('quantity').notNull().default(0), + imageUrl: text('image_url'), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow() }); diff --git a/resolution-frontend/src/routes/app/warehouse/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/+page.server.ts index e0aa77a..85311f4 100644 --- a/resolution-frontend/src/routes/app/warehouse/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/+page.server.ts @@ -3,6 +3,10 @@ import { db } from '$lib/server/db'; import { warehouseItem, ambassadorPathway } from '$lib/server/db/schema'; import { eq, desc } from 'drizzle-orm'; import { error, fail } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; + +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +const MAX_SIZE = 5 * 1024 * 1024; // 5MB export const load: PageServerLoad = async ({ parent }) => { const { user } = await parent(); @@ -40,22 +44,57 @@ export const actions: Actions = { const name = formData.get('name') as string; const sku = formData.get('sku') as string; const sizing = formData.get('sizing') as string | null; - const weightOz = parseFloat(formData.get('weightOz') as string); + const weightGrams = parseFloat(formData.get('weightGrams') as string); const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); const quantity = parseInt(formData.get('quantity') as string) || 0; + const imageFile = formData.get('image') as File | null; - if (!name || !sku || isNaN(weightOz) || isNaN(costCents)) { + if (!name || !sku || isNaN(weightGrams) || isNaN(costCents)) { return fail(400, { error: 'Name, SKU, weight, and cost are required' }); } + let imageUrl: string | null = null; + + if (imageFile && imageFile.size > 0) { + if (!ALLOWED_TYPES.includes(imageFile.type)) { + return fail(400, { error: 'Image must be JPEG, PNG, GIF, or WebP' }); + } + if (imageFile.size > MAX_SIZE) { + return fail(400, { error: 'Image must be under 5MB' }); + } + + const cdnKey = env.HACK_CLUB_CDN_API_KEY; + if (!cdnKey) { + return fail(500, { error: 'CDN not configured' }); + } + + const uploadForm = new FormData(); + uploadForm.append('file', imageFile); + + const cdnResponse = await fetch('https://cdn.hackclub.com/api/v4/upload', { + method: 'POST', + headers: { 'Authorization': `Bearer ${cdnKey}` }, + body: uploadForm + }); + + if (!cdnResponse.ok) { + const cdnError = await cdnResponse.json().catch(() => ({})); + return fail(500, { error: cdnError.error || 'Failed to upload image' }); + } + + const cdnResult = await cdnResponse.json(); + imageUrl = cdnResult.url; + } + try { await db.insert(warehouseItem).values({ name, sku, sizing: sizing || null, - weightOz, + weightGrams, costCents, - quantity + quantity, + imageUrl }); } catch { return fail(400, { error: 'SKU already exists' }); diff --git a/resolution-frontend/src/routes/app/warehouse/+page.svelte b/resolution-frontend/src/routes/app/warehouse/+page.svelte index 56faf8b..b5ffb00 100644 --- a/resolution-frontend/src/routes/app/warehouse/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/+page.svelte @@ -8,10 +8,26 @@ let showAddForm = $state(false); let isSubmitting = $state(false); let confirmDelete = $state(null); + let imagePreview = $state(null); + let expandedImage = $state(null); function formatCost(cents: number) { return `$${(cents / 100).toFixed(2)}`; } + + function handleImageChange(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = () => { + imagePreview = reader.result as string; + }; + reader.readAsDataURL(file); + } else { + imagePreview = null; + } + } @@ -32,7 +48,7 @@

Inventory management

{#if data.isAdmin} - @@ -46,12 +62,16 @@
{ isSubmitting = true; return async ({ update, result }) => { await update(); isSubmitting = false; - if (result.type === 'success') showAddForm = false; + if (result.type === 'success') { + showAddForm = false; + imagePreview = null; + } }; }} > @@ -69,8 +89,8 @@
- - + +
@@ -80,6 +100,13 @@
+
+ + + {#if imagePreview} + Preview + {/if} +
- {/if} - - - +

Redirecting...

diff --git a/resolution-frontend/src/routes/app/warehouse/batches/+page.svelte b/resolution-frontend/src/routes/app/warehouse/batches/+page.svelte new file mode 100644 index 0000000..b502a84 --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/batches/+page.svelte @@ -0,0 +1,14 @@ +
+

Batches coming soon.

+
+ + diff --git a/resolution-frontend/src/routes/app/warehouse/order-templates/+page.svelte b/resolution-frontend/src/routes/app/warehouse/order-templates/+page.svelte new file mode 100644 index 0000000..8281f2a --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/order-templates/+page.svelte @@ -0,0 +1,14 @@ +
+

Order Templates coming soon.

+
+ + diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte new file mode 100644 index 0000000..28601b7 --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte @@ -0,0 +1,14 @@ +
+

Orders coming soon.

+
+ + From 2be285860e8fdb2e6dfd7a17eb92d01e121793f1 Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 2 Mar 2026 11:18:49 -0500 Subject: [PATCH 014/180] Add category rename, reorder, and editCategory server action --- .../app/warehouse/items/+page.server.ts | 27 ++++++ .../routes/app/warehouse/items/+page.svelte | 87 ++++++++++++++++--- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts index 5b2fee3..f369685 100644 --- a/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts @@ -58,6 +58,33 @@ export const actions: Actions = { return { success: true }; }, + editCategory: async ({ request, locals }) => { + if (!locals.user?.isAdmin) { + return fail(403, { error: 'Only admins can edit categories' }); + } + + const formData = await request.formData(); + const categoryId = formData.get('categoryId') as string; + const name = (formData.get('categoryName') as string)?.trim(); + const sortOrder = parseInt(formData.get('sortOrder') as string); + + if (!categoryId) { + return fail(400, { error: 'Category ID required' }); + } + + const updateData: Record = {}; + if (name) updateData.name = name; + if (!isNaN(sortOrder)) updateData.sortOrder = sortOrder; + + if (Object.keys(updateData).length === 0) { + return fail(400, { error: 'Nothing to update' }); + } + + await db.update(warehouseCategory).set(updateData).where(eq(warehouseCategory.id, categoryId)); + + return { success: true }; + }, + deleteCategory: async ({ request, locals }) => { if (!locals.user?.isAdmin) { return fail(403, { error: 'Only admins can delete categories' }); diff --git a/resolution-frontend/src/routes/app/warehouse/items/+page.svelte b/resolution-frontend/src/routes/app/warehouse/items/+page.svelte index 63aa594..49de5dc 100644 --- a/resolution-frontend/src/routes/app/warehouse/items/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/items/+page.svelte @@ -17,6 +17,8 @@ let editOptions = $state(['']); let showCategoryForm = $state(false); let confirmDeleteCategory = $state(null); + let editingCategory = $state(null); + let editCategoryName = $state(''); function formatCost(cents: number) { return `$${(cents / 100).toFixed(2)}`; @@ -111,28 +113,60 @@ {#if data.categories.length > 0}
    - {#each data.categories as cat (cat.id)} + {#each data.categories as cat, i (cat.id)}
  • - {cat.name} - {#if confirmDeleteCategory === cat.id} -
    { + {#if editingCategory === cat.id} + { return async ({ update }) => { await update(); - confirmDeleteCategory = null; + editingCategory = null; }; - }} class="inline-form"> + }} class="category-edit-form"> - - + + + +
    {:else} - + {cat.name} + {/if} + {#if editingCategory !== cat.id} +
    +
    + + + 0 ? data.categories[i - 1].sortOrder - 1 : cat.sortOrder - 1} /> + +
    +
    + + + + +
    + + {#if confirmDeleteCategory === cat.id} +
    { + return async ({ update }) => { + await update(); + confirmDeleteCategory = null; + }; + }} class="inline-form"> + + + +
    + {:else} + + {/if} +
    {/if}
  • {/each}
{:else} -

No categories yet.

+

No categories yet. Add one above, then assign items to it.

{/if} {/if} @@ -500,6 +534,39 @@ display: inline; } + .category-actions { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .category-edit-form { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + } + + .category-name-input { + flex: 1; + padding: 0.375rem 0.625rem; + border: 1px solid #af98ff; + border-radius: 8px; + font-family: inherit; + font-size: 0.9rem; + } + + .move-btn { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + line-height: 1; + } + + .move-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + .category-heading { font-size: 1rem; font-weight: 600; From 187ade921764e31cd4ee868a05b82de9733bcec8 Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 2 Mar 2026 11:19:46 -0500 Subject: [PATCH 015/180] Remove 'steal their prizes' text from hero description --- resolution-frontend/src/routes/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/+page.svelte b/resolution-frontend/src/routes/+page.svelte index 9dbbee9..979d0da 100644 --- a/resolution-frontend/src/routes/+page.svelte +++ b/resolution-frontend/src/routes/+page.svelte @@ -8,7 +8,7 @@
Date: Mon, 2 Mar 2026 16:53:32 -0500 Subject: [PATCH 016/180] Add flat package type for warehouse items and orders page with shipping estimation --- .../0002_add_package_type_and_orders.sql | 43 ++ .../src/lib/server/db/schema.ts | 48 ++ .../src/lib/server/validation/schemas.ts | 8 +- .../app/warehouse/items/+page.server.ts | 12 +- .../routes/app/warehouse/items/+page.svelte | 41 +- .../app/warehouse/orders/+page.server.ts | 352 ++++++++++ .../routes/app/warehouse/orders/+page.svelte | 624 +++++++++++++++++- 7 files changed, 1110 insertions(+), 18 deletions(-) create mode 100644 resolution-frontend/drizzle/0002_add_package_type_and_orders.sql create mode 100644 resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts diff --git a/resolution-frontend/drizzle/0002_add_package_type_and_orders.sql b/resolution-frontend/drizzle/0002_add_package_type_and_orders.sql new file mode 100644 index 0000000..edccf3d --- /dev/null +++ b/resolution-frontend/drizzle/0002_add_package_type_and_orders.sql @@ -0,0 +1,43 @@ +ALTER TABLE "warehouse_item" ADD COLUMN "package_type" text DEFAULT 'box' NOT NULL; +--> statement-breakpoint +CREATE TYPE "warehouse_order_status" AS ENUM ('DRAFT', 'ESTIMATED', 'APPROVED', 'SHIPPED', 'CANCELED'); +--> statement-breakpoint +CREATE TABLE "warehouse_order" ( + "id" text PRIMARY KEY NOT NULL, + "created_by_id" text NOT NULL, + "status" "warehouse_order_status" DEFAULT 'DRAFT' NOT NULL, + "first_name" text NOT NULL, + "last_name" text NOT NULL, + "email" text NOT NULL, + "phone" text, + "address_line_1" text NOT NULL, + "address_line_2" text, + "city" text NOT NULL, + "state_province" text NOT NULL, + "postal_code" text, + "country" text NOT NULL, + "estimated_shipping_cents" integer, + "estimated_service_name" text, + "estimated_package_type" text, + "estimated_total_length_in" real, + "estimated_total_width_in" real, + "estimated_total_height_in" real, + "estimated_total_weight_grams" real, + "notes" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "warehouse_order_item" ( + "id" text PRIMARY KEY NOT NULL, + "order_id" text NOT NULL, + "warehouse_item_id" text NOT NULL, + "quantity" integer DEFAULT 1 NOT NULL, + "sizing_choice" text +); +--> statement-breakpoint +ALTER TABLE "warehouse_order" ADD CONSTRAINT "warehouse_order_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "warehouse_order_item" ADD CONSTRAINT "warehouse_order_item_order_id_warehouse_order_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."warehouse_order"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "warehouse_order_item" ADD CONSTRAINT "warehouse_order_item_warehouse_item_id_warehouse_item_id_fk" FOREIGN KEY ("warehouse_item_id") REFERENCES "public"."warehouse_item"("id") ON DELETE restrict ON UPDATE no action; diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index 809de11..6be171d 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -9,6 +9,7 @@ export const pathwayEnum = pgEnum('pathway', ['PYTHON', 'WEB_DEV', 'GAME_DEV', ' export const difficultyEnum = pgEnum('difficulty', ['BEGINNER', 'INTERMEDIATE', 'ADVANCED']); export const shipStatusEnum = pgEnum('ship_status', ['PLANNED', 'IN_PROGRESS', 'SHIPPED', 'MISSED']); export const payoutStatusEnum = pgEnum('payout_status', ['DRAFT', 'PENDING', 'PAID', 'CANCELED']); +export const warehouseOrderStatusEnum = pgEnum('warehouse_order_status', ['DRAFT', 'ESTIMATED', 'APPROVED', 'SHIPPED', 'CANCELED']); // Tables export const user = pgTable('user', { @@ -287,6 +288,7 @@ export const warehouseItem = pgTable('warehouse_item', { name: text('name').notNull(), sku: text('sku').notNull().unique(), sizing: text('sizing'), + packageType: text('package_type').notNull().default('box'), lengthIn: real('length_in').notNull(), widthIn: real('width_in').notNull(), heightIn: real('height_in').notNull(), @@ -297,3 +299,49 @@ export const warehouseItem = pgTable('warehouse_item', { createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow() }); + +// Warehouse orders +export const warehouseOrder = pgTable('warehouse_order', { + id: text('id').primaryKey().$defaultFn(() => createId()), + createdById: text('created_by_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + status: warehouseOrderStatusEnum('status').notNull().default('DRAFT'), + firstName: text('first_name').notNull(), + lastName: text('last_name').notNull(), + email: text('email').notNull(), + phone: text('phone'), + addressLine1: text('address_line_1').notNull(), + addressLine2: text('address_line_2'), + city: text('city').notNull(), + stateProvince: text('state_province').notNull(), + postalCode: text('postal_code'), + country: text('country').notNull(), + estimatedShippingCents: integer('estimated_shipping_cents'), + estimatedServiceName: text('estimated_service_name'), + estimatedPackageType: text('estimated_package_type'), + estimatedTotalLengthIn: real('estimated_total_length_in'), + estimatedTotalWidthIn: real('estimated_total_width_in'), + estimatedTotalHeightIn: real('estimated_total_height_in'), + estimatedTotalWeightGrams: real('estimated_total_weight_grams'), + notes: text('notes'), + createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow() +}); + +// Warehouse order line items +export const warehouseOrderItem = pgTable('warehouse_order_item', { + id: text('id').primaryKey().$defaultFn(() => createId()), + orderId: text('order_id').notNull().references(() => warehouseOrder.id, { onDelete: 'cascade' }), + warehouseItemId: text('warehouse_item_id').notNull().references(() => warehouseItem.id, { onDelete: 'restrict' }), + quantity: integer('quantity').notNull().default(1), + sizingChoice: text('sizing_choice') +}); + +export const warehouseOrderRelations = relations(warehouseOrder, ({ one, many }) => ({ + createdBy: one(user, { fields: [warehouseOrder.createdById], references: [user.id] }), + items: many(warehouseOrderItem) +})); + +export const warehouseOrderItemRelations = relations(warehouseOrderItem, ({ one }) => ({ + order: one(warehouseOrder, { fields: [warehouseOrderItem.orderId], references: [warehouseOrder.id] }), + warehouseItem: one(warehouseItem, { fields: [warehouseOrderItem.warehouseItemId], references: [warehouseItem.id] }) +})); diff --git a/resolution-frontend/src/lib/server/validation/schemas.ts b/resolution-frontend/src/lib/server/validation/schemas.ts index 80ab09a..d2b893b 100644 --- a/resolution-frontend/src/lib/server/validation/schemas.ts +++ b/resolution-frontend/src/lib/server/validation/schemas.ts @@ -42,6 +42,12 @@ const envelopeSchema = z.object({ width: z.number().positive('Width must be positive') }); +const flatSchema = z.object({ + packageType: z.literal('flat'), + length: z.number().positive('Length must be positive'), + width: z.number().positive('Width must be positive') +}); + const boxSchema = z.object({ packageType: z.literal('box'), length: z.number().positive('Length must be positive'), @@ -56,7 +62,7 @@ export const shippingRateSchema = z.object({ province: z.string().min(1, 'Province/State is required'), postalCode: z.string().optional(), weight: z.number().positive('Weight must be positive') -}).and(z.discriminatedUnion('packageType', [envelopeSchema, boxSchema])); +}).and(z.discriminatedUnion('packageType', [envelopeSchema, flatSchema, boxSchema])); export type ShippingRateInput = z.infer; export type CreateShipInput = z.infer; diff --git a/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts index f369685..5f2e752 100644 --- a/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts @@ -112,15 +112,16 @@ export const actions: Actions = { const sku = formData.get('sku') as string; const categoryId = formData.get('categoryId') as string | null; const sizing = formData.get('sizing') as string | null; + const packageType = (formData.get('packageType') as string) || 'box'; const lengthIn = parseFloat(formData.get('lengthIn') as string); const widthIn = parseFloat(formData.get('widthIn') as string); - const heightIn = parseFloat(formData.get('heightIn') as string); + const heightIn = packageType === 'flat' ? 0 : parseFloat(formData.get('heightIn') as string); const weightGrams = parseFloat(formData.get('weightGrams') as string); const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); const quantity = parseInt(formData.get('quantity') as string) || 0; const imageFile = formData.get('image') as File | null; - if (!name || !sku || isNaN(lengthIn) || isNaN(widthIn) || isNaN(heightIn) || isNaN(weightGrams) || isNaN(costCents)) { + if (!name || !sku || isNaN(lengthIn) || isNaN(widthIn) || (packageType !== 'flat' && isNaN(heightIn)) || isNaN(weightGrams) || isNaN(costCents)) { return fail(400, { error: 'Name, SKU, dimensions, weight, and cost are required' }); } @@ -163,6 +164,7 @@ export const actions: Actions = { sku, categoryId: categoryId || null, sizing: sizing || null, + packageType, lengthIn, widthIn, heightIn, @@ -189,16 +191,17 @@ export const actions: Actions = { const sku = formData.get('sku') as string; const categoryId = formData.get('categoryId') as string | null; const sizing = formData.get('sizing') as string | null; + const packageType = (formData.get('packageType') as string) || 'box'; const lengthIn = parseFloat(formData.get('lengthIn') as string); const widthIn = parseFloat(formData.get('widthIn') as string); - const heightIn = parseFloat(formData.get('heightIn') as string); + const heightIn = packageType === 'flat' ? 0 : parseFloat(formData.get('heightIn') as string); const weightGrams = parseFloat(formData.get('weightGrams') as string); const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); const quantity = parseInt(formData.get('quantity') as string) || 0; const imageFile = formData.get('image') as File | null; const shouldRemoveImage = formData.get('removeImage') === 'true'; - if (!itemId || !name || !sku || isNaN(lengthIn) || isNaN(widthIn) || isNaN(heightIn) || isNaN(weightGrams) || isNaN(costCents)) { + if (!itemId || !name || !sku || isNaN(lengthIn) || isNaN(widthIn) || (packageType !== 'flat' && isNaN(heightIn)) || isNaN(weightGrams) || isNaN(costCents)) { return fail(400, { error: 'Item ID, name, SKU, dimensions, weight, and cost are required' }); } @@ -243,6 +246,7 @@ export const actions: Actions = { sku, categoryId: categoryId || null, sizing: sizing || null, + packageType, lengthIn, widthIn, heightIn, diff --git a/resolution-frontend/src/routes/app/warehouse/items/+page.svelte b/resolution-frontend/src/routes/app/warehouse/items/+page.svelte index 49de5dc..5558042 100644 --- a/resolution-frontend/src/routes/app/warehouse/items/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/items/+page.svelte @@ -6,6 +6,8 @@ let showAddForm = $state(false); let isSubmitting = $state(false); + let addPackageType = $state('box'); + let editPackageType = $state('box'); let confirmDelete = $state(null); let imagePreview = $state(null); let expandedImage = $state(null); @@ -187,6 +189,7 @@ showAddForm = false; imagePreview = null; addOptions = ['']; + addPackageType = 'box'; } }; }} @@ -222,6 +225,13 @@ {/each}
+
+ + +
@@ -230,10 +240,12 @@
-
- - -
+ {#if addPackageType !== 'flat'} +
+ + +
+ {/if}
@@ -309,7 +321,7 @@ {item.name} {item.sku} {item.sizing || '—'} - {item.lengthIn}×{item.widthIn}×{item.heightIn} in + {item.packageType === 'flat' ? `${item.lengthIn}×${item.widthIn} in (flat)` : `${item.lengthIn}×${item.widthIn}×${item.heightIn} in`} {item.weightGrams} g {formatCost(item.costCents)} {item.quantity} @@ -327,7 +339,7 @@ {:else} - + {/if} @@ -384,6 +396,13 @@ {/each}
+
+ + +
@@ -392,10 +411,12 @@
-
- - -
+ {#if editPackageType !== 'flat'} +
+ + +
+ {/if}
diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts new file mode 100644 index 0000000..993485d --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts @@ -0,0 +1,352 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { warehouseItem, warehouseOrder, warehouseOrderItem, ambassadorPathway } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + const ambassadorCheck = await db + .select({ userId: ambassadorPathway.userId }) + .from(ambassadorPathway) + .where(eq(ambassadorPathway.userId, user.id)) + .limit(1); + + const isAmbassador = ambassadorCheck.length > 0; + + if (!user.isAdmin && !isAmbassador) { + throw error(403, 'Access denied'); + } + + const items = await db + .select() + .from(warehouseItem) + .orderBy(warehouseItem.name); + + const orders = await db.query.warehouseOrder.findMany({ + with: { + createdBy: true, + items: { + with: { + warehouseItem: true + } + } + }, + orderBy: [desc(warehouseOrder.createdAt)] + }); + + return { + items, + orders, + isAdmin: user.isAdmin + }; +}; + +/** + * Estimates combined package dimensions for a set of items. + * + * Algorithm: "Flat-stack with bounding box" + * + * 1. Separate items into flats (postcards, stickers) and boxes. + * 2. For flats: stack them — length and width are the max across all flats, + * height is the sum of all flat thicknesses (0.1 in each since they're ~paper). + * 3. For boxes: stack them on their largest face — length and width are the max + * across all boxes, height is the sum of all box heights. + * 4. Combine: the final bounding box uses the max length and width of both groups, + * and the summed height of both groups. + * 5. Weight is always the simple sum of all item weights × quantities. + * 6. If the total height is ≤ 0.5 in and all items are flats, the package is a flat/envelope. + * Otherwise it's a box. + */ +function estimatePackageDimensions( + orderItems: Array<{ quantity: number; item: { packageType: string; lengthIn: number; widthIn: number; heightIn: number; weightGrams: number } }> +) { + let maxLength = 0; + let maxWidth = 0; + let totalHeight = 0; + let totalWeight = 0; + let allFlats = true; + + for (const oi of orderItems) { + const { item, quantity } = oi; + const l = Math.max(item.lengthIn, item.widthIn); + const w = Math.min(item.lengthIn, item.widthIn); + const h = item.packageType === 'flat' ? 0.1 : item.heightIn; + + if (item.packageType !== 'flat') allFlats = false; + + maxLength = Math.max(maxLength, l); + maxWidth = Math.max(maxWidth, w); + totalHeight += h * quantity; + totalWeight += item.weightGrams * quantity; + } + + totalHeight = Math.round(totalHeight * 100) / 100; + totalWeight = Math.round(totalWeight * 100) / 100; + + const packageType = allFlats && totalHeight <= 0.5 ? 'flat' : 'box'; + + return { + lengthIn: maxLength, + widthIn: maxWidth, + heightIn: packageType === 'flat' ? 0 : Math.max(totalHeight, 0.5), + weightGrams: totalWeight, + packageType + }; +} + +export const actions: Actions = { + createOrder: async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const ambassadorCheck = await db + .select({ userId: ambassadorPathway.userId }) + .from(ambassadorPathway) + .where(eq(ambassadorPathway.userId, locals.user.id)) + .limit(1); + + if (!locals.user.isAdmin && ambassadorCheck.length === 0) { + return fail(403, { error: 'Access denied' }); + } + + const formData = await request.formData(); + const firstName = (formData.get('firstName') as string)?.trim(); + const lastName = (formData.get('lastName') as string)?.trim(); + const email = (formData.get('email') as string)?.trim(); + const phone = (formData.get('phone') as string)?.trim() || null; + const addressLine1 = (formData.get('addressLine1') as string)?.trim(); + const addressLine2 = (formData.get('addressLine2') as string)?.trim() || null; + const city = (formData.get('city') as string)?.trim(); + const stateProvince = (formData.get('stateProvince') as string)?.trim(); + const postalCode = (formData.get('postalCode') as string)?.trim() || null; + const country = (formData.get('country') as string)?.trim().toUpperCase(); + const notes = (formData.get('notes') as string)?.trim() || null; + + if (!firstName || !lastName || !email || !addressLine1 || !city || !stateProvince || !country) { + return fail(400, { error: 'First name, last name, email, address line 1, city, state/province, and country are required' }); + } + + // Parse items from form: itemId_0, qty_0, sizing_0, ... + const orderItemsList: Array<{ warehouseItemId: string; quantity: number; sizingChoice: string | null }> = []; + let i = 0; + while (formData.has(`itemId_${i}`)) { + const warehouseItemId = formData.get(`itemId_${i}`) as string; + const qty = parseInt(formData.get(`qty_${i}`) as string) || 1; + const sizing = (formData.get(`sizing_${i}`) as string)?.trim() || null; + if (warehouseItemId && qty > 0) { + orderItemsList.push({ warehouseItemId, quantity: qty, sizingChoice: sizing }); + } + i++; + } + + if (orderItemsList.length === 0) { + return fail(400, { error: 'At least one item is required' }); + } + + // Look up the actual warehouse items for dimension estimation + const itemIds = orderItemsList.map(oi => oi.warehouseItemId); + const warehouseItems = await db + .select() + .from(warehouseItem) + .where(eq(warehouseItem.id, itemIds[0])); + + // Fetch all needed items + const allItems = await db.select().from(warehouseItem); + const itemMap = new Map(allItems.map(it => [it.id, it])); + + const dimensionInput = orderItemsList.map(oi => ({ + quantity: oi.quantity, + item: itemMap.get(oi.warehouseItemId)! + })).filter(oi => oi.item); + + if (dimensionInput.length !== orderItemsList.length) { + return fail(400, { error: 'One or more items not found' }); + } + + const dims = estimatePackageDimensions(dimensionInput); + + // Estimate shipping by calling our own shipping-rates endpoint logic + let estimatedShippingCents: number | null = null; + let estimatedServiceName: string | null = null; + try { + const { env } = await import('$env/dynamic/private'); + const originPostal = env.CP_ORIGIN_POSTAL_CODE; + if (originPostal && env.CP_API_USERNAME && env.CP_API_PASSWORD && env.CP_CUSTOMER_NUMBER) { + const INCHES_TO_CM = 2.54; + const inchesToCm = (v: number) => Math.round(v * INCHES_TO_CM * 10) / 10; + + const lengthCm = inchesToCm(dims.lengthIn); + const widthCm = inchesToCm(dims.widthIn); + const heightCm = dims.packageType === 'flat' ? 0.5 : inchesToCm(dims.heightIn); + const weightKg = Math.round(dims.weightGrams * 0.001 * 100) / 100; + + // Try lettermail first for small items + const weightGrams = dims.weightGrams; + const lengthMm = lengthCm * 10; + const widthMm = widthCm * 10; + const heightMm = heightCm * 10; + + const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; + const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; + const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; + + if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { + let price: number; + if (country === 'CA') price = 1.75; + else if (country === 'US') price = 2.0; + else price = 3.5; + estimatedShippingCents = Math.round(price * 100); + estimatedServiceName = `Lettermail ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; + } else if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { + let price: number; + if (country === 'CA') { + if (weightGrams <= 100) price = 3.11; + else if (weightGrams <= 200) price = 4.51; + else if (weightGrams <= 300) price = 5.91; + else if (weightGrams <= 400) price = 6.62; + else price = 7.05; + } else if (country === 'US') { + if (weightGrams <= 100) price = 4.51; + else if (weightGrams <= 200) price = 7.16; + else price = 13.38; + } else { + if (weightGrams <= 100) price = 8.08; + else if (weightGrams <= 200) price = 13.38; + else price = 25.8; + } + estimatedShippingCents = Math.round(price * 100); + estimatedServiceName = `Bubble Packet ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; + } + + // If no lettermail option, try Canada Post API for parcel rates + if (estimatedShippingCents === null && weightKg > 0) { + const buildDestinationXML = (c: string, pc?: string | null) => { + if (c === 'CA') return `${(pc ?? '').replace(/\s/g, '').toUpperCase()}`; + else if (c === 'US') return `${(pc ?? '').replace(/\s/g, '')}`; + else if (pc) return `${c}${pc}`; + else return `${c}`; + }; + + const xmlBody = ` + + ${env.CP_CUSTOMER_NUMBER} + ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} + + ${weightKg} + + ${lengthCm} + ${widthCm} + ${heightCm} + + + ${originPostal.replace(/\s/g, '').toUpperCase()} + + ${buildDestinationXML(country, postalCode)} + +`; + + const cpEndpoint = env.CP_ENVIRONMENT === 'production' + ? 'https://soa-gw.canadapost.ca/rs/ship/price' + : 'https://ct.soa-gw.canadapost.ca/rs/ship/price'; + + const authString = btoa(`${env.CP_API_USERNAME}:${env.CP_API_PASSWORD}`); + const response = await fetch(cpEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', + Accept: 'application/vnd.cpc.ship.rate-v4+xml', + Authorization: `Basic ${authString}`, + 'Accept-language': 'en-CA' + }, + body: xmlBody + }); + + if (response.ok) { + // @ts-ignore - xml2js lacks type declarations + const xml2js = await import('xml2js'); + const parser = new xml2js.default.Parser({ explicitArray: false }); + const result = await parser.parseStringPromise(await response.text()); + const priceQuotes = result?.['price-quotes']?.['price-quote']; + if (priceQuotes) { + const quotes = Array.isArray(priceQuotes) ? priceQuotes : [priceQuotes]; + // Pick cheapest option + let cheapest: { due: number; name: string } | null = null; + for (const q of quotes) { + const due = parseFloat(q['price-details']?.due ?? '0'); + if (!cheapest || due < cheapest.due) { + cheapest = { due, name: q['service-name'] }; + } + } + if (cheapest) { + const cadToUsd = 0.73; + const handlingFee = 2.0; + const totalUSD = (cheapest.due + handlingFee) * cadToUsd; + estimatedShippingCents = Math.round(totalUSD * 100); + estimatedServiceName = cheapest.name; + } + } + } + } + } + } catch (err) { + console.error('Shipping estimation failed:', err); + } + + // Create order + const [order] = await db.insert(warehouseOrder).values({ + createdById: locals.user.id, + status: estimatedShippingCents ? 'ESTIMATED' : 'DRAFT', + firstName, + lastName, + email, + phone, + addressLine1, + addressLine2, + city, + stateProvince, + postalCode, + country, + estimatedShippingCents, + estimatedServiceName, + estimatedPackageType: dims.packageType, + estimatedTotalLengthIn: dims.lengthIn, + estimatedTotalWidthIn: dims.widthIn, + estimatedTotalHeightIn: dims.heightIn, + estimatedTotalWeightGrams: dims.weightGrams, + notes + }).returning(); + + // Create order items + for (const oi of orderItemsList) { + await db.insert(warehouseOrderItem).values({ + orderId: order.id, + warehouseItemId: oi.warehouseItemId, + quantity: oi.quantity, + sizingChoice: oi.sizingChoice + }); + } + + return { success: true }; + }, + + deleteOrder: async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + if (!orderId) return fail(400, { error: 'Order ID required' }); + + // Only admins or the creator can delete + const [order] = await db.select().from(warehouseOrder).where(eq(warehouseOrder.id, orderId)); + if (!order) return fail(404, { error: 'Order not found' }); + if (!locals.user.isAdmin && order.createdById !== locals.user.id) { + return fail(403, { error: 'Access denied' }); + } + + await db.delete(warehouseOrderItem).where(eq(warehouseOrderItem.orderId, orderId)); + await db.delete(warehouseOrder).where(eq(warehouseOrder.id, orderId)); + + return { success: true }; + } +}; diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte index 28601b7..9a1841b 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte @@ -1,14 +1,632 @@ -
-

Orders coming soon.

+ + +
+
+{#if showCreateForm} +
+

Create Order

+
{ + isSubmitting = true; + return async ({ update, result }) => { + await update(); + isSubmitting = false; + if (result.type === 'success') { + showCreateForm = false; + orderLines = [{ itemId: '', qty: 1, sizing: '' }]; + } + }; + }} + > +

Recipient

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Address

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Items

+
+ {#each orderLines as line, i} +
+ + + +
+ +
+ {#if line.itemId && getItemSizingOptions(line.itemId).length > 0} +
+ +
+ {/if} +
+ +
+ {#if orderLines.length > 1} + + {/if} +
+ {/each} + +
+ +

Notes

+
+ +
+ + +
+
+{/if} + +{#if data.orders.length === 0} +
+

No orders yet.

+

Click "New Order" to create one.

+
+{:else} +
+
+ + + + + + + + + + + + + + + {#each data.orders as order (order.id)} + + + + + + + + + + + {#if expandedOrder === order.id} + + + + {/if} + {/each} + +
RecipientDestinationItemsEst. ShippingPackageStatusCreatedActions
+ {order.firstName} {order.lastName} +
{order.email} +
+ {order.city}, {order.stateProvince} +
{order.country}{order.postalCode ? ` ${order.postalCode}` : ''} +
+ {order.items.length} item{order.items.length !== 1 ? 's' : ''} +
{order.items.reduce((sum: number, oi: any) => sum + oi.quantity, 0)} total qty +
+ {#if order.estimatedShippingCents} + {formatCost(order.estimatedShippingCents)} +
{order.estimatedServiceName || '—'} + {:else} + Not estimated + {/if} +
+ {#if order.estimatedTotalLengthIn} + {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in +
{order.estimatedTotalWeightGrams}g · {order.estimatedPackageType} + {:else} + + {/if} +
{statusLabel(order.status)}{new Date(order.createdAt).toLocaleDateString()} + + {#if confirmDelete === order.id} +
{ + return async ({ update }) => { await update(); confirmDelete = null; }; + }}> + + + +
+ {:else} + + {/if} +
+
+
+

Recipient

+

{order.firstName} {order.lastName}

+

{order.email}{order.phone ? ` · ${order.phone}` : ''}

+
+
+

Address

+

{order.addressLine1}

+ {#if order.addressLine2}

{order.addressLine2}

{/if} +

{order.city}, {order.stateProvince} {order.postalCode || ''}

+

{order.country}

+
+
+

Items

+ {#each order.items as oi} +

+ {oi.warehouseItem.name} + {#if oi.sizingChoice}({oi.sizingChoice}){/if} + × {oi.quantity} +

+ {/each} +
+
+

Estimated Package

+ {#if order.estimatedTotalLengthIn} +

Dimensions: {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in ({order.estimatedPackageType})

+

Weight: {order.estimatedTotalWeightGrams}g

+ {/if} + {#if order.estimatedShippingCents} +

Shipping: {formatCost(order.estimatedShippingCents)} ({order.estimatedServiceName})

+ {:else} +

Shipping: Not estimated

+ {/if} +
+ {#if order.notes} +
+

Notes

+

{order.notes}

+
+ {/if} +
+
+
+
+{/if} + From a893d9f3e77024cdea4b5c03eb364130ae1234ea Mon Sep 17 00:00:00 2001 From: Jenin Date: Tue, 3 Mar 2026 08:28:44 -0500 Subject: [PATCH 017/180] Increase BODY_SIZE_LIMIT to 10M for image uploads --- resolution-frontend/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resolution-frontend/Dockerfile b/resolution-frontend/Dockerfile index 64f3071..569cfe9 100644 --- a/resolution-frontend/Dockerfile +++ b/resolution-frontend/Dockerfile @@ -47,6 +47,8 @@ EXPOSE 3000 # Set Node environment to production ENV NODE_ENV=production +# Increase body size limit for image uploads (default 512K is too small) +ENV BODY_SIZE_LIMIT=10M # Start the application with migrations CMD ["./entrypoint.sh"] From f3032564a5acd13a7b3116131d7ada197d41cd0c Mon Sep 17 00:00:00 2001 From: Jenin Date: Tue, 3 Mar 2026 08:31:44 -0500 Subject: [PATCH 018/180] Move .github/workflows to repo root and set working-directory --- {resolution-frontend/.github => .github}/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) rename {resolution-frontend/.github => .github}/workflows/ci.yml (74%) diff --git a/resolution-frontend/.github/workflows/ci.yml b/.github/workflows/ci.yml similarity index 74% rename from resolution-frontend/.github/workflows/ci.yml rename to .github/workflows/ci.yml index c3c482f..a605f50 100644 --- a/resolution-frontend/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: +defaults: + run: + working-directory: resolution-frontend + jobs: lint-and-check: runs-on: ubuntu-latest @@ -14,6 +18,7 @@ jobs: with: node-version: 20 cache: npm + cache-dependency-path: resolution-frontend/package-lock.json - run: npm ci - run: npm run check @@ -25,6 +30,7 @@ jobs: with: node-version: 20 cache: npm + cache-dependency-path: resolution-frontend/package-lock.json - run: npm ci - run: npm test -- --coverage @@ -37,5 +43,6 @@ jobs: with: node-version: 20 cache: npm + cache-dependency-path: resolution-frontend/package-lock.json - run: npm ci - run: npm run build From 9a930a89585dbe326bd44f96f024ca8f78bcb5f0 Mon Sep 17 00:00:00 2001 From: Jenin Date: Tue, 3 Mar 2026 08:48:34 -0500 Subject: [PATCH 019/180] feat: show who ordered on warehouse orders, add tags for filtering --- .../src/lib/server/db/schema.ts | 20 +- .../app/warehouse/orders/+page.server.ts | 48 ++++- .../routes/app/warehouse/orders/+page.svelte | 179 ++++++++++++++++-- 3 files changed, 231 insertions(+), 16 deletions(-) diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index 6be171d..7347312 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -149,7 +149,8 @@ export const userRelations = relations(user, ({ many }) => ({ completions: many(workshopCompletion), weeklyShips: many(weeklyShip), payouts: many(ambassadorPayout), - referralLinks: many(referralLink) + referralLinks: many(referralLink), + warehouseOrders: many(warehouseOrder), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -336,12 +337,27 @@ export const warehouseOrderItem = pgTable('warehouse_order_item', { sizingChoice: text('sizing_choice') }); +// Warehouse order tags for filtering +export const warehouseOrderTag = pgTable('warehouse_order_tag', { + id: text('id').primaryKey().$defaultFn(() => createId()), + orderId: text('order_id').notNull().references(() => warehouseOrder.id, { onDelete: 'cascade' }), + tag: text('tag').notNull() +}, (table) => [ + uniqueIndex('warehouse_order_tag_unique_idx').on(table.orderId, table.tag), + index('warehouse_order_tag_tag_idx').on(table.tag) +]); + export const warehouseOrderRelations = relations(warehouseOrder, ({ one, many }) => ({ createdBy: one(user, { fields: [warehouseOrder.createdById], references: [user.id] }), - items: many(warehouseOrderItem) + items: many(warehouseOrderItem), + tags: many(warehouseOrderTag) })); export const warehouseOrderItemRelations = relations(warehouseOrderItem, ({ one }) => ({ order: one(warehouseOrder, { fields: [warehouseOrderItem.orderId], references: [warehouseOrder.id] }), warehouseItem: one(warehouseItem, { fields: [warehouseOrderItem.warehouseItemId], references: [warehouseItem.id] }) })); + +export const warehouseOrderTagRelations = relations(warehouseOrderTag, ({ one }) => ({ + order: one(warehouseOrder, { fields: [warehouseOrderTag.orderId], references: [warehouseOrder.id] }) +})); diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts index 993485d..ee259e3 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts @@ -1,7 +1,7 @@ import type { PageServerLoad, Actions } from './$types'; import { db } from '$lib/server/db'; -import { warehouseItem, warehouseOrder, warehouseOrderItem, ambassadorPathway } from '$lib/server/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { warehouseItem, warehouseOrder, warehouseOrderItem, warehouseOrderTag, ambassadorPathway } from '$lib/server/db/schema'; +import { eq, desc, sql } from 'drizzle-orm'; import { error, fail } from '@sveltejs/kit'; export const load: PageServerLoad = async ({ parent }) => { @@ -31,14 +31,21 @@ export const load: PageServerLoad = async ({ parent }) => { with: { warehouseItem: true } - } + }, + tags: true }, orderBy: [desc(warehouseOrder.createdAt)] }); + const allTags = await db + .selectDistinct({ tag: warehouseOrderTag.tag }) + .from(warehouseOrderTag) + .orderBy(warehouseOrderTag.tag); + return { items, orders, + allTags: allTags.map((t) => t.tag), isAdmin: user.isAdmin }; }; @@ -330,6 +337,41 @@ export const actions: Actions = { return { success: true }; }, + addTag: async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + if (!locals.user.isAdmin) return fail(403, { error: 'Access denied' }); + + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + const tag = (formData.get('tag') as string)?.trim().toLowerCase(); + + if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); + + try { + await db.insert(warehouseOrderTag).values({ orderId, tag }); + } catch { + return fail(400, { error: 'Tag already exists on this order' }); + } + + return { success: true }; + }, + + removeTag: async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + if (!locals.user.isAdmin) return fail(403, { error: 'Access denied' }); + + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + const tag = formData.get('tag') as string; + + if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); + + await db.delete(warehouseOrderTag) + .where(sql`${warehouseOrderTag.orderId} = ${orderId} AND ${warehouseOrderTag.tag} = ${tag}`); + + return { success: true }; + }, + deleteOrder: async ({ request, locals }) => { if (!locals.user) throw error(401, 'Unauthorized'); diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte index 9a1841b..c86855c 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte @@ -11,6 +11,8 @@ let orderLines = $state>([ { itemId: '', qty: 1, sizing: '' } ]); + let newTagInputs = $state>({}); + let activeTagFilter = $state(null); function formatCost(cents: number) { return `$${(cents / 100).toFixed(2)}`; @@ -55,6 +57,17 @@ }; return map[status] || ''; } + + function formatCreatorName(creator: { firstName: string | null; lastName: string | null; email: string }): string { + const name = [creator.firstName, creator.lastName].filter(Boolean).join(' '); + return name || creator.email; + } + + const filteredOrders = $derived( + activeTagFilter + ? data.orders.filter((o: any) => o.tags.some((t: any) => t.tag === activeTagFilter)) + : data.orders + );
@@ -178,7 +191,27 @@ {/if} -{#if data.orders.length === 0} +{#if data.allTags.length > 0} +
+ Filter by tag: +
+ + {#each data.allTags as tag} + + {/each} +
+
+{/if} + +{#if filteredOrders.length === 0}

No orders yet.

Click "New Order" to create one.

@@ -190,22 +223,27 @@ Recipient + Ordered By Destination Items Est. Shipping - Package Status + Tags Created Actions - {#each data.orders as order (order.id)} + {#each filteredOrders as order (order.id)} {order.firstName} {order.lastName}
{order.email} + + {formatCreatorName(order.createdBy)} +
{order.createdBy.email} + {order.city}, {order.stateProvince}
{order.country}{order.postalCode ? ` ${order.postalCode}` : ''} @@ -222,15 +260,36 @@ Not estimated {/if} + {statusLabel(order.status)} - {#if order.estimatedTotalLengthIn} - {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in -
{order.estimatedTotalWeightGrams}g · {order.estimatedPackageType} - {:else} - - {/if} +
+ {#each order.tags as tagObj} + + {tagObj.tag} +
+ + + +
+
+ {/each} +
{ + return async ({ update }) => { + await update(); + newTagInputs[order.id] = ''; + }; + }} class="inline-form"> + + +
+
- {statusLabel(order.status)} {new Date(order.createdAt).toLocaleDateString()} + +
+ +{#if showCategoryForm} +
+

Categories

+
{ + return async ({ update }) => { + await update(); + }; + }} + > +
+ + +
+
+ {#if data.categories.length > 0} +
    + {#each data.categories as cat, i (cat.id)} +
  • + {#if editingCategory === cat.id} +
    { + return async ({ update }) => { + await update(); + editingCategory = null; + }; + }} class="category-edit-form"> + + + + + +
    + {:else} + {cat.name} + {/if} + {#if editingCategory !== cat.id} +
    +
    + + + 0 ? data.categories[i - 1].sortOrder - 1 : cat.sortOrder - 1} /> + +
    +
    + + + + +
    + + {#if confirmDeleteCategory === cat.id} +
    { + return async ({ update }) => { + await update(); + confirmDeleteCategory = null; + }; + }} class="inline-form"> + + + +
    + {:else} + + {/if} +
    + {/if} +
  • + {/each} +
+ {:else} +

No categories yet. Add one above, then assign items to it.

+ {/if} +
+{/if} + +{#if showAddForm} +
+

Add New Item

+
{ + isSubmitting = true; + return async ({ update, result }) => { + await update(); + isSubmitting = false; + if (result.type === 'success') { + showAddForm = false; + imagePreview = null; + addOptions = ['']; + addPackageType = 'box'; + } + }; + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + o.trim()).join(', ')} /> + {#each addOptions as option, i} +
+ + {#if addOptions.length > 1} + + {/if} +
+ {/each} + +
+
+ + +
+
+ + +
+
+ + +
+ {#if addPackageType !== 'flat'} +
+ + +
+ {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + + {#if imagePreview} + Preview + {/if} +
+
+ +
+
+{/if} + +{#if data.items.length === 0} +
+

No items in the warehouse yet.

+

Click "Add Item" to start building your inventory.

+
+{:else} + {#each groupedItems() as group} +
+

{group.category?.name || 'Uncategorized'}

+ {#if group.items.length === 0} +

No items in this category.

+ {:else} +
+ + + + + + + + + + + + + + + + {#each group.items as item (item.id)} + + + + + + + + + + + + {#if editingItem === item.id} + + + + {/if} + {/each} + +
PhotoNameIDOptionsDimensionsWeightCostQtyActions
+ {#if item.imageUrl} + + {:else} + + {/if} + {item.name}{item.sku}{item.sizing || '—'}{item.packageType === 'flat' ? `${item.lengthIn}×${item.widthIn} in (flat)` : `${item.lengthIn}×${item.widthIn}×${item.heightIn} in`}{item.weightGrams} g{formatCost(item.costCents)}{item.quantity} + {#if confirmDelete === item.id} +
{ + return async ({ update }) => { + await update(); + confirmDelete = null; + }; + }}> + + + +
+ {:else} + + + {/if} +
+
{ + isEditSubmitting = true; + return async ({ update, result }) => { + await update(); + isEditSubmitting = false; + if (result.type === 'success') { + editingItem = null; + editImagePreview = null; + } + }; + }} + > + +
+
+ + +
+
+ + +
+
+ + +
+
+ + o.trim()).join(', ')} /> + {#each editOptions as option, i} +
+ + {#if editOptions.length > 1} + + {/if} +
+ {/each} + +
+
+ + +
+
+ + +
+
+ + +
+ {#if editPackageType !== 'flat'} +
+ + +
+ {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + {#if removeImage} + + {/if} + {#if !removeImage} + + {/if} + {#if editImagePreview} +
+ Preview + +
+ {:else if item.imageUrl && !removeImage} +
+ Current + +
+ {:else if removeImage} +

Image will be removed on save.

+ {/if} +
+
+
+ + +
+
+
+
+ {/if} +
+ {/each} +{/if} + +{#if expandedImage} + +{/if} + + diff --git a/resolution-frontend/src/routes/app/warehouse-backend/order-templates/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/order-templates/+page.svelte new file mode 100644 index 0000000..8281f2a --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse-backend/order-templates/+page.svelte @@ -0,0 +1,14 @@ +
+

Order Templates coming soon.

+
+ + diff --git a/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.server.ts new file mode 100644 index 0000000..5847231 --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.server.ts @@ -0,0 +1,352 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { warehouseItem, warehouseOrder, warehouseOrderItem, warehouseOrderTag } from '$lib/server/db/schema'; +import { eq, desc, sql } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; + +/** + * Estimates combined package dimensions for a set of items. + */ +function estimatePackageDimensions( + orderItems: Array<{ quantity: number; item: { packageType: string; lengthIn: number; widthIn: number; heightIn: number; weightGrams: number } }> +) { + let maxLength = 0; + let maxWidth = 0; + let totalHeight = 0; + let totalWeight = 0; + let allFlats = true; + + for (const oi of orderItems) { + const { item, quantity } = oi; + const l = Math.max(item.lengthIn, item.widthIn); + const w = Math.min(item.lengthIn, item.widthIn); + const h = item.packageType === 'flat' ? 0.1 : item.heightIn; + + if (item.packageType !== 'flat') allFlats = false; + + maxLength = Math.max(maxLength, l); + maxWidth = Math.max(maxWidth, w); + totalHeight += h * quantity; + totalWeight += item.weightGrams * quantity; + } + + totalHeight = Math.round(totalHeight * 100) / 100; + totalWeight = Math.round(totalWeight * 100) / 100; + + if (allFlats && totalHeight <= 0.5) { + if (maxLength <= 6 && maxWidth <= 4) { + return { lengthIn: 6, widthIn: 4, heightIn: 0, weightGrams: totalWeight, packageType: 'flat' as const }; + } else if (maxLength <= 9 && maxWidth <= 6) { + return { lengthIn: 9, widthIn: 6, heightIn: 0, weightGrams: totalWeight, packageType: 'flat' as const }; + } else { + return { lengthIn: maxLength, widthIn: maxWidth, heightIn: 0.5, weightGrams: totalWeight, packageType: 'box' as const }; + } + } + + return { + lengthIn: maxLength, + widthIn: maxWidth, + heightIn: Math.max(totalHeight, 0.5), + weightGrams: totalWeight, + packageType: 'box' as const + }; +} + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user.isAdmin) { + throw error(403, 'Access denied'); + } + + const items = await db + .select() + .from(warehouseItem) + .orderBy(warehouseItem.name); + + const orders = await db.query.warehouseOrder.findMany({ + with: { + createdBy: true, + items: { + with: { + warehouseItem: true + } + }, + tags: true + }, + orderBy: [desc(warehouseOrder.createdAt)] + }); + + const allTags = await db + .selectDistinct({ tag: warehouseOrderTag.tag }) + .from(warehouseOrderTag) + .orderBy(warehouseOrderTag.tag); + + return { + items, + orders, + allTags: allTags.map((t) => t.tag) + }; +}; + +export const actions: Actions = { + createOrder: async ({ request, locals }) => { + if (!locals.user?.isAdmin) { + return fail(403, { error: 'Only admins can create orders here' }); + } + + const formData = await request.formData(); + const firstName = (formData.get('firstName') as string)?.trim(); + const lastName = (formData.get('lastName') as string)?.trim(); + const email = (formData.get('email') as string)?.trim(); + const phone = (formData.get('phone') as string)?.trim() || null; + const addressLine1 = (formData.get('addressLine1') as string)?.trim(); + const addressLine2 = (formData.get('addressLine2') as string)?.trim() || null; + const city = (formData.get('city') as string)?.trim(); + const stateProvince = (formData.get('stateProvince') as string)?.trim(); + const postalCode = (formData.get('postalCode') as string)?.trim() || null; + const country = (formData.get('country') as string)?.trim().toUpperCase(); + const notes = (formData.get('notes') as string)?.trim() || null; + + if (!firstName || !lastName || !email || !addressLine1 || !city || !stateProvince || !country) { + return fail(400, { error: 'First name, last name, email, address line 1, city, state/province, and country are required' }); + } + + const orderItemsList: Array<{ warehouseItemId: string; quantity: number; sizingChoice: string | null }> = []; + let i = 0; + while (formData.has(`itemId_${i}`)) { + const warehouseItemId = formData.get(`itemId_${i}`) as string; + const qty = parseInt(formData.get(`qty_${i}`) as string) || 1; + const sizing = (formData.get(`sizing_${i}`) as string)?.trim() || null; + if (warehouseItemId && qty > 0) { + orderItemsList.push({ warehouseItemId, quantity: qty, sizingChoice: sizing }); + } + i++; + } + + if (orderItemsList.length === 0) { + return fail(400, { error: 'At least one item is required' }); + } + + const allItems = await db.select().from(warehouseItem); + const itemMap = new Map(allItems.map(it => [it.id, it])); + + const dimensionInput = orderItemsList.map(oi => ({ + quantity: oi.quantity, + item: itemMap.get(oi.warehouseItemId)! + })).filter(oi => oi.item); + + if (dimensionInput.length !== orderItemsList.length) { + return fail(400, { error: 'One or more items not found' }); + } + + const dims = estimatePackageDimensions(dimensionInput); + + let estimatedShippingCents: number | null = null; + let estimatedServiceName: string | null = null; + try { + const { env } = await import('$env/dynamic/private'); + const originPostal = env.CP_ORIGIN_POSTAL_CODE; + if (originPostal && env.CP_API_USERNAME && env.CP_API_PASSWORD && env.CP_CUSTOMER_NUMBER) { + const INCHES_TO_CM = 2.54; + const inchesToCm = (v: number) => Math.round(v * INCHES_TO_CM * 10) / 10; + + const lengthCm = inchesToCm(dims.lengthIn); + const widthCm = inchesToCm(dims.widthIn); + const heightCm = dims.packageType === 'flat' ? 0.5 : inchesToCm(dims.heightIn); + const weightKg = Math.round(dims.weightGrams * 0.001 * 100) / 100; + + const weightGrams = dims.weightGrams; + const lengthMm = lengthCm * 10; + const widthMm = widthCm * 10; + const heightMm = heightCm * 10; + + const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; + const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; + const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; + + if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { + let price: number; + if (country === 'CA') price = 1.75; + else if (country === 'US') price = 2.0; + else price = 3.5; + estimatedShippingCents = Math.round(price * 100); + estimatedServiceName = `Lettermail ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; + } else if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { + let price: number; + if (country === 'CA') { + if (weightGrams <= 100) price = 3.11; + else if (weightGrams <= 200) price = 4.51; + else if (weightGrams <= 300) price = 5.91; + else if (weightGrams <= 400) price = 6.62; + else price = 7.05; + } else if (country === 'US') { + if (weightGrams <= 100) price = 4.51; + else if (weightGrams <= 200) price = 7.16; + else price = 13.38; + } else { + if (weightGrams <= 100) price = 8.08; + else if (weightGrams <= 200) price = 13.38; + else price = 25.8; + } + estimatedShippingCents = Math.round(price * 100); + estimatedServiceName = `Bubble Packet ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; + } + + if (estimatedShippingCents === null && weightKg > 0) { + const buildDestinationXML = (c: string, pc?: string | null) => { + if (c === 'CA') return `${(pc ?? '').replace(/\s/g, '').toUpperCase()}`; + else if (c === 'US') return `${(pc ?? '').replace(/\s/g, '')}`; + else if (pc) return `${c}${pc}`; + else return `${c}`; + }; + + const xmlBody = ` + + ${env.CP_CUSTOMER_NUMBER} + ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} + + ${weightKg} + + ${lengthCm} + ${widthCm} + ${heightCm} + + + ${originPostal.replace(/\s/g, '').toUpperCase()} + + ${buildDestinationXML(country, postalCode)} + +`; + + const cpEndpoint = env.CP_ENVIRONMENT === 'production' + ? 'https://soa-gw.canadapost.ca/rs/ship/price' + : 'https://ct.soa-gw.canadapost.ca/rs/ship/price'; + + const authString = btoa(`${env.CP_API_USERNAME}:${env.CP_API_PASSWORD}`); + const response = await fetch(cpEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', + Accept: 'application/vnd.cpc.ship.rate-v4+xml', + Authorization: `Basic ${authString}`, + 'Accept-language': 'en-CA' + }, + body: xmlBody + }); + + if (response.ok) { + // @ts-ignore - xml2js lacks type declarations + const xml2js = await import('xml2js'); + const parser = new xml2js.default.Parser({ explicitArray: false }); + const result = await parser.parseStringPromise(await response.text()); + const priceQuotes = result?.['price-quotes']?.['price-quote']; + if (priceQuotes) { + const quotes = Array.isArray(priceQuotes) ? priceQuotes : [priceQuotes]; + let cheapest: { due: number; name: string } | null = null; + for (const q of quotes) { + const due = parseFloat(q['price-details']?.due ?? '0'); + if (!cheapest || due < cheapest.due) { + cheapest = { due, name: q['service-name'] }; + } + } + if (cheapest) { + const cadToUsd = 0.73; + const handlingFee = 2.0; + const totalUSD = (cheapest.due + handlingFee) * cadToUsd; + estimatedShippingCents = Math.round(totalUSD * 100); + estimatedServiceName = cheapest.name; + } + } + } + } + } + } catch (err) { + console.error('Shipping estimation failed:', err); + } + + const [order] = await db.insert(warehouseOrder).values({ + createdById: locals.user.id, + status: estimatedShippingCents ? 'ESTIMATED' : 'DRAFT', + firstName, + lastName, + email, + phone, + addressLine1, + addressLine2, + city, + stateProvince, + postalCode, + country, + estimatedShippingCents, + estimatedServiceName, + estimatedPackageType: dims.packageType, + estimatedTotalLengthIn: dims.lengthIn, + estimatedTotalWidthIn: dims.widthIn, + estimatedTotalHeightIn: dims.heightIn, + estimatedTotalWeightGrams: dims.weightGrams, + notes + }).returning(); + + for (const oi of orderItemsList) { + await db.insert(warehouseOrderItem).values({ + orderId: order.id, + warehouseItemId: oi.warehouseItemId, + quantity: oi.quantity, + sizingChoice: oi.sizingChoice + }); + } + + return { success: true }; + }, + + addTag: async ({ request, locals }) => { + if (!locals.user?.isAdmin) return fail(403, { error: 'Access denied' }); + + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + const tag = (formData.get('tag') as string)?.trim().toLowerCase(); + + if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); + + try { + await db.insert(warehouseOrderTag).values({ orderId, tag }); + } catch { + return fail(400, { error: 'Tag already exists on this order' }); + } + + return { success: true }; + }, + + removeTag: async ({ request, locals }) => { + if (!locals.user?.isAdmin) return fail(403, { error: 'Access denied' }); + + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + const tag = formData.get('tag') as string; + + if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); + + await db.delete(warehouseOrderTag) + .where(sql`${warehouseOrderTag.orderId} = ${orderId} AND ${warehouseOrderTag.tag} = ${tag}`); + + return { success: true }; + }, + + deleteOrder: async ({ request, locals }) => { + if (!locals.user?.isAdmin) return fail(403, { error: 'Access denied' }); + + const formData = await request.formData(); + const orderId = formData.get('orderId') as string; + if (!orderId) return fail(400, { error: 'Order ID required' }); + + const [order] = await db.select().from(warehouseOrder).where(eq(warehouseOrder.id, orderId)); + if (!order) return fail(404, { error: 'Order not found' }); + + await db.delete(warehouseOrderItem).where(eq(warehouseOrderItem.orderId, orderId)); + await db.delete(warehouseOrderTag).where(eq(warehouseOrderTag.orderId, orderId)); + await db.delete(warehouseOrder).where(eq(warehouseOrder.id, orderId)); + + return { success: true }; + } +}; diff --git a/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.svelte new file mode 100644 index 0000000..c86855c --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.svelte @@ -0,0 +1,789 @@ + + +
+ +
+ +{#if showCreateForm} +
+

Create Order

+
{ + isSubmitting = true; + return async ({ update, result }) => { + await update(); + isSubmitting = false; + if (result.type === 'success') { + showCreateForm = false; + orderLines = [{ itemId: '', qty: 1, sizing: '' }]; + } + }; + }} + > +

Recipient

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Address

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Items

+
+ {#each orderLines as line, i} +
+ + + +
+ +
+ {#if line.itemId && getItemSizingOptions(line.itemId).length > 0} +
+ +
+ {/if} +
+ +
+ {#if orderLines.length > 1} + + {/if} +
+ {/each} + +
+ +

Notes

+
+ +
+ + +
+
+{/if} + +{#if data.allTags.length > 0} +
+ Filter by tag: +
+ + {#each data.allTags as tag} + + {/each} +
+
+{/if} + +{#if filteredOrders.length === 0} +
+

No orders yet.

+

Click "New Order" to create one.

+
+{:else} +
+
+ + + + + + + + + + + + + + + + {#each filteredOrders as order (order.id)} + + + + + + + + + + + + {#if expandedOrder === order.id} + + + + {/if} + {/each} + +
RecipientOrdered ByDestinationItemsEst. ShippingStatusTagsCreatedActions
+ {order.firstName} {order.lastName} +
{order.email} +
+ {formatCreatorName(order.createdBy)} +
{order.createdBy.email} +
+ {order.city}, {order.stateProvince} +
{order.country}{order.postalCode ? ` ${order.postalCode}` : ''} +
+ {order.items.length} item{order.items.length !== 1 ? 's' : ''} +
{order.items.reduce((sum: number, oi: any) => sum + oi.quantity, 0)} total qty +
+ {#if order.estimatedShippingCents} + {formatCost(order.estimatedShippingCents)} +
{order.estimatedServiceName || '—'} + {:else} + Not estimated + {/if} +
{statusLabel(order.status)} +
+ {#each order.tags as tagObj} + + {tagObj.tag} +
+ + + +
+
+ {/each} +
{ + return async ({ update }) => { + await update(); + newTagInputs[order.id] = ''; + }; + }} class="inline-form"> + + +
+
+
{new Date(order.createdAt).toLocaleDateString()} + + {#if confirmDelete === order.id} +
{ + return async ({ update }) => { await update(); confirmDelete = null; }; + }}> + + + +
+ {:else} + + {/if} +
+
+
+

Recipient

+

{order.firstName} {order.lastName}

+

{order.email}{order.phone ? ` · ${order.phone}` : ''}

+
+
+

Address

+

{order.addressLine1}

+ {#if order.addressLine2}

{order.addressLine2}

{/if} +

{order.city}, {order.stateProvince} {order.postalCode || ''}

+

{order.country}

+
+
+

Items

+ {#each order.items as oi} +

+ {oi.warehouseItem.name} + {#if oi.sizingChoice}({oi.sizingChoice}){/if} + × {oi.quantity} +

+ {/each} +
+
+

Estimated Package

+ {#if order.estimatedTotalLengthIn} +

Dimensions: {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in ({order.estimatedPackageType})

+

Weight: {order.estimatedTotalWeightGrams}g

+ {/if} + {#if order.estimatedShippingCents} +

Shipping: {formatCost(order.estimatedShippingCents)} ({order.estimatedServiceName})

+ {:else} +

Shipping: Not estimated

+ {/if} +
+ {#if order.notes} +
+

Notes

+

{order.notes}

+
+ {/if} +
+
+
+
+{/if} + + diff --git a/resolution-frontend/src/routes/app/warehouse/+layout.svelte b/resolution-frontend/src/routes/app/warehouse/+layout.svelte index 1611a54..3264975 100644 --- a/resolution-frontend/src/routes/app/warehouse/+layout.svelte +++ b/resolution-frontend/src/routes/app/warehouse/+layout.svelte @@ -6,10 +6,8 @@ let { children }: { children: Snippet } = $props(); const tabs = [ - { label: 'Batches', href: '/app/warehouse/batches' }, { label: 'Items', href: '/app/warehouse/items' }, - { label: 'Orders', href: '/app/warehouse/orders' }, - { label: 'Order Templates', href: '/app/warehouse/order-templates' } + { label: 'Orders', href: '/app/warehouse/orders' } ]; diff --git a/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts index 5f2e752..44b19a9 100644 --- a/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/items/+page.server.ts @@ -1,12 +1,8 @@ -import type { PageServerLoad, Actions } from './$types'; +import type { PageServerLoad } from './$types'; import { db } from '$lib/server/db'; import { warehouseItem, warehouseCategory, ambassadorPathway } from '$lib/server/db/schema'; import { eq, desc, asc } from 'drizzle-orm'; -import { error, fail } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; - -const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; -const MAX_SIZE = 5 * 1024 * 1024; // 5MB +import { error } from '@sveltejs/kit'; export const load: PageServerLoad = async ({ parent }) => { const { user } = await parent(); @@ -35,251 +31,6 @@ export const load: PageServerLoad = async ({ parent }) => { return { items, - categories, - isAdmin: user.isAdmin + categories }; }; - -export const actions: Actions = { - addCategory: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can add categories' }); - } - - const formData = await request.formData(); - const name = (formData.get('categoryName') as string)?.trim(); - - if (!name) { - return fail(400, { error: 'Category name is required' }); - } - - await db.insert(warehouseCategory).values({ name }); - - return { success: true }; - }, - - editCategory: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can edit categories' }); - } - - const formData = await request.formData(); - const categoryId = formData.get('categoryId') as string; - const name = (formData.get('categoryName') as string)?.trim(); - const sortOrder = parseInt(formData.get('sortOrder') as string); - - if (!categoryId) { - return fail(400, { error: 'Category ID required' }); - } - - const updateData: Record = {}; - if (name) updateData.name = name; - if (!isNaN(sortOrder)) updateData.sortOrder = sortOrder; - - if (Object.keys(updateData).length === 0) { - return fail(400, { error: 'Nothing to update' }); - } - - await db.update(warehouseCategory).set(updateData).where(eq(warehouseCategory.id, categoryId)); - - return { success: true }; - }, - - deleteCategory: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can delete categories' }); - } - - const formData = await request.formData(); - const categoryId = formData.get('categoryId') as string; - - if (!categoryId) { - return fail(400, { error: 'Category ID required' }); - } - - await db.delete(warehouseCategory).where(eq(warehouseCategory.id, categoryId)); - - return { success: true }; - }, - - addItem: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can add items' }); - } - - const formData = await request.formData(); - const name = formData.get('name') as string; - const sku = formData.get('sku') as string; - const categoryId = formData.get('categoryId') as string | null; - const sizing = formData.get('sizing') as string | null; - const packageType = (formData.get('packageType') as string) || 'box'; - const lengthIn = parseFloat(formData.get('lengthIn') as string); - const widthIn = parseFloat(formData.get('widthIn') as string); - const heightIn = packageType === 'flat' ? 0 : parseFloat(formData.get('heightIn') as string); - const weightGrams = parseFloat(formData.get('weightGrams') as string); - const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); - const quantity = parseInt(formData.get('quantity') as string) || 0; - const imageFile = formData.get('image') as File | null; - - if (!name || !sku || isNaN(lengthIn) || isNaN(widthIn) || (packageType !== 'flat' && isNaN(heightIn)) || isNaN(weightGrams) || isNaN(costCents)) { - return fail(400, { error: 'Name, SKU, dimensions, weight, and cost are required' }); - } - - let imageUrl: string | null = null; - - if (imageFile && imageFile.size > 0) { - if (!ALLOWED_TYPES.includes(imageFile.type)) { - return fail(400, { error: 'Image must be JPEG, PNG, GIF, or WebP' }); - } - if (imageFile.size > MAX_SIZE) { - return fail(400, { error: 'Image must be under 5MB' }); - } - - const cdnKey = env.HACK_CLUB_CDN_API_KEY; - if (!cdnKey) { - return fail(500, { error: 'CDN not configured' }); - } - - const uploadForm = new FormData(); - uploadForm.append('file', imageFile); - - const cdnResponse = await fetch('https://cdn.hackclub.com/api/v4/upload', { - method: 'POST', - headers: { 'Authorization': `Bearer ${cdnKey}` }, - body: uploadForm - }); - - if (!cdnResponse.ok) { - const cdnError = await cdnResponse.json().catch(() => ({})); - return fail(500, { error: cdnError.error || 'Failed to upload image' }); - } - - const cdnResult = await cdnResponse.json(); - imageUrl = cdnResult.url; - } - - try { - await db.insert(warehouseItem).values({ - name, - sku, - categoryId: categoryId || null, - sizing: sizing || null, - packageType, - lengthIn, - widthIn, - heightIn, - weightGrams, - costCents, - quantity, - imageUrl - }); - } catch { - return fail(400, { error: 'SKU already exists' }); - } - - return { success: true }; - }, - - editItem: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can edit items' }); - } - - const formData = await request.formData(); - const itemId = formData.get('itemId') as string; - const name = formData.get('name') as string; - const sku = formData.get('sku') as string; - const categoryId = formData.get('categoryId') as string | null; - const sizing = formData.get('sizing') as string | null; - const packageType = (formData.get('packageType') as string) || 'box'; - const lengthIn = parseFloat(formData.get('lengthIn') as string); - const widthIn = parseFloat(formData.get('widthIn') as string); - const heightIn = packageType === 'flat' ? 0 : parseFloat(formData.get('heightIn') as string); - const weightGrams = parseFloat(formData.get('weightGrams') as string); - const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); - const quantity = parseInt(formData.get('quantity') as string) || 0; - const imageFile = formData.get('image') as File | null; - const shouldRemoveImage = formData.get('removeImage') === 'true'; - - if (!itemId || !name || !sku || isNaN(lengthIn) || isNaN(widthIn) || (packageType !== 'flat' && isNaN(heightIn)) || isNaN(weightGrams) || isNaN(costCents)) { - return fail(400, { error: 'Item ID, name, SKU, dimensions, weight, and cost are required' }); - } - - let imageUrl: string | undefined | null; - - if (shouldRemoveImage) { - imageUrl = null; - } else if (imageFile && imageFile.size > 0) { - if (!ALLOWED_TYPES.includes(imageFile.type)) { - return fail(400, { error: 'Image must be JPEG, PNG, GIF, or WebP' }); - } - if (imageFile.size > MAX_SIZE) { - return fail(400, { error: 'Image must be under 5MB' }); - } - - const cdnKey = env.HACK_CLUB_CDN_API_KEY; - if (!cdnKey) { - return fail(500, { error: 'CDN not configured' }); - } - - const uploadForm = new FormData(); - uploadForm.append('file', imageFile); - - const cdnResponse = await fetch('https://cdn.hackclub.com/api/v4/upload', { - method: 'POST', - headers: { 'Authorization': `Bearer ${cdnKey}` }, - body: uploadForm - }); - - if (!cdnResponse.ok) { - const cdnError = await cdnResponse.json().catch(() => ({})); - return fail(500, { error: cdnError.error || 'Failed to upload image' }); - } - - const cdnResult = await cdnResponse.json(); - imageUrl = cdnResult.url; - } - - try { - const updateData: Record = { - name, - sku, - categoryId: categoryId || null, - sizing: sizing || null, - packageType, - lengthIn, - widthIn, - heightIn, - weightGrams, - costCents, - quantity, - updatedAt: new Date() - }; - if (imageUrl !== undefined) { - updateData.imageUrl = imageUrl; - } - await db.update(warehouseItem).set(updateData).where(eq(warehouseItem.id, itemId)); - } catch { - return fail(400, { error: 'Failed to update item. SKU may already exist.' }); - } - - return { success: true }; - }, - - deleteItem: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can delete items' }); - } - - const formData = await request.formData(); - const itemId = formData.get('itemId') as string; - - if (!itemId) { - return fail(400, { error: 'Item ID required' }); - } - - await db.delete(warehouseItem).where(eq(warehouseItem.id, itemId)); - - return { success: true }; - } -}; diff --git a/resolution-frontend/src/routes/app/warehouse/items/+page.svelte b/resolution-frontend/src/routes/app/warehouse/items/+page.svelte index 5558042..c96cc9f 100644 --- a/resolution-frontend/src/routes/app/warehouse/items/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/items/+page.svelte @@ -1,59 +1,14 @@ -
- {#if data.isAdmin} - - - {/if} -
- -{#if showCategoryForm && data.isAdmin} -
-

Categories

-
{ - return async ({ update }) => { - await update(); - }; - }} - > -
- - -
-
- {#if data.categories.length > 0} -
    - {#each data.categories as cat, i (cat.id)} -
  • - {#if editingCategory === cat.id} -
    { - return async ({ update }) => { - await update(); - editingCategory = null; - }; - }} class="category-edit-form"> - - - - - -
    - {:else} - {cat.name} - {/if} - {#if editingCategory !== cat.id} -
    -
    - - - 0 ? data.categories[i - 1].sortOrder - 1 : cat.sortOrder - 1} /> - -
    -
    - - - - -
    - - {#if confirmDeleteCategory === cat.id} -
    { - return async ({ update }) => { - await update(); - confirmDeleteCategory = null; - }; - }} class="inline-form"> - - - -
    - {:else} - - {/if} -
    - {/if} -
  • - {/each} -
- {:else} -

No categories yet. Add one above, then assign items to it.

- {/if} -
-{/if} - -{#if showAddForm && data.isAdmin} -
-

Add New Item

-
{ - isSubmitting = true; - return async ({ update, result }) => { - await update(); - isSubmitting = false; - if (result.type === 'success') { - showAddForm = false; - imagePreview = null; - addOptions = ['']; - addPackageType = 'box'; - } - }; - }} - > -
-
- - -
-
- - -
-
- - -
-
- - o.trim()).join(', ')} /> - {#each addOptions as option, i} -
- - {#if addOptions.length > 1} - - {/if} -
- {/each} - -
-
- - -
-
- - -
-
- - -
- {#if addPackageType !== 'flat'} -
- - -
- {/if} -
- - -
-
- - -
-
- - -
-
- - - {#if imagePreview} - Preview - {/if} -
-
- -
-
-{/if} - {#if data.items.length === 0}

No items in the warehouse yet.

- {#if data.isAdmin} -

Click "Add Item" to start building your inventory.

- {:else} -

Items will appear here once an admin adds them.

- {/if} +

Items will appear here once they are added.

{:else} {#each groupedItems() as group} @@ -301,9 +62,6 @@ Weight Cost Qty - {#if data.isAdmin} - Actions - {/if} @@ -325,143 +83,7 @@ {item.weightGrams} g {formatCost(item.costCents)} {item.quantity} - {#if data.isAdmin} - - {#if confirmDelete === item.id} -
{ - return async ({ update }) => { - await update(); - confirmDelete = null; - }; - }}> - - - -
- {:else} - - - {/if} - - {/if} - {#if editingItem === item.id && data.isAdmin} - - -
{ - isEditSubmitting = true; - return async ({ update, result }) => { - await update(); - isEditSubmitting = false; - if (result.type === 'success') { - editingItem = null; - editImagePreview = null; - } - }; - }} - > - -
-
- - -
-
- - -
-
- - -
-
- - o.trim()).join(', ')} /> - {#each editOptions as option, i} -
- - {#if editOptions.length > 1} - - {/if} -
- {/each} - -
-
- - -
-
- - -
-
- - -
- {#if editPackageType !== 'flat'} -
- - -
- {/if} -
- - -
-
- - -
-
- - -
-
- - {#if removeImage} - - {/if} - {#if !removeImage} - - {/if} - {#if editImagePreview} -
- Preview - -
- {:else if item.imageUrl && !removeImage} -
- Current - -
- {:else if removeImage} -

Image will be removed on save.

- {/if} -
-
-
- - -
-
- - - {/if} {/each} @@ -478,116 +100,11 @@ {/if} diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts index 5f661c8..67010d2 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.server.ts @@ -1,8 +1,8 @@ -import type { PageServerLoad, Actions } from './$types'; +import type { PageServerLoad } from './$types'; import { db } from '$lib/server/db'; -import { warehouseItem, warehouseOrder, warehouseOrderItem, warehouseOrderTag, ambassadorPathway } from '$lib/server/db/schema'; -import { eq, desc, sql } from 'drizzle-orm'; -import { error, fail } from '@sveltejs/kit'; +import { warehouseItem, warehouseOrder, warehouseOrderTag, ambassadorPathway } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; export const load: PageServerLoad = async ({ parent }) => { const { user } = await parent(); @@ -45,360 +45,6 @@ export const load: PageServerLoad = async ({ parent }) => { return { items, orders, - allTags: allTags.map((t) => t.tag), - isAdmin: user.isAdmin + allTags: allTags.map((t) => t.tag) }; }; - -/** - * Estimates combined package dimensions for a set of items. - * - * Algorithm: "Flat-stack with bounding box" - * - * 1. Separate items into flats (postcards, stickers) and boxes. - * 2. For flats: stack them — length and width are the max across all flats, - * height is the sum of all flat thicknesses (0.1 in each since they're ~paper). - * 3. For boxes: stack them on their largest face — length and width are the max - * across all boxes, height is the sum of all box heights. - * 4. Combine: the final bounding box uses the max length and width of both groups, - * and the summed height of both groups. - * 5. Weight is always the simple sum of all item weights × quantities. - * 6. If the total height is ≤ 0.5 in and all items are flats, the package is a flat/envelope. - * Otherwise it's a box. - */ -function estimatePackageDimensions( - orderItems: Array<{ quantity: number; item: { packageType: string; lengthIn: number; widthIn: number; heightIn: number; weightGrams: number } }> -) { - let maxLength = 0; - let maxWidth = 0; - let totalHeight = 0; - let totalWeight = 0; - let allFlats = true; - - for (const oi of orderItems) { - const { item, quantity } = oi; - const l = Math.max(item.lengthIn, item.widthIn); - const w = Math.min(item.lengthIn, item.widthIn); - const h = item.packageType === 'flat' ? 0.1 : item.heightIn; - - if (item.packageType !== 'flat') allFlats = false; - - maxLength = Math.max(maxLength, l); - maxWidth = Math.max(maxWidth, w); - totalHeight += h * quantity; - totalWeight += item.weightGrams * quantity; - } - - totalHeight = Math.round(totalHeight * 100) / 100; - totalWeight = Math.round(totalWeight * 100) / 100; - - if (allFlats && totalHeight <= 0.5) { - // Snap to available envelope sizes: 4x6in or 6x9in - if (maxLength <= 6 && maxWidth <= 4) { - return { lengthIn: 6, widthIn: 4, heightIn: 0, weightGrams: totalWeight, packageType: 'flat' as const }; - } else if (maxLength <= 9 && maxWidth <= 6) { - return { lengthIn: 9, widthIn: 6, heightIn: 0, weightGrams: totalWeight, packageType: 'flat' as const }; - } else { - // Too large for available envelopes — use bubble packet - return { lengthIn: maxLength, widthIn: maxWidth, heightIn: 0.5, weightGrams: totalWeight, packageType: 'box' as const }; - } - } - - return { - lengthIn: maxLength, - widthIn: maxWidth, - heightIn: Math.max(totalHeight, 0.5), - weightGrams: totalWeight, - packageType: 'box' as const - }; -} - -export const actions: Actions = { - createOrder: async ({ request, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); - - const ambassadorCheck = await db - .select({ userId: ambassadorPathway.userId }) - .from(ambassadorPathway) - .where(eq(ambassadorPathway.userId, locals.user.id)) - .limit(1); - - if (!locals.user.isAdmin && ambassadorCheck.length === 0) { - return fail(403, { error: 'Access denied' }); - } - - const formData = await request.formData(); - const firstName = (formData.get('firstName') as string)?.trim(); - const lastName = (formData.get('lastName') as string)?.trim(); - const email = (formData.get('email') as string)?.trim(); - const phone = (formData.get('phone') as string)?.trim() || null; - const addressLine1 = (formData.get('addressLine1') as string)?.trim(); - const addressLine2 = (formData.get('addressLine2') as string)?.trim() || null; - const city = (formData.get('city') as string)?.trim(); - const stateProvince = (formData.get('stateProvince') as string)?.trim(); - const postalCode = (formData.get('postalCode') as string)?.trim() || null; - const country = (formData.get('country') as string)?.trim().toUpperCase(); - const notes = (formData.get('notes') as string)?.trim() || null; - - if (!firstName || !lastName || !email || !addressLine1 || !city || !stateProvince || !country) { - return fail(400, { error: 'First name, last name, email, address line 1, city, state/province, and country are required' }); - } - - // Parse items from form: itemId_0, qty_0, sizing_0, ... - const orderItemsList: Array<{ warehouseItemId: string; quantity: number; sizingChoice: string | null }> = []; - let i = 0; - while (formData.has(`itemId_${i}`)) { - const warehouseItemId = formData.get(`itemId_${i}`) as string; - const qty = parseInt(formData.get(`qty_${i}`) as string) || 1; - const sizing = (formData.get(`sizing_${i}`) as string)?.trim() || null; - if (warehouseItemId && qty > 0) { - orderItemsList.push({ warehouseItemId, quantity: qty, sizingChoice: sizing }); - } - i++; - } - - if (orderItemsList.length === 0) { - return fail(400, { error: 'At least one item is required' }); - } - - // Look up the actual warehouse items for dimension estimation - const itemIds = orderItemsList.map(oi => oi.warehouseItemId); - const warehouseItems = await db - .select() - .from(warehouseItem) - .where(eq(warehouseItem.id, itemIds[0])); - - // Fetch all needed items - const allItems = await db.select().from(warehouseItem); - const itemMap = new Map(allItems.map(it => [it.id, it])); - - const dimensionInput = orderItemsList.map(oi => ({ - quantity: oi.quantity, - item: itemMap.get(oi.warehouseItemId)! - })).filter(oi => oi.item); - - if (dimensionInput.length !== orderItemsList.length) { - return fail(400, { error: 'One or more items not found' }); - } - - const dims = estimatePackageDimensions(dimensionInput); - - // Estimate shipping by calling our own shipping-rates endpoint logic - let estimatedShippingCents: number | null = null; - let estimatedServiceName: string | null = null; - try { - const { env } = await import('$env/dynamic/private'); - const originPostal = env.CP_ORIGIN_POSTAL_CODE; - if (originPostal && env.CP_API_USERNAME && env.CP_API_PASSWORD && env.CP_CUSTOMER_NUMBER) { - const INCHES_TO_CM = 2.54; - const inchesToCm = (v: number) => Math.round(v * INCHES_TO_CM * 10) / 10; - - const lengthCm = inchesToCm(dims.lengthIn); - const widthCm = inchesToCm(dims.widthIn); - const heightCm = dims.packageType === 'flat' ? 0.5 : inchesToCm(dims.heightIn); - const weightKg = Math.round(dims.weightGrams * 0.001 * 100) / 100; - - // Try lettermail first for small items - const weightGrams = dims.weightGrams; - const lengthMm = lengthCm * 10; - const widthMm = widthCm * 10; - const heightMm = heightCm * 10; - - const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; - const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; - const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; - - if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { - let price: number; - if (country === 'CA') price = 1.75; - else if (country === 'US') price = 2.0; - else price = 3.5; - estimatedShippingCents = Math.round(price * 100); - estimatedServiceName = `Lettermail ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; - } else if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { - let price: number; - if (country === 'CA') { - if (weightGrams <= 100) price = 3.11; - else if (weightGrams <= 200) price = 4.51; - else if (weightGrams <= 300) price = 5.91; - else if (weightGrams <= 400) price = 6.62; - else price = 7.05; - } else if (country === 'US') { - if (weightGrams <= 100) price = 4.51; - else if (weightGrams <= 200) price = 7.16; - else price = 13.38; - } else { - if (weightGrams <= 100) price = 8.08; - else if (weightGrams <= 200) price = 13.38; - else price = 25.8; - } - estimatedShippingCents = Math.round(price * 100); - estimatedServiceName = `Bubble Packet ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; - } - - // If no lettermail option, try Canada Post API for parcel rates - if (estimatedShippingCents === null && weightKg > 0) { - const buildDestinationXML = (c: string, pc?: string | null) => { - if (c === 'CA') return `${(pc ?? '').replace(/\s/g, '').toUpperCase()}`; - else if (c === 'US') return `${(pc ?? '').replace(/\s/g, '')}`; - else if (pc) return `${c}${pc}`; - else return `${c}`; - }; - - const xmlBody = ` - - ${env.CP_CUSTOMER_NUMBER} - ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} - - ${weightKg} - - ${lengthCm} - ${widthCm} - ${heightCm} - - - ${originPostal.replace(/\s/g, '').toUpperCase()} - - ${buildDestinationXML(country, postalCode)} - -`; - - const cpEndpoint = env.CP_ENVIRONMENT === 'production' - ? 'https://soa-gw.canadapost.ca/rs/ship/price' - : 'https://ct.soa-gw.canadapost.ca/rs/ship/price'; - - const authString = btoa(`${env.CP_API_USERNAME}:${env.CP_API_PASSWORD}`); - const response = await fetch(cpEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', - Accept: 'application/vnd.cpc.ship.rate-v4+xml', - Authorization: `Basic ${authString}`, - 'Accept-language': 'en-CA' - }, - body: xmlBody - }); - - if (response.ok) { - // @ts-ignore - xml2js lacks type declarations - const xml2js = await import('xml2js'); - const parser = new xml2js.default.Parser({ explicitArray: false }); - const result = await parser.parseStringPromise(await response.text()); - const priceQuotes = result?.['price-quotes']?.['price-quote']; - if (priceQuotes) { - const quotes = Array.isArray(priceQuotes) ? priceQuotes : [priceQuotes]; - // Pick cheapest option - let cheapest: { due: number; name: string } | null = null; - for (const q of quotes) { - const due = parseFloat(q['price-details']?.due ?? '0'); - if (!cheapest || due < cheapest.due) { - cheapest = { due, name: q['service-name'] }; - } - } - if (cheapest) { - const cadToUsd = 0.73; - const handlingFee = 2.0; - const totalUSD = (cheapest.due + handlingFee) * cadToUsd; - estimatedShippingCents = Math.round(totalUSD * 100); - estimatedServiceName = cheapest.name; - } - } - } - } - } - } catch (err) { - console.error('Shipping estimation failed:', err); - } - - // Create order - const [order] = await db.insert(warehouseOrder).values({ - createdById: locals.user.id, - status: estimatedShippingCents ? 'ESTIMATED' : 'DRAFT', - firstName, - lastName, - email, - phone, - addressLine1, - addressLine2, - city, - stateProvince, - postalCode, - country, - estimatedShippingCents, - estimatedServiceName, - estimatedPackageType: dims.packageType, - estimatedTotalLengthIn: dims.lengthIn, - estimatedTotalWidthIn: dims.widthIn, - estimatedTotalHeightIn: dims.heightIn, - estimatedTotalWeightGrams: dims.weightGrams, - notes - }).returning(); - - // Create order items - for (const oi of orderItemsList) { - await db.insert(warehouseOrderItem).values({ - orderId: order.id, - warehouseItemId: oi.warehouseItemId, - quantity: oi.quantity, - sizingChoice: oi.sizingChoice - }); - } - - return { success: true }; - }, - - addTag: async ({ request, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); - if (!locals.user.isAdmin) return fail(403, { error: 'Access denied' }); - - const formData = await request.formData(); - const orderId = formData.get('orderId') as string; - const tag = (formData.get('tag') as string)?.trim().toLowerCase(); - - if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); - - try { - await db.insert(warehouseOrderTag).values({ orderId, tag }); - } catch { - return fail(400, { error: 'Tag already exists on this order' }); - } - - return { success: true }; - }, - - removeTag: async ({ request, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); - if (!locals.user.isAdmin) return fail(403, { error: 'Access denied' }); - - const formData = await request.formData(); - const orderId = formData.get('orderId') as string; - const tag = formData.get('tag') as string; - - if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); - - await db.delete(warehouseOrderTag) - .where(sql`${warehouseOrderTag.orderId} = ${orderId} AND ${warehouseOrderTag.tag} = ${tag}`); - - return { success: true }; - }, - - deleteOrder: async ({ request, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); - - const formData = await request.formData(); - const orderId = formData.get('orderId') as string; - if (!orderId) return fail(400, { error: 'Order ID required' }); - - // Only admins or the creator can delete - const [order] = await db.select().from(warehouseOrder).where(eq(warehouseOrder.id, orderId)); - if (!order) return fail(404, { error: 'Order not found' }); - if (!locals.user.isAdmin && order.createdById !== locals.user.id) { - return fail(403, { error: 'Access denied' }); - } - - await db.delete(warehouseOrderItem).where(eq(warehouseOrderItem.orderId, orderId)); - await db.delete(warehouseOrder).where(eq(warehouseOrder.id, orderId)); - - return { success: true }; - } -}; diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte index c86855c..5b7891f 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte @@ -1,41 +1,15 @@ -
- -
- -{#if showCreateForm} -
-

Create Order

-
{ - isSubmitting = true; - return async ({ update, result }) => { - await update(); - isSubmitting = false; - if (result.type === 'success') { - showCreateForm = false; - orderLines = [{ itemId: '', qty: 1, sizing: '' }]; - } - }; - }} - > -

Recipient

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

Address

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

Items

-
- {#each orderLines as line, i} -
- - - -
- -
- {#if line.itemId && getItemSizingOptions(line.itemId).length > 0} -
- -
- {/if} -
- -
- {#if orderLines.length > 1} - - {/if} -
- {/each} - -
- -

Notes

-
- -
- - -
-
-{/if} - {#if data.allTags.length > 0}
Filter by tag: @@ -214,7 +67,7 @@ {#if filteredOrders.length === 0}

No orders yet.

-

Click "New Order" to create one.

+

Orders will appear here once they are created.

{:else}
@@ -264,30 +117,8 @@
{#each order.tags as tagObj} - - {tagObj.tag} -
- - - -
-
+ {tagObj.tag} {/each} -
{ - return async ({ update }) => { - await update(); - newTagInputs[order.id] = ''; - }; - }} class="inline-form"> - - -
{new Date(order.createdAt).toLocaleDateString()} @@ -295,17 +126,6 @@ - {#if confirmDelete === order.id} -
{ - return async ({ update }) => { await update(); confirmDelete = null; }; - }}> - - - -
- {:else} - - {/if} {#if expandedOrder === order.id} @@ -364,197 +184,6 @@ {/if} diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts index 6fe823b..701ff06 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts @@ -1,6 +1,5 @@ -import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - throw redirect(302, '/app/warehouse-backend/items'); + // No redirect needed - the backend landing page renders directly }; diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte index 1ef0183..c1bf179 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte @@ -1 +1,20 @@ -

Redirecting...

+
+

Warehouse Backend admin tools coming soon.

+

This will include editing items, marking orders as fulfilled, and more.

+
+ + diff --git a/resolution-frontend/src/routes/app/warehouse-backend/batches/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/batches/+page.svelte deleted file mode 100644 index b502a84..0000000 --- a/resolution-frontend/src/routes/app/warehouse-backend/batches/+page.svelte +++ /dev/null @@ -1,14 +0,0 @@ -
-

Batches coming soon.

-
- - diff --git a/resolution-frontend/src/routes/app/warehouse-backend/items/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/items/+page.server.ts deleted file mode 100644 index abde8e2..0000000 --- a/resolution-frontend/src/routes/app/warehouse-backend/items/+page.server.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { PageServerLoad, Actions } from './$types'; -import { db } from '$lib/server/db'; -import { warehouseItem, warehouseCategory } from '$lib/server/db/schema'; -import { eq, desc, asc } from 'drizzle-orm'; -import { error, fail } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; - -const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; -const MAX_SIZE = 5 * 1024 * 1024; // 5MB - -export const load: PageServerLoad = async ({ parent }) => { - const { user } = await parent(); - - if (!user.isAdmin) { - throw error(403, 'Access denied'); - } - - const items = await db - .select() - .from(warehouseItem) - .orderBy(desc(warehouseItem.createdAt)); - - const categories = await db - .select() - .from(warehouseCategory) - .orderBy(asc(warehouseCategory.sortOrder), asc(warehouseCategory.name)); - - return { - items, - categories - }; -}; - -export const actions: Actions = { - addCategory: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can add categories' }); - } - - const formData = await request.formData(); - const name = (formData.get('categoryName') as string)?.trim(); - - if (!name) { - return fail(400, { error: 'Category name is required' }); - } - - await db.insert(warehouseCategory).values({ name }); - - return { success: true }; - }, - - editCategory: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can edit categories' }); - } - - const formData = await request.formData(); - const categoryId = formData.get('categoryId') as string; - const name = (formData.get('categoryName') as string)?.trim(); - const sortOrder = parseInt(formData.get('sortOrder') as string); - - if (!categoryId) { - return fail(400, { error: 'Category ID required' }); - } - - const updateData: Record = {}; - if (name) updateData.name = name; - if (!isNaN(sortOrder)) updateData.sortOrder = sortOrder; - - if (Object.keys(updateData).length === 0) { - return fail(400, { error: 'Nothing to update' }); - } - - await db.update(warehouseCategory).set(updateData).where(eq(warehouseCategory.id, categoryId)); - - return { success: true }; - }, - - deleteCategory: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can delete categories' }); - } - - const formData = await request.formData(); - const categoryId = formData.get('categoryId') as string; - - if (!categoryId) { - return fail(400, { error: 'Category ID required' }); - } - - await db.delete(warehouseCategory).where(eq(warehouseCategory.id, categoryId)); - - return { success: true }; - }, - - addItem: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can add items' }); - } - - const formData = await request.formData(); - const name = formData.get('name') as string; - const sku = formData.get('sku') as string; - const categoryId = formData.get('categoryId') as string | null; - const sizing = formData.get('sizing') as string | null; - const packageType = (formData.get('packageType') as string) || 'box'; - const lengthIn = parseFloat(formData.get('lengthIn') as string); - const widthIn = parseFloat(formData.get('widthIn') as string); - const heightIn = packageType === 'flat' ? 0 : parseFloat(formData.get('heightIn') as string); - const weightGrams = parseFloat(formData.get('weightGrams') as string); - const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); - const quantity = parseInt(formData.get('quantity') as string) || 0; - const imageFile = formData.get('image') as File | null; - - if (!name || !sku || isNaN(lengthIn) || isNaN(widthIn) || (packageType !== 'flat' && isNaN(heightIn)) || isNaN(weightGrams) || isNaN(costCents)) { - return fail(400, { error: 'Name, SKU, dimensions, weight, and cost are required' }); - } - - let imageUrl: string | null = null; - - if (imageFile && imageFile.size > 0) { - if (!ALLOWED_TYPES.includes(imageFile.type)) { - return fail(400, { error: 'Image must be JPEG, PNG, GIF, or WebP' }); - } - if (imageFile.size > MAX_SIZE) { - return fail(400, { error: 'Image must be under 5MB' }); - } - - const cdnKey = env.HACK_CLUB_CDN_API_KEY; - if (!cdnKey) { - return fail(500, { error: 'CDN not configured' }); - } - - const uploadForm = new FormData(); - uploadForm.append('file', imageFile); - - const cdnResponse = await fetch('https://cdn.hackclub.com/api/v4/upload', { - method: 'POST', - headers: { 'Authorization': `Bearer ${cdnKey}` }, - body: uploadForm - }); - - if (!cdnResponse.ok) { - const cdnError = await cdnResponse.json().catch(() => ({})); - return fail(500, { error: cdnError.error || 'Failed to upload image' }); - } - - const cdnResult = await cdnResponse.json(); - imageUrl = cdnResult.url; - } - - try { - await db.insert(warehouseItem).values({ - name, - sku, - categoryId: categoryId || null, - sizing: sizing || null, - packageType, - lengthIn, - widthIn, - heightIn, - weightGrams, - costCents, - quantity, - imageUrl - }); - } catch { - return fail(400, { error: 'SKU already exists' }); - } - - return { success: true }; - }, - - editItem: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can edit items' }); - } - - const formData = await request.formData(); - const itemId = formData.get('itemId') as string; - const name = formData.get('name') as string; - const sku = formData.get('sku') as string; - const categoryId = formData.get('categoryId') as string | null; - const sizing = formData.get('sizing') as string | null; - const packageType = (formData.get('packageType') as string) || 'box'; - const lengthIn = parseFloat(formData.get('lengthIn') as string); - const widthIn = parseFloat(formData.get('widthIn') as string); - const heightIn = packageType === 'flat' ? 0 : parseFloat(formData.get('heightIn') as string); - const weightGrams = parseFloat(formData.get('weightGrams') as string); - const costCents = Math.round(parseFloat(formData.get('cost') as string) * 100); - const quantity = parseInt(formData.get('quantity') as string) || 0; - const imageFile = formData.get('image') as File | null; - const shouldRemoveImage = formData.get('removeImage') === 'true'; - - if (!itemId || !name || !sku || isNaN(lengthIn) || isNaN(widthIn) || (packageType !== 'flat' && isNaN(heightIn)) || isNaN(weightGrams) || isNaN(costCents)) { - return fail(400, { error: 'Item ID, name, SKU, dimensions, weight, and cost are required' }); - } - - let imageUrl: string | undefined | null; - - if (shouldRemoveImage) { - imageUrl = null; - } else if (imageFile && imageFile.size > 0) { - if (!ALLOWED_TYPES.includes(imageFile.type)) { - return fail(400, { error: 'Image must be JPEG, PNG, GIF, or WebP' }); - } - if (imageFile.size > MAX_SIZE) { - return fail(400, { error: 'Image must be under 5MB' }); - } - - const cdnKey = env.HACK_CLUB_CDN_API_KEY; - if (!cdnKey) { - return fail(500, { error: 'CDN not configured' }); - } - - const uploadForm = new FormData(); - uploadForm.append('file', imageFile); - - const cdnResponse = await fetch('https://cdn.hackclub.com/api/v4/upload', { - method: 'POST', - headers: { 'Authorization': `Bearer ${cdnKey}` }, - body: uploadForm - }); - - if (!cdnResponse.ok) { - const cdnError = await cdnResponse.json().catch(() => ({})); - return fail(500, { error: cdnError.error || 'Failed to upload image' }); - } - - const cdnResult = await cdnResponse.json(); - imageUrl = cdnResult.url; - } - - try { - const updateData: Record = { - name, - sku, - categoryId: categoryId || null, - sizing: sizing || null, - packageType, - lengthIn, - widthIn, - heightIn, - weightGrams, - costCents, - quantity, - updatedAt: new Date() - }; - if (imageUrl !== undefined) { - updateData.imageUrl = imageUrl; - } - await db.update(warehouseItem).set(updateData).where(eq(warehouseItem.id, itemId)); - } catch { - return fail(400, { error: 'Failed to update item. SKU may already exist.' }); - } - - return { success: true }; - }, - - deleteItem: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can delete items' }); - } - - const formData = await request.formData(); - const itemId = formData.get('itemId') as string; - - if (!itemId) { - return fail(400, { error: 'Item ID required' }); - } - - await db.delete(warehouseItem).where(eq(warehouseItem.id, itemId)); - - return { success: true }; - } -}; diff --git a/resolution-frontend/src/routes/app/warehouse-backend/items/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/items/+page.svelte deleted file mode 100644 index 2973088..0000000 --- a/resolution-frontend/src/routes/app/warehouse-backend/items/+page.svelte +++ /dev/null @@ -1,910 +0,0 @@ - - -
- - -
- -{#if showCategoryForm} -
-

Categories

-
{ - return async ({ update }) => { - await update(); - }; - }} - > -
- - -
-
- {#if data.categories.length > 0} -
    - {#each data.categories as cat, i (cat.id)} -
  • - {#if editingCategory === cat.id} -
    { - return async ({ update }) => { - await update(); - editingCategory = null; - }; - }} class="category-edit-form"> - - - - - -
    - {:else} - {cat.name} - {/if} - {#if editingCategory !== cat.id} -
    -
    - - - 0 ? data.categories[i - 1].sortOrder - 1 : cat.sortOrder - 1} /> - -
    -
    - - - - -
    - - {#if confirmDeleteCategory === cat.id} -
    { - return async ({ update }) => { - await update(); - confirmDeleteCategory = null; - }; - }} class="inline-form"> - - - -
    - {:else} - - {/if} -
    - {/if} -
  • - {/each} -
- {:else} -

No categories yet. Add one above, then assign items to it.

- {/if} -
-{/if} - -{#if showAddForm} -
-

Add New Item

-
{ - isSubmitting = true; - return async ({ update, result }) => { - await update(); - isSubmitting = false; - if (result.type === 'success') { - showAddForm = false; - imagePreview = null; - addOptions = ['']; - addPackageType = 'box'; - } - }; - }} - > -
-
- - -
-
- - -
-
- - -
-
- - o.trim()).join(', ')} /> - {#each addOptions as option, i} -
- - {#if addOptions.length > 1} - - {/if} -
- {/each} - -
-
- - -
-
- - -
-
- - -
- {#if addPackageType !== 'flat'} -
- - -
- {/if} -
- - -
-
- - -
-
- - -
-
- - - {#if imagePreview} - Preview - {/if} -
-
- -
-
-{/if} - -{#if data.items.length === 0} -
-

No items in the warehouse yet.

-

Click "Add Item" to start building your inventory.

-
-{:else} - {#each groupedItems() as group} -
-

{group.category?.name || 'Uncategorized'}

- {#if group.items.length === 0} -

No items in this category.

- {:else} -
- - - - - - - - - - - - - - - - {#each group.items as item (item.id)} - - - - - - - - - - - - {#if editingItem === item.id} - - - - {/if} - {/each} - -
PhotoNameIDOptionsDimensionsWeightCostQtyActions
- {#if item.imageUrl} - - {:else} - - {/if} - {item.name}{item.sku}{item.sizing || '—'}{item.packageType === 'flat' ? `${item.lengthIn}×${item.widthIn} in (flat)` : `${item.lengthIn}×${item.widthIn}×${item.heightIn} in`}{item.weightGrams} g{formatCost(item.costCents)}{item.quantity} - {#if confirmDelete === item.id} -
{ - return async ({ update }) => { - await update(); - confirmDelete = null; - }; - }}> - - - -
- {:else} - - - {/if} -
-
{ - isEditSubmitting = true; - return async ({ update, result }) => { - await update(); - isEditSubmitting = false; - if (result.type === 'success') { - editingItem = null; - editImagePreview = null; - } - }; - }} - > - -
-
- - -
-
- - -
-
- - -
-
- - o.trim()).join(', ')} /> - {#each editOptions as option, i} -
- - {#if editOptions.length > 1} - - {/if} -
- {/each} - -
-
- - -
-
- - -
-
- - -
- {#if editPackageType !== 'flat'} -
- - -
- {/if} -
- - -
-
- - -
-
- - -
-
- - {#if removeImage} - - {/if} - {#if !removeImage} - - {/if} - {#if editImagePreview} -
- Preview - -
- {:else if item.imageUrl && !removeImage} -
- Current - -
- {:else if removeImage} -

Image will be removed on save.

- {/if} -
-
-
- - -
-
-
-
- {/if} -
- {/each} -{/if} - -{#if expandedImage} - -{/if} - - diff --git a/resolution-frontend/src/routes/app/warehouse-backend/order-templates/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/order-templates/+page.svelte deleted file mode 100644 index 8281f2a..0000000 --- a/resolution-frontend/src/routes/app/warehouse-backend/order-templates/+page.svelte +++ /dev/null @@ -1,14 +0,0 @@ -
-

Order Templates coming soon.

-
- - diff --git a/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.server.ts deleted file mode 100644 index 5847231..0000000 --- a/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.server.ts +++ /dev/null @@ -1,352 +0,0 @@ -import type { PageServerLoad, Actions } from './$types'; -import { db } from '$lib/server/db'; -import { warehouseItem, warehouseOrder, warehouseOrderItem, warehouseOrderTag } from '$lib/server/db/schema'; -import { eq, desc, sql } from 'drizzle-orm'; -import { error, fail } from '@sveltejs/kit'; - -/** - * Estimates combined package dimensions for a set of items. - */ -function estimatePackageDimensions( - orderItems: Array<{ quantity: number; item: { packageType: string; lengthIn: number; widthIn: number; heightIn: number; weightGrams: number } }> -) { - let maxLength = 0; - let maxWidth = 0; - let totalHeight = 0; - let totalWeight = 0; - let allFlats = true; - - for (const oi of orderItems) { - const { item, quantity } = oi; - const l = Math.max(item.lengthIn, item.widthIn); - const w = Math.min(item.lengthIn, item.widthIn); - const h = item.packageType === 'flat' ? 0.1 : item.heightIn; - - if (item.packageType !== 'flat') allFlats = false; - - maxLength = Math.max(maxLength, l); - maxWidth = Math.max(maxWidth, w); - totalHeight += h * quantity; - totalWeight += item.weightGrams * quantity; - } - - totalHeight = Math.round(totalHeight * 100) / 100; - totalWeight = Math.round(totalWeight * 100) / 100; - - if (allFlats && totalHeight <= 0.5) { - if (maxLength <= 6 && maxWidth <= 4) { - return { lengthIn: 6, widthIn: 4, heightIn: 0, weightGrams: totalWeight, packageType: 'flat' as const }; - } else if (maxLength <= 9 && maxWidth <= 6) { - return { lengthIn: 9, widthIn: 6, heightIn: 0, weightGrams: totalWeight, packageType: 'flat' as const }; - } else { - return { lengthIn: maxLength, widthIn: maxWidth, heightIn: 0.5, weightGrams: totalWeight, packageType: 'box' as const }; - } - } - - return { - lengthIn: maxLength, - widthIn: maxWidth, - heightIn: Math.max(totalHeight, 0.5), - weightGrams: totalWeight, - packageType: 'box' as const - }; -} - -export const load: PageServerLoad = async ({ parent }) => { - const { user } = await parent(); - - if (!user.isAdmin) { - throw error(403, 'Access denied'); - } - - const items = await db - .select() - .from(warehouseItem) - .orderBy(warehouseItem.name); - - const orders = await db.query.warehouseOrder.findMany({ - with: { - createdBy: true, - items: { - with: { - warehouseItem: true - } - }, - tags: true - }, - orderBy: [desc(warehouseOrder.createdAt)] - }); - - const allTags = await db - .selectDistinct({ tag: warehouseOrderTag.tag }) - .from(warehouseOrderTag) - .orderBy(warehouseOrderTag.tag); - - return { - items, - orders, - allTags: allTags.map((t) => t.tag) - }; -}; - -export const actions: Actions = { - createOrder: async ({ request, locals }) => { - if (!locals.user?.isAdmin) { - return fail(403, { error: 'Only admins can create orders here' }); - } - - const formData = await request.formData(); - const firstName = (formData.get('firstName') as string)?.trim(); - const lastName = (formData.get('lastName') as string)?.trim(); - const email = (formData.get('email') as string)?.trim(); - const phone = (formData.get('phone') as string)?.trim() || null; - const addressLine1 = (formData.get('addressLine1') as string)?.trim(); - const addressLine2 = (formData.get('addressLine2') as string)?.trim() || null; - const city = (formData.get('city') as string)?.trim(); - const stateProvince = (formData.get('stateProvince') as string)?.trim(); - const postalCode = (formData.get('postalCode') as string)?.trim() || null; - const country = (formData.get('country') as string)?.trim().toUpperCase(); - const notes = (formData.get('notes') as string)?.trim() || null; - - if (!firstName || !lastName || !email || !addressLine1 || !city || !stateProvince || !country) { - return fail(400, { error: 'First name, last name, email, address line 1, city, state/province, and country are required' }); - } - - const orderItemsList: Array<{ warehouseItemId: string; quantity: number; sizingChoice: string | null }> = []; - let i = 0; - while (formData.has(`itemId_${i}`)) { - const warehouseItemId = formData.get(`itemId_${i}`) as string; - const qty = parseInt(formData.get(`qty_${i}`) as string) || 1; - const sizing = (formData.get(`sizing_${i}`) as string)?.trim() || null; - if (warehouseItemId && qty > 0) { - orderItemsList.push({ warehouseItemId, quantity: qty, sizingChoice: sizing }); - } - i++; - } - - if (orderItemsList.length === 0) { - return fail(400, { error: 'At least one item is required' }); - } - - const allItems = await db.select().from(warehouseItem); - const itemMap = new Map(allItems.map(it => [it.id, it])); - - const dimensionInput = orderItemsList.map(oi => ({ - quantity: oi.quantity, - item: itemMap.get(oi.warehouseItemId)! - })).filter(oi => oi.item); - - if (dimensionInput.length !== orderItemsList.length) { - return fail(400, { error: 'One or more items not found' }); - } - - const dims = estimatePackageDimensions(dimensionInput); - - let estimatedShippingCents: number | null = null; - let estimatedServiceName: string | null = null; - try { - const { env } = await import('$env/dynamic/private'); - const originPostal = env.CP_ORIGIN_POSTAL_CODE; - if (originPostal && env.CP_API_USERNAME && env.CP_API_PASSWORD && env.CP_CUSTOMER_NUMBER) { - const INCHES_TO_CM = 2.54; - const inchesToCm = (v: number) => Math.round(v * INCHES_TO_CM * 10) / 10; - - const lengthCm = inchesToCm(dims.lengthIn); - const widthCm = inchesToCm(dims.widthIn); - const heightCm = dims.packageType === 'flat' ? 0.5 : inchesToCm(dims.heightIn); - const weightKg = Math.round(dims.weightGrams * 0.001 * 100) / 100; - - const weightGrams = dims.weightGrams; - const lengthMm = lengthCm * 10; - const widthMm = widthCm * 10; - const heightMm = heightCm * 10; - - const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; - const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; - const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; - - if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { - let price: number; - if (country === 'CA') price = 1.75; - else if (country === 'US') price = 2.0; - else price = 3.5; - estimatedShippingCents = Math.round(price * 100); - estimatedServiceName = `Lettermail ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; - } else if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { - let price: number; - if (country === 'CA') { - if (weightGrams <= 100) price = 3.11; - else if (weightGrams <= 200) price = 4.51; - else if (weightGrams <= 300) price = 5.91; - else if (weightGrams <= 400) price = 6.62; - else price = 7.05; - } else if (country === 'US') { - if (weightGrams <= 100) price = 4.51; - else if (weightGrams <= 200) price = 7.16; - else price = 13.38; - } else { - if (weightGrams <= 100) price = 8.08; - else if (weightGrams <= 200) price = 13.38; - else price = 25.8; - } - estimatedShippingCents = Math.round(price * 100); - estimatedServiceName = `Bubble Packet ${country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'}`; - } - - if (estimatedShippingCents === null && weightKg > 0) { - const buildDestinationXML = (c: string, pc?: string | null) => { - if (c === 'CA') return `${(pc ?? '').replace(/\s/g, '').toUpperCase()}`; - else if (c === 'US') return `${(pc ?? '').replace(/\s/g, '')}`; - else if (pc) return `${c}${pc}`; - else return `${c}`; - }; - - const xmlBody = ` - - ${env.CP_CUSTOMER_NUMBER} - ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} - - ${weightKg} - - ${lengthCm} - ${widthCm} - ${heightCm} - - - ${originPostal.replace(/\s/g, '').toUpperCase()} - - ${buildDestinationXML(country, postalCode)} - -`; - - const cpEndpoint = env.CP_ENVIRONMENT === 'production' - ? 'https://soa-gw.canadapost.ca/rs/ship/price' - : 'https://ct.soa-gw.canadapost.ca/rs/ship/price'; - - const authString = btoa(`${env.CP_API_USERNAME}:${env.CP_API_PASSWORD}`); - const response = await fetch(cpEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', - Accept: 'application/vnd.cpc.ship.rate-v4+xml', - Authorization: `Basic ${authString}`, - 'Accept-language': 'en-CA' - }, - body: xmlBody - }); - - if (response.ok) { - // @ts-ignore - xml2js lacks type declarations - const xml2js = await import('xml2js'); - const parser = new xml2js.default.Parser({ explicitArray: false }); - const result = await parser.parseStringPromise(await response.text()); - const priceQuotes = result?.['price-quotes']?.['price-quote']; - if (priceQuotes) { - const quotes = Array.isArray(priceQuotes) ? priceQuotes : [priceQuotes]; - let cheapest: { due: number; name: string } | null = null; - for (const q of quotes) { - const due = parseFloat(q['price-details']?.due ?? '0'); - if (!cheapest || due < cheapest.due) { - cheapest = { due, name: q['service-name'] }; - } - } - if (cheapest) { - const cadToUsd = 0.73; - const handlingFee = 2.0; - const totalUSD = (cheapest.due + handlingFee) * cadToUsd; - estimatedShippingCents = Math.round(totalUSD * 100); - estimatedServiceName = cheapest.name; - } - } - } - } - } - } catch (err) { - console.error('Shipping estimation failed:', err); - } - - const [order] = await db.insert(warehouseOrder).values({ - createdById: locals.user.id, - status: estimatedShippingCents ? 'ESTIMATED' : 'DRAFT', - firstName, - lastName, - email, - phone, - addressLine1, - addressLine2, - city, - stateProvince, - postalCode, - country, - estimatedShippingCents, - estimatedServiceName, - estimatedPackageType: dims.packageType, - estimatedTotalLengthIn: dims.lengthIn, - estimatedTotalWidthIn: dims.widthIn, - estimatedTotalHeightIn: dims.heightIn, - estimatedTotalWeightGrams: dims.weightGrams, - notes - }).returning(); - - for (const oi of orderItemsList) { - await db.insert(warehouseOrderItem).values({ - orderId: order.id, - warehouseItemId: oi.warehouseItemId, - quantity: oi.quantity, - sizingChoice: oi.sizingChoice - }); - } - - return { success: true }; - }, - - addTag: async ({ request, locals }) => { - if (!locals.user?.isAdmin) return fail(403, { error: 'Access denied' }); - - const formData = await request.formData(); - const orderId = formData.get('orderId') as string; - const tag = (formData.get('tag') as string)?.trim().toLowerCase(); - - if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); - - try { - await db.insert(warehouseOrderTag).values({ orderId, tag }); - } catch { - return fail(400, { error: 'Tag already exists on this order' }); - } - - return { success: true }; - }, - - removeTag: async ({ request, locals }) => { - if (!locals.user?.isAdmin) return fail(403, { error: 'Access denied' }); - - const formData = await request.formData(); - const orderId = formData.get('orderId') as string; - const tag = formData.get('tag') as string; - - if (!orderId || !tag) return fail(400, { error: 'Order ID and tag required' }); - - await db.delete(warehouseOrderTag) - .where(sql`${warehouseOrderTag.orderId} = ${orderId} AND ${warehouseOrderTag.tag} = ${tag}`); - - return { success: true }; - }, - - deleteOrder: async ({ request, locals }) => { - if (!locals.user?.isAdmin) return fail(403, { error: 'Access denied' }); - - const formData = await request.formData(); - const orderId = formData.get('orderId') as string; - if (!orderId) return fail(400, { error: 'Order ID required' }); - - const [order] = await db.select().from(warehouseOrder).where(eq(warehouseOrder.id, orderId)); - if (!order) return fail(404, { error: 'Order not found' }); - - await db.delete(warehouseOrderItem).where(eq(warehouseOrderItem.orderId, orderId)); - await db.delete(warehouseOrderTag).where(eq(warehouseOrderTag.orderId, orderId)); - await db.delete(warehouseOrder).where(eq(warehouseOrder.id, orderId)); - - return { success: true }; - } -}; diff --git a/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.svelte deleted file mode 100644 index c86855c..0000000 --- a/resolution-frontend/src/routes/app/warehouse-backend/orders/+page.svelte +++ /dev/null @@ -1,789 +0,0 @@ - - -
- -
- -{#if showCreateForm} -
-

Create Order

-
{ - isSubmitting = true; - return async ({ update, result }) => { - await update(); - isSubmitting = false; - if (result.type === 'success') { - showCreateForm = false; - orderLines = [{ itemId: '', qty: 1, sizing: '' }]; - } - }; - }} - > -

Recipient

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

Address

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

Items

-
- {#each orderLines as line, i} -
- - - -
- -
- {#if line.itemId && getItemSizingOptions(line.itemId).length > 0} -
- -
- {/if} -
- -
- {#if orderLines.length > 1} - - {/if} -
- {/each} - -
- -

Notes

-
- -
- - -
-
-{/if} - -{#if data.allTags.length > 0} -
- Filter by tag: -
- - {#each data.allTags as tag} - - {/each} -
-
-{/if} - -{#if filteredOrders.length === 0} -
-

No orders yet.

-

Click "New Order" to create one.

-
-{:else} -
-
- - - - - - - - - - - - - - - - {#each filteredOrders as order (order.id)} - - - - - - - - - - - - {#if expandedOrder === order.id} - - - - {/if} - {/each} - -
RecipientOrdered ByDestinationItemsEst. ShippingStatusTagsCreatedActions
- {order.firstName} {order.lastName} -
{order.email} -
- {formatCreatorName(order.createdBy)} -
{order.createdBy.email} -
- {order.city}, {order.stateProvince} -
{order.country}{order.postalCode ? ` ${order.postalCode}` : ''} -
- {order.items.length} item{order.items.length !== 1 ? 's' : ''} -
{order.items.reduce((sum: number, oi: any) => sum + oi.quantity, 0)} total qty -
- {#if order.estimatedShippingCents} - {formatCost(order.estimatedShippingCents)} -
{order.estimatedServiceName || '—'} - {:else} - Not estimated - {/if} -
{statusLabel(order.status)} -
- {#each order.tags as tagObj} - - {tagObj.tag} -
- - - -
-
- {/each} -
{ - return async ({ update }) => { - await update(); - newTagInputs[order.id] = ''; - }; - }} class="inline-form"> - - -
-
-
{new Date(order.createdAt).toLocaleDateString()} - - {#if confirmDelete === order.id} -
{ - return async ({ update }) => { await update(); confirmDelete = null; }; - }}> - - - -
- {:else} - - {/if} -
-
-
-

Recipient

-

{order.firstName} {order.lastName}

-

{order.email}{order.phone ? ` · ${order.phone}` : ''}

-
-
-

Address

-

{order.addressLine1}

- {#if order.addressLine2}

{order.addressLine2}

{/if} -

{order.city}, {order.stateProvince} {order.postalCode || ''}

-

{order.country}

-
-
-

Items

- {#each order.items as oi} -

- {oi.warehouseItem.name} - {#if oi.sizingChoice}({oi.sizingChoice}){/if} - × {oi.quantity} -

- {/each} -
-
-

Estimated Package

- {#if order.estimatedTotalLengthIn} -

Dimensions: {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in ({order.estimatedPackageType})

-

Weight: {order.estimatedTotalWeightGrams}g

- {/if} - {#if order.estimatedShippingCents} -

Shipping: {formatCost(order.estimatedShippingCents)} ({order.estimatedServiceName})

- {:else} -

Shipping: Not estimated

- {/if} -
- {#if order.notes} -
-

Notes

-

{order.notes}

-
- {/if} -
-
-
-
-{/if} - - diff --git a/resolution-frontend/src/routes/app/warehouse/+layout.svelte b/resolution-frontend/src/routes/app/warehouse/+layout.svelte index 3264975..fdb0a17 100644 --- a/resolution-frontend/src/routes/app/warehouse/+layout.svelte +++ b/resolution-frontend/src/routes/app/warehouse/+layout.svelte @@ -7,7 +7,9 @@ const tabs = [ { label: 'Items', href: '/app/warehouse/items' }, - { label: 'Orders', href: '/app/warehouse/orders' } + { label: 'Orders', href: '/app/warehouse/orders' }, + { label: 'Batches', href: '/app/warehouse/batches' }, + { label: 'Order Templates', href: '/app/warehouse/order-templates' } ]; From ffbb4d695475b3c58ace2f531490415d2ed4d5f2 Mon Sep 17 00:00:00 2001 From: Jenin Date: Tue, 3 Mar 2026 12:46:01 -0500 Subject: [PATCH 025/180] Add warehouse backend admin tools for managing categories and items --- .../app/warehouse-backend/+page.server.ts | 188 +++++- .../routes/app/warehouse-backend/+page.svelte | 621 +++++++++++++++++- 2 files changed, 798 insertions(+), 11 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts index 701ff06..f1c6739 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts @@ -1,5 +1,189 @@ -import type { PageServerLoad } from './$types'; +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { warehouseItem, warehouseCategory } from '$lib/server/db/schema'; +import { eq, asc, desc } from 'drizzle-orm'; +import { fail } from '@sveltejs/kit'; export const load: PageServerLoad = async () => { - // No redirect needed - the backend landing page renders directly + const [categories, items] = await Promise.all([ + db.select().from(warehouseCategory).orderBy(asc(warehouseCategory.sortOrder)), + db.select().from(warehouseItem).orderBy(desc(warehouseItem.createdAt)) + ]); + + return { categories, items }; +}; + +export const actions: Actions = { + createCategory: async ({ request }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const sortOrder = parseInt(formData.get('sortOrder') as string); + + if (!name) { + return fail(400, { error: 'Category name is required' }); + } + + await db.insert(warehouseCategory).values({ + name, + sortOrder: isNaN(sortOrder) ? 0 : sortOrder + }); + + return { success: true }; + }, + + updateCategory: async ({ request }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + const name = formData.get('name') as string; + const sortOrder = parseInt(formData.get('sortOrder') as string); + + if (!id) { + return fail(400, { error: 'Category ID is required' }); + } + if (!name) { + return fail(400, { error: 'Category name is required' }); + } + + await db + .update(warehouseCategory) + .set({ + name, + sortOrder: isNaN(sortOrder) ? 0 : sortOrder + }) + .where(eq(warehouseCategory.id, id)); + + return { success: true }; + }, + + deleteCategory: async ({ request }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + if (!id) { + return fail(400, { error: 'Category ID is required' }); + } + + await db.delete(warehouseCategory).where(eq(warehouseCategory.id, id)); + + return { success: true }; + }, + + createItem: async ({ request }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const sku = formData.get('sku') as string; + const categoryId = formData.get('categoryId') as string | null; + const sizing = formData.get('sizing') as string | null; + const packageType = formData.get('packageType') as string; + const lengthIn = parseFloat(formData.get('lengthIn') as string); + const widthIn = parseFloat(formData.get('widthIn') as string); + const heightIn = parseFloat(formData.get('heightIn') as string); + const weightGrams = parseFloat(formData.get('weightGrams') as string); + const costDollars = parseFloat(formData.get('costDollars') as string); + const quantity = parseInt(formData.get('quantity') as string); + const imageUrl = formData.get('imageUrl') as string | null; + + if (!name) { + return fail(400, { error: 'Item name is required' }); + } + if (!sku) { + return fail(400, { error: 'SKU is required' }); + } + if (isNaN(lengthIn) || isNaN(widthIn) || isNaN(heightIn)) { + return fail(400, { error: 'Valid dimensions are required' }); + } + if (isNaN(weightGrams)) { + return fail(400, { error: 'Valid weight is required' }); + } + if (isNaN(costDollars)) { + return fail(400, { error: 'Valid cost is required' }); + } + + await db.insert(warehouseItem).values({ + name, + sku, + categoryId: categoryId || null, + sizing: sizing || null, + packageType: packageType || 'box', + lengthIn, + widthIn, + heightIn, + weightGrams, + costCents: Math.round(costDollars * 100), + quantity: isNaN(quantity) ? 0 : quantity, + imageUrl: imageUrl || null + }); + + return { success: true }; + }, + + updateItem: async ({ request }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + const name = formData.get('name') as string; + const sku = formData.get('sku') as string; + const categoryId = formData.get('categoryId') as string | null; + const sizing = formData.get('sizing') as string | null; + const packageType = formData.get('packageType') as string; + const lengthIn = parseFloat(formData.get('lengthIn') as string); + const widthIn = parseFloat(formData.get('widthIn') as string); + const heightIn = parseFloat(formData.get('heightIn') as string); + const weightGrams = parseFloat(formData.get('weightGrams') as string); + const costDollars = parseFloat(formData.get('costDollars') as string); + const quantity = parseInt(formData.get('quantity') as string); + const imageUrl = formData.get('imageUrl') as string | null; + + if (!id) { + return fail(400, { error: 'Item ID is required' }); + } + if (!name) { + return fail(400, { error: 'Item name is required' }); + } + if (!sku) { + return fail(400, { error: 'SKU is required' }); + } + if (isNaN(lengthIn) || isNaN(widthIn) || isNaN(heightIn)) { + return fail(400, { error: 'Valid dimensions are required' }); + } + if (isNaN(weightGrams)) { + return fail(400, { error: 'Valid weight is required' }); + } + if (isNaN(costDollars)) { + return fail(400, { error: 'Valid cost is required' }); + } + + await db + .update(warehouseItem) + .set({ + name, + sku, + categoryId: categoryId || null, + sizing: sizing || null, + packageType: packageType || 'box', + lengthIn, + widthIn, + heightIn, + weightGrams, + costCents: Math.round(costDollars * 100), + quantity: isNaN(quantity) ? 0 : quantity, + imageUrl: imageUrl || null, + updatedAt: new Date() + }) + .where(eq(warehouseItem.id, id)); + + return { success: true }; + }, + + deleteItem: async ({ request }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + if (!id) { + return fail(400, { error: 'Item ID is required' }); + } + + await db.delete(warehouseItem).where(eq(warehouseItem.id, id)); + + return { success: true }; + } }; diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte index c1bf179..f917bde 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte @@ -1,20 +1,623 @@ -
-

Warehouse Backend admin tools coming soon.

-

This will include editing items, marking orders as fulfilled, and more.

-
+ + + +
+

Categories

+ + {#if data.categories.length === 0} +

No categories yet.

+ {:else} + + + + + + + + + + {#each data.categories as cat (cat.id)} + {#if editingCategoryId === cat.id} + + + + {:else} + + + + + + {/if} + {/each} + +
NameSort OrderActions
+
{ return async ({ update }) => { await update(); cancelEditCategory(); }; }}> + +
+ + + + +
+
+
{cat.name}{cat.sortOrder} + +
+ + +
+
+ {/if} + +

Add Category

+
+
+ + + +
+
+
+ + +
+

Items

+ + {#if data.items.length === 0} +

No items in the warehouse yet.

+ {:else} + {#each groupedItems() as group} +
+

{group.category?.name || 'Uncategorized'}

+ {#if group.items.length === 0} +

No items in this category.

+ {:else} +
+ + + + + + + + + + + + + + + + {#each group.items as item (item.id)} + + + + + + + + + + + + {/each} + +
PhotoNameSKUOptionsDimensionsWeightCostQtyActions
+ {#if item.imageUrl} + + {:else} + + {/if} + {item.name}{item.sku}{item.sizing || '—'} + {item.packageType === 'flat' + ? `${item.lengthIn}×${item.widthIn} in (flat)` + : `${item.lengthIn}×${item.widthIn}×${item.heightIn} in`} + {item.weightGrams} g{formatCost(item.costCents)}{item.quantity} + +
+ + +
+
+
+ {/if} +
+ {/each} + {/if} +
+ + +{#if editingItemId} + +{/if} + + +
+

Add New Item

+
+
+ + + + + + + + + + + + +
+ +
+
+ + +{#if expandedImage} + +{/if} From b56a7056074aab5c937380bf7df520661d1d77ed Mon Sep 17 00:00:00 2001 From: Jenin Date: Tue, 3 Mar 2026 13:34:21 -0500 Subject: [PATCH 026/180] Fix table layout - remove flex from table cells --- .../src/routes/app/warehouse-backend/+page.svelte | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte index f917bde..77b7335 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte @@ -479,12 +479,17 @@ /* Actions */ .actions-cell { - display: flex; - align-items: center; - gap: 0.5rem; white-space: nowrap; } + .actions-cell :global(form) { + display: inline; + } + + .actions-cell .btn { + margin-right: 0.25rem; + } + .inline-form { display: inline; } From ac8dd2ecbb7cd9aec4ff9ae0bf3049bfd7d32d00 Mon Sep 17 00:00:00 2001 From: Jenin Date: Tue, 3 Mar 2026 13:41:21 -0500 Subject: [PATCH 027/180] Add order placement page for ambassadors --- .../routes/app/warehouse/orders/+page.svelte | 23 ++ .../app/warehouse/orders/new/+page.server.ts | 108 ++++++ .../app/warehouse/orders/new/+page.svelte | 335 ++++++++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts create mode 100644 resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte diff --git a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte index 5b7891f..e88ef66 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/+page.svelte @@ -44,6 +44,10 @@ ); + + {#if data.allTags.length > 0}
Filter by tag: @@ -357,6 +361,25 @@ font-size: 0.7rem; } + .page-actions { + margin-bottom: 1rem; + } + + .new-order-btn { + display: inline-block; + background: #338eda; + color: white; + text-decoration: none; + border-radius: 8px; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-family: inherit; + } + + .new-order-btn:hover { + opacity: 0.9; + } + @media (max-width: 768px) { .detail-grid { grid-template-columns: 1fr; diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts new file mode 100644 index 0000000..a8372f3 --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts @@ -0,0 +1,108 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { warehouseItem, warehouseCategory, warehouseOrder, warehouseOrderItem, warehouseOrderTag, ambassadorPathway } from '$lib/server/db/schema'; +import { eq, asc, desc } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + const ambassadorCheck = await db + .select({ userId: ambassadorPathway.userId }) + .from(ambassadorPathway) + .where(eq(ambassadorPathway.userId, user.id)) + .limit(1); + + const isAmbassador = ambassadorCheck.length > 0; + + if (!user.isAdmin && !isAmbassador) { + throw error(403, 'Access denied'); + } + + const [items, categories] = await Promise.all([ + db.select().from(warehouseItem).orderBy(asc(warehouseItem.name)), + db.select().from(warehouseCategory).orderBy(asc(warehouseCategory.sortOrder)) + ]); + + return { items, categories }; +}; + +export const actions: Actions = { + createOrder: async ({ request, locals }) => { + const user = locals.user; + if (!user) { + return fail(401, { error: 'Not logged in' }); + } + + const formData = await request.formData(); + + const firstName = formData.get('firstName') as string; + const lastName = formData.get('lastName') as string; + const email = formData.get('email') as string; + const phone = formData.get('phone') as string; + const addressLine1 = formData.get('addressLine1') as string; + const addressLine2 = formData.get('addressLine2') as string; + const city = formData.get('city') as string; + const stateProvince = formData.get('stateProvince') as string; + const postalCode = formData.get('postalCode') as string; + const country = formData.get('country') as string; + const notes = formData.get('notes') as string; + const tagsString = formData.get('tags') as string; + + const itemsJson = formData.get('items') as string; + let items: { warehouseItemId: string; quantity: number; sizingChoice?: string }[] = []; + try { + items = JSON.parse(itemsJson || '[]'); + } catch { + return fail(400, { error: 'Invalid items data' }); + } + + if (!firstName || !lastName || !email || !addressLine1 || !city || !stateProvince || !country) { + return fail(400, { error: 'Missing required fields' }); + } + + if (items.length === 0) { + return fail(400, { error: 'At least one item is required' }); + } + + const [order] = await db.insert(warehouseOrder).values({ + createdById: user.id, + firstName, + lastName, + email, + phone: phone || null, + addressLine1, + addressLine2: addressLine2 || null, + city, + stateProvince, + postalCode: postalCode || null, + country, + notes: notes || null + }).returning({ id: warehouseOrder.id }); + + await Promise.all( + items.map((item) => + db.insert(warehouseOrderItem).values({ + orderId: order.id, + warehouseItemId: item.warehouseItemId, + quantity: item.quantity, + sizingChoice: item.sizingChoice || null + }) + ) + ); + + if (tagsString && tagsString.trim()) { + const tags = tagsString.split(',').map((t) => t.trim()).filter((t) => t.length > 0); + if (tags.length > 0) { + await db.insert(warehouseOrderTag).values( + tags.map((tag) => ({ + orderId: order.id, + tag + })) + ); + } + } + + throw redirect(303, '/app/warehouse/orders'); + } +}; diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte new file mode 100644 index 0000000..e44a8ef --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -0,0 +1,335 @@ + + +
+
+

Recipient Info

+
+ + +
+ + +
+ +
+

Shipping Address

+ + +
+ + +
+
+ + +
+
+ +
+

Select Items

+ {#each groupedItems() as group} + {#if group.items.length > 0} +

{group.category?.name || 'Uncategorized'}

+
+ + + + + + + + + + + {#each group.items as item (item.id)} + {@const sizingOptions = item.sizing ? item.sizing.split(',').map((s) => s.trim()) : []} + {@const qty = itemQuantities[item.id] || 0} + + + + + + + {/each} + +
NameSKUSizingQuantity
{item.name}{item.sku} + {#if sizingOptions.length > 0} + + {:else} + + {/if} + + itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0} + /> +
+
+ {/if} + {/each} + +
+ +
+

Notes & Tags

+ + +
+ + +
+ + From 07fdfc2c810f5e65de485edc617855023456db5d Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 4 Mar 2026 08:32:51 -0500 Subject: [PATCH 028/180] feat(warehouse): add search-to-add items and shipping cost estimation --- .../app/warehouse/orders/new/+page.svelte | 460 ++++++++++++++---- 1 file changed, 377 insertions(+), 83 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index e44a8ef..19f8488 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -21,33 +21,22 @@ let itemQuantities = $state>({}); let itemSizing = $state>({}); + let searchQuery = $state(''); + + type ItemType = (typeof data.items)[number]; + + let searchResults = $derived(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return []; + return data.items.filter( + (item) => + item.name.toLowerCase().includes(q) || + item.sku.toLowerCase().includes(q) + ); + }); - type CategoryType = (typeof data.categories)[number]; - - let groupedItems = $derived(() => { - const groups: { category: CategoryType | null; items: typeof data.items }[] = []; - const categoryMap = new Map(); - const uncategorized: typeof data.items = []; - - for (const item of data.items) { - if (item.categoryId) { - const existing = categoryMap.get(item.categoryId) || []; - existing.push(item); - categoryMap.set(item.categoryId, existing); - } else { - uncategorized.push(item); - } - } - - for (const cat of data.categories) { - groups.push({ category: cat, items: categoryMap.get(cat.id) || [] }); - } - - if (uncategorized.length > 0) { - groups.push({ category: null, items: uncategorized }); - } - - return groups; + let addedItems = $derived(() => { + return data.items.filter((item) => (itemQuantities[item.id] || 0) > 0); }); let selectedItems = $derived(() => { @@ -59,6 +48,105 @@ sizingChoice: itemSizing[item.id] || null })); }); + + interface ShippingRate { + serviceName: string; + serviceCode: string; + priceDetails: { base: number; gst: number; pst: number; hst: number; total: number }; + deliveryDate: string; + transitDays: string; + isLettermail?: boolean; + note?: string; + } + + let estimateLoading = $state(false); + let estimateError = $state(''); + let estimatedRates = $state([]); + let hasEstimated = $state(false); + + function addItem(item: ItemType) { + if (!itemQuantities[item.id] || itemQuantities[item.id] === 0) { + itemQuantities[item.id] = 1; + } + searchQuery = ''; + hasEstimated = false; + estimatedRates = []; + } + + function computePackageTotals() { + const items = addedItems(); + let totalWeight = 0; + let maxLength = 0; + let maxWidth = 0; + let totalHeight = 0; + let hasBox = false; + + for (const item of items) { + const qty = itemQuantities[item.id] || 0; + totalWeight += item.weightGrams * qty; + maxLength = Math.max(maxLength, item.lengthIn); + maxWidth = Math.max(maxWidth, item.widthIn); + totalHeight += item.heightIn * qty; + if (item.packageType === 'box') hasBox = true; + } + + const packageType = hasBox || totalHeight > 0.5 ? 'box' : 'flat'; + return { weight: totalWeight, length: maxLength, width: maxWidth, height: totalHeight, packageType }; + } + + async function estimateShipping() { + const items = addedItems(); + if (items.length === 0) { + estimateError = 'Add at least one item first.'; + return; + } + if (!addressLine1 || !city || !stateProvince || !country) { + estimateError = 'Fill in the shipping address first.'; + return; + } + + estimateLoading = true; + estimateError = ''; + estimatedRates = []; + + try { + const pkg = computePackageTotals(); + const body: Record = { + country, + street: addressLine1, + city, + province: stateProvince, + postalCode: postalCode || undefined, + weight: pkg.weight, + packageType: pkg.packageType, + length: pkg.length, + width: pkg.width + }; + if (pkg.packageType === 'box') { + body.height = pkg.height; + } + + const res = await fetch('/api/shipping-rates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: 'Estimation failed' })); + estimateError = err.message || 'Estimation failed'; + return; + } + + const result = await res.json(); + estimatedRates = result.rates || []; + hasEstimated = true; + } catch { + estimateError = 'Failed to connect to shipping API.'; + } finally { + estimateLoading = false; + } + }
@@ -118,62 +206,122 @@

Select Items

- {#each groupedItems() as group} - {#if group.items.length > 0} -

{group.category?.name || 'Uncategorized'}

-
- - +
+ + {#if searchResults().length > 0} +
    + {#each searchResults() as item (item.id)} +
  • + +
  • + {/each} +
+ {:else if searchQuery.trim().length > 0} +
No items found
+ {/if} +
+ + {#if addedItems().length > 0} +
+
+ + + + + + + + + + + {#each addedItems() as item (item.id)} + {@const sizingOptions = item.sizing ? item.sizing.split(',').map((s) => s.trim()) : []} + {@const qty = itemQuantities[item.id] || 0} - - - - + + + + + - - - {#each group.items as item (item.id)} - {@const sizingOptions = item.sizing ? item.sizing.split(',').map((s) => s.trim()) : []} - {@const qty = itemQuantities[item.id] || 0} - - - - - - - {/each} - -
NameSKUSizingQuantity
NameSKUSizingQuantity{item.name}{item.sku} + {#if sizingOptions.length > 0} + + {:else} + + {/if} + + { itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0; hasEstimated = false; estimatedRates = []; }} + /> + + +
{item.name}{item.sku} - {#if sizingOptions.length > 0} - - {:else} - - {/if} - - itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0} - /> -
-
- {/if} - {/each} + {/each} + + +
+ {:else} +

Search above to add items to your order.

+ {/if} +
+

Shipping Estimate

+ + {#if estimateError} +

{estimateError}

+ {/if} + {#if hasEstimated && estimatedRates.length > 0} +
+ {#each estimatedRates as rate} +
+
+ {rate.serviceName} + ${rate.priceDetails.total.toFixed(2)} USD +
+
+ Transit: {rate.transitDays} days + {#if rate.note} + {rate.note} + {/if} +
+
+ {/each} +
+ {:else if hasEstimated} +

No shipping rates available for this destination.

+ {/if} +
+

Notes & Tags

From fac18ba7e587e1f81a4f5c2c759eb9bc59d83cdf Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 4 Mar 2026 08:40:56 -0500 Subject: [PATCH 030/180] feat(warehouse): auto-estimate shipping on item add/remove/qty change --- .../app/warehouse/orders/new/+page.svelte | 53 ++++++------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index 4b0211b..d08606b 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -136,8 +136,7 @@ itemQuantities[item.id] = 1; } searchQuery = ''; - hasEstimated = false; - estimatedRates = []; + tryEstimate(); } function computePackageTotals() { @@ -161,17 +160,15 @@ return { weight: totalWeight, length: maxLength, width: maxWidth, height: totalHeight, packageType }; } - async function estimateShipping() { + function tryEstimate() { const items = addedItems(); - if (items.length === 0) { - estimateError = 'Add at least one item first.'; - return; - } - if (!addressLine1 || !city || !stateProvince || !country) { - estimateError = 'Fill in the shipping address first.'; + if (items.length === 0 || !addressLine1 || !city || !stateProvince || !country) { return; } + estimateShipping(); + } + async function estimateShipping() { estimateLoading = true; estimateError = ''; estimatedRates = []; @@ -342,14 +339,14 @@ class="qty-input" min="1" value={qty} - oninput={(e) => { itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0; hasEstimated = false; estimatedRates = []; }} + oninput={(e) => { itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0; tryEstimate(); }} /> @@ -365,13 +362,11 @@

Shipping Estimate

- - {#if estimateError} + {#if estimateLoading} +

Estimating shipping costs...

+ {:else if estimateError}

{estimateError}

- {/if} - {#if hasEstimated && estimatedRates.length > 0} + {:else if hasEstimated && estimatedRates.length > 0}
{#each estimatedRates as rate}
@@ -389,7 +384,9 @@ {/each}
{:else if hasEstimated} -

No shipping rates available for this destination.

+

No shipping rates available for this destination.

+ {:else} +

Add items and fill in the shipping address to see estimates.

{/if}
@@ -602,26 +599,6 @@ text-align: center; } - .estimate-btn { - background: #6c5ce7; - color: white; - border: none; - border-radius: 8px; - padding: 0.5rem 1.25rem; - font-size: 0.875rem; - cursor: pointer; - font-family: inherit; - } - - .estimate-btn:hover { - opacity: 0.9; - } - - .estimate-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - } - .estimate-error { color: #e74c3c; font-size: 0.85rem; From b9e7210b8694b87fce977205183e034c1922f51d Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 4 Mar 2026 09:03:46 -0500 Subject: [PATCH 031/180] feat(warehouse): multi-step order wizard with shipping rate selection and order summary --- .../app/warehouse/orders/new/+page.svelte | 762 +++++++++++++----- 1 file changed, 546 insertions(+), 216 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index d08606b..e285ed6 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -4,6 +4,8 @@ let { data }: { data: PageData } = $props(); + let step = $state(1); + let firstName = $state(''); let lastName = $state(''); let email = $state(''); @@ -130,13 +132,13 @@ let estimateError = $state(''); let estimatedRates = $state([]); let hasEstimated = $state(false); + let selectedRate = $state(null); function addItem(item: ItemType) { if (!itemQuantities[item.id] || itemQuantities[item.id] === 0) { itemQuantities[item.id] = 1; } searchQuery = ''; - tryEstimate(); } function computePackageTotals() { @@ -160,18 +162,11 @@ return { weight: totalWeight, length: maxLength, width: maxWidth, height: totalHeight, packageType }; } - function tryEstimate() { - const items = addedItems(); - if (items.length === 0 || !addressLine1 || !city || !stateProvince || !country) { - return; - } - estimateShipping(); - } - async function estimateShipping() { estimateLoading = true; estimateError = ''; estimatedRates = []; + selectedRate = null; try { const pkg = computePackageTotals(); @@ -211,201 +206,447 @@ estimateLoading = false; } } + + let itemsCostCents = $derived(() => { + let total = 0; + for (const item of addedItems()) { + total += item.costCents * (itemQuantities[item.id] || 0); + } + return total; + }); + + let countryName = $derived(() => { + return countries.find((c) => c.code === country)?.name || country; + }); + + function canAdvance(s: number): boolean { + if (s === 1) return !!firstName && !!lastName && !!email && !!addressLine1 && !!city && !!stateProvince && !!country; + if (s === 2) return addedItems().length > 0; + if (s === 3) return !!selectedRate; + if (s === 4) return true; + return false; + } + + function nextStep() { + if (!canAdvance(step)) return; + if (step === 2) { + estimateShipping(); + } + step++; + } + + function prevStep() { + if (step > 1) step--; + } + + function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; + } +
+ {#each ['Address', 'Items', 'Shipping', 'Notes', 'Review'] as label, i} + + {#if i < 4} +
i + 1}>
+ {/if} + {/each} +
+ -
-

Recipient Info

-
+ + {#if step === 1} +
+

Recipient Info

+
+ + +
-
- - -
- -
-

Shipping Address

- - -
+
+ +
+

Shipping Address

-
-
+
+ + +
+
+ + +
+ + {/if} + + + {#if step === 2} +
+

Select Items

+
+ + {#if searchResults().length > 0} +
    + {#each searchResults() as item (item.id)} +
  • + +
  • + {/each} +
+ {:else if searchQuery.trim().length > 0} +
No items found
+ {/if} +
+ + {#if addedItems().length > 0} +
+ + + + + + + + + + + + {#each addedItems() as item (item.id)} + {@const sizingOptions = item.sizing ? item.sizing.split(',').map((s) => s.trim()) : []} + {@const qty = itemQuantities[item.id] || 0} + + + + + + + + {/each} + +
NameSKUSizingQuantity
{item.name}{item.sku} + {#if sizingOptions.length > 0} + + {:else} + + {/if} + + { itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0; }} + /> + + +
+
+ {:else} +

Search above to add items to your order.

+ {/if} +
+ {/if} + + + {#if step === 3} +
+

Choose Shipping

+ {#if estimateLoading} +

Estimating shipping costs...

+ {:else if estimateError} +

{estimateError}

+ + {:else if hasEstimated && estimatedRates.length > 0} +
+ {#each estimatedRates as rate} + + {/each} +
+ {:else if hasEstimated} +

No shipping rates available for this destination.

+ {/if} +
+ {/if} + + + {#if step === 4} +
+

Notes & Tags

-
- - -
-

Select Items

-
- - {#if searchResults().length > 0} -
    - {#each searchResults() as item (item.id)} -
  • - -
  • - {/each} -
- {:else if searchQuery.trim().length > 0} -
No items found
- {/if} -
+
+ {/if} + + + {#if step === 5} +
+

Order Summary

+ +
+

Ship To

+

{firstName} {lastName}

+

{addressLine1}{addressLine2 ? `, ${addressLine2}` : ''}

+

{city}, {stateProvince} {postalCode}

+

{countryName()}

+

{email}{phone ? ` · ${phone}` : ''}

+
- {#if addedItems().length > 0} -
- +
+

Items

+
- - - - - + + + - {#each addedItems() as item (item.id)} - {@const sizingOptions = item.sizing ? item.sizing.split(',').map((s) => s.trim()) : []} + {#each addedItems() as item} {@const qty = itemQuantities[item.id] || 0} - - - - - + + {/each}
NameSKUSizingQuantityItemQtyCost
{item.name}{item.sku} - {#if sizingOptions.length > 0} - - {:else} - + + {item.name} + {#if itemSizing[item.id]} + — {itemSizing[item.id]} {/if} - { itemQuantities[item.id] = parseInt((e.target as HTMLInputElement).value) || 0; tryEstimate(); }} - /> - - - {qty}{formatUsd(item.costCents * qty)}
- {:else} -

Search above to add items to your order.

- {/if} - -
- -
-

Shipping Estimate

- {#if estimateLoading} -

Estimating shipping costs...

- {:else if estimateError} -

{estimateError}

- {:else if hasEstimated && estimatedRates.length > 0} -
- {#each estimatedRates as rate} -
-
- {rate.serviceName} - ${rate.priceDetails.total.toFixed(2)} USD -
-
- Transit: {rate.transitDays} days - {#if rate.note} - {rate.note} - {/if} -
+ + {#if selectedRate} +
+

Shipping

+

{selectedRate.serviceName} — ${selectedRate.priceDetails.total.toFixed(2)}

+

Transit: {selectedRate.transitDays} days

+
+ {/if} + + {#if notes || tags} +
+

Notes & Tags

+ {#if notes}

{notes}

{/if} + {#if tags}

Tags: {tags}

{/if} +
+ {/if} + +
+
+ Items + {formatUsd(itemsCostCents())} +
+ {#if selectedRate} +
+ Shipping + ${selectedRate.priceDetails.total.toFixed(2)}
- {/each} +
+ Total + {formatUsd(itemsCostCents() + Math.round(selectedRate.priceDetails.total * 100))} +
+ {/if}
- {:else if hasEstimated} -

No shipping rates available for this destination.

+
+ + + + + + + + + + + + + + + + {/if} + + + From e3d04f29f8cbc1b40fd7c9fea28a54f3087c0868 Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 4 Mar 2026 09:16:46 -0500 Subject: [PATCH 032/180] feat(warehouse): make postal code required in order wizard --- .../src/routes/app/warehouse/orders/new/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index e285ed6..752cf75 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -220,7 +220,7 @@ }); function canAdvance(s: number): boolean { - if (s === 1) return !!firstName && !!lastName && !!email && !!addressLine1 && !!city && !!stateProvince && !!country; + if (s === 1) return !!firstName && !!lastName && !!email && !!addressLine1 && !!city && !!stateProvince && !!postalCode && !!country; if (s === 2) return addedItems().length > 0; if (s === 3) return !!selectedRate; if (s === 4) return true; @@ -311,7 +311,7 @@
From f6c8124f02d5d47ae80a0df31c77f8b0bbaf5dc7 Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 4 Mar 2026 09:19:27 -0500 Subject: [PATCH 034/180] feat(warehouse): tag input with chips, Enter to add, autocomplete from past tags --- .../app/warehouse/orders/new/+page.server.ts | 7 +- .../app/warehouse/orders/new/+page.svelte | 190 +++++++++++++++++- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts index a8372f3..5b070f9 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts @@ -19,12 +19,13 @@ export const load: PageServerLoad = async ({ parent }) => { throw error(403, 'Access denied'); } - const [items, categories] = await Promise.all([ + const [items, categories, existingTags] = await Promise.all([ db.select().from(warehouseItem).orderBy(asc(warehouseItem.name)), - db.select().from(warehouseCategory).orderBy(asc(warehouseCategory.sortOrder)) + db.select().from(warehouseCategory).orderBy(asc(warehouseCategory.sortOrder)), + db.selectDistinct({ tag: warehouseOrderTag.tag }).from(warehouseOrderTag).orderBy(asc(warehouseOrderTag.tag)) ]); - return { items, categories }; + return { items, categories, allTags: existingTags.map((t) => t.tag) }; }; export const actions: Actions = { diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index 1bd0491..7325d7d 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -19,7 +19,37 @@ let country = $state('US'); let notes = $state(''); - let tags = $state(''); + let tagsArray = $state([]); + let tagInput = $state(''); + + let tagSuggestions = $derived(() => { + const q = tagInput.trim().toLowerCase(); + if (!q) return []; + return data.allTags.filter( + (t) => t.toLowerCase().includes(q) && !tagsArray.includes(t) + ); + }); + + function addTag(tag: string) { + const t = tag.trim().toLowerCase(); + if (t && !tagsArray.includes(t)) { + tagsArray = [...tagsArray, t]; + } + tagInput = ''; + } + + function removeTag(tag: string) { + tagsArray = tagsArray.filter((t) => t !== tag); + } + + function handleTagKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + if (tagInput.trim()) addTag(tagInput); + } else if (e.key === 'Backspace' && !tagInput && tagsArray.length > 0) { + tagsArray = tagsArray.slice(0, -1); + } + } const countries = [ { code: 'US', name: 'United States' }, @@ -460,10 +490,37 @@ Notes -
+ + {@render children()} @@ -60,4 +76,31 @@ color: #8492a6; margin: 0; } + + .tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid #e0e0e0; + } + + .tab { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 600; + color: #8492a6; + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-family: inherit; + } + + .tab:hover { + color: #338eda; + } + + .tab.active { + color: #338eda; + border-bottom-color: #338eda; + } diff --git a/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts new file mode 100644 index 0000000..d809267 --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts @@ -0,0 +1,30 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { warehouseOrder, warehouseOrderTag } from '$lib/server/db/schema'; +import { ne, desc } from 'drizzle-orm'; + +export const load: PageServerLoad = async () => { + const orders = await db.query.warehouseOrder.findMany({ + where: ne(warehouseOrder.status, 'DRAFT'), + with: { + createdBy: true, + items: { + with: { + warehouseItem: true + } + }, + tags: true + }, + orderBy: [desc(warehouseOrder.createdAt)] + }); + + const allTags = await db + .selectDistinct({ tag: warehouseOrderTag.tag }) + .from(warehouseOrderTag) + .orderBy(warehouseOrderTag.tag); + + return { + orders, + allTags: allTags.map((t) => t.tag) + }; +}; diff --git a/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.svelte new file mode 100644 index 0000000..be7027e --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.svelte @@ -0,0 +1,392 @@ + + +
+
+ Status: + + {#each statuses as s} + {@const count = data.orders.filter((o: any) => o.status === s).length} + {#if count > 0} + + {/if} + {/each} +
+ + {#if data.allTags.length > 0} +
+ Tag: + + {#each data.allTags as tag} + + {/each} +
+ {/if} +
+ +{#if filteredOrders().length === 0} +
+

No orders to fulfill.

+

Placed orders will appear here.

+
+{:else} +
+
+ + + + + + + + + + + + + + + + + {#each filteredOrders() as order (order.id)} + + + + + + + + + + + + + {#if expandedOrder === order.id} + + + + {/if} + {/each} + +
IDRecipientOrdered ByDestinationItemsEst. ShippingStatusTagsCreatedActions
#{order.fulfillmentId} + {order.firstName} {order.lastName} +
{order.email} +
+ {formatCreatorName(order.createdBy)} +
{order.createdBy.email} +
+ {order.city}, {order.stateProvince} +
{order.country}{order.postalCode ? ` ${order.postalCode}` : ''} +
+ {order.items.length} item{order.items.length !== 1 ? 's' : ''} +
{order.items.reduce((sum: number, oi: any) => sum + oi.quantity, 0)} total qty +
+ {#if order.estimatedShippingCents} + {formatCost(order.estimatedShippingCents)} +
{order.estimatedServiceName || '—'} + {:else} + Not estimated + {/if} +
{statusLabel(order.status)} +
+ {#each order.tags as tagObj} + {tagObj.tag} + {/each} +
+
{new Date(order.createdAt).toLocaleDateString()} + +
+
+
+

Recipient

+

{order.firstName} {order.lastName}

+

{order.email}{order.phone ? ` · ${order.phone}` : ''}

+
+
+

Address

+

{order.addressLine1}

+ {#if order.addressLine2}

{order.addressLine2}

{/if} +

{order.city}, {order.stateProvince} {order.postalCode || ''}

+

{order.country}

+
+
+

Items

+ {#each order.items as oi} +

+ {oi.warehouseItem.name} + {#if oi.sizingChoice}({oi.sizingChoice}){/if} + × {oi.quantity} +

+ {/each} +
+
+

Estimated Package

+ {#if order.estimatedTotalLengthIn} +

Dimensions: {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in ({order.estimatedPackageType})

+

Weight: {order.estimatedTotalWeightGrams}g

+ {/if} + {#if order.estimatedShippingCents} +

Shipping: {formatCost(order.estimatedShippingCents)} ({order.estimatedServiceName})

+ {:else} +

Shipping: Not estimated

+ {/if} +
+ {#if order.notes} +
+

Notes

+

{order.notes}

+
+ {/if} +
+
+
+
+{/if} + + From c9aa778df743425a87bbb928aeae6164fa6b1dda Mon Sep 17 00:00:00 2001 From: Jenin Date: Wed, 11 Mar 2026 21:24:56 -0400 Subject: [PATCH 051/180] Fix fulfillment page: await parent layout data --- .../routes/app/warehouse-backend/fulfillment/+page.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts index d809267..33e02f2 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse-backend/fulfillment/+page.server.ts @@ -3,7 +3,8 @@ import { db } from '$lib/server/db'; import { warehouseOrder, warehouseOrderTag } from '$lib/server/db/schema'; import { ne, desc } from 'drizzle-orm'; -export const load: PageServerLoad = async () => { +export const load: PageServerLoad = async ({ parent }) => { + await parent(); const orders = await db.query.warehouseOrder.findMany({ where: ne(warehouseOrder.status, 'DRAFT'), with: { From d3cef6c1b2685e25f8b331501a2dfa455709f850 Mon Sep 17 00:00:00 2001 From: Jenin Date: Thu, 12 Mar 2026 08:18:25 -0400 Subject: [PATCH 052/180] Pin drizzle-kit version in Dockerfile, add stderr to push output --- resolution-frontend/Dockerfile | 2 +- resolution-frontend/entrypoint.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resolution-frontend/Dockerfile b/resolution-frontend/Dockerfile index 569cfe9..2d2c1c1 100644 --- a/resolution-frontend/Dockerfile +++ b/resolution-frontend/Dockerfile @@ -36,7 +36,7 @@ COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts COPY --from=builder /app/src/lib/server/db/schema.ts ./src/lib/server/db/schema.ts # Install drizzle-kit for runtime schema push (pruned as devDependency) -RUN npm install drizzle-kit +RUN npm install drizzle-kit@0.31.8 # Copy entrypoint script COPY entrypoint.sh ./entrypoint.sh diff --git a/resolution-frontend/entrypoint.sh b/resolution-frontend/entrypoint.sh index 3125d2c..30f0a0d 100644 --- a/resolution-frontend/entrypoint.sh +++ b/resolution-frontend/entrypoint.sh @@ -1,8 +1,8 @@ #!/bin/sh set -e -echo "Running database migrations..." -npx drizzle-kit push --force -echo "Migrations complete." +echo "Running database schema push..." +npx drizzle-kit push --force 2>&1 +echo "Schema push complete." exec node build From be3dda4d7bc435fb4656843590e4c1ab93d24f3b Mon Sep 17 00:00:00 2001 From: Jenin Date: Thu, 12 Mar 2026 08:23:25 -0400 Subject: [PATCH 053/180] Add debug logging to entrypoint for drizzle-kit push --- resolution-frontend/entrypoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/entrypoint.sh b/resolution-frontend/entrypoint.sh index 30f0a0d..0238459 100644 --- a/resolution-frontend/entrypoint.sh +++ b/resolution-frontend/entrypoint.sh @@ -1,8 +1,10 @@ #!/bin/sh set -e +echo "DATABASE_URL is set: $([ -n "$DATABASE_URL" ] && echo 'yes' || echo 'NO')" + echo "Running database schema push..." -npx drizzle-kit push --force 2>&1 +npx drizzle-kit push --force 2>&1 || echo "WARNING: drizzle-kit push failed with exit code $?" echo "Schema push complete." exec node build From 4f1d0cc675e60594e387210aa9f3268700edf1cd Mon Sep 17 00:00:00 2001 From: Jenin Date: Thu, 12 Mar 2026 08:30:35 -0400 Subject: [PATCH 054/180] Fix drizzle-kit push: pipe stdin to avoid interactive prompt blocking, add verbose flag --- resolution-frontend/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolution-frontend/entrypoint.sh b/resolution-frontend/entrypoint.sh index 0238459..3c5fbe5 100644 --- a/resolution-frontend/entrypoint.sh +++ b/resolution-frontend/entrypoint.sh @@ -4,7 +4,7 @@ set -e echo "DATABASE_URL is set: $([ -n "$DATABASE_URL" ] && echo 'yes' || echo 'NO')" echo "Running database schema push..." -npx drizzle-kit push --force 2>&1 || echo "WARNING: drizzle-kit push failed with exit code $?" +echo "" | npx drizzle-kit push --force --verbose 2>&1 || echo "WARNING: drizzle-kit push failed with exit code $?" echo "Schema push complete." exec node build From 4e9c7e990dd6a723c49404c741f79884e050341e Mon Sep 17 00:00:00 2001 From: Jenin Date: Sat, 21 Mar 2026 20:39:21 -0400 Subject: [PATCH 055/180] fix: correct migration journal tag to match actual filename --- resolution-frontend/drizzle/meta/_journal.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolution-frontend/drizzle/meta/_journal.json b/resolution-frontend/drizzle/meta/_journal.json index b4f70aa..75c06c7 100644 --- a/resolution-frontend/drizzle/meta/_journal.json +++ b/resolution-frontend/drizzle/meta/_journal.json @@ -20,7 +20,7 @@ "idx": 2, "version": "7", "when": 1772206185519, - "tag": "0002_violet_nighthawk", + "tag": "0002_add_package_type_and_orders", "breakpoints": true }, { From 2618c0249769ab619d8c012f9883866d00acfd14 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 15:38:34 -0400 Subject: [PATCH 056/180] Set new warehouse orders to APPROVED status instead of DRAFT --- .../src/routes/app/warehouse/orders/new/+page.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts index 799027a..a4f0887 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts @@ -78,7 +78,8 @@ export const actions: Actions = { stateProvince, postalCode: postalCode || null, country, - notes: notes || null + notes: notes || null, + status: 'APPROVED' }).returning({ id: warehouseOrder.id }); await Promise.all( From 00cefa3d41ebef21f7f1e67eee6c16060dae7cfe Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 15:54:28 -0400 Subject: [PATCH 057/180] Save selected shipping rate and service name when creating orders --- .../src/routes/app/warehouse/orders/new/+page.server.ts | 6 +++++- .../src/routes/app/warehouse/orders/new/+page.svelte | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts index a4f0887..a4cc4f7 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts @@ -49,6 +49,8 @@ export const actions: Actions = { const country = formData.get('country') as string; const notes = formData.get('notes') as string; const tagsString = formData.get('tags') as string; + const estimatedShippingCents = formData.get('estimatedShippingCents') as string; + const estimatedServiceName = formData.get('estimatedServiceName') as string; const itemsJson = formData.get('items') as string; let items: { warehouseItemId: string; quantity: number; sizingChoice?: string }[] = []; @@ -79,7 +81,9 @@ export const actions: Actions = { postalCode: postalCode || null, country, notes: notes || null, - status: 'APPROVED' + status: 'APPROVED', + estimatedShippingCents: estimatedShippingCents ? parseInt(estimatedShippingCents) : null, + estimatedServiceName: estimatedServiceName || null }).returning({ id: warehouseOrder.id }); await Promise.all( diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index b423061..71c18c9 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -621,6 +621,10 @@ + {#if selectedRate} + + + {/if} {/if} From a43dc36fff0ba328b60d6fe49c0f1739a5ac7685 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 16:25:30 -0400 Subject: [PATCH 058/180] Add fulfillment label generation with Canada Post, Theseus lettermail, and QZ Tray printing - Add Get Label button on fulfillment page (Canada Post parcels / Theseus lettermail) - Add QZ Tray integration for label and packing slip printing - Add printer settings page with printer selection, DPI config, and test print - Add QZ certificate and signing API endpoints - Add tracking_number, label_url, shipping_method columns to warehouse_order --- .../0004_add_label_tracking_fields.sql | 3 + .../src/lib/server/db/schema.ts | 3 + .../api/fulfillment/get-label/+server.ts | 357 ++++++++++++++++++ .../src/routes/api/qz/cert/+server.ts | 12 + .../src/routes/api/qz/sign/+server.ts | 28 ++ .../src/routes/app/warehouse/+layout.svelte | 3 +- .../app/warehouse/fulfillment/+page.svelte | 252 ++++++++++++- .../app/warehouse/settings/+page.svelte | 312 +++++++++++++++ 8 files changed, 964 insertions(+), 6 deletions(-) create mode 100644 resolution-frontend/drizzle/0004_add_label_tracking_fields.sql create mode 100644 resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts create mode 100644 resolution-frontend/src/routes/api/qz/cert/+server.ts create mode 100644 resolution-frontend/src/routes/api/qz/sign/+server.ts create mode 100644 resolution-frontend/src/routes/app/warehouse/settings/+page.svelte diff --git a/resolution-frontend/drizzle/0004_add_label_tracking_fields.sql b/resolution-frontend/drizzle/0004_add_label_tracking_fields.sql new file mode 100644 index 0000000..7b1af83 --- /dev/null +++ b/resolution-frontend/drizzle/0004_add_label_tracking_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE "warehouse_order" ADD COLUMN "tracking_number" text; +ALTER TABLE "warehouse_order" ADD COLUMN "label_url" text; +ALTER TABLE "warehouse_order" ADD COLUMN "shipping_method" text; diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index 3c2ee87..59a6554 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -326,6 +326,9 @@ export const warehouseOrder = pgTable('warehouse_order', { estimatedTotalWidthIn: real('estimated_total_width_in'), estimatedTotalHeightIn: real('estimated_total_height_in'), estimatedTotalWeightGrams: real('estimated_total_weight_grams'), + trackingNumber: text('tracking_number'), + labelUrl: text('label_url'), + shippingMethod: text('shipping_method'), // 'canada_post' or 'lettermail' notes: text('notes'), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow() diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts new file mode 100644 index 0000000..0981232 --- /dev/null +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -0,0 +1,357 @@ +import { env } from '$env/dynamic/private'; +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { warehouseOrder } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import xml2js from 'xml2js'; +import type { RequestHandler } from './$types'; + +const INCHES_TO_CM = 2.54; +const GRAMS_TO_KG = 0.001; + +function inchesToCm(inches: number): number { + return Math.round(inches * INCHES_TO_CM * 10) / 10; +} + +function isLettermail(serviceName: string | null): boolean { + if (!serviceName) return false; + const lower = serviceName.toLowerCase(); + return lower.includes('lettermail') || lower.includes('bubble packet'); +} + +function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, widthCm: number, heightCm: number, serviceCode: string): string { + const originPostal = (env.CP_ORIGIN_POSTAL_CODE || '').replace(/\s/g, '').toUpperCase(); + const customerNumber = env.CP_CUSTOMER_NUMBER; + const contractId = env.CP_CONTRACT_ID; + + let destinationXml = ''; + if (order.country === 'CA') { + destinationXml = ` + ${(order.postalCode ?? '').replace(/\s/g, '').toUpperCase()} + `; + } else if (order.country === 'US') { + destinationXml = ` + ${(order.postalCode ?? '').replace(/\s/g, '')} + ${order.stateProvince} + `; + } else { + destinationXml = ` + ${order.country} + ${order.postalCode ? `${order.postalCode}` : ''} + `; + } + + // For US/international shipments, add customs + let customsXml = ''; + if (order.country !== 'CA') { + const items = order.items || []; + const skuLines = items.map((oi: any) => { + const item = oi.warehouseItem; + const unitWeightKg = Math.round(item.weightGrams * GRAMS_TO_KG * 1000) / 1000; + const valuePerUnit = Math.round(item.costCents) / 100; + return ` + ${oi.quantity} + ${item.name.substring(0, 44)} + ${unitWeightKg} + ${valuePerUnit.toFixed(2)} + CA + `; + }).join('\n'); + + customsXml = ` + USD + 0.730 + SOG + Merchandise + ${skuLines} + `; + } + + return ` + + + ${originPostal} + true + + ${serviceCode} + + ${env.CP_SENDER_NAME || 'Hack Club'} + ${env.CP_SENDER_COMPANY || 'Hack Club'} + ${env.CP_SENDER_PHONE || '000-000-0000'} + + ${env.CP_SENDER_ADDRESS || ''} + ${env.CP_SENDER_CITY || ''} + ${env.CP_SENDER_PROVINCE || ''} + CA + ${originPostal} + + + + ${order.firstName} ${order.lastName} + + ${order.addressLine1} + ${order.addressLine2 ? `${order.addressLine2}` : ''} + ${order.city} + ${order.stateProvince} + ${order.country} + ${(order.postalCode ?? '').replace(/\s/g, '').toUpperCase()} + + + + ${Math.max(0.01, Math.round(weightKg * 1000) / 1000)} + + ${Math.max(1, lengthCm)} + ${Math.max(1, widthCm)} + ${Math.max(1, heightCm)} + + + + 4x6 + PDF + + ${customsXml} + + false + + +`; +} + +// Map common service names to Canada Post service codes +function getServiceCode(serviceName: string): string { + const lower = serviceName.toLowerCase(); + if (lower.includes('priority')) return 'DOM.PC'; + if (lower.includes('xpresspost') && lower.includes('international')) return 'INT.XP'; + if (lower.includes('xpresspost')) return 'DOM.XP'; + if (lower.includes('expedited') && lower.includes('usa')) return 'USA.EP'; + if (lower.includes('expedited')) return 'DOM.EP'; + if (lower.includes('regular') && lower.includes('usa')) return 'USA.PW.ENV'; + if (lower.includes('regular')) return 'DOM.RP'; + if (lower.includes('small packet') && lower.includes('usa')) return 'USA.SP.AIR'; + if (lower.includes('small packet') && lower.includes('surface')) return 'INT.SP.SURF'; + if (lower.includes('small packet') && lower.includes('air')) return 'INT.SP.AIR'; + if (lower.includes('tracked packet') && lower.includes('usa')) return 'USA.TP'; + if (lower.includes('tracked packet')) return 'INT.TP'; + if (lower.includes('surface') && lower.includes('international')) return 'INT.SP.SURF'; + if (lower.includes('air') && lower.includes('international')) return 'INT.SP.AIR'; + // Default to regular parcel + return 'DOM.RP'; +} + +export const POST: RequestHandler = async ({ request, locals }) => { + const user = locals.user; + if (!user) throw error(401, 'Not logged in'); + + const { orderId } = await request.json(); + if (!orderId) throw error(400, 'Order ID required'); + + const order = await db.query.warehouseOrder.findFirst({ + where: eq(warehouseOrder.id, orderId), + with: { + items: { + with: { + warehouseItem: true + } + } + } + }); + + if (!order) throw error(404, 'Order not found'); + if (order.labelUrl) throw error(400, 'Label already generated for this order'); + + // Calculate package totals from items + let totalWeight = 0; + let maxLength = 0; + let maxWidth = 0; + let totalHeight = 0; + + for (const oi of order.items) { + const item = oi.warehouseItem; + totalWeight += item.weightGrams * oi.quantity; + maxLength = Math.max(maxLength, item.lengthIn); + maxWidth = Math.max(maxWidth, item.widthIn); + totalHeight += item.heightIn * oi.quantity; + } + + // Build packing slip as a simple text-based content (to be rendered as PDF by the client or as an HTML page) + const packingSlipLines: string[] = [ + `PACKING SLIP`, + `Order #${order.fulfillmentId}`, + `Date: ${new Date().toLocaleDateString('en-US')}`, + ``, + `SHIP TO:`, + `${order.firstName} ${order.lastName}`, + `${order.addressLine1}`, + order.addressLine2 || '', + `${order.city}, ${order.stateProvince} ${order.postalCode || ''}`, + `${order.country}`, + ``, + `CONTENTS:`, + `${'Item'.padEnd(35)} ${'Size'.padEnd(10)} ${'Qty'.padEnd(5)}`, + `${'─'.repeat(50)}`, + ]; + + for (const oi of order.items) { + const name = oi.warehouseItem.name.substring(0, 35).padEnd(35); + const size = (oi.sizingChoice || '—').padEnd(10); + const qty = String(oi.quantity).padEnd(5); + packingSlipLines.push(`${name} ${size} ${qty}`); + } + + packingSlipLines.push(`${'─'.repeat(50)}`); + packingSlipLines.push(`Total items: ${order.items.reduce((s, oi) => s + oi.quantity, 0)}`); + if (order.notes) { + packingSlipLines.push(``); + packingSlipLines.push(`NOTES: ${order.notes}`); + } + + const packingSlipText = packingSlipLines.filter(l => l !== undefined).join('\n'); + const packingSlipBase64 = btoa(unescape(encodeURIComponent(packingSlipText))); + + let trackingNumber: string | null = null; + let labelUrl: string | null = null; + let shippingMethod: string; + + if (isLettermail(order.estimatedServiceName)) { + // ── LETTERMAIL PATH: Use Theseus/mail.hackclub.com ── + shippingMethod = 'lettermail'; + + const theseusApiKey = env.THESEUS_API_KEY; + const theseusQueueSlug = env.THESEUS_QUEUE_SLUG; + + if (!theseusApiKey || !theseusQueueSlug) { + throw error(500, 'Theseus API not configured (THESEUS_API_KEY and THESEUS_QUEUE_SLUG required)'); + } + + const theseusBody = { + address: { + first_name: order.firstName, + last_name: order.lastName, + line_1: order.addressLine1, + line_2: order.addressLine2 || undefined, + city: order.city, + state: order.stateProvince, + postal_code: order.postalCode || undefined, + country: order.country + }, + idempotency_key: `warehouse-order-${order.id}`, + metadata: { + warehouse_order_id: order.id, + fulfillment_id: order.fulfillmentId + } + }; + + const theseusUrl = `${env.THESEUS_BASE_URL || 'https://mail.hackclub.com'}/api/v1/letter_queues/instant/${theseusQueueSlug}`; + + const theseusRes = await fetch(theseusUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${theseusApiKey}` + }, + body: JSON.stringify(theseusBody) + }); + + if (!theseusRes.ok) { + const errBody = await theseusRes.text(); + console.error('Theseus API error:', errBody); + throw error(502, `Lettermail label creation failed: ${theseusRes.status}`); + } + + const theseusData = await theseusRes.json(); + trackingNumber = theseusData.id || null; + labelUrl = theseusData.label_url || null; + + } else { + // ── CANADA POST PARCEL PATH ── + shippingMethod = 'canada_post'; + + const cpUsername = env.CP_API_USERNAME; + const cpPassword = env.CP_API_PASSWORD; + const customerNumber = env.CP_CUSTOMER_NUMBER; + + if (!cpUsername || !cpPassword || !customerNumber) { + throw error(500, 'Canada Post API not configured'); + } + + const weightKg = totalWeight * GRAMS_TO_KG; + const lengthCm = inchesToCm(maxLength); + const widthCm = inchesToCm(maxWidth); + const heightCm = inchesToCm(totalHeight); + + const serviceCode = getServiceCode(order.estimatedServiceName || ''); + const shipmentXml = buildCreateShipmentXML(order, weightKg, lengthCm, widthCm, heightCm, serviceCode); + + const cpBaseUrl = env.CP_ENVIRONMENT === 'production' + ? 'https://soa-gw.canadapost.ca' + : 'https://ct.soa-gw.canadapost.ca'; + + const mobo = customerNumber; + const cpEndpoint = `${cpBaseUrl}/rs/${customerNumber}/${mobo}/shipment`; + const authString = btoa(`${cpUsername}:${cpPassword}`); + + const cpRes = await fetch(cpEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.cpc.shipment-v8+xml', + Accept: 'application/vnd.cpc.shipment-v8+xml', + Authorization: `Basic ${authString}`, + 'Accept-language': 'en-CA' + }, + body: shipmentXml + }); + + if (!cpRes.ok) { + const errText = await cpRes.text(); + console.error('Canada Post Create Shipment error:', errText); + throw error(502, `Canada Post shipment creation failed: ${cpRes.status}`); + } + + const cpXml = await cpRes.text(); + const parser = new xml2js.Parser({ explicitArray: false }); + const cpResult = await parser.parseStringPromise(cpXml); + const shipmentInfo = cpResult['shipment-info']; + + trackingNumber = shipmentInfo?.['tracking-pin'] || null; + + // Find the label link + const links = shipmentInfo?.links?.link; + if (links) { + const linkArray = Array.isArray(links) ? links : [links]; + const labelLink = linkArray.find((l: any) => l.$?.rel === 'label'); + if (labelLink?.$?.href) { + // Fetch the label PDF from Canada Post + const labelRes = await fetch(labelLink.$.href, { + headers: { + Accept: 'application/pdf', + Authorization: `Basic ${authString}` + } + }); + if (labelRes.ok) { + const labelBuffer = await labelRes.arrayBuffer(); + const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); + // Store as data URL for direct use + labelUrl = `data:application/pdf;base64,${labelBase64}`; + } + } + } + } + + // Update the order with tracking info + await db.update(warehouseOrder) + .set({ + trackingNumber, + labelUrl, + shippingMethod, + status: 'SHIPPED', + updatedAt: new Date() + }) + .where(eq(warehouseOrder.id, orderId)); + + return json({ + trackingNumber, + labelUrl, + packingSlipBase64, + shippingMethod + }); +}; diff --git a/resolution-frontend/src/routes/api/qz/cert/+server.ts b/resolution-frontend/src/routes/api/qz/cert/+server.ts new file mode 100644 index 0000000..f7c04b3 --- /dev/null +++ b/resolution-frontend/src/routes/api/qz/cert/+server.ts @@ -0,0 +1,12 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + const cert = env.QZ_CERTIFICATE; + if (!cert) { + return new Response('QZ certificate not configured', { status: 500 }); + } + return new Response(cert, { + headers: { 'Content-Type': 'text/plain' } + }); +}; diff --git a/resolution-frontend/src/routes/api/qz/sign/+server.ts b/resolution-frontend/src/routes/api/qz/sign/+server.ts new file mode 100644 index 0000000..89dce65 --- /dev/null +++ b/resolution-frontend/src/routes/api/qz/sign/+server.ts @@ -0,0 +1,28 @@ +import { env } from '$env/dynamic/private'; +import { createSign } from 'crypto'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + const toSign = await request.text(); + + const privateKey = env.QZ_PRIVATE_KEY; + const password = env.QZ_PK_PASSWORD; + + if (!privateKey) { + return new Response('QZ private key not configured', { status: 500 }); + } + + try { + const sign = createSign('SHA512'); + sign.update(toSign); + const signature = sign.sign( + { key: privateKey, passphrase: password || '' }, + 'base64' + ); + return new Response(signature, { + headers: { 'Content-Type': 'text/plain' } + }); + } catch (e) { + return new Response('Signing failed', { status: 500 }); + } +}; diff --git a/resolution-frontend/src/routes/app/warehouse/+layout.svelte b/resolution-frontend/src/routes/app/warehouse/+layout.svelte index b2a1e5e..d795a02 100644 --- a/resolution-frontend/src/routes/app/warehouse/+layout.svelte +++ b/resolution-frontend/src/routes/app/warehouse/+layout.svelte @@ -9,7 +9,8 @@ { label: 'Orders', href: '/app/warehouse/orders' }, { label: 'Fulfillment', href: '/app/warehouse/fulfillment' }, { label: 'Batches', href: '/app/warehouse/batches' }, - { label: 'Order Templates', href: '/app/warehouse/order-templates' } + { label: 'Order Templates', href: '/app/warehouse/order-templates' }, + { label: 'Settings', href: '/app/warehouse/settings' } ]; diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index be7027e..f7f84e1 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -50,8 +50,121 @@ } return orders; }); + + // ── QZ Tray + Label state ── + let qz: any = null; + let qzStatus = $state<'connecting' | 'connected' | 'error'>('connecting'); + let labelLoading = $state>({}); + let labelErrors = $state>({}); + let labelResults = $state>({}); + + function getQZSettings(): { printer: string; dpi: number } { + try { + const saved = localStorage.getItem('warehouse-qz-settings'); + if (saved) return JSON.parse(saved); + } catch {} + return { printer: '', dpi: 203 }; + } + + async function initQZ() { + const maxWait = 5000; + const start = Date.now(); + while (!(window as any).qz && Date.now() - start < maxWait) { + await new Promise(r => setTimeout(r, 100)); + } + qz = (window as any).qz; + if (!qz) { qzStatus = 'error'; return; } + + qz.security.setCertificatePromise(function(resolve: any, reject: any) { + fetch('/api/qz/cert', { cache: 'no-store' }) + .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); + }); + qz.security.setSignatureAlgorithm('SHA512'); + qz.security.setSignaturePromise(function(toSign: string) { + return function(resolve: any, reject: any) { + fetch('/api/qz/sign', { method: 'POST', cache: 'no-store', body: toSign, headers: { 'Content-Type': 'text/plain' } }) + .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); + }; + }); + + try { + if (!qz.websocket.isActive()) await qz.websocket.connect(); + qzStatus = 'connected'; + } catch { qzStatus = 'error'; } + } + + async function printPdf(pdfDataUrl: string) { + if (!qz || qzStatus !== 'connected') return; + const settings = getQZSettings(); + if (!settings.printer) { alert('No printer selected. Go to Settings to configure.'); return; } + const config = qz.configs.create(settings.printer, { + colorType: 'blackwhite', density: settings.dpi, units: 'in', + rasterize: true, interpolation: 'nearest-neighbor', size: { width: 4, height: 6 } + }); + await qz.print(config, [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfDataUrl.replace(/^data:application\/pdf;base64,/, '') }]); + } + + async function printPackingSlip(base64Text: string) { + if (!qz || qzStatus !== 'connected') return; + const settings = getQZSettings(); + if (!settings.printer) { alert('No printer selected. Go to Settings to configure.'); return; } + const text = decodeURIComponent(escape(atob(base64Text))); + const html = `
${text}
`; + const config = qz.configs.create(settings.printer, { + colorType: 'blackwhite', density: settings.dpi, units: 'in', + rasterize: true, interpolation: 'nearest-neighbor', size: { width: 4, height: 6 } + }); + await qz.print(config, [{ type: 'html', format: 'plain', data: html }]); + } + + async function getLabel(orderId: string) { + labelLoading[orderId] = true; + labelErrors[orderId] = ''; + try { + const res = await fetch('/api/fulfillment/get-label', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: 'Failed' })); + labelErrors[orderId] = err.message || `Error ${res.status}`; + return; + } + const result = await res.json(); + labelResults[orderId] = result; + + // Update the order status in the local data + const order = data.orders.find((o: any) => o.id === orderId); + if (order) { + (order as any).status = 'SHIPPED'; + (order as any).trackingNumber = result.trackingNumber; + (order as any).labelUrl = result.labelUrl; + } + } catch (e: any) { + labelErrors[orderId] = e?.message || 'Network error'; + } finally { + labelLoading[orderId] = false; + } + } + + $effect(() => { initQZ(); }); + + + + +
+ {#if qzStatus === 'connecting'} + ⏳ QZ Tray connecting... + {:else if qzStatus === 'connected'} + 🖨️ Printer ready + {:else} + ✗ QZ Tray not connected — Settings + {/if} +
+
Status: @@ -145,8 +258,49 @@ + {#if order.status === 'APPROVED' && !order.labelUrl && !labelResults[order.id]} + + {/if} + {#if labelErrors[order.id]} + + +

✗ {labelErrors[order.id]}

+ + + {/if} + {#if labelResults[order.id]} + + +
+
+ {labelResults[order.id].shippingMethod === 'lettermail' ? '✉️ Lettermail' : '📦 Canada Post'} + {#if labelResults[order.id].trackingNumber} + Tracking: {labelResults[order.id].trackingNumber} + {/if} +
+
+ {#if labelResults[order.id].labelUrl} + + {/if} + +
+
+ + + {/if} {#if expandedOrder === order.id} @@ -174,16 +328,19 @@ {/each}
-

Estimated Package

- {#if order.estimatedTotalLengthIn} -

Dimensions: {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in ({order.estimatedPackageType})

-

Weight: {order.estimatedTotalWeightGrams}g

+

Shipping

+ {#if order.trackingNumber} +

Tracking: {order.trackingNumber}

{/if} {#if order.estimatedShippingCents} -

Shipping: {formatCost(order.estimatedShippingCents)} ({order.estimatedServiceName})

+

Cost: {formatCost(order.estimatedShippingCents)} ({order.estimatedServiceName})

{:else}

Shipping: Not estimated

{/if} + {#if order.estimatedTotalLengthIn} +

Dimensions: {order.estimatedTotalLengthIn}×{order.estimatedTotalWidthIn}{order.estimatedPackageType !== 'flat' ? `×${order.estimatedTotalHeightIn}` : ''} in ({order.estimatedPackageType})

+

Weight: {order.estimatedTotalWeightGrams}g

+ {/if}
{#if order.notes}
@@ -384,6 +541,91 @@ font-size: 0.7rem; } + .qz-status-bar { + margin-bottom: 0.75rem; + } + + .qz-indicator { + display: inline-block; + padding: 0.375rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; + } + + .qz-connecting { background: #fff8e1; color: #f59e0b; } + .qz-connected { background: #e8fff0; color: #27ae60; } + .qz-error { background: #ffe8ea; color: #ec3750; } + .qz-error a { color: #ec3750; font-weight: 600; } + + .label-btn { + background: #e8fff0 !important; + border-color: #33d6a6 !important; + color: #27ae60 !important; + } + + .label-btn:hover:not(:disabled) { + background: #d0ffe0 !important; + } + + .label-btn:disabled { + opacity: 0.6; + cursor: wait; + } + + .label-error { + color: #ec3750; + font-size: 0.85rem; + margin: 0; + } + + .label-result { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + } + + .label-result-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .label-method { + font-weight: 600; + font-size: 0.9rem; + } + + .label-tracking { + font-size: 0.85rem; + color: #8492a6; + } + + .label-tracking code { + background: #f0edff; + padding: 0.125rem 0.375rem; + border-radius: 4px; + color: #6c5ce7; + font-weight: 600; + } + + .label-actions { + display: flex; + gap: 0.5rem; + } + + .print-btn { + background: #f0f7ff !important; + border-color: #338eda !important; + color: #338eda !important; + } + + .print-btn:hover { + background: #ddeeff !important; + } + @media (max-width: 768px) { .detail-grid { grid-template-columns: 1fr; diff --git a/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte b/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte new file mode 100644 index 0000000..8b85e3b --- /dev/null +++ b/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte @@ -0,0 +1,312 @@ + + + + + + +
+

QZ Tray Connection

+ {#if status === 'connecting'} +
+ ⏳ Connecting to QZ Tray... +
+ {:else if status === 'connected'} +
+ ✓ Connected to QZ Tray +
+ {:else} +
+ ✗ Could not connect to QZ Tray + Download QZ Tray +
+ {/if} +
+ +
+

Printer

+
+ +
+ +
+ DPI +
+ {#each [203, 300, 305] as d} + + {/each} +
+
+
+ +
+

Test

+ + {#if testPrintStatus} +

{testPrintStatus}

+ {/if} +
+ + From 41d0eb72298fdb5b69ae166a9fcbd738bae5448c Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 16:30:36 -0400 Subject: [PATCH 059/180] Mark Theseus letters as printed, remove company from CP sender --- .../src/routes/api/fulfillment/get-label/+server.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 0981232..deefcaa 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -76,7 +76,6 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, ${serviceCode} ${env.CP_SENDER_NAME || 'Hack Club'} - ${env.CP_SENDER_COMPANY || 'Hack Club'} ${env.CP_SENDER_PHONE || '000-000-0000'} ${env.CP_SENDER_ADDRESS || ''} @@ -262,6 +261,15 @@ export const POST: RequestHandler = async ({ request, locals }) => { trackingNumber = theseusData.id || null; labelUrl = theseusData.label_url || null; + // Mark the letter as printed in Theseus + if (trackingNumber) { + const theseusBaseUrl = env.THESEUS_BASE_URL || 'https://mail.hackclub.com'; + await fetch(`${theseusBaseUrl}/api/v1/letters/${trackingNumber}/mark_printed`, { + method: 'POST', + headers: { Authorization: `Bearer ${theseusApiKey}` } + }).catch((e) => console.error('Failed to mark Theseus letter as printed:', e)); + } + } else { // ── CANADA POST PARCEL PATH ── shippingMethod = 'canada_post'; From a7582ed70090b0e01301b415b69e77adb56b188d Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 16:35:22 -0400 Subject: [PATCH 060/180] Add sender address line 2 and restore required company field for Canada Post --- .../src/routes/api/fulfillment/get-label/+server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index deefcaa..12f57cf 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -76,9 +76,11 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, ${serviceCode} ${env.CP_SENDER_NAME || 'Hack Club'} + ${env.CP_SENDER_NAME || 'Hack Club'} ${env.CP_SENDER_PHONE || '000-000-0000'} ${env.CP_SENDER_ADDRESS || ''} + ${env.CP_SENDER_ADDRESS_2 ? `${env.CP_SENDER_ADDRESS_2}` : ''} ${env.CP_SENDER_CITY || ''} ${env.CP_SENDER_PROVINCE || ''} CA From 22285f4a665222d9d9cd630d96a0ec9677d86ef8 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 16:41:49 -0400 Subject: [PATCH 061/180] Make QZ Tray certificate optional for demo mode --- .../app/warehouse/fulfillment/+page.svelte | 28 +++++++++------- .../app/warehouse/settings/+page.svelte | 32 +++++++++---------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index f7f84e1..eedd8ae 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -75,17 +75,23 @@ qz = (window as any).qz; if (!qz) { qzStatus = 'error'; return; } - qz.security.setCertificatePromise(function(resolve: any, reject: any) { - fetch('/api/qz/cert', { cache: 'no-store' }) - .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); - }); - qz.security.setSignatureAlgorithm('SHA512'); - qz.security.setSignaturePromise(function(toSign: string) { - return function(resolve: any, reject: any) { - fetch('/api/qz/sign', { method: 'POST', cache: 'no-store', body: toSign, headers: { 'Content-Type': 'text/plain' } }) - .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); - }; - }); + // Only set up signing if certificate is configured; otherwise QZ runs in demo/unsigned mode + try { + const certRes = await fetch('/api/qz/cert', { cache: 'no-store' }); + if (certRes.ok) { + const certText = await certRes.text(); + if (certText && !certText.includes('not configured')) { + qz.security.setCertificatePromise(function(resolve: any) { resolve(certText); }); + qz.security.setSignatureAlgorithm('SHA512'); + qz.security.setSignaturePromise(function(toSign: string) { + return function(resolve: any, reject: any) { + fetch('/api/qz/sign', { method: 'POST', cache: 'no-store', body: toSign, headers: { 'Content-Type': 'text/plain' } }) + .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); + }; + }); + } + } + } catch {} try { if (!qz.websocket.isActive()) await qz.websocket.connect(); diff --git a/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte b/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte index 8b85e3b..996bd20 100644 --- a/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/settings/+page.svelte @@ -38,22 +38,22 @@ return; } - qz.security.setCertificatePromise(function(resolve: any, reject: any) { - fetch('/api/qz/cert', { cache: 'no-store' }) - .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); - }); - - qz.security.setSignatureAlgorithm('SHA512'); - qz.security.setSignaturePromise(function(toSign: string) { - return function(resolve: any, reject: any) { - fetch('/api/qz/sign', { - method: 'POST', - cache: 'no-store', - body: toSign, - headers: { 'Content-Type': 'text/plain' } - }).then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); - }; - }); + try { + const certRes = await fetch('/api/qz/cert', { cache: 'no-store' }); + if (certRes.ok) { + const certText = await certRes.text(); + if (certText && !certText.includes('not configured')) { + qz.security.setCertificatePromise(function(resolve: any) { resolve(certText); }); + qz.security.setSignatureAlgorithm('SHA512'); + qz.security.setSignaturePromise(function(toSign: string) { + return function(resolve: any, reject: any) { + fetch('/api/qz/sign', { method: 'POST', cache: 'no-store', body: toSign, headers: { 'Content-Type': 'text/plain' } }) + .then((r: Response) => r.ok ? resolve(r.text()) : reject(r.text())); + }; + }); + } + } + } catch {} try { if (!qz.websocket.isActive()) { From 659956ae7acfe6539852517cbca84f05d3146de2 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 16:54:03 -0400 Subject: [PATCH 062/180] Combine print label and packing slip into single Print button --- .../app/warehouse/fulfillment/+page.svelte | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index eedd8ae..53fe9b8 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -99,28 +99,24 @@ } catch { qzStatus = 'error'; } } - async function printPdf(pdfDataUrl: string) { + async function printAll(result: { labelUrl: string | null; packingSlipBase64: string }) { if (!qz || qzStatus !== 'connected') return; const settings = getQZSettings(); if (!settings.printer) { alert('No printer selected. Go to Settings to configure.'); return; } - const config = qz.configs.create(settings.printer, { + const config = () => qz.configs.create(settings.printer, { colorType: 'blackwhite', density: settings.dpi, units: 'in', rasterize: true, interpolation: 'nearest-neighbor', size: { width: 4, height: 6 } }); - await qz.print(config, [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfDataUrl.replace(/^data:application\/pdf;base64,/, '') }]); - } - async function printPackingSlip(base64Text: string) { - if (!qz || qzStatus !== 'connected') return; - const settings = getQZSettings(); - if (!settings.printer) { alert('No printer selected. Go to Settings to configure.'); return; } - const text = decodeURIComponent(escape(atob(base64Text))); + // Print label + if (result.labelUrl) { + await qz.print(config(), [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: result.labelUrl.replace(/^data:application\/pdf;base64,/, '') }]); + } + + // Print packing slip + const text = decodeURIComponent(escape(atob(result.packingSlipBase64))); const html = `
${text}
`; - const config = qz.configs.create(settings.printer, { - colorType: 'blackwhite', density: settings.dpi, units: 'in', - rasterize: true, interpolation: 'nearest-neighbor', size: { width: 4, height: 6 } - }); - await qz.print(config, [{ type: 'html', format: 'plain', data: html }]); + await qz.print(config(), [{ type: 'html', format: 'plain', data: html }]); } async function getLabel(orderId: string) { @@ -294,13 +290,8 @@ {/if}
- {#if labelResults[order.id].labelUrl} - - {/if} -
From f553cf848e6284b3b06390970c30e6eaa9c07207 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 16:56:01 -0400 Subject: [PATCH 063/180] Handle escaped newlines in QZ certificate and private key env vars --- resolution-frontend/src/routes/api/qz/cert/+server.ts | 2 +- resolution-frontend/src/routes/api/qz/sign/+server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resolution-frontend/src/routes/api/qz/cert/+server.ts b/resolution-frontend/src/routes/api/qz/cert/+server.ts index f7c04b3..6a21c9f 100644 --- a/resolution-frontend/src/routes/api/qz/cert/+server.ts +++ b/resolution-frontend/src/routes/api/qz/cert/+server.ts @@ -2,7 +2,7 @@ import { env } from '$env/dynamic/private'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async () => { - const cert = env.QZ_CERTIFICATE; + const cert = env.QZ_CERTIFICATE?.replace(/\\n/g, '\n'); if (!cert) { return new Response('QZ certificate not configured', { status: 500 }); } diff --git a/resolution-frontend/src/routes/api/qz/sign/+server.ts b/resolution-frontend/src/routes/api/qz/sign/+server.ts index 89dce65..7b696f3 100644 --- a/resolution-frontend/src/routes/api/qz/sign/+server.ts +++ b/resolution-frontend/src/routes/api/qz/sign/+server.ts @@ -5,7 +5,7 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request }) => { const toSign = await request.text(); - const privateKey = env.QZ_PRIVATE_KEY; + const privateKey = env.QZ_PRIVATE_KEY?.replace(/\\n/g, '\n'); const password = env.QZ_PK_PASSWORD; if (!privateKey) { From 8c34a2be765c7e8bceb1935f35b5d69eca7be967 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 17:17:25 -0400 Subject: [PATCH 064/180] Add settlement-info to Canada Post shipment XML, support CreditCard payment without contract --- .../src/routes/api/fulfillment/get-label/+server.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 12f57cf..17e8983 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -114,6 +114,11 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, false + + ${customerNumber} + ${contractId ? `${contractId}` : ''} + ${contractId ? 'Account' : 'CreditCard'} + `; } From 499fc565ba68bfe8049bf9d73494f4af24b35f96 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 17:19:39 -0400 Subject: [PATCH 065/180] Fetch Theseus label PDF and convert to base64 for qz-tray printing --- .../api/fulfillment/get-label/+server.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 17e8983..43ff4f9 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -266,7 +266,23 @@ export const POST: RequestHandler = async ({ request, locals }) => { const theseusData = await theseusRes.json(); trackingNumber = theseusData.id || null; - labelUrl = theseusData.label_url || null; + const rawLabelUrl = theseusData.label_url || null; + + // Fetch the label PDF and convert to base64 data URL so the frontend can print it via qz-tray + if (rawLabelUrl) { + try { + const labelRes = await fetch(rawLabelUrl); + if (labelRes.ok) { + const labelBuffer = await labelRes.arrayBuffer(); + const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); + labelUrl = `data:application/pdf;base64,${labelBase64}`; + } else { + labelUrl = rawLabelUrl; + } + } catch { + labelUrl = rawLabelUrl; + } + } // Mark the letter as printed in Theseus if (trackingNumber) { From bc24e256fb18826a0be515a23f75cc79d0bef5a4 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 17:38:24 -0400 Subject: [PATCH 066/180] Add reprint button for orders with existing labels, remove label-already-generated guard --- .../src/routes/api/fulfillment/get-label/+server.ts | 1 - .../routes/app/warehouse/fulfillment/+page.svelte | 13 +++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 43ff4f9..07a3236 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -163,7 +163,6 @@ export const POST: RequestHandler = async ({ request, locals }) => { }); if (!order) throw error(404, 'Order not found'); - if (order.labelUrl) throw error(400, 'Label already generated for this order'); // Calculate package totals from items let totalWeight = 0; diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index 53fe9b8..321e363 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -260,14 +260,23 @@ - {#if order.status === 'APPROVED' && !order.labelUrl && !labelResults[order.id]} + {#if order.status === 'APPROVED' && !labelResults[order.id]} + {/if} + {#if order.status === 'SHIPPED' && order.labelUrl && !labelResults[order.id]} + {/if} From 3a8c15e16f898764aea0555178064d206e748877 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 17:40:02 -0400 Subject: [PATCH 067/180] Handle both data URLs and remote URLs in printAll for label printing --- .../app/warehouse/fulfillment/+page.svelte | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index 321e363..e42cd4a 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -110,13 +110,27 @@ // Print label if (result.labelUrl) { - await qz.print(config(), [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: result.labelUrl.replace(/^data:application\/pdf;base64,/, '') }]); + let base64Data: string; + if (result.labelUrl.startsWith('data:')) { + base64Data = result.labelUrl.replace(/^data:application\/pdf;base64,/, ''); + } else { + // Fetch remote PDF and convert to base64 + const res = await fetch(result.labelUrl); + const buf = await res.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + base64Data = btoa(binary); + } + await qz.print(config(), [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: base64Data }]); } // Print packing slip - const text = decodeURIComponent(escape(atob(result.packingSlipBase64))); - const html = `
${text}
`; - await qz.print(config(), [{ type: 'html', format: 'plain', data: html }]); + if (result.packingSlipBase64) { + const text = decodeURIComponent(escape(atob(result.packingSlipBase64))); + const html = `
${text}
`; + await qz.print(config(), [{ type: 'html', format: 'plain', data: html }]); + } } async function getLabel(orderId: string) { From b0ca5a6dc4b6acfc6f5a4c925576551368f6b383 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 17:45:50 -0400 Subject: [PATCH 068/180] Fix CORS: always proxy label fetch through server, never fetch remote URLs client-side --- .../app/warehouse/fulfillment/+page.svelte | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index e42cd4a..54a4859 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -110,18 +110,7 @@ // Print label if (result.labelUrl) { - let base64Data: string; - if (result.labelUrl.startsWith('data:')) { - base64Data = result.labelUrl.replace(/^data:application\/pdf;base64,/, ''); - } else { - // Fetch remote PDF and convert to base64 - const res = await fetch(result.labelUrl); - const buf = await res.arrayBuffer(); - const bytes = new Uint8Array(buf); - let binary = ''; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - base64Data = btoa(binary); - } + const base64Data = result.labelUrl.replace(/^data:application\/pdf;base64,/, ''); await qz.print(config(), [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: base64Data }]); } @@ -284,13 +273,14 @@ {labelLoading[order.id] ? '⏳...' : order.labelUrl ? '🔄 Reprint' : '📦 Get Label'} {/if} - {#if order.status === 'SHIPPED' && order.labelUrl && !labelResults[order.id]} + {#if order.status === 'SHIPPED' && !labelResults[order.id]} {/if} From af2a6555da94bbd8fed1e138b068156c85daf12a Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 17:55:48 -0400 Subject: [PATCH 069/180] Add reprint path: re-fetch existing label as base64 instead of creating new shipment --- .../api/fulfillment/get-label/+server.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 07a3236..927516b 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -164,6 +164,27 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (!order) throw error(404, 'Order not found'); + // If the order already has a label, re-fetch and return it as base64 + if (order.labelUrl) { + let labelUrl = order.labelUrl; + if (!labelUrl.startsWith('data:')) { + try { + const labelRes = await fetch(labelUrl); + if (labelRes.ok) { + const labelBuffer = await labelRes.arrayBuffer(); + const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); + labelUrl = `data:application/pdf;base64,${labelBase64}`; + } + } catch {} + } + return json({ + trackingNumber: order.trackingNumber, + labelUrl, + packingSlipBase64: '', + shippingMethod: order.shippingMethod || '' + }); + } + // Calculate package totals from items let totalWeight = 0; let maxLength = 0; From d0240195438b55c65b90e0498f6736df6a81f937 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 18:07:59 -0400 Subject: [PATCH 070/180] Add logging and proper error handling for label re-fetch to debug 502 --- .../api/fulfillment/get-label/+server.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 927516b..71dfa5d 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -168,14 +168,19 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (order.labelUrl) { let labelUrl = order.labelUrl; if (!labelUrl.startsWith('data:')) { - try { - const labelRes = await fetch(labelUrl); - if (labelRes.ok) { - const labelBuffer = await labelRes.arrayBuffer(); - const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); - labelUrl = `data:application/pdf;base64,${labelBase64}`; - } - } catch {} + console.log('Fetching label from:', labelUrl); + const labelRes = await fetch(labelUrl); + console.log('Label fetch status:', labelRes.status, labelRes.statusText); + if (labelRes.ok) { + const labelBuffer = await labelRes.arrayBuffer(); + console.log('Label PDF size:', labelBuffer.byteLength, 'bytes'); + const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); + labelUrl = `data:application/pdf;base64,${labelBase64}`; + } else { + const errBody = await labelRes.text(); + console.error('Label fetch failed:', errBody); + throw error(502, `Failed to fetch label PDF: ${labelRes.status}`); + } } return json({ trackingNumber: order.trackingNumber, From ca8691d88913073e8f822267707e78920df658e2 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 18:13:22 -0400 Subject: [PATCH 071/180] Fix stack overflow: use loop-based arrayBufferToBase64 instead of spread operator --- .../routes/api/fulfillment/get-label/+server.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 71dfa5d..3f8e393 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -9,6 +9,15 @@ import type { RequestHandler } from './$types'; const INCHES_TO_CM = 2.54; const GRAMS_TO_KG = 0.001; +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + function inchesToCm(inches: number): number { return Math.round(inches * INCHES_TO_CM * 10) / 10; } @@ -174,8 +183,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (labelRes.ok) { const labelBuffer = await labelRes.arrayBuffer(); console.log('Label PDF size:', labelBuffer.byteLength, 'bytes'); - const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); - labelUrl = `data:application/pdf;base64,${labelBase64}`; + labelUrl = `data:application/pdf;base64,${arrayBufferToBase64(labelBuffer)}`; } else { const errBody = await labelRes.text(); console.error('Label fetch failed:', errBody); @@ -299,7 +307,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { const labelRes = await fetch(rawLabelUrl); if (labelRes.ok) { const labelBuffer = await labelRes.arrayBuffer(); - const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); + const labelBase64 = arrayBufferToBase64(labelBuffer); labelUrl = `data:application/pdf;base64,${labelBase64}`; } else { labelUrl = rawLabelUrl; @@ -385,7 +393,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { }); if (labelRes.ok) { const labelBuffer = await labelRes.arrayBuffer(); - const labelBase64 = btoa(String.fromCharCode(...new Uint8Array(labelBuffer))); + const labelBase64 = arrayBufferToBase64(labelBuffer); // Store as data URL for direct use labelUrl = `data:application/pdf;base64,${labelBase64}`; } From c94665d53c6b8abe3948141c6414f0fe6f1888ca Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 18:18:33 -0400 Subject: [PATCH 072/180] Include packing slip in reprint response, extract buildPackingSlipBase64 helper --- .../api/fulfillment/get-label/+server.ts | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 3f8e393..53033ae 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -18,6 +18,39 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { return btoa(binary); } +function buildPackingSlipBase64(order: any): string { + const lines: string[] = [ + `PACKING SLIP`, + `Order #${order.fulfillmentId}`, + `Date: ${new Date().toLocaleDateString('en-US')}`, + ``, + `SHIP TO:`, + `${order.firstName} ${order.lastName}`, + `${order.addressLine1}`, + order.addressLine2 || '', + `${order.city}, ${order.stateProvince} ${order.postalCode || ''}`, + `${order.country}`, + ``, + `CONTENTS:`, + `${'Item'.padEnd(35)} ${'Size'.padEnd(10)} ${'Qty'.padEnd(5)}`, + `${'─'.repeat(50)}`, + ]; + for (const oi of order.items) { + const name = oi.warehouseItem.name.substring(0, 35).padEnd(35); + const size = (oi.sizingChoice || '—').padEnd(10); + const qty = String(oi.quantity).padEnd(5); + lines.push(`${name} ${size} ${qty}`); + } + lines.push(`${'─'.repeat(50)}`); + lines.push(`Total items: ${order.items.reduce((s: number, oi: any) => s + oi.quantity, 0)}`); + if (order.notes) { + lines.push(``); + lines.push(`NOTES: ${order.notes}`); + } + const text = lines.filter(l => l !== undefined).join('\n'); + return btoa(unescape(encodeURIComponent(text))); +} + function inchesToCm(inches: number): number { return Math.round(inches * INCHES_TO_CM * 10) / 10; } @@ -193,7 +226,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { return json({ trackingNumber: order.trackingNumber, labelUrl, - packingSlipBase64: '', + packingSlipBase64: buildPackingSlipBase64(order), shippingMethod: order.shippingMethod || '' }); } @@ -212,40 +245,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { totalHeight += item.heightIn * oi.quantity; } - // Build packing slip as a simple text-based content (to be rendered as PDF by the client or as an HTML page) - const packingSlipLines: string[] = [ - `PACKING SLIP`, - `Order #${order.fulfillmentId}`, - `Date: ${new Date().toLocaleDateString('en-US')}`, - ``, - `SHIP TO:`, - `${order.firstName} ${order.lastName}`, - `${order.addressLine1}`, - order.addressLine2 || '', - `${order.city}, ${order.stateProvince} ${order.postalCode || ''}`, - `${order.country}`, - ``, - `CONTENTS:`, - `${'Item'.padEnd(35)} ${'Size'.padEnd(10)} ${'Qty'.padEnd(5)}`, - `${'─'.repeat(50)}`, - ]; - - for (const oi of order.items) { - const name = oi.warehouseItem.name.substring(0, 35).padEnd(35); - const size = (oi.sizingChoice || '—').padEnd(10); - const qty = String(oi.quantity).padEnd(5); - packingSlipLines.push(`${name} ${size} ${qty}`); - } - - packingSlipLines.push(`${'─'.repeat(50)}`); - packingSlipLines.push(`Total items: ${order.items.reduce((s, oi) => s + oi.quantity, 0)}`); - if (order.notes) { - packingSlipLines.push(``); - packingSlipLines.push(`NOTES: ${order.notes}`); - } - - const packingSlipText = packingSlipLines.filter(l => l !== undefined).join('\n'); - const packingSlipBase64 = btoa(unescape(encodeURIComponent(packingSlipText))); + const packingSlipBase64 = buildPackingSlipBase64(order); let trackingNumber: string | null = null; let labelUrl: string | null = null; From 63e6c95d083fe2e108529ea8f8bc83b925f88d3f Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 18:39:42 -0400 Subject: [PATCH 073/180] fix: rename method-of-payment to intended-method-of-payment in Canada Post shipment XML --- .../src/routes/api/fulfillment/get-label/+server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 53033ae..793ecaf 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -159,7 +159,7 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, ${customerNumber} ${contractId ? `${contractId}` : ''} - ${contractId ? 'Account' : 'CreditCard'} + ${contractId ? 'Account' : 'CreditCard'} `; From c41519ef6b7b498b2fb38ed244c47edb95f714a2 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 18:48:22 -0400 Subject: [PATCH 074/180] fix: add non-delivery handling (RASE) for intl shipments, require phone number --- .../src/routes/api/fulfillment/get-label/+server.ts | 9 ++++++++- .../src/routes/app/warehouse/orders/new/+page.server.ts | 2 +- .../src/routes/app/warehouse/orders/new/+page.svelte | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 793ecaf..2104dc2 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -100,11 +100,17 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, `; }).join('\n'); - customsXml = ` + customsXml = ` + + + USD 0.730 SOG Merchandise + RASE ${skuLines} `; } @@ -131,6 +137,7 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, ${order.firstName} ${order.lastName} + ${order.phone ? `${order.phone}` : ''} ${order.addressLine1} ${order.addressLine2 ? `${order.addressLine2}` : ''} diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts index a4cc4f7..a64e821 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.server.ts @@ -60,7 +60,7 @@ export const actions: Actions = { return fail(400, { error: 'Invalid items data' }); } - if (!firstName || !lastName || !email || !addressLine1 || !city || !stateProvince || !country) { + if (!firstName || !lastName || !email || !phone || !addressLine1 || !city || !stateProvince || !country) { return fail(400, { error: 'Missing required fields' }); } diff --git a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte index 71c18c9..3ee31b4 100644 --- a/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/orders/new/+page.svelte @@ -313,8 +313,8 @@ From f723ec68db2fd6f9d132fbbf96a621ef71399bce Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 19:00:21 -0400 Subject: [PATCH 075/180] fix: remove invalid non-delivery and RASE option from Canada Post customs XML non-delivery is not a valid element in CustomsType per the shipment-v8 XSD, and RASE is not a valid shipment option code. --- .../src/routes/api/fulfillment/get-label/+server.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 2104dc2..7bffa1a 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -100,17 +100,11 @@ function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, `; }).join('\n'); - customsXml = ` - - - + customsXml = ` USD 0.730 SOG Merchandise - RASE ${skuLines} `; } From ed6511b8b2d4413563b1cf3e3def793f0fa12845 Mon Sep 17 00:00:00 2001 From: Jenin Date: Sun, 22 Mar 2026 19:26:57 -0400 Subject: [PATCH 076/180] refactor: consolidate Canada Post integration into shared module - Create /server/canada-post.ts with all CP logic: XML builders, rate fetching, shipment creation, service code mapping, lettermail - Remove 622 lines of duplicated code across 3 files - Add XML escaping on all user-provided strings - Fix customs XML to match shipment-v8 XSD (no non-delivery element) - Use CAD currency in customs declarations - Add HS tariff codes to customs items (sku-list) - Add required hsCode field to warehouseItem schema + migration - Add HS Code input to warehouse item create/edit UI --- .../drizzle/0005_add_hs_code.sql | 1 + .../src/lib/server/canada-post.ts | 539 ++++++++++++++++++ .../src/lib/server/db/schema.ts | 1 + .../api/fulfillment/get-label/+server.ts | 210 +------ .../src/routes/api/shipping-rates/+server.ts | 229 +------- .../app/warehouse-backend/+page.server.ts | 10 + .../routes/app/warehouse-backend/+page.svelte | 10 + .../app/warehouse/batches/+page.server.ts | 211 +------ 8 files changed, 589 insertions(+), 622 deletions(-) create mode 100644 resolution-frontend/drizzle/0005_add_hs_code.sql create mode 100644 resolution-frontend/src/lib/server/canada-post.ts diff --git a/resolution-frontend/drizzle/0005_add_hs_code.sql b/resolution-frontend/drizzle/0005_add_hs_code.sql new file mode 100644 index 0000000..0260b5a --- /dev/null +++ b/resolution-frontend/drizzle/0005_add_hs_code.sql @@ -0,0 +1 @@ +ALTER TABLE "warehouse_item" ADD COLUMN "hs_code" text NOT NULL DEFAULT ''; diff --git a/resolution-frontend/src/lib/server/canada-post.ts b/resolution-frontend/src/lib/server/canada-post.ts new file mode 100644 index 0000000..eb06bce --- /dev/null +++ b/resolution-frontend/src/lib/server/canada-post.ts @@ -0,0 +1,539 @@ +import { env } from '$env/dynamic/private'; +import xml2js from 'xml2js'; + +export const INCHES_TO_CM = 2.54; +export const GRAMS_TO_KG = 0.001; + +export function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function inchesToCm(inches: number): number { + return Math.round(inches * INCHES_TO_CM * 10) / 10; +} + +export function isLettermail(serviceName: string | null): boolean { + if (!serviceName) return false; + const lower = serviceName.toLowerCase(); + return lower.includes('lettermail') || lower.includes('bubble packet'); +} + +export function buildDestinationXml(country: string, postalCode?: string, stateCode?: string): string { + if (country === 'CA') { + return ` + ${(postalCode ?? '').replace(/\s/g, '').toUpperCase()} + `; + } else if (country === 'US') { + return ` + ${(postalCode ?? '').replace(/\s/g, '')} + ${stateCode ? `${escapeXml(stateCode)}` : ''} + `; + } else { + return ` + ${escapeXml(country)} + ${postalCode ? `${escapeXml(postalCode)}` : ''} + `; + } +} + +export function buildRateRequestXml(params: { + originPostal: string; + country: string; + postalCode?: string; + weightKg: number; + lengthCm: number; + widthCm: number; + heightCm: number; +}): string { + const { originPostal, country, postalCode, weightKg, lengthCm, widthCm, heightCm } = params; + return ` + + ${env.CP_CUSTOMER_NUMBER} + ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} + + ${Math.round(weightKg * 100) / 100} + + ${lengthCm} + ${widthCm} + ${heightCm} + + + ${originPostal.replace(/\s/g, '').toUpperCase()} + + ${buildDestinationXml(country, postalCode)} + +`; +} + +export function buildCreateShipmentXml(params: { + order: any; + weightKg: number; + lengthCm: number; + widthCm: number; + heightCm: number; + serviceCode: string; +}): string { + const { order, weightKg, lengthCm, widthCm, heightCm, serviceCode } = params; + const originPostal = (env.CP_ORIGIN_POSTAL_CODE || '').replace(/\s/g, '').toUpperCase(); + const customerNumber = env.CP_CUSTOMER_NUMBER; + const contractId = env.CP_CONTRACT_ID; + + let destinationXml = ''; + if (order.country === 'CA') { + destinationXml = ` + ${(order.postalCode ?? '').replace(/\s/g, '').toUpperCase()} + `; + } else if (order.country === 'US') { + destinationXml = ` + ${(order.postalCode ?? '').replace(/\s/g, '')} + ${escapeXml(order.stateProvince)} + `; + } else { + destinationXml = ` + ${escapeXml(order.country)} + ${order.postalCode ? `${escapeXml(order.postalCode)}` : ''} + `; + } + + let customsXml = ''; + if (order.country !== 'CA') { + const items = order.items || []; + const skuLines = items.map((oi: any) => { + const item = oi.warehouseItem; + const unitWeightKg = Math.round(item.weightGrams * GRAMS_TO_KG * 1000) / 1000; + const valuePerUnit = Math.round(item.costCents) / 100; + return ` + ${oi.quantity} + ${escapeXml(item.name.substring(0, 44))} + ${escapeXml(item.sku || '')} + ${escapeXml(item.hsCode || '')} + ${unitWeightKg} + ${valuePerUnit.toFixed(2)} + CA + `; + }).join('\n'); + + customsXml = ` + CAD + SOG + Merchandise + ${skuLines} + `; + } + + return ` + + + ${originPostal} + true + + ${escapeXml(serviceCode)} + + ${escapeXml(env.CP_SENDER_NAME || 'Hack Club')} + ${escapeXml(env.CP_SENDER_NAME || 'Hack Club')} + ${escapeXml(env.CP_SENDER_PHONE || '000-000-0000')} + + ${escapeXml(env.CP_SENDER_ADDRESS || '')} + ${env.CP_SENDER_ADDRESS_2 ? `${escapeXml(env.CP_SENDER_ADDRESS_2)}` : ''} + ${escapeXml(env.CP_SENDER_CITY || '')} + ${escapeXml(env.CP_SENDER_PROVINCE || '')} + CA + ${originPostal} + + + + ${escapeXml(order.firstName)} ${escapeXml(order.lastName)} + ${order.phone ? `${escapeXml(order.phone)}` : ''} + + ${escapeXml(order.addressLine1)} + ${order.addressLine2 ? `${escapeXml(order.addressLine2)}` : ''} + ${escapeXml(order.city)} + ${escapeXml(order.stateProvince)} + ${escapeXml(order.country)} + ${(order.postalCode ?? '').replace(/\s/g, '').toUpperCase()} + + + + ${Math.max(0.01, Math.round(weightKg * 1000) / 1000)} + + ${Math.max(1, lengthCm)} + ${Math.max(1, widthCm)} + ${Math.max(1, heightCm)} + + + + 4x6 + PDF + + + false + + ${customsXml} + + ${customerNumber} + ${contractId ? `${contractId}` : ''} + ${contractId ? 'Account' : 'CreditCard'} + + +`; +} + +export function getServiceCode(serviceName: string): string { + const lower = serviceName.toLowerCase(); + if (lower.includes('priority')) return 'DOM.PC'; + if (lower.includes('xpresspost') && lower.includes('international')) return 'INT.XP'; + if (lower.includes('xpresspost')) return 'DOM.XP'; + if (lower.includes('expedited') && lower.includes('usa')) return 'USA.EP'; + if (lower.includes('expedited')) return 'DOM.EP'; + if (lower.includes('regular') && lower.includes('usa')) return 'USA.PW.ENV'; + if (lower.includes('regular')) return 'DOM.RP'; + if (lower.includes('small packet') && lower.includes('usa')) return 'USA.SP.AIR'; + if (lower.includes('small packet') && lower.includes('surface')) return 'INT.SP.SURF'; + if (lower.includes('small packet') && lower.includes('air')) return 'INT.SP.AIR'; + if (lower.includes('tracked packet') && lower.includes('usa')) return 'USA.TP'; + if (lower.includes('tracked packet')) return 'INT.TP'; + if (lower.includes('surface') && lower.includes('international')) return 'INT.SP.SURF'; + if (lower.includes('air') && lower.includes('international')) return 'INT.SP.AIR'; + return 'DOM.RP'; +} + +export interface RateOption { + serviceName: string; + serviceCode: string; + priceDetails: { base: number; gst: number; pst: number; hst: number; total: number }; + deliveryDate: string; + transitDays: string; + currency: string; +} + +interface LetterMailOption { + serviceName: string; + serviceCode: string; + priceDetails: { base: number; gst: number; pst: number; hst: number; total: number }; + deliveryDate: string; + transitDays: string; + isLettermail: boolean; + note: string; +} + +export function getLetterMailOptions( + weightGrams: number, + lengthCm: number, + widthCm: number, + heightCm: number, + country: string +): LetterMailOption[] { + const options: LetterMailOption[] = []; + + const lengthMm = lengthCm * 10; + const widthMm = widthCm * 10; + const heightMm = heightCm * 10; + + const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; + const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; + const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; + + if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { + let price: number; + if (country === 'CA') price = 1.75; + else if (country === 'US') price = 2.0; + else price = 3.5; + + const countryLabel = country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'; + options.push({ + serviceName: `Lettermail ${countryLabel} (up to 30g)`, + serviceCode: 'LETTERMAIL.STD', + priceDetails: { base: price, gst: 0, pst: 0, hst: 0, total: price }, + deliveryDate: 'N/A', + transitDays: country === 'CA' ? '2-4' : country === 'US' ? '4-7' : '7-14', + isLettermail: true, + note: 'Max: 245mm x 156mm x 5mm' + }); + } + + if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { + let price: number; + const countryLabel = country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'; + + if (country === 'CA') { + if (weightGrams <= 100) price = 3.11; + else if (weightGrams <= 200) price = 4.51; + else if (weightGrams <= 300) price = 5.91; + else if (weightGrams <= 400) price = 6.62; + else price = 7.05; + } else if (country === 'US') { + if (weightGrams <= 100) price = 4.51; + else if (weightGrams <= 200) price = 7.16; + else price = 13.38; + } else { + if (weightGrams <= 100) price = 8.08; + else if (weightGrams <= 200) price = 13.38; + else price = 25.80; + } + + options.push({ + serviceName: `Bubble Packet ${countryLabel} (up to 500g)`, + serviceCode: 'BUBBLE.PACKET', + priceDetails: { base: price, gst: 0, pst: 0, hst: 0, total: price }, + deliveryDate: 'N/A', + transitDays: country === 'CA' ? '2-5' : country === 'US' ? '5-10' : '10-21', + isLettermail: true, + note: 'Max: 380mm x 270mm x 20mm' + }); + } + + return options; +} + +export function getCanadaPostConfig(): { baseUrl: string; authHeader: string; customerNumber: string } { + const username = env.CP_API_USERNAME; + const password = env.CP_API_PASSWORD; + const customerNumber = env.CP_CUSTOMER_NUMBER; + + if (!username || !password || !customerNumber) { + throw new Error('Canada Post API not configured (CP_API_USERNAME, CP_API_PASSWORD, CP_CUSTOMER_NUMBER required)'); + } + + const baseUrl = env.CP_ENVIRONMENT === 'production' + ? 'https://soa-gw.canadapost.ca' + : 'https://ct.soa-gw.canadapost.ca'; + + const authHeader = `Basic ${btoa(`${username}:${password}`)}`; + + return { baseUrl, authHeader, customerNumber }; +} + +interface PriceQuote { + 'service-name': string; + 'service-code': string; + 'price-details': { + base?: string; + due?: string; + taxes?: { + gst?: string | { $: string }; + pst?: string | { $: string }; + hst?: string | { $: string }; + }; + }; + 'service-standard'?: { + 'expected-delivery-date'?: string; + 'expected-transit-time'?: string; + }; +} + +function getTaxValue(tax: string | { $: string } | undefined): number { + if (!tax) return 0; + if (typeof tax === 'string') return parseFloat(tax) || 0; + return parseFloat(tax.$) || 0; +} + +export async function fetchRates(params: { + country: string; + postalCode?: string; + weightKg: number; + lengthCm: number; + widthCm: number; + heightCm: number; +}): Promise { + const { baseUrl, authHeader } = getCanadaPostConfig(); + const originPostal = env.CP_ORIGIN_POSTAL_CODE; + if (!originPostal) { + throw new Error('CP_ORIGIN_POSTAL_CODE is required'); + } + + const xmlBody = buildRateRequestXml({ + originPostal, + country: params.country, + postalCode: params.postalCode, + weightKg: params.weightKg, + lengthCm: params.lengthCm, + widthCm: params.widthCm, + heightCm: params.heightCm + }); + + const response = await fetch(`${baseUrl}/rs/ship/price`, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', + Accept: 'application/vnd.cpc.ship.rate-v4+xml', + Authorization: authHeader, + 'Accept-language': 'en-CA' + }, + body: xmlBody + }); + + const xmlResponse = await response.text(); + + if (!response.ok) { + console.error('Canada Post API error:', xmlResponse); + return []; + } + + const parser = new xml2js.Parser({ explicitArray: false }); + const result = await parser.parseStringPromise(xmlResponse); + const cadToUsd = 0.73; + const handlingFee = 2.0; + + const priceQuotes = result['price-quotes'] as { 'price-quote'?: PriceQuote | PriceQuote[] } | undefined; + if (!priceQuotes?.['price-quote']) return []; + + let quotes = priceQuotes['price-quote']; + if (!Array.isArray(quotes)) quotes = [quotes]; + + return quotes.map((quote) => { + const priceDetails = quote['price-details']; + const taxes = priceDetails.taxes ?? {}; + const baseTotalCAD = parseFloat(priceDetails.due ?? '0'); + const totalCAD = baseTotalCAD + handlingFee; + const totalUSD = Math.round(totalCAD * cadToUsd * 100) / 100; + + return { + serviceName: quote['service-name'], + serviceCode: quote['service-code'], + priceDetails: { + base: Math.round(parseFloat(priceDetails.base ?? '0') * cadToUsd * 100) / 100, + gst: Math.round(getTaxValue(taxes.gst) * cadToUsd * 100) / 100, + pst: Math.round(getTaxValue(taxes.pst) * cadToUsd * 100) / 100, + hst: Math.round(getTaxValue(taxes.hst) * cadToUsd * 100) / 100, + total: totalUSD + }, + deliveryDate: quote['service-standard']?.['expected-delivery-date'] ?? 'N/A', + transitDays: quote['service-standard']?.['expected-transit-time'] ?? 'N/A', + currency: 'USD' + }; + }); +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export async function createShipment(params: { + order: any; + weightKg: number; + lengthCm: number; + widthCm: number; + heightCm: number; + serviceCode: string; +}): Promise<{ trackingPin: string | null; labelBase64: string | null }> { + const { baseUrl, authHeader, customerNumber } = getCanadaPostConfig(); + + const shipmentXml = buildCreateShipmentXml(params); + + const cpEndpoint = `${baseUrl}/rs/${customerNumber}/${customerNumber}/shipment`; + + const cpRes = await fetch(cpEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.cpc.shipment-v8+xml', + Accept: 'application/vnd.cpc.shipment-v8+xml', + Authorization: authHeader, + 'Accept-language': 'en-CA' + }, + body: shipmentXml + }); + + if (!cpRes.ok) { + const errText = await cpRes.text(); + console.error('Canada Post Create Shipment error:', errText); + throw new Error(`Canada Post shipment creation failed: ${cpRes.status}`); + } + + const cpXml = await cpRes.text(); + const parser = new xml2js.Parser({ explicitArray: false }); + const cpResult = await parser.parseStringPromise(cpXml); + const shipmentInfo = cpResult['shipment-info']; + + const trackingPin: string | null = shipmentInfo?.['tracking-pin'] || null; + let labelBase64: string | null = null; + + const links = shipmentInfo?.links?.link; + if (links) { + const linkArray = Array.isArray(links) ? links : [links]; + const labelLink = linkArray.find((l: any) => l.$?.rel === 'label'); + if (labelLink?.$?.href) { + const labelRes = await fetch(labelLink.$.href, { + headers: { + Accept: 'application/pdf', + Authorization: authHeader + } + }); + if (labelRes.ok) { + const labelBuffer = await labelRes.arrayBuffer(); + labelBase64 = `data:application/pdf;base64,${arrayBufferToBase64(labelBuffer)}`; + } + } + } + + return { trackingPin, labelBase64 }; +} + +export async function fetchCheapestRate(params: { + country: string; + postalCode?: string; + weightGrams: number; + lengthIn: number; + widthIn: number; + heightIn: number; + packageType: string; +}): Promise<{ serviceName: string; shippingCostUsd: number } | null> { + const originPostal = env.CP_ORIGIN_POSTAL_CODE; + if (!originPostal || !env.CP_API_USERNAME || !env.CP_API_PASSWORD || !env.CP_CUSTOMER_NUMBER) { + return null; + } + + let effectiveLength = params.lengthIn; + let effectiveWidth = params.widthIn; + let effectivePackageType = params.packageType; + if (params.packageType === 'flat' || params.packageType === 'envelope') { + const l = Math.max(params.lengthIn, params.widthIn); + const w = Math.min(params.lengthIn, params.widthIn); + if (l <= 6 && w <= 4) { effectiveLength = 6; effectiveWidth = 4; } + else if (l <= 9 && w <= 6) { effectiveLength = 9; effectiveWidth = 6; } + else { effectiveLength = l; effectiveWidth = w; effectivePackageType = 'box'; } + } + + const lengthCm = inchesToCm(effectiveLength); + const widthCm = inchesToCm(effectiveWidth); + const heightCm = effectivePackageType === 'box' + ? inchesToCm(params.packageType === 'box' ? params.heightIn : 0.5) + : 0.5; + + const allOptions: Array<{ serviceName: string; total: number }> = []; + + const lettermailOpts = getLetterMailOptions(params.weightGrams, lengthCm, widthCm, heightCm, params.country); + for (const opt of lettermailOpts) { + allOptions.push({ serviceName: opt.serviceName, total: opt.priceDetails.total }); + } + + try { + const parcelRates = await fetchRates({ + country: params.country, + postalCode: params.postalCode, + weightKg: params.weightGrams * GRAMS_TO_KG, + lengthCm, + widthCm, + heightCm + }); + for (const rate of parcelRates) { + allOptions.push({ serviceName: rate.serviceName, total: rate.priceDetails.total }); + } + } catch (err) { + console.error('Parcel rate lookup failed:', err); + } + + if (allOptions.length === 0) return null; + + allOptions.sort((a, b) => a.total - b.total); + return { serviceName: allOptions[0].serviceName, shippingCostUsd: allOptions[0].total }; +} diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index 59a6554..b326ba2 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -296,6 +296,7 @@ export const warehouseItem = pgTable('warehouse_item', { heightIn: real('height_in').notNull(), weightGrams: real('weight_grams').notNull(), costCents: integer('cost_cents').notNull(), + hsCode: text('hs_code').notNull(), quantity: integer('quantity').notNull().default(0), imageUrl: text('image_url'), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), diff --git a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts index 7bffa1a..135f9ca 100644 --- a/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts +++ b/resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts @@ -3,11 +3,8 @@ import { json, error } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { warehouseOrder } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; -import xml2js from 'xml2js'; import type { RequestHandler } from './$types'; - -const INCHES_TO_CM = 2.54; -const GRAMS_TO_KG = 0.001; +import { GRAMS_TO_KG, inchesToCm, isLettermail, getServiceCode, createShipment } from '$lib/server/canada-post'; function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); @@ -51,142 +48,6 @@ function buildPackingSlipBase64(order: any): string { return btoa(unescape(encodeURIComponent(text))); } -function inchesToCm(inches: number): number { - return Math.round(inches * INCHES_TO_CM * 10) / 10; -} - -function isLettermail(serviceName: string | null): boolean { - if (!serviceName) return false; - const lower = serviceName.toLowerCase(); - return lower.includes('lettermail') || lower.includes('bubble packet'); -} - -function buildCreateShipmentXML(order: any, weightKg: number, lengthCm: number, widthCm: number, heightCm: number, serviceCode: string): string { - const originPostal = (env.CP_ORIGIN_POSTAL_CODE || '').replace(/\s/g, '').toUpperCase(); - const customerNumber = env.CP_CUSTOMER_NUMBER; - const contractId = env.CP_CONTRACT_ID; - - let destinationXml = ''; - if (order.country === 'CA') { - destinationXml = ` - ${(order.postalCode ?? '').replace(/\s/g, '').toUpperCase()} - `; - } else if (order.country === 'US') { - destinationXml = ` - ${(order.postalCode ?? '').replace(/\s/g, '')} - ${order.stateProvince} - `; - } else { - destinationXml = ` - ${order.country} - ${order.postalCode ? `${order.postalCode}` : ''} - `; - } - - // For US/international shipments, add customs - let customsXml = ''; - if (order.country !== 'CA') { - const items = order.items || []; - const skuLines = items.map((oi: any) => { - const item = oi.warehouseItem; - const unitWeightKg = Math.round(item.weightGrams * GRAMS_TO_KG * 1000) / 1000; - const valuePerUnit = Math.round(item.costCents) / 100; - return ` - ${oi.quantity} - ${item.name.substring(0, 44)} - ${unitWeightKg} - ${valuePerUnit.toFixed(2)} - CA - `; - }).join('\n'); - - customsXml = ` - USD - 0.730 - SOG - Merchandise - ${skuLines} - `; - } - - return ` - - - ${originPostal} - true - - ${serviceCode} - - ${env.CP_SENDER_NAME || 'Hack Club'} - ${env.CP_SENDER_NAME || 'Hack Club'} - ${env.CP_SENDER_PHONE || '000-000-0000'} - - ${env.CP_SENDER_ADDRESS || ''} - ${env.CP_SENDER_ADDRESS_2 ? `${env.CP_SENDER_ADDRESS_2}` : ''} - ${env.CP_SENDER_CITY || ''} - ${env.CP_SENDER_PROVINCE || ''} - CA - ${originPostal} - - - - ${order.firstName} ${order.lastName} - ${order.phone ? `${order.phone}` : ''} - - ${order.addressLine1} - ${order.addressLine2 ? `${order.addressLine2}` : ''} - ${order.city} - ${order.stateProvince} - ${order.country} - ${(order.postalCode ?? '').replace(/\s/g, '').toUpperCase()} - - - - ${Math.max(0.01, Math.round(weightKg * 1000) / 1000)} - - ${Math.max(1, lengthCm)} - ${Math.max(1, widthCm)} - ${Math.max(1, heightCm)} - - - - 4x6 - PDF - - ${customsXml} - - false - - - ${customerNumber} - ${contractId ? `${contractId}` : ''} - ${contractId ? 'Account' : 'CreditCard'} - - -`; -} - -// Map common service names to Canada Post service codes -function getServiceCode(serviceName: string): string { - const lower = serviceName.toLowerCase(); - if (lower.includes('priority')) return 'DOM.PC'; - if (lower.includes('xpresspost') && lower.includes('international')) return 'INT.XP'; - if (lower.includes('xpresspost')) return 'DOM.XP'; - if (lower.includes('expedited') && lower.includes('usa')) return 'USA.EP'; - if (lower.includes('expedited')) return 'DOM.EP'; - if (lower.includes('regular') && lower.includes('usa')) return 'USA.PW.ENV'; - if (lower.includes('regular')) return 'DOM.RP'; - if (lower.includes('small packet') && lower.includes('usa')) return 'USA.SP.AIR'; - if (lower.includes('small packet') && lower.includes('surface')) return 'INT.SP.SURF'; - if (lower.includes('small packet') && lower.includes('air')) return 'INT.SP.AIR'; - if (lower.includes('tracked packet') && lower.includes('usa')) return 'USA.TP'; - if (lower.includes('tracked packet')) return 'INT.TP'; - if (lower.includes('surface') && lower.includes('international')) return 'INT.SP.SURF'; - if (lower.includes('air') && lower.includes('international')) return 'INT.SP.AIR'; - // Default to regular parcel - return 'DOM.RP'; -} - export const POST: RequestHandler = async ({ request, locals }) => { const user = locals.user; if (!user) throw error(401, 'Not logged in'); @@ -331,74 +192,19 @@ export const POST: RequestHandler = async ({ request, locals }) => { // ── CANADA POST PARCEL PATH ── shippingMethod = 'canada_post'; - const cpUsername = env.CP_API_USERNAME; - const cpPassword = env.CP_API_PASSWORD; - const customerNumber = env.CP_CUSTOMER_NUMBER; - - if (!cpUsername || !cpPassword || !customerNumber) { - throw error(500, 'Canada Post API not configured'); - } - const weightKg = totalWeight * GRAMS_TO_KG; const lengthCm = inchesToCm(maxLength); const widthCm = inchesToCm(maxWidth); const heightCm = inchesToCm(totalHeight); - const serviceCode = getServiceCode(order.estimatedServiceName || ''); - const shipmentXml = buildCreateShipmentXML(order, weightKg, lengthCm, widthCm, heightCm, serviceCode); - - const cpBaseUrl = env.CP_ENVIRONMENT === 'production' - ? 'https://soa-gw.canadapost.ca' - : 'https://ct.soa-gw.canadapost.ca'; - - const mobo = customerNumber; - const cpEndpoint = `${cpBaseUrl}/rs/${customerNumber}/${mobo}/shipment`; - const authString = btoa(`${cpUsername}:${cpPassword}`); - - const cpRes = await fetch(cpEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/vnd.cpc.shipment-v8+xml', - Accept: 'application/vnd.cpc.shipment-v8+xml', - Authorization: `Basic ${authString}`, - 'Accept-language': 'en-CA' - }, - body: shipmentXml - }); - - if (!cpRes.ok) { - const errText = await cpRes.text(); - console.error('Canada Post Create Shipment error:', errText); - throw error(502, `Canada Post shipment creation failed: ${cpRes.status}`); - } - - const cpXml = await cpRes.text(); - const parser = new xml2js.Parser({ explicitArray: false }); - const cpResult = await parser.parseStringPromise(cpXml); - const shipmentInfo = cpResult['shipment-info']; - - trackingNumber = shipmentInfo?.['tracking-pin'] || null; - // Find the label link - const links = shipmentInfo?.links?.link; - if (links) { - const linkArray = Array.isArray(links) ? links : [links]; - const labelLink = linkArray.find((l: any) => l.$?.rel === 'label'); - if (labelLink?.$?.href) { - // Fetch the label PDF from Canada Post - const labelRes = await fetch(labelLink.$.href, { - headers: { - Accept: 'application/pdf', - Authorization: `Basic ${authString}` - } - }); - if (labelRes.ok) { - const labelBuffer = await labelRes.arrayBuffer(); - const labelBase64 = arrayBufferToBase64(labelBuffer); - // Store as data URL for direct use - labelUrl = `data:application/pdf;base64,${labelBase64}`; - } - } + try { + const result = await createShipment({ order, weightKg, lengthCm, widthCm, heightCm, serviceCode }); + trackingNumber = result.trackingPin; + labelUrl = result.labelBase64; + } catch (e: any) { + console.error('Canada Post Create Shipment error:', e.message); + throw error(502, `Canada Post shipment creation failed`); } } diff --git a/resolution-frontend/src/routes/api/shipping-rates/+server.ts b/resolution-frontend/src/routes/api/shipping-rates/+server.ts index 333318a..6e618fc 100644 --- a/resolution-frontend/src/routes/api/shipping-rates/+server.ts +++ b/resolution-frontend/src/routes/api/shipping-rates/+server.ts @@ -6,191 +6,7 @@ import { validateJson, shippingRateSchema } from '$lib/server/validation'; import { db } from '$lib/server/db'; import { ambassadorPathway } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; -import xml2js from 'xml2js'; - -const INCHES_TO_CM = 2.54; -const GRAMS_TO_KG = 0.001; - -function inchesToCm(inches: number): number { - return Math.round(inches * INCHES_TO_CM * 10) / 10; -} - -function buildDestinationXML(country: string, postalCode?: string): string { - if (country === 'CA') { - return ` - ${(postalCode ?? '').replace(/\s/g, '').toUpperCase()} - `; - } else if (country === 'US') { - return ` - ${(postalCode ?? '').replace(/\s/g, '')} - `; - } else { - if (postalCode) { - return ` - ${country} - ${postalCode} - `; - } - return ` - ${country} - `; - } -} - -function buildRateRequestXML( - originPostal: string, - country: string, - postalCode: string | undefined, - weightKg: number, - lengthCm: number, - widthCm: number, - heightCm: number -): string { - return ` - - ${env.CP_CUSTOMER_NUMBER} - ${env.CP_CONTRACT_ID ? `${env.CP_CONTRACT_ID}` : ''} - - ${Math.round(weightKg * 100) / 100} - - ${lengthCm} - ${widthCm} - ${heightCm} - - - ${originPostal.replace(/\s/g, '').toUpperCase()} - - ${buildDestinationXML(country, postalCode)} - -`; -} - -interface PriceQuote { - 'service-name': string; - 'service-code': string; - 'price-details': { - base?: string; - due?: string; - taxes?: { - gst?: string | { $: string }; - pst?: string | { $: string }; - hst?: string | { $: string }; - }; - }; - 'service-standard'?: { - 'expected-delivery-date'?: string; - 'expected-transit-time'?: string; - }; -} - -function getTaxValue(tax: string | { $: string } | undefined): number { - if (!tax) return 0; - if (typeof tax === 'string') return parseFloat(tax) || 0; - return parseFloat(tax.$) || 0; -} - -function formatRatesResponse(parsedXml: Record, cadToUsd: number) { - const priceQuotes = parsedXml['price-quotes'] as { 'price-quote'?: PriceQuote | PriceQuote[] } | undefined; - if (!priceQuotes?.['price-quote']) return []; - - let quotes = priceQuotes['price-quote']; - if (!Array.isArray(quotes)) quotes = [quotes]; - - return quotes.map((quote) => { - const priceDetails = quote['price-details']; - const taxes = priceDetails.taxes ?? {}; - const baseTotalCAD = parseFloat(priceDetails.due ?? '0'); - const handlingFee = 2.0; - const totalCAD = baseTotalCAD + handlingFee; - const totalUSD = Math.round(totalCAD * cadToUsd * 100) / 100; - - return { - serviceName: quote['service-name'], - serviceCode: quote['service-code'], - priceDetails: { - base: Math.round(parseFloat(priceDetails.base ?? '0') * cadToUsd * 100) / 100, - gst: Math.round(getTaxValue(taxes.gst) * cadToUsd * 100) / 100, - pst: Math.round(getTaxValue(taxes.pst) * cadToUsd * 100) / 100, - hst: Math.round(getTaxValue(taxes.hst) * cadToUsd * 100) / 100, - total: totalUSD - }, - deliveryDate: quote['service-standard']?.['expected-delivery-date'] ?? 'N/A', - transitDays: quote['service-standard']?.['expected-transit-time'] ?? 'N/A', - currency: 'USD' - }; - }); -} - -function getLetterMailOptions(weightGrams: number, lengthCm: number, widthCm: number, heightCm: number, country: string) { - const options: Array<{ - serviceName: string; - serviceCode: string; - priceDetails: { base: number; gst: number; pst: number; hst: number; total: number }; - deliveryDate: string; - transitDays: string; - isLettermail: boolean; - note: string; - }> = []; - - const lengthMm = lengthCm * 10; - const widthMm = widthCm * 10; - const heightMm = heightCm * 10; - - const meetsMinDimensions = lengthMm >= 140 && widthMm >= 90; - const isStandardSize = lengthMm <= 245 && widthMm <= 156 && heightMm <= 5; - const isOversizeSize = lengthMm <= 380 && widthMm <= 270 && heightMm <= 20; - - if (meetsMinDimensions && isStandardSize && weightGrams <= 30 && weightGrams >= 2) { - let price: number; - if (country === 'CA') price = 1.75; - else if (country === 'US') price = 2.0; - else price = 3.5; - - const countryLabel = country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'; - options.push({ - serviceName: `Lettermail ${countryLabel} (up to 30g)`, - serviceCode: 'LETTERMAIL.STD', - priceDetails: { base: price, gst: 0, pst: 0, hst: 0, total: price }, - deliveryDate: 'N/A', - transitDays: country === 'CA' ? '2-4' : country === 'US' ? '4-7' : '7-14', - isLettermail: true, - note: 'Max: 245mm x 156mm x 5mm' - }); - } - - if (isOversizeSize && weightGrams >= 5 && weightGrams <= 500) { - let price: number; - const countryLabel = country === 'CA' ? 'Domestic' : country === 'US' ? 'USA' : 'International'; - - if (country === 'CA') { - if (weightGrams <= 100) price = 3.11; - else if (weightGrams <= 200) price = 4.51; - else if (weightGrams <= 300) price = 5.91; - else if (weightGrams <= 400) price = 6.62; - else price = 7.05; - } else if (country === 'US') { - if (weightGrams <= 100) price = 4.51; - else if (weightGrams <= 200) price = 7.16; - else price = 13.38; - } else { - if (weightGrams <= 100) price = 8.08; - else if (weightGrams <= 200) price = 13.38; - else price = 25.8; - } - - options.push({ - serviceName: `Bubble Packet ${countryLabel} (up to 500g)`, - serviceCode: 'BUBBLE.PACKET', - priceDetails: { base: price, gst: 0, pst: 0, hst: 0, total: price }, - deliveryDate: 'N/A', - transitDays: country === 'CA' ? '2-5' : country === 'US' ? '5-10' : '10-21', - isLettermail: true, - note: 'Max: 380mm x 270mm x 20mm' - }); - } - - return options; -} +import { GRAMS_TO_KG, inchesToCm, fetchRates, getLetterMailOptions } from '$lib/server/canada-post'; export const POST: RequestHandler = async (event) => { const { user } = requireAuth(event); @@ -210,8 +26,7 @@ export const POST: RequestHandler = async (event) => { throw error(400, 'Postal/ZIP code is required for Canadian and US destinations'); } - const originPostal = env.CP_ORIGIN_POSTAL_CODE; - if (!originPostal || !env.CP_API_USERNAME || !env.CP_API_PASSWORD || !env.CP_CUSTOMER_NUMBER) { + if (!env.CP_ORIGIN_POSTAL_CODE) { throw error(500, 'Canada Post API not configured'); } @@ -247,36 +62,16 @@ export const POST: RequestHandler = async (event) => { const lettermailOptions = getLetterMailOptions(data.weight, lengthCm, widthCm, heightCm, data.country); - let parcelRates: ReturnType = []; + let parcelRates: Array<{ + serviceName: string; + serviceCode: string; + priceDetails: { base: number; gst: number; pst: number; hst: number; total: number }; + deliveryDate: string; + transitDays: string; + currency: string; + }> = []; try { - const cpEndpoint = env.CP_ENVIRONMENT === 'production' - ? 'https://soa-gw.canadapost.ca/rs/ship/price' - : 'https://ct.soa-gw.canadapost.ca/rs/ship/price'; - - const authString = btoa(`${env.CP_API_USERNAME}:${env.CP_API_PASSWORD}`); - const xmlBody = buildRateRequestXML(originPostal, data.country, data.postalCode, weightKg, lengthCm, widthCm, heightCm); - - const response = await fetch(cpEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/vnd.cpc.ship.rate-v4+xml', - Accept: 'application/vnd.cpc.ship.rate-v4+xml', - Authorization: `Basic ${authString}`, - 'Accept-language': 'en-CA' - }, - body: xmlBody - }); - - const xmlResponse = await response.text(); - - if (!response.ok) { - console.error('Canada Post API error:', xmlResponse); - } else { - const parser = new xml2js.Parser({ explicitArray: false }); - const result = await parser.parseStringPromise(xmlResponse); - const cadToUsd = 0.73; - parcelRates = formatRatesResponse(result, cadToUsd); - } + parcelRates = await fetchRates({ country: data.country, postalCode: data.postalCode, weightKg, lengthCm, widthCm, heightCm }); } catch (err) { console.error('Parcel rate lookup failed:', err); } @@ -285,7 +80,7 @@ export const POST: RequestHandler = async (event) => { return json({ rates: allRates, - origin: originPostal, + origin: env.CP_ORIGIN_POSTAL_CODE, destination: { country: data.country, city: data.city, diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts b/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts index f1c6739..9430e52 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.server.ts @@ -80,6 +80,7 @@ export const actions: Actions = { const heightIn = parseFloat(formData.get('heightIn') as string); const weightGrams = parseFloat(formData.get('weightGrams') as string); const costDollars = parseFloat(formData.get('costDollars') as string); + const hsCode = formData.get('hsCode') as string; const quantity = parseInt(formData.get('quantity') as string); const imageUrl = formData.get('imageUrl') as string | null; @@ -98,6 +99,9 @@ export const actions: Actions = { if (isNaN(costDollars)) { return fail(400, { error: 'Valid cost is required' }); } + if (!hsCode) { + return fail(400, { error: 'HS Code is required' }); + } await db.insert(warehouseItem).values({ name, @@ -110,6 +114,7 @@ export const actions: Actions = { heightIn, weightGrams, costCents: Math.round(costDollars * 100), + hsCode, quantity: isNaN(quantity) ? 0 : quantity, imageUrl: imageUrl || null }); @@ -130,6 +135,7 @@ export const actions: Actions = { const heightIn = parseFloat(formData.get('heightIn') as string); const weightGrams = parseFloat(formData.get('weightGrams') as string); const costDollars = parseFloat(formData.get('costDollars') as string); + const hsCode = formData.get('hsCode') as string; const quantity = parseInt(formData.get('quantity') as string); const imageUrl = formData.get('imageUrl') as string | null; @@ -151,6 +157,9 @@ export const actions: Actions = { if (isNaN(costDollars)) { return fail(400, { error: 'Valid cost is required' }); } + if (!hsCode) { + return fail(400, { error: 'HS Code is required' }); + } await db .update(warehouseItem) @@ -165,6 +174,7 @@ export const actions: Actions = { heightIn, weightGrams, costCents: Math.round(costDollars * 100), + hsCode, quantity: isNaN(quantity) ? 0 : quantity, imageUrl: imageUrl || null, updatedAt: new Date() diff --git a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte index 77b7335..e2a823c 100644 --- a/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse-backend/+page.svelte @@ -22,6 +22,7 @@ heightIn: 0, weightGrams: 0, costDollars: '', + hsCode: '', quantity: 0, imageUrl: '' }); @@ -55,6 +56,7 @@ heightIn: item.heightIn, weightGrams: item.weightGrams, costDollars: (item.costCents / 100).toFixed(2), + hsCode: item.hsCode || '', quantity: item.quantity, imageUrl: item.imageUrl || '' }; @@ -283,6 +285,10 @@ Cost ($) + + + ${order.country !== 'CA' ? ` + + ` : ''} ${Math.max(0.01, Math.round(weightKg * 1000) / 1000)} From 8234a402912c67b2bc6d273ed3ecfae5057b4b90 Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 23 Mar 2026 09:10:56 -0400 Subject: [PATCH 082/180] debug: log shipment XML to diagnose non-delivery handling error --- resolution-frontend/src/lib/server/canada-post.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/resolution-frontend/src/lib/server/canada-post.ts b/resolution-frontend/src/lib/server/canada-post.ts index c716f7b..66f686c 100644 --- a/resolution-frontend/src/lib/server/canada-post.ts +++ b/resolution-frontend/src/lib/server/canada-post.ts @@ -445,6 +445,7 @@ export async function createShipment(params: { const { baseUrl, authHeader, customerNumber } = getCanadaPostConfig(); const shipmentXml = buildCreateShipmentXml(params); + console.log('Canada Post Shipment XML:', shipmentXml); const cpEndpoint = `${baseUrl}/rs/${customerNumber}/${customerNumber}/shipment`; From c0deb140d564b051af90a17846368aaabe4679ef Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 23 Mar 2026 09:18:02 -0400 Subject: [PATCH 083/180] fix: use ABAN non-delivery for small packet services, RASE for others Small Packet services (USA.SP.AIR, INT.SP.SURF, etc.) don't support RASE. Use ABAN (Abandon) for small packets, RASE for Xpresspost/Expedited. --- resolution-frontend/src/lib/server/canada-post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolution-frontend/src/lib/server/canada-post.ts b/resolution-frontend/src/lib/server/canada-post.ts index 66f686c..89a8396 100644 --- a/resolution-frontend/src/lib/server/canada-post.ts +++ b/resolution-frontend/src/lib/server/canada-post.ts @@ -171,7 +171,7 @@ export function buildCreateShipmentXml(params: { ${order.country !== 'CA' ? ` ` : ''} From 2c835a1ad7d924c296c360d6b04fd1866787e359 Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 23 Mar 2026 09:23:10 -0400 Subject: [PATCH 084/180] fix: try RTS non-delivery option for US shipments RASE and ABAN both rejected for USA.SP.AIR. Try RTS (Return to Sender) which is the third valid non-delivery handling code. --- resolution-frontend/src/lib/server/canada-post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolution-frontend/src/lib/server/canada-post.ts b/resolution-frontend/src/lib/server/canada-post.ts index 89a8396..df1fe1a 100644 --- a/resolution-frontend/src/lib/server/canada-post.ts +++ b/resolution-frontend/src/lib/server/canada-post.ts @@ -171,7 +171,7 @@ export function buildCreateShipmentXml(params: { ${order.country !== 'CA' ? ` ` : ''} From 1330fc72cd5a8e6f15e3eadc110aad95341acbba Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 23 Mar 2026 09:32:41 -0400 Subject: [PATCH 085/180] feat: add download buttons for label PDF and packing slip - Download Label as PDF and Packing Slip as TXT alongside Print button - Labels served from base64 data URLs (no CDN), sourced from CP API - Remove debug XML logging --- .../src/lib/server/canada-post.ts | 1 - .../app/warehouse/fulfillment/+page.svelte | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/src/lib/server/canada-post.ts b/resolution-frontend/src/lib/server/canada-post.ts index df1fe1a..c845335 100644 --- a/resolution-frontend/src/lib/server/canada-post.ts +++ b/resolution-frontend/src/lib/server/canada-post.ts @@ -445,7 +445,6 @@ export async function createShipment(params: { const { baseUrl, authHeader, customerNumber } = getCanadaPostConfig(); const shipmentXml = buildCreateShipmentXml(params); - console.log('Canada Post Shipment XML:', shipmentXml); const cpEndpoint = `${baseUrl}/rs/${customerNumber}/${customerNumber}/shipment`; diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index 54a4859..aeee792 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -306,6 +306,20 @@ + {#if labelResults[order.id].labelUrl} + 📥 Label + {/if} + {#if labelResults[order.id].packingSlipBase64} + 📥 Slip + {/if} @@ -636,6 +650,17 @@ background: #ddeeff !important; } + .download-btn { + background: #f0fff0 !important; + border-color: #27ae60 !important; + color: #27ae60 !important; + text-decoration: none; + } + + .download-btn:hover { + background: #ddf5dd !important; + } + @media (max-width: 768px) { .detail-grid { grid-template-columns: 1fr; From 815bd54c4580f96f9626f48038835a4b929ee6d9 Mon Sep 17 00:00:00 2001 From: Jenin Date: Mon, 23 Mar 2026 11:04:03 -0400 Subject: [PATCH 086/180] feat: add Zonos integration for US-bound Canada Post shipments - Pass X-CPC-Zonos-Key header on US shipments for automatic duty prepayment via Zonos Verified Account - Add ZONOS_ACCOUNT_KEY env var - Show Zonos DDP badge on US shipment labels in fulfillment UI --- resolution-frontend/.env.example | 1 + resolution-frontend/src/lib/server/canada-post.ts | 3 ++- .../routes/app/warehouse/fulfillment/+page.svelte | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/resolution-frontend/.env.example b/resolution-frontend/.env.example index 729e821..8820219 100644 --- a/resolution-frontend/.env.example +++ b/resolution-frontend/.env.example @@ -18,6 +18,7 @@ CP_CUSTOMER_NUMBER=your_cp_customer_number CP_CONTRACT_ID= # optional CP_ORIGIN_POSTAL_CODE=A1A1A1 CP_ENVIRONMENT=development # or "production" +ZONOS_ACCOUNT_KEY= # Zonos Verified Account key for US duty prepayment # Hack Club CDN (for warehouse image uploads) HACK_CLUB_CDN_API_KEY=sk_cdn_your_key_here diff --git a/resolution-frontend/src/lib/server/canada-post.ts b/resolution-frontend/src/lib/server/canada-post.ts index c845335..f60e78d 100644 --- a/resolution-frontend/src/lib/server/canada-post.ts +++ b/resolution-frontend/src/lib/server/canada-post.ts @@ -457,7 +457,8 @@ export async function createShipment(params: { 'Content-Type': 'application/vnd.cpc.shipment-v8+xml', Accept: 'application/vnd.cpc.shipment-v8+xml', Authorization: authHeader, - 'Accept-language': 'en-CA' + 'Accept-language': 'en-CA', + ...(params.order.country === 'US' && env.ZONOS_ACCOUNT_KEY ? { 'X-CPC-Zonos-Key': env.ZONOS_ACCOUNT_KEY } : {}) }, body: shipmentXml }); diff --git a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte index aeee792..d25737d 100644 --- a/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte +++ b/resolution-frontend/src/routes/app/warehouse/fulfillment/+page.svelte @@ -301,6 +301,9 @@ {#if labelResults[order.id].trackingNumber} Tracking: {labelResults[order.id].trackingNumber} {/if} + {#if order.country === 'US'} + 🇺🇸 Zonos DDP (duties billed separately) + {/if}
{#if order.status === 'APPROVED' && !labelResults[order.id]} + {#if order.status === 'APPROVED' && !labelResults[order.id]} -
- + { + return async ({ result, update }) => { + if (result.type === 'failure') { + await update({ reset: false }); + } else { + await update(); + } + }; +}}> {#if step === 1}
@@ -628,6 +638,10 @@ {/if} {/if} + {#if form?.error} +
{form.error}
+ {/if} +