ReviewOS

also looking at this

stacks/pet-store

umbrella: pet-store storefront fixes (rolling)

#3
Merged glennmichael123 wants to merge umbrella/pet-store into main
586 files +38082 -7487

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

database/migrations/0000000102-create-addresses-table.sqladded+34-0
Changes to database/migrations/0000000102-create-addresses-table.sql
@@ -0,0 +1,34 @@
1-- Customer address book.
2--
3-- One row per address a customer has saved; `is_default = 1` marks
4-- the address that pre-fills checkout's shipping form. The action
5-- that flips a row to default also flips every other row for that
6-- customer back to 0, so at most one default exists per customer.
7--
8-- `name` and `phone` are denormalized from `customers` because each
9-- shipment can go to a different person at a different number — the
10-- gift-to-mom case. Pre-filling them from the customer record on
11-- the form is fine, but the row of record is per-address.
12--
13-- Postal address fields follow the rough US/CA shape; the
14-- `country` column is intentionally TEXT (ISO-2 like 'US' or full
15-- name) so storefronts can localize without a schema change.
16CREATE TABLE IF NOT EXISTS "addresses" (
17 "id" INTEGER PRIMARY KEY AUTOINCREMENT,
18 "customer_id" INTEGER NOT NULL,
19 "label" TEXT,
20 "name" TEXT NOT NULL,
21 "phone" TEXT,
22 "street_1" TEXT NOT NULL,
23 "street_2" TEXT,
24 "city" TEXT NOT NULL,
25 "region" TEXT,
26 "postal_code" TEXT NOT NULL,
27 "country" TEXT NOT NULL DEFAULT 'US',
28 "is_default" INTEGER NOT NULL DEFAULT 0,
29 "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
30 "updated_at" TEXT,
31 "uuid" TEXT
32);
33CREATE INDEX IF NOT EXISTS "addresses_customer_idx" ON "addresses" ("customer_id");
34CREATE INDEX IF NOT EXISTS "addresses_customer_default_idx" ON "addresses" ("customer_id", "is_default");
resources/views/account/index.stxmodified+9-1
Changes to resources/views/account/index.stx
@@ -61,7 +61,7 @@ if (customerId) {
6161 </div>
6262 </div>
6363
64 <div class="grid gap-4 sm:grid-cols-3 mt-10">
64 <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mt-10">
6565 <div class="p-6 border border-stone-200 rounded-3xl">
6666 <p class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Orders</p>
6767 <p class="mt-3 font-bold tabular-nums text-3xl text-stone-900">{{ orderCount }}</p>
@@ -83,6 +83,14 @@ if (customerId) {
8383 <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>
8484 </a>
8585 </div>
86 <div class="p-6 border border-stone-200 rounded-3xl">
87 <p class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Addresses</p>
88 <p class="mt-3 font-semibold text-lg text-stone-900">Address book</p>
89 <a href="/addresses" class="inline-flex gap-1 items-center mt-4 font-semibold text-sm text-stone-700 hover:text-stone-900">
90 Manage addresses
91 <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>
92 </a>
93 </div>
8694 </div>
8795
8896 <div class="mt-12">
resources/views/addresses.stxadded+200-0
Changes to resources/views/addresses.stx
@@ -0,0 +1,200 @@
1@extends('layouts/storefront')
2
3@section('title', 'Addresses — PetStore')
4@section('description', 'Manage the addresses your orders ship to.')
5
6<script server>
7const Database = require('bun:sqlite').Database
8const path = require('path')
9const { readCustomerId } = require(path.resolve(process.cwd(), 'resources/lib/customer-cookie.cjs'))
10
11const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
12
13const rawCookie = (typeof requestContext !== 'undefined') ? requestContext.cookie('stacks_customer') : null
14const customerId = readCustomerId(rawCookie)
15
16let customer = null
17let addresses = []
18let editing = null
19
20if (customerId) {
21 const db = new Database(dbPath, { readonly: true })
22 customer = db.query(`SELECT id, name, email, phone FROM customers WHERE id = ?1`).get(customerId)
23
24 if (customer) {
25 addresses = db.query(`
26 SELECT id, label, name, phone, street_1, street_2, city, region, postal_code, country, is_default, updated_at
27 FROM addresses
28 WHERE customer_id = ?1
29 ORDER BY is_default DESC, id DESC
30 `).all(customer.id)
31
32 // ?edit=<id> opens the form pre-filled with that address. Anything
33 // else (or no param) shows a fresh "add an address" form below
34 // the list.
35 const editId = Number((typeof query !== 'undefined' && query?.edit) || 0)
36 if (editId)
37 editing = addresses.find(a => Number(a.id) === editId) || null
38 }
39 db.close()
40}
41
42const noticeRaw = (typeof query !== 'undefined' && query?.notice) ? String(query.notice) : ''
43const noticeSaved = noticeRaw === 'saved'
44const noticeDeleted = noticeRaw === 'deleted'
45const noticeDefaultSet = noticeRaw === 'default-set'
46const noticeMissing = noticeRaw === 'missing-fields'
47const noticeTooLong = noticeRaw === 'field-too-long'
48</script>
49
50@section('content')
51 @if (!customer)
52 <meta http-equiv="refresh" content="0; url=/login">
53 <div class="mx-auto px-6 py-24 max-w-md text-center">
54 <p class="text-stone-600">Redirecting to <a href="/login" class="font-semibold underline">sign in</a>…</p>
55 </div>
56 @else
57 <div class="mx-auto px-6 py-12 max-w-3xl">
58 <nav class="font-semibold text-stone-500 text-xs">
59 <a href="/account" class="hover:text-stone-900">Account</a>
60 <span class="mx-2">/</span>
61 <span class="text-stone-900">Addresses</span>
62 </nav>
63
64 <h1 class="mt-3 font-bold text-4xl text-stone-900 tracking-tight">Addresses</h1>
65 <p class="mt-2 text-stone-600">{{ addresses.length === 1 ? '1 address' : `${addresses.length} addresses` }} on file. The default is what checkout pre-fills.</p>
66
67 @if (noticeSaved)
68 <div class="mt-6 px-5 py-4 text-emerald-900 text-sm bg-emerald-50 border border-emerald-200 rounded-2xl">Saved.</div>
69 @endif
70 @if (noticeDeleted)
71 <div class="mt-6 px-5 py-4 text-sm text-stone-700 bg-stone-100 border border-stone-200 rounded-2xl">Address removed.</div>
72 @endif
73 @if (noticeDefaultSet)
74 <div class="mt-6 px-5 py-4 text-emerald-900 text-sm bg-emerald-50 border border-emerald-200 rounded-2xl">Default updated.</div>
75 @endif
76 @if (noticeMissing)
77 <div class="mt-6 px-5 py-4 text-amber-900 text-sm bg-amber-50 border border-amber-200 rounded-2xl">Name, street, city, and postal code are required.</div>
78 @endif
79 @if (noticeTooLong)
80 <div class="mt-6 px-5 py-4 text-amber-900 text-sm bg-amber-50 border border-amber-200 rounded-2xl">One of those fields is too long.</div>
81 @endif
82
83 @if (addresses.length > 0)
84 <ul class="mt-8 space-y-3">
85 @foreach (addresses as addr)
86 <li class="rounded-3xl border p-6">
87 <div class="flex flex-wrap gap-4 items-start justify-between">
88 <div>
89 <div class="flex gap-2 items-center">
90 @if (addr.label)
91 <p class="font-semibold text-stone-500 text-xs tracking-wider uppercase">{{ addr.label }}</p>
92 @endif
93 @if (addr.is_default)
94 <span class="inline-flex items-center px-2 py-0.5 font-semibold text-[10px] text-stone-50 tracking-wider uppercase bg-stone-900 rounded-full">Default</span>
95 @endif
96 </div>
97 <p class="mt-1 font-semibold text-stone-900">{{ addr.name }}</p>
98 <p class="mt-1 text-sm text-stone-700 whitespace-pre-line">{{ addr.street_1 }}{{ addr.street_2 ? `\n${addr.street_2}` : '' }}
99{{ addr.city }}{{ addr.region ? `, ${addr.region}` : '' }} {{ addr.postal_code }}
100{{ addr.country }}</p>
101 @if (addr.phone)
102 <p class="mt-1 text-sm text-stone-500">{{ addr.phone }}</p>
103 @endif
104 </div>
105
106 <div class="flex flex-col gap-2 items-end">
107 <a href="/addresses?edit=" class="font-semibold text-sm text-stone-700 underline underline-offset-4 hover:text-stone-900">Edit</a>
108 @if (!addr.is_default)
109 <form action="/api/account/addresses//default" method="POST">
110 <button type="submit" class="font-semibold text-sm text-stone-700 underline underline-offset-4 hover:text-stone-900">Set default</button>
111 </form>
112 @endif
113 <form action="/api/account/addresses//delete" method="POST" onsubmit="return confirm('Remove this address?');">
114 <button type="submit" class="font-semibold text-rose-700 text-sm underline underline-offset-4 hover:text-rose-900">Remove</button>
115 </form>
116 </div>
117 </div>
118 </li>
119 @endforeach
120 </ul>
121 @endif
122
123 <section class="mt-10 p-6 border border-stone-200 rounded-3xl">
124 <h2 class="font-bold text-2xl text-stone-900 tracking-tight">{{ editing ? 'Edit address' : 'Add an address' }}</h2>
125 <p class="mt-1 text-sm text-stone-600">{{ editing ? 'Update this address. Other orders that already shipped to it stay unchanged.' : 'New addresses go straight into your address book; checkout will offer them on the shipping step.' }}</p>
126
127 <form action="/api/account/addresses" method="POST" class="grid gap-4 sm:grid-cols-2 mt-6">
128 @if (editing)
129 <input type="hidden" name="id" value="">
130 @endif
131
132 <div class="sm:col-span-2">
133 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Label (optional)</label>
134 <input type="text" name="label" maxlength="50" value="" placeholder="Home, Work, Mom's house…" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
135 </div>
136
137 <div>
138 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Name</label>
139 <input type="text" name="name" required autocomplete="name" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
140 </div>
141
142 <div>
143 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Phone</label>
144 <input type="tel" name="phone" autocomplete="tel" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
145 </div>
146
147 <div class="sm:col-span-2">
148 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Street</label>
149 <input type="text" name="street_1" required autocomplete="address-line1" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
150 </div>
151
152 <div class="sm:col-span-2">
153 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Apt / suite (optional)</label>
154 <input type="text" name="street_2" autocomplete="address-line2" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
155 </div>
156
157 <div>
158 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">City</label>
159 <input type="text" name="city" required autocomplete="address-level2" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
160 </div>
161
162 <div>
163 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">State / region</label>
164 <input type="text" name="region" autocomplete="address-level1" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
165 </div>
166
167 <div>
168 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Postal code</label>
169 <input type="text" name="postal_code" required autocomplete="postal-code" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
170 </div>
171
172 <div>
173 <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Country</label>
174 <input type="text" name="country" maxlength="2" autocomplete="country" value="US" class="mt-1 px-4 py-3 w-full text-sm uppercase border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none">
175 <p class="mt-1 text-stone-500 text-xs">2-letter ISO code (US, CA, GB, …).</p>
176 </div>
177
178 <div class="sm:col-span-2">
179 <label class="inline-flex gap-3 items-center text-sm text-stone-700">
180 <input type="checkbox" name="is_default" value="1" {{ editing?.is_default ? 'checked' : '' }} class="h-4 w-4 text-stone-900 border-stone-300 rounded focus:ring-stone-900">
181 Use as default for new orders
182 </label>
183 </div>
184
185 <div class="flex flex-wrap gap-4 items-center justify-between sm:col-span-2 pt-2">
186 <a href="/account" class="font-semibold text-sm text-stone-600 underline underline-offset-4 hover:text-stone-900">Back to account</a>
187 <div class="flex gap-3 items-center">
188 @if (editing)
189 <a href="/addresses" class="font-semibold text-sm text-stone-500 hover:text-stone-900">Cancel</a>
190 @endif
191 <button type="submit" class="inline-flex items-center justify-center px-6 py-3 font-semibold text-sm text-stone-50 bg-stone-900 hover:bg-stone-800 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 focus-visible:ring-stone-900 transition-colors">
192 {{ editing ? 'Save changes' : 'Add address' }}
193 </button>
194 </div>
195 </div>
196 </form>
197 </section>
198 </div>
199 @endif
200@endsection
routes/api.tsmodified+9-0
Changes to routes/api.ts
@@ -39,6 +39,15 @@ route.post('/api/auth/logout', 'Actions/Storefront/LogoutAction').skipCsrf()
3939// the action — no separate middleware — and rate-limits per IP.
4040route.post('/api/account/profile', 'Actions/Storefront/UpdateProfileAction').skipCsrf()
4141
42// Customer address book. Each action reads the `stacks_customer`
43// cookie inline + scopes every UPDATE/DELETE by `customer_id` so a
44// guessed row id can't be hijacked across accounts. SaveAddress
45// handles both create and update — splitting them doubled the route
46// + view code without doubling the value.
47route.post('/api/account/addresses', 'Actions/Storefront/SaveAddressAction').skipCsrf()
48route.post('/api/account/addresses/{id}/delete', 'Actions/Storefront/DeleteAddressAction').skipCsrf()
49route.post('/api/account/addresses/{id}/default', 'Actions/Storefront/SetDefaultAddressAction').skipCsrf()
50
4251// ============================================================================
4352// Admin: order fulfillment
4453//
storage/framework/defaults/app/Actions/Storefront/DeleteAddressAction.tsadded+81-0
Changes to storage/framework/defaults/app/Actions/Storefront/DeleteAddressAction.ts
@@ -0,0 +1,81 @@
1import { Action } from '@stacksjs/actions'
2import { db } from '@stacksjs/database'
3import { rateLimit, response } from '@stacksjs/router'
4import { readCustomerCookie } from '../../Storefront/CustomerCookie'
5
6const CUSTOMER_COOKIE = 'stacks_customer'
7
8/**
9 * Delete an address from the logged-in customer's address book.
10 *
11 * The action is idempotent: deleting an address that doesn't exist
12 * (or that belongs to someone else) silently no-ops, then redirects
13 * back to /addresses. We don't surface "not found" because that
14 * would leak whether a row id existed for a different customer.
15 *
16 * If the deleted row was the default, the next-most-recent address
17 * gets promoted automatically. We keep the address book having a
18 * default whenever it's non-empty so checkout pre-fill always has
19 * something to load.
20 */
21export default new Action({
22 name: 'DeleteAddressAction',
23 description: 'Remove an address from the logged-in customer\'s address book.',
24 method: 'POST',
25
26 async handle(request: any) {
27 await rateLimit('storefront-address-delete', 20).per('minute')
28
29 const customerId = readCustomerCookie(request, CUSTOMER_COOKIE)
30 if (!customerId) {
31 return request.wantsJson?.()
32 ? response.json({ success: false, message: 'Sign in first.' }, { status: 401 })
33 : response.redirect('/login')
34 }
35
36 const id = Number(request.getParam('id') ?? request.get('id') ?? 0)
37 if (!id) {
38 return request.wantsJson?.()
39 ? response.json({ success: false, message: 'Missing address id.' }, { status: 422 })
40 : response.redirect('/addresses')
41 }
42
43 const target = await (db as any)
44 .selectFrom('addresses')
45 .where('id', '=', id)
46 .where('customer_id', '=', customerId)
47 .selectAll()
48 .executeTakeFirst()
49
50 if (target) {
51 await (db as any)
52 .deleteFrom('addresses')
53 .where('id', '=', id)
54 .where('customer_id', '=', customerId)
55 .execute()
56
57 // If we just deleted the default, promote the most recent
58 // remaining address so the address book never sits in a
59 // "no default" state for someone with addresses on file.
60 if (target.is_default) {
61 const fallback = await (db as any)
62 .selectFrom('addresses')
63 .where('customer_id', '=', customerId)
64 .orderBy('id', 'desc')
65 .select('id')
66 .executeTakeFirst()
67 if (fallback?.id) {
68 await (db as any)
69 .updateTable('addresses')
70 .set({ is_default: 1, updated_at: new Date().toISOString() })
71 .where('id', '=', fallback.id)
72 .execute()
73 }
74 }
75 }
76
77 if (request.wantsJson?.())
78 return response.json({ success: true })
79 return response.redirect('/addresses?notice=deleted')
80 },
81})
storage/framework/defaults/app/Actions/Storefront/SaveAddressAction.tsadded+184-0
Changes to storage/framework/defaults/app/Actions/Storefront/SaveAddressAction.ts
@@ -0,0 +1,184 @@
1import { Action } from '@stacksjs/actions'
2import { db } from '@stacksjs/database'
3import { rateLimit, response } from '@stacksjs/router'
4import { readCustomerCookie } from '../../Storefront/CustomerCookie'
5
6const CUSTOMER_COOKIE = 'stacks_customer'
7
8/**
9 * Create or update an address in the logged-in customer's address
10 * book.
11 *
12 * The form posts the same fields whether it's a brand-new address
13 * (no `id` field) or an edit (`id` set + the row already belongs to
14 * this customer). Splitting the two paths into separate actions
15 * doubled the route + view code without doubling the value, so they
16 * share one endpoint here.
17 *
18 * Ownership: every UPDATE includes `WHERE id = ? AND customer_id = ?`
19 * so a guessed id from a different account can't be hijacked. CREATE
20 * always uses the cookie's customer_id directly.
21 *
22 * Validation is intentionally loose on the postal-address fields —
23 * different countries shape addresses differently, and we don't want
24 * to reject "1A 1AA" because it doesn't match a US ZIP regex. The
25 * carrier will fail the label if the address is truly garbage.
26 */
27export default new Action({
28 name: 'SaveAddressAction',
29 description: 'Create or update an address in the logged-in customer\'s address book.',
30 method: 'POST',
31
32 async handle(request: any) {
33 await rateLimit('storefront-address-save', 20).per('minute')
34
35 const customerId = readCustomerCookie(request, CUSTOMER_COOKIE)
36 if (!customerId) {
37 return request.wantsJson?.()
38 ? response.json({ success: false, message: 'Sign in first.' }, { status: 401 })
39 : response.redirect('/login')
40 }
41
42 const id = Number(request.get('id') ?? 0)
43 const label = String(request.get('label') ?? '').trim().slice(0, 50)
44 const name = String(request.get('name') ?? '').trim()
45 const phone = String(request.get('phone') ?? '').trim()
46 const street1 = String(request.get('street_1') ?? '').trim()
47 const street2 = String(request.get('street_2') ?? '').trim()
48 const city = String(request.get('city') ?? '').trim()
49 const region = String(request.get('region') ?? '').trim()
50 const postalCode = String(request.get('postal_code') ?? '').trim()
51 const country = String(request.get('country') ?? 'US').trim().toUpperCase().slice(0, 2)
52 const setDefault = String(request.get('is_default') ?? '') === '1'
53
54 if (!name || !street1 || !city || !postalCode) {
55 return request.wantsJson?.()
56 ? response.json({ success: false, message: 'Name, street, city, and postal code are required.' }, { status: 422 })
57 : response.redirect('/addresses?notice=missing-fields')
58 }
59 if (name.length > 255 || street1.length > 255 || street2.length > 255 || city.length > 100 || region.length > 100 || postalCode.length > 20) {
60 return request.wantsJson?.()
61 ? response.json({ success: false, message: 'One of those fields is too long.' }, { status: 422 })
62 : response.redirect('/addresses?notice=field-too-long')
63 }
64
65 const now = new Date().toISOString()
66
67 if (id > 0) {
68 // Update path. The WHERE clause includes `customer_id` so an
69 // attacker can't update someone else's row even if they guess
70 // the id.
71 const existing = await (db as any)
72 .selectFrom('addresses')
73 .where('id', '=', id)
74 .where('customer_id', '=', customerId)
75 .selectAll()
76 .executeTakeFirst()
77
78 if (!existing) {
79 return request.wantsJson?.()
80 ? response.json({ success: false, message: 'Address not found.' }, { status: 404 })
81 : response.redirect('/addresses?notice=not-found')
82 }
83
84 await (db as any)
85 .updateTable('addresses')
86 .set({
87 label: label || null,
88 name,
89 phone: phone || null,
90 street_1: street1,
91 street_2: street2 || null,
92 city,
93 region: region || null,
94 postal_code: postalCode,
95 country,
96 updated_at: now,
97 })
98 .where('id', '=', id)
99 .where('customer_id', '=', customerId)
100 .execute()
101
102 if (setDefault)
103 await markDefault(customerId, id, now)
104 }
105 else {
106 // Insert. If the customer has no addresses yet, this one auto-
107 // wins the default flag — saves a second click on first save.
108 const existing = await (db as any)
109 .selectFrom('addresses')
110 .where('customer_id', '=', customerId)
111 .select('id')
112 .execute()
113
114 const isFirst = existing.length === 0
115
116 const result = await (db as any)
117 .insertInto('addresses')
118 .values({
119 customer_id: customerId,
120 uuid: crypto.randomUUID(),
121 label: label || null,
122 name,
123 phone: phone || null,
124 street_1: street1,
125 street_2: street2 || null,
126 city,
127 region: region || null,
128 postal_code: postalCode,
129 country,
130 is_default: setDefault || isFirst ? 1 : 0,
131 created_at: now,
132 })
133 .executeTakeFirstOrThrow?.() ?? await (db as any)
134 .insertInto('addresses')
135 .values({
136 customer_id: customerId,
137 uuid: crypto.randomUUID(),
138 label: label || null,
139 name,
140 phone: phone || null,
141 street_1: street1,
142 street_2: street2 || null,
143 city,
144 region: region || null,
145 postal_code: postalCode,
146 country,
147 is_default: setDefault || isFirst ? 1 : 0,
148 created_at: now,
149 })
150 .execute()
151
152 // If the user explicitly checked "set as default" on a non-
153 // first address, demote the previous default. (For first-
154 // address inserts the auto-default flag above already wins.)
155 if (setDefault && !isFirst) {
156 const newId = Number(result?.insertId ?? 0)
157 if (newId > 0)
158 await markDefault(customerId, newId, now)
159 }
160 }
161
162 if (request.wantsJson?.())
163 return response.json({ success: true })
164 return response.redirect('/addresses?notice=saved')
165 },
166})
167
168async function markDefault(customerId: number, addressId: number, now: string) {
169 // Demote every other address for this customer, then promote the
170 // target. Two writes; sqlite has no atomic "set this one to 1 and
171 // every other to 0" idiom that's cleaner than this pair.
172 await (db as any)
173 .updateTable('addresses')
174 .set({ is_default: 0, updated_at: now })
175 .where('customer_id', '=', customerId)
176 .where('id', '!=', addressId)
177 .execute()
178 await (db as any)
179 .updateTable('addresses')
180 .set({ is_default: 1, updated_at: now })
181 .where('id', '=', addressId)
182 .where('customer_id', '=', customerId)
183 .execute()
184}
storage/framework/defaults/app/Actions/Storefront/SetDefaultAddressAction.tsadded+74-0
Changes to storage/framework/defaults/app/Actions/Storefront/SetDefaultAddressAction.ts
@@ -0,0 +1,74 @@
1import { Action } from '@stacksjs/actions'
2import { db } from '@stacksjs/database'
3import { rateLimit, response } from '@stacksjs/router'
4import { readCustomerCookie } from '../../Storefront/CustomerCookie'
5
6const CUSTOMER_COOKIE = 'stacks_customer'
7
8/**
9 * Mark one address as the default for the logged-in customer; demote
10 * every other address for that same customer to non-default in the
11 * same flow. Two writes — sqlite has no atomic "flip every row that
12 * matches X to 0 except this one" idiom that's cleaner than a pair.
13 *
14 * Defaults are mutually exclusive per customer: at most one row per
15 * customer has `is_default = 1`. Checkout's pre-fill flow reads that
16 * row, so keeping the invariant matters.
17 */
18export default new Action({
19 name: 'SetDefaultAddressAction',
20 description: 'Mark one address as the customer\'s default; demote the others.',
21 method: 'POST',
22
23 async handle(request: any) {
24 await rateLimit('storefront-address-default', 20).per('minute')
25
26 const customerId = readCustomerCookie(request, CUSTOMER_COOKIE)
27 if (!customerId) {
28 return request.wantsJson?.()
29 ? response.json({ success: false, message: 'Sign in first.' }, { status: 401 })
30 : response.redirect('/login')
31 }
32
33 const id = Number(request.getParam('id') ?? request.get('id') ?? 0)
34 if (!id) {
35 return request.wantsJson?.()
36 ? response.json({ success: false, message: 'Missing address id.' }, { status: 422 })
37 : response.redirect('/addresses')
38 }
39
40 // Verify ownership before doing the demote/promote pair so we
41 // don't accidentally clear someone else's default just because
42 // they got the same id.
43 const target = await (db as any)
44 .selectFrom('addresses')
45 .where('id', '=', id)
46 .where('customer_id', '=', customerId)
47 .select('id')
48 .executeTakeFirst()
49
50 if (!target) {
51 return request.wantsJson?.()
52 ? response.json({ success: false, message: 'Address not found.' }, { status: 404 })
53 : response.redirect('/addresses?notice=not-found')
54 }
55
56 const now = new Date().toISOString()
57 await (db as any)
58 .updateTable('addresses')
59 .set({ is_default: 0, updated_at: now })
60 .where('customer_id', '=', customerId)
61 .where('id', '!=', id)
62 .execute()
63 await (db as any)
64 .updateTable('addresses')
65 .set({ is_default: 1, updated_at: now })
66 .where('id', '=', id)
67 .where('customer_id', '=', customerId)
68 .execute()
69
70 if (request.wantsJson?.())
71 return response.json({ success: true })
72 return response.redirect('/addresses?notice=default-set')
73 },
74})