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:
parent
3b046276f6
commit
5255a99f1a
1 changed files with 333 additions and 0 deletions
333
migrations.py
Normal file
333
migrations.py
Normal 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;
|
||||||
|
"""
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue