Compare commits

...

5 Commits
main ... dev

Author SHA1 Message Date
SaiD 0353d4c642 Generic API builder 2025-12-21 22:06:51 +05:30
Chandresh Kerkar 8e6f6d32d4 Working Correctly but added logs 2025-12-21 12:03:51 +05:30
Chandresh Kerkar 4121b99775 Updated For Verfy JWT 2025-12-21 02:18:37 +05:30
Chandresh Kerkar 35872ccee2 Working Querry Engine and UserRoute 2025-12-21 00:42:13 +05:30
Chandresh Kerkar b3899dc14d Add user management, API testing tools, and fix database connection issues
- Added user routes (CRUD operations) for managing users
- Created Postman collection for API testing
- Added HTML test page for interactive API testing
- Fixed UUID handling for species_id and breed_id
- Added user validation for listings and locations
- Improved error handling and validation
- Added static file serving for test page
- Updated server configuration with all routes
2025-12-20 23:47:27 +05:30
51 changed files with 8635 additions and 724 deletions

294
API_FLOW_DOCUMENTATION.md Normal file
View File

@ -0,0 +1,294 @@
# API Request Flow Documentation
## GET `/users/:userId` Request Flow
### Complete Request Journey
```
Mobile App (Android/iOS)
GET http://10.0.2.2:3200/users/:userId
Headers: Authorization: Bearer <JWT_TOKEN>
┌─────────────────────────────────────────────────────────────┐
│ Backend Server (Port 3200) │
│ │
│ 1. Express App Layer │
│ ├─ CORS middleware │
│ ├─ express.json() │
│ │ │
│ 2. Global Middleware Chain │
│ ├─ requestContext │
│ │ └─ Extracts: IP, User-Agent, Request-ID │
│ │ │
│ ├─ auditLoggerMiddleware │
│ │ └─ Attaches audit logger to req.auditLogger │
│ │ │
│ 3. Route Matching │
│ └─ /users → userRoutes │
│ │
│ 4. Route-Level Middleware (userRoutes.js) │
│ ├─ jwtAuthenticate │
│ │ ├─ Extracts token from Authorization header │
│ │ ├─ Calls Auth Service API: │
│ │ │ POST http://auth-service:3000/auth/validate-token
│ │ │ Body: { token: <JWT_TOKEN> } │
│ │ ├─ Auth Service validates: signature, expiry, etc. │
│ │ └─ Sets req.user = { userId, role, ... } │
│ │ │
│ ├─ rateLimiterRead │
│ │ └─ Checks rate limits (Redis-based) │
│ │ │
│ ├─ requireUserOrAdmin (coarseAuthorize) │
│ │ └─ Verifies role is USER or ADMIN │
│ │ │
│ └─ fineAuthorize (for GET /:id only) │
│ └─ Verifies user can read this specific user │
│ (user can read own profile OR admin can read any)│
│ │
│ 5. Route Handler │
│ └─ GET /:id handler │
│ ├─ Extracts userId from req.params.id │
│ ├─ Database Query (via queryHelper) │
│ │ SELECT * FROM users WHERE id = :userId │
│ └─ Returns user data │
└─────────────────────────────────────────────────────────────┘
Response: { id, name, phone_number, avatar_url, ... }
Mobile App
```
### Services & Dependencies
1. **Backend Server** (Port 3200)
- Main API server
- Handles business logic
2. **Auth Service** (Port 3000)
- JWT token validation endpoint: `/auth/validate-token`
- Called by `jwtAuthenticate` middleware
- Returns user payload if token is valid
3. **Database** (PostgreSQL)
- Stores user data
- Queried via queryHelper
4. **Redis** (Optional)
- Used for rate limiting
- Falls back gracefully if unavailable
---
## Implementation Pattern for All APIs
### Standard Middleware Chain Pattern
All API routes should follow this consistent pattern:
```javascript
// routes/exampleRoutes.js
import express from "express";
import jwtAuthenticate from "../middleware/jwtAuthenticate.js";
import { rateLimiterRead, rateLimiterWrite } from "../middleware/rateLimiter.js";
import createCoarseAuthorize from "../middleware/coarseAuthorize.js";
import { fineAuthorize } from "../middleware/fineAuthorize.js";
const router = express.Router();
// 1. Apply authentication to ALL routes
router.use(jwtAuthenticate);
// 2. Apply rate limiting (read for GET, write for POST/PUT/DELETE)
router.use(rateLimiterRead); // Default: read rate limiter
// 3. Apply coarse-grained authorization (role-based)
const requireUserOrAdmin = createCoarseAuthorize(['USER', 'ADMIN']);
router.use(requireUserOrAdmin);
// 4. Define routes with fine-grained authorization where needed
router.get("/", async (req, res) => {
// No fine authorization - all authenticated users can list
});
router.get("/:id",
fineAuthorize({
action: 'read',
resource: 'resource_type', // e.g., 'user', 'order', 'listing'
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
// Handler logic
}
);
router.post("/",
rateLimiterWrite, // Override default rate limiter for write operations
async (req, res) => {
// Handler logic
}
);
router.put("/:id",
rateLimiterWrite,
fineAuthorize({
action: 'write',
resource: 'resource_type',
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
// Handler logic
}
);
```
### Middleware Execution Order
```
Request
[Global Middleware - server.js]
1. requestContext
2. auditLoggerMiddleware
3. CORS
4. express.json()
[Route Matching]
[Route-Level Middleware - routes/*.js]
1. jwtAuthenticate → Validates JWT, sets req.user
2. rateLimiterRead/Write → Rate limiting
3. coarseAuthorize → Role-based access (USER/ADMIN)
4. fineAuthorize (optional) → Resource-level permissions
[Route Handler]
Response
```
### Quick Reference: Route Setup Checklist
For each new API route file:
- [ ] Import required middleware
- [ ] Apply `jwtAuthenticate` to all routes (router.use)
- [ ] Apply appropriate rate limiter (router.use or per-route)
- [ ] Apply coarse authorization (router.use)
- [ ] Add fine authorization for resource-specific routes
- [ ] Add audit logging in handlers (req.auditLogger)
- [ ] Handle errors appropriately
### Example: New Resource Route
```javascript
// routes/productsRoutes.js
import express from "express";
import jwtAuthenticate from "../middleware/jwtAuthenticate.js";
import { rateLimiterRead, rateLimiterWrite } from "../middleware/rateLimiter.js";
import createCoarseAuthorize from "../middleware/coarseAuthorize.js";
import { fineAuthorize } from "../middleware/fineAuthorize.js";
import { select, insert, update } from "../db/queryHelper/index.js";
const router = express.Router();
// Authentication & Authorization
router.use(jwtAuthenticate);
router.use(rateLimiterRead);
router.use(createCoarseAuthorize(['USER', 'ADMIN']));
// GET /products - List all (public listings, no fine auth needed)
router.get("/", async (req, res) => {
try {
const products = await select({ table: 'products', where: { deleted: false } });
res.json(products);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
});
// GET /products/:id - Get specific product
router.get("/:id",
fineAuthorize({
action: 'read',
resource: 'product',
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
try {
const product = await select({
table: 'products',
where: { id: req.params.id, deleted: false },
limit: 1,
});
if (product.length === 0) return res.status(404).json({ error: "Not found" });
res.json(product[0]);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
}
);
// POST /products - Create product (requires write rate limiter)
router.post("/",
rateLimiterWrite,
async (req, res) => {
try {
const product = await insert({
table: 'products',
data: req.body,
returning: '*',
});
res.status(201).json(product);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
}
);
// PUT /products/:id - Update product
router.put("/:id",
rateLimiterWrite,
fineAuthorize({
action: 'write',
resource: 'product',
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
try {
const updated = await update({
table: 'products',
data: req.body,
where: { id: req.params.id, deleted: false },
returning: '*',
});
if (updated.length === 0) return res.status(404).json({ error: "Not found" });
res.json(updated[0]);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
}
);
export default router;
```
---
## Summary
**All API requests follow this pattern:**
1. Mobile App sends request with JWT token
2. Backend receives request → Global middleware → Route middleware → Handler
3. JWT validated via Auth Service
4. Rate limiting applied
5. Authorization checks (coarse + fine)
6. Business logic executes
7. Response returned to app
**Key Principles:**
- JWT authentication is mandatory for all protected routes
- Rate limiting prevents abuse
- Coarse authorization checks roles (USER/ADMIN)
- Fine authorization checks resource-level permissions
- Audit logging tracks all operations

188
AUTH_FLOW.md Normal file
View File

@ -0,0 +1,188 @@
# Authentication Flow - BuySellService → Auth Service
## Overview
BuySellService **does NOT validate JWT tokens locally**. Instead, it calls the auth service API to validate and authorize every request.
## Flow Diagram
```
Android App → BuySellService → Auth Service → BuySellService → Response
| | | |
| | | |
[Token] [Extract Token] [Validate Token] [User Info]
| | | |
| | | |
└──────────────┴────────────────┴───────────────┘
```
## Detailed Flow
### 1. Request Arrives at BuySellService
**Android App sends:**
```
GET http://localhost:3200/users/user-123
Authorization: Bearer eyJhbGc...
```
### 2. BuySellService Middleware Chain
**Step 1: requestContext**
- Extracts IP, User-Agent, generates Request-ID
**Step 2: auditLogger**
- Attaches audit logger to request
**Step 3: jwtAuthenticate** ⭐ **CALLS AUTH SERVICE**
- Extracts token from `Authorization: Bearer <token>` header
- **Calls auth service**: `POST http://localhost:3000/auth/validate-token`
- **Request body**: `{ token: "eyJhbGc..." }`
- **Waits for response**
### 3. Auth Service Validates Token
**Auth Service receives:**
```json
POST /auth/validate-token
Body: { "token": "eyJhbGc..." }
```
**Auth Service validates:**
- ✅ Token signature (using JWT secret)
- ✅ Token expiry (`exp` claim)
- ✅ Issuer (`iss` claim)
- ✅ Audience (`aud` claim)
- ✅ Token version (checks database for logout-all-devices)
- ✅ User exists in database
**Auth Service responds (if valid):**
```json
{
"valid": true,
"payload": {
"sub": "user-123",
"role": "user",
"user_type": "seller",
"phone_number": "+919876543210",
"token_version": 1,
"high_assurance": false
}
}
```
**Auth Service responds (if invalid):**
```json
{
"valid": false,
"error": "Invalid or expired token"
}
```
### 4. BuySellService Receives Response
**If valid:**
- Extracts user info from `payload`
- Sets `req.user = { userId: "user-123", role: "user", ... }`
- Logs successful authentication
- Continues to next middleware
**If invalid:**
- Returns `401 Unauthorized`
- Logs failed authentication
- Stops request processing
### 5. Continue with Authorization
**After authentication:**
- Rate limiting (checks limits for userId)
- Coarse-grained authorization (checks role)
- Fine-grained authorization (checks resource ownership)
- Route handler (fetches user data)
- Response returned to Android app
## Code Flow
```javascript
// Backend/middleware/jwtAuthenticate.js
async function jwtAuthenticate(req, res, next) {
// 1. Extract token
const token = req.headers.authorization?.slice(7); // "Bearer <token>"
// 2. Call auth service
const response = await axios.post(
'http://localhost:3000/auth/validate-token',
{ token }
);
// 3. Check response
if (response.data.valid === true) {
// 4. Extract user info
req.user = {
userId: response.data.payload.sub,
role: response.data.payload.role,
// ... other fields
};
next(); // Continue to next middleware
} else {
// 5. Return 401 if invalid
return res.status(401).json({ error: 'Unauthorized' });
}
}
```
## Benefits
**Centralized Validation**: All token validation logic in one place (auth service)
**No Secret Sharing**: BuySellService doesn't need JWT secret
**Token Version Checking**: Auth service checks database for logout-all-devices
**Consistent**: All services use same validation logic
**Easy Updates**: Update validation logic once in auth service
## Error Handling
**Auth Service Unavailable:**
- Returns `401 Unauthorized` with message: "Authentication service unavailable"
- Request is blocked (fail closed for security)
**Token Invalid:**
- Returns `401 Unauthorized` with error details
- Request is blocked
**Timeout:**
- 5 second timeout by default (configurable via `AUTH_SERVICE_TIMEOUT`)
- Returns `401 Unauthorized` if timeout exceeded
## Configuration
**Required Environment Variables:**
```env
AUTH_SERVICE_URL=http://localhost:3000
```
**Optional:**
```env
AUTH_SERVICE_TIMEOUT=5000 # milliseconds
```
## Testing
**Test auth service endpoint directly:**
```bash
# Valid token
curl -X POST http://localhost:3000/auth/validate-token \
-H "Content-Type: application/json" \
-d '{"token":"your_valid_token_here"}'
# Response:
# {"valid":true,"payload":{"sub":"user-123","role":"user",...}}
```
**Test through BuySellService:**
```bash
curl -X GET http://localhost:3200/users/user-123 \
-H "Authorization: Bearer your_valid_token_here"
```
The BuySellService will call the auth service internally to validate the token.

