From 1cdf87b04ba7cbb089a749d3dd5754d2126f9785 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:16:32 +0200 Subject: [PATCH] feat(restaurant): types + RestaurantAPI REST client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. - 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. --- src/modules/restaurant/index.ts | 18 +- .../restaurant/services/RestaurantAPI.ts | 170 ++++++++++ src/modules/restaurant/types/restaurant.ts | 308 ++++++++++++++++++ 3 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 src/modules/restaurant/services/RestaurantAPI.ts create mode 100644 src/modules/restaurant/types/restaurant.ts diff --git a/src/modules/restaurant/index.ts b/src/modules/restaurant/index.ts index 765231f..864e32c 100644 --- a/src/modules/restaurant/index.ts +++ b/src/modules/restaurant/index.ts @@ -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') }, diff --git a/src/modules/restaurant/services/RestaurantAPI.ts b/src/modules/restaurant/services/RestaurantAPI.ts new file mode 100644 index 0000000..788a342 --- /dev/null +++ b/src/modules/restaurant/services/RestaurantAPI.ts @@ -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 { + this.debug('RestaurantAPI initialized with base URL:', this.baseUrl) + } + + // ----------------------------------------------------------------- // + // request helper // + // ----------------------------------------------------------------- // + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}/restaurant/api/v1${endpoint}` + + const headers: Record = { + 'Content-Type': 'application/json', + } + if (options.headers) { + Object.assign(headers, options.headers as Record) + } + + 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 { + return this.request( + `/restaurants/by-slug/${encodeURIComponent(slug)}` + ) + } + + async getRestaurantById(id: string): Promise { + return this.request( + `/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 { + return this.request( + `/restaurants/${encodeURIComponent(restaurantId)}/menu` + ) + } + + async getMenuItem(itemId: string): Promise { + return this.request( + `/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 { + return this.request('/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 { + return this.request('/orders', { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + async getOrder(orderId: string): Promise { + return this.request( + `/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 } diff --git a/src/modules/restaurant/types/restaurant.ts b/src/modules/restaurant/types/restaurant.ts new file mode 100644 index 0000000..87a61cf --- /dev/null +++ b/src/modules/restaurant/types/restaurant.ts @@ -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> +} + +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 +} + +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 +} + +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 + 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 +} + +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 +}