Working Querry Engine and UserRoute

This commit is contained in:
Chandresh Kerkar 2025-12-21 00:42:13 +05:30
parent b3899dc14d
commit 35872ccee2
14 changed files with 3495 additions and 807 deletions

View File

@ -1,20 +0,0 @@
import pg from "pg";
import "dotenv/config";
const { Pool } = pg;
const baseConfig = {};
baseConfig.host = process.env.PGHOST || "127.0.0.1";
baseConfig.port = Number(process.env.PGPORT || 5432);
baseConfig.user = process.env.PGUSER || "postgres";
baseConfig.password = process.env.PGPASSWORD || "postgres";
baseConfig.database = process.env.PGDATABASE || "postgres";
const pool = new Pool(baseConfig);
pool.on("error", (err) => {
console.error("Unexpected Postgres client error", err);
});
export default pool;

View File

@ -0,0 +1,194 @@
# Query Helper API Reference
This document provides a complete reference for the JSON-based database query helper.
## Method Overview
| Method | Description | Notes |
|--------|-------------|-------|
| `select(options)` | Retrieve records from database | JSON-based query builder |
| `insert(options)` | Insert new record(s) | Single or batch insert |
| `update(options)` | Update existing records | Requires WHERE clause |
| `deleteRecord(options)` | Delete records | Requires WHERE clause |
| `execute(options)` | Execute transactions or custom queries | Transactions or raw builder |
## Detailed Method Reference
### SELECT
Retrieve records from database with filtering, sorting, and pagination.
```javascript
const users = await select({
table: 'users',
where: { deleted: false, status: 'active' },
orderBy: { column: 'created_at', direction: 'desc' },
limit: 20
});
```
**Options:**
- `table` (string, required): Table name (must be in whitelist)
- `columns` (string[] | '*', optional): Columns to select (default: '*')
- `where` (object, optional): WHERE conditions
- `orderBy` (object, optional): `{ column: string, direction: 'asc'|'desc' }`
- `limit` (number, optional): Max records (capped at 100)
- `offset` (number, optional): Skip records
- `joins` (array, optional): Join configurations
### INSERT
Insert new record(s) into database.
```javascript
const user = await insert({
table: 'users',
data: { name: 'John', phone: '+1234567890' },
returning: ['id', 'name']
});
```
**Options:**
- `table` (string, required): Table name
- `data` (object | object[], required): Data to insert (single object or array for batch)
- `returning` (string[] | '*', optional): Columns to return (PostgreSQL)
### UPDATE
Update existing records in database.
```javascript
const updated = await update({
table: 'users',
data: { name: 'Jane' },
where: { id: userId }
});
```
**Options:**
- `table` (string, required): Table name
- `data` (object, required): Data to update
- `where` (object, required): WHERE conditions (required for safety)
- `returning` (string[] | '*', optional): Columns to return
### DELETE
Delete records from database.
```javascript
await deleteRecord({
table: 'users',
where: { id: userId }
});
```
**Options:**
- `table` (string, required): Table name
- `where` (object, required): WHERE conditions (required for safety)
- `returning` (string[] | '*', optional): Columns to return
### EXECUTE (Transaction)
Execute transactions or custom query builder logic.
```javascript
await execute({
type: 'transaction',
handler: async (trx) => {
await trx('animals').insert(animalData);
await trx('listings').insert(listingData);
}
});
```
**Options:**
- `type` (string, required): 'transaction' or 'raw-builder'
- `handler` (function, required): Handler function receiving knex/trx instance
## WHERE Clause Operators
| Operator | Description | Example |
|----------|-------------|---------|
| Equality | Simple key-value match | `where: { status: 'active' }` |
| `>` | Greater than | `where: { price: { op: '>', value: 100 } }` |
| `<` | Less than | `where: { age: { op: '<', value: 18 } }` |
| `>=` | Greater than or equal | `where: { price: { op: '>=', value: 100 } }` |
| `<=` | Less than or equal | `where: { age: { op: '<=', value: 65 } }` |
| `!=` or `<>` | Not equal | `where: { status: { op: '!=', value: 'deleted' } }` |
| `in` | In array | `where: { id: { op: 'in', value: [1, 2, 3] } }` |
| `notIn` | Not in array | `where: { id: { op: 'notIn', value: [1, 2, 3] } }` |
| `like` | Case-sensitive LIKE | `where: { name: { op: 'like', value: '%John%' } }` |
| `ilike` | Case-insensitive LIKE | `where: { name: { op: 'ilike', value: '%john%' } }` |
| `between` | Between two values | `where: { age: { op: 'between', value: [18, 65] } }` |
| `isNull` | IS NULL | `where: { deleted_at: { op: 'isNull' } }` |
| `isNotNull` | IS NOT NULL | `where: { deleted_at: { op: 'isNotNull' } }` |
## Supported Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `>` | Greater than | `{ op: '>', value: 100 }` |
| `<` | Less than | `{ op: '<', value: 100 }` |
| `>=` | Greater than or equal | `{ op: '>=', value: 100 }` |
| `<=` | Less than or equal | `{ op: '<=', value: 100 }` |
| `!=` or `<>` | Not equal | `{ op: '!=', value: 'deleted' }` |
| `in` | In array | `{ op: 'in', value: [1, 2, 3] }` |
| `notIn` | Not in array | `{ op: 'notIn', value: [1, 2, 3] }` |
| `like` | Case-sensitive LIKE | `{ op: 'like', value: '%test%' }` |
| `ilike` | Case-insensitive LIKE | `{ op: 'ilike', value: '%test%' }` |
| `between` | Between two values | `{ op: 'between', value: [10, 20] }` |
| `isNull` | IS NULL | `{ op: 'isNull' }` |
| `isNotNull` | IS NOT NULL | `{ op: 'isNotNull' }` |
## Naming Conventions
- **Table names**: lowercase, snake_case (e.g., `users`, `listing_media`)
- **Column names**: lowercase, snake_case (e.g., `created_at`, `user_id`)
- **Options**: camelCase (e.g., `orderBy`, `returning`)
- **Operators**: lowercase (e.g., `op: '>'`)
## Type System
- **JavaScript**: JSON objects with runtime validation
- **Runtime Validation**: Descriptive errors for invalid inputs
- **Type Safety**: Structured JSON prevents SQL injection
## Error Handling
- **Runtime Validation**: All inputs validated at runtime
- **Descriptive Errors**: Clear error messages for debugging
- **Database Errors**: Propagated with context
## Transactions
```javascript
await execute({
type: 'transaction',
handler: async (trx) => {
await trx('users').insert(userData);
await trx('listings').insert(listingData);
}
});
```
## Migration Checklist
When migrating from raw SQL to queryHelper:
- [ ] Replace `pool.query()` with `select()`, `insert()`, `update()`, or `deleteRecord()`
- [ ] Convert SQL WHERE clauses to JSON objects
- [ ] Replace string concatenation with structured options
- [ ] Convert transactions to `execute({ type: 'transaction' })`
- [ ] Update JOINs to use `joins` array
- [ ] Replace parameterized queries with JSON where conditions
- [ ] Test all queries to ensure same results
- [ ] Remove all raw SQL strings from service layer
## Benefits
1. **Type Safety**: Structured JSON prevents SQL injection
2. **Readability**: Clear, self-documenting queries
3. **Maintainability**: Easy to modify and extend
4. **Security**: Table whitelist prevents unauthorized access
5. **Consistency**: Uniform query interface across codebase
6. **Testing**: Easier to mock and test

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,172 @@
# Query Helper - Quick Reference Card
One-page quick reference for daily development.
## Import
```javascript
import { select, insert, update, deleteRecord, execute } from '../db/queryHelper/index.js';
```
## SELECT
```javascript
const results = await select({
table: 'users',
columns: ['id', 'name'], // or '*'
where: { deleted: false },
orderBy: { column: 'created_at', direction: 'desc' },
limit: 20,
offset: 0,
});
```
## INSERT
```javascript
// Single
const user = await insert({
table: 'users',
data: { name: 'John', phone: '+1234567890' },
returning: '*',
});
// Batch
const items = await insert({
table: 'items',
data: [{ name: 'Item 1' }, { name: 'Item 2' }],
returning: '*',
});
```
## UPDATE
```javascript
const updated = await update({
table: 'users',
data: { name: 'Jane' },
where: { id: userId, deleted: false }, // WHERE required
returning: '*',
});
```
## DELETE (Soft Delete Recommended)
```javascript
// Soft delete (recommended)
const deleted = await update({
table: 'users',
data: { deleted: true },
where: { id: userId },
});
// Hard delete (use with caution)
const deleted = await deleteRecord({
table: 'users',
where: { id: userId }, // WHERE required
});
```
## TRANSACTION
```javascript
const result = await execute({
type: 'transaction',
handler: async (trx) => {
const user = await trx('users').insert(userData).returning('*');
await trx('listings').insert({ ...listingData, seller_id: user[0].id });
return user[0];
},
});
```
## WHERE Operators
```javascript
// Simple
where: { status: 'active' }
// Comparison
where: { price: { op: '>=', value: 1000 } }
// IN
where: { id: { op: 'in', value: ['uuid1', 'uuid2'] } }
// LIKE
where: { name: { op: 'ilike', value: '%john%' } }
// BETWEEN
where: { age: { op: 'between', value: [18, 65] } }
// NULL
where: { deleted_at: { op: 'isNull' } }
```
## Common Patterns
### GET with Pagination
```javascript
const { limit = 20, offset = 0 } = req.query;
const results = await select({
table: 'table_name',
where: { deleted: false },
orderBy: { column: 'created_at', direction: 'desc' },
limit: Math.min(limit, 100),
offset: parseInt(offset),
});
```
### GET Single by ID
```javascript
const records = await select({
table: 'table_name',
where: { id: id, deleted: false },
limit: 1,
});
if (records.length === 0) return res.status(404).json({ error: "Not found" });
res.json(records[0]);
```
### Partial Update
```javascript
const updateData = {};
if (req.body.name !== undefined) updateData.name = req.body.name;
if (req.body.email !== undefined) updateData.email = req.body.email;
const updated = await update({
table: 'table_name',
data: updateData,
where: { id: id },
returning: '*',
});
```
## Important Rules
1. ✅ Always include `deleted: false` in WHERE for SELECT/UPDATE
2. ✅ WHERE clause is **required** for UPDATE/DELETE
3. ✅ Cap limits: `Math.min(limit, 100)`
4. ✅ Use transactions for multi-step operations
5. ✅ Use `returning: '*'` to get inserted/updated data
6. ✅ Validate input before querying
7. ✅ Handle errors appropriately
## Error Codes
- `23505` - Unique constraint violation (duplicate)
- `23503` - Foreign key violation
- `22P02` - Invalid UUID format
- `42P01` - Table doesn't exist
## File Structure
```
db/queryHelper/
├── index.js # Main implementation
├── knex.js # Knex configuration
├── README.md # Overview
├── API_REFERENCE.md # Complete API docs
├── DEVELOPER_GUIDE.md # This comprehensive guide
└── QUICK_REFERENCE.md # This file
```