424
AUTH_IMPLEMENTATION.md Normal file
View File

@ -0,0 +1,424 @@
# Authentication & Authorization Implementation
This document describes the complete authentication and authorization system implemented for BuySellService.
## Overview
The system implements a comprehensive security middleware chain that:
1. ✅ Validates JWT tokens from Authorization header
2. ✅ Applies rate limiting (per userId, fallback to IP)
3. ✅ Enforces coarse-grained authorization (route level)
4. ✅ Enforces fine-grained authorization (business logic)
5. ✅ Logs all API requests for audit
## Middleware Chain Order
The middleware is applied in this **critical order**:
```
1. requestContext → Extract IP, user agent, request ID
2. auditLogger → Attach audit logger to request
3. jwtAuthenticate → Validate JWT token, extract user info
4. rateLimiter → Apply rate limiting
5. coarseAuthorize → Route-level role checking
6. fineAuthorize → Business logic authorization (per route)
7. routeHandler → Actual business logic
```
## Implementation Details
### 1. Request Context (`middleware/requestContext.js`)
**Purpose**: Extract and attach request metadata
**Extracts**:
- Client IP (considers proxies)
- User agent
- Request ID (for tracing)
- Request timestamp
**Usage**: Applied globally first in middleware chain
---
### 2. JWT Authentication (`middleware/jwtAuthenticate.js`)
**Purpose**: Validate JWT tokens and extract user information
**Validates**:
- ✅ Token signature
- ✅ Token expiry
- ✅ Issuer (iss)
- ✅ Audience (aud)
**Token Validation**:
- **Calls auth service API**: All token validation is done by the auth service
- **Endpoint**: `POST /auth/validate-token` on auth service
- **Auth service validates**: Signature, expiry, issuer, audience, token_version (for logout-all)
- **Response**: Returns `{ valid: true, payload: {...} }` or `{ valid: false, error: "..." }`
**Extracts to `req.user`**:
```javascript
{
userId: payload.sub, // User ID
role: payload.role, // User role (USER, ADMIN)
userType: payload.user_type,
phoneNumber: payload.phone_number,
tokenVersion: payload.token_version,
highAssurance: payload.high_assurance,
tenantId: payload.tenant_id, // For multi-tenant apps
}
```
**Error Responses**:
- `401 Unauthorized` - Missing or invalid token
**Environment Variables**:
- `AUTH_SERVICE_URL` (required) - URL of the auth service (default: 'http://localhost:3000')
- `AUTH_SERVICE_TIMEOUT` (optional) - Timeout for auth service calls in ms (default: 5000)
---
### 3. Rate Limiting (`middleware/rateLimiter.js`)
**Purpose**: Prevent API abuse by limiting requests per user/IP
**Strategy**:
- **Preferred**: Rate limit per `userId` (if authenticated)
- **Fallback**: Rate limit per IP (if not authenticated)
**Storage**:
- **Redis** (if available) - Distributed rate limiting
- **In-memory** (fallback) - Single-instance rate limiting
**Pre-configured Limiters**:
- `rateLimiterRead` - 100 requests per 15 minutes (GET operations)
- `rateLimiterWrite` - 20 requests per 15 minutes (POST, PUT, DELETE)
- `rateLimiterSensitive` - 10 requests per hour (sensitive operations)
**Response Headers**:
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 2024-01-20T15:30:00Z
X-RateLimit-Type: read
```
**Error Responses**:
- `429 Too Many Requests` - Rate limit exceeded
**Environment Variables**:
- `REDIS_URL` (optional) - Redis connection URL
- `RATE_LIMIT_MAX_REQUESTS` (optional, default: 100)
- `RATE_LIMIT_WINDOW_SECONDS` (optional, default: 900)
---
### 4. Coarse-Grained Authorization (`middleware/coarseAuthorize.js`)
**Purpose**: Route-level role-based access control
**Route-to-Role Mapping**:
```javascript
'/admin/*' → ['ADMIN']
'/users/*' → ['USER', 'ADMIN']
'/orders/*' → ['USER', 'ADMIN']
'/listings/*' → ['USER', 'ADMIN']
'/locations/*' → ['USER', 'ADMIN']
'/chat/*' → ['USER', 'ADMIN']
'*' → ['USER', 'ADMIN'] // Default
```
**Usage**:
```javascript
// Use route mapping
const requireUserOrAdmin = createCoarseAuthorize();
// Or specify roles explicitly
const requireAdmin = createCoarseAuthorize(['ADMIN']);
```
**Error Responses**:
- `403 Forbidden` - User role doesn't match required roles
---
### 5. Fine-Grained Authorization (`middleware/fineAuthorize.js`)
**Purpose**: Business logic level authorization (resource ownership, action permissions)
**Authorization Rules**:
**Users**:
- ✅ Users can read their own profile
- ✅ Users can update their own profile
- ❌ Users cannot access other users' data (unless admin)
**Orders**:
- ✅ Users can create orders
- ✅ Users can read/update/delete their own orders
- ❌ Users cannot access other users' orders
**Listings**:
- ✅ Users can create listings
- ✅ Users can read all active listings (public)
- ✅ Users can update/delete their own listings
**Usage**:
```javascript
router.get('/users/:id',
fineAuthorize({
action: 'read',
resource: 'user',
getResourceOwnerId: (req) => req.params.id,
}),
handler
);
```
**Helper Function**:
```javascript
const result = authorizeAction({
user: req.user,
action: 'read',
resource: 'user',
resourceOwnerId: userId,
});
if (!result.authorized) {
// Handle unauthorized
}
```
**Error Responses**:
- `403 Forbidden` - Action not authorized on resource
---
### 6. Audit Logging (`services/auditLogger.js`)
**Purpose**: Log all API requests for security auditing
**Logged Information**:
- Timestamp
- Request ID
- User ID
- Action
- Route
- HTTP Method
- Status (success, failed, forbidden, etc.)
- Client IP
- User Agent
- Additional metadata
**Usage**:
```javascript
// Automatic logging via middleware
req.auditLogger.logSuccess('get_user', { userId: id });
req.auditLogger.logFailure('get_user', 'User not found', { userId: id });
req.auditLogger.logForbidden('get_user', 'Access denied', { userId: id });
```
**Storage**:
- Currently logs to console and in-memory store
- TODO: Integrate with external logging service (CloudWatch, Elasticsearch, etc.)
---
## Example: GET /users/:userId Endpoint
Here's how the complete flow works for `GET /users/:userId`:
```
1. Request arrives with: Authorization: Bearer <token>
2. requestContext
→ Extracts IP: 192.168.1.1
→ Extracts User-Agent: Android App
→ Generates Request-ID: abc123
3. auditLogger
→ Attaches logger to req.auditLogger
4. jwtAuthenticate
→ Validates JWT token
→ Extracts: userId = "user-123", role = "USER"
→ Sets: req.user = { userId: "user-123", role: "USER", ... }
5. rateLimiterRead
→ Checks rate limit for user-123
→ Count: 5/100 (under limit)
→ Sets headers: X-RateLimit-Remaining: 95
6. coarseAuthorize
→ Checks route: /users/:id
→ Required roles: ['USER', 'ADMIN']
→ User role: 'USER' ✅
→ Allows request
7. fineAuthorize
→ Action: 'read'
→ Resource: 'user'
→ Resource owner: req.params.id = "user-456"
→ Checks: Can user-123 read user-456?
→ Rule: Users can only read their own profile
→ Result: user-123 !== user-456 → ❌ Unauthorized
→ Returns: 403 Forbidden (unless user-123 === user-456 or user-123 is ADMIN)
8. routeHandler
→ Fetches user from database
→ Returns user data
→ Logs success via req.auditLogger
```
---
## Configuration
### Environment Variables
Create a `.env` file:
```env
# Server
PORT=3200
TRUST_PROXY=false
# JWT (REQUIRED - must match auth service)
JWT_ACCESS_SECRET=your_jwt_secret_here
JWT_ISSUER=farm-auth-service
JWT_AUDIENCE=mobile-app
# Rate Limiting (Optional)
REDIS_URL=redis://localhost:6379
RATE_LIMIT_READ_MAX=100
RATE_LIMIT_READ_WINDOW=900
# Auth Service (Optional - for centralized validation)
VALIDATE_VIA_AUTH_SERVICE=false
AUTH_SERVICE_URL=http://localhost:3000
```
---
## Testing
### Test with Valid Token
```bash
# Get token from auth service (via login)
TOKEN="your_access_token_here"
# Test GET /users/:userId
curl -X GET http://localhost:3200/users/user-123 \
-H "Authorization: Bearer $TOKEN"
```
### Test without Token (Should return 401)
```bash
curl -X GET http://localhost:3200/users/user-123
# Returns: 401 Unauthorized
```
### Test with Invalid Token (Should return 401)
```bash
curl -X GET http://localhost:3200/users/user-123 \
-H "Authorization: Bearer invalid_token"
# Returns: 401 Unauthorized
```
### Test Rate Limiting (Should return 429 after limit)
```bash
# Make many rapid requests
for i in {1..101}; do
curl -X GET http://localhost:3200/users/user-123 \
-H "Authorization: Bearer $TOKEN"
done
# After 100 requests, returns: 429 Too Many Requests
```
---
## Security Features
**JWT Token Validation**: Signature, expiry, issuer, audience
**Rate Limiting**: Per-user and per-IP
**Role-Based Access Control**: Route-level and resource-level
**Audit Logging**: All requests logged with context
**No Cookies**: Uses Bearer tokens only
**No Server-Side Token Storage**: Stateless JWT validation
**HTTPS Assumed**: No certificate handling in app code
---
## Files Structure
```
Backend/
├── middleware/
│ ├── requestContext.js # Request metadata extraction
│ ├── jwtAuthenticate.js # JWT token validation
│ ├── rateLimiter.js # Rate limiting
│ ├── coarseAuthorize.js # Route-level authorization
│ └── fineAuthorize.js # Business logic authorization
├── services/
│ └── auditLogger.js # Audit logging service
├── routes/
│ └── userRoutes.js # User routes with auth middleware
└── server.js # Server setup with middleware chain
```
---
## Next Steps
1. **Install Dependencies**:
```bash
cd Backend
npm install
```
2. **Configure Environment**:
```bash
cp .env.example .env
# Edit .env with your JWT_ACCESS_SECRET from auth service
```
3. **Start Server**:
```bash
npm start
```
4. **Test Endpoint**:
- Use Android app to call GET /users/:userId
- Verify authentication and authorization work correctly
---
## Integration with Auth Service
The BuySellService calls the auth service API to validate JWT tokens. All token validation logic is centralized in the auth service.
**Token Flow**:
1. User logs in via auth service → Receives JWT token
2. Android app stores token securely
3. Android app sends token in `Authorization: Bearer <token>` header to BuySellService
4. BuySellService extracts token and calls auth service: `POST /auth/validate-token`
5. Auth service validates token (signature, expiry, token_version, claims) and returns user info
6. BuySellService receives validated user info and attaches to `req.user`
7. BuySellService continues with authorization checks and processes request
**Auth Service Endpoint**:
- `POST /auth/validate-token`
- Request: `{ token: "..." }`
- Response (valid): `{ valid: true, payload: { sub, role, user_type, ... } }`
- Response (invalid): `{ valid: false, error: "..." }`
**Benefits of Centralized Validation**:
- ✅ Single source of truth for token validation
- ✅ Token version checking (for logout-all functionality) handled centrally
- ✅ No need to share JWT secrets between services
- ✅ Easier to update validation logic in one place

View File

@ -0,0 +1,637 @@
{
"info": {
"_postman_id": "buysellservice-api-collection",
"name": "BuySellService API",
"description": "Complete API collection for Livestock Marketplace - BuySellService",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Users",
"item": [
{
"name": "Create User",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"id\": \"aaf295cb-a19e-4179-a2df-31c0c64ea9f4\",\n \"name\": \"Test User\",\n \"phone_number\": \"+919876543210\",\n \"avatar_url\": null,\n \"language\": \"en\",\n \"timezone\": \"Asia/Kolkata\",\n \"country_code\": \"+91\"\n}"
},
"url": {
"raw": "http://localhost:3200/users",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"users"
]
},
"description": "Create a new user. You can provide a specific UUID or let the system generate one."
}
},
{
"name": "Get All Users",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/users?limit=100&offset=0",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"users"
],
"query": [
{
"key": "limit",
"value": "100"
},
{
"key": "offset",
"value": "0"
}
]
},
"description": "Get a list of all users"
}
},
{
"name": "Get User by ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/users/:userId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"users",
":userId"
],
"variable": [
{
"key": "userId",
"value": "aaf295cb-a19e-4179-a2df-31c0c64ea9f4"
}
]
},
"description": "Get a single user by UUID"
}
},
{
"name": "Update User",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated User Name\",\n \"phone_number\": \"+919876543210\"\n}"
},
"url": {
"raw": "http://localhost:3200/users/:userId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"users",
":userId"
],
"variable": [
{
"key": "userId",
"value": "aaf295cb-a19e-4179-a2df-31c0c64ea9f4"
}
]
},
"description": "Update user information"
}
}
]
},
{
"name": "Listings",
"item": [
{
"name": "Get All Listings",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/listings?status=active&limit=20&offset=0",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings"
],
"query": [
{
"key": "status",
"value": "active"
},
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
},
"description": "Get all active listings"
}
},
{
"name": "Get Listing by ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/listings/:listingId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings",
":listingId"
],
"variable": [
{
"key": "listingId",
"value": "your-listing-uuid-here"
}
]
},
"description": "Get a single listing with full details"
}
},
{
"name": "Create Listing",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"seller_id\": \"aaf295cb-a19e-4179-a2df-31c0c64ea9f4\",\n \"title\": \"High-yield Gir cow for sale\",\n \"price\": 55000,\n \"currency\": \"INR\",\n \"is_negotiable\": true,\n \"listing_type\": \"sale\",\n \"animal\": {\n \"species_id\": \"your-species-uuid\",\n \"breed_id\": \"your-breed-uuid\",\n \"sex\": \"F\",\n \"age_months\": 36,\n \"weight_kg\": 450,\n \"color_markings\": \"Brown with white patches\",\n \"quantity\": 1,\n \"purpose\": \"dairy\",\n \"health_status\": \"healthy\",\n \"vaccinated\": true,\n \"dewormed\": true,\n \"pregnancy_status\": \"pregnant\",\n \"milk_yield_litre_per_day\": 15,\n \"ear_tag_no\": \"TAG-12345\",\n \"description\": \"Calm nature, easy to handle.\"\n },\n \"media\": [\n {\n \"media_url\": \"https://cdn.app.com/listings/abc1.jpg\",\n \"media_type\": \"image\",\n \"is_primary\": true,\n \"sort_order\": 1\n }\n ]\n}"
},
"url": {
"raw": "http://localhost:3200/listings",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings"
]
},
"description": "Create a new listing with animal details"
}
},
{
"name": "Update Listing",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated title\",\n \"price\": 52000,\n \"status\": \"active\"\n}"
},
"url": {
"raw": "http://localhost:3200/listings/:listingId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings",
":listingId"
],
"variable": [
{
"key": "listingId",
"value": "your-listing-uuid-here"
}
]
},
"description": "Update listing information"
}
},
{
"name": "Search Listings",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/listings/search?q=cow&limit=20&offset=0",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings",
"search"
],
"query": [
{
"key": "q",
"value": "cow"
},
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
},
"description": "Search listings by text query"
}
},
{
"name": "Near Me Search",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/listings/near-me?lat=18.5204&lng=73.8567&radius_meters=100000&limit=20",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings",
"near-me"
],
"query": [
{
"key": "lat",
"value": "18.5204"
},
{
"key": "lng",
"value": "73.8567"
},
{
"key": "radius_meters",
"value": "100000"
},
{
"key": "limit",
"value": "20"
}
]
},
"description": "Find listings near a specific location"
}
},
{
"name": "Get Species",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/listings/species",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings",
"species"
]
},
"description": "Get all available species"
}
},
{
"name": "Get Breeds",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/listings/breeds?species_id=your-species-uuid",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"listings",
"breeds"
],
"query": [
{
"key": "species_id",
"value": "your-species-uuid",
"description": "Optional: Filter breeds by species"
}
]
},
"description": "Get all available breeds (optionally filtered by species)"
}
}
]
},
{
"name": "Locations",
"item": [
{
"name": "Create Location",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"user_id\": \"aaf295cb-a19e-4179-a2df-31c0c64ea9f4\",\n \"lat\": 18.15,\n \"lng\": 74.5833,\n \"country\": \"India\",\n \"state\": \"Maharashtra\",\n \"district\": \"Pune\",\n \"city_village\": \"Baramati\",\n \"pincode\": \"413102\",\n \"location_type\": \"farm\",\n \"is_saved_address\": true,\n \"source_type\": \"manual\",\n \"source_confidence\": \"high\"\n}"
},
"url": {
"raw": "http://localhost:3200/locations",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"locations"
]
},
"description": "Create a new location. user_id is optional for captured locations but required for saved addresses."
}
},
{
"name": "Get User Locations",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/locations/user/:userId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"locations",
"user",
":userId"
],
"variable": [
{
"key": "userId",
"value": "aaf295cb-a19e-4179-a2df-31c0c64ea9f4"
}
]
},
"description": "Get all locations for a specific user"
}
},
{
"name": "Get Location by ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/locations/:locationId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"locations",
":locationId"
],
"variable": [
{
"key": "locationId",
"value": "your-location-uuid-here"
}
]
},
"description": "Get a single location by ID"
}
},
{
"name": "Update Location",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"city_village\": \"Updated City Name\",\n \"pincode\": \"413103\"\n}"
},
"url": {
"raw": "http://localhost:3200/locations/:locationId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"locations",
":locationId"
],
"variable": [
{
"key": "locationId",
"value": "your-location-uuid-here"
}
]
},
"description": "Update location information"
}
}
]
},
{
"name": "Chat",
"item": [
{
"name": "Create/Get Conversation",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"buyer_id\": \"buyer-uuid-here\",\n \"seller_id\": \"seller-uuid-here\"\n}"
},
"url": {
"raw": "http://localhost:3200/chat/conversations",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"chat",
"conversations"
]
},
"description": "Create a new conversation or get existing one between buyer and seller"
}
},
{
"name": "Get User Conversations",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/chat/conversations/user/:userId",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"chat",
"conversations",
"user",
":userId"
],
"variable": [
{
"key": "userId",
"value": "aaf295cb-a19e-4179-a2df-31c0c64ea9f4"
}
]
},
"description": "Get all conversations for a specific user"
}
},
{
"name": "Get Messages",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://localhost:3200/chat/conversations/:conversationId/messages?limit=50&offset=0",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"chat",
"conversations",
":conversationId",
"messages"
],
"query": [
{
"key": "limit",
"value": "50"
},
{
"key": "offset",
"value": "0"
}
],
"variable": [
{
"key": "conversationId",
"value": "your-conversation-uuid-here"
}
]
},
"description": "Get messages for a conversation"
}
},
{
"name": "Send Message",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"conversation_id\": \"conversation-uuid-here\",\n \"sender_id\": \"sender-uuid-here\",\n \"receiver_id\": \"receiver-uuid-here\",\n \"content\": \"Hello, I'm interested in your listing.\",\n \"message_type\": \"text\"\n}"
},
"url": {
"raw": "http://localhost:3200/chat/messages",
"protocol": "http",
"host": [
"localhost"
],
"port": "3200",
"path": [
"chat",
"messages"
]
},
"description": "Send a message in a conversation"
}
}
]
}
],
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:3200",
"type": "string"
}
]
}

143
POSTMAN_TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,143 @@
# Postman Testing Guide for BuySellService API
## 📥 How to Import the Collection
1. **Open Postman**
2. Click **Import** button (top left)
3. Select **File** tab
4. Choose `BuySellService_API.postman_collection.json`
5. Click **Import**
The collection will appear in your Postman sidebar with all endpoints organized by category.
## 🚀 Quick Start Testing
### Step 1: Create a User (Required First Step)
Before creating listings or locations, you need to create a user:
1. Go to **Users** → **Create User**
2. The request body is pre-filled with the UUID from your error: `aaf295cb-a19e-4179-a2df-31c0c64ea9f4`
3. Click **Send**
4. You should get a 201 response with the created user
**Request Body:**
```json
{
"id": "aaf295cb-a19e-4179-a2df-31c0c64ea9f4",
"name": "Test User",
"phone_number": "+919876543210",
"avatar_url": null,
"language": "en",
"timezone": "Asia/Kolkata",
"country_code": "+91"
}
```
### Step 2: Get Species and Breeds
Before creating a listing, you need to know the UUIDs of species and breeds:
1. Go to **Listings** → **Get Species**
2. Click **Send** - This will show you all available species with their UUIDs
3. Copy a species UUID
4. Go to **Listings** → **Get Breeds**
5. Add the species UUID as a query parameter: `?species_id=your-species-uuid`
6. Click **Send** - This will show breeds for that species
7. Copy a breed UUID
### Step 3: Create a Location (Optional)
1. Go to **Locations** → **Create Location**
2. Update the `user_id` in the request body to your user UUID
3. Modify other fields as needed
4. Click **Send**
### Step 4: Create a Listing
1. Go to **Listings** → **Create Listing**
2. Update the request body:
- `seller_id`: Use your user UUID
- `species_id`: Use the species UUID from Step 2
- `breed_id`: Use the breed UUID from Step 2
3. Click **Send**
## 📋 Endpoint Categories
### 👤 Users
- **Create User** - Create a new user (with optional UUID)
- **Get All Users** - List all users
- **Get User by ID** - Get specific user details
- **Update User** - Update user information
### 📋 Listings
- **Get All Listings** - List all active listings
- **Get Listing by ID** - Get specific listing with full details
- **Create Listing** - Create a new listing with animal details
- **Update Listing** - Update listing information
- **Search Listings** - Search by text query
- **Near Me Search** - Find listings near coordinates
- **Get Species** - Get all available species
- **Get Breeds** - Get all breeds (optionally filtered by species)
### 📍 Locations
- **Create Location** - Create a location (user_id optional for captured locations)
- **Get User Locations** - Get all locations for a user
- **Get Location by ID** - Get specific location
- **Update Location** - Update location information
### 💬 Chat
- **Create/Get Conversation** - Create or get existing conversation
- **Get User Conversations** - Get all conversations for a user
- **Get Messages** - Get messages in a conversation
- **Send Message** - Send a message
## 🔧 Important Notes
### UUIDs Required
- All IDs in this API are **UUIDs**, not integers
- Make sure to use valid UUID format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
### User ID Requirements
- **For Listings**: `seller_id` must exist in users table
- **For Locations**:
- `user_id` is **optional** if `is_saved_address = false` (captured location)
- `user_id` is **required** if `is_saved_address = true` (saved address)
### Testing Order
1. ✅ Create User first
2. ✅ Get Species and Breeds
3. ✅ Create Location (optional)
4. ✅ Create Listing
## 🐛 Common Errors
### "User does not exist"
- **Solution**: Create the user first using the **Create User** endpoint
### "Invalid input syntax for type uuid"
- **Solution**: Make sure you're using UUIDs, not integers. Check species_id and breed_id are UUIDs.
### "Foreign key constraint violation"
- **Solution**: Ensure all referenced IDs (user_id, species_id, breed_id) exist in their respective tables
## 💡 Tips
1. **Use Environment Variables**: Create a Postman environment with:
- `baseUrl`: `http://localhost:3200`
- `userId`: Your user UUID
- `speciesId`: A species UUID
- `breedId`: A breed UUID
2. **Save Responses**: After creating resources, save the returned UUIDs for use in other requests
3. **Test in Order**: Follow the testing order above to avoid foreign key errors
4. **Check Server**: Make sure your server is running on port 3200 before testing
## 🔗 Base URL
All endpoints use: `http://localhost:3200`
If your server runs on a different port, update the base URL in the collection variables.

