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.
This commit is contained in:
Padreug 2026-04-29 23:35:08 +02:00
commit 5255a99f1a

333
migrations.py Normal file
View file

@ -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;
"""
)