284
db/queryHelper/README.md Normal file
View File

@ -0,0 +1,284 @@
# Query Helper - JSON-Based Database Interface
A centralized, SQL-string-free database query helper built on Knex.js that provides a safe, structured interface for all database operations.
## 🎯 Goals
- **No Raw SQL**: All queries built using Knex query builder
- **JSON-Based**: Structured JSON objects instead of SQL strings
- **Type Safe**: Parameterized queries prevent SQL injection
- **Server-Only**: Internal helper, not exposed to API clients
- **Production Ready**: Error handling, validation, and security built-in
## 📦 Installation
Knex.js is already installed. The query helper uses the existing database connection from `db/pool.js`.
## 🚀 Quick Start
```javascript
import { select, insert, update, deleteRecord, execute } from '../db/queryHelper/index.js';
// SELECT
const users = await select({
table: 'users',
where: { deleted: false },
limit: 10
});
// INSERT
const user = await insert({
table: 'users',
data: { name: 'John', phone_number: '+1234567890' },
returning: '*'
});
// UPDATE
const updated = await update({
table: 'users',
data: { name: 'Jane' },
where: { id: userId },
returning: '*'
});
// DELETE
await deleteRecord({
table: 'users',
where: { id: userId }
});
// TRANSACTION
await execute({
type: 'transaction',
handler: async (trx) => {
await trx('users').insert(userData);
await trx('listings').insert(listingData);
}
});
```
## 📚 API Reference
### `select(options)`
Retrieve records from database.
**Options:**
- `table` (string, required): Table name (must be in whitelist)
- `columns` (string[] | '*', optional): Columns to select (default: '*')
- `where` (object, optional): WHERE conditions
- `orderBy` (object, optional): `{ column: string, direction: 'asc'|'desc' }`
- `limit` (number, optional): Max records (capped at 100)
- `offset` (number, optional): Skip records
- `joins` (array, optional): Join configurations
**Returns:** `Promise<object[]>`
### `insert(options)`
Insert new record(s).
**Options:**
- `table` (string, required): Table name
- `data` (object | object[], required): Data to insert
- `returning` (string[] | '*', optional): Columns to return
**Returns:** `Promise<object | object[]>`
### `update(options)`
Update existing records.
**Options:**
- `table` (string, required): Table name
- `data` (object, required): Data to update
- `where` (object, required): WHERE conditions (required for safety)
- `returning` (string[] | '*', optional): Columns to return
**Returns:** `Promise<object[]>`
### `deleteRecord(options)`
Delete records.
**Options:**
- `table` (string, required): Table name
- `where` (object, required): WHERE conditions (required for safety)
- `returning` (string[] | '*', optional): Columns to return
**Returns:** `Promise<object[]>`
### `execute(options)`
Execute transactions or custom query builder logic.
**Options:**
- `type` (string, required): 'transaction' or 'raw-builder'
- `handler` (function, required): Handler function receiving knex/trx instance
**Returns:** `Promise<any>`
## 🔍 WHERE Clause Operators
### Simple Equality
```javascript
where: { status: 'active' }
// WHERE status = 'active'
```
### Comparison Operators
```javascript
where: {
price: { op: '>=', value: 1000 },
age: { op: '<', value: 65 }
}
// WHERE price >= 1000 AND age < 65
```
### IN Operator
```javascript
where: {
id: { op: 'in', value: ['uuid1', 'uuid2', 'uuid3'] }
}
// WHERE id IN ('uuid1', 'uuid2', 'uuid3')
```
### LIKE Operator
```javascript
where: {
name: { op: 'ilike', value: '%john%' }
}
// WHERE name ILIKE '%john%'
```
### BETWEEN Operator
```javascript
where: {
age: { op: 'between', value: [18, 65] }
}
// WHERE age BETWEEN 18 AND 65
```
### NULL Checks
```javascript
where: {
deleted_at: { op: 'isNull' }
}
// WHERE deleted_at IS NULL
```
## 🔒 Security Features
1. **Table Whitelist**: Only allowed tables can be queried
2. **Parameterized Queries**: All values are parameterized
3. **WHERE Required**: UPDATE/DELETE require WHERE clause
4. **Limit Caps**: Maximum 100 records per query
5. **No SQL Strings**: Impossible to inject SQL
## 📋 Allowed Tables
The following tables are whitelisted:
- `users`
- `listings`
- `animals`
- `locations`
- `species`
- `breeds`
- `listing_media`
- `conversations`
- `messages`
- `communication_records`
- `favorites`
- `user_devices`
- `subscription_plans`
- `otp_requests`
- `auth_audit`
To add more tables, update `ALLOWED_TABLES` in `queryHelper.js`.
## 🎓 Examples
See `db/queryHelper/examples.js` for comprehensive examples.
## 📖 Migration Guide
See `db/MIGRATION_EXAMPLE.md` for before/after comparisons.
## 🔄 API Reference
See `db/QUERY_HELPER_MAPPING.md` for complete method reference and examples.
## ⚠️ Important Notes
1. **No Raw SQL**: Never use `pool.query()` with SQL strings
2. **Always Use WHERE**: UPDATE/DELETE must include WHERE clause
3. **Table Names**: Must be in whitelist (case-sensitive)
4. **Transactions**: Use `execute({ type: 'transaction' })` for multi-step operations
5. **Complex Queries**: Use `execute({ type: 'raw-builder' })` for advanced cases
## 🐛 Error Handling
The query helper throws descriptive errors:
- `Table 'xxx' is not allowed` - Table not in whitelist
- `WHERE clause is required` - Missing WHERE for UPDATE/DELETE
- `Unsupported operator: xxx` - Invalid operator
- Database errors are propagated with context
## 📝 Best Practices
1. **Use Transactions**: For multi-step operations
2. **Soft Deletes**: Use UPDATE instead of DELETE
3. **Limit Results**: Always set reasonable limits
4. **Validate Input**: Validate data before passing to helper
5. **Handle Errors**: Wrap in try-catch blocks
6. **Use Returning**: Get inserted/updated records back
## 🔧 Advanced Usage
### Complex Joins
```javascript
const results = await select({
table: 'listings',
columns: ['listings.*', 'animals.*'],
joins: [
{
type: 'inner',
table: 'animals',
on: 'listings.animal_id = animals.id'
}
],
where: { 'listings.deleted': false }
});
```
### Custom Query Builder
```javascript
const results = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('listings')
.select('listings.*')
.join('animals', 'listings.animal_id', 'animals.id')
.where('listings.deleted', false)
.where('animals.species_id', speciesId)
.orderBy('listings.created_at', 'desc')
.limit(20);
}
});
```
## 📚 Documentation
- **DEVELOPER_GUIDE.md** - Complete developer guide with examples, flow diagrams, and best practices
- **QUICK_REFERENCE.md** - One-page quick reference card for daily use
- **API_REFERENCE.md** - Complete API reference with all methods and options
## 📞 Support
For questions or issues, refer to:
- `DEVELOPER_GUIDE.md` - Comprehensive guide with real-world examples from the codebase
- `QUICK_REFERENCE.md` - Quick lookup for common patterns
- `API_REFERENCE.md` - Complete method documentation