132
SETUP_AUTH.md Normal file
View File

@ -0,0 +1,132 @@
# Quick Setup Guide - Authentication & Authorization
## 1. Install Dependencies
```bash
cd Backend
npm install
```
This will install:
- `jsonwebtoken` - For JWT token validation
- `axios` - For optional auth service API calls
- `redis` - For distributed rate limiting (optional)
## 2. Configure Environment Variables
The JWT secret **must match** the auth service secret:
```env
# Copy from farm-auth-service/.env
JWT_ACCESS_SECRET=add74b258202057143382e8ee9ecc24a1114eddd3da5db79f3d29d24d7083043
```
Create `.env` file in `Backend/` directory:
```env
PORT=3200
TRUST_PROXY=false
# Auth Service Configuration (REQUIRED)
# BuySellService calls this to validate tokens
AUTH_SERVICE_URL=http://localhost:3000
AUTH_SERVICE_TIMEOUT=5000
# Optional: Redis for rate limiting (if not set, uses in-memory)
# REDIS_URL=redis://localhost:6379
```
## 3. Start the Server
```bash
npm start
```
Server will start on `http://localhost:3200`
## 4. Test the Implementation
### Test from Android App
The Android app's `getUserById` function will:
1. Send JWT token in `Authorization: Bearer <token>` header
2. Call `GET http://localhost:3200/users/:userId`
3. Backend validates token → applies rate limiting → checks authorization → returns user data
### Test with cURL
```bash
# 1. Get token from auth service (via login)
TOKEN="your_access_token_from_login"
# 2. Test GET /users/:userId
curl -X GET http://localhost:3200/users/YOUR_USER_ID \
-H "Authorization: Bearer $TOKEN"
# Expected: Returns user data in JSON format
```
### Test Error Cases
```bash
# No token (should return 401)
curl -X GET http://localhost:3200/users/YOUR_USER_ID
# Response: {"error":"Unauthorized","message":"Missing Authorization header..."}
# Invalid token (should return 401)
curl -X GET http://localhost:3200/users/YOUR_USER_ID \
-H "Authorization: Bearer invalid_token"
# Response: {"error":"Unauthorized","message":"Invalid or expired token"}
# Access other user's profile (should return 403 if not admin)
curl -X GET http://localhost:3200/users/OTHER_USER_ID \
-H "Authorization: Bearer $TOKEN"
# Response: {"error":"Forbidden","message":"Cannot access other users' data"}
```
## 5. Verify End-to-End Flow
1. **Login via Auth Service** → Get JWT token
2. **Android app calls** `GET /users/:userId` with token
3. **Backend validates** token → ✅ Authenticated
4. **Backend checks rate limit** → ✅ Under limit
5. **Backend checks authorization** → ✅ User can access their own profile
6. **Backend fetches user** from database
7. **Backend returns** JSON response
8. **Android app displays** JSON in dialog
## 6. Monitor Logs
All requests are logged with:
- User ID
- Action
- Route
- Status (success/failed/forbidden)
- Timestamp
Check console output for audit logs:
```
[AUDIT] {"timestamp":"...","userId":"...","action":"get_user","route":"/users/...","status":"success",...}
```
## Troubleshooting
### Issue: 401 Unauthorized
- Check that `AUTH_SERVICE_URL` is correct and auth service is running
- Verify token is valid and not expired
- Check token is sent in correct format: `Authorization: Bearer <token>`
- Check auth service logs for validation errors
### Issue: 403 Forbidden
- User is trying to access another user's data
- Check user role matches required roles for route
- Admins can access any user data
### Issue: 429 Too Many Requests
- Rate limit exceeded
- Wait for rate limit window to reset
- Check `X-RateLimit-Reset` header for reset time
### Issue: Redis Connection Errors
- Redis is optional - rate limiting will use in-memory store if Redis unavailable
- Check `REDIS_URL` in `.env` if you want to use Redis

