From 5255a99f1a86e54caa604eaf24863a38b0337af1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 29 Apr 2026 23:35:08 +0200 Subject: [PATCH] feat(db): m001_initial schema Tables: restaurants, categories, subcategories, menu_items, modifier_groups, modifiers, availability_windows, orders, order_items, print_jobs, settings. Design notes: - One wallet -> N restaurants (no 1:1 assumption). - Each restaurant carries its own Nostr identity (pubkey + relays). - Publishable rows have nostr_event_id + nostr_event_created_at for cheap reconciliation against relay state. - No umbrella/festival concept stored here; cross-restaurant grouping is the customer/webapp's concern. - Modifiers and addons unified under modifier_groups.kind + selection (one|many). - Money as msat throughout orders for precision. - Availability windows per item with optional weekday. --- migrations.py | 333 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 migrations.py diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..d59a2c1 --- /dev/null +++ b/migrations.py @@ -0,0 +1,333 @@ +async def m001_initial(db): + """ + Initial schema for the restaurant extension. + + Design notes + ------------ + * One LNbits wallet → one *or many* restaurants. We do not assume 1:1. + A single owner can run multiple kitchens out of one LNbits account. + * Each `restaurants` row carries its own Nostr identity (pubkey + relay + hints). The wallet's account keypair is the default signing key, but + a per-restaurant override is allowed for venues that want to keep + their public identity separate from their LNbits owner identity. + * Every publishable row (restaurants, menu_items) has nostr_event_id + + nostr_event_created_at columns so reconciliation against relay state + is cheap. + * We do NOT store an "umbrella order" spanning multiple restaurants. + Cross-restaurant grouping is the customer/webapp's concern; the + extension only ever knows about its own restaurant's orders. + * Modifiers and addons are unified under modifier_groups + + modifiers (a `kind` column on the group distinguishes "required + choice" from "optional addon"). Flat is better than nested. + """ + + # ---------------------------------------------------------------- # + # Restaurants # + # ---------------------------------------------------------------- # + await db.execute( + f""" + CREATE TABLE restaurant.restaurants ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + currency TEXT NOT NULL DEFAULT 'sat', + timezone TEXT NOT NULL DEFAULT 'UTC', + location TEXT, + geohash TEXT, + logo_url TEXT, + banner_url TEXT, + social_links TEXT, + open_hours TEXT, + is_open BOOLEAN NOT NULL DEFAULT TRUE, + accepts_cash BOOLEAN NOT NULL DEFAULT TRUE, + accepts_lightning BOOLEAN NOT NULL DEFAULT TRUE, + tip_presets TEXT, + tax_rate REAL NOT NULL DEFAULT 0, + printer_endpoint TEXT, + nostr_pubkey TEXT, + nostr_relays TEXT, + nostr_event_id TEXT, + nostr_event_created_at INTEGER, + extra TEXT, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + # ---------------------------------------------------------------- # + # Categories + Subcategories # + # ---------------------------------------------------------------- # + await db.execute( + f""" + CREATE TABLE restaurant.categories ( + id TEXT PRIMARY KEY, + restaurant_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + image_url TEXT, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_categories_restaurant ON categories(restaurant_id);" + ) + + await db.execute( + f""" + CREATE TABLE restaurant.subcategories ( + id TEXT PRIMARY KEY, + category_id TEXT NOT NULL, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_subcategories_category ON subcategories(category_id);" + ) + + # ---------------------------------------------------------------- # + # Menu items # + # ---------------------------------------------------------------- # + # `dietary` and `allergens` are JSON-encoded string arrays: + # dietary → ["vegan", "gluten_free", ...] + # allergens → ["nuts", "dairy", "shellfish", ...] + # `images` is a JSON array of URLs (first one is the cover). + await db.execute( + f""" + CREATE TABLE restaurant.menu_items ( + id TEXT PRIMARY KEY, + restaurant_id TEXT NOT NULL, + category_id TEXT, + subcategory_id TEXT, + name TEXT NOT NULL, + description TEXT, + price REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'sat', + sku TEXT, + images TEXT, + dietary TEXT, + allergens TEXT, + ingredients TEXT, + calories INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + is_available BOOLEAN NOT NULL DEFAULT TRUE, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + stock INTEGER, + low_stock_threshold INTEGER, + nostr_event_id TEXT, + nostr_event_created_at INTEGER, + extra TEXT, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_menu_items_restaurant ON menu_items(restaurant_id);" + ) + await db.execute( + "CREATE INDEX restaurant.idx_menu_items_category ON menu_items(category_id);" + ) + + # ---------------------------------------------------------------- # + # Modifier groups + modifiers (covers required choices AND addons) # + # ---------------------------------------------------------------- # + # kind: 'required' (e.g. "Choose your protein"), 'optional' (e.g. "Extras") + # selection: 'one' (radio) | 'many' (checkbox) + await db.execute( + f""" + CREATE TABLE restaurant.modifier_groups ( + id TEXT PRIMARY KEY, + menu_item_id TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'required', + selection TEXT NOT NULL DEFAULT 'one', + min_selections INTEGER NOT NULL DEFAULT 0, + max_selections INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_modgroups_item ON modifier_groups(menu_item_id);" + ) + + await db.execute( + f""" + CREATE TABLE restaurant.modifiers ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + price_delta REAL NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_modifiers_group ON modifiers(group_id);" + ) + + # ---------------------------------------------------------------- # + # Availability windows (per item) # + # ---------------------------------------------------------------- # + # weekday: 0-6 (Mon-Sun), or NULL for "every day" + # start_time / end_time: 'HH:MM' 24h, restaurant-local timezone + await db.execute( + f""" + CREATE TABLE restaurant.availability_windows ( + id TEXT PRIMARY KEY, + menu_item_id TEXT NOT NULL, + weekday INTEGER, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_availability_item ON availability_windows(menu_item_id);" + ) + + # ---------------------------------------------------------------- # + # Orders # + # ---------------------------------------------------------------- # + # status: + # pending → invoice issued, not yet paid + # paid → invoice settled (kicked off by tasks.py listener) + # accepted → restaurant has acknowledged, prep in progress + # ready → ready for pickup / served + # completed → finished + # canceled → manually canceled + # refunded → paid then refunded + # + # customer_pubkey is the Nostr pubkey of the ordering customer (when + # the order arrived via NIP-17 DM). Optional; cash and walk-in orders + # have no pubkey. + # + # parent_order_ref is opaque metadata so a webapp can correlate this + # order with its own multi-restaurant umbrella order. The extension + # never reads this — it just stores and echoes it back. + await db.execute( + f""" + CREATE TABLE restaurant.orders ( + id TEXT PRIMARY KEY, + restaurant_id TEXT NOT NULL, + wallet TEXT NOT NULL, + customer_pubkey TEXT, + customer_name TEXT, + customer_contact TEXT, + status TEXT NOT NULL DEFAULT 'pending', + channel TEXT NOT NULL DEFAULT 'rest', + payment_method TEXT NOT NULL DEFAULT 'lightning', + payment_hash TEXT, + bolt11 TEXT, + subtotal_msat INTEGER NOT NULL DEFAULT 0, + tip_msat INTEGER NOT NULL DEFAULT 0, + tax_msat INTEGER NOT NULL DEFAULT 0, + total_msat INTEGER NOT NULL DEFAULT 0, + currency_display TEXT NOT NULL DEFAULT 'sat', + fiat_amount REAL, + fiat_rate REAL, + note TEXT, + parent_order_ref TEXT, + paid_at TIMESTAMP, + accepted_at TIMESTAMP, + ready_at TIMESTAMP, + completed_at TIMESTAMP, + canceled_at TIMESTAMP, + extra TEXT, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_orders_restaurant ON orders(restaurant_id);" + ) + await db.execute( + "CREATE INDEX restaurant.idx_orders_payment_hash ON orders(payment_hash);" + ) + await db.execute( + "CREATE INDEX restaurant.idx_orders_status ON orders(status);" + ) + + # ---------------------------------------------------------------- # + # Order items # + # ---------------------------------------------------------------- # + # selected_modifiers is a JSON snapshot of the modifier names + price + # deltas at order time. We snapshot rather than FK so price/menu + # changes don't retroactively rewrite history. + await db.execute( + f""" + CREATE TABLE restaurant.order_items ( + id TEXT PRIMARY KEY, + order_id TEXT NOT NULL, + menu_item_id TEXT, + name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price_msat INTEGER NOT NULL DEFAULT 0, + line_total_msat INTEGER NOT NULL DEFAULT 0, + selected_modifiers TEXT, + note TEXT, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_order_items_order ON order_items(order_id);" + ) + + # ---------------------------------------------------------------- # + # Print jobs # + # ---------------------------------------------------------------- # + # status: queued | sent | acknowledged | failed + await db.execute( + f""" + CREATE TABLE restaurant.print_jobs ( + id TEXT PRIMARY KEY, + restaurant_id TEXT NOT NULL, + order_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + sent_at TIMESTAMP, + acknowledged_at TIMESTAMP, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX restaurant.idx_print_jobs_order ON print_jobs(order_id);" + ) + await db.execute( + "CREATE INDEX restaurant.idx_print_jobs_status ON print_jobs(status);" + ) + + # ---------------------------------------------------------------- # + # Settings # + # ---------------------------------------------------------------- # + await db.execute( + """ + CREATE TABLE IF NOT EXISTS restaurant.settings ( + id INTEGER PRIMARY KEY DEFAULT 1, + nostr_publish_enabled BOOLEAN NOT NULL DEFAULT TRUE, + nostr_orders_enabled BOOLEAN NOT NULL DEFAULT FALSE, + invoice_expiry_seconds INTEGER NOT NULL DEFAULT 900, + auto_accept_orders BOOLEAN NOT NULL DEFAULT FALSE + ); + """ + ) + await db.execute( + """ + INSERT INTO restaurant.settings (id) VALUES (1) + ON CONFLICT (id) DO NOTHING; + """ + )