Compare commits

...

2 Commits

Author SHA1 Message Date
f804ff8442 fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Fix duplicate card creation when the same email enrolls at different
stores under the same merchant, and implement cross-location-aware
enrollment behavior.

- Cross-location enabled (default): one card per customer per merchant.
  Re-enrolling at another store returns the existing card with a
  "works at all our locations" message + store list.
- Cross-location disabled: one card per customer per store. Enrolling
  at a different store creates a separate card for that store.

Changes:
- Migration loyalty_004: replace (merchant_id, customer_id) unique
  index with (enrolled_at_store_id, customer_id). Per-merchant
  uniqueness enforced at application layer when cross-location enabled.
- card_service.resolve_customer_id: cross-store email lookup via
  merchant_id param to find existing cardholders at other stores.
- card_service.enroll_customer: branch duplicate check on
  allow_cross_location_redemption setting.
- card_service.search_card_for_store: cross-store email search when
  cross-location enabled so staff at store2 can find cards from store1.
- card_service.get_card_by_customer_and_store: new service method.
- storefront enrollment: catch LoyaltyCardAlreadyExistsException,
  return existing card with already_enrolled flag, locations, and
  cross-location context. Server-rendered i18n via Jinja2 tojson.
- enroll-success.html: conditional cross-store/single-store messaging,
  server-rendered translations and context, i18n_modules block added.
- dashboard.html, history.html: replace $t() with server-side _() to
  fix i18n flicker across all storefront templates.
- Fix device-mobile icon → phone icon.
- 4 new i18n keys in 4 locales (en, fr, de, lb).
- Docs: updated data-model, business-logic, production-launch-plan,
  user-journeys with cross-location behavior and E2E test checklist.
- 12 new unit tests + 3 new integration tests (334 total pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:28:19 +02:00
d9abb275a5 feat(dev_tools): expand SQL query tool presets and fix column headers
Add 45 new preset queries covering all database tables, reorganize into
platform-aligned sections (Infrastructure, Core, OMS, Loyalty, Hosting,
Internal) with search/filter input. Fix column headers not appearing on
SELECT * queries by capturing result.keys() before fetchmany().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:28:57 +02:00
23 changed files with 1304 additions and 161 deletions

View File

@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
start = time.perf_counter() start = time.perf_counter()
result = connection.execute(text(sql)) result = connection.execute(text(sql))
columns = list(result.keys()) if result.returns_rows else []
rows_raw = result.fetchmany(max_rows + 1) rows_raw = result.fetchmany(max_rows + 1)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2) elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
columns = list(result.keys()) if result.returns_rows else []
truncated = len(rows_raw) > max_rows truncated = len(rows_raw) > max_rows
rows_raw = rows_raw[:max_rows] rows_raw = rows_raw[:max_rows]

View File