527
db/queryHelper/index.js Normal file
View File

@ -0,0 +1,527 @@
/**
* Centralized JSON-based Database Query Helper
*
* This helper provides a safe, SQL-string-free interface for database operations.
* All queries are built using Knex.js query builder with parameterized queries.
*
* @module db/queryHelper
*/
import db from './knex.js';
// Whitelist of allowed table names (security: prevent SQL injection via table names)
const ALLOWED_TABLES = new Set([
'users',
'listings',
'animals',
'locations',
'species',
'breeds',
'listing_media',
'conversations',
'messages',
'communication_records',
'favorites',
'user_devices',
'subscription_plans',
'otp_requests',
'auth_audit',
]);
// Maximum limit to prevent resource exhaustion
const MAX_LIMIT = 100;
/**
* Validates table name against whitelist
* @param {string} table - Table name to validate
* @throws {Error} If table is not in whitelist
*/
function validateTable(table) {
if (!table || typeof table !== 'string') {
throw new Error('Table name is required and must be a string');
}
if (!ALLOWED_TABLES.has(table)) {
throw new Error(`Table '${table}' is not allowed. Allowed tables: ${Array.from(ALLOWED_TABLES).join(', ')}`);
}
}
/**
* Applies WHERE conditions from JSON object to Knex query builder
* Supports simple equality, operators, and complex conditions
*
* @param {object} queryBuilder - Knex query builder instance
* @param {object} where - WHERE conditions object
*
* @example
* // Simple equality
* { status: 'active' } WHERE status = 'active'
*
* @example
* // Operators
* { price: { op: '>', value: 1000 } } WHERE price > 1000
* { age: { op: 'in', value: [18, 19, 20] } } WHERE age IN (18, 19, 20)
*
* @example
* // Multiple conditions (AND)
* { status: 'active', deleted: false } WHERE status = 'active' AND deleted = false
*/
function applyWhereConditions(queryBuilder, where) {
if (!where || typeof where !== 'object') {
return queryBuilder;
}
for (const [column, condition] of Object.entries(where)) {
if (condition === null || condition === undefined) {
// Handle null checks
queryBuilder.whereNull(column);
} else if (typeof condition === 'object' && condition.op) {
// Handle operators: { op: '>', value: 100 }
const { op, value } = condition;
switch (op) {
case '>':
queryBuilder.where(column, '>', value);
break;
case '<':
queryBuilder.where(column, '<', value);
break;
case '>=':
queryBuilder.where(column, '>=', value);
break;
case '<=':
queryBuilder.where(column, '<=', value);
break;
case '!=':
case '<>':
queryBuilder.where(column, '!=', value);
break;
case 'in':
if (Array.isArray(value)) {
queryBuilder.whereIn(column, value);
} else {
throw new Error(`Operator 'in' requires an array value, got ${typeof value}`);
}
break;
case 'notIn':
if (Array.isArray(value)) {
queryBuilder.whereNotIn(column, value);
} else {
throw new Error(`Operator 'notIn' requires an array value, got ${typeof value}`);
}
break;
case 'like':
queryBuilder.where(column, 'like', value);
break;
case 'ilike':
queryBuilder.where(column, 'ilike', value);
break;
case 'between':
if (Array.isArray(value) && value.length === 2) {
queryBuilder.whereBetween(column, value);
} else {
throw new Error(`Operator 'between' requires an array of 2 values, got ${JSON.stringify(value)}`);
}
break;
case 'isNull':
queryBuilder.whereNull(column);
break;
case 'isNotNull':
queryBuilder.whereNotNull(column);
break;
default:
throw new Error(`Unsupported operator: ${op}. Supported: >, <, >=, <=, !=, in, notIn, like, ilike, between, isNull, isNotNull`);
}
} else {
// Simple equality: { status: 'active' }
queryBuilder.where(column, condition);
}
}
return queryBuilder;
}
/**
* SELECT query - Retrieve records from database
*
* @param {object} options - Query options
* @param {string} options.table - Table name (must be in whitelist)
* @param {string[]|string} [options.columns='*'] - Columns to select
* @param {object} [options.where] - WHERE conditions (JSON object)
* @param {object} [options.orderBy] - Order by configuration
* @param {string} options.orderBy.column - Column to order by
* @param {string} [options.orderBy.direction='asc'] - 'asc' or 'desc'
* @param {number} [options.limit] - Maximum number of records (capped at MAX_LIMIT)
* @param {number} [options.offset=0] - Number of records to skip
* @param {object[]} [options.joins] - Join configurations
* @param {string} options.joins[].type - 'inner', 'left', 'right', 'full'
* @param {string} options.joins[].table - Table to join
* @param {string} options.joins[].on - Join condition (e.g., 'listings.animal_id = animals.id')
*
* @returns {Promise<object[]>} Array of records
*
* @example
* // Simple select
* const users = await select({
* table: 'users',
* where: { deleted: false },
* limit: 10
* });
*
* @example
* // With operators
* const listings = await select({
* table: 'listings',
* where: {
* status: 'active',
* price: { op: '>=', value: 1000 },
* age: { op: 'in', value: [18, 19, 20] }
* },
* orderBy: { column: 'created_at', direction: 'desc' },
* limit: 20
* });
*/
export async function select(options) {
const { table, columns = '*', where, orderBy, limit, offset = 0, joins } = options;
validateTable(table);
let query = db(table);
// Select columns
if (Array.isArray(columns)) {
query = query.select(columns);
} else if (columns === '*') {
query = query.select('*');
} else {
throw new Error('Columns must be an array or "*"');
}
// Apply joins
if (joins && Array.isArray(joins)) {
for (const join of joins) {
const { type = 'inner', table: joinTable, on } = join;
if (!joinTable || !on) {
throw new Error('Join must have table and on properties');
}
switch (type.toLowerCase()) {
case 'inner':
query = query.innerJoin(joinTable, on);
break;
case 'left':
query = query.leftJoin(joinTable, on);
break;
case 'right':
query = query.rightJoin(joinTable, on);
break;
case 'full':
query = query.fullOuterJoin(joinTable, on);
break;
default:
throw new Error(`Unsupported join type: ${type}`);
}
}
}
// Apply WHERE conditions
query = applyWhereConditions(query, where);
// Apply ORDER BY
if (orderBy) {
const { column, direction = 'asc' } = orderBy;
if (!column) {
throw new Error('orderBy.column is required');
}
if (direction !== 'asc' && direction !== 'desc') {
throw new Error('orderBy.direction must be "asc" or "desc"');
}
query = query.orderBy(column, direction);
}
// Apply LIMIT (with cap)
if (limit !== undefined) {
const cappedLimit = Math.min(limit, MAX_LIMIT);
query = query.limit(cappedLimit);
}
// Apply OFFSET
if (offset > 0) {
query = query.offset(offset);
}
const results = await query;
return results;
}
/**
* INSERT query - Insert new record(s)
*
* @param {object} options - Insert options
* @param {string} options.table - Table name (must be in whitelist)
* @param {object|object[]} options.data - Data to insert (single object or array for batch)
* @param {string[]} [options.returning] - Columns to return (PostgreSQL)
*
* @returns {Promise<object|object[]>} Inserted record(s)
*
* @example
* // Single insert
* const user = await insert({
* table: 'users',
* data: {
* name: 'John Doe',
* phone_number: '+1234567890'
* },
* returning: ['id', 'name']
* });
*
* @example
* // Batch insert
* const media = await insert({
* table: 'listing_media',
* data: [
* { listing_id: 'uuid1', media_url: 'url1', media_type: 'image' },
* { listing_id: 'uuid2', media_url: 'url2', media_type: 'image' }
* ]
* });
*/
export async function insert(options) {
const { table, data, returning } = options;
validateTable(table);
if (!data || (typeof data !== 'object' && !Array.isArray(data))) {
throw new Error('Data is required and must be an object or array');
}
let query = db(table);
if (Array.isArray(data)) {
// Batch insert
query = query.insert(data);
} else {
// Single insert
query = query.insert(data);
}
// PostgreSQL RETURNING clause
if (returning && Array.isArray(returning)) {
query = query.returning(returning);
} else if (returning === '*') {
query = query.returning('*');
}
const results = await query;
return Array.isArray(data) ? results : (results[0] || results);
}
/**
* UPDATE query - Update existing records
*
* @param {object} options - Update options
* @param {string} options.table - Table name (must be in whitelist)
* @param {object} options.data - Data to update
* @param {object} options.where - WHERE conditions (REQUIRED for safety)
* @param {string[]} [options.returning] - Columns to return (PostgreSQL)
*
* @returns {Promise<object[]>} Updated records
*
* @throws {Error} If where clause is missing (safety requirement)
*
* @example
* const updated = await update({
* table: 'users',
* data: { name: 'Jane Doe' },
* where: { id: 'user-uuid' },
* returning: ['id', 'name']
* });
*
* @example
* // Update with operators
* const updated = await update({
* table: 'listings',
* data: { status: 'sold' },
* where: {
* seller_id: 'seller-uuid',
* status: { op: '!=', value: 'sold' }
* }
* });
*/
export async function update(options) {
const { table, data, where, returning } = options;
validateTable(table);
if (!data || typeof data !== 'object') {
throw new Error('Data is required and must be an object');
}
if (!where || typeof where !== 'object' || Object.keys(where).length === 0) {
throw new Error('WHERE clause is required for UPDATE operations (safety requirement)');
}
let query = db(table).update(data);
// Apply WHERE conditions
query = applyWhereConditions(query, where);
// PostgreSQL RETURNING clause
if (returning && Array.isArray(returning)) {
query = query.returning(returning);
} else if (returning === '*') {
query = query.returning('*');
}
const results = await query;
return results;
}
/**
* DELETE query - Delete records (soft delete recommended)
*
* @param {object} options - Delete options
* @param {string} options.table - Table name (must be in whitelist)
* @param {object} options.where - WHERE conditions (REQUIRED for safety)
* @param {string[]} [options.returning] - Columns to return (PostgreSQL)
*
* @returns {Promise<object[]>} Deleted records
*
* @throws {Error} If where clause is missing (safety requirement)
*
* @example
* const deleted = await deleteRecord({
* table: 'users',
* where: { id: 'user-uuid' },
* returning: ['id']
* });
*
* @example
* // Soft delete (recommended)
* await update({
* table: 'users',
* data: { deleted: true },
* where: { id: 'user-uuid' }
* });
*/
export async function deleteRecord(options) {
const { table, where, returning } = options;
validateTable(table);
if (!where || typeof where !== 'object' || Object.keys(where).length === 0) {
throw new Error('WHERE clause is required for DELETE operations (safety requirement)');
}
let query = db(table).delete();
// Apply WHERE conditions
query = applyWhereConditions(query, where);
// PostgreSQL RETURNING clause
if (returning && Array.isArray(returning)) {
query = query.returning(returning);
} else if (returning === '*') {
query = query.returning('*');
}
const results = await query;
return results;
}
/**
* EXECUTE - Execute custom query builder logic or transactions
*
* @param {object} options - Execute options
* @param {string} options.type - 'transaction' or 'raw-builder'
* @param {Function} options.handler - Handler function
* @param {object} options.handler.knexInstance - Knex query builder or transaction instance
*
* @returns {Promise<any>} Result from handler function
*
* @example
* // Transaction
* const result = await execute({
* type: 'transaction',
* handler: async (trx) => {
* const user = await trx('users').insert({ name: 'John' }).returning('*');
* await trx('listings').insert({ seller_id: user[0].id, title: 'Test' });
* return user;
* }
* });
*
* @example
* // Raw builder (for complex queries)
* const result = await execute({
* type: 'raw-builder',
* handler: async (knex) => {
* return await knex('listings')
* .select('listings.*', 'animals.*')
* .join('animals', 'listings.animal_id', 'animals.id')
* .where('listings.deleted', false);
* }
* });
*/
export async function execute(options) {
const { type, handler } = options;
if (!handler || typeof handler !== 'function') {
throw new Error('Handler function is required');
}
if (type === 'transaction') {
return await db.transaction(async (trx) => {
return await handler(trx);
});
} else if (type === 'raw-builder') {
return await handler(db);
} else {
throw new Error(`Unsupported execute type: ${type}. Use 'transaction' or 'raw-builder'`);
}
}
/**
* Get a single record by ID
* Convenience method for common use case
*
* @param {string} table - Table name
* @param {string} id - Record ID
* @param {string[]} [columns] - Columns to select
* @returns {Promise<object|null>} Record or null if not found
*/
export async function findById(table, id, columns = '*') {
const results = await select({
table,
columns,
where: { id },
limit: 1,
});
return results.length > 0 ? results[0] : null;
}
/**
* Count records matching conditions
*
* @param {string} table - Table name
* @param {object} [where] - WHERE conditions
* @returns {Promise<number>} Count of records
*/
export async function count(table, where = {}) {
validateTable(table);
let query = db(table);
query = applyWhereConditions(query, where);
const result = await query.count('* as count').first();
return parseInt(result.count, 10);
}
// Export all methods
export default {
select,
insert,
update,
delete: deleteRecord,
execute,
findById,
count,
};