View File

@ -0,0 +1,17 @@
export default class ApiBuilder {
constructor(common){
this.app=common.app;
this.middlewares=[...common.middlewares];
}
method(m){ this.m=m.toLowerCase(); return this; }
url(u){ this.path=u.build(); return this; }
schema(s){ this.schemaBuilder=s; return this; }
sync(){ return this; }
build(dbClient){
this.app[this.m](
this.path,
...this.middlewares,
(req,res)=>this.schemaBuilder.execute(req,res,dbClient)
);
}
}

View File

@ -0,0 +1,5 @@
export default class CommonApiBuilder {
constructor(app){ this.app=app; this.middlewares=[]; }
use(mw){ this.middlewares.push(mw.middleware()); return this; }
build(){ return { app:this.app, middlewares:[...this.middlewares] }; }
}

View File

@ -0,0 +1,9 @@
export default class QueryBuilder {
constructor(){ this.query={}; }
fromJSON(q){ this.query=q; return this; }
select(cfg){ this.query={ op:'SELECT', ...cfg }; return this; }
insert(cfg){ this.query={ op:'INSERT', ...cfg }; return this; }
update(cfg){ this.query={ op:'UPDATE', ...cfg }; return this; }
delete(cfg){ this.query={ op:'DELETE', ...cfg }; return this; }
async execute(dbClient){ return dbClient.execute(this.query); }
}

View File

@ -0,0 +1,156 @@
import PayloadValidator from '../validation/PayloadValidator.js';
import QueryBuilder from './QueryBuilder.js';
export default class SchemaBuilder {
constructor() {
this.errorSchemas = new Map();
}
requestSchema(s) { this.reqSchema = s; return this; }
responseSchema(s) { this.resSchema = s; return this; }
requestTranslator(f) { this.reqT = f; return this; }
responseTranslator(f) { this.resT = f; return this; }
preProcess(f) { this.pre = f; return this; }
postProcess(f) { this.post = f; return this; }
onError(c, s) { this.errorSchemas.set(c, s); return this; }
defaultError(s) { this.defErr = s; return this; }
pathSchema(schema) { this._pathSchema = schema; return this; }
querySchema(schema) { this._querySchema = schema; return this; }
headerSchema(schema) { this._headerSchema = schema; return this; }
getErrorSchema(statusCode) {
return this.errorSchemas.has(statusCode)
? this.errorSchemas.get(statusCode)
: this.defErr;
}
log(req, level, message, meta = {}) {
const requestId = req?.requestId || 'unknown';
console[level](
`[SchemaBuilder] [${level.toUpperCase()}] [req:${requestId}] ${message}`,
Object.keys(meta).length ? meta : ''
);
}
async execute(req, res, dbClient) {
const start = Date.now();
try {
this.log(req, 'info', 'Execution started');
// -----------------------------
// PATH PARAM VALIDATION
// -----------------------------
if (this._pathSchema) {
this.log(req, 'debug', 'Validating path params');
const vPath = PayloadValidator.validate(this._pathSchema, req.params);
if (!vPath.valid) {
this.log(req, 'warn', 'Path validation failed', vPath);
return res.status(400).json(vPath);
}
}
// -----------------------------
// QUERY PARAM VALIDATION
// -----------------------------
if (this._querySchema) {
this.log(req, 'debug', 'Validating query params');
const vQuery = PayloadValidator.validate(this._querySchema, req.query);
if (!vQuery.valid) {
this.log(req, 'warn', 'Query validation failed', vQuery);
return res.status(400).json(vQuery);
}
}
// -----------------------------
// HEADER VALIDATION
// -----------------------------
if (this._headerSchema) {
this.log(req, 'debug', 'Validating headers');
const vHeader = PayloadValidator.validate(this._headerSchema, req.headers);
if (!vHeader.valid) {
this.log(req, 'warn', 'Header validation failed', vHeader);
return res.status(400).json(vHeader);
}
}
// -----------------------------
// BODY VALIDATION
// -----------------------------
if (this.reqSchema) {
this.log(req, 'debug', 'Validating request body');
const vReq = PayloadValidator.validate(this.reqSchema, req.body);
if (!vReq.valid) {
this.log(req, 'warn', 'Body validation failed', vReq);
return res.status(400).json(vReq);
}
}
// -----------------------------
// PRE-PROCESS
// -----------------------------
if (this.pre) {
this.log(req, 'debug', 'Running pre-process hook');
const c = await this.pre(req.headers, req.params, req.query, req.body);
if (c !== 200) {
this.log(req, 'warn', 'Pre-process failed', { status: c });
return res.status(c).json(this.getErrorSchema(c));
}
}
// -----------------------------
// REQUEST TRANSLATION
// -----------------------------
this.log(req, 'debug', 'Translating request to query');
const q = this.reqT(req.headers, req.params, req.query, req.body);
// -----------------------------
// DB EXECUTION
// -----------------------------
this.log(req, 'info', 'Executing DB query', { type: q?.type });
const qb = new QueryBuilder().fromJSON(q);
const dbResp = await qb.execute(dbClient);
// -----------------------------
// RESPONSE TRANSLATION
// -----------------------------
this.log(req, 'debug', 'Translating DB response');
const resp = this.resT(dbResp, this.resSchema);
// -----------------------------
// RESPONSE VALIDATION
// -----------------------------
this.log(req, 'debug', 'Validating response');
const vResp = PayloadValidator.validate(this.resSchema, resp);
if (!vResp.valid) {
this.log(req, 'error', 'Response validation failed', vResp);
return res.status(500).json(vResp);
}
// -----------------------------
// POST-PROCESS
// -----------------------------
if (this.post) {
this.log(req, 'debug', 'Running post-process hook');
const c = await this.post(req, resp);
if (c !== 200) {
this.log(req, 'warn', 'Post-process failed', { status: c });
return res.status(c).json(this.getErrorSchema(c));
}
}
const duration = Date.now() - start;
this.log(req, 'info', 'Execution completed', { durationMs: duration });
res.json(resp);
} catch (e) {
this.log(req, 'error', 'Unhandled exception', {
message: e.message,
stack: e.stack
});
res.status(500).json(this.defErr);
}
}
}

View File

@ -0,0 +1,33 @@
export default class UrlBuilder {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.pathParams = {};
this.queryParams = {};
this.headerParams = {};
}
withPathParams(params = {}) {
this.pathParams = params;
return this;
}
withQueryParams(params = {}) {
this.queryParams = params;
return this;
}
withHeaderParams(params = {}) {
this.headerParams = params;
return this;
}
build() {
let path = this.baseUrl;
for (const key of Object.keys(this.pathParams)) {
path += `/:${key}`;
}
return path;
}
}

70
core/db/client.js Normal file
View File

@ -0,0 +1,70 @@
import knex from 'knex';
import 'dotenv/config';
import executeJsonQuery from './jsonQueryExecutor.js';
/**
* Knex configuration
* Uses environment variables with sane defaults
*/
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 knexInstance = knex(config);
const dbClient = {
async execute(query) {
if (!query || typeof query !== 'object') {
throw new Error('Invalid query object');
}
switch (query.type) {
case 'json': {
return await executeJsonQuery(knexInstance, query);
}
case 'raw-builder': {
if (typeof query.handler !== 'function') {
throw new Error('raw-builder requires a handler function');
}
return await query.handler(knexInstance);
}
case 'transaction': {
if (typeof query.handler !== 'function') {
throw new Error('transaction requires a handler function');
}
return await knexInstance.transaction(async (trx) => {
return await query.handler(trx);
});
}
default:
throw new Error(`Unsupported query type: ${query.type}`);
}
},
getKnex() {
return knexInstance;
},
async destroy() {
await knexInstance.destroy();
}
};
export default dbClient;

View File