@@ -40,129 +40,272 @@ function sqlQueryTool() {
// Schema explorer // Schema explorer
showPresets: true, showPresets: true,
expandedCategories: {}, expandedCategories: {},
presetQueries: [ presetSearch: '',
// Preset sections — grouped by platform
presetSections: [
// ── Infrastructure ──
{ {
category: 'Schema', label: 'Infrastructure',
items: [ groups: [
{ 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;" }, category: 'Schema',
{ 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;" }, items: [
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" }, { 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', label: 'Core',
items: [ groups: [
{ 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;" }, category: 'Tenancy',
{ 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;" }, 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', label: 'OMS',
items: [ groups: [
{ 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;" }, category: 'Orders',
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" }, items: [
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" }, { 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: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\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', label: 'Loyalty',
items: [ groups: [
{ 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;" }, category: 'Loyalty',
{ 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;" }, items: [
{ 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: '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: '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;" }, { 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', label: 'Hosting',
items: [ groups: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" }, {
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', label: 'Internal',
items: [ groups: [
{ 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;" }, category: 'System',
{ 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;" }, items: [
{ 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: '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: 'Billing', {
items: [ category: 'Monitoring',
{ 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;" }, items: [
{ 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: '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;" },
{ 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: 'Dev Tools',
{ items: [
category: 'Orders', { 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;" },
items: [ { 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: '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: '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;" },
{ 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;" },
] ]
}, },
], ],
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) { toggleCategory(category) {
this.expandedCategories[category] = !this.expandedCategories[category]; this.expandedCategories[category] = !this.expandedCategories[category];
}, },
isCategoryExpanded(category) { isCategoryExpanded(category) {
if (this.presetSearch.trim()) return true;
return this.expandedCategories[category] || false; return this.expandedCategories[category] || false;
}, },

View File

@@ -24,24 +24,37 @@
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span> <span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
</button> </button>
<div x-show="showPresets" x-collapse class="mt-3"> <div x-show="showPresets" x-collapse class="mt-3">
<template x-for="group in presetQueries" :key="group.category"> <!-- Search filter -->
<div class="mb-1"> <div class="mb-2">
<button @click="toggleCategory(group.category)" <input type="text" x-model="presetSearch" placeholder="Filter presets..."
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"> 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">
<span x-text="group.category"></span> </div>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span> <template x-for="section in filteredPresetSections" :key="section.label">
</button> <div class="mb-2">
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5"> <div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
<template x-for="preset in group.items" :key="preset.name"> x-text="section.label"></div>
<li @click="loadPreset(preset)" <template x-for="group in section.groups" :key="group.category">
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"> <div class="mb-1">
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span> <button @click="toggleCategory(group.category)"
<span class="truncate" x-text="preset.name"></span> 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">
</li> <span x-text="group.category"></span>
</template> <span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</ul> </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> </div>
</template> </template>
<div x-show="presetSearch && filteredPresetSections.length === 0"
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
</div> </div>
</div> </div>

View File

@@ -214,14 +214,25 @@ Uses PKCS#7 signed `.pkpass` files and APNs push notifications.
## Cross-Store Redemption ## 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 - Customer can earn stamps at Store A and redeem at Store B
- Each transaction records which `store_id` it occurred at - Each transaction records which `store_id` it occurred at
- The `enrolled_at_store_id` field tracks where the customer first enrolled - 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 ## Enrollment Flow
@@ -229,21 +240,25 @@ When disabled, stamp/point operations are restricted to the enrollment store.
Staff enrolls customer via terminal: Staff enrolls customer via terminal:
1. Enter customer email (and optional name) 1. Enter customer email (and optional name)
2. System resolves or creates customer record 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. Creates loyalty card with unique card number and QR code 3. If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises `LoyaltyCardAlreadyExistsException`
4. Creates `CARD_CREATED` transaction 4. Otherwise creates loyalty card with unique card number and QR code
5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction 5. Creates `CARD_CREATED` transaction
6. Creates Google Wallet object and Apple Wallet serial 6. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
7. Returns card details with "Add to Wallet" URLs 7. Creates Google Wallet object and Apple Wallet serial
8. Returns card details with "Add to Wallet" URLs
### Self-Enrollment (Public) ### Self-Enrollment (Public)
Customer enrolls via public page (if `allow_self_enrollment` enabled): Customer enrolls via public page (if `allow_self_enrollment` enabled):
1. Customer visits `/loyalty/join` page 1. Customer visits `/loyalty/join` page
2. Enters email and name 2. Enters email, name, and optional birthday
3. System creates customer + card 3. System resolves customer (cross-store lookup for existing cardholders under the same merchant)
4. Redirected to success page with card number 4. If already enrolled: returns existing card with success page showing location info
5. Can add to Google/Apple Wallet from success page - 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 ## Scheduled Tasks

View File

@@ -120,14 +120,21 @@ Merchant-wide loyalty program configuration. One program per merchant, shared ac
### LoyaltyCard ### 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 | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `merchant_id` | FK | Links to program's merchant | | `merchant_id` | FK | Links to program's merchant |
| `customer_id` | FK | Card owner | | `customer_id` | FK | Card owner |
| `program_id` | FK | Associated program | | `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 | | `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX |
| `qr_code_data` | String (unique) | URL-safe token for QR codes | | `qr_code_data` | String (unique) | URL-safe token for QR codes |
| `stamp_count` | Integer | Current stamp count | | `stamp_count` | Integer | Current stamp count |

View File

@@ -40,11 +40,14 @@ This is the active execution plan for taking the Loyalty module to production. I
``` ```
loyalty_002 (existing) loyalty_002 (existing)
loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.) loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.)
loyalty_004 — seed 28 notification email templates loyalty_004 — relax card uniqueness: replace (merchant_id, customer_id) unique index
loyalty_005 — add columns: last_expiration_warning_at, last_reengagement_at on cards; 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 acting_admin_id on transactions
loyalty_006 — terms_cms_page_slug on programs loyalty_007 — terms_cms_page_slug on programs
loyalty_007 — birth_date on customers (P0 — Phase 1.4 fix, dropped data bug)
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) #### 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. **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 `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 `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. - 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)`. - 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. - 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`. - 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`. - 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. - **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()`. - In `card_service.enroll_customer_for_store` (~lines 480-540), call notification service **after** `db.commit()`.
#### 2.5 Wire expiration warning into expiration task #### 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. - 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. - **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 #### 2.8 Re-engagement Celery beat task
- Weekly schedule. Finds cards inactive > N days (default 60, configurable). - 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)* ### Phase 4 — Accessibility & T&C *(2d)*
#### 4.1 T&C via store CMS integration #### 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`). - Update `schemas/program.py` (`ProgramCreate`, `ProgramUpdate`).
- `program-form.html:251` — CMS page picker scoped to the program's owning store. - `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. - `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`): - **Admin "act on behalf"** (`routes/api/admin.py`):
- `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.) - `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.)
- Shared service layer; route stamps `acting_admin_id` in audit log - 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 #### 6.5 Manual override: restore expired points
- `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`. - `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`.

View File

@@ -792,3 +792,109 @@ flowchart TD
There is no feature gating on loyalty program creation — you can test them in 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 either order. Journey 0 is listed second because domain setup is about URL
presentation, not a functional prerequisite for the loyalty module. 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

View File

@@ -135,6 +135,10 @@
"view_dashboard": "Mein Treue-Dashboard anzeigen", "view_dashboard": "Mein Treue-Dashboard anzeigen",
"continue_shopping": "Weiter einkaufen" "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": { "errors": {
"load_failed": "Programminformationen konnten nicht geladen werden", "load_failed": "Programminformationen konnten nicht geladen werden",
"email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.", "email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.",

View File

@@ -135,6 +135,10 @@
"view_dashboard": "View My Loyalty Dashboard", "view_dashboard": "View My Loyalty Dashboard",
"continue_shopping": "Continue Shopping" "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": { "errors": {
"load_failed": "Failed to load program information", "load_failed": "Failed to load program information",
"email_exists": "This email is already registered in our loyalty program.", "email_exists": "This email is already registered in our loyalty program.",

View File

@@ -135,6 +135,10 @@
"view_dashboard": "Voir mon tableau de bord fidélité", "view_dashboard": "Voir mon tableau de bord fidélité",
"continue_shopping": "Continuer mes achats" "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": { "errors": {
"load_failed": "Impossible de charger les informations du programme", "load_failed": "Impossible de charger les informations du programme",
"email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.", "email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.",

View File

@@ -135,6 +135,10 @@
"view_dashboard": "Mäin Treie-Dashboard kucken", "view_dashboard": "Mäin Treie-Dashboard kucken",
"continue_shopping": "Weider akafen" "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": { "errors": {
"load_failed": "Programminformatiounen konnten net gelueden ginn", "load_failed": "Programminformatiounen konnten net gelueden ginn",
"email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.", "email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.",

View File

@@ -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,
)

View File

@@ -255,11 +255,14 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
cascade="all, delete-orphan", 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__ = ( __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_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 # Balances must never go negative — guards against direct SQL writes
# bypassing the service layer's clamping logic. # bypassing the service layer's clamping logic.
CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"), CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"),

View File

@@ -540,11 +540,18 @@ def enroll_customer(
""" """
store_id = current_user.token_store_id 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( customer_id = card_service.resolve_customer_id(
db, db,
customer_id=data.customer_id, customer_id=data.customer_id,
email=data.email, email=data.email,
store_id=store_id, store_id=store_id,
merchant_id=merchant_id,
) )
card = card_service.enroll_customer_for_store(db, customer_id, store_id) card = card_service.enroll_customer_for_store(db, customer_id, store_id)

View File

@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api from app.api.deps import get_current_customer_api
from app.core.database import get_db from app.core.database import get_db
from app.modules.customers.schemas import CustomerContext from app.modules.customers.schemas import CustomerContext
from app.modules.loyalty.exceptions import LoyaltyCardAlreadyExistsException
from app.modules.loyalty.schemas import ( from app.modules.loyalty.schemas import (
CardEnrollRequest, CardEnrollRequest,
CardResponse, CardResponse,
@@ -88,6 +89,7 @@ def self_enroll(
customer_id=data.customer_id, customer_id=data.customer_id,
email=data.email, email=data.email,
store_id=store.id, store_id=store.id,
merchant_id=store.merchant_id,
create_if_missing=True, create_if_missing=True,
customer_name=data.customer_name, customer_name=data.customer_name,
customer_phone=data.customer_phone, 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}") 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 program = card.program
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card) 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 { return {
"card": CardResponse( "card": CardResponse(
id=card.id, id=card.id,
@@ -122,6 +159,10 @@ def self_enroll(
has_apple_wallet=bool(card.apple_serial_number), has_apple_wallet=bool(card.apple_serial_number),
), ),
"wallet_urls": wallet_urls, "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,
} }

View File

@@ -133,6 +133,7 @@ async def loyalty_self_enrollment(
async def loyalty_enrollment_success( async def loyalty_enrollment_success(
request: Request, request: Request,
card: str = Query(None, description="Card number"), card: str = Query(None, description="Card number"),
already: str = Query(None, description="Already enrolled flag"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -149,6 +150,27 @@ async def loyalty_enrollment_success(
context = get_storefront_context(request, db=db) context = get_storefront_context(request, db=db)
context["enrolled_card_number"] = card 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( return templates.TemplateResponse(
"loyalty/storefront/enroll-success.html", "loyalty/storefront/enroll-success.html",
context, context,

View File

@@ -119,6 +119,23 @@ class CardService:
.first() .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( def get_card_by_customer_and_program(
self, self,
db: Session, db: Session,
@@ -166,6 +183,7 @@ class CardService:
customer_id: int | None, customer_id: int | None,
email: str | None, email: str | None,
store_id: int, store_id: int,
merchant_id: int | None = None,
create_if_missing: bool = False, create_if_missing: bool = False,
customer_name: str | None = None, customer_name: str | None = None,
customer_phone: str | None = None, customer_phone: str | None = None,
@@ -179,6 +197,7 @@ class CardService:
customer_id: Direct customer ID (used if provided) customer_id: Direct customer ID (used if provided)
email: Customer email to look up email: Customer email to look up
store_id: Store ID for scoping the email lookup 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 create_if_missing: If True, create customer when email not found
(used for self-enrollment) (used for self-enrollment)
customer_name: Full name for customer creation customer_name: Full name for customer creation
@@ -196,6 +215,9 @@ class CardService:
return customer_id return customer_id
if email: if email:
from app.modules.customers.models.customer import (
Customer as CustomerModel,
)
from app.modules.customers.services.customer_service import ( from app.modules.customers.services.customer_service import (
customer_service, customer_service,
) )
@@ -210,6 +232,29 @@ class CardService:
db.flush() db.flush()
return customer.id 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: if create_if_missing:
# Parse name into first/last # Parse name into first/last
first_name = customer_name or "" first_name = customer_name or ""
@@ -347,18 +392,45 @@ class CardService:
merchant_id = store.merchant_id merchant_id = store.merchant_id
# Try card number # Try card number — always merchant-scoped
card = self.get_card_by_number(db, query) card = self.get_card_by_number(db, query)
if card and card.merchant_id == merchant_id: if card and card.merchant_id == merchant_id:
return card return card
# Try customer email # Try customer email — first at this store
customer = customer_service.get_customer_by_email(db, store_id, query) customer = customer_service.get_customer_by_email(db, store_id, query)
if customer: if customer:
card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id) card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id)
if card: if card:
return 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 return None
def list_cards( def list_cards(
@@ -479,10 +551,32 @@ class CardService:
if not program.is_active: if not program.is_active:
raise LoyaltyProgramInactiveException(program.id) raise LoyaltyProgramInactiveException(program.id)
# Check if customer already has a card # Check for duplicate enrollment — the scope depends on whether
existing = self.get_card_by_customer_and_merchant(db, customer_id, merchant_id) # cross-location redemption is enabled for this merchant.
if existing: from app.modules.loyalty.services.program_service import program_service
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
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 # Create the card
card = LoyaltyCard( card = LoyaltyCard(

View File

@@ -81,10 +81,21 @@ function customerLoyaltyEnroll() {
sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls)); 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 currentPath = window.location.pathname;
const alreadyFlag = response.already_enrolled ? '&already=1' : '';
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') + const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
'?card=' + encodeURIComponent(cardNumber); '?card=' + encodeURIComponent(cardNumber) + alreadyFlag;
window.location.href = successUrl; window.location.href = successUrl;
} }
} catch (error) { } catch (error) {

View File

@@ -105,12 +105,12 @@
<template x-if="(card?.points_balance || 0) >= reward.points_required"> <template x-if="(card?.points_balance || 0) >= reward.points_required">
<span class="inline-flex items-center text-sm font-medium text-green-600"> <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-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> </span>
</template> </template>
<template x-if="(card?.points_balance || 0) < reward.points_required"> <template x-if="(card?.points_balance || 0) < reward.points_required">
<span class="text-sm text-gray-500" <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> </span>
</template> </template>
</div> </div>
@@ -208,14 +208,14 @@
<template x-if="walletUrls.apple_wallet_url"> <template x-if="walletUrls.apple_wallet_url">
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener" <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"> 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') }} {{ _('loyalty.loyalty.wallet.apple') }}
</a> </a>
</template> </template>
<template x-if="walletUrls.google_wallet_url"> <template x-if="walletUrls.google_wallet_url">
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener" <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"> 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') }} {{ _('loyalty.loyalty.wallet.google') }}
</a> </a>
</template> </template>

View File

@@ -3,6 +3,7 @@
{% block title %}{{ _('loyalty.enrollment.success.title') }} - {{ store.name }}{% endblock %} {% block title %}{{ _('loyalty.enrollment.success.title') }} - {{ store.name }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %} {% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %}
{% block content %} {% block content %}
@@ -16,7 +17,10 @@
</div> </div>
</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> <p class="text-gray-600 dark:text-gray-400 mb-8">{{ _('loyalty.enrollment.success.message') }}</p>
<!-- Card Number Display --> <!-- Card Number Display -->
@@ -33,14 +37,14 @@
<template x-if="walletUrls.apple_wallet_url"> <template x-if="walletUrls.apple_wallet_url">
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener" <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"> 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') }} {{ _('loyalty.loyalty.wallet.apple') }}
</a> </a>
</template> </template>
<template x-if="walletUrls.google_wallet_url"> <template x-if="walletUrls.google_wallet_url">
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener" <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"> 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') }} {{ _('loyalty.loyalty.wallet.google') }}
</a> </a>
</template> </template>
@@ -48,6 +52,41 @@
</div> </div>
</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 --> <!-- Next Steps -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8"> <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> <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 { return {
...storefrontLayoutData(), ...storefrontLayoutData(),
walletUrls: { google_wallet_url: null, apple_wallet_url: null }, 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() { init() {
// Read wallet URLs saved during enrollment (no auth needed) // Read wallet URLs saved during enrollment (no auth needed)
@@ -101,6 +154,20 @@ function customerLoyaltyEnrollSuccess() {
} catch (e) { } catch (e) {
console.log('Could not load wallet URLs:', e.message); 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);
}
} }
}; };
} }

View File

@@ -93,7 +93,7 @@
{{ _('loyalty.storefront.history.previous') }} {{ _('loyalty.storefront.history.previous') }}
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400" <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> </span>
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages" <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"> 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">

View File

@@ -830,3 +830,248 @@ class TestAdjustPointsRoleGate:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True 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

View File

@@ -153,6 +153,69 @@ class TestSearchCardForStore:
result = self.service.search_card_for_store(db, test_store.id, "nobody@nowhere.com") result = self.service.search_card_for_store(db, test_store.id, "nobody@nowhere.com")
assert result is None 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.unit
@pytest.mark.loyalty @pytest.mark.loyalty
@@ -405,3 +468,232 @@ class TestReactivateCardAudit:
card = self.service.reactivate_card(db, test_loyalty_card.id) card = self.service.reactivate_card(db, test_loyalty_card.id)
assert card.is_active is True 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
)