Compare commits
2 Commits
4b56eb7ab1
...
f804ff8442
| Author | SHA1 | Date | |
|---|---|---|---|
| f804ff8442 | |||
| d9abb275a5 |
@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
|
||||
|
||||
start = time.perf_counter()
|
||||
result = connection.execute(text(sql))
|
||||
columns = list(result.keys()) if result.returns_rows else []
|
||||
rows_raw = result.fetchmany(max_rows + 1)
|
||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
|
||||
columns = list(result.keys()) if result.returns_rows else []
|
||||
truncated = len(rows_raw) > max_rows
|
||||
rows_raw = rows_raw[:max_rows]
|
||||
|
||||
|
||||
@@ -40,129 +40,272 @@ function sqlQueryTool() {
|
||||
// Schema explorer
|
||||
showPresets: true,
|
||||
expandedCategories: {},
|
||||
presetQueries: [
|
||||
presetSearch: '',
|
||||
|
||||
// Preset sections — grouped by platform
|
||||
presetSections: [
|
||||
// ── Infrastructure ──
|
||||
{
|
||||
category: 'Schema',
|
||||
items: [
|
||||
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
|
||||
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
|
||||
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
|
||||
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
|
||||
label: 'Infrastructure',
|
||||
groups: [
|
||||
{
|
||||
category: 'Schema',
|
||||
items: [
|
||||
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
|
||||
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
|
||||
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
|
||||
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Statistics',
|
||||
items: [
|
||||
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
|
||||
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
|
||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Core ──
|
||||
{
|
||||
category: 'Statistics',
|
||||
items: [
|
||||
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
|
||||
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
|
||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||
label: 'Core',
|
||||
groups: [
|
||||
{
|
||||
category: 'Tenancy',
|
||||
items: [
|
||||
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||
{ name: 'Merchant domains', sql: "SELECT md.id, m.name AS merchant_name,\n md.domain, md.is_primary, md.is_active,\n md.ssl_status, md.is_verified\nFROM merchant_domains md\nJOIN merchants m ON m.id = md.merchant_id\nORDER BY m.name, md.domain;" },
|
||||
{ name: 'Store domains', sql: "SELECT sd.id, s.name AS store_name,\n sd.domain, sd.is_primary, sd.is_active,\n sd.ssl_status, sd.is_verified\nFROM store_domains sd\nJOIN stores s ON s.id = sd.store_id\nORDER BY s.name, sd.domain;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Permissions',
|
||||
items: [
|
||||
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
|
||||
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
|
||||
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
|
||||
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
|
||||
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Admin & Audit',
|
||||
items: [
|
||||
{ name: 'Audit log', sql: "SELECT al.id, u.email AS admin_email,\n al.action, al.target_type, al.target_id,\n al.ip_address, al.created_at\nFROM admin_audit_logs al\nJOIN users u ON u.id = al.admin_user_id\nORDER BY al.created_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Active sessions', sql: "SELECT s.id, u.email AS admin_email,\n s.ip_address, s.login_at, s.last_activity_at,\n s.is_active, s.logout_reason\nFROM admin_sessions s\nJOIN users u ON u.id = s.admin_user_id\nORDER BY s.last_activity_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin settings', sql: "SELECT id, key, value, value_type,\n category, is_encrypted, is_public\nFROM admin_settings\nORDER BY category, key;" },
|
||||
{ name: 'Platform alerts', sql: "SELECT id, alert_type, severity, title,\n is_resolved, occurrence_count,\n first_occurred_at, last_occurred_at\nFROM platform_alerts\nORDER BY last_occurred_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Application logs', sql: "SELECT id, timestamp, level, logger_name,\n module, message, exception_type,\n request_id\nFROM application_logs\nORDER BY timestamp DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Customers',
|
||||
items: [
|
||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Password reset tokens', sql: "SELECT prt.id, c.email AS customer_email,\n prt.expires_at, prt.used_at, prt.created_at\nFROM password_reset_tokens prt\nJOIN customers c ON c.id = prt.customer_id\nORDER BY prt.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Messaging',
|
||||
items: [
|
||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Conversations', sql: "SELECT cv.id, cv.conversation_type, cv.subject,\n s.name AS store_name, cv.is_closed,\n cv.message_count, cv.last_message_at\nFROM conversations cv\nLEFT JOIN stores s ON s.id = cv.store_id\nORDER BY cv.last_message_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Messages', sql: "SELECT m.id, m.conversation_id,\n m.sender_type, m.sender_id,\n LEFT(m.content, 100) AS content_preview,\n m.is_system_message, m.created_at\nFROM messages m\nORDER BY m.created_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Message attachments', sql: "SELECT ma.id, ma.message_id,\n ma.original_filename, ma.mime_type,\n ma.file_size, ma.is_image, ma.created_at\nFROM message_attachments ma\nORDER BY ma.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Store email templates', sql: "SELECT set_.id, s.name AS store_name,\n set_.template_code, set_.language,\n set_.name, set_.is_active\nFROM store_email_templates set_\nJOIN stores s ON s.id = set_.store_id\nORDER BY s.name, set_.template_code;" },
|
||||
{ name: 'Store email settings', sql: "SELECT ses.id, s.name AS store_name,\n ses.from_email, ses.from_name, ses.provider,\n ses.is_configured, ses.is_verified\nFROM store_email_settings ses\nJOIN stores s ON s.id = ses.store_id\nORDER BY s.name;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'CMS',
|
||||
items: [
|
||||
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Billing',
|
||||
items: [
|
||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||
{ name: 'Tier feature limits', sql: "SELECT tfl.id, st.code AS tier_code,\n st.name AS tier_name, tfl.feature_code,\n tfl.limit_value\nFROM tier_feature_limits tfl\nJOIN subscription_tiers st ON st.id = tfl.tier_id\nORDER BY st.code, tfl.feature_code;" },
|
||||
{ name: 'Merchant feature overrides', sql: "SELECT mfo.id, m.name AS merchant_name,\n p.code AS platform_code, mfo.feature_code,\n mfo.limit_value, mfo.is_enabled, mfo.reason\nFROM merchant_feature_overrides mfo\nJOIN merchants m ON m.id = mfo.merchant_id\nJOIN platforms p ON p.id = mfo.platform_id\nORDER BY m.name, mfo.feature_code;" },
|
||||
{ name: 'Store add-ons', sql: "SELECT sa.id, s.name AS store_name,\n ap.name AS addon_name, sa.status,\n sa.quantity, sa.domain_name,\n sa.period_start, sa.period_end\nFROM store_addons sa\nJOIN stores s ON s.id = sa.store_id\nJOIN addon_products ap ON ap.id = sa.addon_product_id\nORDER BY sa.id DESC;" },
|
||||
{ name: 'Stripe webhook events', sql: "SELECT swe.id, swe.event_id, swe.event_type,\n swe.status, swe.processed_at,\n s.name AS store_name, swe.error_message\nFROM stripe_webhook_events swe\nLEFT JOIN stores s ON s.id = swe.store_id\nORDER BY swe.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── OMS ──
|
||||
{
|
||||
category: 'Tenancy',
|
||||
items: [
|
||||
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
|
||||
label: 'OMS',
|
||||
groups: [
|
||||
{
|
||||
category: 'Orders',
|
||||
items: [
|
||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order item exceptions', sql: "SELECT oie.id, o.order_number,\n oie.original_product_name, oie.original_gtin,\n oie.exception_type, oie.status,\n oie.resolved_at, oie.created_at\nFROM order_item_exceptions oie\nJOIN order_items oi ON oi.id = oie.order_item_id\nJOIN orders o ON o.id = oi.order_id\nORDER BY oie.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Invoice settings', sql: "SELECT sis.id, s.name AS store_name,\n sis.merchant_name, sis.vat_number,\n sis.is_vat_registered, sis.invoice_prefix,\n sis.invoice_next_number, sis.default_vat_rate\nFROM store_invoice_settings sis\nJOIN stores s ON s.id = sis.store_id\nORDER BY s.name;" },
|
||||
{ name: 'Customer order stats', sql: "SELECT cos.id, s.name AS store_name,\n c.email AS customer_email, cos.total_orders,\n cos.total_spent_cents, cos.first_order_date,\n cos.last_order_date\nFROM customer_order_stats cos\nJOIN stores s ON s.id = cos.store_id\nJOIN customers c ON c.id = cos.customer_id\nORDER BY cos.total_spent_cents DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Cart',
|
||||
items: [
|
||||
{ name: 'Cart items', sql: "SELECT ci.id, s.name AS store_name,\n p.store_sku, ci.session_id,\n ci.quantity, ci.price_at_add_cents,\n ci.created_at\nFROM cart_items ci\nJOIN stores s ON s.id = ci.store_id\nJOIN products p ON p.id = ci.product_id\nORDER BY ci.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Catalog',
|
||||
items: [
|
||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Inventory',
|
||||
items: [
|
||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Marketplace',
|
||||
items: [
|
||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Import errors', sql: "SELECT mie.id, mij.marketplace,\n mie.row_number, mie.identifier,\n mie.error_type, mie.error_message\nFROM marketplace_import_errors mie\nJOIN marketplace_import_jobs mij ON mij.id = mie.import_job_id\nORDER BY mie.created_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT mpt.id, mp.gtin,\n mpt.language, mpt.title, mpt.url_slug\nFROM marketplace_product_translations mpt\nJOIN marketplace_products mp ON mp.id = mpt.marketplace_product_id\nORDER BY mpt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Letzshop credentials', sql: "SELECT slc.id, s.name AS store_name,\n slc.api_endpoint, slc.auto_sync_enabled,\n slc.sync_interval_minutes, slc.last_sync_at,\n slc.last_sync_status\nFROM store_letzshop_credentials slc\nJOIN stores s ON s.id = slc.store_id\nORDER BY s.name;" },
|
||||
{ name: 'Sync logs', sql: "SELECT sl.id, s.name AS store_name,\n sl.operation_type, sl.direction, sl.status,\n sl.records_processed, sl.records_failed,\n sl.duration_seconds, sl.triggered_by\nFROM letzshop_sync_logs sl\nJOIN stores s ON s.id = sl.store_id\nORDER BY sl.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Historical import jobs', sql: "SELECT hij.id, s.name AS store_name,\n hij.status, hij.current_phase,\n hij.orders_imported, hij.orders_skipped,\n hij.products_matched, hij.products_not_found\nFROM letzshop_historical_import_jobs hij\nJOIN stores s ON s.id = hij.store_id\nORDER BY hij.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Loyalty ──
|
||||
{
|
||||
category: 'Permissions',
|
||||
items: [
|
||||
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
|
||||
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
|
||||
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
|
||||
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
|
||||
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
|
||||
label: 'Loyalty',
|
||||
groups: [
|
||||
{
|
||||
category: 'Loyalty',
|
||||
items: [
|
||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||
{ name: 'Apple device registrations', sql: "SELECT adr.id, lc.card_number,\n adr.device_library_identifier,\n adr.push_token, adr.created_at\nFROM apple_device_registrations adr\nJOIN loyalty_cards lc ON lc.id = adr.card_id\nORDER BY adr.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Merchant loyalty settings', sql: "SELECT mls.id, m.name AS merchant_name,\n mls.staff_pin_policy,\n mls.allow_self_enrollment,\n mls.allow_void_transactions,\n mls.allow_cross_location_redemption,\n mls.require_order_reference\nFROM merchant_loyalty_settings mls\nJOIN merchants m ON m.id = mls.merchant_id\nORDER BY m.name;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Hosting ──
|
||||
{
|
||||
category: 'System',
|
||||
items: [
|
||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||
label: 'Hosting',
|
||||
groups: [
|
||||
{
|
||||
category: 'Hosting',
|
||||
items: [
|
||||
{ name: 'Hosted sites', sql: "SELECT hs.id, s.name AS store_name,\n hs.business_name, hs.status,\n hs.contact_email, hs.live_domain,\n hs.went_live_at, hs.created_at\nFROM hosted_sites hs\nLEFT JOIN stores s ON s.id = hs.store_id\nORDER BY hs.created_at DESC;" },
|
||||
{ name: 'Client services', sql: "SELECT cs.id, hs.business_name,\n cs.service_type, cs.name, cs.status,\n cs.billing_period, cs.price_cents,\n cs.domain_name, cs.expires_at\nFROM client_services cs\nJOIN hosted_sites hs ON hs.id = cs.hosted_site_id\nORDER BY hs.business_name, cs.service_type;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Prospecting',
|
||||
items: [
|
||||
{ name: 'Prospects', sql: "SELECT id, channel, business_name,\n domain_name, status, source,\n city, country, created_at\nFROM prospects\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Prospect contacts', sql: "SELECT pc.id, p.business_name,\n pc.contact_type, pc.value, pc.label,\n pc.is_primary, pc.is_validated\nFROM prospect_contacts pc\nJOIN prospects p ON p.id = pc.prospect_id\nORDER BY pc.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Tech profiles', sql: "SELECT tp.id, p.business_name,\n tp.cms, tp.cms_version, tp.server,\n tp.hosting_provider, tp.ecommerce_platform,\n tp.has_valid_cert\nFROM prospect_tech_profiles tp\nJOIN prospects p ON p.id = tp.prospect_id\nORDER BY tp.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Performance profiles', sql: "SELECT pp.id, p.business_name,\n pp.performance_score, pp.accessibility_score,\n pp.seo_score, pp.is_mobile_friendly,\n pp.total_bytes, pp.total_requests\nFROM prospect_performance_profiles pp\nJOIN prospects p ON p.id = pp.prospect_id\nORDER BY pp.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Interactions', sql: "SELECT pi.id, p.business_name,\n pi.interaction_type, pi.subject,\n pi.outcome, pi.next_action,\n pi.next_action_date, pi.created_at\nFROM prospect_interactions pi\nJOIN prospects p ON p.id = pi.prospect_id\nORDER BY pi.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Scan jobs', sql: "SELECT id, job_type, status,\n total_items, processed_items, failed_items,\n started_at, completed_at\nFROM prospect_scan_jobs\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Security audits', sql: "SELECT psa.id, p.business_name,\n psa.score, psa.grade,\n psa.findings_count_critical,\n psa.findings_count_high,\n psa.has_https, psa.has_valid_ssl\nFROM prospect_security_audits psa\nJOIN prospects p ON p.id = psa.prospect_id\nORDER BY psa.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Prospect scores', sql: "SELECT ps.id, p.business_name,\n ps.score, ps.lead_tier,\n ps.technical_health_score,\n ps.modernity_score,\n ps.business_value_score,\n ps.engagement_score\nFROM prospect_scores ps\nJOIN prospects p ON p.id = ps.prospect_id\nORDER BY ps.score DESC\nLIMIT 50;" },
|
||||
{ name: 'Campaign templates', sql: "SELECT id, name, lead_type,\n channel, language, is_active\nFROM campaign_templates\nORDER BY lead_type, channel;" },
|
||||
{ name: 'Campaign sends', sql: "SELECT cs.id, ct.name AS template_name,\n p.business_name, cs.channel,\n cs.status, cs.sent_at\nFROM campaign_sends cs\nJOIN campaign_templates ct ON ct.id = cs.template_id\nJOIN prospects p ON p.id = cs.prospect_id\nORDER BY cs.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Internal ──
|
||||
{
|
||||
category: 'Loyalty',
|
||||
items: [
|
||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Billing',
|
||||
items: [
|
||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Orders',
|
||||
items: [
|
||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Catalog',
|
||||
items: [
|
||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Customers',
|
||||
items: [
|
||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Inventory',
|
||||
items: [
|
||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'CMS',
|
||||
items: [
|
||||
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Messaging',
|
||||
items: [
|
||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Marketplace',
|
||||
items: [
|
||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||
label: 'Internal',
|
||||
groups: [
|
||||
{
|
||||
category: 'System',
|
||||
items: [
|
||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||
{ name: 'Menu configs', sql: "SELECT amc.id, amc.frontend_type,\n p.code AS platform_code, u.email,\n amc.menu_item_id, amc.is_visible\nFROM admin_menu_configs amc\nLEFT JOIN platforms p ON p.id = amc.platform_id\nLEFT JOIN users u ON u.id = amc.user_id\nORDER BY amc.frontend_type, amc.menu_item_id;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Monitoring',
|
||||
items: [
|
||||
{ name: 'Capacity snapshots', sql: "SELECT id, snapshot_date,\n active_stores, total_products,\n total_orders_month, total_team_members,\n db_size_mb, avg_response_ms,\n peak_cpu_percent, peak_memory_percent\nFROM capacity_snapshots\nORDER BY snapshot_date DESC\nLIMIT 30;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Dev Tools',
|
||||
items: [
|
||||
{ name: 'Test runs', sql: "SELECT id, timestamp, status,\n total_tests, passed, failed, errors,\n coverage_percent, duration_seconds,\n git_branch\nFROM test_runs\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||
{ name: 'Architecture scans', sql: "SELECT id, timestamp, validator_type,\n status, total_files, total_violations,\n errors, warnings, duration_seconds\nFROM architecture_scans\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||
{ name: 'Architecture violations', sql: "SELECT av.id, av.rule_id, av.rule_name,\n av.severity, av.file_path, av.line_number,\n av.status, av.message\nFROM architecture_violations av\nORDER BY av.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
get filteredPresetSections() {
|
||||
const q = this.presetSearch.toLowerCase().trim();
|
||||
if (!q) return this.presetSections;
|
||||
|
||||
const filtered = [];
|
||||
for (const section of this.presetSections) {
|
||||
const groups = [];
|
||||
for (const group of section.groups) {
|
||||
const items = group.items.filter(
|
||||
item => item.name.toLowerCase().includes(q)
|
||||
|| group.category.toLowerCase().includes(q)
|
||||
|| section.label.toLowerCase().includes(q)
|
||||
);
|
||||
if (items.length > 0) {
|
||||
groups.push({ ...group, items });
|
||||
}
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
filtered.push({ ...section, groups });
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
},
|
||||
|
||||
toggleCategory(category) {
|
||||
this.expandedCategories[category] = !this.expandedCategories[category];
|
||||
},
|
||||
|
||||
isCategoryExpanded(category) {
|
||||
if (this.presetSearch.trim()) return true;
|
||||
return this.expandedCategories[category] || false;
|
||||
},
|
||||
|
||||
|
||||
@@ -24,24 +24,37 @@
|
||||
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<div x-show="showPresets" x-collapse class="mt-3">
|
||||
<template x-for="group in presetQueries" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="preset in group.items" :key="preset.name">
|
||||
<li @click="loadPreset(preset)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="preset.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<!-- Search filter -->
|
||||
<div class="mb-2">
|
||||
<input type="text" x-model="presetSearch" placeholder="Filter presets..."
|
||||
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1.5 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<template x-for="section in filteredPresetSections" :key="section.label">
|
||||
<div class="mb-2">
|
||||
<div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
|
||||
x-text="section.label"></div>
|
||||
<template x-for="group in section.groups" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="preset in group.items" :key="preset.name">
|
||||
<li @click="loadPreset(preset)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="preset.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="presetSearch && filteredPresetSections.length === 0"
|
||||
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -214,14 +214,25 @@ Uses PKCS#7 signed `.pkpass` files and APNs push notifications.
|
||||
|
||||
## Cross-Store Redemption
|
||||
|
||||
When `allow_cross_location_redemption` is enabled in merchant settings:
|
||||
The `allow_cross_location_redemption` merchant setting controls both card scoping and enrollment behavior:
|
||||
|
||||
- Cards are scoped to the **merchant** (not individual stores)
|
||||
### When enabled (default)
|
||||
|
||||
- **One card per customer per merchant** — enforced at the application layer
|
||||
- Customer can earn stamps at Store A and redeem at Store B
|
||||
- Each transaction records which `store_id` it occurred at
|
||||
- The `enrolled_at_store_id` field tracks where the customer first enrolled
|
||||
- If a customer tries to enroll at a second store, the system returns their existing card with a message showing all available locations
|
||||
|
||||
When disabled, stamp/point operations are restricted to the enrollment store.
|
||||
### When disabled
|
||||
|
||||
- **One card per customer per store** — each store under the merchant issues its own card
|
||||
- Stamp/point operations are restricted to the card's enrollment store
|
||||
- A customer can hold separate cards at different stores under the same merchant
|
||||
- Re-enrolling at the **same** store returns the existing card
|
||||
- Enrolling at a **different** store creates a new card scoped to that store
|
||||
|
||||
**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` prevents duplicate cards at the same store regardless of the cross-location setting.
|
||||
|
||||
## Enrollment Flow
|
||||
|
||||
@@ -229,21 +240,25 @@ When disabled, stamp/point operations are restricted to the enrollment store.
|
||||
|
||||
Staff enrolls customer via terminal:
|
||||
1. Enter customer email (and optional name)
|
||||
2. System resolves or creates customer record
|
||||
3. Creates loyalty card with unique card number and QR code
|
||||
4. Creates `CARD_CREATED` transaction
|
||||
5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
|
||||
6. Creates Google Wallet object and Apple Wallet serial
|
||||
7. Returns card details with "Add to Wallet" URLs
|
||||
2. System resolves customer — checks the current store first, then searches across all stores under the merchant for an existing cardholder with the same email
|
||||
3. If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises `LoyaltyCardAlreadyExistsException`
|
||||
4. Otherwise creates loyalty card with unique card number and QR code
|
||||
5. Creates `CARD_CREATED` transaction
|
||||
6. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
|
||||
7. Creates Google Wallet object and Apple Wallet serial
|
||||
8. Returns card details with "Add to Wallet" URLs
|
||||
|
||||
### Self-Enrollment (Public)
|
||||
|
||||
Customer enrolls via public page (if `allow_self_enrollment` enabled):
|
||||
1. Customer visits `/loyalty/join` page
|
||||
2. Enters email and name
|
||||
3. System creates customer + card
|
||||
4. Redirected to success page with card number
|
||||
5. Can add to Google/Apple Wallet from success page
|
||||
2. Enters email, name, and optional birthday
|
||||
3. System resolves customer (cross-store lookup for existing cardholders under the same merchant)
|
||||
4. If already enrolled: returns existing card with success page showing location info
|
||||
- Cross-location enabled: "Your card works at all our locations" + store list
|
||||
- Cross-location disabled: "Your card is registered at {original_store}"
|
||||
5. If new: creates customer + card, redirected to success page with card number
|
||||
6. Can add to Google/Apple Wallet from success page
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
|
||||
@@ -120,14 +120,21 @@ Merchant-wide loyalty program configuration. One program per merchant, shared ac
|
||||
|
||||
### LoyaltyCard
|
||||
|
||||
Customer loyalty card linking a customer to a merchant's program. One card per customer per merchant.
|
||||
Customer loyalty card linking a customer to a merchant's program.
|
||||
|
||||
**Card uniqueness depends on the `allow_cross_location_redemption` merchant setting:**
|
||||
|
||||
- **Cross-location enabled (default):** One card per customer per merchant. The application layer enforces this by checking all stores under the merchant before creating a card. Re-enrolling at another store returns the existing card.
|
||||
- **Cross-location disabled:** One card per customer per store. A customer can hold separate cards at different stores under the same merchant, each scoped to its enrollment store.
|
||||
|
||||
**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` — always enforced. The per-merchant uniqueness (cross-location enabled) is enforced at the application layer in `card_service.enroll_customer`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `merchant_id` | FK | Links to program's merchant |
|
||||
| `customer_id` | FK | Card owner |
|
||||
| `program_id` | FK | Associated program |
|
||||
| `enrolled_at_store_id` | FK | Store where customer enrolled |
|
||||
| `enrolled_at_store_id` | FK | Store where customer enrolled (part of unique constraint) |
|
||||
| `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX |
|
||||
| `qr_code_data` | String (unique) | URL-safe token for QR codes |
|
||||
| `stamp_count` | Integer | Current stamp count |
|
||||
|
||||
@@ -40,11 +40,14 @@ This is the active execution plan for taking the Loyalty module to production. I
|
||||
```
|
||||
loyalty_002 (existing)
|
||||
loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.)
|
||||
loyalty_004 — seed 28 notification email templates
|
||||
loyalty_005 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
|
||||
loyalty_004 — relax card uniqueness: replace (merchant_id, customer_id) unique index
|
||||
with (enrolled_at_store_id, customer_id) for cross-location support
|
||||
loyalty_005 — seed 28 notification email templates
|
||||
loyalty_006 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
|
||||
acting_admin_id on transactions
|
||||
loyalty_006 — terms_cms_page_slug on programs
|
||||
loyalty_007 — birth_date on customers (P0 — Phase 1.4 fix, dropped data bug)
|
||||
loyalty_007 — terms_cms_page_slug on programs
|
||||
|
||||
customers_003 — birth_date on customers (Phase 1.4 fix, dropped data bug)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -82,7 +85,7 @@ All 8 decisions locked. No external blockers.
|
||||
#### 1.4 Fix dropped birthday data (P0 bug)
|
||||
**Background:** The enrollment form (`enroll.html:87`) collects a birthday, the schema accepts `customer_birthday` (`schemas/card.py:34`), and `card_service.resolve_customer_id` has the parameter (`card_service.py:172`) — but the call to `customer_service.create_customer_for_enrollment` at `card_service.py:215-222` does not pass it, and the `Customer` model has no `birth_date` column at all. Every enrollment silently loses the birthday. Not live yet, so no backfill needed.
|
||||
|
||||
- New migration `loyalty_007_add_customer_birth_date.py` (or place under customers module if that's the convention) adds `birth_date: Date | None` to `customers`.
|
||||
- Migration `customers_003_add_birth_date.py` adds `birth_date: Date | None` to `customers`.
|
||||
- Update `customer_service.create_customer_for_enrollment` to accept and persist `birth_date`.
|
||||
- Update `card_service.py:215-222` to pass `customer_birthday` through, with `date.fromisoformat()` parsing and validation (must be a real past date, sensible age range).
|
||||
- Update `customer_service.update_customer` to allow backfill.
|
||||
@@ -114,7 +117,7 @@ All 8 decisions locked. No external blockers.
|
||||
- New `app/modules/loyalty/tasks/notifications.py` with `@shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60)`.
|
||||
- Opens fresh `SessionLocal`, calls `EmailService(db).send_template(...)`, retries on SMTP errors.
|
||||
|
||||
#### 2.3 Seed templates `loyalty_004`
|
||||
#### 2.3 Seed templates `loyalty_005`
|
||||
- 7 templates × 4 locales (en, fr, de, lb) = **28 rows** in `email_templates`.
|
||||
- Template codes: `loyalty_enrollment`, `loyalty_welcome_bonus`, `loyalty_points_expiring`, `loyalty_points_expired`, `loyalty_reward_ready`, `loyalty_birthday`, `loyalty_reengagement`.
|
||||
- **Copywriting needs sign-off** before applying to prod.
|
||||
@@ -123,7 +126,7 @@ All 8 decisions locked. No external blockers.
|
||||
- In `card_service.enroll_customer_for_store` (~lines 480-540), call notification service **after** `db.commit()`.
|
||||
|
||||
#### 2.5 Wire expiration warning into expiration task
|
||||
- Migration `loyalty_005` adds `last_expiration_warning_at` to prevent duplicates.
|
||||
- Migration `loyalty_006` adds `last_expiration_warning_at` to prevent duplicates.
|
||||
- In rewritten `tasks/point_expiration.py` (see 3.1), find cards 14 days from expiry, fire warning, stamp timestamp.
|
||||
- **Validation:** time-mocked test — fires once at 14-day mark.
|
||||
|
||||
@@ -137,7 +140,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
#### 2.8 Re-engagement Celery beat task
|
||||
- Weekly schedule. Finds cards inactive > N days (default 60, configurable).
|
||||
- Throttled via `last_reengagement_at` (added in `loyalty_005`) — once per quarter per card.
|
||||
- Throttled via `last_reengagement_at` (added in `loyalty_006`) — once per quarter per card.
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +166,7 @@ All 8 decisions locked. No external blockers.
|
||||
### Phase 4 — Accessibility & T&C *(2d)*
|
||||
|
||||
#### 4.1 T&C via store CMS integration
|
||||
- Migration `loyalty_006`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
||||
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
||||
- Update `schemas/program.py` (`ProgramCreate`, `ProgramUpdate`).
|
||||
- `program-form.html:251` — CMS page picker scoped to the program's owning store.
|
||||
- `enroll.html:99-160` — resolve slug to CMS page URL/content; legacy `terms_text` fallback.
|
||||
@@ -224,7 +227,7 @@ All 8 decisions locked. No external blockers.
|
||||
- **Admin "act on behalf"** (`routes/api/admin.py`):
|
||||
- `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.)
|
||||
- Shared service layer; route stamps `acting_admin_id` in audit log
|
||||
- New `loyalty_transactions.acting_admin_id` column in `loyalty_005`.
|
||||
- New `loyalty_transactions.acting_admin_id` column in `loyalty_006`.
|
||||
|
||||
#### 6.5 Manual override: restore expired points
|
||||
- `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`.
|
||||
|
||||
@@ -792,3 +792,109 @@ flowchart TD
|
||||
There is no feature gating on loyalty program creation — you can test them in
|
||||
either order. Journey 0 is listed second because domain setup is about URL
|
||||
presentation, not a functional prerequisite for the loyalty module.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Launch E2E Test Checklist (Fashion Group)
|
||||
|
||||
Manual end-to-end checklist using Fashion Group (merchant 2: FASHIONHUB + FASHIONOUTLET).
|
||||
Covers all customer-facing flows including the cross-store enrollment and redemption features
|
||||
added in the Phase 1 production launch hardening.
|
||||
|
||||
### Pre-requisite: Program Setup (Journey 1)
|
||||
|
||||
If Fashion Group doesn't have a loyalty program yet:
|
||||
|
||||
1. Login as `jane.owner@fashiongroup.com` at `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login`
|
||||
2. Navigate to: `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings`
|
||||
3. Create program (hybrid or points), set welcome bonus, enable self-enrollment
|
||||
4. Verify Cross-Location Redemption is **enabled** in merchant settings
|
||||
|
||||
### Test 1: Customer Self-Enrollment (Journey 4)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 1.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` | Enrollment form loads, no console errors |
|
||||
| 1.2 | Fill in: fresh email, name, **birthday** → Submit | Redirected to success page with card number |
|
||||
| 1.3 | Check DB: `SELECT birth_date FROM customers WHERE email = '...'` | `birth_date` is set (not NULL) |
|
||||
| 1.4 | Enroll **without** birthday (different email) | Success, `birth_date` is NULL (no crash) |
|
||||
|
||||
### Test 2: Cross-Store Re-Enrollment (Cross-Location Enabled)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 2.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONOUTLET/loyalty/join` | Enrollment form loads |
|
||||
| 2.2 | Submit with the **same email** from Test 1 | Success page shows **"You're already a member!"** |
|
||||
| 2.3 | Check: store list shown | Blue box: "Your card works at all our locations:" with Fashion Hub + Fashion Outlet listed |
|
||||
| 2.4 | Check: same card number as Test 1 | Card number matches (no duplicate created) |
|
||||
| 2.5 | Check DB: `SELECT COUNT(*) FROM loyalty_cards WHERE customer_id = ...` | Exactly 1 card |
|
||||
| 2.6 | Re-enroll at FASHIONHUB (same store as original) | Same behavior: "already a member" + locations |
|
||||
| 2.7 | Refresh the success page | Message persists, no flicker, no untranslated i18n keys |
|
||||
|
||||
### Test 3: Staff Operations — Stamps/Points (Journeys 2 & 3)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 3.1 | Login as `jane.owner@fashiongroup.com` at FASHIONHUB | Login succeeds |
|
||||
| 3.2 | Open terminal: `.../store/FASHIONHUB/loyalty/terminal` | Terminal loads |
|
||||
| 3.3 | Look up card by card number | Card found, balance displayed |
|
||||
| 3.4 | Look up card by customer email | Card found (same result) |
|
||||
| 3.5 | Add stamp (or earn points with purchase amount) | Count/balance updates |
|
||||
| 3.6 | Add stamp again immediately (within cooldown) | Rejected: cooldown active |
|
||||
|
||||
### Test 4: Cross-Store Redemption (Journey 8)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 4.1 | Staff at FASHIONHUB adds stamps/points to the card | Balance updated |
|
||||
| 4.2 | Login as staff at FASHIONOUTLET (e.g., `diana.stylist@fashiongroup.com` or `jane.owner`) | Login succeeds |
|
||||
| 4.3 | Open terminal: `.../store/FASHIONOUTLET/loyalty/terminal` | Terminal loads |
|
||||
| 4.4 | Look up card **by email** | Card found (cross-store email search) |
|
||||
| 4.5 | Look up card **by card number** | Card found |
|
||||
| 4.6 | Redeem reward (if enough stamps/points) | Redemption succeeds |
|
||||
| 4.7 | View card detail | Transaction history shows entries from both FASHIONHUB and FASHIONOUTLET |
|
||||
|
||||
### Test 5: Customer Views Status (Journey 5)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 5.1 | Login as the customer at storefront | Customer dashboard loads |
|
||||
| 5.2 | Dashboard: `.../storefront/FASHIONHUB/account/loyalty` | Shows balance, available rewards |
|
||||
| 5.3 | History: `.../storefront/FASHIONHUB/account/loyalty/history` | Shows transactions from both stores |
|
||||
|
||||
### Test 6: Void/Return (Journey 7)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 6.1 | Staff at FASHIONHUB opens terminal, looks up card | Card found |
|
||||
| 6.2 | Void a stamp or points transaction | Balance adjusted |
|
||||
| 6.3 | Check transaction history | Void transaction appears, linked to original |
|
||||
|
||||
### Test 7: Admin Oversight (Journey 6)
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 7.1 | Login as `samir.boulahtit@gmail.com` (admin) | Admin dashboard loads |
|
||||
| 7.2 | Programs: `.../admin/loyalty/programs` | Fashion Group program visible |
|
||||
| 7.3 | Fashion Group detail: `.../admin/loyalty/merchants/2` | Cards, transactions, stats appear correctly |
|
||||
| 7.4 | Fashion Group settings: `.../admin/loyalty/merchants/2/settings` | Cross-location toggle visible and correct |
|
||||
|
||||
### Test 8: Cross-Location Disabled Behavior
|
||||
|
||||
| Step | Action | Expected Result |
|
||||
|------|--------|-----------------|
|
||||
| 8.1 | Admin disables Cross-Location Redemption for Fashion Group | Setting saved |
|
||||
| 8.2 | Enroll a **new email** at FASHIONHUB | New card created for FASHIONHUB |
|
||||
| 8.3 | Enroll **same email** at FASHIONOUTLET | **New card created** for FASHIONOUTLET (separate card) |
|
||||
| 8.4 | Enroll **same email** at FASHIONHUB again | "Already a member" — shows "Your card is registered at Fashion Hub" (single store, no list) |
|
||||
| 8.5 | Staff at FASHIONOUTLET searches by email | Only finds the FASHIONOUTLET card (no cross-store search) |
|
||||
| 8.6 | Re-enable Cross-Location Redemption when done | Restore default state |
|
||||
|
||||
### Key Things to Watch
|
||||
|
||||
- [ ] Birthday persisted after enrollment (check DB)
|
||||
- [ ] No i18n flicker or console warnings on success page
|
||||
- [ ] Cross-store email search works in the terminal (cross-location enabled)
|
||||
- [ ] "Already a member" message shows correct locations/store based on cross-location setting
|
||||
- [ ] No duplicate cards created under same merchant (when cross-location enabled)
|
||||
- [ ] Rate limiting: rapid-fire stamp calls eventually return 429
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"view_dashboard": "Mein Treue-Dashboard anzeigen",
|
||||
"continue_shopping": "Weiter einkaufen"
|
||||
},
|
||||
"already_enrolled_title": "Sie sind bereits Mitglied!",
|
||||
"cross_location_message": "Ihre Karte gilt an allen unseren Standorten:",
|
||||
"single_location_message": "Ihre Karte ist bei {store_name} registriert",
|
||||
"available_locations": "Nutzen Sie Ihre Karte an allen unseren Standorten:",
|
||||
"errors": {
|
||||
"load_failed": "Programminformationen konnten nicht geladen werden",
|
||||
"email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.",
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"view_dashboard": "View My Loyalty Dashboard",
|
||||
"continue_shopping": "Continue Shopping"
|
||||
},
|
||||
"already_enrolled_title": "You're already a member!",
|
||||
"cross_location_message": "Your card works at all our locations:",
|
||||
"single_location_message": "Your card is registered at {store_name}",
|
||||
"available_locations": "Use your card at all our locations:",
|
||||
"errors": {
|
||||
"load_failed": "Failed to load program information",
|
||||
"email_exists": "This email is already registered in our loyalty program.",
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"view_dashboard": "Voir mon tableau de bord fidélité",
|
||||
"continue_shopping": "Continuer mes achats"
|
||||
},
|
||||
"already_enrolled_title": "Vous êtes déjà membre !",
|
||||
"cross_location_message": "Votre carte est valable dans tous nos points de vente :",
|
||||
"single_location_message": "Votre carte est enregistrée chez {store_name}",
|
||||
"available_locations": "Utilisez votre carte dans tous nos points de vente :",
|
||||
"errors": {
|
||||
"load_failed": "Impossible de charger les informations du programme",
|
||||
"email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.",
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"view_dashboard": "Mäin Treie-Dashboard kucken",
|
||||
"continue_shopping": "Weider akafen"
|
||||
},
|
||||
"already_enrolled_title": "Dir sidd schonn Member!",
|
||||
"cross_location_message": "Är Kaart gëllt an all eise Standuerter:",
|
||||
"single_location_message": "Är Kaart ass bei {store_name} registréiert",
|
||||
"available_locations": "Benotzt Är Kaart an all eise Standuerter:",
|
||||
"errors": {
|
||||
"load_failed": "Programminformatiounen konnten net gelueden ginn",
|
||||
"email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""loyalty 004 - relax card uniqueness for cross-location support
|
||||
|
||||
Replace the (merchant_id, customer_id) and (customer_id, program_id)
|
||||
unique indexes with (enrolled_at_store_id, customer_id). This allows
|
||||
merchants with cross-location redemption DISABLED to issue one card per
|
||||
store per customer, while merchants with it ENABLED enforce the
|
||||
per-merchant constraint in the application layer.
|
||||
|
||||
Revision ID: loyalty_004
|
||||
Revises: loyalty_003
|
||||
Create Date: 2026-04-10
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "loyalty_004"
|
||||
down_revision = "loyalty_003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the old per-merchant unique indexes
|
||||
op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards")
|
||||
op.drop_index("idx_loyalty_card_customer_program", table_name="loyalty_cards")
|
||||
|
||||
# Keep a non-unique index on (merchant_id, customer_id) for lookups
|
||||
op.create_index(
|
||||
"idx_loyalty_card_merchant_customer",
|
||||
"loyalty_cards",
|
||||
["merchant_id", "customer_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# New unique constraint: one card per customer per store (always valid)
|
||||
op.create_index(
|
||||
"idx_loyalty_card_store_customer",
|
||||
"loyalty_cards",
|
||||
["enrolled_at_store_id", "customer_id"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_loyalty_card_store_customer", table_name="loyalty_cards")
|
||||
op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards")
|
||||
|
||||
# Restore original unique indexes
|
||||
op.create_index(
|
||||
"idx_loyalty_card_merchant_customer",
|
||||
"loyalty_cards",
|
||||
["merchant_id", "customer_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_loyalty_card_customer_program",
|
||||
"loyalty_cards",
|
||||
["customer_id", "program_id"],
|
||||
unique=True,
|
||||
)
|
||||
@@ -255,11 +255,14 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Indexes - one card per customer per merchant
|
||||
# Indexes
|
||||
# One card per customer per store (always enforced at DB level).
|
||||
# Per-merchant uniqueness (when cross-location is enabled) is enforced
|
||||
# by the application layer in enroll_customer().
|
||||
__table_args__ = (
|
||||
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id", unique=True),
|
||||
Index("idx_loyalty_card_store_customer", "enrolled_at_store_id", "customer_id", unique=True),
|
||||
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id"),
|
||||
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
|
||||
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
|
||||
# Balances must never go negative — guards against direct SQL writes
|
||||
# bypassing the service layer's clamping logic.
|
||||
CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"),
|
||||
|
||||
@@ -540,11 +540,18 @@ def enroll_customer(
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
|
||||
# Resolve merchant_id for cross-store customer lookup
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
merchant_id = store.merchant_id if store else None
|
||||
|
||||
customer_id = card_service.resolve_customer_id(
|
||||
db,
|
||||
customer_id=data.customer_id,
|
||||
email=data.email,
|
||||
store_id=store_id,
|
||||
merchant_id=merchant_id,
|
||||
)
|
||||
|
||||
card = card_service.enroll_customer_for_store(db, customer_id, store_id)
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_customer_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.customers.schemas import CustomerContext
|
||||
from app.modules.loyalty.exceptions import LoyaltyCardAlreadyExistsException
|
||||
from app.modules.loyalty.schemas import (
|
||||
CardEnrollRequest,
|
||||
CardResponse,
|
||||
@@ -88,6 +89,7 @@ def self_enroll(
|
||||
customer_id=data.customer_id,
|
||||
email=data.email,
|
||||
store_id=store.id,
|
||||
merchant_id=store.merchant_id,
|
||||
create_if_missing=True,
|
||||
customer_name=data.customer_name,
|
||||
customer_phone=data.customer_phone,
|
||||
@@ -96,10 +98,45 @@ def self_enroll(
|
||||
|
||||
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
||||
|
||||
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
||||
# Build merchant context for the response (locations, cross-location flag)
|
||||
settings = program_service.get_merchant_settings(db, store.merchant_id)
|
||||
allow_cross_location = (
|
||||
settings.allow_cross_location_redemption if settings else True
|
||||
)
|
||||
locations = program_service.get_merchant_locations(db, store.merchant_id)
|
||||
location_list = [
|
||||
{"id": loc.id, "name": loc.name}
|
||||
for loc in locations
|
||||
]
|
||||
|
||||
already_enrolled = False
|
||||
try:
|
||||
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
||||
except LoyaltyCardAlreadyExistsException:
|
||||
# Customer already has a card — return it instead of erroring out.
|
||||
# For cross-location=true this is the normal re-enroll-at-another-store
|
||||
# path; for cross-location=false this is a same-store re-enroll.
|
||||
already_enrolled = True
|
||||
if allow_cross_location:
|
||||
card = card_service.get_card_by_customer_and_merchant(
|
||||
db, customer_id, store.merchant_id
|
||||
)
|
||||
else:
|
||||
card = card_service.get_card_by_customer_and_store(
|
||||
db, customer_id, store.id
|
||||
)
|
||||
|
||||
program = card.program
|
||||
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||
|
||||
# Resolve the name of the original enrollment store
|
||||
enrolled_at_store_name = None
|
||||
if card.enrolled_at_store_id:
|
||||
for loc in locations:
|
||||
if loc.id == card.enrolled_at_store_id:
|
||||
enrolled_at_store_name = loc.name
|
||||
break
|
||||
|
||||
return {
|
||||
"card": CardResponse(
|
||||
id=card.id,
|
||||
@@ -122,6 +159,10 @@ def self_enroll(
|
||||
has_apple_wallet=bool(card.apple_serial_number),
|
||||
),
|
||||
"wallet_urls": wallet_urls,
|
||||
"already_enrolled": already_enrolled,
|
||||
"allow_cross_location": allow_cross_location,
|
||||
"enrolled_at_store_name": enrolled_at_store_name,
|
||||
"merchant_locations": location_list,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ async def loyalty_self_enrollment(
|
||||
async def loyalty_enrollment_success(
|
||||
request: Request,
|
||||
card: str = Query(None, description="Card number"),
|
||||
already: str = Query(None, description="Already enrolled flag"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -149,6 +150,27 @@ async def loyalty_enrollment_success(
|
||||
|
||||
context = get_storefront_context(request, db=db)
|
||||
context["enrolled_card_number"] = card
|
||||
|
||||
# Provide merchant locations and cross-location flag server-side so
|
||||
# the template doesn't depend on sessionStorage surviving refreshes.
|
||||
store = getattr(request.state, "store", None)
|
||||
if store:
|
||||
from app.modules.loyalty.services import program_service
|
||||
|
||||
settings = program_service.get_merchant_settings(db, store.merchant_id)
|
||||
locations = program_service.get_merchant_locations(db, store.merchant_id)
|
||||
context["server_already_enrolled"] = already == "1"
|
||||
context["server_allow_cross_location"] = (
|
||||
settings.allow_cross_location_redemption if settings else True
|
||||
)
|
||||
context["server_merchant_locations"] = [
|
||||
{"id": loc.id, "name": loc.name} for loc in locations
|
||||
]
|
||||
else:
|
||||
context["server_already_enrolled"] = False
|
||||
context["server_allow_cross_location"] = True
|
||||
context["server_merchant_locations"] = []
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"loyalty/storefront/enroll-success.html",
|
||||
context,
|
||||
|
||||
@@ -119,6 +119,23 @@ class CardService:
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_card_by_customer_and_store(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
store_id: int,
|
||||
) -> LoyaltyCard | None:
|
||||
"""Get a customer's card for a specific store."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(
|
||||
LoyaltyCard.customer_id == customer_id,
|
||||
LoyaltyCard.enrolled_at_store_id == store_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_card_by_customer_and_program(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -166,6 +183,7 @@ class CardService:
|
||||
customer_id: int | None,
|
||||
email: str | None,
|
||||
store_id: int,
|
||||
merchant_id: int | None = None,
|
||||
create_if_missing: bool = False,
|
||||
customer_name: str | None = None,
|
||||
customer_phone: str | None = None,
|
||||
@@ -179,6 +197,7 @@ class CardService:
|
||||
customer_id: Direct customer ID (used if provided)
|
||||
email: Customer email to look up
|
||||
store_id: Store ID for scoping the email lookup
|
||||
merchant_id: Merchant ID for cross-store loyalty card lookup
|
||||
create_if_missing: If True, create customer when email not found
|
||||
(used for self-enrollment)
|
||||
customer_name: Full name for customer creation
|
||||
@@ -196,6 +215,9 @@ class CardService:
|
||||
return customer_id
|
||||
|
||||
if email:
|
||||
from app.modules.customers.models.customer import (
|
||||
Customer as CustomerModel,
|
||||
)
|
||||
from app.modules.customers.services.customer_service import (
|
||||
customer_service,
|
||||
)
|
||||
@@ -210,6 +232,29 @@ class CardService:
|
||||
db.flush()
|
||||
return customer.id
|
||||
|
||||
# Customers are store-scoped, but loyalty cards are merchant-scoped.
|
||||
# Check if this email already has a card under the same merchant at
|
||||
# a different store — if so, reuse that customer_id so the duplicate
|
||||
# check in enroll_customer() fires correctly.
|
||||
if merchant_id:
|
||||
existing_cardholder = (
|
||||
db.query(CustomerModel)
|
||||
.join(
|
||||
LoyaltyCard,
|
||||
CustomerModel.id == LoyaltyCard.customer_id,
|
||||
)
|
||||
.filter(
|
||||
CustomerModel.email == email.lower(),
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_cardholder:
|
||||
if customer_birthday and not existing_cardholder.birth_date:
|
||||
existing_cardholder.birth_date = customer_birthday
|
||||
db.flush()
|
||||
return existing_cardholder.id
|
||||
|
||||
if create_if_missing:
|
||||
# Parse name into first/last
|
||||
first_name = customer_name or ""
|
||||
@@ -347,18 +392,45 @@ class CardService:
|
||||
|
||||
merchant_id = store.merchant_id
|
||||
|
||||
# Try card number
|
||||
# Try card number — always merchant-scoped
|
||||
card = self.get_card_by_number(db, query)
|
||||
if card and card.merchant_id == merchant_id:
|
||||
return card
|
||||
|
||||
# Try customer email
|
||||
# Try customer email — first at this store
|
||||
customer = customer_service.get_customer_by_email(db, store_id, query)
|
||||
if customer:
|
||||
card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id)
|
||||
if card:
|
||||
return card
|
||||
|
||||
# Cross-store email search: the customer may have enrolled at a
|
||||
# different store under the same merchant. Only search when
|
||||
# cross-location redemption is enabled.
|
||||
from app.modules.customers.models.customer import Customer as CustomerModel
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
|
||||
settings = program_service.get_merchant_settings(db, merchant_id)
|
||||
cross_location_enabled = (
|
||||
settings.allow_cross_location_redemption if settings else True
|
||||
)
|
||||
if cross_location_enabled:
|
||||
cross_store_customer = (
|
||||
db.query(CustomerModel)
|
||||
.join(LoyaltyCard, CustomerModel.id == LoyaltyCard.customer_id)
|
||||
.filter(
|
||||
CustomerModel.email == query.lower(),
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if cross_store_customer:
|
||||
card = self.get_card_by_customer_and_merchant(
|
||||
db, cross_store_customer.id, merchant_id
|
||||
)
|
||||
if card:
|
||||
return card
|
||||
|
||||
return None
|
||||
|
||||
def list_cards(
|
||||
@@ -479,10 +551,32 @@ class CardService:
|
||||
if not program.is_active:
|
||||
raise LoyaltyProgramInactiveException(program.id)
|
||||
|
||||
# Check if customer already has a card
|
||||
existing = self.get_card_by_customer_and_merchant(db, customer_id, merchant_id)
|
||||
if existing:
|
||||
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||
# Check for duplicate enrollment — the scope depends on whether
|
||||
# cross-location redemption is enabled for this merchant.
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
|
||||
settings = program_service.get_merchant_settings(db, merchant_id)
|
||||
if settings and not settings.allow_cross_location_redemption:
|
||||
# Per-store cards: only block if the customer already has a card
|
||||
# at THIS specific store. Cards at other stores are allowed.
|
||||
if enrolled_at_store_id:
|
||||
existing = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.customer_id == customer_id,
|
||||
LoyaltyCard.enrolled_at_store_id == enrolled_at_store_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||
else:
|
||||
# Cross-location enabled (default): one card per merchant
|
||||
existing = self.get_card_by_customer_and_merchant(
|
||||
db, customer_id, merchant_id
|
||||
)
|
||||
if existing:
|
||||
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||
|
||||
# Create the card
|
||||
card = LoyaltyCard(
|
||||
|
||||
@@ -81,10 +81,21 @@ function customerLoyaltyEnroll() {
|
||||
sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls));
|
||||
}
|
||||
|
||||
// Redirect to success page
|
||||
// Store enrollment context for the success page
|
||||
sessionStorage.setItem('loyalty_enroll_context', JSON.stringify({
|
||||
already_enrolled: response.already_enrolled || false,
|
||||
allow_cross_location: response.allow_cross_location ?? true,
|
||||
enrolled_at_store_name: response.enrolled_at_store_name || null,
|
||||
merchant_locations: response.merchant_locations || [],
|
||||
}));
|
||||
|
||||
// Redirect to success page — pass already_enrolled in the
|
||||
// URL so the message survives page refreshes (sessionStorage
|
||||
// is supplementary for the location list).
|
||||
const currentPath = window.location.pathname;
|
||||
const alreadyFlag = response.already_enrolled ? '&already=1' : '';
|
||||
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
||||
'?card=' + encodeURIComponent(cardNumber);
|
||||
'?card=' + encodeURIComponent(cardNumber) + alreadyFlag;
|
||||
window.location.href = successUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -105,12 +105,12 @@
|
||||
<template x-if="(card?.points_balance || 0) >= reward.points_required">
|
||||
<span class="inline-flex items-center text-sm font-medium text-green-600">
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4 mr-1')"></span>
|
||||
<span x-text="$t('loyalty.storefront.dashboard.ready_to_redeem')"></span>
|
||||
<span>{{ _('loyalty.storefront.dashboard.ready_to_redeem') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="(card?.points_balance || 0) < reward.points_required">
|
||||
<span class="text-sm text-gray-500"
|
||||
x-text="$t('loyalty.storefront.dashboard.x_more_to_go', {count: reward.points_required - (card?.points_balance || 0)})">
|
||||
x-text="'{{ _('loyalty.storefront.dashboard.x_more_to_go') }}'.replace('{count}', reward.points_required - (card?.points_balance || 0))">
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -208,14 +208,14 @@
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.apple') }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.google') }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
{% block title %}{{ _('loyalty.enrollment.success.title') }} - {{ store.name }}{% endblock %}
|
||||
|
||||
{% block i18n_modules %}['loyalty']{% endblock %}
|
||||
{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -16,7 +17,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ _('loyalty.enrollment.success.title') }}</h1>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2"
|
||||
x-text="enrollContext.already_enrolled ? i18nStrings.already_enrolled_title : i18nStrings.success_title">
|
||||
{{ _('loyalty.enrollment.success.title') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ _('loyalty.enrollment.success.message') }}</p>
|
||||
|
||||
<!-- Card Number Display -->
|
||||
@@ -33,14 +37,14 @@
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.apple') }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.google') }}
|
||||
</a>
|
||||
</template>
|
||||
@@ -48,6 +52,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cross-location info -->
|
||||
<template x-if="enrollContext.already_enrolled || (enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location)">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-8 text-left">
|
||||
<template x-if="enrollContext.already_enrolled && enrollContext.allow_cross_location">
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.cross_location_message"></p>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
|
||||
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
|
||||
<span x-text="loc.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="enrollContext.already_enrolled && !enrollContext.allow_cross_location">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="i18nStrings.single_location_message.replace('{store_name}', enrollContext.enrolled_at_store_name || '')"></p>
|
||||
</template>
|
||||
<template x-if="!enrollContext.already_enrolled && enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location">
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.available_locations"></p>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
|
||||
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
|
||||
<span x-text="loc.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Next Steps -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white mb-4">{{ _('loyalty.enrollment.success.next_steps_title') }}</h2>
|
||||
@@ -89,6 +128,20 @@ function customerLoyaltyEnrollSuccess() {
|
||||
return {
|
||||
...storefrontLayoutData(),
|
||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||
// Server-rendered context — no flicker, survives refreshes
|
||||
enrollContext: {
|
||||
already_enrolled: {{ server_already_enrolled|tojson }},
|
||||
allow_cross_location: {{ server_allow_cross_location|tojson }},
|
||||
enrolled_at_store_name: null,
|
||||
merchant_locations: {{ server_merchant_locations|tojson }},
|
||||
},
|
||||
i18nStrings: {
|
||||
success_title: {{ _('loyalty.enrollment.success.title')|tojson }},
|
||||
already_enrolled_title: {{ _('loyalty.enrollment.already_enrolled_title')|tojson }},
|
||||
cross_location_message: {{ _('loyalty.enrollment.cross_location_message')|tojson }},
|
||||
single_location_message: {{ _('loyalty.enrollment.single_location_message')|tojson }},
|
||||
available_locations: {{ _('loyalty.enrollment.available_locations')|tojson }},
|
||||
},
|
||||
|
||||
init() {
|
||||
// Read wallet URLs saved during enrollment (no auth needed)
|
||||
@@ -101,6 +154,20 @@ function customerLoyaltyEnrollSuccess() {
|
||||
} catch (e) {
|
||||
console.log('Could not load wallet URLs:', e.message);
|
||||
}
|
||||
|
||||
// Merge sessionStorage context (has enrolled_at_store_name from
|
||||
// the enrollment API response) into the server-rendered defaults
|
||||
try {
|
||||
const ctx = sessionStorage.getItem('loyalty_enroll_context');
|
||||
if (ctx) {
|
||||
const parsed = JSON.parse(ctx);
|
||||
if (parsed.enrolled_at_store_name) {
|
||||
this.enrollContext.enrolled_at_store_name = parsed.enrolled_at_store_name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load enroll context:', e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
{{ _('loyalty.storefront.history.previous') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="$t('loyalty.storefront.history.page_x_of_y', {page: pagination.page, pages: pagination.pages})">
|
||||
x-text="'{{ _('loyalty.storefront.history.page_x_of_y') }}'.replace('{page}', pagination.page).replace('{pages}', pagination.pages)">
|
||||
</span>
|
||||
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
|
||||
|
||||
@@ -830,3 +830,248 @@ class TestAdjustPointsRoleGate:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 4: Cross-store enrollment
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cross_store_setup(db, loyalty_platform):
|
||||
"""Setup with two stores under the same merchant for cross-store tests.
|
||||
|
||||
Creates: merchant → store1 + store2 (each with its own user),
|
||||
program, customer at store1 with card.
|
||||
"""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
# Owner for store1
|
||||
owner1 = User(
|
||||
email=f"xs1own_{uid}@test.com",
|
||||
username=f"xs1own_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner1)
|
||||
db.commit()
|
||||
db.refresh(owner1)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Cross-Store Merchant {uid}",
|
||||
owner_user_id=owner1.id,
|
||||
contact_email=owner1.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
# Store 1
|
||||
store1 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XS1_{uid.upper()}",
|
||||
subdomain=f"xs1{uid}",
|
||||
name=f"Cross Store 1 {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store1)
|
||||
db.commit()
|
||||
db.refresh(store1)
|
||||
|
||||
su1 = StoreUser(store_id=store1.id, user_id=owner1.id, is_active=True)
|
||||
db.add(su1)
|
||||
sp1 = StorePlatform(store_id=store1.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp1)
|
||||
db.commit()
|
||||
|
||||
# Separate user for store2 (login always binds to user's first store)
|
||||
owner2 = User(
|
||||
email=f"xs2own_{uid}@test.com",
|
||||
username=f"xs2own_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner2)
|
||||
db.commit()
|
||||
db.refresh(owner2)
|
||||
|
||||
# Store 2
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XS2_{uid.upper()}",
|
||||
subdomain=f"xs2{uid}",
|
||||
name=f"Cross Store 2 {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
db.refresh(store2)
|
||||
|
||||
su2 = StoreUser(store_id=store2.id, user_id=owner2.id, is_active=True)
|
||||
db.add(su2)
|
||||
sp2 = StorePlatform(store_id=store2.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp2)
|
||||
db.commit()
|
||||
|
||||
# Customer at store1
|
||||
customer_email = f"xscust_{uid}@test.com"
|
||||
customer = Customer(
|
||||
email=customer_email,
|
||||
first_name="Cross",
|
||||
last_name="StoreCustomer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"XSC-{uid.upper()}",
|
||||
store_id=store1.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
# Program
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=0,
|
||||
minimum_redemption_points=100,
|
||||
minimum_purchase_cents=0,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Cross Store Rewards",
|
||||
card_color="#4F46E5",
|
||||
is_active=True,
|
||||
points_rewards=[],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
# Card enrolled at store1
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store1.id,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"owner1": owner1,
|
||||
"owner2": owner2,
|
||||
"merchant": merchant,
|
||||
"store1": store1,
|
||||
"store2": store2,
|
||||
"customer": customer,
|
||||
"customer_email": customer_email,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cross_store_headers_store2(client, cross_store_setup):
|
||||
"""JWT auth headers bound to store2 (via owner2 who only belongs to store2)."""
|
||||
owner2 = cross_store_setup["owner2"]
|
||||
response = client.post(
|
||||
"/api/v1/store/auth/login",
|
||||
json={"email_or_username": owner2.username, "password": "storepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestCrossStoreEnrollment:
|
||||
"""Integration tests for enrollment across stores under the same merchant."""
|
||||
|
||||
def test_enroll_same_email_at_store2_returns_409(
|
||||
self, client, cross_store_headers_store2, cross_store_setup
|
||||
):
|
||||
"""With cross-location enabled (default), enrolling the same email
|
||||
at store2 returns 409 because the customer already has a card under
|
||||
this merchant."""
|
||||
response = client.post(
|
||||
f"{BASE}/cards/enroll",
|
||||
json={"email": cross_store_setup["customer_email"]},
|
||||
headers=cross_store_headers_store2,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_enroll_new_customer_at_store2_succeeds(
|
||||
self, client, cross_store_headers_store2, cross_store_setup, db
|
||||
):
|
||||
"""A fresh customer at store2 (no existing card) enrolls normally."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
|
||||
# Pre-create a customer at store2 (store API requires existing customer)
|
||||
store2 = cross_store_setup["store2"]
|
||||
new_customer = Customer(
|
||||
email=f"newcust_{uuid.uuid4().hex[:8]}@test.com",
|
||||
first_name="New",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"NEW-{uuid.uuid4().hex[:6].upper()}",
|
||||
store_id=store2.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_customer)
|
||||
db.commit()
|
||||
db.refresh(new_customer)
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/cards/enroll",
|
||||
json={"customer_id": new_customer.id},
|
||||
headers=cross_store_headers_store2,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["card_number"] is not None
|
||||
|
||||
def test_enroll_cross_location_disabled_allows_store2_card(
|
||||
self, client, cross_store_headers_store2, cross_store_setup, db
|
||||
):
|
||||
"""With cross-location disabled, enrolling the same email at store2
|
||||
creates a second card for that store."""
|
||||
# Disable cross-location
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, cross_store_setup["merchant"].id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/cards/enroll",
|
||||
json={"email": cross_store_setup["customer_email"]},
|
||||
headers=cross_store_headers_store2,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["card_number"] is not None
|
||||
# Different card from the original
|
||||
assert data["id"] != cross_store_setup["card"].id
|
||||
|
||||
@@ -153,6 +153,69 @@ class TestSearchCardForStore:
|
||||
result = self.service.search_card_for_store(db, test_store.id, "nobody@nowhere.com")
|
||||
assert result is None
|
||||
|
||||
def test_search_email_finds_cross_store_card(self, db, loyalty_store_setup):
|
||||
"""Email search at store2 finds a card enrolled at store1 when
|
||||
cross-location is enabled."""
|
||||
setup = loyalty_store_setup
|
||||
customer = setup["customer"]
|
||||
card = setup["card"]
|
||||
merchant = setup["merchant"]
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XSRCH_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"xsrch{uuid.uuid4().hex[:6]}",
|
||||
name="Cross Search Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
# Cross-location is enabled by default — should find the card
|
||||
result = self.service.search_card_for_store(
|
||||
db, store2.id, customer.email
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == card.id
|
||||
|
||||
def test_search_email_no_cross_store_when_disabled(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""Email search at store2 does NOT find cross-store cards when
|
||||
cross-location is disabled."""
|
||||
setup = loyalty_store_setup
|
||||
customer = setup["customer"]
|
||||
merchant = setup["merchant"]
|
||||
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, merchant.id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"NOSRCH_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"nosrch{uuid.uuid4().hex[:6]}",
|
||||
name="No Cross Search Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
result = self.service.search_card_for_store(
|
||||
db, store2.id, customer.email
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
@@ -405,3 +468,232 @@ class TestReactivateCardAudit:
|
||||
|
||||
card = self.service.reactivate_card(db, test_loyalty_card.id)
|
||||
assert card.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestGetCardByCustomerAndStore:
|
||||
"""Tests for the per-store card lookup."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CardService()
|
||||
|
||||
def test_finds_card_at_store(self, db, loyalty_store_setup):
|
||||
"""Returns card when customer has one at the given store."""
|
||||
setup = loyalty_store_setup
|
||||
result = self.service.get_card_by_customer_and_store(
|
||||
db, setup["customer"].id, setup["store"].id
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == setup["card"].id
|
||||
|
||||
def test_returns_none_at_different_store(self, db, loyalty_store_setup):
|
||||
"""Returns None when customer has no card at the given store."""
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
setup = loyalty_store_setup
|
||||
store2 = Store(
|
||||
merchant_id=setup["merchant"].id,
|
||||
store_code=f"NOCARD_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"nocard{uuid.uuid4().hex[:6]}",
|
||||
name="No Card Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
result = self.service.get_card_by_customer_and_store(
|
||||
db, setup["customer"].id, store2.id
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestCrossStoreEnrollment:
|
||||
"""
|
||||
Tests for cross-store enrollment with merchant_id-aware resolution.
|
||||
|
||||
The customer model is store-scoped, but loyalty cards are merchant-scoped.
|
||||
When a customer enrolls at store1 and then at store2 (same merchant),
|
||||
resolve_customer_id should find the existing customer from store1 via
|
||||
the cross-store loyalty card lookup.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CardService()
|
||||
|
||||
def test_resolve_finds_existing_cardholder_across_stores(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""Same email at a different store returns the original customer_id
|
||||
when merchant_id is provided and they already have a card."""
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"] # Already has a card at store1
|
||||
|
||||
# Create a second store under the same merchant
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"SECOND_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"second{uuid.uuid4().hex[:6]}",
|
||||
name="Second Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
db.refresh(store2)
|
||||
|
||||
# Resolve with the same email at store2 — should find
|
||||
# the existing customer from store1 via the loyalty card join
|
||||
result = self.service.resolve_customer_id(
|
||||
db,
|
||||
customer_id=None,
|
||||
email=customer.email,
|
||||
store_id=store2.id,
|
||||
merchant_id=merchant.id,
|
||||
create_if_missing=True,
|
||||
)
|
||||
|
||||
assert result == customer.id
|
||||
|
||||
def test_resolve_without_merchant_id_creates_new_customer(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""Without merchant_id, the cross-store lookup is skipped and
|
||||
a new customer is created at the new store."""
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"]
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"NOMID_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"nomid{uuid.uuid4().hex[:6]}",
|
||||
name="No Merchant ID Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
result = self.service.resolve_customer_id(
|
||||
db,
|
||||
customer_id=None,
|
||||
email=customer.email,
|
||||
store_id=store2.id,
|
||||
# No merchant_id — cross-store lookup skipped
|
||||
create_if_missing=True,
|
||||
customer_name="New Customer",
|
||||
)
|
||||
|
||||
assert result != customer.id # Different customer created
|
||||
|
||||
def test_enroll_cross_location_enabled_rejects_duplicate(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""With cross-location enabled (default), enrolling the same
|
||||
customer_id at a different store raises AlreadyExists."""
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyCardAlreadyExistsException,
|
||||
)
|
||||
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"] # Already has a card
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"DUP_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"dup{uuid.uuid4().hex[:6]}",
|
||||
name="Dup Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(LoyaltyCardAlreadyExistsException):
|
||||
self.service.enroll_customer_for_store(
|
||||
db, customer.id, store2.id
|
||||
)
|
||||
|
||||
def test_enroll_cross_location_disabled_allows_second_card(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""With cross-location disabled, the same customer can enroll
|
||||
at a different store and get a separate card."""
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"]
|
||||
|
||||
# Disable cross-location
|
||||
from app.modules.loyalty.services.program_service import (
|
||||
program_service,
|
||||
)
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, merchant.id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XLOC_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"xloc{uuid.uuid4().hex[:6]}",
|
||||
name="Cross-Loc Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
db.refresh(store2)
|
||||
|
||||
# Should succeed — different store, cross-location disabled
|
||||
card2 = self.service.enroll_customer_for_store(
|
||||
db, customer.id, store2.id
|
||||
)
|
||||
assert card2.enrolled_at_store_id == store2.id
|
||||
assert card2.merchant_id == merchant.id
|
||||
assert card2.customer_id == customer.id
|
||||
# Original card still exists
|
||||
assert setup["card"].id != card2.id
|
||||
|
||||
def test_enroll_cross_location_disabled_rejects_same_store(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""With cross-location disabled, re-enrolling at the SAME store
|
||||
still raises AlreadyExists."""
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyCardAlreadyExistsException,
|
||||
)
|
||||
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"]
|
||||
|
||||
from app.modules.loyalty.services.program_service import (
|
||||
program_service,
|
||||
)
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, merchant.id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(LoyaltyCardAlreadyExistsException):
|
||||
self.service.enroll_customer_for_store(
|
||||
db, customer.id, setup["store"].id
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user