@ -0,0 +1,107 @@
/**
* Execute a JSON-based query using Knex
*
* Supported ops:
* SELECT | INSERT | UPDATE | DELETE
*/
export default function executeJsonQuery(knex, query) {
const {
op,
table,
columns,
values,
where,
orderBy,
limit,
offset
} = query;
if (!op || !table) {
throw new Error('JSON query must include "op" and "table"');
}
let qb;
switch (op) {
// ----------------------------------
// SELECT
// ----------------------------------
case 'SELECT': {
qb = knex(table);
if (Array.isArray(columns) && columns.length > 0) {
qb.select(columns);
} else {
qb.select('*');
}
if (where && typeof where === 'object') {
qb.where(where);
}
if (Array.isArray(orderBy)) {
for (const { column, direction } of orderBy) {
qb.orderBy(column, direction || 'asc');
}
}
if (Number.isInteger(limit)) {
qb.limit(limit);
}
if (Number.isInteger(offset)) {
qb.offset(offset);
}
return qb;
}
// ----------------------------------
// INSERT
// ----------------------------------
case 'INSERT': {
if (!values || typeof values !== 'object') {
throw new Error('INSERT requires "values"');
}
return knex(table)
.insert(values)
.returning(columns || '*');
}
// ----------------------------------
// UPDATE
// ----------------------------------
case 'UPDATE': {
if (!values || typeof values !== 'object') {
throw new Error('UPDATE requires "values"');
}
if (!where || typeof where !== 'object') {
throw new Error('UPDATE without WHERE is not allowed');
}
return knex(table)
.where(where)
.update(values)
.returning(columns || '*');
}
// ----------------------------------
// DELETE
// ----------------------------------
case 'DELETE': {
if (!where || typeof where !== 'object') {
throw new Error('DELETE without WHERE is not allowed');
}
return knex(table)
.where(where)
.del();
}
default:
throw new Error(`Unsupported JSON op: ${op}`);
}
}

View File

@ -0,0 +1,3 @@
export default class BaseMiddleware {
middleware(){ throw new Error('middleware() not implemented'); }
}

View File

@ -0,0 +1,11 @@
import BaseMiddleware from './BaseMiddleware.js';
export default class CoarseAuthMiddleware extends BaseMiddleware {
constructor(roles=[]){ super(); this.roles=roles; }
middleware(){
return (req,res,next)=>{
if(!this.roles.includes(req.user?.role))
return res.status(403).json({ error:'FORBIDDEN' });
next();
};
}
}

View File

@ -0,0 +1,11 @@
import BaseMiddleware from './BaseMiddleware.js';
export default class FineAuthMiddleware extends BaseMiddleware {
constructor({ getResourceOwnerId }){ super(); this.getResourceOwnerId=getResourceOwnerId; }
middleware(){
return (req,res,next)=>{
if(req.user.role==='ADMIN') return next();
if(req.user.userId===this.getResourceOwnerId(req)) return next();
res.status(403).json({ error:'FORBIDDEN' });
};
}
}

View File

@ -0,0 +1,22 @@
import axios from 'axios';
import BaseMiddleware from './BaseMiddleware.js';
export default class JwtAuthMiddleware extends BaseMiddleware {
constructor(options={}){
super();
this.authServiceUrl=options.authServiceUrl||'http://auth-service:3000/auth/validate-token';
}
middleware(){
return async (req,res,next)=>{
const h=req.headers.authorization;
if(!h) return res.status(401).json({ error:'UNAUTHORIZED' });
try{
const token=h.replace('Bearer ','');
const r=await axios.post(this.authServiceUrl,{ token });
req.user=r.data;
next();
}catch{
res.status(401).json({ error:'INVALID_TOKEN' });
}
};
}
}

View File

@ -0,0 +1,5 @@
import BaseMiddleware from './BaseMiddleware.js';
export default class RateLimiterMiddleware extends BaseMiddleware {
constructor(createLimiterFn){ super(); this.createLimiterFn=createLimiterFn; }
middleware(){ return this.createLimiterFn(); }
}

View File

@ -0,0 +1,7 @@
export default {
STRING: 'string',
NUMBER: 'number',
ARRAY: 'Array',
JSON_OBJECT: 'JSONObject',
JSON_ARRAY: 'JSONArray'
};

View File

