Add an admin panel: orders dashboard, menu management, kitchen status

Introduces a role-gated /admin section (sidebar layout, requireAdmin
checked both in route loaders and independently in every admin server
function) with three sections:
- Orders: every order across all customers, and an admin-only order
  detail view nested under /admin so the sidebar persists and admin
  access bypasses the guest-token check.
- Menu: create/edit/delete menu items, their options, and their
  preparation steps; deleting an item with past orders is refused
  (protected by the orderItems FK) instead of crashing.
- Kitchen: read-only live view of every machine's busy/idle status and
  per-tag queue depth, with a manual refresh.

Also:
- Adds a `role` field to the user table (Better Auth additionalFields,
  input:false so it can't be self-assigned) and a db:seed-admin script
  to create/promote an admin account.
- Fixes a real bug found while building this: src/server/auth.ts's
  shared session helpers were plain exported functions using a
  server-only import, which the framework couldn't tree-shake out of
  the client bundle once shared across multiple server/*.ts modules —
  this silently broke hydration app-wide. Fixed with createServerOnlyFn.
- Removes the now-fully-unused @tanstack/devtools-vite plugin.
- Disables view transitions for in-admin navigation so the persistent
  sidebar doesn't cross-fade/shift on every click.
- Gives the "your orders" empty state the same visual treatment as the
  basket page's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 23:14:28 +02:00
co-authored by Claude Sonnet 5
parent 18a7d2030e
commit c8d1721358
37 changed files with 2224 additions and 24 deletions
+6
View File
@@ -14,3 +14,9 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
APPLE_CLIENT_ID= APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET= APPLE_CLIENT_SECRET=
# Used by `npm run db:seed-admin` to create/promote one admin account
# (can view every order at /admin/orders). Not read by the app itself.
ADMIN_EMAIL=
ADMIN_PASSWORD=
ADMIN_NAME=
+1
View File
@@ -16,6 +16,7 @@
"db:pull": "drizzle-kit pull", "db:pull": "drizzle-kit pull",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts", "db:seed": "tsx src/db/seed.ts",
"db:seed-admin": "tsx src/db/seed-admin.ts",
"auth:migrate": "better-auth migrate", "auth:migrate": "better-auth migrate",
"lint": "eslint", "lint": "eslint",
"format": "prettier --write . && eslint --fix", "format": "prettier --write . && eslint --fix",
@@ -0,0 +1,129 @@
@use '../../styles/variables' as *;
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
margin-bottom: $space-4;
}
.heading {
margin: 0;
}
.addLink {
padding: $space-2 $space-4;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.empty {
color: $color-text-muted;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.itemInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.itemHeader {
display: flex;
align-items: baseline;
gap: $space-2;
}
.itemName {
font-weight: 600;
}
.itemPrice {
color: $color-text-muted;
font-size: 0.9rem;
}
.itemMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemActions {
display: flex;
align-items: center;
gap: $space-2;
flex: 0 0 auto;
}
.editLink {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
color: $color-text;
text-decoration: none;
&:hover {
border-color: $color-primary;
}
}
.deleteButton {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-danger;
cursor: pointer;
&:hover {
border-color: $color-danger;
}
}
.confirmButton {
padding: $space-1 $space-3;
border: none;
border-radius: $radius;
background: $color-danger;
color: $color-primary-contrast;
cursor: pointer;
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: $space-2 0 0;
}
+108
View File
@@ -0,0 +1,108 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import type { AdminMenuItem } from '#/domain/menu.ts'
import { formatPrice } from '#/lib/format.ts'
import styles from './AdminMenuListView.module.scss'
type AdminMenuListViewProps = {
items: Array<AdminMenuItem>
onDelete: (menuItemId: number) => Promise<void>
}
export const AdminMenuListView = ({
items,
onDelete,
}: AdminMenuListViewProps) => {
const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const confirmDelete = async (menuItemId: number) => {
setDeletingId(menuItemId)
setError(null)
try {
await onDelete(menuItemId)
} catch (deleteError) {
setError(
deleteError instanceof Error
? deleteError.message
: 'Something went wrong.',
)
} finally {
setDeletingId(null)
setConfirmingId(null)
}
}
return (
<section>
<div className={styles.header}>
<h1 className={styles.heading}>Menu</h1>
<Link
to="/admin/menu/new"
className={styles.addLink}
viewTransition={false}
>
Add item
</Link>
</div>
{items.length === 0 ? (
<p className={styles.empty}>No menu items yet.</p>
) : (
<ul className={styles.items}>
{items.map((item) => (
<li key={item.id} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.name}</span>
<span className={styles.itemPrice}>
{formatPrice(item.priceCents)}
</span>
</div>
<p className={styles.itemMeta}>
{item.steps.length} step
{item.steps.length === 1 ? '' : 's'} ·{' '}
{item.options.length} option
{item.options.length === 1 ? '' : 's'}
{item.description ? ` · ${item.description}` : ''}
</p>
</div>
<div className={styles.itemActions}>
<Link
to="/admin/menu/$menuItemId"
params={{ menuItemId: String(item.id) }}
className={styles.editLink}
viewTransition={false}
>
Edit
</Link>
{confirmingId === item.id ? (
<button
type="button"
className={styles.confirmButton}
disabled={deletingId === item.id}
onClick={() => void confirmDelete(item.id)}
>
{deletingId === item.id ? 'Deleting…' : 'Confirm?'}
</button>
) : (
<button
type="button"
className={styles.deleteButton}
onClick={() => setConfirmingId(item.id)}
>
Delete
</button>
)}
</div>
</li>
))}
</ul>
)}
{error && <p className={styles.error}>{error}</p>}
</section>
)
}
@@ -0,0 +1,145 @@
@use '../../styles/variables' as *;
.form {
display: flex;
flex-direction: column;
gap: $space-4;
max-width: 640px;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input,
textarea,
select {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
font-family: inherit;
color: $color-text;
}
textarea {
resize: vertical;
min-height: 4.5rem;
}
}
.row {
display: flex;
gap: $space-2;
}
.section {
display: flex;
flex-direction: column;
gap: $space-2;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.sectionTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.repeatingRow {
display: flex;
align-items: flex-start;
gap: $space-2;
.field {
flex: 1;
}
}
.stepName {
flex: 2;
}
.narrowField {
flex: 1;
}
.removeButton {
flex: 0 0 auto;
width: 2.25rem;
height: 2.25rem;
margin-top: 1.35rem;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-danger;
cursor: pointer;
&:hover {
border-color: $color-danger;
}
}
.addButton {
align-self: flex-start;
padding: $space-1 $space-3;
border: 1px dashed $color-border;
border-radius: $radius;
background: $color-background;
cursor: pointer;
font-size: 0.85rem;
&:hover {
border-color: $color-primary;
color: $color-primary;
}
}
.actions {
display: flex;
align-items: center;
gap: $space-3;
}
.submitButton {
padding: $space-2 $space-4;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cancelLink {
color: $color-text-muted;
text-decoration: none;
&:hover {
color: $color-text;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
+268
View File
@@ -0,0 +1,268 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import type { AdminMenuItem, MenuItemInput } from '#/domain/menu.ts'
import type { MachineTagOption } from '#/domain/kitchen.ts'
import styles from './MenuItemForm.module.scss'
type OptionRow = { name: string; priceDelta: string }
type StepRow = { name: string; machineTagId: string; durationSeconds: string }
const toOptionRows = (item?: AdminMenuItem): Array<OptionRow> =>
item?.options.map((option) => ({
name: option.name,
priceDelta: (option.priceDeltaCents / 100).toString(),
})) ?? []
const toStepRows = (item?: AdminMenuItem): Array<StepRow> =>
item?.steps.map((step) => ({
name: step.name,
machineTagId: String(step.machineTagId),
durationSeconds: String(step.estimatedDurationSeconds),
})) ?? []
type MenuItemFormProps = {
machineTags: Array<MachineTagOption>
initialItem?: AdminMenuItem
submitLabel: string
onSubmit: (input: MenuItemInput) => Promise<void>
}
export const MenuItemForm = ({
machineTags,
initialItem,
submitLabel,
onSubmit,
}: MenuItemFormProps) => {
const [name, setName] = useState(initialItem?.name ?? '')
const [description, setDescription] = useState(
initialItem?.description ?? '',
)
const [price, setPrice] = useState(
initialItem ? (initialItem.priceCents / 100).toString() : '',
)
const [options, setOptions] = useState<Array<OptionRow>>(
toOptionRows(initialItem),
)
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const updateOption = (index: number, patch: Partial<OptionRow>) =>
setOptions((rows) =>
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
const updateStep = (index: number, patch: Partial<StepRow>) =>
setSteps((rows) =>
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
const submit = async (event: FormEvent) => {
event.preventDefault()
setError(null)
if (steps.length === 0) {
setError('Add at least one Preparation Step — otherwise this item can never be cooked.')
return
}
const input: MenuItemInput = {
name,
description: description || undefined,
priceCents: Math.round(Number(price) * 100),
options: options
.filter((option) => option.name.trim() !== '')
.map((option) => ({
name: option.name,
priceDeltaCents: Math.round(Number(option.priceDelta || '0') * 100),
})),
steps: steps.map((step) => ({
name: step.name,
machineTagId: Number(step.machineTagId),
estimatedDurationSeconds: Math.round(Number(step.durationSeconds)),
})),
}
setSubmitting(true)
try {
await onSubmit(input)
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: 'Something went wrong.',
)
} finally {
setSubmitting(false)
}
}
return (
<form className={styles.form} onSubmit={submit}>
<label className={styles.field}>
Name
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Description
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<label className={styles.field}>
Price
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={(event) => setPrice(event.target.value)}
required
/>
</label>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<p className={styles.sectionTitle}>Options</p>
</div>
{options.map((option, index) => (
<div key={index} className={styles.repeatingRow}>
<label className={styles.field}>
Name
<input
value={option.name}
onChange={(event) =>
updateOption(index, { name: event.target.value })
}
/>
</label>
<label className={`${styles.field} ${styles.narrowField}`}>
Price delta
<input
type="number"
step="0.01"
value={option.priceDelta}
onChange={(event) =>
updateOption(index, { priceDelta: event.target.value })
}
/>
</label>
<button
type="button"
className={styles.removeButton}
aria-label={`Remove option ${index + 1}`}
onClick={() =>
setOptions((rows) => rows.filter((_, i) => i !== index))
}
>
×
</button>
</div>
))}
<button
type="button"
className={styles.addButton}
onClick={() =>
setOptions((rows) => [...rows, { name: '', priceDelta: '0' }])
}
>
Add option
</button>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<p className={styles.sectionTitle}>Preparation steps</p>
</div>
{steps.map((step, index) => (
<div key={index} className={styles.repeatingRow}>
<label className={`${styles.field} ${styles.stepName}`}>
Step name
<input
value={step.name}
onChange={(event) =>
updateStep(index, { name: event.target.value })
}
/>
</label>
<label className={`${styles.field} ${styles.narrowField}`}>
Machine
<select
value={step.machineTagId}
onChange={(event) =>
updateStep(index, { machineTagId: event.target.value })
}
>
<option value="" disabled>
Select…
</option>
{machineTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
</label>
<label className={`${styles.field} ${styles.narrowField}`}>
Duration (s)
<input
type="number"
min="1"
value={step.durationSeconds}
onChange={(event) =>
updateStep(index, { durationSeconds: event.target.value })
}
/>
</label>
<button
type="button"
className={styles.removeButton}
aria-label={`Remove step ${index + 1}`}
onClick={() =>
setSteps((rows) => rows.filter((_, i) => i !== index))
}
>
×
</button>
</div>
))}
<button
type="button"
className={styles.addButton}
onClick={() =>
setSteps((rows) => [
...rows,
{ name: '', machineTagId: '', durationSeconds: '60' },
])
}
>
Add step
</button>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.actions}>
<button type="submit" className={styles.submitButton} disabled={submitting}>
{submitting ? 'Saving…' : submitLabel}
</button>
<Link
to="/admin/menu"
className={styles.cancelLink}
viewTransition={false}
>
Cancel
</Link>
</div>
</form>
)
}
@@ -0,0 +1,69 @@
@use '../../styles/variables' as *;
.page {
max-width: 960px;
margin: 0 auto;
padding: $space-5 $space-3;
}
.heading {
margin: 0 0 $space-4;
}
.empty {
color: $color-text-muted;
}
.orders {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-3;
}
.order {
display: block;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.orderHeader {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: $space-1;
}
.orderStatus {
text-transform: capitalize;
color: $color-text-muted;
font-weight: 500;
}
.orderCustomer {
margin: 0 0 $space-1;
font-size: 0.9rem;
color: $color-text-muted;
}
.orderItems {
margin: 0 0 $space-1;
font-size: 0.9rem;
}
.orderMeta {
margin: 0;
font-size: 0.85rem;
color: $color-text-muted;
}
+62
View File
@@ -0,0 +1,62 @@
import { Link } from '@tanstack/react-router'
import type { AdminOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts'
import styles from './AdminOrdersView.module.scss'
type AdminOrdersViewProps = {
orders: Array<AdminOrderSummary>
}
const formatItems = (items: AdminOrderSummary['items']) =>
items
.map(({ menuItemName, quantity }) =>
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
)
.join(', ')
const formatCustomer = (customer: AdminOrderSummary['customer']) =>
customer.type === 'account'
? `Account ${customer.customerId}`
: `${customer.name} (guest) · ${customer.contact}`
export const AdminOrdersView = ({ orders }: AdminOrdersViewProps) => (
<section className={styles.page}>
<h1 className={styles.heading}>All orders</h1>
{orders.length === 0 ? (
<p className={styles.empty}>No orders have been placed yet.</p>
) : (
<ul className={styles.orders}>
{orders.map((order) => (
<li key={order.id}>
<Link
to="/admin/orders/$orderId"
params={{ orderId: String(order.id) }}
className={styles.order}
viewTransition={false}
>
<div className={styles.orderHeader}>
<span>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span>
</div>
<p className={styles.orderCustomer}>
{formatCustomer(order.customer)}
</p>
<p className={styles.orderItems}>{formatItems(order.items)}</p>
<p className={styles.orderMeta}>
{order.createdAt.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}{' '}
· {formatPrice(order.totalCents)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
)
+8 -2
View File
@@ -69,11 +69,13 @@ export type OrderStatusResult = {
type OrderStatusViewProps = { type OrderStatusViewProps = {
status: OrderStatusResult status: OrderStatusResult
guestAccessToken: string | undefined guestAccessToken: string | undefined
backTo?: '/orders' | '/admin/orders'
} }
export const OrderStatusView = ({ export const OrderStatusView = ({
status, status,
guestAccessToken, guestAccessToken,
backTo = '/orders',
}: OrderStatusViewProps) => { }: OrderStatusViewProps) => {
const router = useRouter() const router = useRouter()
const [pendingItemId, setPendingItemId] = useState<number | null>(null) const [pendingItemId, setPendingItemId] = useState<number | null>(null)
@@ -94,9 +96,13 @@ export const OrderStatusView = ({
return ( return (
<section className={styles.page}> <section className={styles.page}>
<Link to="/orders" className={styles.backLink}> <Link
to={backTo}
className={styles.backLink}
viewTransition={backTo !== '/admin/orders'}
>
<BackArrowIcon /> <BackArrowIcon />
Back to orders {backTo === '/admin/orders' ? 'Back to all orders' : 'Back to orders'}
</Link> </Link>
<div style={{ viewTransitionName: `order-card-${status.id}` }}> <div style={{ viewTransitionName: `order-card-${status.id}` }}>
<h1 className={styles.heading}>Order #{status.id}</h1> <h1 className={styles.heading}>Order #{status.id}</h1>
@@ -11,10 +11,46 @@
} }
.empty { .empty {
color: $color-text-muted; display: flex;
flex-direction: column;
align-items: center;
gap: $space-1;
padding: $space-5 $space-3;
text-align: center;
border: 1px dashed $color-border;
border-radius: $radius;
background: $color-surface;
a { svg {
color: $color-primary; width: 2.5rem;
height: 2.5rem;
margin-bottom: $space-2;
color: $color-text-muted;
}
}
.emptyTitle {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.emptyText {
margin: 0 0 $space-3;
color: $color-text-muted;
}
.emptyCta {
padding: $space-2 $space-4;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
} }
} }
+25 -3
View File
@@ -9,6 +9,22 @@ type OrdersListViewProps = {
orders: Array<CustomerOrderSummary> orders: Array<CustomerOrderSummary>
} }
const OrdersIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 3h6l1 3H8l1-3Z" />
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
<path d="M9 11h6" />
</svg>
)
const formatItems = (items: CustomerOrderSummary['items']) => const formatItems = (items: CustomerOrderSummary['items']) =>
items items
.map(({ menuItemName, quantity }) => .map(({ menuItemName, quantity }) =>
@@ -20,10 +36,16 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
<section className={styles.page}> <section className={styles.page}>
<h1 className={styles.heading}>Your orders</h1> <h1 className={styles.heading}>Your orders</h1>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className={styles.empty}> <div className={styles.empty}>
You haven't placed an order yet — <Link to="/">browse the menu</Link>{' '} <OrdersIcon />
to get started. <p className={styles.emptyTitle}>No orders yet</p>
<p className={styles.emptyText}>
Your placed orders will show up here.
</p> </p>
<Link to="/" className={styles.emptyCta}>
Browse the menu
</Link>
</div>
) : ( ) : (
<ul className={styles.orders}> <ul className={styles.orders}>
{orders.map((order) => ( {orders.map((order) => (
+65
View File
@@ -0,0 +1,65 @@
import { sql } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type * as schema from '#/db/schema.ts'
import type { auth as Auth } from '#/lib/auth.ts'
// Drizzle types a raw `.execute()`'s rows as always present, even though
// zero rows is a real possibility at runtime (without
// noUncheckedIndexedAccess) — mirrors the firstRow helper in
// domain/ordering.ts.
const firstRow = <T>(rows: Array<T>): T | undefined => rows[0]
// The `role` field is `input: false` in the Better Auth config (see
// src/lib/auth.ts) so it can't be set through sign-up itself — an admin
// account is always a normal sign-up followed by a direct DB update here,
// never something the sign-up API can be asked to do.
export const seedAdmin = async (
db: NodePgDatabase<typeof schema>,
auth: typeof Auth,
email: string,
password: string,
name = 'Admin',
) => {
let userId: string
try {
const signUp = await auth.api.signUpEmail({ body: { email, password, name } })
userId = signUp.user.id
} catch {
// Already signed up (e.g. this script has run before) — just make sure
// the existing account has the admin role.
const existing = await db.execute<{ id: string }>(
sql`select id from "user" where email = ${email}`,
)
const user = firstRow(existing.rows)
if (!user) throw new Error(`Sign-up failed and no existing user found for ${email}`)
userId = user.id
}
await db.execute(sql`update "user" set role = 'admin' where id = ${userId}`)
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { config } = await import('dotenv')
config({ path: ['.env.local', '.env'] })
const email = process.env.ADMIN_EMAIL
const password = process.env.ADMIN_PASSWORD
if (!email || !password) {
console.error(
'Set ADMIN_EMAIL and ADMIN_PASSWORD (in .env.local or the environment) before running this script.',
)
process.exit(1)
}
// Imported dynamically, after dotenv config runs, since both #/db/index.ts
// and #/lib/auth.ts read DATABASE_URL from process.env at module load time.
const { drizzle } = await import('drizzle-orm/node-postgres')
const schemaModule = await import('#/db/schema.ts')
const { auth } = await import('#/lib/auth.ts')
const db = drizzle(process.env.DATABASE_URL!, { schema: schemaModule })
await seedAdmin(db, auth, email, password, process.env.ADMIN_NAME)
console.log(`Created admin account for ${email}.`)
process.exit(0)
}
+150
View File
@@ -0,0 +1,150 @@
import { and, eq, sql } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
type Db = NodePgDatabase<typeof schema>
export type MachineTagOption = { id: number; name: string }
export const getMachineTags = async (
db: Db,
): Promise<Array<MachineTagOption>> =>
db.select({ id: schema.machineTags.id, name: schema.machineTags.name }).from(
schema.machineTags,
)
export type MachineStatus = {
id: number
name: string
tagName: string
status: 'busy' | 'idle'
current: {
stepName: string
menuItemName: string
orderId: number
startedAt: Date | null
} | null
}
export type TagQueueSummary = {
tagName: string
idleMachineCount: number
busyMachineCount: number
queuedStepCount: number
}
export type KitchenStatus = {
machines: Array<MachineStatus>
queues: Array<TagQueueSummary>
}
// A snapshot of every Machine's current work, and how many Preparation
// Steps are still waiting per Tag — the admin "Kitchen" page's whole model.
// Read-only: nothing here mutates state (see assignQueuedStepsForTag in
// ordering.ts for how a Machine actually picks up a queued Step).
export const getKitchenStatus = async (db: Db): Promise<KitchenStatus> => {
const machines = await db
.select({
id: schema.machines.id,
name: schema.machines.name,
tagId: schema.machineTags.id,
tagName: schema.machineTags.name,
})
.from(schema.machines)
.innerJoin(
schema.machineTags,
eq(schema.machines.machineTagId, schema.machineTags.id),
)
.orderBy(schema.machineTags.name, schema.machines.name)
const busyInstances = await db
.select({
machineId: schema.preparationStepInstances.machineId,
startedAt: schema.preparationStepInstances.startedAt,
stepName: schema.orderItemSteps.name,
orderId: schema.orders.id,
menuItemName: schema.menuItems.name,
})
.from(schema.preparationStepInstances)
.innerJoin(
schema.orderItems,
eq(schema.preparationStepInstances.orderItemId, schema.orderItems.id),
)
.innerJoin(schema.orders, eq(schema.orderItems.orderId, schema.orders.id))
.innerJoin(
schema.menuItems,
eq(schema.orderItems.menuItemId, schema.menuItems.id),
)
.innerJoin(
schema.orderItemSteps,
and(
eq(
schema.orderItemSteps.orderItemId,
schema.preparationStepInstances.orderItemId,
),
eq(
schema.orderItemSteps.sequence,
schema.preparationStepInstances.sequence,
),
),
)
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
const busyByMachineId = new Map(
busyInstances
.filter((row) => row.machineId !== null)
.map((row) => [row.machineId, row]),
)
const machineStatuses: Array<MachineStatus> = machines.map((machine) => {
const busy = busyByMachineId.get(machine.id)
return {
id: machine.id,
name: machine.name,
tagName: machine.tagName,
status: busy ? 'busy' : 'idle',
current: busy
? {
stepName: busy.stepName,
menuItemName: busy.menuItemName,
orderId: busy.orderId,
startedAt: busy.startedAt,
}
: null,
}
})
const queuedCounts = await db
.select({
machineTagId: schema.preparationStepInstances.machineTagId,
count: sql<number>`count(*)::int`,
})
.from(schema.preparationStepInstances)
.where(eq(schema.preparationStepInstances.status, 'queued'))
.groupBy(schema.preparationStepInstances.machineTagId)
const queuedCountByTagId = new Map(
queuedCounts.map((row) => [row.machineTagId, row.count]),
)
const tagsById = new Map(
machines.map((machine) => [machine.tagId, machine.tagName]),
)
const queues: Array<TagQueueSummary> = [...tagsById.entries()].map(
([tagId, tagName]) => {
const tagMachines = machineStatuses.filter((m) => m.tagName === tagName)
return {
tagName,
idleMachineCount: tagMachines.filter((m) => m.status === 'idle')
.length,
busyMachineCount: tagMachines.filter((m) => m.status === 'busy')
.length,
queuedStepCount: queuedCountByTagId.get(tagId) ?? 0,
}
},
)
return { machines: machineStatuses, queues }
}
+216
View File
@@ -0,0 +1,216 @@
import { eq } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
type Db = NodePgDatabase<typeof schema>
export class MenuItemHasOrdersError extends Error {
constructor() {
super('This item cannot be deleted because it has past Orders')
this.name = 'MenuItemHasOrdersError'
}
}
export type AdminMenuItem = {
id: number
name: string
description: string | null
priceCents: number
options: Array<{ id: number; name: string; priceDeltaCents: number }>
steps: Array<{
id: number
sequence: number
name: string
machineTagId: number
machineTagName: string
estimatedDurationSeconds: number
}>
}
export type MenuItemInput = {
name: string
description?: string
priceCents: number
options: Array<{ name: string; priceDeltaCents: number }>
steps: Array<{
name: string
machineTagId: number
estimatedDurationSeconds: number
}>
}
const assertHasSteps = (input: MenuItemInput) => {
if (input.steps.length === 0) {
throw new Error('A menu item needs at least one Preparation Step')
}
}
export const getMenuItemsForAdmin = async (
db: Db,
brandId: number,
): Promise<Array<AdminMenuItem>> => {
const items = await db
.select()
.from(schema.menuItems)
.where(eq(schema.menuItems.brandId, brandId))
.orderBy(schema.menuItems.name)
const options = await db.select().from(schema.menuItemOptions)
const steps = await db
.select({
id: schema.menuItemSteps.id,
menuItemId: schema.menuItemSteps.menuItemId,
sequence: schema.menuItemSteps.sequence,
name: schema.menuItemSteps.name,
machineTagId: schema.menuItemSteps.machineTagId,
machineTagName: schema.machineTags.name,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
})
.from(schema.menuItemSteps)
.innerJoin(
schema.machineTags,
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
)
.orderBy(schema.menuItemSteps.sequence)
return items.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
priceCents: item.priceCents,
options: options
.filter((option) => option.menuItemId === item.id)
.map((option) => ({
id: option.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
steps: steps
.filter((step) => step.menuItemId === item.id)
.map(({ menuItemId: _menuItemId, ...step }) => step),
}))
}
export const createMenuItem = async (
db: Db,
brandId: number,
input: MenuItemInput,
): Promise<number> => {
assertHasSteps(input)
return db.transaction(async (tx) => {
const [item] = await tx
.insert(schema.menuItems)
.values({
brandId,
name: input.name,
description: input.description ?? null,
priceCents: input.priceCents,
})
.returning()
if (input.options.length > 0) {
await tx.insert(schema.menuItemOptions).values(
input.options.map((option) => ({
menuItemId: item.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
}
await tx.insert(schema.menuItemSteps).values(
input.steps.map((step, index) => ({
menuItemId: item.id,
sequence: index + 1,
name: step.name,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
return item.id
})
}
// Options and Steps are replaced wholesale on every edit rather than diffed
// — simplest correct behavior for a form that resubmits its full list each
// time, and safe because placed Order Items snapshot their own copy (see
// orderItemSteps/orderItemOptions) so this never rewrites order history.
export const updateMenuItem = async (
db: Db,
menuItemId: number,
input: MenuItemInput,
): Promise<void> => {
assertHasSteps(input)
await db.transaction(async (tx) => {
await tx
.update(schema.menuItems)
.set({
name: input.name,
description: input.description ?? null,
priceCents: input.priceCents,
})
.where(eq(schema.menuItems.id, menuItemId))
await tx
.delete(schema.menuItemOptions)
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
await tx
.delete(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
if (input.options.length > 0) {
await tx.insert(schema.menuItemOptions).values(
input.options.map((option) => ({
menuItemId,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
}
await tx.insert(schema.menuItemSteps).values(
input.steps.map((step, index) => ({
menuItemId,
sequence: index + 1,
name: step.name,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
})
}
const isForeignKeyViolation = (error: unknown) =>
typeof error === 'object' &&
error !== null &&
'cause' in error &&
typeof error.cause === 'object' &&
error.cause !== null &&
'code' in error.cause &&
error.cause.code === '23503'
export const deleteMenuItem = async (
db: Db,
menuItemId: number,
): Promise<void> => {
try {
await db.transaction(async (tx) => {
await tx
.delete(schema.menuItemOptions)
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
await tx
.delete(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
// rolls back the deletes above) if any Order ever referenced it.
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
})
} catch (error) {
if (isForeignKeyViolation(error)) throw new MenuItemHasOrdersError()
throw error
}
}
+40 -1
View File
@@ -409,7 +409,11 @@ export const placeOrder = async (
} }
} }
export type OrderAccess = { customerId?: string; guestAccessToken?: string } export type OrderAccess = {
customerId?: string
guestAccessToken?: string
isAdmin?: boolean
}
const assertOrderAccess = ( const assertOrderAccess = (
order: Pick< order: Pick<
@@ -418,6 +422,7 @@ const assertOrderAccess = (
>, >,
access: OrderAccess, access: OrderAccess,
) => { ) => {
if (access.isAdmin) return
if (order.customerId) { if (order.customerId) {
if (access.customerId === order.customerId) return if (access.customerId === order.customerId) return
} else if (order.guestAccessToken) { } else if (order.guestAccessToken) {
@@ -955,6 +960,40 @@ export const getOrdersForCustomer = async (
return summarizeOrders(db, orders) return summarizeOrders(db, orders)
} }
export type AdminOrderSummary = CustomerOrderSummary & {
customer:
| { type: 'account'; customerId: string }
| { type: 'guest'; name: string; contact: string }
}
// Every Order across every customer, for the admin dashboard — unlike
// getOrdersForCustomer/getGuestOrderSummaries, this isn't scoped to one
// customer's own access, so it must only ever be called once the caller has
// already been checked for admin access.
export const getAllOrders = async (
db: Db,
): Promise<Array<AdminOrderSummary>> => {
const orders = await db
.select()
.from(schema.orders)
.orderBy(desc(schema.orders.createdAt))
const summaries = await summarizeOrders(db, orders)
const ordersById = new Map(orders.map((order) => [order.id, order]))
return summaries.map((summary) => {
const order = ordersById.get(summary.id)!
const customer = order.customerId
? ({ type: 'account', customerId: order.customerId } as const)
: ({
type: 'guest',
name: order.guestName ?? 'Guest',
contact: order.guestContact ?? '',
} as const)
return { ...summary, customer }
})
}
export type GuestOrderRef = { orderId: number; guestAccessToken: string } export type GuestOrderRef = { orderId: number; guestAccessToken: string }
// A guest's Order history, looked up from the (orderId, guestAccessToken) // A guest's Order history, looked up from the (orderId, guestAccessToken)
@@ -29,6 +29,21 @@ const OrdersIcon = () => (
</svg> </svg>
) )
const AdminIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6l7-3Z" />
</svg>
)
const LogOutIcon = () => ( const LogOutIcon = () => (
<svg <svg
className={styles.menuIcon} className={styles.menuIcon}
@@ -114,6 +129,16 @@ const BetterAuthHeader = () => {
<OrdersIcon /> <OrdersIcon />
Orders Orders
</Link> </Link>
{session.user.role === 'admin' && (
<Link
to="/admin"
role="menuitem"
className={styles.menuButton}
>
<AdminIcon />
Administration
</Link>
)}
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from '@tanstack/react-router'
// Errors cross the server-function boundary as a plain `Error` carrying only
// `.message` (TanStack Start's ShallowErrorPlugin drops the subclass), so
// requireAdmin's rejection is told apart by message rather than `instanceof`.
// Every /admin/* route loader wraps its data fetch in this so a signed-out
// or non-admin visitor is redirected to sign in instead of crashing.
export const withAdminGuard = async <T>(load: () => Promise<T>): Promise<T> => {
try {
return await load()
} catch (error) {
if (error instanceof Error && error.message === 'Admin access required') {
throw redirect({ to: '/login' })
}
throw error
}
}
+6 -1
View File
@@ -1,3 +1,8 @@
import { createAuthClient } from 'better-auth/react' import { createAuthClient } from 'better-auth/react'
import { inferAdditionalFields } from 'better-auth/client/plugins'
export const authClient = createAuthClient() import type { auth } from '#/lib/auth.ts'
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()],
})
+12
View File
@@ -9,6 +9,18 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
user: {
additionalFields: {
// `input: false` keeps this out of the public sign-up/update schema so
// a client can't self-assign admin — it's only ever set by a direct DB
// update (see src/db/seed-admin.ts).
role: {
type: 'string',
defaultValue: 'customer',
input: false,
},
},
},
socialProviders: { socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? { ? {
+174
View File
@@ -10,12 +10,20 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as BasketRouteImport } from './routes/basket' import { Route as BasketRouteImport } from './routes/basket'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as SignupRouteImport } from './routes/signup' import { Route as SignupRouteImport } from './routes/signup'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminKitchenRouteImport } from './routes/admin/kitchen'
import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId' import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId'
import { Route as OrdersIndexRouteImport } from './routes/orders/index' import { Route as OrdersIndexRouteImport } from './routes/orders/index'
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId' import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
import { Route as AdminMenuIndexRouteImport } from './routes/admin/menu/index'
import { Route as AdminMenuMenuItemIdRouteImport } from './routes/admin/menu/$menuItemId'
import { Route as AdminMenuNewRouteImport } from './routes/admin/menu/new'
import { Route as AdminOrdersIndexRouteImport } from './routes/admin/orders/index'
import { Route as AdminOrdersOrderIdRouteImport } from './routes/admin/orders/$orderId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
@@ -23,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const BasketRoute = BasketRouteImport.update({ const BasketRoute = BasketRouteImport.update({
id: '/basket', id: '/basket',
path: '/basket', path: '/basket',
@@ -38,6 +51,16 @@ const SignupRoute = SignupRouteImport.update({
path: '/signup', path: '/signup',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AdminRoute,
} as any)
const AdminKitchenRoute = AdminKitchenRouteImport.update({
id: '/kitchen',
path: '/kitchen',
getParentRoute: () => AdminRoute,
} as any)
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({ const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
id: '/items/$menuItemId', id: '/items/$menuItemId',
path: '/items/$menuItemId', path: '/items/$menuItemId',
@@ -53,6 +76,31 @@ const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
path: '/orders/$orderId', path: '/orders/$orderId',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminMenuIndexRoute = AdminMenuIndexRouteImport.update({
id: '/menu/',
path: '/menu/',
getParentRoute: () => AdminRoute,
} as any)
const AdminMenuMenuItemIdRoute = AdminMenuMenuItemIdRouteImport.update({
id: '/menu/$menuItemId',
path: '/menu/$menuItemId',
getParentRoute: () => AdminRoute,
} as any)
const AdminMenuNewRoute = AdminMenuNewRouteImport.update({
id: '/menu/new',
path: '/menu/new',
getParentRoute: () => AdminRoute,
} as any)
const AdminOrdersIndexRoute = AdminOrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => AdminRoute,
} as any)
const AdminOrdersOrderIdRoute = AdminOrdersOrderIdRouteImport.update({
id: '/orders/$orderId',
path: '/orders/$orderId',
getParentRoute: () => AdminRoute,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$', id: '/api/auth/$',
path: '/api/auth/$', path: '/api/auth/$',
@@ -61,70 +109,117 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute '/basket': typeof BasketRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute '/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/admin/': typeof AdminIndexRoute
'/orders/': typeof OrdersIndexRoute '/orders/': typeof OrdersIndexRoute
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
'/admin/menu/new': typeof AdminMenuNewRoute
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/admin/menu/': typeof AdminMenuIndexRoute
'/admin/orders/': typeof AdminOrdersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/basket': typeof BasketRoute '/basket': typeof BasketRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute '/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/admin': typeof AdminIndexRoute
'/orders': typeof OrdersIndexRoute '/orders': typeof OrdersIndexRoute
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
'/admin/menu/new': typeof AdminMenuNewRoute
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/admin/menu': typeof AdminMenuIndexRoute
'/admin/orders': typeof AdminOrdersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute '/basket': typeof BasketRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin/kitchen': typeof AdminKitchenRoute
'/items/$menuItemId': typeof ItemsMenuItemIdRoute '/items/$menuItemId': typeof ItemsMenuItemIdRoute
'/orders/$orderId': typeof OrdersOrderIdRoute '/orders/$orderId': typeof OrdersOrderIdRoute
'/admin/': typeof AdminIndexRoute
'/orders/': typeof OrdersIndexRoute '/orders/': typeof OrdersIndexRoute
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
'/admin/menu/new': typeof AdminMenuNewRoute
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/admin/menu/': typeof AdminMenuIndexRoute
'/admin/orders/': typeof AdminOrdersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/admin'
| '/basket' | '/basket'
| '/login' | '/login'
| '/signup' | '/signup'
| '/admin/kitchen'
| '/items/$menuItemId' | '/items/$menuItemId'
| '/orders/$orderId' | '/orders/$orderId'
| '/admin/'
| '/orders/' | '/orders/'
| '/admin/menu/$menuItemId'
| '/admin/menu/new'
| '/admin/orders/$orderId'
| '/api/auth/$' | '/api/auth/$'
| '/admin/menu/'
| '/admin/orders/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/basket' | '/basket'
| '/login' | '/login'
| '/signup' | '/signup'
| '/admin/kitchen'
| '/items/$menuItemId' | '/items/$menuItemId'
| '/orders/$orderId' | '/orders/$orderId'
| '/admin'
| '/orders' | '/orders'
| '/admin/menu/$menuItemId'
| '/admin/menu/new'
| '/admin/orders/$orderId'
| '/api/auth/$' | '/api/auth/$'
| '/admin/menu'
| '/admin/orders'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/admin'
| '/basket' | '/basket'
| '/login' | '/login'
| '/signup' | '/signup'
| '/admin/kitchen'
| '/items/$menuItemId' | '/items/$menuItemId'
| '/orders/$orderId' | '/orders/$orderId'
| '/admin/'
| '/orders/' | '/orders/'
| '/admin/menu/$menuItemId'
| '/admin/menu/new'
| '/admin/orders/$orderId'
| '/api/auth/$' | '/api/auth/$'
| '/admin/menu/'
| '/admin/orders/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRouteWithChildren
BasketRoute: typeof BasketRoute BasketRoute: typeof BasketRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute SignupRoute: typeof SignupRoute
@@ -143,6 +238,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/basket': { '/basket': {
id: '/basket' id: '/basket'
path: '/basket' path: '/basket'
@@ -164,6 +266,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SignupRouteImport preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin/': {
id: '/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/kitchen': {
id: '/admin/kitchen'
path: '/kitchen'
fullPath: '/admin/kitchen'
preLoaderRoute: typeof AdminKitchenRouteImport
parentRoute: typeof AdminRoute
}
'/items/$menuItemId': { '/items/$menuItemId': {
id: '/items/$menuItemId' id: '/items/$menuItemId'
path: '/items/$menuItemId' path: '/items/$menuItemId'
@@ -185,6 +301,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OrdersOrderIdRouteImport preLoaderRoute: typeof OrdersOrderIdRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/admin/menu/': {
id: '/admin/menu/'
path: '/menu'
fullPath: '/admin/menu/'
preLoaderRoute: typeof AdminMenuIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/menu/$menuItemId': {
id: '/admin/menu/$menuItemId'
path: '/menu/$menuItemId'
fullPath: '/admin/menu/$menuItemId'
preLoaderRoute: typeof AdminMenuMenuItemIdRouteImport
parentRoute: typeof AdminRoute
}
'/admin/menu/new': {
id: '/admin/menu/new'
path: '/menu/new'
fullPath: '/admin/menu/new'
preLoaderRoute: typeof AdminMenuNewRouteImport
parentRoute: typeof AdminRoute
}
'/admin/orders/': {
id: '/admin/orders/'
path: '/orders'
fullPath: '/admin/orders/'
preLoaderRoute: typeof AdminOrdersIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/orders/$orderId': {
id: '/admin/orders/$orderId'
path: '/orders/$orderId'
fullPath: '/admin/orders/$orderId'
preLoaderRoute: typeof AdminOrdersOrderIdRouteImport
parentRoute: typeof AdminRoute
}
'/api/auth/$': { '/api/auth/$': {
id: '/api/auth/$' id: '/api/auth/$'
path: '/api/auth/$' path: '/api/auth/$'
@@ -195,8 +346,31 @@ declare module '@tanstack/react-router' {
} }
} }
interface AdminRouteChildren {
AdminKitchenRoute: typeof AdminKitchenRoute
AdminIndexRoute: typeof AdminIndexRoute
AdminMenuMenuItemIdRoute: typeof AdminMenuMenuItemIdRoute
AdminMenuNewRoute: typeof AdminMenuNewRoute
AdminOrdersOrderIdRoute: typeof AdminOrdersOrderIdRoute
AdminMenuIndexRoute: typeof AdminMenuIndexRoute
AdminOrdersIndexRoute: typeof AdminOrdersIndexRoute
}
const AdminRouteChildren: AdminRouteChildren = {
AdminKitchenRoute: AdminKitchenRoute,
AdminIndexRoute: AdminIndexRoute,
AdminMenuMenuItemIdRoute: AdminMenuMenuItemIdRoute,
AdminMenuNewRoute: AdminMenuNewRoute,
AdminOrdersOrderIdRoute: AdminOrdersOrderIdRoute,
AdminMenuIndexRoute: AdminMenuIndexRoute,
AdminOrdersIndexRoute: AdminOrdersIndexRoute,
}
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AdminRoute: AdminRouteWithChildren,
BasketRoute: BasketRoute, BasketRoute: BasketRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
SignupRoute: SignupRoute, SignupRoute: SignupRoute,
+67
View File
@@ -0,0 +1,67 @@
@use '../styles/variables' as *;
.layout {
display: flex;
align-items: flex-start;
gap: $space-4;
max-width: 1100px;
margin: 0 auto;
padding: $space-4 $space-3;
@media (max-width: $breakpoint-tablet) {
flex-direction: column;
}
}
.sidebar {
flex: 0 0 200px;
display: flex;
flex-direction: column;
gap: $space-1;
position: sticky;
top: $space-4;
@media (max-width: $breakpoint-tablet) {
position: static;
flex-direction: row;
width: 100%;
overflow-x: auto;
}
}
.sidebarTitle {
margin: 0 0 $space-2;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $color-text-muted;
@media (max-width: $breakpoint-tablet) {
display: none;
}
}
.navLink {
padding: $space-2 $space-3;
border-radius: $radius;
color: $color-text;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.15s ease;
&:hover {
background: $color-surface;
}
}
.navLinkActive {
background: $color-surface;
color: $color-primary;
}
.content {
flex: 1;
min-width: 0;
}
+46
View File
@@ -0,0 +1,46 @@
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { requireAdminSession } from '#/server/auth.ts'
import styles from './admin.module.scss'
const NAV_ITEMS = [
{ to: '/admin/orders', label: 'Orders' },
{ to: '/admin/menu', label: 'Menu' },
{ to: '/admin/kitchen', label: 'Kitchen' },
] as const
const AdminLayout = () => (
<div className={styles.layout}>
<nav
className={styles.sidebar}
style={{ viewTransitionName: 'admin-sidebar' }}
>
<p className={styles.sidebarTitle}>Administration</p>
{NAV_ITEMS.map((item) => (
<Link
key={item.to}
to={item.to}
className={styles.navLink}
activeProps={{ className: styles.navLinkActive }}
// The sidebar and overall layout never change between /admin/*
// pages — a view transition's root crossfade has nothing useful
// to animate here and just makes the static sidebar look like
// it's moving, so navigation within the admin panel skips it.
viewTransition={false}
>
{item.label}
</Link>
))}
</nav>
<div className={styles.content}>
<Outlet />
</div>
</div>
)
export const Route = createFileRoute('/admin')({
loader: () => withAdminGuard(() => requireAdminSession()),
component: AdminLayout,
})
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
// Orders is the only admin section today — this is the stable entry point
// the account menu's "Administration" link points at, so adding more
// sections later doesn't mean touching the menu again. /admin/orders itself
// still enforces the admin check.
export const Route = createFileRoute('/admin/')({
loader: () => {
throw redirect({ to: '/admin/orders' })
},
})
+107
View File
@@ -0,0 +1,107 @@
@use '../../styles/variables' as *;
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
margin-bottom: $space-4;
}
.heading {
margin: 0;
}
.refreshButton {
padding: $space-1 $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: $color-primary;
}
}
.tagSection {
margin-bottom: $space-4;
}
.tagHeader {
display: flex;
align-items: baseline;
gap: $space-2;
margin-bottom: $space-2;
}
.tagName {
margin: 0;
font-size: 1rem;
font-weight: 600;
text-transform: capitalize;
}
.tagQueue {
font-size: 0.85rem;
color: $color-text-muted;
}
.machines {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $space-2;
}
.machine {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.machineInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.machineName {
font-weight: 600;
}
.machineCurrent {
font-size: 0.85rem;
color: $color-text-muted;
}
.status {
flex: 0 0 auto;
padding: 2px $space-2;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.statusIdle {
background: rgba($color-success, 0.12);
color: $color-success;
}
.statusBusy {
background: rgba($color-primary, 0.12);
color: $color-primary;
}
.empty {
color: $color-text-muted;
}
+98
View File
@@ -0,0 +1,98 @@
import { createFileRoute, useRouter } from '@tanstack/react-router'
import type { KitchenStatus } from '#/domain/kitchen.ts'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { getKitchenStatus } from '#/server/kitchen.ts'
import styles from './kitchen.module.scss'
const formatStartedAt = (startedAt: Date | null) =>
startedAt
? startedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: 'just now'
const groupByTag = (machines: KitchenStatus['machines']) => {
const byTag = new Map<string, KitchenStatus['machines']>()
for (const machine of machines) {
byTag.set(machine.tagName, [...(byTag.get(machine.tagName) ?? []), machine])
}
return byTag
}
const KitchenRoute = () => {
const { machines, queues } = Route.useLoaderData()
const router = useRouter()
const machinesByTag = groupByTag(machines)
const queueByTag = new Map(queues.map((queue) => [queue.tagName, queue]))
return (
<section>
<div className={styles.header}>
<h1 className={styles.heading}>Kitchen</h1>
<button
type="button"
className={styles.refreshButton}
onClick={() => void router.invalidate()}
>
Refresh
</button>
</div>
{machines.length === 0 ? (
<p className={styles.empty}>No machines are configured.</p>
) : (
[...machinesByTag.entries()].map(([tagName, tagMachines]) => {
const queue = queueByTag.get(tagName)
return (
<div key={tagName} className={styles.tagSection}>
<div className={styles.tagHeader}>
<h2 className={styles.tagName}>{tagName}</h2>
{queue && (
<span className={styles.tagQueue}>
{queue.idleMachineCount} idle · {queue.busyMachineCount}{' '}
busy
{queue.queuedStepCount > 0 &&
` · ${queue.queuedStepCount} waiting`}
</span>
)}
</div>
<ul className={styles.machines}>
{tagMachines.map((machine) => (
<li key={machine.id} className={styles.machine}>
<div className={styles.machineInfo}>
<span className={styles.machineName}>
{machine.name}
</span>
{machine.current && (
<span className={styles.machineCurrent}>
{machine.current.menuItemName} ·{' '}
{machine.current.stepName} · Order #
{machine.current.orderId} · since{' '}
{formatStartedAt(machine.current.startedAt)}
</span>
)}
</div>
<span
className={`${styles.status} ${
machine.status === 'busy'
? styles.statusBusy
: styles.statusIdle
}`}
>
{machine.status}
</span>
</li>
))}
</ul>
</div>
)
})
)}
</section>
)
}
export const Route = createFileRoute('/admin/kitchen')({
loader: () => withAdminGuard(() => getKitchenStatus()),
component: KitchenRoute,
})
+37
View File
@@ -0,0 +1,37 @@
import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { getAdminMenu, getMachineTagOptions, updateMenuItem } from '#/server/menu-admin.ts'
const EditMenuItemRoute = () => {
const { item, machineTags } = Route.useLoaderData()
const navigate = useNavigate()
return (
<section>
<h1>Edit {item.name}</h1>
<MenuItemForm
machineTags={machineTags}
initialItem={item}
submitLabel="Save changes"
onSubmit={async (input) => {
await updateMenuItem({ data: { menuItemId: item.id, ...input } })
await navigate({ to: '/admin/menu', viewTransition: false })
}}
/>
</section>
)
}
export const Route = createFileRoute('/admin/menu/$menuItemId')({
loader: async ({ params }) => {
const [items, machineTags] = await withAdminGuard(() =>
Promise.all([getAdminMenu(), getMachineTagOptions()]),
)
const item = items.find((i) => i.id === Number(params.menuItemId))
if (!item) throw notFound()
return { item, machineTags }
},
component: EditMenuItemRoute,
})
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { AdminMenuListView } from '#/components/admin/AdminMenuListView.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { deleteMenuItem, getAdminMenu } from '#/server/menu-admin.ts'
const AdminMenuRoute = () => {
const items = Route.useLoaderData()
const router = useRouter()
return (
<AdminMenuListView
items={items}
onDelete={async (menuItemId) => {
await deleteMenuItem({ data: { menuItemId } })
await router.invalidate()
}}
/>
)
}
export const Route = createFileRoute('/admin/menu/')({
loader: () => withAdminGuard(() => getAdminMenu()),
component: AdminMenuRoute,
})
+29
View File
@@ -0,0 +1,29 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { createMenuItem, getMachineTagOptions } from '#/server/menu-admin.ts'
const NewMenuItemRoute = () => {
const machineTags = Route.useLoaderData()
const navigate = useNavigate()
return (
<section>
<h1>Add menu item</h1>
<MenuItemForm
machineTags={machineTags}
submitLabel="Create item"
onSubmit={async (input) => {
await createMenuItem({ data: input })
await navigate({ to: '/admin/menu', viewTransition: false })
}}
/>
</section>
)
}
export const Route = createFileRoute('/admin/menu/new')({
loader: () => withAdminGuard(() => getMachineTagOptions()),
component: NewMenuItemRoute,
})
+42
View File
@@ -0,0 +1,42 @@
import { createFileRoute, notFound } from '@tanstack/react-router'
import { OrderStatusView } from '#/components/orders/OrderStatusView.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { getOrderStatus } from '#/server/orders.ts'
// Same "both collapse to notFound" reasoning as routes/orders/$orderId.tsx —
// an admin session bypasses the ownership check entirely (see isAdmin in
// assertOrderAccess), so in practice only "Order not found" is ever hit
// here, but the guard stays for a torn-down/renumbered order id.
const notFoundMessages = new Set([
'Order not found',
'You do not have access to this Order',
])
const AdminOrderStatusRoute = () => {
const status = Route.useLoaderData()
return (
<OrderStatusView
status={status}
guestAccessToken={undefined}
backTo="/admin/orders"
/>
)
}
export const Route = createFileRoute('/admin/orders/$orderId')({
loader: ({ params }) =>
withAdminGuard(async () => {
try {
return await getOrderStatus({
data: { orderId: Number(params.orderId) },
})
} catch (error) {
if (error instanceof Error && notFoundMessages.has(error.message)) {
throw notFound()
}
throw error
}
}),
component: AdminOrderStatusRoute,
})
+15
View File
@@ -0,0 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { AdminOrdersView } from '#/components/orders/AdminOrdersView.tsx'
import { withAdminGuard } from '#/lib/admin-guard.ts'
import { getAllOrders } from '#/server/orders.ts'
const AdminOrdersRoute = () => {
const orders = Route.useLoaderData()
return <AdminOrdersView orders={orders} />
}
export const Route = createFileRoute('/admin/orders/')({
loader: () => withAdminGuard(() => getAllOrders()),
component: AdminOrdersRoute,
})
+35
View File
@@ -0,0 +1,35 @@
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { auth } from '#/lib/auth.ts'
// Wrapped in createServerOnlyFn: this file is exported for reuse by several
// server/*.ts modules, so (unlike a private, unexported helper) the
// framework's client/server code-split can't tree-shake it away on its own
// — without this it drags getRequest (and the rest of this file) into any
// client bundle that transitively imports one of this module's exports,
// which @tanstack/react-start's import-protection then refuses to load.
export const getSessionUser = createServerOnlyFn(async () => {
const session = await auth.api.getSession({ headers: getRequest().headers })
return session?.user
})
export const getSessionCustomerId = createServerOnlyFn(
async () => (await getSessionUser())?.id,
)
// Defense in depth: an admin-only server function must check this itself
// rather than trust the caller's route already gated on role, since a
// server function is reachable directly over its RPC endpoint.
export const requireAdmin = createServerOnlyFn(async () => {
const user = await getSessionUser()
if (user?.role !== 'admin') throw new Error('Admin access required')
})
// A minimal round trip the admin layout route's loader can call to gate
// every /admin/* page behind one check, without fetching any page's data.
export const requireAdminSession = createServerFn({ method: 'GET' }).handler(
async () => {
await requireAdmin()
},
)
+12
View File
@@ -0,0 +1,12 @@
import { createServerFn } from '@tanstack/react-start'
import { db } from '#/db/index.ts'
import * as kitchen from '#/domain/kitchen.ts'
import { requireAdmin } from '#/server/auth.ts'
export const getKitchenStatus = createServerFn({ method: 'GET' }).handler(
async () => {
await requireAdmin()
return kitchen.getKitchenStatus(db)
},
)
+61
View File
@@ -0,0 +1,61 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '#/db/index.ts'
import { getMachineTags } from '#/domain/kitchen.ts'
import * as menu from '#/domain/menu.ts'
import { requireAdmin } from '#/server/auth.ts'
import { getBrand } from '#/server/brand.ts'
const menuItemInputSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
priceCents: z.number().int().nonnegative(),
options: z.array(
z.object({ name: z.string().min(1), priceDeltaCents: z.number().int() }),
),
steps: z.array(
z.object({
name: z.string().min(1),
machineTagId: z.number(),
estimatedDurationSeconds: z.number().int().positive(),
}),
),
})
export const getAdminMenu = createServerFn({ method: 'GET' }).handler(
async () => {
await requireAdmin()
const brand = await getBrand()
return menu.getMenuItemsForAdmin(db, brand.id)
},
)
export const getMachineTagOptions = createServerFn({ method: 'GET' }).handler(
async () => {
await requireAdmin()
return getMachineTags(db)
},
)
export const createMenuItem = createServerFn({ method: 'POST' })
.validator(menuItemInputSchema)
.handler(async ({ data }) => {
await requireAdmin()
const brand = await getBrand()
return menu.createMenuItem(db, brand.id, data)
})
export const updateMenuItem = createServerFn({ method: 'POST' })
.validator(menuItemInputSchema.extend({ menuItemId: z.number() }))
.handler(async ({ data: { menuItemId, ...input } }) => {
await requireAdmin()
return menu.updateMenuItem(db, menuItemId, input)
})
export const deleteMenuItem = createServerFn({ method: 'POST' })
.validator(z.object({ menuItemId: z.number() }))
.handler(async ({ data }) => {
await requireAdmin()
return menu.deleteMenuItem(db, data.menuItemId)
})
+15 -10
View File
@@ -1,16 +1,10 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { z } from 'zod' import { z } from 'zod'
import { db } from '#/db/index.ts' import { db } from '#/db/index.ts'
import * as ordering from '#/domain/ordering.ts' import * as ordering from '#/domain/ordering.ts'
import { auth } from '#/lib/auth.ts'
import { getPaymentProvider } from '#/lib/payment.ts' import { getPaymentProvider } from '#/lib/payment.ts'
import { getSessionCustomerId, getSessionUser, requireAdmin } from '#/server/auth.ts'
const getSessionCustomerId = async () => {
const session = await auth.api.getSession({ headers: getRequest().headers })
return session?.user.id
}
const customerSchema = z.discriminatedUnion('type', [ const customerSchema = z.discriminatedUnion('type', [
z.object({ z.object({
@@ -58,10 +52,14 @@ const accessSchema = z.object({ guestAccessToken: z.string().optional() })
const resolveAccess = async ( const resolveAccess = async (
data: z.infer<typeof accessSchema>, data: z.infer<typeof accessSchema>,
): Promise<ordering.OrderAccess> => ({ ): Promise<ordering.OrderAccess> => {
customerId: await getSessionCustomerId(), const user = await getSessionUser()
return {
customerId: user?.id,
guestAccessToken: data.guestAccessToken, guestAccessToken: data.guestAccessToken,
}) isAdmin: user?.role === 'admin',
}
}
export const getOrderStatus = createServerFn({ method: 'GET' }) export const getOrderStatus = createServerFn({ method: 'GET' })
.validator(accessSchema.extend({ orderId: z.number() })) .validator(accessSchema.extend({ orderId: z.number() }))
@@ -79,6 +77,13 @@ export const getMyOrders = createServerFn({ method: 'GET' }).handler(
}, },
) )
export const getAllOrders = createServerFn({ method: 'GET' }).handler(
async () => {
await requireAdmin()
return ordering.getAllOrders(db)
},
)
export const getGuestOrders = createServerFn({ method: 'GET' }) export const getGuestOrders = createServerFn({ method: 'GET' })
.validator( .validator(
z.object({ z.object({
+12
View File
@@ -73,6 +73,18 @@ button {
::view-transition-new(site-header) { ::view-transition-new(site-header) {
animation: none; animation: none;
} }
// Same reasoning as site-header: the admin sidebar nav is identical across
// every /admin/* route, so it should stay put across those navigations
// instead of fading/translating along with the root snapshot.
::view-transition-group(admin-sidebar) {
animation: none;
}
::view-transition-old(admin-sidebar),
::view-transition-new(admin-sidebar) {
animation: none;
}
} }
// document.startViewTransition() itself doesn't check this preference — // document.startViewTransition() itself doesn't check this preference —
+44
View File
@@ -665,6 +665,50 @@ describe('access control', () => {
ordering.getOrderStatus(testDb.db, order.id, {}), ordering.getOrderStatus(testDb.db, order.id, {}),
).rejects.toThrow(ordering.ForbiddenError) ).rejects.toThrow(ordering.ForbiddenError)
}) })
it('lets an admin view any Order, guest or account, without a token', async () => {
const guestOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const accountOrder = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
await expect(
ordering.getOrderStatus(testDb.db, guestOrder.id, { isAdmin: true }),
).resolves.toMatchObject({ id: guestOrder.id })
await expect(
ordering.getOrderStatus(testDb.db, accountOrder.id, { isAdmin: true }),
).resolves.toMatchObject({ id: accountOrder.id })
})
})
describe('admin order listing', () => {
it("lists every Order across every customer, with each one's placer", async () => {
const guestOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const accountOrder = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
const orders = await ordering.getAllOrders(testDb.db)
expect(orders).toHaveLength(2)
const guestSummary = orders.find((order) => order.id === guestOrder.id)
const accountSummary = orders.find((order) => order.id === accountOrder.id)
expect(guestSummary?.customer).toEqual({
type: 'guest',
name: 'Ada',
contact: 'ada@example.com',
})
expect(accountSummary?.customer).toEqual({
type: 'account',
customerId: 'test-customer-1',
})
})
}) })
describe('order history', () => { describe('order history', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite'
@@ -11,7 +10,7 @@ const config = defineConfig({
// port — a silent port change breaks Better Auth's origin check, since // port — a silent port change breaks Better Auth's origin check, since
// BETTER_AUTH_URL is pinned to http://localhost:3000. // BETTER_AUTH_URL is pinned to http://localhost:3000.
server: { strictPort: true }, server: { strictPort: true },
plugins: [devtools(), tanstackStart(), viteReact()], plugins: [tanstackStart(), viteReact()],
}) })
export default config export default config