32
db/queryHelper/knex.js Normal file
View File

@ -0,0 +1,32 @@
import knex from 'knex';
import 'dotenv/config';
/**
* Knex.js configuration for query helper
*
* Uses the same database connection settings as pool.js
* but creates a separate Knex instance for the query builder.
*
* Note: This is separate from db/pool.js which uses the native pg Pool.
* Both can coexist - pool.js for existing raw SQL queries,
* and this for the new JSON-based query helper.
*/
const config = {
client: 'pg',
connection: {
host: process.env.PGHOST || '127.0.0.1',
port: Number(process.env.PGPORT || 5432),
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'postgres',
},
pool: {
min: 2,
max: 10,
},
};
const db = knex(config);
export default db;

View File

@ -1,71 +1,67 @@
import schedule from "node-cron";
import pool from "../db/pool.js";
import { insert, execute } from "../db/queryHelper/index.js";
import { getIO, getSocketId } from "../socket.js";
// Run every hour
export const startExpirationJob = () => {
schedule.schedule("0 * * * *", async () => {
console.log("Running listing expiration check...");
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await execute({
type: 'transaction',
handler: async (trx) => {
// 1. Identify expired listings (active & not updated in last 48h)
const expiredListings = await trx('listings')
.select('id', 'title', 'seller_id')
.where({ status: 'active', deleted: false })
.whereRaw("updated_at < NOW() - INTERVAL '48 hours'")
.forUpdate()
.skipLocked();
// 1. Identify expired listings (active & not updated in last 48h)
// Using INTERVAL '48 hours'
const findExpiredQuery = `
SELECT id, title, seller_id
FROM listings
WHERE status = 'active'
AND updated_at < NOW() - INTERVAL '48 hours'
AND deleted = FALSE
FOR UPDATE SKIP LOCKED
`;
const { rows: expiredListings } = await client.query(findExpiredQuery);
if (expiredListings.length === 0) {
return [];
}
if (expiredListings.length === 0) {
await client.query("COMMIT");
return;
}
console.log(`Found ${expiredListings.length} listings to expire.`);
console.log(`Found ${expiredListings.length} listings to expire.`);
// 2. Update status to 'expired'
const expiredIds = expiredListings.map(l => l.id);
await trx('listings')
.whereIn('id', expiredIds)
.update({ status: 'expired' });
// 2. Update status to 'expired'
const expiredIds = expiredListings.map(l => l.id);
await client.query(`
UPDATE listings
SET status = 'expired'
WHERE id = ANY($1::uuid[])
`, [expiredIds]);
// 3. Create Notifications & Real-time Alerts
for (const listing of expiredListings) {
const message = `Your listing "${listing.title}" has expired after 48 hours of inactivity. Click here to re-list it.`;
// Insert Notification
await trx('notifications').insert({
user_id: listing.seller_id,
type: 'listing_expired',
message,
data: { listing_id: listing.id }
});
// 3. Create Notifications & Real-time Alerts
for (const listing of expiredListings) {
const message = `Your listing "${listing.title}" has expired after 48 hours of inactivity. Click here to re-list it.`;
// Insert Notification
await client.query(`
INSERT INTO notifications (user_id, type, message, data)
VALUES ($1, 'listing_expired', $2, $3)
`, [listing.seller_id, message, { listing_id: listing.id }]);
// Real-time Socket Emit
const socketId = getSocketId(listing.seller_id);
if (socketId) {
getIO().to(socketId).emit("notification", {
type: "listing_expired",
message,
data: { listing_id: listing.id }
});
}
}
// Real-time Socket Emit
const socketId = getSocketId(listing.seller_id);
if (socketId) {
getIO().to(socketId).emit("notification", {
type: "listing_expired",
message,
data: { listing_id: listing.id }
});
return expiredListings;
}
});
if (result && result.length > 0) {
console.log("Expiration check completed successfully.");
}
await client.query("COMMIT");
console.log("Expiration check completed successfully.");
} catch (err) {
await client.query("ROLLBACK");
console.error("Error in expiration job:", err);
} finally {
client.release();
}
});
};

228
package-lock.json generated
View File

@ -13,6 +13,7 @@
"dotenv": "^17.2.3",
"express": "^5.1.0",
"firebase-admin": "^13.6.0",
"knex": "^3.1.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
"socket.io": "^4.8.1"
@ -699,6 +700,12 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
"node_modules/colorette": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -711,6 +718,15 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -992,7 +1008,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"optional": true,
"engines": {
"node": ">=6"
}
@ -1002,6 +1017,15 @@
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -1301,6 +1325,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@ -1313,6 +1346,12 @@
"node": ">= 0.4"
}
},
"node_modules/getopts": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==",
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
@ -1531,6 +1570,15 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/interpret": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1539,6 +1587,21 @@
"node": ">= 0.10"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -1636,11 +1699,97 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/knex": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz",
"integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==",
"license": "MIT",
"dependencies": {
"colorette": "2.0.19",
"commander": "^10.0.0",
"debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
"get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
"pg-connection-string": "2.6.2",
"rechoir": "^0.8.0",
"resolve-from": "^5.0.0",
"tarn": "^3.0.2",
"tildify": "2.0.0"
},
"bin": {
"knex": "bin/cli.js"
},
"engines": {
"node": ">=16"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
},
"mysql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/knex/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/knex/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/knex/node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==",
"license": "MIT"
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@ -1893,6 +2042,12 @@
"node": ">= 0.8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
@ -2116,6 +2271,18 @@
"node": ">= 6"
}
},
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
"license": "MIT",
"dependencies": {
"resolve": "^1.20.0"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -2125,6 +2292,35 @@
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@ -2515,6 +2711,27 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"optional": true
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/teeny-request": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
@ -2569,6 +2786,15 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/tildify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@ -24,6 +24,7 @@
"dotenv": "^17.2.3",
"express": "^5.1.0",
"firebase-admin": "^13.6.0",
"knex": "^3.1.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
"socket.io": "^4.8.1"

View File

@ -1,5 +1,5 @@
import express from "express";
import pool from "../db/pool.js";
import { insert, select, update, execute } from "../db/queryHelper/index.js";
import { getIO, getSocketId } from "../socket.js";
// import { sendPushNotification } from "../utils/fcm.js";
@ -18,27 +18,30 @@ router.post("/conversations", async (req, res) => {
}
// Check if conversation exists (bidirectional check for robustness, although schema has specific columns)
const queryCheck = `
SELECT * FROM conversations
WHERE (buyer_id = $1 AND seller_id = $2)
OR (buyer_id = $2 AND seller_id = $1)
AND deleted = FALSE
`;
const checkResult = await pool.query(queryCheck, [buyer_id, seller_id]);
const checkResult = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('conversations')
.where(function() {
this.where({ buyer_id, seller_id })
.orWhere({ buyer_id: seller_id, seller_id: buyer_id });
})
.where({ deleted: false });
}
});
if (checkResult.rows.length > 0) {
return res.json(checkResult.rows[0]);
if (checkResult.length > 0) {
return res.json(checkResult[0]);
}
// Create new
const queryInsert = `
INSERT INTO conversations (buyer_id, seller_id)
VALUES ($1, $2)
RETURNING *
`;
const insertResult = await pool.query(queryInsert, [buyer_id, seller_id]);
const insertResult = await insert({
table: 'conversations',
data: { buyer_id, seller_id },
returning: '*'
});
res.status(201).json(insertResult.rows[0]);
res.status(201).json(insertResult);
} catch (err) {
console.error("Error creating/getting conversation:", err);
res.status(500).json({ error: "Internal server error" });
@ -52,30 +55,35 @@ router.get("/conversations/user/:userId", async (req, res) => {
// Fetch conversations where user is involved.
// Also fetch the OTHER user's name/avatar.
const queryText = `
SELECT
c.*,
CASE
WHEN c.buyer_id = $1 THEN u_seller.name
ELSE u_buyer.name
END as other_user_name,
CASE
WHEN c.buyer_id = $1 THEN u_seller.avatar_url
ELSE u_buyer.avatar_url
END as other_user_avatar,
CASE
WHEN c.buyer_id = $1 THEN u_seller.id
ELSE u_buyer.id
END as other_user_id
FROM conversations c
JOIN users u_buyer ON c.buyer_id = u_buyer.id
JOIN users u_seller ON c.seller_id = u_seller.id
WHERE (c.buyer_id = $1 OR c.seller_id = $1)
AND c.deleted = FALSE
ORDER BY c.updated_at DESC
`;
const result = await pool.query(queryText, [userId]);
res.json(result.rows);
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('conversations as c')
.select(
'c.*',
knex.raw(`CASE
WHEN c.buyer_id = ? THEN u_seller.name
ELSE u_buyer.name
END as other_user_name`, [userId]),
knex.raw(`CASE
WHEN c.buyer_id = ? THEN u_seller.avatar_url
ELSE u_buyer.avatar_url
END as other_user_avatar`, [userId]),
knex.raw(`CASE
WHEN c.buyer_id = ? THEN u_seller.id
ELSE u_buyer.id
END as other_user_id`, [userId])
)
.join('users as u_buyer', 'c.buyer_id', 'u_buyer.id')
.join('users as u_seller', 'c.seller_id', 'u_seller.id')
.where(function() {
this.where('c.buyer_id', userId).orWhere('c.seller_id', userId);
})
.where('c.deleted', false)
.orderBy('c.updated_at', 'desc');
}
});
res.json(result);
} catch (err) {
console.error("Error fetching user conversations:", err);
res.status(500).json({ error: "Internal server error" });
@ -88,20 +96,17 @@ router.get("/conversations/:conversationId/messages", async (req, res) => {
const { conversationId } = req.params;
const { limit = 50, offset = 0 } = req.query;
const queryText = `
SELECT
m.*
FROM messages m
WHERE m.conversation_id = $1
AND m.deleted = FALSE
ORDER BY m.created_at DESC
LIMIT $2 OFFSET $3
`;
const result = await pool.query(queryText, [conversationId, limit, offset]);
const result = await select({
table: 'messages',
where: { conversation_id: conversationId, deleted: false },
orderBy: { column: 'created_at', direction: 'desc' },
limit: parseInt(limit),
offset: parseInt(offset)
});
// Reverse for frontend if needed, but API usually sends standard order.
// Sending newest first (DESC) is common for pagination.
res.json(result.rows);
res.json(result);
} catch (err) {
console.error("Error fetching messages:", err);
res.status(500).json({ error: "Internal server error" });
@ -110,66 +115,61 @@ router.get("/conversations/:conversationId/messages", async (req, res) => {
// 4. POST /messages (Send Message)
router.post("/messages", async (req, res) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const { conversation_id, sender_id, receiver_id, content, message_type = 'text', media_url, media_type, media_metadata } = req.body;
// Insert Message with embedded media fields
const insertMessageQuery = `
INSERT INTO messages (
conversation_id, sender_id, receiver_id, message_type, content,
message_media, media_type, media_metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const messageResult = await client.query(insertMessageQuery, [
conversation_id,
sender_id,
receiver_id,
message_type,
content,
media_url || null,
media_type || null,
media_metadata || null
]);
const messageData = {
conversation_id,
sender_id,
receiver_id,
message_type,
content,
message_media: media_url || null,
media_type: media_type || null,
media_metadata: media_metadata || null
};
await client.query("COMMIT");
const messageResult = await insert({
table: 'messages',
data: messageData,
returning: '*'
});
// Real-time update via Socket.io
const receiverSocketId = getSocketId(receiver_id);
if (receiverSocketId) {
getIO().to(receiverSocketId).emit("receive_message", messageResult.rows[0]);
getIO().to(receiverSocketId).emit("receive_message", messageResult);
}
// else {
// // Receiver is OFFLINE: Send Push Notification
// const fcmQuery = `SELECT fcm_token FROM user_devices WHERE user_id = $1 AND fcm_token IS NOT NULL AND is_active = TRUE`;
// const fcmResult = await pool.query(fcmQuery, [receiver_id]);
// const fcmResult = await select({
// table: 'user_devices',
// columns: ['fcm_token'],
// where: {
// user_id: receiver_id,
// is_active: true
// }
// });
// const tokens = fcmResult.filter(row => row.fcm_token).map(row => row.fcm_token);
// if (fcmResult.rows.length > 0) {
// const tokens = fcmResult.rows.map(row => row.fcm_token);
// // Title could be sender's name if we fetched it, or generic. For speed, generic "New Message".
// // Ideally, we'd join sender info in the SELECT or pass it if available.
// if (tokens.length > 0) {
// const notificationTitle = "New Message";
// const notificationBody = message_type === 'text' ? (content.substring(0, 100) + (content.length > 100 ? "..." : "")) : "Sent a media file";
// sendPushNotification(tokens, notificationTitle, notificationBody, {
// type: "new_message",
// conversation_id: conversation_id,
// message_id: messageResult.rows[0].id
// message_id: messageResult.id
// });
// }
// }
res.status(201).json(messageResult.rows[0]);
res.status(201).json(messageResult);
} catch (err) {
await client.query("ROLLBACK");
console.error("Error sending message:", err);
res.status(500).json({ error: "Internal server error" });
} finally {
client.release();
}
}
});
// 5. PUT /conversations/:conversationId/read (Mark Conversation as Read)
@ -182,28 +182,35 @@ router.put("/conversations/:conversationId/read", async (req, res) => {
return res.status(400).json({ error: "userId is required" });
}
const queryText = `
UPDATE messages
SET is_read = TRUE, read_at = NOW()
WHERE conversation_id = $1
AND receiver_id = $2
AND is_read = FALSE
RETURNING id, sender_id, conversation_id
`;
const result = await pool.query(queryText, [conversationId, userId]);
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('messages')
.where({
conversation_id: conversationId,
receiver_id: userId,
is_read: false
})
.update({
is_read: true,
read_at: knex.fn.now()
})
.returning(['id', 'sender_id', 'conversation_id']);
}
});
// Even if 0 rows updated, we return success (idempotent)
res.json({
message: "Messages marked as read",
updated_count: result.rows.length,
updated_messages: result.rows
updated_count: result.length,
updated_messages: result
});
// Real-time update via Socket.io
// Notify the SENDER(s) that their messages have been read.
// In a 1-on-1 chat, this is just one person.
if (result.rows.length > 0) {
const uniqueSenders = [...new Set(result.rows.map(m => m.sender_id))];
if (result.length > 0) {
const uniqueSenders = [...new Set(result.map(m => m.sender_id))];
for (const senderId of uniqueSenders) {
const senderSocketId = getSocketId(senderId);
@ -211,7 +218,7 @@ router.put("/conversations/:conversationId/read", async (req, res) => {
getIO().to(senderSocketId).emit("conversation_read", {
conversation_id: conversationId,
read_by_user_id: userId,
updated_count: result.rows.length // Simplification: total count, not per sender, but fine for 1:1
updated_count: result.length // Simplification: total count, not per sender, but fine for 1:1
});
}
}
@ -224,7 +231,6 @@ router.put("/conversations/:conversationId/read", async (req, res) => {
// 6. POST /communications (Log Call/Communication)
router.post("/communications", async (req, res) => {
const client = await pool.connect();
try {
const {
conversation_id,
@ -236,30 +242,27 @@ router.post("/communications", async (req, res) => {
call_recording_url
} = req.body;
await client.query("BEGIN");
const communicationData = {
conversation_id,
buyer_id,
seller_id,
communication_type,
call_status,
duration_seconds: duration_seconds || 0,
call_recording_url
};
const insertQuery = `
INSERT INTO communication_records (
conversation_id, buyer_id, seller_id,
communication_type, call_status, duration_seconds, call_recording_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
const result = await client.query(insertQuery, [
conversation_id, buyer_id, seller_id,
communication_type, call_status, duration_seconds || 0, call_recording_url
]);
const result = await insert({
table: 'communication_records',
data: communicationData,
returning: '*'
});
await client.query("COMMIT");
res.status(201).json(result.rows[0]);
res.status(201).json(result);
} catch (err) {
await client.query("ROLLBACK");
console.error("Error logging communication:", err);
res.status(500).json({ error: "Internal server error" });
} finally {
client.release();
}
});

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,11 @@
import express from "express";
import pool from "../db/pool.js";
import { insert, select, update, execute } from "../db/queryHelper/index.js";
const router = express.Router();
// 1. CREATE Location
router.post("/", async (req, res) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const {
user_id,
lat,
@ -28,9 +25,13 @@ router.post("/", async (req, res) => {
// Validate user exists if this is a saved address
let finalUserId = user_id;
if (is_saved_address && user_id) {
const userCheck = await client.query("SELECT id FROM users WHERE id = $1 AND deleted = FALSE", [user_id]);
if (userCheck.rows.length === 0) {
await client.query("ROLLBACK");
const userCheck = await select({
table: 'users',
columns: ['id'],
where: { id: user_id, deleted: false },
limit: 1
});
if (userCheck.length === 0) {
return res.status(400).json({
error: `User with id ${user_id} does not exist. Cannot create saved address for non-existent user.`
});
@ -39,45 +40,36 @@ router.post("/", async (req, res) => {
// For captured locations (not saved addresses), user_id can be NULL
finalUserId = null;
} else if (is_saved_address && !user_id) {
await client.query("ROLLBACK");
return res.status(400).json({
error: "user_id is required when is_saved_address is true"
});
}
// 1. Insert into locations
const insertLocationQuery = `
INSERT INTO locations (
user_id, lat, lng, source_type, source_confidence, selected_location,
is_saved_address, location_type, country, state, district, city_village, pincode
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *
`;
const locationValues = [
finalUserId,
// Insert into locations
const locationData = {
user_id: finalUserId,
lat,
lng,
source_type || "manual",
source_confidence || "low",
selected_location || false,
is_saved_address || false,
location_type || "other",
source_type: source_type || "manual",
source_confidence: source_confidence || "low",
selected_location: selected_location || false,
is_saved_address: is_saved_address || false,
location_type: location_type || "other",
country,
state,
district,
city_village,
pincode,
];
const locationResult = await client.query(insertLocationQuery, locationValues);
};
await client.query("COMMIT");
res.status(201).json({
...locationResult.rows[0],
const locationResult = await insert({
table: 'locations',
data: locationData,
returning: '*'
});
res.status(201).json(locationResult);
} catch (err) {
await client.query("ROLLBACK");
console.error("Error creating location:", err);
// Provide more specific error messages
@ -88,8 +80,6 @@ router.post("/", async (req, res) => {
}
res.status(500).json({ error: err.message || "Internal server error" });
} finally {
client.release();
}
});
@ -97,13 +87,12 @@ router.post("/", async (req, res) => {
router.get("/user/:userId", async (req, res) => {
try {
const { userId } = req.params;
const queryText = `
SELECT * FROM locations
WHERE user_id = $1 AND deleted = FALSE
ORDER BY created_at DESC
`;
const result = await pool.query(queryText, [userId]);
res.json(result.rows);
const result = await select({
table: 'locations',
where: { user_id: userId, deleted: false },
orderBy: { column: 'created_at', direction: 'desc' }
});
res.json(result);
} catch (err) {
console.error("Error fetching user locations:", err);
res.status(500).json({ error: "Internal server error" });
@ -114,17 +103,17 @@ router.get("/user/:userId", async (req, res) => {
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const queryText = `
SELECT * FROM locations
WHERE id = $1 AND deleted = FALSE
`;
const result = await pool.query(queryText, [id]);
const result = await select({
table: 'locations',
where: { id, deleted: false },
limit: 1
});
if (result.rows.length === 0) {
if (result.length === 0) {
return res.status(404).json({ error: "Location not found" });
}
res.json(result.rows[0]);
res.json(result[0]);
} catch (err) {
console.error("Error fetching location:", err);
res.status(500).json({ error: "Internal server error" });
@ -150,37 +139,36 @@ router.put("/:id", async (req, res) => {
pincode,
} = req.body;
const updateQuery = `
UPDATE locations
SET lat = COALESCE($1, lat),
lng = COALESCE($2, lng),
source_type = COALESCE($3, source_type),
source_confidence = COALESCE($4, source_confidence),
selected_location = COALESCE($5, selected_location),
is_saved_address = COALESCE($6, is_saved_address),
location_type = COALESCE($7, location_type),
country = COALESCE($8, country),
state = COALESCE($9, state),
district = COALESCE($10, district),
city_village = COALESCE($11, city_village),
pincode = COALESCE($12, pincode)
WHERE id = $13 AND deleted = FALSE
RETURNING *
`;
const values = [
lat, lng, source_type, source_confidence, selected_location,
is_saved_address, location_type, country, state, district, city_village, pincode,
id
];
// Use raw builder for COALESCE functionality
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
const updates = {};
if (lat !== undefined) updates.lat = knex.raw('COALESCE(?, lat)', [lat]);
if (lng !== undefined) updates.lng = knex.raw('COALESCE(?, lng)', [lng]);
if (source_type !== undefined) updates.source_type = knex.raw('COALESCE(?, source_type)', [source_type]);
if (source_confidence !== undefined) updates.source_confidence = knex.raw('COALESCE(?, source_confidence)', [source_confidence]);
if (selected_location !== undefined) updates.selected_location = knex.raw('COALESCE(?, selected_location)', [selected_location]);
if (is_saved_address !== undefined) updates.is_saved_address = knex.raw('COALESCE(?, is_saved_address)', [is_saved_address]);
if (location_type !== undefined) updates.location_type = knex.raw('COALESCE(?, location_type)', [location_type]);
if (country !== undefined) updates.country = knex.raw('COALESCE(?, country)', [country]);
if (state !== undefined) updates.state = knex.raw('COALESCE(?, state)', [state]);
if (district !== undefined) updates.district = knex.raw('COALESCE(?, district)', [district]);
if (city_village !== undefined) updates.city_village = knex.raw('COALESCE(?, city_village)', [city_village]);
if (pincode !== undefined) updates.pincode = knex.raw('COALESCE(?, pincode)', [pincode]);
return await knex('locations')
.where({ id, deleted: false })
.update(updates)
.returning('*');
}
});
const result = await pool.query(updateQuery, values);
if (result.rows.length === 0) {
if (result.length === 0) {
return res.status(404).json({ error: "Location not found" });
}
res.json(result.rows[0]);
res.json(result[0]);
} catch (err) {
console.error("Error updating location:", err);
res.status(500).json({ error: "Internal server error" });
@ -191,15 +179,14 @@ router.put("/:id", async (req, res) => {
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
const queryText = `
UPDATE locations
SET deleted = TRUE
WHERE id = $1
RETURNING id
`;
const result = await pool.query(queryText, [id]);
const result = await update({
table: 'locations',
data: { deleted: true },
where: { id },
returning: ['id']
});
if (result.rows.length === 0) {
if (result.length === 0) {
return res.status(404).json({ error: "Location not found" });
}

View File

@ -1,14 +1,12 @@
import express from "express";
import pool from "../db/pool.js";
import { insert, select, update } from "../db/queryHelper/index.js";
const router = express.Router();
// 1. CREATE User
router.post("/", async (req, res) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
// Parse and extract user data from request body
const {
id, // Optional: if provided, use this UUID; otherwise generate one
name,
@ -19,30 +17,37 @@ router.post("/", async (req, res) => {
country_code = "+91",
} = req.body;
// If id is provided, use it; otherwise let the database generate one
const insertUserQuery = id
? `
INSERT INTO users (id, name, phone_number, avatar_url, language, timezone, country_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`
: `
INSERT INTO users (name, phone_number, avatar_url, language, timezone, country_code)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
// Validate required fields
if (!name || !phone_number) {
return res.status(400).json({
error: "name and phone_number are required fields",
});
}
const userValues = id
? [id, name, phone_number, avatar_url, language, timezone, country_code]
: [name, phone_number, avatar_url, language, timezone, country_code];
// Build user data object using JSON-based structure
const userData = {
name: name.trim(),
phone_number: phone_number.trim(),
avatar_url: avatar_url || null,
language: language || null,
timezone: timezone || null,
country_code: country_code || "+91",
};
const userResult = await client.query(insertUserQuery, userValues);
// If id is provided, include it; otherwise let the database generate one
if (id) {
userData.id = id;
}
await client.query("COMMIT");
// Use queryHelper insert with JSON-based approach
const user = await insert({
table: 'users',
data: userData,
returning: '*',
});
res.status(201).json(userResult.rows[0]);
res.status(201).json(user);
} catch (err) {
await client.query("ROLLBACK");
console.error("Error creating user:", err);
if (err.code === "23505") {
@ -60,25 +65,42 @@ router.post("/", async (req, res) => {
}
res.status(500).json({ error: err.message || "Internal server error" });
} finally {
client.release();
}
});
// 2. GET All Users
router.get("/", async (req, res) => {
try {
const { limit = 100, offset = 0 } = req.query;
const queryText = `
SELECT id, name, phone_number, avatar_url, language, timezone, country_code,
is_active, created_at, updated_at
FROM users
WHERE deleted = FALSE
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`;
const result = await pool.query(queryText, [limit, offset]);
res.json(result.rows);
// Parse and validate query parameters
const limit = Math.min(parseInt(req.query.limit) || 100, 100);
const offset = parseInt(req.query.offset) || 0;
const { is_active, phone_number, name } = req.query;
// Build where conditions from query parameters
const where = { deleted: false };
if (is_active !== undefined) {
where.is_active = is_active === 'true' || is_active === true;
}
if (phone_number) {
where.phone_number = phone_number;
}
if (name) {
where.name = { op: 'ilike', value: `%${name}%` };
}
const users = await select({
table: 'users',
columns: ['id', 'name', 'phone_number', 'avatar_url', 'language', 'timezone', 'country_code', 'is_active', 'created_at', 'updated_at'],
where,
orderBy: {
column: 'created_at',
direction: 'desc',
},
limit,
offset,
});
res.json(users);
} catch (err) {
console.error("Error fetching users:", err);
res.status(500).json({ error: "Internal server error" });
@ -89,19 +111,23 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const queryText = `
SELECT id, name, phone_number, avatar_url, language, timezone, country_code,
is_active, created_at, updated_at
FROM users
WHERE id = $1 AND deleted = FALSE
`;
const result = await pool.query(queryText, [id]);
// Use queryHelper select with JSON-based where conditions
const user = await select({
table: 'users',
columns: ['id', 'name', 'phone_number', 'avatar_url', 'language', 'timezone', 'country_code', 'is_active', 'created_at', 'updated_at'],
where: {
id,
deleted: false,
},
limit: 1,
});
if (result.rows.length === 0) {
if (user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
res.json(result.rows[0]);
res.json(user[0]);
} catch (err) {
console.error("Error fetching user:", err);
res.status(500).json({ error: "Internal server error" });
@ -112,33 +138,51 @@ router.get("/:id", async (req, res) => {
router.put("/:id", async (req, res) => {
try {
const { id } = req.params;
// Parse and extract update data from request body
const { name, phone_number, avatar_url, language, timezone, country_code, is_active } = req.body;
const updateQuery = `
UPDATE users
SET name = COALESCE($1, name),
phone_number = COALESCE($2, phone_number),
avatar_url = COALESCE($3, avatar_url),
language = COALESCE($4, language),
timezone = COALESCE($5, timezone),
country_code = COALESCE($6, country_code),
is_active = COALESCE($7, is_active)
WHERE id = $8 AND deleted = FALSE
RETURNING *
`;
// Build update data object using JSON-based structure (only include fields that are provided)
const updateData = {};
if (name !== undefined) updateData.name = name.trim();
if (phone_number !== undefined) updateData.phone_number = phone_number.trim();
if (avatar_url !== undefined) updateData.avatar_url = avatar_url || null;
if (language !== undefined) updateData.language = language || null;
if (timezone !== undefined) updateData.timezone = timezone || null;
if (country_code !== undefined) updateData.country_code = country_code;
if (is_active !== undefined) updateData.is_active = is_active === true || is_active === 'true';
const values = [name, phone_number, avatar_url, language, timezone, country_code, is_active, id];
// Validate that at least one field is being updated
if (Object.keys(updateData).length === 0) {
return res.status(400).json({ error: "At least one field must be provided for update" });
}
const result = await pool.query(updateQuery, values);
// Use queryHelper update with JSON-based where conditions
const updated = await update({
table: 'users',
data: updateData,
where: {
id,
deleted: false,
},
returning: '*',
});
if (result.rows.length === 0) {
if (updated.length === 0) {
return res.status(404).json({ error: "User not found" });
}
res.json(result.rows[0]);
res.json(updated[0]);
} catch (err) {
console.error("Error updating user:", err);
res.status(500).json({ error: "Internal server error" });
if (err.code === "23505") {
// Unique constraint violation
return res.status(400).json({
error: err.detail || "A user with this phone number already exists",
});
}
res.status(500).json({ error: err.message || "Internal server error" });
}
});
@ -146,19 +190,25 @@ router.put("/:id", async (req, res) => {
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
const queryText = `
UPDATE users
SET deleted = TRUE
WHERE id = $1
RETURNING id
`;
const result = await pool.query(queryText, [id]);
// Use queryHelper update with JSON-based where conditions for soft delete
const deleted = await update({
table: 'users',
data: {
deleted: true,
},
where: {
id,
deleted: false, // Only delete if not already deleted
},
returning: ['id'],
});
if (result.rows.length === 0) {
return res.status(404).json({ error: "User not found" });
if (deleted.length === 0) {
return res.status(404).json({ error: "User not found or already deleted" });
}
res.json({ message: "User deleted successfully" });
res.json({ message: "User deleted successfully", id: deleted[0].id });
} catch (err) {
console.error("Error deleting user:", err);
res.status(500).json({ error: "Internal server error" });