@ -0,0 +1,74 @@
import fs from 'fs';
import path from 'path';
export default class SchemaFileLoader {
/**
* Load a schema JSON file and resolve all nested json_dtype references
*
* @param {string} filePath - Absolute or relative path to schema file
* @param {Set<string>} visited - Internal circular reference protection
* @returns {object} Fully resolved schema JSON
*/
static load(filePath, visited = new Set()) {
const absolutePath = path.resolve(filePath);
if (visited.has(absolutePath)) {
throw new Error(`Circular schema reference detected: ${absolutePath}`);
}
visited.add(absolutePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Schema file not found: ${absolutePath}`);
}
const raw = fs.readFileSync(absolutePath, 'utf-8');
const schema = JSON.parse(raw);
const baseDir = path.dirname(absolutePath);
return this._resolveSchema(schema, baseDir, visited);
}
/**
* Recursively resolve json_dtype references
*/
static _resolveSchema(schema, baseDir, visited) {
if (typeof schema !== 'object' || schema === null) return schema;
for (const field in schema) {
const rule = schema[field];
if (!rule || typeof rule !== 'object') continue;
// Resolve nested JSON object schema
if (
rule.type === 'JSONObject' ||
rule.type === 'JSONArray'
) {
if (typeof rule.json_dtype === 'string') {
const nestedPath = path.join(baseDir, rule.json_dtype);
rule.json_dtype = this.load(nestedPath, visited);
} else if (typeof rule.json_dtype === 'object') {
rule.json_dtype = this._resolveSchema(
rule.json_dtype,
baseDir,
visited
);
}
}
// Resolve array of JSON objects
if (
rule.type === 'Array' &&
rule.array_dtype === 'JSONObject'
) {
if (typeof rule.json_dtype === 'string') {
const nestedPath = path.join(baseDir, rule.json_dtype);
rule.json_dtype = this.load(nestedPath, visited);
}
}
}
return schema;
}
}

View File

@ -0,0 +1,39 @@
import PayloadTypes from '../types/PayloadTypes.js';
export default class PayloadValidator {
static validate(schema, payload, path = '') {
for (const field in schema) {
const rule = schema[field];
const value = payload[field];
const p = path ? `${path}.${field}` : field;
if (rule.is_mandatory_field && value === undefined) {
return { valid:false, error:`Missing field ${p}` };
}
if (value === undefined) continue;
switch (rule.type) {
case PayloadTypes.STRING:
case PayloadTypes.NUMBER:
if (typeof value !== rule.type) return { valid:false, error:`Invalid type ${p}` };
break;
case PayloadTypes.ARRAY:
case PayloadTypes.JSON_ARRAY:
if (!Array.isArray(value)) return { valid:false, error:`Invalid array ${p}` };
if (rule.array_dtype) {
for (const v of value) {
if (typeof v !== rule.array_dtype)
return { valid:false, error:`Invalid array dtype ${p}` };
}
}
break;
case PayloadTypes.JSON_OBJECT:
if (typeof value !== 'object' || Array.isArray(value))
return { valid:false, error:`Invalid object ${p}` };
if (rule.json_dtype) {
const nested = this.validate(rule.json_dtype, value, p);
if (!nested.valid) return nested;
}
break;
}
}
return { valid:true };
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

View File

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

View File

@ -0,0 +1,89 @@
export default function listingRequestTranslator(
headerParams,
pathParams,
queryParams,
body
) {
const {
id
} = pathParams || {};
const {
limit = 20,
offset = 0,
species_id,
breed_id,
sex,
min_price,
max_price,
status
} = queryParams || {};
// -----------------------------
// BASE WHERE CLAUSE
// -----------------------------
const where = {
deleted: false
};
// -----------------------------
// PATH FILTER (single listing)
// -----------------------------
if (id) {
where.id = id;
}
// -----------------------------
// FILTERS (denormalized columns)
// -----------------------------
if (species_id) {
where.filter_species_id = species_id;
}
if (breed_id) {
where.filter_breed_id = breed_id;
}
if (sex) {
where.filter_sex = sex;
}
if (status) {
where.status = status;
}
// -----------------------------
// BUILD QUERY JSON
// -----------------------------
const query = {
type: 'json',
op: 'SELECT',
table: 'listings',
where,
orderBy: [
{ column: 'created_at', direction: 'desc' }
],
limit: Math.min(Number(limit), 100),
offset: Number(offset)
};
// -----------------------------
// PRICE RANGE (handled separately)
// -----------------------------
// JSON executor extension point:
// price range is encoded as a special where clause
if (min_price !== undefined || max_price !== undefined) {
query.whereRange = {
column: 'price',
min: min_price !== undefined ? Number(min_price) : undefined,
max: max_price !== undefined ? Number(max_price) : undefined
};
}
return query;
}

View File

@ -0,0 +1,53 @@
export default function listingResponseTranslator(dbResp, respSchema) {
// -----------------------------
// Normalize DB response
// -----------------------------
const rows = Array.isArray(dbResp)
? dbResp
: dbResp
? [dbResp]
: [];
// -----------------------------
// Pick only fields defined in response schema
// -----------------------------
const allowedFields = respSchema
? Object.keys(respSchema)
: null;
const normalizeRow = (row) => {
if (!row || typeof row !== 'object') return null;
// If schema exists, filter fields
if (allowedFields) {
const filtered = {};
for (const key of allowedFields) {
if (row[key] !== undefined) {
filtered[key] = row[key];
}
}
return filtered;
}
// Fallback (should not happen normally)
return row;
};
// -----------------------------
// Map rows
// -----------------------------
const result = rows
.map(normalizeRow)
.filter(Boolean);
// -----------------------------
// Return shape
// -----------------------------
// If schema represents a single object, return object
if (!Array.isArray(respSchema)) {
return result.length === 1 ? result[0] : result;
}
return result;
}

View File

@ -0,0 +1,171 @@
// middleware/coarseAuthorize.js
/**
* Coarse-Grained Authorization Middleware (Route Level)
*
* Blocks access if user's role doesn't match required roles for the route
* Returns 403 Forbidden if unauthorized
*
* Route-to-role mapping:
* /admin/* ADMIN
* /users/* USER, ADMIN
* /orders/* USER, ADMIN
* etc.
*/
/**
* Route-to-Role Mapping Configuration
* Maps route patterns to allowed roles
*/
const ROUTE_ROLE_MAP = {
// Admin routes require ADMIN role
'/admin': ['ADMIN'],
'/admin/*': ['ADMIN'],
// User routes allow USER and ADMIN
'/users': ['USER', 'ADMIN'],
'/users/*': ['USER', 'ADMIN'],
// Order routes allow USER and ADMIN
'/orders': ['USER', 'ADMIN'],
'/orders/*': ['USER', 'ADMIN'],
// Listing routes allow USER and ADMIN
'/listings': ['USER', 'ADMIN'],
'/listings/*': ['USER', 'ADMIN'],
// Location routes allow USER and ADMIN
'/locations': ['USER', 'ADMIN'],
'/locations/*': ['USER', 'ADMIN'],
// Chat routes allow USER and ADMIN
'/chat': ['USER', 'ADMIN'],
'/chat/*': ['USER', 'ADMIN'],
// Default: Allow all authenticated users
'*': ['USER', 'ADMIN'],
};
/**
* Get required roles for a route
*/
function getRequiredRoles(path) {
// Check exact matches first
if (ROUTE_ROLE_MAP[path]) {
return ROUTE_ROLE_MAP[path];
}
// Check wildcard patterns
for (const [pattern, roles] of Object.entries(ROUTE_ROLE_MAP)) {
if (pattern.includes('*')) {
const regexPattern = pattern.replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(path)) {
return roles;
}
}
}
// Default: allow all authenticated users
return ROUTE_ROLE_MAP['*'] || ['USER', 'ADMIN'];
}
/**
* Normalize role to standard format
*/
function normalizeRole(role) {
if (!role) return null;
// Map common role variations to standard roles
const roleMap = {
'user': 'USER',
'admin': 'ADMIN',
'USER': 'USER',
'ADMIN': 'ADMIN',
'security_admin': 'ADMIN',
'moderator': 'ADMIN',
};
return roleMap[role.toUpperCase()] || role.toUpperCase();
}
/**
* Coarse-Grained Authorization Middleware Factory
*
* @param {string[]} allowedRoles - Roles allowed to access this route (optional, uses route mapping if not provided)
* @returns {Function} Express middleware
*/
function createCoarseAuthorize(allowedRoles = null) {
return function coarseAuthorize(req, res, next) {
try {
console.log(`[Coarse Auth] Starting authorization check for ${req.method} ${req.path}`);
// User should be set by jwtAuthenticate middleware
if (!req.user || !req.user.userId) {
console.log(`[Coarse Auth] ❌ FAILED: No user found in request. req.user:`, req.user);
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
console.log(`[Coarse Auth] User found:`, {
userId: req.user.userId,
role: req.user.role,
userType: req.user.userType,
});
const userRole = normalizeRole(req.user.role);
// Determine required roles for this route
const requiredRoles = allowedRoles || getRequiredRoles(req.path);
const normalizedRequiredRoles = requiredRoles.map(normalizeRole);
console.log(`[Coarse Auth] Role check:`, {
userRole,
requiredRoles: normalizedRequiredRoles,
routePath: req.path,
});
// Check if user's role is in the allowed roles list
if (!normalizedRequiredRoles.includes(userRole)) {
console.log(`[Coarse Auth] ❌ FAILED: User role "${userRole}" not in required roles:`, normalizedRequiredRoles);
// Log authorization failure
if (req.auditLogger) {
req.auditLogger.log({
userId: req.user.userId,
action: 'authorization_failed',
route: req.path,
status: 'forbidden',
meta: {
userRole,
requiredRoles: normalizedRequiredRoles,
},
});
}
return res.status(403).json({
error: 'Forbidden',
message: `Access denied. Required roles: ${normalizedRequiredRoles.join(', ')}. Your role: ${userRole}`,
});
}
console.log(`[Coarse Auth] ✅ Authorization passed: User role "${userRole}" is allowed`);
next();
} catch (err) {
console.error('[Coarse Auth] ❌ Authorization error:', err);
return res.status(500).json({
error: 'Internal server error',
message: 'Authorization check failed',
});
}
};
}
/**
* Pre-configured authorization middlewares for common cases
*/
export const requireUser = createCoarseAuthorize(['USER', 'ADMIN']);
export const requireAdmin = createCoarseAuthorize(['ADMIN']);
export default createCoarseAuthorize;

220
middleware/fineAuthorize.js Normal file
View File

@ -0,0 +1,220 @@
// middleware/fineAuthorize.js
/**
* Fine-Grained Authorization Helpers
*
* Provides reusable authorization functions for business logic
* Checks if user can perform specific actions on resources
*/
/**
* Authorize action on resource
*
* @param {Object} params
* @param {Object} params.user - User object from req.user
* @param {string} params.action - Action being performed (e.g., 'read', 'write', 'delete', 'create')
* @param {string} params.resource - Resource type (e.g., 'order', 'user', 'listing')
* @param {string} params.resourceOwnerId - ID of the resource owner (optional)
* @param {Object} params.resourceData - Additional resource data for context (optional)
* @returns {Object} { authorized: boolean, reason?: string }
*/
export function authorizeAction({ user, action, resource, resourceOwnerId = null, resourceData = {} }) {
if (!user || !user.userId) {
return { authorized: false, reason: 'User not authenticated' };
}
const userRole = (user.role || '').toUpperCase();
const isAdmin = userRole === 'ADMIN';
// Admin can do everything
if (isAdmin) {
return { authorized: true };
}
// Resource-specific authorization rules
switch (resource.toLowerCase()) {
case 'user':
return authorizeUserAction({ user, action, resourceOwnerId });
case 'order':
return authorizeOrderAction({ user, action, resourceOwnerId, resourceData });
case 'listing':
return authorizeListingAction({ user, action, resourceOwnerId, resourceData });
case 'location':
return authorizeLocationAction({ user, action, resourceOwnerId });
default:
// Default: Users can access their own resources, admins can access all
if (resourceOwnerId && resourceOwnerId === user.userId) {
return { authorized: true };
}
return { authorized: false, reason: `Access denied for ${resource}` };
}
}
/**
* Authorize user-related actions
*/
function authorizeUserAction({ user, action, resourceOwnerId }) {
// Users can read their own profile
if (action === 'read' && resourceOwnerId === user.userId) {
return { authorized: true };
}
// Users can update their own profile
if (action === 'write' && resourceOwnerId === user.userId) {
return { authorized: true };
}
// Users cannot delete accounts (should go through special endpoint)
if (action === 'delete') {
return { authorized: false, reason: 'Account deletion requires special process' };
}
// Users cannot access other users' data
if (resourceOwnerId && resourceOwnerId !== user.userId) {
return { authorized: false, reason: 'Cannot access other users\' data' };
}
return { authorized: true };
}
/**
* Authorize order-related actions
*/
function authorizeOrderAction({ user, action, resourceOwnerId, resourceData }) {
// Users can create orders
if (action === 'create') {
// Verify user can create order for their tenantId if multi-tenant
if (resourceData.tenantId && resourceData.tenantId !== user.tenantId) {
return { authorized: false, reason: 'Cannot create order for different tenant' };
}
return { authorized: true };
}
// Users can read/update/delete their own orders
if (resourceOwnerId === user.userId) {
return { authorized: true };
}
// Users cannot access other users' orders
return { authorized: false, reason: 'Cannot access other users\' orders' };
}
/**
* Authorize listing-related actions
*/
function authorizeListingAction({ user, action, resourceOwnerId, resourceData }) {
// Users can create listings
if (action === 'create') {
return { authorized: true };
}
// Users can read all active listings (public)
if (action === 'read' && resourceData.isActive !== false) {
return { authorized: true };
}
// Users can update/delete their own listings
if ((action === 'write' || action === 'delete') && resourceOwnerId === user.userId) {
return { authorized: true };
}
// Users cannot modify other users' listings
if (resourceOwnerId && resourceOwnerId !== user.userId) {
return { authorized: false, reason: 'Cannot modify other users\' listings' };
}
return { authorized: true };
}
/**
* Authorize location-related actions
*/
function authorizeLocationAction({ user, action, resourceOwnerId }) {
// Users can manage their own locations
if (resourceOwnerId === user.userId) {
return { authorized: true };
}
// Users cannot access other users' locations
return { authorized: false, reason: 'Cannot access other users\' locations' };
}
/**
* Express middleware wrapper for fine-grained authorization
*
* Usage:
* app.get('/orders/:orderId',
* fineAuthorize({ action: 'read', resource: 'order', getResourceOwnerId: (req) => req.params.orderId }),
* handler
* )
*/
export function fineAuthorize({ action, resource, getResourceOwnerId, getResourceData }) {
return async function(req, res, next) {
try {
console.log(`[Fine Auth] Starting fine-grained authorization for ${req.method} ${req.path}`);
console.log(`[Fine Auth] Action: ${action}, Resource: ${resource}`);
console.log(`[Fine Auth] User:`, req.user ? {
userId: req.user.userId,
role: req.user.role,
} : 'NOT SET');
const resourceOwnerId = getResourceOwnerId ? await getResourceOwnerId(req) : null;
const resourceData = getResourceData ? await getResourceData(req) : {};
console.log(`[Fine Auth] Resource details:`, {
resourceOwnerId,
resourceData: Object.keys(resourceData),
});
const result = authorizeAction({
user: req.user,
action,
resource,
resourceOwnerId,
resourceData,
});
console.log(`[Fine Auth] Authorization result:`, {
authorized: result.authorized,
reason: result.reason || 'N/A',
});
if (!result.authorized) {
console.log(`[Fine Auth] ❌ FAILED: ${result.reason || 'Access denied'}`);
// Log authorization failure
if (req.auditLogger) {
req.auditLogger.log({
userId: req.user?.userId,
action: 'fine_authorization_failed',
route: req.path,
status: 'forbidden',
meta: {
action,
resource,
resourceOwnerId,
reason: result.reason,
},
});
}
return res.status(403).json({
error: 'Forbidden',
message: result.reason || 'Access denied',
});
}
console.log(`[Fine Auth] ✅ Authorization passed`);
next();
} catch (err) {
console.error('[Fine Auth] ❌ Fine authorization error:', err);
return res.status(500).json({
error: 'Internal server error',
message: 'Authorization check failed',
});
}
};
}

View File

@ -0,0 +1,162 @@
// middleware/jwtAuthenticate.js
/**
* JWT Authentication Middleware
*
* Validates JWT tokens by calling the auth service API
* Auth service handles all token validation (signature, expiry, issuer, audience, token_version)
* Extracts and attaches user information to req.user
*
* Flow:
* 1. Extract token from Authorization header
* 2. Call auth service /auth/validate-token endpoint
* 3. Auth service validates token and returns user info
* 4. Attach user info to req.user and continue
*/
import axios from 'axios';
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:3000';
const AUTH_SERVICE_TIMEOUT = parseInt(process.env.AUTH_SERVICE_TIMEOUT || '5000', 10); // 5 seconds default
/**
* Validate JWT token via auth service API
*/
async function validateTokenViaAuthService(token) {
try {
console.log(`[JWT Auth] Calling auth service to validate token...`);
const response = await axios.post(
`${AUTH_SERVICE_URL}/auth/validate-token`,
{ token },
{
timeout: AUTH_SERVICE_TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
}
);
console.log(`[JWT Auth] Auth service responded with status: ${response.status}`);
if (response.data.valid === true) {
console.log(`[JWT Auth] Auth service confirmed token is valid`);
return {
valid: true,
payload: response.data.payload,
};
} else {
console.log(`[JWT Auth] Auth service reported token as invalid:`, response.data.error);
return {
valid: false,
error: response.data.error || 'Token validation failed'
};
}
} catch (err) {
// Handle different error types
if (err.response) {
// Auth service returned an error response
console.error(`[JWT Auth] ❌ Auth service validation error:`, err.response.status, err.response.data);
return {
valid: false,
error: err.response.data?.error || 'Token validation failed'
};
} else if (err.request) {
// Request was made but no response received
console.error(`[JWT Auth] ❌ Auth service unavailable:`, err.message);
return {
valid: false,
error: 'Authentication service unavailable'
};
} else {
// Error setting up request
console.error(`[JWT Auth] ❌ Error calling auth service:`, err.message);
return {
valid: false,
error: 'Failed to validate token'
};
}
}
}
/**
* JWT Authentication Middleware
*
* Calls auth service to validate token and authorize the request
*/
async function jwtAuthenticate(req, res, next) {
console.log(`[JWT Auth] Starting authentication check for ${req.method} ${req.path}`);
console.log(`[JWT Auth] Headers received:`, {
authorization: req.headers.authorization ? 'Bearer <token>' : 'MISSING',
'content-type': req.headers['content-type'],
'user-agent': req.headers['user-agent']?.substring(0, 50),
});
// Extract token from Authorization header
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
console.log(`[JWT Auth] ❌ FAILED: No token found in Authorization header`);
console.log(`[JWT Auth] Auth header value: "${authHeader}"`);
return res.status(401).json({
error: 'Unauthorized',
message: 'Missing Authorization header. Expected format: Authorization: Bearer <token>'
});
}
console.log(`[JWT Auth] Token found (length: ${token.length}), validating via auth service...`);
console.log(`[JWT Auth] Auth service URL: ${AUTH_SERVICE_URL}/auth/validate-token`);
// Validate token via auth service API
const validationResult = await validateTokenViaAuthService(token);
if (!validationResult.valid) {
console.log(`[JWT Auth] ❌ FAILED: Token validation failed - ${validationResult.error}`);
// Log failed authentication attempt
if (req.auditLogger) {
req.auditLogger.logFailure('authenticate', validationResult.error || 'Token validation failed');
}
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or expired token',
details: validationResult.error
});
}
const payload = validationResult.payload;
console.log(`[JWT Auth] ✅ Token validated successfully`);
console.log(`[JWT Auth] Token payload:`, {
userId: payload.sub,
role: payload.role,
userType: payload.user_type,
phoneNumber: payload.phone_number ? '***' : null,
tokenVersion: payload.token_version,
});
// Extract user information from auth service response
req.user = {
userId: payload.sub, // Subject (user ID)
role: payload.role || 'user',
userType: payload.user_type || null,
phoneNumber: payload.phone_number || null,
tokenVersion: payload.token_version || 1,
highAssurance: payload.high_assurance || false,
// Add tenantId if present in payload (for multi-tenant apps)
tenantId: payload.tenant_id || null,
};
console.log(`[JWT Auth] ✅ User authenticated:`, {
userId: req.user.userId,
role: req.user.role,
userType: req.user.userType,
});
// Log successful authentication
if (req.auditLogger) {
req.auditLogger.logSuccess('authenticate', { userId: req.user.userId });
}
next();
}
export default jwtAuthenticate;

205
middleware/rateLimiter.js Normal file
View File

@ -0,0 +1,205 @@
// middleware/rateLimiter.js
/**
* Rate Limiting Middleware
*
* Rate limits requests per userId (preferred) or IP (fallback)
* Returns 429 Too Many Requests when limit exceeded
*
* Uses Redis if available, falls back to in-memory store
*/
import { createClient } from 'redis';
// In-memory fallback store
const memoryStore = new Map();
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of memoryStore.entries()) {
if (value.expiresAt < now) {
memoryStore.delete(key);
}
}
}, 60000); // Clean up every minute
// Redis client (lazy initialization)
let redisClient = null;
async function getRedisClient() {
if (redisClient) return redisClient;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
try {
redisClient = createClient({ url: REDIS_URL });
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
redisClient = null;
});
await redisClient.connect();
console.log('Redis connected for rate limiting');
return redisClient;
} catch (err) {
console.error('Failed to connect to Redis, using in-memory store:', err.message);
redisClient = null;
return null;
}
}
// Rate limit configuration
const RATE_LIMIT_CONFIG = {
// Default limits
DEFAULT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
DEFAULT_WINDOW_SECONDS: parseInt(process.env.RATE_LIMIT_WINDOW_SECONDS || '900', 10), // 15 minutes
// Per-route custom limits can be specified when creating middleware
};
/**
* Increment counter in Redis or memory store
*/
async function incrementCounter(key, ttlSeconds) {
const redis = await getRedisClient();
if (redis) {
try {
const count = await redis.incr(key);
if (count === 1) {
// First increment, set TTL
await redis.expire(key, ttlSeconds);
}
return count;
} catch (err) {
console.error('Redis increment error, falling back to memory:', err.message);
// Fall through to memory store
}
}
// Memory store fallback
const now = Date.now();
const stored = memoryStore.get(key);
if (stored && stored.expiresAt > now) {
stored.count++;
return stored.count;
} else {
memoryStore.set(key, {
count: 1,
expiresAt: now + (ttlSeconds * 1000),
});
return 1;
}
}
/**
* Get counter value
*/
async function getCounter(key) {
const redis = await getRedisClient();
if (redis) {
try {
const count = await redis.get(key);
return count ? parseInt(count, 10) : 0;
} catch (err) {
// Fall through to memory store
}
}
// Memory store fallback
const stored = memoryStore.get(key);
const now = Date.now();
if (stored && stored.expiresAt > now) {
return stored.count || 0;
}
return 0;
}
/**
* Rate Limiting Middleware Factory
*
* @param {Object} options - Rate limit options
* @param {number} options.maxRequests - Maximum requests per window (default: 100)
* @param {number} options.windowSeconds - Time window in seconds (default: 900 = 15 minutes)
* @param {string} options.type - Rate limit type for logging (default: 'default')
* @returns {Function} Express middleware
*/
function createRateLimiter(options = {}) {
const maxRequests = options.maxRequests || RATE_LIMIT_CONFIG.DEFAULT_MAX_REQUESTS;
const windowSeconds = options.windowSeconds || RATE_LIMIT_CONFIG.DEFAULT_WINDOW_SECONDS;
const type = options.type || 'default';
return async function rateLimiter(req, res, next) {
try {
// Determine rate limit key (prefer userId, fallback to IP)
const userId = req.user?.userId;
const clientIp = req.clientIp || 'unknown';
const key = userId
? `rate_limit:${type}:user:${userId}`
: `rate_limit:${type}:ip:${clientIp}`;
// Increment counter
const count = await incrementCounter(key, windowSeconds);
// Check if limit exceeded
if (count > maxRequests) {
// Log rate limit exceeded
if (req.auditLogger) {
req.auditLogger.log({
userId,
action: 'rate_limit_exceeded',
route: req.path,
status: 'blocked',
meta: { count, maxRequests, windowSeconds, type, key },
});
}
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Maximum ${maxRequests} requests per ${windowSeconds} seconds allowed.`,
retry_after: windowSeconds,
limit_type: type,
});
}
// Add rate limit headers
const remaining = Math.max(0, maxRequests - count);
const resetTime = new Date(Date.now() + (windowSeconds * 1000));
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetTime.toISOString());
res.setHeader('X-RateLimit-Type', type);
next();
} catch (err) {
console.error('Rate limiter error:', err);
// On error, allow the request to proceed (fail open)
// This ensures legitimate users aren't blocked if rate limiting fails
next();
}
};
}
// Export pre-configured rate limiters
export const rateLimiterRead = createRateLimiter({
maxRequests: parseInt(process.env.RATE_LIMIT_READ_MAX || '100', 10),
windowSeconds: parseInt(process.env.RATE_LIMIT_READ_WINDOW || '900', 10),
type: 'read',
});
export const rateLimiterWrite = createRateLimiter({
maxRequests: parseInt(process.env.RATE_LIMIT_WRITE_MAX || '20', 10),
windowSeconds: parseInt(process.env.RATE_LIMIT_WRITE_WINDOW || '900', 10),
type: 'write',
});
export const rateLimiterSensitive = createRateLimiter({
maxRequests: parseInt(process.env.RATE_LIMIT_SENSITIVE_MAX || '10', 10),
windowSeconds: parseInt(process.env.RATE_LIMIT_SENSITIVE_WINDOW || '3600', 10),
type: 'sensitive',
});
export default createRateLimiter;

