feat(restaurant): customer-facing restaurant bundle (v1) #54

Merged
padreug merged 9 commits from feat/restaurant-bundle into main 2026-05-11 17:49:19 +00:00
3 changed files with 493 additions and 3 deletions
Showing only changes of commit 1cdf87b04b - Show all commits

feat(restaurant): types + RestaurantAPI REST client

types/restaurant.ts — full set of TS interfaces hand-translated
from ~/dev/shared/extensions/restaurant/models.py. Key notes:
  - Money is integer msat end-to-end on orders, order items, and
    the order quote response (matches the extension).
  - Open OrderStatus type with KNOWN_ORDER_STATUSES const so the
    production / kitchen workflow (aiolabs/restaurant#4) can
    introduce new states without breaking the build.
  - MenuItem.extra carries forward-compatible metadata for
    inventory (#3), happy-hour / COGS (#6), loyalty (#5), and
    mode-gated badges (#2). Plain Record<string, unknown>.
  - OrderExtra.fields is the loyalty (#5) pass-through hook the
    useCheckout buildCreateOrder helper will inject through.
  - Restaurant.mode is acknowledged but not branched on in v1.

services/RestaurantAPI.ts — BaseService subclass, mirrors the
extension's REST surface:
  getRestaurantBySlug / getRestaurantById / getMenu / getMenuItem
  quoteOrder / placeOrder / getOrder
No API key for any of these — public read and customer-facing
write endpoints. Base URL pulled from
appConfig.modules.restaurant.config.apiBaseUrl.

modules/restaurant/index.ts — install() now constructs the API
client, registers it under SERVICE_TOKENS.RESTAURANT_API, and
kicks off .initialize(). Consumers (views, composables, stores)
get the client via injectService starting in commit 4.
Padreug 2026-05-11 17:16:32 +02:00

View file

@ -1,6 +1,9 @@
import type { App } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import { RestaurantAPI } from './services/RestaurantAPI'
// v1 skeleton — REST-only, single-venue, URL-driven (/r/:slug).
//
@ -50,9 +53,18 @@ export const restaurantModule: ModulePlugin = {
throw new Error('Restaurant module requires configuration')
}
// Services (RestaurantAPI, RestaurantNostrSync) are wired in
// commits 3 and 8 respectively. v1 skeleton only registers
// the route table.
// REST client. Initialized lazily — onInitialize() is a no-op
// (no async dependencies); failures here would only fire if
// the appConfig is malformed and we want to know about that.
const restaurantAPI = new RestaurantAPI()
container.provide(SERVICE_TOKENS.RESTAURANT_API, restaurantAPI)
await restaurantAPI
.initialize({ waitForDependencies: true, maxRetries: 1 })
.catch((error) => {
console.warn('🍴 RestaurantAPI init deferred:', error)
})
// RestaurantNostrSync lands in commit 8.
console.log('✅ Restaurant module installed')
},

View file

@ -0,0 +1,170 @@
/**
* Typed REST client for the LNbits "restaurant" extension.
*
* Mirrors the surface in ~/dev/shared/extensions/restaurant/views_api.py.
* Public read endpoints (`/restaurants/by-slug/{slug}`,
* `/restaurants/{id}/menu`, `/menu_items/{id}`) and customer order
* endpoints (`/orders/quote`, `/orders`, `/orders/{id}`) need no API
* key; `customer_pubkey` rides in the request body as optional
* metadata.
*/
import { BaseService } from '@/core/base/BaseService'
import appConfig from '@/app.config'
import type {
CreateOrder,
CreateOrderItem,
MenuResponse,
MenuItem,
Order,
OrderInvoice,
OrderQuote,
OrderWithItems,
PlaceOrderResponse,
Restaurant,
} from '../types/restaurant'
export class RestaurantAPI extends BaseService {
protected readonly metadata = {
name: 'RestaurantAPI',
version: '1.0.0',
dependencies: [] as string[],
}
private baseUrl: string
constructor() {
super()
const config = (
appConfig.modules.restaurant as
| { config?: { apiBaseUrl?: string } }
| undefined
)?.config
if (!config?.apiBaseUrl) {
throw new Error(
'RestaurantAPI: Missing apiBaseUrl in restaurant module config'
)
}
this.baseUrl = config.apiBaseUrl
}
protected async onInitialize(): Promise<void> {
this.debug('RestaurantAPI initialized with base URL:', this.baseUrl)
}
// ----------------------------------------------------------------- //
// request helper //
// ----------------------------------------------------------------- //
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}/restaurant/api/v1${endpoint}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (options.headers) {
Object.assign(headers, options.headers as Record<string, string>)
}
const response = await fetch(url, { ...options, headers })
if (!response.ok) {
let detail = response.statusText
try {
const body = await response.json()
if (body?.detail) detail = body.detail
} catch {
// body wasn't JSON, fall through
}
throw new Error(
`RestaurantAPI ${options.method || 'GET'} ${endpoint} ` +
`failed: ${response.status} ${detail}`
)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
// ----------------------------------------------------------------- //
// Restaurants //
// ----------------------------------------------------------------- //
/** Resolve a URL slug → Restaurant payload. Used by /r/:slug. */
async getRestaurantBySlug(slug: string): Promise<Restaurant> {
return this.request<Restaurant>(
`/restaurants/by-slug/${encodeURIComponent(slug)}`
)
}
async getRestaurantById(id: string): Promise<Restaurant> {
return this.request<Restaurant>(
`/restaurants/${encodeURIComponent(id)}`
)
}
/**
* Full hydrated menu returns `{restaurant, tree, items}` where
* `tree` is the rooted MenuNode tree with children + items attached
* and `items` is the flat enriched list (modifier groups + options
* + availability windows pre-joined).
*/
async getMenu(restaurantId: string): Promise<MenuResponse> {
return this.request<MenuResponse>(
`/restaurants/${encodeURIComponent(restaurantId)}/menu`
)
}
async getMenuItem(itemId: string): Promise<MenuItem> {
return this.request<MenuItem>(
`/menu_items/${encodeURIComponent(itemId)}`
)
}
// ----------------------------------------------------------------- //
// Orders //
// ----------------------------------------------------------------- //
/**
* Pre-flight balance check. Returns the msat the customer needs to
* cover this cart at one restaurant. Called per-restaurant by the
* webapp before opening any invoice so a customer with
* insufficient funds gets one clean error rather than partially
* paid carts.
*/
async quoteOrder(items: CreateOrderItem[]): Promise<OrderQuote> {
return this.request<OrderQuote>('/orders/quote', {
method: 'POST',
body: JSON.stringify(items),
})
}
/**
* Place an order against one restaurant. Returns
* { order, invoice }
* where `invoice` is null for cash orders and the bolt11 payload
* otherwise. Pay the bolt11 with WalletService.sendPayment to
* settle.
*/
async placeOrder(payload: CreateOrder): Promise<PlaceOrderResponse> {
return this.request<PlaceOrderResponse>('/orders', {
method: 'POST',
body: JSON.stringify(payload),
})
}
async getOrder(orderId: string): Promise<OrderWithItems> {
return this.request<OrderWithItems>(
`/orders/${encodeURIComponent(orderId)}`
)
}
}
// Re-export Order type for consumers reaching this surface for status
// strings — keeps the import chain shallow in views/composables.
export type { Order, OrderInvoice }

View file

@ -0,0 +1,308 @@
/**
* TypeScript types mirroring the Python pydantic models in
* ~/dev/shared/extensions/restaurant/models.py.
*
* Hand-translated (no OpenAPI codegen on day one). Money on orders
* and order items is integer **msat** end-to-end, matching the
* extension. Display conversion is cosmetic via formatPrice().
*
* Future-compatibility scaffolding lives here intentionally see
* `OrderStatus`, `MenuItem.extra`, `Restaurant.mode`. Do not tighten
* those unless you've shipped the corresponding feature on the
* extension side first.
*/
// --------------------------------------------------------------------- //
// Restaurant //
// --------------------------------------------------------------------- //
export interface OpenHours {
// Weekday key '0'..'6' (Mon..Sun) → array of {start,end} 'HH:MM' ranges.
schedule: Record<string, Array<{ start: string; end: string }>>
}
export interface SocialLinks {
website?: string | null
instagram?: string | null
facebook?: string | null
twitter?: string | null
nostr?: string | null
}
export interface RestaurantExtra {
notes?: string | null
// Plain dict — forward-compatible pass-through. See models.py.
fields: Record<string, unknown>
}
export interface Restaurant {
id: string
wallet: string
name: string
slug: string
description?: string | null
currency: string
timezone: string
location?: string | null
geohash?: string | null
logo_url?: string | null
banner_url?: string | null
social_links: SocialLinks
open_hours: OpenHours
is_open: boolean
accepts_cash: boolean
accepts_lightning: boolean
tip_presets: number[]
tax_rate: number
printer_endpoint?: string | null
nostr_pubkey?: string | null
nostr_relays: string[]
nostr_event_id?: string | null
nostr_event_created_at?: number | null
extra: RestaurantExtra
time: string // ISO 8601 from extension
// Set by the operator (aiolabs/restaurant#2: bar/bistro/full
// tiered modes). v1 webapp does not branch on it; future work
// may hide / show UI surfaces based on the venue's tier.
mode?: string
}
// --------------------------------------------------------------------- //
// Menu tree //
// --------------------------------------------------------------------- //
export interface MenuNodeRow {
id: string
restaurant_id: string
parent_id: string | null
name: string
description: string | null
sort_order: number
image_url: string | null
depth: number
path: string
time: string
}
export interface MenuNode extends MenuNodeRow {
// Hydrated only by the /menu endpoint; never persisted.
children: MenuNode[]
items: MenuItem[]
}
// --------------------------------------------------------------------- //
// Menu items //
// --------------------------------------------------------------------- //
export interface MenuItemExtra {
notes?: string | null
// Pass-through for forward-compatible metadata: inventory
// (aiolabs/restaurant#3), happy-hour / cost-of-goods (#6),
// loyalty (#5), mode-gated badges (#2). v1 reads but never writes.
fields: Record<string, unknown>
}
export interface AvailabilityWindow {
id: string
menu_item_id: string
weekday: number | null // 0=Mon, 6=Sun, null = every day
start_time: string // 'HH:MM'
end_time: string // 'HH:MM'
time: string
}
export interface ModifierGroup {
id: string
menu_item_id: string
name: string
// 'required' | 'optional' — see services.place_order for semantics.
// Kept open so future tier features can extend (#2).
kind: string
// 'one' | 'many' (radio / multi-select).
selection: string
min_selections: number
max_selections: number | null
sort_order: number
time: string
}
export interface Modifier {
id: string
group_id: string
name: string
description: string | null
price_delta: number
is_default: boolean
sort_order: number
time: string
}
export interface MenuItem {
id: string
restaurant_id: string
node_id: string | null
name: string
description: string | null
price: number
currency: string
sku: string | null
images: string[]
dietary: string[]
allergens: string[]
ingredients: string[]
calories: number | null
sort_order: number
is_available: boolean
is_featured: boolean
stock: number | null
low_stock_threshold: number | null
nostr_event_id: string | null
nostr_event_created_at: number | null
extra: MenuItemExtra
time: string
}
/** Item with modifier groups + availability windows hydrated.
* Returned in the `items` array of `GET /restaurants/{id}/menu`. */
export interface EnrichedMenuItem extends MenuItem {
modifier_groups: Array<ModifierGroup & { modifiers: Modifier[] }>
availability_windows: AvailabilityWindow[]
}
// --------------------------------------------------------------------- //
// Orders //
// --------------------------------------------------------------------- //
export interface SelectedModifier {
group_id?: string | null
group_name?: string | null
modifier_id?: string | null
name: string
price_delta: number
}
export interface CreateOrderItem {
menu_item_id: string
quantity: number
selected_modifiers: SelectedModifier[]
note?: string | null
}
export interface OrderExtra {
fiat: boolean
fiat_currency?: string | null
fiat_rate?: number | null
refund_address?: string | null
// Pass-through, forward-compatible. Loyalty (#5) can ride here:
// e.g. { loyalty_credits_msat, loyalty_pubkey }.
fields: Record<string, unknown>
}
export interface CreateOrder {
restaurant_id: string
customer_pubkey?: string | null
customer_name?: string | null
customer_contact?: string | null
items: CreateOrderItem[]
tip_msat?: number
note?: string | null
parent_order_ref?: string | null
channel?: 'rest' | 'nostr' | 'kiosk' | 'pos'
payment_method?: 'lightning' | 'cash' | 'internal'
extra?: OrderExtra
}
/**
* Known order statuses are listed here for UI hint mapping (icons,
* colors) but the type is intentionally **open** so the production
* / kitchen workflow (aiolabs/restaurant#4) can introduce new states
* without breaking the build. Code that branches on status should
* use `KNOWN_ORDER_STATUSES.includes(...)` as a guard before
* assuming the styling lookup will resolve.
*/
export const KNOWN_ORDER_STATUSES = [
'pending',
'paid',
'accepted',
'ready',
'completed',
'canceled',
'refunded',
] as const
export type KnownOrderStatus = (typeof KNOWN_ORDER_STATUSES)[number]
export type OrderStatus = string
export interface Order {
id: string
restaurant_id: string
wallet: string
customer_pubkey?: string | null
customer_name?: string | null
customer_contact?: string | null
status: OrderStatus
channel: string
payment_method: string
payment_hash?: string | null
bolt11?: string | null
subtotal_msat: number
tip_msat: number
tax_msat: number
total_msat: number
currency_display: string
fiat_amount?: number | null
fiat_rate?: number | null
note?: string | null
parent_order_ref?: string | null
paid_at?: string | null
accepted_at?: string | null
ready_at?: string | null
completed_at?: string | null
canceled_at?: string | null
extra: OrderExtra
time: string
}
export interface OrderItemRow {
id: string
order_id: string
menu_item_id: string | null
name: string
quantity: number
unit_price_msat: number
line_total_msat: number
selected_modifiers: SelectedModifier[]
note: string | null
time: string
}
export interface OrderWithItems {
order: Order
items: OrderItemRow[]
}
export interface OrderInvoice {
order_id: string
payment_hash: string
bolt11: string
amount_msat: number
expires_at: number
}
/** Response of `POST /api/v1/orders`. */
export interface PlaceOrderResponse {
order: Order
invoice: OrderInvoice | null
}
/** Response of `GET /api/v1/restaurants/{id}/menu`. */
export interface MenuResponse {
restaurant: Restaurant
tree: MenuNode[]
items: EnrichedMenuItem[]
}
/** Response of `POST /api/v1/orders/quote`. */
export interface OrderQuote {
required_msat: number
}