Files
orion/app/modules/dev_tools/static/admin/js/sql-query.js
Samir Boulahtit 319900623a
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
- Add admin SQL query tool with saved queries, schema explorer presets,
  and collapsible category sections (dev_tools module)
- Add platform debug tool for admin diagnostics
- Add loyalty settings page with owner-only access control
- Fix loyalty settings owner check (use currentUser instead of window.__userData)
- Replace HTTPException with AuthorizationException in loyalty routes
- Expand loyalty module with PIN service, Apple Wallet, program management
- Improve store login with platform detection and multi-platform support
- Update billing feature gates and subscription services
- Add store platform sync improvements and remove is_primary column
- Add unit tests for loyalty (PIN, points, stamps, program services)
- Update i18n translations across dev_tools locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:08:07 +01:00

250 lines
11 KiB
JavaScript

// app/modules/dev_tools/static/admin/js/sql-query.js
const sqlLog = window.LogConfig.createLogger('SQL_QUERY');
/**
* SQL Query Tool Alpine.js Component
* Execute ad-hoc SQL queries and manage saved queries.
*/
function sqlQueryTool() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'sql-query',
// Editor state
sql: '',
running: false,
error: null,
// Results
columns: [],
rows: [],
rowCount: 0,
truncated: false,
executionTimeMs: null,
// Saved queries
savedQueries: [],
loadingSaved: false,
activeSavedId: null,
// Save modal
showSaveModal: false,
saveName: '',
saveDescription: '',
saving: false,
// Schema explorer
showPresets: true,
expandedCategories: {},
presetQueries: [
{
category: 'Schema',
items: [
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
]
},
{
category: 'Statistics',
items: [
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
]
},
{
category: 'Tenancy',
items: [
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
]
},
{
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: 'System',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
]
},
],
toggleCategory(category) {
this.expandedCategories[category] = !this.expandedCategories[category];
},
isCategoryExpanded(category) {
return this.expandedCategories[category] || false;
},
async init() {
if (window._sqlQueryInitialized) return;
window._sqlQueryInitialized = true;
this.$nextTick(() => {
if (typeof this.initBase === 'function') this.initBase();
});
try {
await this.loadSavedQueries();
} catch (e) {
sqlLog.error('Failed to initialize:', e);
}
// Ctrl+Enter shortcut
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.executeQuery();
}
});
},
async executeQuery() {
if (!this.sql.trim() || this.running) return;
this.running = true;
this.error = null;
this.columns = [];
this.rows = [];
this.rowCount = 0;
this.truncated = false;
this.executionTimeMs = null;
try {
const payload = { sql: this.sql };
if (this.activeSavedId) {
payload.saved_query_id = this.activeSavedId;
}
const data = await apiClient.post('/admin/sql-query/execute', payload);
this.columns = data.columns;
this.rows = data.rows;
this.rowCount = data.row_count;
this.truncated = data.truncated;
this.executionTimeMs = data.execution_time_ms;
// Refresh saved queries to update run_count
if (this.activeSavedId) {
await this.loadSavedQueries();
}
} catch (e) {
this.error = e.message;
} finally {
this.running = false;
}
},
async loadSavedQueries() {
this.loadingSaved = true;
try {
this.savedQueries = await apiClient.get('/admin/sql-query/saved');
} catch (e) {
sqlLog.error('Failed to load saved queries:', e);
} finally {
this.loadingSaved = false;
}
},
loadQuery(q) {
this.sql = q.sql_text;
this.activeSavedId = q.id;
this.error = null;
},
loadPreset(preset) {
this.sql = preset.sql;
this.activeSavedId = null;
this.error = null;
},
openSaveModal() {
this.saveName = '';
this.saveDescription = '';
this.showSaveModal = true;
},
async saveQuery() {
if (!this.saveName.trim() || !this.sql.trim()) return;
this.saving = true;
try {
const saved = await apiClient.post('/admin/sql-query/saved', {
name: this.saveName,
sql_text: this.sql,
description: this.saveDescription || null,
});
this.activeSavedId = saved.id;
this.showSaveModal = false;
await this.loadSavedQueries();
} catch (e) {
this.error = e.message;
} finally {
this.saving = false;
}
},
async deleteSavedQuery(id) {
if (!confirm('Delete this saved query?')) return;
try {
await apiClient.delete(`/admin/sql-query/saved/${id}`);
if (this.activeSavedId === id) {
this.activeSavedId = null;
}
await this.loadSavedQueries();
} catch (e) {
sqlLog.error('Failed to delete:', e);
}
},
exportCsv() {
if (!this.columns.length || !this.rows.length) return;
const escape = (val) => {
if (val === null || val === undefined) return '';
const s = String(val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const lines = [this.columns.map(escape).join(',')];
for (const row of this.rows) {
lines.push(row.map(escape).join(','));
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'query-results.csv';
a.click();
URL.revokeObjectURL(url);
},
formatCell(val) {
if (val === null || val === undefined) return 'NULL';
return String(val);
},
isNull(val) {
return val === null || val === undefined;
},
};
}