View File

@ -0,0 +1,33 @@
// middleware/requestContext.js
/**
* Request Context Middleware
*
* Extracts and attaches request context information (IP, user agent, etc.)
* This should be the FIRST middleware in the chain
*/
function requestContext(req, res, next) {
// Extract client IP (considering proxies)
req.clientIp = req.ip ||
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection?.remoteAddress ||
'unknown';
// Extract user agent
req.userAgent = req.headers['user-agent'] || 'unknown';
// Extract request ID for tracing (if not already set)
req.requestId = req.headers['x-request-id'] ||
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Set request ID in response header
res.setHeader('X-Request-Id', req.requestId);
// Attach timestamp
req.requestTimestamp = new Date();
next();
}
export default requestContext;

View File

@ -0,0 +1,6 @@
{
"listingId": {
"is_mandatory_field": true,
"type": "number"
}
}

View File

@ -0,0 +1,46 @@
{
"seller_id": {
"is_mandatory_field": true,
"type": "string"
},
"animal_id": {
"is_mandatory_field": true,
"type": "string"
},
"title": {
"is_mandatory_field": true,
"type": "string"
},
"price": {
"is_mandatory_field": false,
"type": "number"
},
"currency": {
"is_mandatory_field": false,
"type": "string"
},
"is_negotiable": {
"is_mandatory_field": false,
"type": "boolean"
},
"listing_type": {
"is_mandatory_field": false,
"type": "string"
},
"status": {
"is_mandatory_field": false,
"type": "string"
},
"thumbnail_url": {
"is_mandatory_field": false,
"type": "string"
}
}

View File

@ -0,0 +1,10 @@
{
"phone": {
"is_mandatory_field": true,
"type": "string"
},
"email": {
"is_mandatory_field": false,
"type": "string"
}
}

View File

@ -0,0 +1,15 @@
{
"id": {
"is_mandatory_field": true,
"type": "number"
},
"name": {
"is_mandatory_field": true,
"type": "string"
},
"contacts": {
"is_mandatory_field": false,
"type": "JSONArray",
"json_dtype": "user_contacts.json"
}
}

View File

@ -0,0 +1,15 @@
{
"id": {
"is_mandatory_field": true,
"type": "number"
},
"name": {
"is_mandatory_field": true,
"type": "string"
},
"contacts": {
"is_mandatory_field": false,
"type": "JSONArray",
"json_dtype": "user_contacts.json"
}
}

407
package-lock.json generated
View File

@ -9,12 +9,15 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.7.9",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"firebase-admin": "^13.6.0",
"knex": "^3.1.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
"redis": "^4.7.0",
"socket.io": "^4.8.1"
}
},
@ -329,6 +332,66 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"optional": true
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@ -566,8 +629,55 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"optional": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axios/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/axios/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
@ -681,6 +791,15 @@
"node": ">=12"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -699,11 +818,16 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
"node_modules/colorette": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"optional": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@ -711,6 +835,15 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -779,7 +912,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"optional": true,
"engines": {
"node": ">=0.4.0"
}
@ -977,7 +1109,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"optional": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@ -992,7 +1123,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"optional": true,
"engines": {
"node": ">=6"
}
@ -1002,6 +1132,15 @@
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -1161,6 +1300,26 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
@ -1269,6 +1428,15 @@
"node": ">=14"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -1301,6 +1469,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@ -1313,6 +1490,12 @@
"node": ">= 0.4"
}
},
"node_modules/getopts": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==",
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
@ -1411,7 +1594,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"optional": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
@ -1531,6 +1713,15 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/interpret": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1539,6 +1730,21 @@
"node": ">= 0.10"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -1636,11 +1842,97 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/knex": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz",
"integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==",
"license": "MIT",
"dependencies": {
"colorette": "2.0.19",
"commander": "^10.0.0",
"debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
"get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
"pg-connection-string": "2.6.2",
"rechoir": "^0.8.0",
"resolve-from": "^5.0.0",
"tarn": "^3.0.2",
"tildify": "2.0.0"
},
"bin": {
"knex": "bin/cli.js"
},
"engines": {
"node": ">=16"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
},
"mysql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/knex/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/knex/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/knex/node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==",
"license": "MIT"
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@ -1893,6 +2185,12 @@
"node": ">= 0.8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
@ -1906,6 +2204,7 @@
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -2066,6 +2365,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -2116,6 +2421,35 @@
"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/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -2125,6 +2459,35 @@
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@ -2515,6 +2878,27 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"optional": true
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/teeny-request": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
@ -2569,6 +2953,15 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/tildify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@ -5,7 +5,9 @@
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js",
"dev": "node server.js"
},
"repository": {
"type": "git",
@ -18,12 +20,15 @@
},
"homepage": "https://github.com/schari2509/BuySellService_LivingAI#readme",
"dependencies": {
"axios": "^1.7.9",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"firebase-admin": "^13.6.0",
"knex": "^3.1.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
"redis": "^4.7.0",
"socket.io": "^4.8.1"
}
}

1108
public/index.html Normal file

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

70
routes/listings.route.js Normal file
View File

@ -0,0 +1,70 @@
import ApiBuilder from '../core/builders/ApiBuilder.js';
import UrlBuilder from '../core/builders/UrlBuilder.js';
import SchemaBuilder from '../core/builders/SchemaBuilder.js';
import SchemaFileLoader from '../core/utils/SchemaFileLoader.js';
import listingRequestTranslator from '../logic/listings/listingRequestTranslator.js';
import listingResponseTranslator from '../logic/listings/listingResponseTranslator.js';
export default function registerListingsApi(baseUrl, common, dbClient) {
// GET /listings (feed)
new ApiBuilder(common)
.method('get')
.url(new UrlBuilder(baseUrl))
.schema(
new SchemaBuilder()
.responseSchema(
SchemaFileLoader.load('./models/listings/listings_response.json')
)
.requestTranslator(listingRequestTranslator)
.responseTranslator(listingResponseTranslator)
.defaultError({ error: 'INTERNAL_ERROR' })
)
.sync()
.build(dbClient);
// GET /listings/:id
new ApiBuilder(common)
.method('get')
.url(new UrlBuilder(baseUrl).withPathParams(SchemaFileLoader.load('./models/listings/listing_request_path_param.json')))
.schema(
new SchemaBuilder()
.responseSchema(
SchemaFileLoader.load('./models/listings/listings_response.json')
)
.requestTranslator(listingRequestTranslator)
.responseTranslator(listingResponseTranslator)
.defaultError({ error: 'NOT_FOUND' })
)
.sync()
.build(dbClient);
// POST /listings (create)
new ApiBuilder(common)
.method('post')
.url(new UrlBuilder(baseUrl))
.schema(
new SchemaBuilder()
.requestSchema(
SchemaFileLoader.load('./models/listings/listings_response.json')
)
.responseSchema(
SchemaFileLoader.load('./models/listings/listings_response.json')
)
.requestTranslator(req => ({
type: 'transaction',
handler: async (trx) => {
const [listing] = await trx('listings')
.insert(req.body)
.returning('*');
return listing;
}
}))
.responseTranslator(result => result)
.defaultError({ error: 'CREATE_FAILED' })
)
.sync()
.build(dbClient);
};

