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.
This commit is contained in:
parent
41fbad3d90
commit
1cdf87b04b
3 changed files with 493 additions and 3 deletions
|
|
@ -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')
|
||||
},
|
||||
|
|
|
|||
170
src/modules/restaurant/services/RestaurantAPI.ts
Normal file
170
src/modules/restaurant/services/RestaurantAPI.ts
Normal 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 }
|
||||
308
src/modules/restaurant/types/restaurant.ts
Normal file
308
src/modules/restaurant/types/restaurant.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue