Admin Panel (Next.js): - Dashboard with stats - Users list with relationships (watches/watched_by) - User detail pages - Deployments list and detail pages - Devices, Orders, Subscriptions pages - OTP-based admin authentication Backend Optimizations: - Fixed N+1 query problem in admin APIs - Added pagination support - Added .range() and count support to Supabase wrapper - Optimized batch queries with lookup maps Database: - Added migrations for schema evolution - New tables: push_tokens, notification_settings - Updated access model iOS Build Scripts: - build-ios.sh, clear-apple-cache.sh - EAS configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
393 lines
9.5 KiB
JavaScript
393 lines
9.5 KiB
JavaScript
/**
|
|
* Supabase-like wrapper for direct PostgreSQL
|
|
* Provides similar API to @supabase/supabase-js but uses pg pool directly
|
|
*/
|
|
const { pool } = require('./database');
|
|
|
|
class QueryBuilder {
|
|
constructor(tableName) {
|
|
this.tableName = tableName;
|
|
this._select = '*';
|
|
this._where = [];
|
|
this._whereParams = [];
|
|
this._orderBy = null;
|
|
this._limit = null;
|
|
this._offset = null;
|
|
this._single = false;
|
|
this._returning = true;
|
|
this._count = false;
|
|
}
|
|
|
|
select(columns, options = {}) {
|
|
if (typeof columns === 'string') {
|
|
this._select = columns || '*';
|
|
} else {
|
|
this._select = '*';
|
|
}
|
|
if (options && options.count === 'exact') {
|
|
this._count = true;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
eq(column, value) {
|
|
this._where.push(`"${column}" = $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
neq(column, value) {
|
|
this._where.push(`"${column}" != $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
gt(column, value) {
|
|
this._where.push(`"${column}" > $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
gte(column, value) {
|
|
this._where.push(`"${column}" >= $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
lt(column, value) {
|
|
this._where.push(`"${column}" < $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
lte(column, value) {
|
|
this._where.push(`"${column}" <= $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
is(column, value) {
|
|
if (value === null) {
|
|
this._where.push(`"${column}" IS NULL`);
|
|
} else {
|
|
this._where.push(`"${column}" IS $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
in(column, values) {
|
|
const placeholders = values.map((_, i) => `$${this._whereParams.length + i + 1}`).join(', ');
|
|
this._where.push(`"${column}" IN (${placeholders})`);
|
|
this._whereParams.push(...values);
|
|
return this;
|
|
}
|
|
|
|
order(column, options = {}) {
|
|
const direction = options.ascending === false ? 'DESC' : 'ASC';
|
|
this._orderBy = `"${column}" ${direction}`;
|
|
return this;
|
|
}
|
|
|
|
limit(count) {
|
|
this._limit = count;
|
|
return this;
|
|
}
|
|
|
|
range(from, to) {
|
|
this._offset = from;
|
|
this._limit = to - from + 1;
|
|
return this;
|
|
}
|
|
|
|
single() {
|
|
this._single = true;
|
|
this._limit = 1;
|
|
return this;
|
|
}
|
|
|
|
async _buildSelectQuery() {
|
|
let query = `SELECT ${this._select} FROM "${this.tableName}"`;
|
|
|
|
if (this._where.length > 0) {
|
|
query += ` WHERE ${this._where.join(' AND ')}`;
|
|
}
|
|
|
|
if (this._orderBy) {
|
|
query += ` ORDER BY ${this._orderBy}`;
|
|
}
|
|
|
|
if (this._limit) {
|
|
query += ` LIMIT ${this._limit}`;
|
|
}
|
|
|
|
if (this._offset) {
|
|
query += ` OFFSET ${this._offset}`;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
async _buildCountQuery() {
|
|
let query = `SELECT COUNT(*) as count FROM "${this.tableName}"`;
|
|
|
|
if (this._where.length > 0) {
|
|
query += ` WHERE ${this._where.join(' AND ')}`;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
async then(resolve, reject) {
|
|
try {
|
|
const query = await this._buildSelectQuery();
|
|
const result = await pool.query(query, this._whereParams);
|
|
|
|
let count = null;
|
|
if (this._count) {
|
|
const countQuery = await this._buildCountQuery();
|
|
const countResult = await pool.query(countQuery, this._whereParams);
|
|
count = parseInt(countResult.rows[0]?.count || 0);
|
|
}
|
|
|
|
if (this._single) {
|
|
if (result.rows.length === 0) {
|
|
resolve({ data: null, error: { code: 'PGRST116', message: 'No rows returned' }, count });
|
|
} else {
|
|
resolve({ data: result.rows[0], error: null, count });
|
|
}
|
|
} else {
|
|
resolve({ data: result.rows, error: null, count });
|
|
}
|
|
} catch (error) {
|
|
resolve({ data: null, error: { code: error.code, message: error.message, details: error.detail, hint: error.hint }, count: null });
|
|
}
|
|
}
|
|
}
|
|
|
|
class InsertBuilder {
|
|
constructor(tableName, data) {
|
|
this.tableName = tableName;
|
|
this.data = Array.isArray(data) ? data : [data];
|
|
this._select = null;
|
|
this._single = false;
|
|
}
|
|
|
|
select(columns) {
|
|
this._select = columns || '*';
|
|
return this;
|
|
}
|
|
|
|
single() {
|
|
this._single = true;
|
|
return this;
|
|
}
|
|
|
|
async then(resolve, reject) {
|
|
try {
|
|
const record = this.data[0];
|
|
const columns = Object.keys(record);
|
|
const values = Object.values(record);
|
|
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
|
|
|
let query = `INSERT INTO "${this.tableName}" ("${columns.join('", "')}") VALUES (${placeholders})`;
|
|
|
|
if (this._select) {
|
|
query += ` RETURNING ${this._select}`;
|
|
} else {
|
|
query += ' RETURNING *';
|
|
}
|
|
|
|
const result = await pool.query(query, values);
|
|
|
|
if (this._single) {
|
|
resolve({ data: result.rows[0] || null, error: null });
|
|
} else {
|
|
resolve({ data: result.rows, error: null });
|
|
}
|
|
} catch (error) {
|
|
resolve({ data: null, error: { code: error.code, message: error.message, details: error.detail, hint: error.hint } });
|
|
}
|
|
}
|
|
}
|
|
|
|
class UpdateBuilder {
|
|
constructor(tableName, data) {
|
|
this.tableName = tableName;
|
|
this.data = data;
|
|
this._where = [];
|
|
this._whereParams = [];
|
|
this._select = null;
|
|
this._single = false;
|
|
}
|
|
|
|
eq(column, value) {
|
|
this._where.push({ column, value });
|
|
return this;
|
|
}
|
|
|
|
select(columns) {
|
|
this._select = columns || '*';
|
|
return this;
|
|
}
|
|
|
|
single() {
|
|
this._single = true;
|
|
return this;
|
|
}
|
|
|
|
async then(resolve, reject) {
|
|
try {
|
|
const columns = Object.keys(this.data);
|
|
const values = Object.values(this.data);
|
|
|
|
const setClause = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', ');
|
|
|
|
let paramIndex = columns.length + 1;
|
|
const whereClause = this._where.map(w => {
|
|
values.push(w.value);
|
|
return `"${w.column}" = $${paramIndex++}`;
|
|
}).join(' AND ');
|
|
|
|
let query = `UPDATE "${this.tableName}" SET ${setClause}`;
|
|
if (whereClause) {
|
|
query += ` WHERE ${whereClause}`;
|
|
}
|
|
|
|
if (this._select) {
|
|
query += ` RETURNING ${this._select}`;
|
|
} else {
|
|
query += ' RETURNING *';
|
|
}
|
|
|
|
const result = await pool.query(query, values);
|
|
|
|
if (this._single) {
|
|
resolve({ data: result.rows[0] || null, error: null });
|
|
} else {
|
|
resolve({ data: result.rows, error: null });
|
|
}
|
|
} catch (error) {
|
|
resolve({ data: null, error: { code: error.code, message: error.message, details: error.detail, hint: error.hint } });
|
|
}
|
|
}
|
|
}
|
|
|
|
class UpsertBuilder {
|
|
constructor(tableName, data, options = {}) {
|
|
this.tableName = tableName;
|
|
this.data = Array.isArray(data) ? data : [data];
|
|
this.onConflict = options.onConflict;
|
|
this._select = null;
|
|
this._single = false;
|
|
}
|
|
|
|
select(columns) {
|
|
this._select = columns || '*';
|
|
return this;
|
|
}
|
|
|
|
single() {
|
|
this._single = true;
|
|
return this;
|
|
}
|
|
|
|
async then(resolve, reject) {
|
|
try {
|
|
const record = this.data[0];
|
|
const columns = Object.keys(record);
|
|
const values = Object.values(record);
|
|
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
|
|
|
const updateClause = columns
|
|
.filter(col => col !== this.onConflict)
|
|
.map(col => `"${col}" = EXCLUDED."${col}"`)
|
|
.join(', ');
|
|
|
|
let query = `INSERT INTO "${this.tableName}" ("${columns.join('", "')}") VALUES (${placeholders})`;
|
|
query += ` ON CONFLICT ("${this.onConflict}") DO UPDATE SET ${updateClause}`;
|
|
query += ' RETURNING *';
|
|
|
|
const result = await pool.query(query, values);
|
|
|
|
if (this._single) {
|
|
resolve({ data: result.rows[0] || null, error: null });
|
|
} else {
|
|
resolve({ data: result.rows, error: null });
|
|
}
|
|
} catch (error) {
|
|
resolve({ data: null, error: { code: error.code, message: error.message, details: error.detail, hint: error.hint } });
|
|
}
|
|
}
|
|
}
|
|
|
|
class DeleteBuilder {
|
|
constructor(tableName) {
|
|
this.tableName = tableName;
|
|
this._where = [];
|
|
this._whereParams = [];
|
|
}
|
|
|
|
eq(column, value) {
|
|
this._where.push(`"${column}" = $${this._whereParams.length + 1}`);
|
|
this._whereParams.push(value);
|
|
return this;
|
|
}
|
|
|
|
is(column, value) {
|
|
if (value === null) {
|
|
this._where.push(`"${column}" IS NULL`);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
async then(resolve, reject) {
|
|
try {
|
|
let query = `DELETE FROM "${this.tableName}"`;
|
|
|
|
if (this._where.length > 0) {
|
|
query += ` WHERE ${this._where.join(' AND ')}`;
|
|
}
|
|
|
|
const result = await pool.query(query, this._whereParams);
|
|
resolve({ data: null, error: null, count: result.rowCount });
|
|
} catch (error) {
|
|
resolve({ data: null, error: { code: error.code, message: error.message, details: error.detail, hint: error.hint } });
|
|
}
|
|
}
|
|
}
|
|
|
|
class TableClient {
|
|
constructor(tableName) {
|
|
this.tableName = tableName;
|
|
}
|
|
|
|
select(columns) {
|
|
const builder = new QueryBuilder(this.tableName);
|
|
return builder.select(columns);
|
|
}
|
|
|
|
insert(data) {
|
|
return new InsertBuilder(this.tableName, data);
|
|
}
|
|
|
|
update(data) {
|
|
return new UpdateBuilder(this.tableName, data);
|
|
}
|
|
|
|
upsert(data, options) {
|
|
return new UpsertBuilder(this.tableName, data, options);
|
|
}
|
|
|
|
delete() {
|
|
return new DeleteBuilder(this.tableName);
|
|
}
|
|
}
|
|
|
|
// Supabase-compatible client
|
|
const supabase = {
|
|
from: (tableName) => new TableClient(tableName)
|
|
};
|
|
|
|
module.exports = { supabase };
|