View File

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

285
routes/userRoutes.js Normal file
View File

@ -0,0 +1,285 @@
import express from "express";
import { insert, select, update } from "../db/queryHelper/index.js";
import jwtAuthenticate from "../middleware/jwtAuthenticate.js";
import { rateLimiterRead, rateLimiterWrite } from "../middleware/rateLimiter.js";
import createCoarseAuthorize from "../middleware/coarseAuthorize.js";
import { fineAuthorize, authorizeAction } from "../middleware/fineAuthorize.js";
const router = express.Router();
// Apply authentication and rate limiting to all user routes
router.use(jwtAuthenticate);
router.use(rateLimiterRead); // Use read rate limiter for user routes
// Apply coarse-grained authorization (USER and ADMIN can access)
const requireUserOrAdmin = createCoarseAuthorize(['USER', 'ADMIN']);
router.use(requireUserOrAdmin);
// 1. CREATE User (Requires write rate limiter)
router.post("/", rateLimiterWrite, async (req, res) => {
console.log(`[User Route] POST /users - Request received`);
console.log(`[User Route] Authenticated user:`, req.user ? {
userId: req.user.userId,
role: req.user.role,
} : 'NOT AUTHENTICATED');
console.log(`[User Route] Request body:`, {
id: req.body?.id,
name: req.body?.name,
phone_number: req.body?.phone_number ? '***' : undefined,
});
try {
// Parse and extract user data from request body
const {
id, // Optional: if provided, use this UUID; otherwise generate one
name,
phone_number,
avatar_url,
language,
timezone,
country_code = "+91",
} = req.body;
// Validate required fields
if (!name || !phone_number) {
return res.status(400).json({
error: "name and phone_number are required fields",
});
}
// Build user data object using JSON-based structure
const userData = {
name: name.trim(),
phone_number: phone_number.trim(),
avatar_url: avatar_url || null,
language: language || null,
timezone: timezone || null,
country_code: country_code || "+91",
};
// If id is provided, include it; otherwise let the database generate one
if (id) {
userData.id = id;
}
// Use queryHelper insert with JSON-based approach
const user = await insert({
table: 'users',
data: userData,
returning: '*',
});
res.status(201).json(user);
} catch (err) {
console.error("Error creating user:", err);
if (err.code === "23505") {
// Unique constraint violation
return res.status(400).json({
error: err.detail || "A user with this phone number or ID already exists",
});
}
if (err.code === "23503") {
// Foreign key violation
return res.status(400).json({
error: err.detail || "Foreign key constraint violation",
});
}
res.status(500).json({ error: err.message || "Internal server error" });
}
});
// 2. GET All Users
router.get("/", async (req, res) => {
try {
// Parse and validate query parameters
const limit = Math.min(parseInt(req.query.limit) || 100, 100);
const offset = parseInt(req.query.offset) || 0;
const { is_active, phone_number, name } = req.query;
// Build where conditions from query parameters
const where = { deleted: false };
if (is_active !== undefined) {
where.is_active = is_active === 'true' || is_active === true;
}
if (phone_number) {
where.phone_number = phone_number;
}
if (name) {
where.name = { op: 'ilike', value: `%${name}%` };
}
const users = await select({
table: 'users',
columns: ['id', 'name', 'phone_number', 'avatar_url', 'language', 'timezone', 'country_code', 'is_active', 'created_at', 'updated_at'],
where,
orderBy: {
column: 'created_at',
direction: 'desc',
},
limit,
offset,
});
res.json(users);
} catch (err) {
console.error("Error fetching users:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// 3. GET Single User
// Fine-grained authorization: Users can only read their own profile, admins can read any profile
router.get("/:id",
fineAuthorize({
action: 'read',
resource: 'user',
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
console.log(`[User Route] GET /users/:id - Request received`);
console.log(`[User Route] User ID requested: ${req.params.id}`);
console.log(`[User Route] Authenticated user:`, req.user ? {
userId: req.user.userId,
role: req.user.role,
} : 'NOT AUTHENTICATED');
try {
const { id } = req.params;
// Use queryHelper select with JSON-based where conditions
const user = await select({
table: 'users',
columns: ['id', 'name', 'phone_number', 'avatar_url', 'language', 'timezone', 'country_code', 'is_active', 'created_at', 'updated_at'],
where: {
id,
deleted: false,
},
limit: 1,
});
if (user.length === 0) {
// Log not found
if (req.auditLogger) {
req.auditLogger.logFailure('get_user', 'User not found', { userId: id });
}
return res.status(404).json({ error: "User not found" });
}
// Log success
if (req.auditLogger) {
req.auditLogger.logSuccess('get_user', { userId: id });
}
res.json(user[0]);
} catch (err) {
console.error("Error fetching user:", err);
// Log error
if (req.auditLogger) {
req.auditLogger.logFailure('get_user', err.message, { userId: req.params.id });
}
res.status(500).json({ error: "Internal server error" });
}
}
);
// 4. UPDATE User (Requires write rate limiter and fine-grained authorization)
router.put("/:id",
rateLimiterWrite,
fineAuthorize({
action: 'write',
resource: 'user',
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
try {
const { id } = req.params;
// Parse and extract update data from request body
const { name, phone_number, avatar_url, language, timezone, country_code, is_active } = req.body;
// Build update data object using JSON-based structure (only include fields that are provided)
const updateData = {};
if (name !== undefined) updateData.name = name.trim();
if (phone_number !== undefined) updateData.phone_number = phone_number.trim();
if (avatar_url !== undefined) updateData.avatar_url = avatar_url || null;
if (language !== undefined) updateData.language = language || null;
if (timezone !== undefined) updateData.timezone = timezone || null;
if (country_code !== undefined) updateData.country_code = country_code;
if (is_active !== undefined) updateData.is_active = is_active === true || is_active === 'true';
// 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" });
}
// Use queryHelper update with JSON-based where conditions
const updated = await update({
table: 'users',
data: updateData,
where: {
id,
deleted: false,
},
returning: '*',
});
if (updated.length === 0) {
return res.status(404).json({ error: "User not found" });
}
res.json(updated[0]);
} catch (err) {
console.error("Error updating user:", err);
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" });
}
});
// 5. DELETE User (Soft Delete) - Requires fine-grained authorization
router.delete("/:id",
fineAuthorize({
action: 'delete',
resource: 'user',
getResourceOwnerId: (req) => req.params.id,
}),
async (req, res) => {
try {
const { id } = req.params;
// Use queryHelper update with JSON-based where conditions for soft delete
const deleted = await update({
table: 'users',
data: {
deleted: true,
},
where: {
id,
deleted: false, // Only delete if not already deleted
},
returning: ['id'],
});
if (deleted.length === 0) {
return res.status(404).json({ error: "User not found or already deleted" });
}
res.json({ message: "User deleted successfully", id: deleted[0].id });
} catch (err) {
console.error("Error deleting user:", err);
res.status(500).json({ error: "Internal server error" });
}
});
export default router;

View File

@ -1,24 +1,70 @@
import express from "express";
import cors from "cors";
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import http from "http";
import dotenv from "dotenv";
// import listingRoutes from "./routes/listingRoutes.js";
import locationRoutes from "./routes/locationRoutes.js";
import chatRoutes from "./routes/chatRoutes.js";
import userRoutes from "./routes/userRoutes.js";
import { initSocket } from "./socket.js";
import { startExpirationJob } from "./jobs/expirationJob.js";
// Import middleware (in correct order)
import requestContext from "./middleware/requestContext.js";
import { auditLoggerMiddleware } from "./services/auditLogger.js";
import registerListingsApi from "./routes/listings.route.js";
import CommonApiBuilder from "./core/builders/CommonApiBuilder.js";
// import JwtAuthMiddleware from "./core/middleware/JwtAuthMiddleware.js";
// import RateLimiterMiddleware from "./core/middleware/RateLimiterMiddleware.js";
import dbClient from "./core/db/client.js";
// Load environment variables
dotenv.config();
const app = express();
// Trust proxy for correct IP addresses (important for rate limiting)
if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
app.set('trust proxy', true);
}
app.use(cors());
app.use(express.json());
// =====================================================
// MIDDLEWARE CHAIN (IMPORTANT ORDER)
// =====================================================
// 1. Request Context (FIRST - extracts IP, user agent, etc.)
app.use(requestContext);
// 2. Audit Logger (attach logger to request)
app.use(auditLoggerMiddleware);
// Serve static files from public directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
app.use(express.static(join(__dirname, 'public')));
const PORT = process.env.PORT || 3200;
const common = new CommonApiBuilder(app)
// .use(new JwtAuthMiddleware())
// .use(new RateLimiterMiddleware(rateLimiterRead))
// .use(new CoarseAuthMiddleware(['USER', 'ADMIN']))
.build();
// register ALL listing APIs
registerListingsApi("/listings", common, dbClient);
// Add routes here
import listingRoutes from "./routes/listingRoutes.js";
import locationRoutes from "./routes/locationRoutes.js";
import chatRoutes from "./routes/chatRoutes.js";
app.use("/listings", listingRoutes);
app.use("/locations", locationRoutes);
app.use("/chat", chatRoutes);
import http from "http";
import { initSocket } from "./socket.js";
import { startExpirationJob } from "./jobs/expirationJob.js";
app.use("/users", userRoutes);
const server = http.createServer(app);
initSocket(server);

106
services/auditLogger.js Normal file
View File

@ -0,0 +1,106 @@
// services/auditLogger.js
/**
* Audit Logging Service
*
* Logs all API requests with user context, action, route, status, and timestamp
* Logs both success and failure cases
*/
// In-memory log store (for development/testing)
// In production, you should integrate with a logging service (e.g., Winston, Bunyan, CloudWatch, etc.)
const auditLogs = [];
/**
* Audit Logger Class
*/
class AuditLogger {
constructor(req) {
this.req = req;
this.userId = req.user?.userId || null;
this.clientIp = req.clientIp || 'unknown';
this.userAgent = req.userAgent || 'unknown';
this.requestId = req.requestId;
this.route = req.path;
this.method = req.method;
}
/**
* Log an audit event
*
* @param {Object} params
* @param {string} params.action - Action being performed
* @param {string} params.status - Status ('success', 'failed', 'forbidden', etc.)
* @param {Object} params.meta - Additional metadata
*/
log({ action, status = 'success', meta = {} }) {
const auditEntry = {
timestamp: new Date().toISOString(),
requestId: this.requestId,
userId: this.userId,
action,
route: this.route,
method: this.method,
status,
clientIp: this.clientIp,
userAgent: this.userAgent,
meta,
};
// In production, send to logging service (e.g., CloudWatch, Elasticsearch, etc.)
// For now, log to console and store in memory
console.log('[AUDIT]', JSON.stringify(auditEntry));
auditLogs.push(auditEntry);
// Keep only last 1000 logs in memory
if (auditLogs.length > 1000) {
auditLogs.shift();
}
// TODO: Integrate with external logging service
// Example:
// if (process.env.LOGGING_SERVICE_URL) {
// axios.post(process.env.LOGGING_SERVICE_URL + '/audit', auditEntry).catch(err => {
// console.error('Failed to send audit log:', err);
// });
// }
}
/**
* Log successful request
*/
logSuccess(action, meta = {}) {
this.log({ action, status: 'success', meta });
}
/**
* Log failed request
*/
logFailure(action, reason, meta = {}) {
this.log({ action, status: 'failed', meta: { ...meta, reason } });
}
/**
* Log forbidden access
*/
logForbidden(action, reason, meta = {}) {
this.log({ action, status: 'forbidden', meta: { ...meta, reason } });
}
}
/**
* Middleware to attach audit logger to request
*/
export function auditLoggerMiddleware(req, res, next) {
req.auditLogger = new AuditLogger(req);
next();
}
/**
* Get audit logs (for debugging/admin purposes)
*/
export function getAuditLogs(limit = 100) {
return auditLogs.slice(-limit);
}
export default AuditLogger;