feat: add shop#93
Open
thesleepyniko wants to merge 53 commits into
Open
Conversation
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
work on backend for /create and / for ambassadors (so ambassador stuff) fix some stuff update schema
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| cancelledReason: text('cancelled_reason'), | ||
| createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), | ||
| updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow().$onUpdate(() => new Date()), | ||
| }) |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a complete in-app shop feature: participant-facing shop landing & item detail pages, ambassador-facing shop management (create items, fulfill orders), supporting DB schema for pathway_shop, shop_item, shop_order, and transaction_ledger, a shared shop access helper, and a dev seed routine. Also extracts addressSchema into a shared validation module and adds a hook to render fulfillment of HCB-funded orders.
Changes:
- New
/app/pathway/[pathway]/shopand/shop/[id]participant routes with cancel/purchase actions, plus ambassador/shop,/shop/create, and/shop/fufillroutes. - New
pathwayShop,shopItem,shopOrder,transactionLedgertables with shipping/fulfillment metadata, plus sharedassertShopAccess/shopErrorhelpers and dev seed wired throughhooks.server.ts. - Drizzle migration history is rewritten: existing migrations are moved under
drizzle.backup*/and a new0000_redundant_gamorabaseline +0001_sloppy_namoraare added.
Reviewed changes
Copilot reviewed 36 out of 51 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| resolution-frontend/drizzle/meta/_journal.json | Resets migration journal to two new entries; destroys history of prior 11 migrations. |
| resolution-frontend/drizzle/0000_redundant_gamora.sql | New baseline schema dump replacing prior incremental migrations. |
| resolution-frontend/drizzle/0001_sloppy_namora.sql | Adds REJECTED to shop_order_status enum. |
| resolution-frontend/src/lib/server/db/schema.ts | Adds shop/order/ledger tables (note misspelled fufilled_* / fufiller_notes). |
| resolution-frontend/src/lib/shop/utils.ts | Shared assertShopAccess and shopError (lowercase class name). |
| resolution-frontend/src/lib/server/validation/schemas.ts | Extracts addressSchema for reuse. |
| resolution-frontend/src/lib/server/devSeed.ts | Seeds shops/items in development. |
| resolution-frontend/src/hooks.server.ts | Invokes dev seed on startup. |
| resolution-frontend/src/routes/app/pathway/[pathway]/+page.svelte | Adds shop link on pathway landing. |
| resolution-frontend/src/routes/app/pathway/[pathway]/shop/+page.svelte | Participant shop list; show-more threshold bug and out-of-stock card still navigable. |
| resolution-frontend/src/routes/app/pathway/[pathway]/shop/+page.server.ts | Loads items/balance/orders + cancel action; unused imports, dead purchase block, race on stock restore. |
| resolution-frontend/src/routes/app/pathway/[pathway]/shop/[id]/+page.svelte | Purchase form with shipping fields. |
| resolution-frontend/src/routes/app/pathway/[pathway]/shop/[id]/+page.server.ts | Buy action; duplicates assertShopAccess/ShopError from $lib/shop/utils. |
| resolution-frontend/src/routes/app/ambassador/+page.svelte | Adds shop-management entry point. |
| resolution-frontend/src/routes/app/ambassador/[pathway]/shop/+layout.server.ts, +page.svelte, +page.server.ts | Ambassador shop overview & settings. |
| resolution-frontend/src/routes/app/ambassador/[pathway]/shop/create/+page.{svelte,server.ts} (+ test) | Create-item form & action with tests. |
| resolution-frontend/src/routes/app/ambassador/[pathway]/shop/fufill/+page.{svelte,server.ts} (+ test) | Order-fulfillment UI/actions; folder typo fufill, "Nonexistant", REJECTED writes to cancelledReason. |
| resolution-frontend/src/routes/api/fulfillment/get-label/+server.ts | Marks linked shop order FULFILLED when label is generated. |
| resolution-frontend/drizzle.backup/, drizzle.backup2/ | Old migrations relocated out of drizzle's path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
5
to
19
| { | ||
| "idx": 0, | ||
| "version": "7", | ||
| "when": 1770227074880, | ||
| "tag": "0000_curly_raza", | ||
| "when": 1778903874976, | ||
| "tag": "0000_redundant_gamora", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 1, | ||
| "version": "7", | ||
| "when": 1771512581182, | ||
| "tag": "0001_burly_caretaker", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 2, | ||
| "version": "7", | ||
| "when": 1772636037511, | ||
| "tag": "0002_luxuriant_maria_hill", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 3, | ||
| "version": "7", | ||
| "when": 1775185421664, | ||
| "tag": "0003_misty_week_prize_image", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 4, | ||
| "version": "7", | ||
| "when": 1776130671597, | ||
| "tag": "0004_left_pretty_boy", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 5, | ||
| "version": "7", | ||
| "when": 1776500000000, | ||
| "tag": "0005_add_package_type_and_orders", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 6, | ||
| "version": "7", | ||
| "when": 1776600000000, | ||
| "tag": "0006_add_templates_and_batches", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 7, | ||
| "version": "7", | ||
| "when": 1776700000000, | ||
| "tag": "0007_add_label_tracking_fields", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 8, | ||
| "version": "7", | ||
| "when": 1776800000000, | ||
| "tag": "0008_add_hs_code", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 9, | ||
| "version": "7", | ||
| "when": 1776900000000, | ||
| "tag": "0009_add_packaging_columns", | ||
| "breakpoints": true | ||
| }, | ||
| { | ||
| "idx": 10, | ||
| "version": "7", | ||
| "when": 1777000000000, | ||
| "tag": "0010_warehouse_indexes_and_tags", | ||
| "when": 1778950267393, | ||
| "tag": "0001_sloppy_namora", | ||
| "breakpoints": true | ||
| } | ||
| ] |
Comment on lines
+12
to
+16
| export class shopError extends Error { | ||
| constructor(public status: number, public body: { message: string }) { | ||
| super(body.message); | ||
| } | ||
| } |
Comment on lines
+17
to
+57
| class ShopError extends Error { | ||
| constructor(public status: number, public body: { message: string }) { | ||
| super(body.message); | ||
| } | ||
| } | ||
|
|
||
| // always collect a shipping address regardless of item type | ||
| const buySchema = z.object({ | ||
| userNotes: z.string().max(500).optional().default(''), | ||
| phone: z | ||
| .string() | ||
| .min(1, 'Phone number is required') | ||
| .max(30, 'Phone number is too long'), | ||
| ...addressSchema.shape | ||
| }); | ||
|
|
||
| type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]; | ||
|
|
||
| async function assertShopAccess(userId: string, pathwayParam: string, conn: DbOrTx = db) { | ||
| const pathwayId = pathwayParam.toUpperCase(); | ||
| if (!PATHWAY_IDS.includes(pathwayId as PathwayId)) throw error(404, 'Pathway not found'); | ||
| const typedPathwayId = pathwayId as PathwayId; | ||
|
|
||
| const membership = await conn | ||
| .select() | ||
| .from(userPathway) | ||
| .where(and(eq(userPathway.userId, userId), eq(userPathway.pathway, typedPathwayId))) | ||
| .limit(1); | ||
| if (membership.length === 0) throw redirect(302, '/app'); | ||
|
|
||
| const pathwayShopRow = await conn | ||
| .select() | ||
| .from(pathwayShop) | ||
| .where(eq(pathwayShop.pathway, typedPathwayId)) | ||
| .limit(1); | ||
| if (pathwayShopRow.length === 0 || !pathwayShopRow[0].isEnabled) { | ||
| throw error(404); | ||
| } | ||
|
|
||
| return { typedPathwayId, shop: pathwayShopRow[0] }; | ||
| } |
| @@ -0,0 +1,490 @@ | |||
| // NOTE: MOST OF THE HCB LOGIC IS VIBED. I DON'T TRUST MYSELF WITH WAREHOUSE OR HCB. | |||
Comment on lines
+203
to
+206
| fufillerNotes: text('fufiller_notes'), | ||
| // claimedBy: | ||
| fufilledBy: text('fufilled_by').references(() => user.id, { onDelete: 'set null' }), | ||
| fufilledAt: timestamp('fufilled_at', { mode: 'date' }), |
Comment on lines
+79
to
+108
| <a | ||
| href="/app/pathway/{data.pathwayId.toLowerCase()}/shop/{item.id}" | ||
| class="item-card" | ||
| class:disabled={outOfStock} | ||
| > | ||
| <div class="item-image"> | ||
| {#if item.itemImageUrl} | ||
| <img src={item.itemImageUrl} alt={item.name} /> | ||
| {:else} | ||
| <div class="item-image-placeholder">No image</div> | ||
| {/if} | ||
| {#if outOfStock} | ||
| <div class="overlay">Out of stock</div> | ||
| {/if} | ||
| </div> | ||
| <div class="item-body"> | ||
| <div class="item-top"> | ||
| <h3>{item.name}</h3> | ||
| </div> | ||
| <p class="item-desc">{item.description}</p> | ||
| <div class="item-foot"> | ||
| <span class="price" class:price-low={cantAfford && !outOfStock}> | ||
| {priceLabel(item.price)} | ||
| </span> | ||
| {#if item.stock !== null && !outOfStock} | ||
| <span class="stock">{item.stock} left</span> | ||
| {/if} | ||
| </div> | ||
| </div> | ||
| </a> |
Comment on lines
+32
to
+34
| function switchShowState(e: MouseEvent) { | ||
| showAll = !showAll | ||
| } |
Comment on lines
+1
to
+27
| import type { PageServerLoad, Actions } from './$types'; | ||
| import { db } from '$lib/server/db'; | ||
| import { | ||
| userPathway, | ||
| pathwayShop, | ||
| shopItem, | ||
| shopOrder, | ||
| transactionLedger | ||
| } from '$lib/server/db/schema'; | ||
| import { and, eq, sql, desc, or } from 'drizzle-orm'; | ||
| import { error, fail, redirect } from '@sveltejs/kit'; | ||
| import { PATHWAY_IDS, type PathwayId } from '$lib/pathways'; | ||
| import { z } from 'zod'; | ||
| import { addressSchema, validateFormData } from '$lib/server/validation'; | ||
| import { assertShopAccess, shopError } from '$lib/shop/utils' | ||
| import { guardAdminOrAmbassador } from '$lib/server/auth/guard'; | ||
|
|
||
| const purchaseSchema = z.object({ | ||
| itemId: z.string().min(1), | ||
| userNotes: z.string().max(500).optional(), | ||
| shippingAddress: addressSchema.optional() // optional as user might not actually be buying a physical item, in which case i don't think we need it | ||
| }); | ||
|
|
||
| const cancelSchema = z.object({ | ||
| orderId: z.string().min(1), // needs to be a valid string | ||
| cancelReason: z.string().min(1) | ||
| }); |
Comment on lines
+180
to
+190
| if (order.item) { | ||
| const [item] = await tx | ||
| .select() | ||
| .from(shopItem) | ||
| .where(eq(shopItem.id, order.item)) | ||
| .limit(1); | ||
| if (item && item.stock !== null) { | ||
| await tx.update(shopItem) | ||
| .set({ stock: item.stock + 1 }) | ||
| .where(eq(shopItem.id, item.id)); | ||
| } |
Comment on lines
+69
to
+150
| // commented out as this should probably be in ./[id] | ||
| // purchase: async ({ request, params, locals }) => { | ||
| // if (!locals.user) throw redirect(302, '/api/auth/login'); | ||
| // const userId = locals.user.id; | ||
|
|
||
| // const purchaseData = await validateFormData(purchaseSchema, request); | ||
|
|
||
| // let orderId: string; | ||
| // try { | ||
| // orderId = await db.transaction(async (tx) => { | ||
| // const { typedPathwayId } = await assertShopAccess(userId, params.pathway, tx); | ||
|
|
||
| // const [item] = await tx | ||
| // .select() | ||
| // .from(shopItem) | ||
| // .where(and( | ||
| // eq(shopItem.id, purchaseData.itemId), | ||
| // eq(shopItem.pathway, typedPathwayId), | ||
| // eq(shopItem.isActive, true) | ||
| // )) | ||
| // .limit(1); | ||
|
|
||
| // if (!item) throw new ShopError(400, { message: 'Item not found' }); | ||
|
|
||
| // if (item.itemType === 'PHYSICAL' && !purchaseData.shippingAddress) { | ||
| // throw new ShopError(400, { message: 'Shipping address required for physical items' }); | ||
| // } | ||
|
|
||
| // const [{ balance }] = await tx | ||
| // .select({ | ||
| // balance: sql<number>`COALESCE(SUM(${transactionLedger.amount}), 0)`.mapWith(Number) | ||
| // }) | ||
| // .from(transactionLedger) | ||
| // .where(and( | ||
| // eq(transactionLedger.userId, userId), | ||
| // eq(transactionLedger.pathway, typedPathwayId) | ||
| // )); | ||
|
|
||
| // if (balance < item.price) { | ||
| // throw new ShopError(400, { message: 'Not enough currency' }); | ||
| // } | ||
|
|
||
| // if (item.stock !== null) { | ||
| // if (item.stock <= 0) throw new ShopError(400, { message: 'No stock remaining' }); | ||
| // await tx.update(shopItem) | ||
| // .set({ stock: item.stock - 1 }) | ||
| // .where(eq(shopItem.id, item.id)); | ||
| // } | ||
|
|
||
| // const [order] = await tx | ||
| // .insert(shopOrder) | ||
| // .values({ | ||
| // userId, | ||
| // pathway: typedPathwayId, | ||
| // totalAmount: item.price, | ||
| // item: item.id, | ||
| // itemPriceSnapshot: item.price, | ||
| // itemTypeSnapshot: item.itemType, | ||
| // itemNameSnapshot: item.name, | ||
| // shippingAddress: purchaseData.shippingAddress ?? null, | ||
| // userNotes: purchaseData.userNotes ?? null | ||
| // }) | ||
| // .returning({ id: shopOrder.id }); | ||
|
|
||
| // await tx.insert(transactionLedger).values({ | ||
| // userId, | ||
| // pathway: typedPathwayId, | ||
| // amount: -item.price, | ||
| // reason: 'PURCHASE', | ||
| // refType: 'SHOP', | ||
| // refId: order.id // ← ties the ledger entry to the order | ||
| // }); | ||
|
|
||
| // return order.id; | ||
| // }); | ||
| // } catch (e) { | ||
| // if (e instanceof ShopError) return fail(e.status, e.body); | ||
| // throw e; | ||
| // } | ||
|
|
||
| // return { success: true, orderId }; | ||
| // }, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
as described