Working Querry Engine and UserRoute
This commit is contained in:
parent
b3899dc14d
commit
35872ccee2
20
db/pool.js
20
db/pool.js
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -1,71 +1,67 @@
|
||||||
import schedule from "node-cron";
|
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";
|
import { getIO, getSocketId } from "../socket.js";
|
||||||
|
|
||||||
// Run every hour
|
// Run every hour
|
||||||
export const startExpirationJob = () => {
|
export const startExpirationJob = () => {
|
||||||
schedule.schedule("0 * * * *", async () => {
|
schedule.schedule("0 * * * *", async () => {
|
||||||
console.log("Running listing expiration check...");
|
console.log("Running listing expiration check...");
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
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)
|
if (expiredListings.length === 0) {
|
||||||
// Using INTERVAL '48 hours'
|
return [];
|
||||||
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) {
|
console.log(`Found ${expiredListings.length} listings to expire.`);
|
||||||
await client.query("COMMIT");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
// 3. Create Notifications & Real-time Alerts
|
||||||
const expiredIds = expiredListings.map(l => l.id);
|
for (const listing of expiredListings) {
|
||||||
await client.query(`
|
const message = `Your listing "${listing.title}" has expired after 48 hours of inactivity. Click here to re-list it.`;
|
||||||
UPDATE listings
|
|
||||||
SET status = 'expired'
|
// Insert Notification
|
||||||
WHERE id = ANY($1::uuid[])
|
await trx('notifications').insert({
|
||||||
`, [expiredIds]);
|
user_id: listing.seller_id,
|
||||||
|
type: 'listing_expired',
|
||||||
|
message,
|
||||||
|
data: { listing_id: listing.id }
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Create Notifications & Real-time Alerts
|
// Real-time Socket Emit
|
||||||
for (const listing of expiredListings) {
|
const socketId = getSocketId(listing.seller_id);
|
||||||
const message = `Your listing "${listing.title}" has expired after 48 hours of inactivity. Click here to re-list it.`;
|
if (socketId) {
|
||||||
|
getIO().to(socketId).emit("notification", {
|
||||||
// Insert Notification
|
type: "listing_expired",
|
||||||
await client.query(`
|
message,
|
||||||
INSERT INTO notifications (user_id, type, message, data)
|
data: { listing_id: listing.id }
|
||||||
VALUES ($1, 'listing_expired', $2, $3)
|
});
|
||||||
`, [listing.seller_id, message, { listing_id: listing.id }]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Real-time Socket Emit
|
return expiredListings;
|
||||||
const socketId = getSocketId(listing.seller_id);
|
|
||||||
if (socketId) {
|
|
||||||
getIO().to(socketId).emit("notification", {
|
|
||||||
type: "listing_expired",
|
|
||||||
message,
|
|
||||||
data: { listing_id: listing.id }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && result.length > 0) {
|
||||||
|
console.log("Expiration check completed successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log("Expiration check completed successfully.");
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("Error in expiration job:", err);
|
console.error("Error in expiration job:", err);
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"firebase-admin": "^13.6.0",
|
"firebase-admin": "^13.6.0",
|
||||||
|
"knex": "^3.1.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"socket.io": "^4.8.1"
|
"socket.io": "^4.8.1"
|
||||||
|
|
@ -699,6 +700,12 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"optional": true
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
|
@ -711,6 +718,15 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
|
|
@ -992,7 +1008,6 @@
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -1002,6 +1017,15 @@
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
"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": {
|
"node_modules/etag": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
|
@ -1301,6 +1325,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
|
@ -1313,6 +1346,12 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/google-auth-library": {
|
||||||
"version": "9.15.1",
|
"version": "9.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|
@ -1539,6 +1587,21 @@
|
||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/limiter": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
"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": {
|
"node_modules/lodash.camelcase": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
|
|
@ -1893,6 +2042,12 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||||
|
|
@ -2116,6 +2271,18 @@
|
||||||
"node": ">= 6"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -2125,6 +2292,35 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/retry": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
|
|
@ -2515,6 +2711,27 @@
|
||||||
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
|
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
|
||||||
"optional": true
|
"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": {
|
"node_modules/teeny-request": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
|
||||||
|
|
@ -2569,6 +2786,15 @@
|
||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"firebase-admin": "^13.6.0",
|
"firebase-admin": "^13.6.0",
|
||||||
|
"knex": "^3.1.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"socket.io": "^4.8.1"
|
"socket.io": "^4.8.1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import express from "express";
|
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 { getIO, getSocketId } from "../socket.js";
|
||||||
// import { sendPushNotification } from "../utils/fcm.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)
|
// Check if conversation exists (bidirectional check for robustness, although schema has specific columns)
|
||||||
const queryCheck = `
|
const checkResult = await execute({
|
||||||
SELECT * FROM conversations
|
type: 'raw-builder',
|
||||||
WHERE (buyer_id = $1 AND seller_id = $2)
|
handler: async (knex) => {
|
||||||
OR (buyer_id = $2 AND seller_id = $1)
|
return await knex('conversations')
|
||||||
AND deleted = FALSE
|
.where(function() {
|
||||||
`;
|
this.where({ buyer_id, seller_id })
|
||||||
const checkResult = await pool.query(queryCheck, [buyer_id, seller_id]);
|
.orWhere({ buyer_id: seller_id, seller_id: buyer_id });
|
||||||
|
})
|
||||||
|
.where({ deleted: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (checkResult.rows.length > 0) {
|
if (checkResult.length > 0) {
|
||||||
return res.json(checkResult.rows[0]);
|
return res.json(checkResult[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new
|
// Create new
|
||||||
const queryInsert = `
|
const insertResult = await insert({
|
||||||
INSERT INTO conversations (buyer_id, seller_id)
|
table: 'conversations',
|
||||||
VALUES ($1, $2)
|
data: { buyer_id, seller_id },
|
||||||
RETURNING *
|
returning: '*'
|
||||||
`;
|
});
|
||||||
const insertResult = await pool.query(queryInsert, [buyer_id, seller_id]);
|
|
||||||
|
|
||||||
res.status(201).json(insertResult.rows[0]);
|
res.status(201).json(insertResult);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error creating/getting conversation:", err);
|
console.error("Error creating/getting conversation:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
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.
|
// Fetch conversations where user is involved.
|
||||||
// Also fetch the OTHER user's name/avatar.
|
// Also fetch the OTHER user's name/avatar.
|
||||||
const queryText = `
|
const result = await execute({
|
||||||
SELECT
|
type: 'raw-builder',
|
||||||
c.*,
|
handler: async (knex) => {
|
||||||
CASE
|
return await knex('conversations as c')
|
||||||
WHEN c.buyer_id = $1 THEN u_seller.name
|
.select(
|
||||||
ELSE u_buyer.name
|
'c.*',
|
||||||
END as other_user_name,
|
knex.raw(`CASE
|
||||||
CASE
|
WHEN c.buyer_id = ? THEN u_seller.name
|
||||||
WHEN c.buyer_id = $1 THEN u_seller.avatar_url
|
ELSE u_buyer.name
|
||||||
ELSE u_buyer.avatar_url
|
END as other_user_name`, [userId]),
|
||||||
END as other_user_avatar,
|
knex.raw(`CASE
|
||||||
CASE
|
WHEN c.buyer_id = ? THEN u_seller.avatar_url
|
||||||
WHEN c.buyer_id = $1 THEN u_seller.id
|
ELSE u_buyer.avatar_url
|
||||||
ELSE u_buyer.id
|
END as other_user_avatar`, [userId]),
|
||||||
END as other_user_id
|
knex.raw(`CASE
|
||||||
FROM conversations c
|
WHEN c.buyer_id = ? THEN u_seller.id
|
||||||
JOIN users u_buyer ON c.buyer_id = u_buyer.id
|
ELSE u_buyer.id
|
||||||
JOIN users u_seller ON c.seller_id = u_seller.id
|
END as other_user_id`, [userId])
|
||||||
WHERE (c.buyer_id = $1 OR c.seller_id = $1)
|
)
|
||||||
AND c.deleted = FALSE
|
.join('users as u_buyer', 'c.buyer_id', 'u_buyer.id')
|
||||||
ORDER BY c.updated_at DESC
|
.join('users as u_seller', 'c.seller_id', 'u_seller.id')
|
||||||
`;
|
.where(function() {
|
||||||
const result = await pool.query(queryText, [userId]);
|
this.where('c.buyer_id', userId).orWhere('c.seller_id', userId);
|
||||||
res.json(result.rows);
|
})
|
||||||
|
.where('c.deleted', false)
|
||||||
|
.orderBy('c.updated_at', 'desc');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching user conversations:", err);
|
console.error("Error fetching user conversations:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
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 { conversationId } = req.params;
|
||||||
const { limit = 50, offset = 0 } = req.query;
|
const { limit = 50, offset = 0 } = req.query;
|
||||||
|
|
||||||
const queryText = `
|
const result = await select({
|
||||||
SELECT
|
table: 'messages',
|
||||||
m.*
|
where: { conversation_id: conversationId, deleted: false },
|
||||||
FROM messages m
|
orderBy: { column: 'created_at', direction: 'desc' },
|
||||||
WHERE m.conversation_id = $1
|
limit: parseInt(limit),
|
||||||
AND m.deleted = FALSE
|
offset: parseInt(offset)
|
||||||
ORDER BY m.created_at DESC
|
});
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
`;
|
|
||||||
const result = await pool.query(queryText, [conversationId, limit, offset]);
|
|
||||||
|
|
||||||
// Reverse for frontend if needed, but API usually sends standard order.
|
// Reverse for frontend if needed, but API usually sends standard order.
|
||||||
// Sending newest first (DESC) is common for pagination.
|
// Sending newest first (DESC) is common for pagination.
|
||||||
res.json(result.rows);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching messages:", err);
|
console.error("Error fetching messages:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
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)
|
// 4. POST /messages (Send Message)
|
||||||
router.post("/messages", async (req, res) => {
|
router.post("/messages", async (req, res) => {
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
|
||||||
const { conversation_id, sender_id, receiver_id, content, message_type = 'text', media_url, media_type, media_metadata } = req.body;
|
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
|
// Insert Message with embedded media fields
|
||||||
const insertMessageQuery = `
|
const messageData = {
|
||||||
INSERT INTO messages (
|
conversation_id,
|
||||||
conversation_id, sender_id, receiver_id, message_type, content,
|
sender_id,
|
||||||
message_media, media_type, media_metadata
|
receiver_id,
|
||||||
)
|
message_type,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
content,
|
||||||
RETURNING *
|
message_media: media_url || null,
|
||||||
`;
|
media_type: media_type || null,
|
||||||
const messageResult = await client.query(insertMessageQuery, [
|
media_metadata: media_metadata || null
|
||||||
conversation_id,
|
};
|
||||||
sender_id,
|
|
||||||
receiver_id,
|
|
||||||
message_type,
|
|
||||||
content,
|
|
||||||
media_url || null,
|
|
||||||
media_type || null,
|
|
||||||
media_metadata || null
|
|
||||||
]);
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
const messageResult = await insert({
|
||||||
|
table: 'messages',
|
||||||
|
data: messageData,
|
||||||
|
returning: '*'
|
||||||
|
});
|
||||||
|
|
||||||
// Real-time update via Socket.io
|
// Real-time update via Socket.io
|
||||||
const receiverSocketId = getSocketId(receiver_id);
|
const receiverSocketId = getSocketId(receiver_id);
|
||||||
if (receiverSocketId) {
|
if (receiverSocketId) {
|
||||||
getIO().to(receiverSocketId).emit("receive_message", messageResult.rows[0]);
|
getIO().to(receiverSocketId).emit("receive_message", messageResult);
|
||||||
}
|
}
|
||||||
// else {
|
// else {
|
||||||
// // Receiver is OFFLINE: Send Push Notification
|
// // 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 select({
|
||||||
// const fcmResult = await pool.query(fcmQuery, [receiver_id]);
|
// 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) {
|
// if (tokens.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.
|
|
||||||
// const notificationTitle = "New Message";
|
// const notificationTitle = "New Message";
|
||||||
// const notificationBody = message_type === 'text' ? (content.substring(0, 100) + (content.length > 100 ? "..." : "")) : "Sent a media file";
|
// const notificationBody = message_type === 'text' ? (content.substring(0, 100) + (content.length > 100 ? "..." : "")) : "Sent a media file";
|
||||||
|
|
||||||
// sendPushNotification(tokens, notificationTitle, notificationBody, {
|
// sendPushNotification(tokens, notificationTitle, notificationBody, {
|
||||||
// type: "new_message",
|
// type: "new_message",
|
||||||
// conversation_id: conversation_id,
|
// 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) {
|
} catch (err) {
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("Error sending message:", err);
|
console.error("Error sending message:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
} finally {
|
}
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. PUT /conversations/:conversationId/read (Mark Conversation as Read)
|
// 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" });
|
return res.status(400).json({ error: "userId is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `
|
const result = await execute({
|
||||||
UPDATE messages
|
type: 'raw-builder',
|
||||||
SET is_read = TRUE, read_at = NOW()
|
handler: async (knex) => {
|
||||||
WHERE conversation_id = $1
|
return await knex('messages')
|
||||||
AND receiver_id = $2
|
.where({
|
||||||
AND is_read = FALSE
|
conversation_id: conversationId,
|
||||||
RETURNING id, sender_id, conversation_id
|
receiver_id: userId,
|
||||||
`;
|
is_read: false
|
||||||
const result = await pool.query(queryText, [conversationId, userId]);
|
})
|
||||||
|
.update({
|
||||||
|
is_read: true,
|
||||||
|
read_at: knex.fn.now()
|
||||||
|
})
|
||||||
|
.returning(['id', 'sender_id', 'conversation_id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Even if 0 rows updated, we return success (idempotent)
|
// Even if 0 rows updated, we return success (idempotent)
|
||||||
res.json({
|
res.json({
|
||||||
message: "Messages marked as read",
|
message: "Messages marked as read",
|
||||||
updated_count: result.rows.length,
|
updated_count: result.length,
|
||||||
updated_messages: result.rows
|
updated_messages: result
|
||||||
});
|
});
|
||||||
|
|
||||||
// Real-time update via Socket.io
|
// Real-time update via Socket.io
|
||||||
// Notify the SENDER(s) that their messages have been read.
|
// Notify the SENDER(s) that their messages have been read.
|
||||||
// In a 1-on-1 chat, this is just one person.
|
// In a 1-on-1 chat, this is just one person.
|
||||||
if (result.rows.length > 0) {
|
if (result.length > 0) {
|
||||||
const uniqueSenders = [...new Set(result.rows.map(m => m.sender_id))];
|
const uniqueSenders = [...new Set(result.map(m => m.sender_id))];
|
||||||
|
|
||||||
for (const senderId of uniqueSenders) {
|
for (const senderId of uniqueSenders) {
|
||||||
const senderSocketId = getSocketId(senderId);
|
const senderSocketId = getSocketId(senderId);
|
||||||
|
|
@ -211,7 +218,7 @@ router.put("/conversations/:conversationId/read", async (req, res) => {
|
||||||
getIO().to(senderSocketId).emit("conversation_read", {
|
getIO().to(senderSocketId).emit("conversation_read", {
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
read_by_user_id: userId,
|
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)
|
// 6. POST /communications (Log Call/Communication)
|
||||||
router.post("/communications", async (req, res) => {
|
router.post("/communications", async (req, res) => {
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
|
|
@ -236,30 +242,27 @@ router.post("/communications", async (req, res) => {
|
||||||
call_recording_url
|
call_recording_url
|
||||||
} = req.body;
|
} = 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 = `
|
const result = await insert({
|
||||||
INSERT INTO communication_records (
|
table: 'communication_records',
|
||||||
conversation_id, buyer_id, seller_id,
|
data: communicationData,
|
||||||
communication_type, call_status, duration_seconds, call_recording_url
|
returning: '*'
|
||||||
)
|
});
|
||||||
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
|
|
||||||
]);
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
res.status(201).json(result);
|
||||||
res.status(201).json(result.rows[0]);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("Error logging communication:", err);
|
console.error("Error logging communication:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,14 +1,11 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import pool from "../db/pool.js";
|
import { insert, select, update, execute } from "../db/queryHelper/index.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 1. CREATE Location
|
// 1. CREATE Location
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
user_id,
|
user_id,
|
||||||
lat,
|
lat,
|
||||||
|
|
@ -28,9 +25,13 @@ router.post("/", async (req, res) => {
|
||||||
// Validate user exists if this is a saved address
|
// Validate user exists if this is a saved address
|
||||||
let finalUserId = user_id;
|
let finalUserId = user_id;
|
||||||
if (is_saved_address && 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]);
|
const userCheck = await select({
|
||||||
if (userCheck.rows.length === 0) {
|
table: 'users',
|
||||||
await client.query("ROLLBACK");
|
columns: ['id'],
|
||||||
|
where: { id: user_id, deleted: false },
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
if (userCheck.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `User with id ${user_id} does not exist. Cannot create saved address for non-existent user.`
|
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
|
// For captured locations (not saved addresses), user_id can be NULL
|
||||||
finalUserId = null;
|
finalUserId = null;
|
||||||
} else if (is_saved_address && !user_id) {
|
} else if (is_saved_address && !user_id) {
|
||||||
await client.query("ROLLBACK");
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "user_id is required when is_saved_address is true"
|
error: "user_id is required when is_saved_address is true"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Insert into locations
|
// Insert into locations
|
||||||
const insertLocationQuery = `
|
const locationData = {
|
||||||
INSERT INTO locations (
|
user_id: finalUserId,
|
||||||
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,
|
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
source_type || "manual",
|
source_type: source_type || "manual",
|
||||||
source_confidence || "low",
|
source_confidence: source_confidence || "low",
|
||||||
selected_location || false,
|
selected_location: selected_location || false,
|
||||||
is_saved_address || false,
|
is_saved_address: is_saved_address || false,
|
||||||
location_type || "other",
|
location_type: location_type || "other",
|
||||||
country,
|
country,
|
||||||
state,
|
state,
|
||||||
district,
|
district,
|
||||||
city_village,
|
city_village,
|
||||||
pincode,
|
pincode,
|
||||||
];
|
};
|
||||||
const locationResult = await client.query(insertLocationQuery, locationValues);
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
const locationResult = await insert({
|
||||||
|
table: 'locations',
|
||||||
res.status(201).json({
|
data: locationData,
|
||||||
...locationResult.rows[0],
|
returning: '*'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
res.status(201).json(locationResult);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("Error creating location:", err);
|
console.error("Error creating location:", err);
|
||||||
|
|
||||||
// Provide more specific error messages
|
// Provide more specific error messages
|
||||||
|
|
@ -88,8 +80,6 @@ router.post("/", async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: err.message || "Internal server error" });
|
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) => {
|
router.get("/user/:userId", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const queryText = `
|
const result = await select({
|
||||||
SELECT * FROM locations
|
table: 'locations',
|
||||||
WHERE user_id = $1 AND deleted = FALSE
|
where: { user_id: userId, deleted: false },
|
||||||
ORDER BY created_at DESC
|
orderBy: { column: 'created_at', direction: 'desc' }
|
||||||
`;
|
});
|
||||||
const result = await pool.query(queryText, [userId]);
|
res.json(result);
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching user locations:", err);
|
console.error("Error fetching user locations:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
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) => {
|
router.get("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const queryText = `
|
const result = await select({
|
||||||
SELECT * FROM locations
|
table: 'locations',
|
||||||
WHERE id = $1 AND deleted = FALSE
|
where: { id, deleted: false },
|
||||||
`;
|
limit: 1
|
||||||
const result = await pool.query(queryText, [id]);
|
});
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.length === 0) {
|
||||||
return res.status(404).json({ error: "Location not found" });
|
return res.status(404).json({ error: "Location not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(result[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching location:", err);
|
console.error("Error fetching location:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
|
@ -150,37 +139,36 @@ router.put("/:id", async (req, res) => {
|
||||||
pincode,
|
pincode,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const updateQuery = `
|
// Use raw builder for COALESCE functionality
|
||||||
UPDATE locations
|
const result = await execute({
|
||||||
SET lat = COALESCE($1, lat),
|
type: 'raw-builder',
|
||||||
lng = COALESCE($2, lng),
|
handler: async (knex) => {
|
||||||
source_type = COALESCE($3, source_type),
|
const updates = {};
|
||||||
source_confidence = COALESCE($4, source_confidence),
|
if (lat !== undefined) updates.lat = knex.raw('COALESCE(?, lat)', [lat]);
|
||||||
selected_location = COALESCE($5, selected_location),
|
if (lng !== undefined) updates.lng = knex.raw('COALESCE(?, lng)', [lng]);
|
||||||
is_saved_address = COALESCE($6, is_saved_address),
|
if (source_type !== undefined) updates.source_type = knex.raw('COALESCE(?, source_type)', [source_type]);
|
||||||
location_type = COALESCE($7, location_type),
|
if (source_confidence !== undefined) updates.source_confidence = knex.raw('COALESCE(?, source_confidence)', [source_confidence]);
|
||||||
country = COALESCE($8, country),
|
if (selected_location !== undefined) updates.selected_location = knex.raw('COALESCE(?, selected_location)', [selected_location]);
|
||||||
state = COALESCE($9, state),
|
if (is_saved_address !== undefined) updates.is_saved_address = knex.raw('COALESCE(?, is_saved_address)', [is_saved_address]);
|
||||||
district = COALESCE($10, district),
|
if (location_type !== undefined) updates.location_type = knex.raw('COALESCE(?, location_type)', [location_type]);
|
||||||
city_village = COALESCE($11, city_village),
|
if (country !== undefined) updates.country = knex.raw('COALESCE(?, country)', [country]);
|
||||||
pincode = COALESCE($12, pincode)
|
if (state !== undefined) updates.state = knex.raw('COALESCE(?, state)', [state]);
|
||||||
WHERE id = $13 AND deleted = FALSE
|
if (district !== undefined) updates.district = knex.raw('COALESCE(?, district)', [district]);
|
||||||
RETURNING *
|
if (city_village !== undefined) updates.city_village = knex.raw('COALESCE(?, city_village)', [city_village]);
|
||||||
`;
|
if (pincode !== undefined) updates.pincode = knex.raw('COALESCE(?, pincode)', [pincode]);
|
||||||
|
|
||||||
const values = [
|
return await knex('locations')
|
||||||
lat, lng, source_type, source_confidence, selected_location,
|
.where({ id, deleted: false })
|
||||||
is_saved_address, location_type, country, state, district, city_village, pincode,
|
.update(updates)
|
||||||
id
|
.returning('*');
|
||||||
];
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const result = await pool.query(updateQuery, values);
|
if (result.length === 0) {
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: "Location not found" });
|
return res.status(404).json({ error: "Location not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(result[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating location:", err);
|
console.error("Error updating location:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
|
@ -191,15 +179,14 @@ router.put("/:id", async (req, res) => {
|
||||||
router.delete("/:id", async (req, res) => {
|
router.delete("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const queryText = `
|
const result = await update({
|
||||||
UPDATE locations
|
table: 'locations',
|
||||||
SET deleted = TRUE
|
data: { deleted: true },
|
||||||
WHERE id = $1
|
where: { id },
|
||||||
RETURNING id
|
returning: ['id']
|
||||||
`;
|
});
|
||||||
const result = await pool.query(queryText, [id]);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.length === 0) {
|
||||||
return res.status(404).json({ error: "Location not found" });
|
return res.status(404).json({ error: "Location not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import pool from "../db/pool.js";
|
import { insert, select, update } from "../db/queryHelper/index.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 1. CREATE User
|
// 1. CREATE User
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
// Parse and extract user data from request body
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id, // Optional: if provided, use this UUID; otherwise generate one
|
id, // Optional: if provided, use this UUID; otherwise generate one
|
||||||
name,
|
name,
|
||||||
|
|
@ -19,30 +17,37 @@ router.post("/", async (req, res) => {
|
||||||
country_code = "+91",
|
country_code = "+91",
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// If id is provided, use it; otherwise let the database generate one
|
// Validate required fields
|
||||||
const insertUserQuery = id
|
if (!name || !phone_number) {
|
||||||
? `
|
return res.status(400).json({
|
||||||
INSERT INTO users (id, name, phone_number, avatar_url, language, timezone, country_code)
|
error: "name and phone_number are required fields",
|
||||||
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 *
|
|
||||||
`;
|
|
||||||
|
|
||||||
const userValues = id
|
// Build user data object using JSON-based structure
|
||||||
? [id, name, phone_number, avatar_url, language, timezone, country_code]
|
const userData = {
|
||||||
: [name, phone_number, avatar_url, language, timezone, country_code];
|
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) {
|
} catch (err) {
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("Error creating user:", err);
|
console.error("Error creating user:", err);
|
||||||
|
|
||||||
if (err.code === "23505") {
|
if (err.code === "23505") {
|
||||||
|
|
@ -60,25 +65,42 @@ router.post("/", async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: err.message || "Internal server error" });
|
res.status(500).json({ error: err.message || "Internal server error" });
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. GET All Users
|
// 2. GET All Users
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { limit = 100, offset = 0 } = req.query;
|
// Parse and validate query parameters
|
||||||
const queryText = `
|
const limit = Math.min(parseInt(req.query.limit) || 100, 100);
|
||||||
SELECT id, name, phone_number, avatar_url, language, timezone, country_code,
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
is_active, created_at, updated_at
|
const { is_active, phone_number, name } = req.query;
|
||||||
FROM users
|
|
||||||
WHERE deleted = FALSE
|
// Build where conditions from query parameters
|
||||||
ORDER BY created_at DESC
|
const where = { deleted: false };
|
||||||
LIMIT $1 OFFSET $2
|
if (is_active !== undefined) {
|
||||||
`;
|
where.is_active = is_active === 'true' || is_active === true;
|
||||||
const result = await pool.query(queryText, [limit, offset]);
|
}
|
||||||
res.json(result.rows);
|
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) {
|
} catch (err) {
|
||||||
console.error("Error fetching users:", err);
|
console.error("Error fetching users:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
|
@ -89,19 +111,23 @@ router.get("/", async (req, res) => {
|
||||||
router.get("/:id", async (req, res) => {
|
router.get("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const queryText = `
|
|
||||||
SELECT id, name, phone_number, avatar_url, language, timezone, country_code,
|
// Use queryHelper select with JSON-based where conditions
|
||||||
is_active, created_at, updated_at
|
const user = await select({
|
||||||
FROM users
|
table: 'users',
|
||||||
WHERE id = $1 AND deleted = FALSE
|
columns: ['id', 'name', 'phone_number', 'avatar_url', 'language', 'timezone', 'country_code', 'is_active', 'created_at', 'updated_at'],
|
||||||
`;
|
where: {
|
||||||
const result = await pool.query(queryText, [id]);
|
id,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (user.length === 0) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(user[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching user:", err);
|
console.error("Error fetching user:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
|
@ -112,33 +138,51 @@ router.get("/:id", async (req, res) => {
|
||||||
router.put("/:id", async (req, res) => {
|
router.put("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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 { name, phone_number, avatar_url, language, timezone, country_code, is_active } = req.body;
|
||||||
|
|
||||||
const updateQuery = `
|
// Build update data object using JSON-based structure (only include fields that are provided)
|
||||||
UPDATE users
|
const updateData = {};
|
||||||
SET name = COALESCE($1, name),
|
if (name !== undefined) updateData.name = name.trim();
|
||||||
phone_number = COALESCE($2, phone_number),
|
if (phone_number !== undefined) updateData.phone_number = phone_number.trim();
|
||||||
avatar_url = COALESCE($3, avatar_url),
|
if (avatar_url !== undefined) updateData.avatar_url = avatar_url || null;
|
||||||
language = COALESCE($4, language),
|
if (language !== undefined) updateData.language = language || null;
|
||||||
timezone = COALESCE($5, timezone),
|
if (timezone !== undefined) updateData.timezone = timezone || null;
|
||||||
country_code = COALESCE($6, country_code),
|
if (country_code !== undefined) updateData.country_code = country_code;
|
||||||
is_active = COALESCE($7, is_active)
|
if (is_active !== undefined) updateData.is_active = is_active === true || is_active === 'true';
|
||||||
WHERE id = $8 AND deleted = FALSE
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
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" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(updated[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating user:", 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) => {
|
router.delete("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const queryText = `
|
|
||||||
UPDATE users
|
// Use queryHelper update with JSON-based where conditions for soft delete
|
||||||
SET deleted = TRUE
|
const deleted = await update({
|
||||||
WHERE id = $1
|
table: 'users',
|
||||||
RETURNING id
|
data: {
|
||||||
`;
|
deleted: true,
|
||||||
const result = await pool.query(queryText, [id]);
|
},
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
deleted: false, // Only delete if not already deleted
|
||||||
|
},
|
||||||
|
returning: ['id'],
|
||||||
|
});
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (deleted.length === 0) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
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) {
|
} catch (err) {
|
||||||
console.error("Error deleting user:", err);
|
console.error("Error deleting user:", err);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue