Skip to content

feat: add shop#93

Open
thesleepyniko wants to merge 53 commits into
mainfrom
feat/shop
Open

feat: add shop#93
thesleepyniko wants to merge 53 commits into
mainfrom
feat/shop

Conversation

@thesleepyniko
Copy link
Copy Markdown
Collaborator

as described

thesleepyniko and others added 30 commits May 5, 2026 12:43
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
resolution Ready Ready Preview, Comment May 16, 2026 6:58pm

Request Review

cancelledReason: text('cancelled_reason'),
createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow().$onUpdate(() => new Date()),
})
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]/shop and /shop/[id] participant routes with cancel/purchase actions, plus ambassador /shop, /shop/create, and /shop/fufill routes.
  • New pathwayShop, shopItem, shopOrder, transactionLedger tables with shipping/fulfillment metadata, plus shared assertShopAccess/shopError helpers and dev seed wired through hooks.server.ts.
  • Drizzle migration history is rewritten: existing migrations are moved under drizzle.backup*/ and a new 0000_redundant_gamora baseline + 0001_sloppy_namora are 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 };
// },
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants