Compare commits
5 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
0353d4c642 | |
|
|
8e6f6d32d4 | |
|
|
4121b99775 | |
|
|
35872ccee2 | |
|
|
b3899dc14d |
|
|
@ -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
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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] }; }
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export default class BaseMiddleware {
|
||||
middleware(){ throw new Error('middleware() not implemented'); }
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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' });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
STRING: 'string',
|
||||
NUMBER: 'number',
|
||||
ARRAY: 'Array',
|
||||
JSON_OBJECT: 'JSONObject',
|
||||
JSON_ARRAY: 'JSONArray'
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
20
db/pool.js
20
db/pool.js
|
|
@ -1,20 +0,0 @@
|
|||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const baseConfig = {};
|
||||
|
||||
baseConfig.host = process.env.PGHOST || "127.0.0.1";
|
||||
baseConfig.port = Number(process.env.PGPORT || 5432);
|
||||
baseConfig.user = process.env.PGUSER || "postgres";
|
||||
baseConfig.password = process.env.PGPASSWORD || "postgres";
|
||||
baseConfig.database = process.env.PGDATABASE || "postgres";
|
||||
|
||||
const pool = new Pool(baseConfig);
|
||||
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unexpected Postgres client error", err);
|
||||
});
|
||||
|
||||
export default pool;
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
# Query Helper API Reference
|
||||
|
||||
This document provides a complete reference for the JSON-based database query helper.
|
||||
|
||||
## Method Overview
|
||||
|
||||
| Method | Description | Notes |
|
||||
|--------|-------------|-------|
|
||||
| `select(options)` | Retrieve records from database | JSON-based query builder |
|
||||
| `insert(options)` | Insert new record(s) | Single or batch insert |
|
||||
| `update(options)` | Update existing records | Requires WHERE clause |
|
||||
| `deleteRecord(options)` | Delete records | Requires WHERE clause |
|
||||
| `execute(options)` | Execute transactions or custom queries | Transactions or raw builder |
|
||||
|
||||
## Detailed Method Reference
|
||||
|
||||
### SELECT
|
||||
|
||||
Retrieve records from database with filtering, sorting, and pagination.
|
||||
|
||||
```javascript
|
||||
const users = await select({
|
||||
table: 'users',
|
||||
where: { deleted: false, status: 'active' },
|
||||
orderBy: { column: 'created_at', direction: 'desc' },
|
||||
limit: 20
|
||||
});
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name (must be in whitelist)
|
||||
- `columns` (string[] | '*', optional): Columns to select (default: '*')
|
||||
- `where` (object, optional): WHERE conditions
|
||||
- `orderBy` (object, optional): `{ column: string, direction: 'asc'|'desc' }`
|
||||
- `limit` (number, optional): Max records (capped at 100)
|
||||
- `offset` (number, optional): Skip records
|
||||
- `joins` (array, optional): Join configurations
|
||||
|
||||
### INSERT
|
||||
|
||||
Insert new record(s) into database.
|
||||
|
||||
```javascript
|
||||
const user = await insert({
|
||||
table: 'users',
|
||||
data: { name: 'John', phone: '+1234567890' },
|
||||
returning: ['id', 'name']
|
||||
});
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name
|
||||
- `data` (object | object[], required): Data to insert (single object or array for batch)
|
||||
- `returning` (string[] | '*', optional): Columns to return (PostgreSQL)
|
||||
|
||||
### UPDATE
|
||||
|
||||
Update existing records in database.
|
||||
|
||||
```javascript
|
||||
const updated = await update({
|
||||
table: 'users',
|
||||
data: { name: 'Jane' },
|
||||
where: { id: userId }
|
||||
});
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name
|
||||
- `data` (object, required): Data to update
|
||||
- `where` (object, required): WHERE conditions (required for safety)
|
||||
- `returning` (string[] | '*', optional): Columns to return
|
||||
|
||||
### DELETE
|
||||
|
||||
Delete records from database.
|
||||
|
||||
```javascript
|
||||
await deleteRecord({
|
||||
table: 'users',
|
||||
where: { id: userId }
|
||||
});
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name
|
||||
- `where` (object, required): WHERE conditions (required for safety)
|
||||
- `returning` (string[] | '*', optional): Columns to return
|
||||
|
||||
### EXECUTE (Transaction)
|
||||
|
||||
Execute transactions or custom query builder logic.
|
||||
|
||||
```javascript
|
||||
await execute({
|
||||
type: 'transaction',
|
||||
handler: async (trx) => {
|
||||
await trx('animals').insert(animalData);
|
||||
await trx('listings').insert(listingData);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `type` (string, required): 'transaction' or 'raw-builder'
|
||||
- `handler` (function, required): Handler function receiving knex/trx instance
|
||||
|
||||
## WHERE Clause Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| Equality | Simple key-value match | `where: { status: 'active' }` |
|
||||
| `>` | Greater than | `where: { price: { op: '>', value: 100 } }` |
|
||||
| `<` | Less than | `where: { age: { op: '<', value: 18 } }` |
|
||||
| `>=` | Greater than or equal | `where: { price: { op: '>=', value: 100 } }` |
|
||||
| `<=` | Less than or equal | `where: { age: { op: '<=', value: 65 } }` |
|
||||
| `!=` or `<>` | Not equal | `where: { status: { op: '!=', value: 'deleted' } }` |
|
||||
| `in` | In array | `where: { id: { op: 'in', value: [1, 2, 3] } }` |
|
||||
| `notIn` | Not in array | `where: { id: { op: 'notIn', value: [1, 2, 3] } }` |
|
||||
| `like` | Case-sensitive LIKE | `where: { name: { op: 'like', value: '%John%' } }` |
|
||||
| `ilike` | Case-insensitive LIKE | `where: { name: { op: 'ilike', value: '%john%' } }` |
|
||||
| `between` | Between two values | `where: { age: { op: 'between', value: [18, 65] } }` |
|
||||
| `isNull` | IS NULL | `where: { deleted_at: { op: 'isNull' } }` |
|
||||
| `isNotNull` | IS NOT NULL | `where: { deleted_at: { op: 'isNotNull' } }` |
|
||||
|
||||
## Supported Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `>` | Greater than | `{ op: '>', value: 100 }` |
|
||||
| `<` | Less than | `{ op: '<', value: 100 }` |
|
||||
| `>=` | Greater than or equal | `{ op: '>=', value: 100 }` |
|
||||
| `<=` | Less than or equal | `{ op: '<=', value: 100 }` |
|
||||
| `!=` or `<>` | Not equal | `{ op: '!=', value: 'deleted' }` |
|
||||
| `in` | In array | `{ op: 'in', value: [1, 2, 3] }` |
|
||||
| `notIn` | Not in array | `{ op: 'notIn', value: [1, 2, 3] }` |
|
||||
| `like` | Case-sensitive LIKE | `{ op: 'like', value: '%test%' }` |
|
||||
| `ilike` | Case-insensitive LIKE | `{ op: 'ilike', value: '%test%' }` |
|
||||
| `between` | Between two values | `{ op: 'between', value: [10, 20] }` |
|
||||
| `isNull` | IS NULL | `{ op: 'isNull' }` |
|
||||
| `isNotNull` | IS NOT NULL | `{ op: 'isNotNull' }` |
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Table names**: lowercase, snake_case (e.g., `users`, `listing_media`)
|
||||
- **Column names**: lowercase, snake_case (e.g., `created_at`, `user_id`)
|
||||
- **Options**: camelCase (e.g., `orderBy`, `returning`)
|
||||
- **Operators**: lowercase (e.g., `op: '>'`)
|
||||
|
||||
## Type System
|
||||
|
||||
- **JavaScript**: JSON objects with runtime validation
|
||||
- **Runtime Validation**: Descriptive errors for invalid inputs
|
||||
- **Type Safety**: Structured JSON prevents SQL injection
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Runtime Validation**: All inputs validated at runtime
|
||||
- **Descriptive Errors**: Clear error messages for debugging
|
||||
- **Database Errors**: Propagated with context
|
||||
|
||||
## Transactions
|
||||
|
||||
```javascript
|
||||
await execute({
|
||||
type: 'transaction',
|
||||
handler: async (trx) => {
|
||||
await trx('users').insert(userData);
|
||||
await trx('listings').insert(listingData);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When migrating from raw SQL to queryHelper:
|
||||
|
||||
- [ ] Replace `pool.query()` with `select()`, `insert()`, `update()`, or `deleteRecord()`
|
||||
- [ ] Convert SQL WHERE clauses to JSON objects
|
||||
- [ ] Replace string concatenation with structured options
|
||||
- [ ] Convert transactions to `execute({ type: 'transaction' })`
|
||||
- [ ] Update JOINs to use `joins` array
|
||||
- [ ] Replace parameterized queries with JSON where conditions
|
||||
- [ ] Test all queries to ensure same results
|
||||
- [ ] Remove all raw SQL strings from service layer
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Type Safety**: Structured JSON prevents SQL injection
|
||||
2. **Readability**: Clear, self-documenting queries
|
||||
3. **Maintainability**: Easy to modify and extend
|
||||
4. **Security**: Table whitelist prevents unauthorized access
|
||||
5. **Consistency**: Uniform query interface across codebase
|
||||
6. **Testing**: Easier to mock and test
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,172 @@
|
|||
# Query Helper - Quick Reference Card
|
||||
|
||||
One-page quick reference for daily development.
|
||||
|
||||
## Import
|
||||
|
||||
```javascript
|
||||
import { select, insert, update, deleteRecord, execute } from '../db/queryHelper/index.js';
|
||||
```
|
||||
|
||||
## SELECT
|
||||
|
||||
```javascript
|
||||
const results = await select({
|
||||
table: 'users',
|
||||
columns: ['id', 'name'], // or '*'
|
||||
where: { deleted: false },
|
||||
orderBy: { column: 'created_at', direction: 'desc' },
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
```
|
||||
|
||||
## INSERT
|
||||
|
||||
```javascript
|
||||
// Single
|
||||
const user = await insert({
|
||||
table: 'users',
|
||||
data: { name: 'John', phone: '+1234567890' },
|
||||
returning: '*',
|
||||
});
|
||||
|
||||
// Batch
|
||||
const items = await insert({
|
||||
table: 'items',
|
||||
data: [{ name: 'Item 1' }, { name: 'Item 2' }],
|
||||
returning: '*',
|
||||
});
|
||||
```
|
||||
|
||||
## UPDATE
|
||||
|
||||
```javascript
|
||||
const updated = await update({
|
||||
table: 'users',
|
||||
data: { name: 'Jane' },
|
||||
where: { id: userId, deleted: false }, // WHERE required
|
||||
returning: '*',
|
||||
});
|
||||
```
|
||||
|
||||
## DELETE (Soft Delete Recommended)
|
||||
|
||||
```javascript
|
||||
// Soft delete (recommended)
|
||||
const deleted = await update({
|
||||
table: 'users',
|
||||
data: { deleted: true },
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Hard delete (use with caution)
|
||||
const deleted = await deleteRecord({
|
||||
table: 'users',
|
||||
where: { id: userId }, // WHERE required
|
||||
});
|
||||
```
|
||||
|
||||
## TRANSACTION
|
||||
|
||||
```javascript
|
||||
const result = await execute({
|
||||
type: 'transaction',
|
||||
handler: async (trx) => {
|
||||
const user = await trx('users').insert(userData).returning('*');
|
||||
await trx('listings').insert({ ...listingData, seller_id: user[0].id });
|
||||
return user[0];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## WHERE Operators
|
||||
|
||||
```javascript
|
||||
// Simple
|
||||
where: { status: 'active' }
|
||||
|
||||
// Comparison
|
||||
where: { price: { op: '>=', value: 1000 } }
|
||||
|
||||
// IN
|
||||
where: { id: { op: 'in', value: ['uuid1', 'uuid2'] } }
|
||||
|
||||
// LIKE
|
||||
where: { name: { op: 'ilike', value: '%john%' } }
|
||||
|
||||
// BETWEEN
|
||||
where: { age: { op: 'between', value: [18, 65] } }
|
||||
|
||||
// NULL
|
||||
where: { deleted_at: { op: 'isNull' } }
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### GET with Pagination
|
||||
```javascript
|
||||
const { limit = 20, offset = 0 } = req.query;
|
||||
const results = await select({
|
||||
table: 'table_name',
|
||||
where: { deleted: false },
|
||||
orderBy: { column: 'created_at', direction: 'desc' },
|
||||
limit: Math.min(limit, 100),
|
||||
offset: parseInt(offset),
|
||||
});
|
||||
```
|
||||
|
||||
### GET Single by ID
|
||||
```javascript
|
||||
const records = await select({
|
||||
table: 'table_name',
|
||||
where: { id: id, deleted: false },
|
||||
limit: 1,
|
||||
});
|
||||
if (records.length === 0) return res.status(404).json({ error: "Not found" });
|
||||
res.json(records[0]);
|
||||
```
|
||||
|
||||
### Partial Update
|
||||
```javascript
|
||||
const updateData = {};
|
||||
if (req.body.name !== undefined) updateData.name = req.body.name;
|
||||
if (req.body.email !== undefined) updateData.email = req.body.email;
|
||||
|
||||
const updated = await update({
|
||||
table: 'table_name',
|
||||
data: updateData,
|
||||
where: { id: id },
|
||||
returning: '*',
|
||||
});
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. ✅ Always include `deleted: false` in WHERE for SELECT/UPDATE
|
||||
2. ✅ WHERE clause is **required** for UPDATE/DELETE
|
||||
3. ✅ Cap limits: `Math.min(limit, 100)`
|
||||
4. ✅ Use transactions for multi-step operations
|
||||
5. ✅ Use `returning: '*'` to get inserted/updated data
|
||||
6. ✅ Validate input before querying
|
||||
7. ✅ Handle errors appropriately
|
||||
|
||||
## Error Codes
|
||||
|
||||
- `23505` - Unique constraint violation (duplicate)
|
||||
- `23503` - Foreign key violation
|
||||
- `22P02` - Invalid UUID format
|
||||
- `42P01` - Table doesn't exist
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
db/queryHelper/
|
||||
├── index.js # Main implementation
|
||||
├── knex.js # Knex configuration
|
||||
├── README.md # Overview
|
||||
├── API_REFERENCE.md # Complete API docs
|
||||
├── DEVELOPER_GUIDE.md # This comprehensive guide
|
||||
└── QUICK_REFERENCE.md # This file
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
# Query Helper - JSON-Based Database Interface
|
||||
|
||||
A centralized, SQL-string-free database query helper built on Knex.js that provides a safe, structured interface for all database operations.
|
||||
|
||||
## 🎯 Goals
|
||||
|
||||
- **No Raw SQL**: All queries built using Knex query builder
|
||||
- **JSON-Based**: Structured JSON objects instead of SQL strings
|
||||
- **Type Safe**: Parameterized queries prevent SQL injection
|
||||
- **Server-Only**: Internal helper, not exposed to API clients
|
||||
- **Production Ready**: Error handling, validation, and security built-in
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
Knex.js is already installed. The query helper uses the existing database connection from `db/pool.js`.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```javascript
|
||||
import { select, insert, update, deleteRecord, execute } from '../db/queryHelper/index.js';
|
||||
|
||||
// SELECT
|
||||
const users = await select({
|
||||
table: 'users',
|
||||
where: { deleted: false },
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// INSERT
|
||||
const user = await insert({
|
||||
table: 'users',
|
||||
data: { name: 'John', phone_number: '+1234567890' },
|
||||
returning: '*'
|
||||
});
|
||||
|
||||
// UPDATE
|
||||
const updated = await update({
|
||||
table: 'users',
|
||||
data: { name: 'Jane' },
|
||||
where: { id: userId },
|
||||
returning: '*'
|
||||
});
|
||||
|
||||
// DELETE
|
||||
await deleteRecord({
|
||||
table: 'users',
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
// TRANSACTION
|
||||
await execute({
|
||||
type: 'transaction',
|
||||
handler: async (trx) => {
|
||||
await trx('users').insert(userData);
|
||||
await trx('listings').insert(listingData);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### `select(options)`
|
||||
|
||||
Retrieve records from database.
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name (must be in whitelist)
|
||||
- `columns` (string[] | '*', optional): Columns to select (default: '*')
|
||||
- `where` (object, optional): WHERE conditions
|
||||
- `orderBy` (object, optional): `{ column: string, direction: 'asc'|'desc' }`
|
||||
- `limit` (number, optional): Max records (capped at 100)
|
||||
- `offset` (number, optional): Skip records
|
||||
- `joins` (array, optional): Join configurations
|
||||
|
||||
**Returns:** `Promise<object[]>`
|
||||
|
||||
### `insert(options)`
|
||||
|
||||
Insert new record(s).
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name
|
||||
- `data` (object | object[], required): Data to insert
|
||||
- `returning` (string[] | '*', optional): Columns to return
|
||||
|
||||
**Returns:** `Promise<object | object[]>`
|
||||
|
||||
### `update(options)`
|
||||
|
||||
Update existing records.
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name
|
||||
- `data` (object, required): Data to update
|
||||
- `where` (object, required): WHERE conditions (required for safety)
|
||||
- `returning` (string[] | '*', optional): Columns to return
|
||||
|
||||
**Returns:** `Promise<object[]>`
|
||||
|
||||
### `deleteRecord(options)`
|
||||
|
||||
Delete records.
|
||||
|
||||
**Options:**
|
||||
- `table` (string, required): Table name
|
||||
- `where` (object, required): WHERE conditions (required for safety)
|
||||
- `returning` (string[] | '*', optional): Columns to return
|
||||
|
||||
**Returns:** `Promise<object[]>`
|
||||
|
||||
### `execute(options)`
|
||||
|
||||
Execute transactions or custom query builder logic.
|
||||
|
||||
**Options:**
|
||||
- `type` (string, required): 'transaction' or 'raw-builder'
|
||||
- `handler` (function, required): Handler function receiving knex/trx instance
|
||||
|
||||
**Returns:** `Promise<any>`
|
||||
|
||||
## 🔍 WHERE Clause Operators
|
||||
|
||||
### Simple Equality
|
||||
```javascript
|
||||
where: { status: 'active' }
|
||||
// WHERE status = 'active'
|
||||
```
|
||||
|
||||
### Comparison Operators
|
||||
```javascript
|
||||
where: {
|
||||
price: { op: '>=', value: 1000 },
|
||||
age: { op: '<', value: 65 }
|
||||
}
|
||||
// WHERE price >= 1000 AND age < 65
|
||||
```
|
||||
|
||||
### IN Operator
|
||||
```javascript
|
||||
where: {
|
||||
id: { op: 'in', value: ['uuid1', 'uuid2', 'uuid3'] }
|
||||
}
|
||||
// WHERE id IN ('uuid1', 'uuid2', 'uuid3')
|
||||
```
|
||||
|
||||
### LIKE Operator
|
||||
```javascript
|
||||
where: {
|
||||
name: { op: 'ilike', value: '%john%' }
|
||||
}
|
||||
// WHERE name ILIKE '%john%'
|
||||
```
|
||||
|
||||
### BETWEEN Operator
|
||||
```javascript
|
||||
where: {
|
||||
age: { op: 'between', value: [18, 65] }
|
||||
}
|
||||
// WHERE age BETWEEN 18 AND 65
|
||||
```
|
||||
|
||||
### NULL Checks
|
||||
```javascript
|
||||
where: {
|
||||
deleted_at: { op: 'isNull' }
|
||||
}
|
||||
// WHERE deleted_at IS NULL
|
||||
```
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
1. **Table Whitelist**: Only allowed tables can be queried
|
||||
2. **Parameterized Queries**: All values are parameterized
|
||||
3. **WHERE Required**: UPDATE/DELETE require WHERE clause
|
||||
4. **Limit Caps**: Maximum 100 records per query
|
||||
5. **No SQL Strings**: Impossible to inject SQL
|
||||
|
||||
## 📋 Allowed Tables
|
||||
|
||||
The following tables are whitelisted:
|
||||
|
||||
- `users`
|
||||
- `listings`
|
||||
- `animals`
|
||||
- `locations`
|
||||
- `species`
|
||||
- `breeds`
|
||||
- `listing_media`
|
||||
- `conversations`
|
||||
- `messages`
|
||||
- `communication_records`
|
||||
- `favorites`
|
||||
- `user_devices`
|
||||
- `subscription_plans`
|
||||
- `otp_requests`
|
||||
- `auth_audit`
|
||||
|
||||
To add more tables, update `ALLOWED_TABLES` in `queryHelper.js`.
|
||||
|
||||
## 🎓 Examples
|
||||
|
||||
See `db/queryHelper/examples.js` for comprehensive examples.
|
||||
|
||||
## 📖 Migration Guide
|
||||
|
||||
See `db/MIGRATION_EXAMPLE.md` for before/after comparisons.
|
||||
|
||||
## 🔄 API Reference
|
||||
|
||||
See `db/QUERY_HELPER_MAPPING.md` for complete method reference and examples.
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
1. **No Raw SQL**: Never use `pool.query()` with SQL strings
|
||||
2. **Always Use WHERE**: UPDATE/DELETE must include WHERE clause
|
||||
3. **Table Names**: Must be in whitelist (case-sensitive)
|
||||
4. **Transactions**: Use `execute({ type: 'transaction' })` for multi-step operations
|
||||
5. **Complex Queries**: Use `execute({ type: 'raw-builder' })` for advanced cases
|
||||
|
||||
## 🐛 Error Handling
|
||||
|
||||
The query helper throws descriptive errors:
|
||||
|
||||
- `Table 'xxx' is not allowed` - Table not in whitelist
|
||||
- `WHERE clause is required` - Missing WHERE for UPDATE/DELETE
|
||||
- `Unsupported operator: xxx` - Invalid operator
|
||||
- Database errors are propagated with context
|
||||
|
||||
## 📝 Best Practices
|
||||
|
||||
1. **Use Transactions**: For multi-step operations
|
||||
2. **Soft Deletes**: Use UPDATE instead of DELETE
|
||||
3. **Limit Results**: Always set reasonable limits
|
||||
4. **Validate Input**: Validate data before passing to helper
|
||||
5. **Handle Errors**: Wrap in try-catch blocks
|
||||
6. **Use Returning**: Get inserted/updated records back
|
||||
|
||||
## 🔧 Advanced Usage
|
||||
|
||||
### Complex Joins
|
||||
```javascript
|
||||
const results = await select({
|
||||
table: 'listings',
|
||||
columns: ['listings.*', 'animals.*'],
|
||||
joins: [
|
||||
{
|
||||
type: 'inner',
|
||||
table: 'animals',
|
||||
on: 'listings.animal_id = animals.id'
|
||||
}
|
||||
],
|
||||
where: { 'listings.deleted': false }
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Query Builder
|
||||
```javascript
|
||||
const results = await execute({
|
||||
type: 'raw-builder',
|
||||
handler: async (knex) => {
|
||||
return await knex('listings')
|
||||
.select('listings.*')
|
||||
.join('animals', 'listings.animal_id', 'animals.id')
|
||||
.where('listings.deleted', false)
|
||||
.where('animals.species_id', speciesId)
|
||||
.orderBy('listings.created_at', 'desc')
|
||||
.limit(20);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **DEVELOPER_GUIDE.md** - Complete developer guide with examples, flow diagrams, and best practices
|
||||
- **QUICK_REFERENCE.md** - One-page quick reference card for daily use
|
||||
- **API_REFERENCE.md** - Complete API reference with all methods and options
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or issues, refer to:
|
||||
- `DEVELOPER_GUIDE.md` - Comprehensive guide with real-world examples from the codebase
|
||||
- `QUICK_REFERENCE.md` - Quick lookup for common patterns
|
||||
- `API_REFERENCE.md` - Complete method documentation
|
||||
|
||||
|
|
@ -0,0 +1,527 @@
|
|||
/**
|
||||
* Centralized JSON-based Database Query Helper
|
||||
*
|
||||
* This helper provides a safe, SQL-string-free interface for database operations.
|
||||
* All queries are built using Knex.js query builder with parameterized queries.
|
||||
*
|
||||
* @module db/queryHelper
|
||||
*/
|
||||
|
||||
import db from './knex.js';
|
||||
|
||||
// Whitelist of allowed table names (security: prevent SQL injection via table names)
|
||||
const ALLOWED_TABLES = new Set([
|
||||
'users',
|
||||
'listings',
|
||||
'animals',
|
||||
'locations',
|
||||
'species',
|
||||
'breeds',
|
||||
'listing_media',
|
||||
'conversations',
|
||||
'messages',
|
||||
'communication_records',
|
||||
'favorites',
|
||||
'user_devices',
|
||||
'subscription_plans',
|
||||
'otp_requests',
|
||||
'auth_audit',
|
||||
]);
|
||||
|
||||
// Maximum limit to prevent resource exhaustion
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Validates table name against whitelist
|
||||
* @param {string} table - Table name to validate
|
||||
* @throws {Error} If table is not in whitelist
|
||||
*/
|
||||
function validateTable(table) {
|
||||
if (!table || typeof table !== 'string') {
|
||||
throw new Error('Table name is required and must be a string');
|
||||
}
|
||||
if (!ALLOWED_TABLES.has(table)) {
|
||||
throw new Error(`Table '${table}' is not allowed. Allowed tables: ${Array.from(ALLOWED_TABLES).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies WHERE conditions from JSON object to Knex query builder
|
||||
* Supports simple equality, operators, and complex conditions
|
||||
*
|
||||
* @param {object} queryBuilder - Knex query builder instance
|
||||
* @param {object} where - WHERE conditions object
|
||||
*
|
||||
* @example
|
||||
* // Simple equality
|
||||
* { status: 'active' } → WHERE status = 'active'
|
||||
*
|
||||
* @example
|
||||
* // Operators
|
||||
* { price: { op: '>', value: 1000 } } → WHERE price > 1000
|
||||
* { age: { op: 'in', value: [18, 19, 20] } } → WHERE age IN (18, 19, 20)
|
||||
*
|
||||
* @example
|
||||
* // Multiple conditions (AND)
|
||||
* { status: 'active', deleted: false } → WHERE status = 'active' AND deleted = false
|
||||
*/
|
||||
function applyWhereConditions(queryBuilder, where) {
|
||||
if (!where || typeof where !== 'object') {
|
||||
return queryBuilder;
|
||||
}
|
||||
|
||||
for (const [column, condition] of Object.entries(where)) {
|
||||
if (condition === null || condition === undefined) {
|
||||
// Handle null checks
|
||||
queryBuilder.whereNull(column);
|
||||
} else if (typeof condition === 'object' && condition.op) {
|
||||
// Handle operators: { op: '>', value: 100 }
|
||||
const { op, value } = condition;
|
||||
|
||||
switch (op) {
|
||||
case '>':
|
||||
queryBuilder.where(column, '>', value);
|
||||
break;
|
||||
case '<':
|
||||
queryBuilder.where(column, '<', value);
|
||||
break;
|
||||
case '>=':
|
||||
queryBuilder.where(column, '>=', value);
|
||||
break;
|
||||
case '<=':
|
||||
queryBuilder.where(column, '<=', value);
|
||||
break;
|
||||
case '!=':
|
||||
case '<>':
|
||||
queryBuilder.where(column, '!=', value);
|
||||
break;
|
||||
case 'in':
|
||||
if (Array.isArray(value)) {
|
||||
queryBuilder.whereIn(column, value);
|
||||
} else {
|
||||
throw new Error(`Operator 'in' requires an array value, got ${typeof value}`);
|
||||
}
|
||||
break;
|
||||
case 'notIn':
|
||||
if (Array.isArray(value)) {
|
||||
queryBuilder.whereNotIn(column, value);
|
||||
} else {
|
||||
throw new Error(`Operator 'notIn' requires an array value, got ${typeof value}`);
|
||||
}
|
||||
break;
|
||||
case 'like':
|
||||
queryBuilder.where(column, 'like', value);
|
||||
break;
|
||||
case 'ilike':
|
||||
queryBuilder.where(column, 'ilike', value);
|
||||
break;
|
||||
case 'between':
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
queryBuilder.whereBetween(column, value);
|
||||
} else {
|
||||
throw new Error(`Operator 'between' requires an array of 2 values, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
break;
|
||||
case 'isNull':
|
||||
queryBuilder.whereNull(column);
|
||||
break;
|
||||
case 'isNotNull':
|
||||
queryBuilder.whereNotNull(column);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operator: ${op}. Supported: >, <, >=, <=, !=, in, notIn, like, ilike, between, isNull, isNotNull`);
|
||||
}
|
||||
} else {
|
||||
// Simple equality: { status: 'active' }
|
||||
queryBuilder.where(column, condition);
|
||||
}
|
||||
}
|
||||
|
||||
return queryBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT query - Retrieve records from database
|
||||
*
|
||||
* @param {object} options - Query options
|
||||
* @param {string} options.table - Table name (must be in whitelist)
|
||||
* @param {string[]|string} [options.columns='*'] - Columns to select
|
||||
* @param {object} [options.where] - WHERE conditions (JSON object)
|
||||
* @param {object} [options.orderBy] - Order by configuration
|
||||
* @param {string} options.orderBy.column - Column to order by
|
||||
* @param {string} [options.orderBy.direction='asc'] - 'asc' or 'desc'
|
||||
* @param {number} [options.limit] - Maximum number of records (capped at MAX_LIMIT)
|
||||
* @param {number} [options.offset=0] - Number of records to skip
|
||||
* @param {object[]} [options.joins] - Join configurations
|
||||
* @param {string} options.joins[].type - 'inner', 'left', 'right', 'full'
|
||||
* @param {string} options.joins[].table - Table to join
|
||||
* @param {string} options.joins[].on - Join condition (e.g., 'listings.animal_id = animals.id')
|
||||
*
|
||||
* @returns {Promise<object[]>} Array of records
|
||||
*
|
||||
* @example
|
||||
* // Simple select
|
||||
* const users = await select({
|
||||
* table: 'users',
|
||||
* where: { deleted: false },
|
||||
* limit: 10
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // With operators
|
||||
* const listings = await select({
|
||||
* table: 'listings',
|
||||
* where: {
|
||||
* status: 'active',
|
||||
* price: { op: '>=', value: 1000 },
|
||||
* age: { op: 'in', value: [18, 19, 20] }
|
||||
* },
|
||||
* orderBy: { column: 'created_at', direction: 'desc' },
|
||||
* limit: 20
|
||||
* });
|
||||
*/
|
||||
export async function select(options) {
|
||||
const { table, columns = '*', where, orderBy, limit, offset = 0, joins } = options;
|
||||
|
||||
validateTable(table);
|
||||
|
||||
let query = db(table);
|
||||
|
||||
// Select columns
|
||||
if (Array.isArray(columns)) {
|
||||
query = query.select(columns);
|
||||
} else if (columns === '*') {
|
||||
query = query.select('*');
|
||||
} else {
|
||||
throw new Error('Columns must be an array or "*"');
|
||||
}
|
||||
|
||||
// Apply joins
|
||||
if (joins && Array.isArray(joins)) {
|
||||
for (const join of joins) {
|
||||
const { type = 'inner', table: joinTable, on } = join;
|
||||
if (!joinTable || !on) {
|
||||
throw new Error('Join must have table and on properties');
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'inner':
|
||||
query = query.innerJoin(joinTable, on);
|
||||
break;
|
||||
case 'left':
|
||||
query = query.leftJoin(joinTable, on);
|
||||
break;
|
||||
case 'right':
|
||||
query = query.rightJoin(joinTable, on);
|
||||
break;
|
||||
case 'full':
|
||||
query = query.fullOuterJoin(joinTable, on);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported join type: ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply WHERE conditions
|
||||
query = applyWhereConditions(query, where);
|
||||
|
||||
// Apply ORDER BY
|
||||
if (orderBy) {
|
||||
const { column, direction = 'asc' } = orderBy;
|
||||
if (!column) {
|
||||
throw new Error('orderBy.column is required');
|
||||
}
|
||||
if (direction !== 'asc' && direction !== 'desc') {
|
||||
throw new Error('orderBy.direction must be "asc" or "desc"');
|
||||
}
|
||||
query = query.orderBy(column, direction);
|
||||
}
|
||||
|
||||
// Apply LIMIT (with cap)
|
||||
if (limit !== undefined) {
|
||||
const cappedLimit = Math.min(limit, MAX_LIMIT);
|
||||
query = query.limit(cappedLimit);
|
||||
}
|
||||
|
||||
// Apply OFFSET
|
||||
if (offset > 0) {
|
||||
query = query.offset(offset);
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT query - Insert new record(s)
|
||||
*
|
||||
* @param {object} options - Insert options
|
||||
* @param {string} options.table - Table name (must be in whitelist)
|
||||
* @param {object|object[]} options.data - Data to insert (single object or array for batch)
|
||||
* @param {string[]} [options.returning] - Columns to return (PostgreSQL)
|
||||
*
|
||||
* @returns {Promise<object|object[]>} Inserted record(s)
|
||||
*
|
||||
* @example
|
||||
* // Single insert
|
||||
* const user = await insert({
|
||||
* table: 'users',
|
||||
* data: {
|
||||
* name: 'John Doe',
|
||||
* phone_number: '+1234567890'
|
||||
* },
|
||||
* returning: ['id', 'name']
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Batch insert
|
||||
* const media = await insert({
|
||||
* table: 'listing_media',
|
||||
* data: [
|
||||
* { listing_id: 'uuid1', media_url: 'url1', media_type: 'image' },
|
||||
* { listing_id: 'uuid2', media_url: 'url2', media_type: 'image' }
|
||||
* ]
|
||||
* });
|
||||
*/
|
||||
export async function insert(options) {
|
||||
const { table, data, returning } = options;
|
||||
|
||||
validateTable(table);
|
||||
|
||||
if (!data || (typeof data !== 'object' && !Array.isArray(data))) {
|
||||
throw new Error('Data is required and must be an object or array');
|
||||
}
|
||||
|
||||
let query = db(table);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// Batch insert
|
||||
query = query.insert(data);
|
||||
} else {
|
||||
// Single insert
|
||||
query = query.insert(data);
|
||||
}
|
||||
|
||||
// PostgreSQL RETURNING clause
|
||||
if (returning && Array.isArray(returning)) {
|
||||
query = query.returning(returning);
|
||||
} else if (returning === '*') {
|
||||
query = query.returning('*');
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return Array.isArray(data) ? results : (results[0] || results);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE query - Update existing records
|
||||
*
|
||||
* @param {object} options - Update options
|
||||
* @param {string} options.table - Table name (must be in whitelist)
|
||||
* @param {object} options.data - Data to update
|
||||
* @param {object} options.where - WHERE conditions (REQUIRED for safety)
|
||||
* @param {string[]} [options.returning] - Columns to return (PostgreSQL)
|
||||
*
|
||||
* @returns {Promise<object[]>} Updated records
|
||||
*
|
||||
* @throws {Error} If where clause is missing (safety requirement)
|
||||
*
|
||||
* @example
|
||||
* const updated = await update({
|
||||
* table: 'users',
|
||||
* data: { name: 'Jane Doe' },
|
||||
* where: { id: 'user-uuid' },
|
||||
* returning: ['id', 'name']
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Update with operators
|
||||
* const updated = await update({
|
||||
* table: 'listings',
|
||||
* data: { status: 'sold' },
|
||||
* where: {
|
||||
* seller_id: 'seller-uuid',
|
||||
* status: { op: '!=', value: 'sold' }
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export async function update(options) {
|
||||
const { table, data, where, returning } = options;
|
||||
|
||||
validateTable(table);
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!where || typeof where !== 'object' || Object.keys(where).length === 0) {
|
||||
throw new Error('WHERE clause is required for UPDATE operations (safety requirement)');
|
||||
}
|
||||
|
||||
let query = db(table).update(data);
|
||||
|
||||
// Apply WHERE conditions
|
||||
query = applyWhereConditions(query, where);
|
||||
|
||||
// PostgreSQL RETURNING clause
|
||||
if (returning && Array.isArray(returning)) {
|
||||
query = query.returning(returning);
|
||||
} else if (returning === '*') {
|
||||
query = query.returning('*');
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE query - Delete records (soft delete recommended)
|
||||
*
|
||||
* @param {object} options - Delete options
|
||||
* @param {string} options.table - Table name (must be in whitelist)
|
||||
* @param {object} options.where - WHERE conditions (REQUIRED for safety)
|
||||
* @param {string[]} [options.returning] - Columns to return (PostgreSQL)
|
||||
*
|
||||
* @returns {Promise<object[]>} Deleted records
|
||||
*
|
||||
* @throws {Error} If where clause is missing (safety requirement)
|
||||
*
|
||||
* @example
|
||||
* const deleted = await deleteRecord({
|
||||
* table: 'users',
|
||||
* where: { id: 'user-uuid' },
|
||||
* returning: ['id']
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Soft delete (recommended)
|
||||
* await update({
|
||||
* table: 'users',
|
||||
* data: { deleted: true },
|
||||
* where: { id: 'user-uuid' }
|
||||
* });
|
||||
*/
|
||||
export async function deleteRecord(options) {
|
||||
const { table, where, returning } = options;
|
||||
|
||||
validateTable(table);
|
||||
|
||||
if (!where || typeof where !== 'object' || Object.keys(where).length === 0) {
|
||||
throw new Error('WHERE clause is required for DELETE operations (safety requirement)');
|
||||
}
|
||||
|
||||
let query = db(table).delete();
|
||||
|
||||
// Apply WHERE conditions
|
||||
query = applyWhereConditions(query, where);
|
||||
|
||||
// PostgreSQL RETURNING clause
|
||||
if (returning && Array.isArray(returning)) {
|
||||
query = query.returning(returning);
|
||||
} else if (returning === '*') {
|
||||
query = query.returning('*');
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXECUTE - Execute custom query builder logic or transactions
|
||||
*
|
||||
* @param {object} options - Execute options
|
||||
* @param {string} options.type - 'transaction' or 'raw-builder'
|
||||
* @param {Function} options.handler - Handler function
|
||||
* @param {object} options.handler.knexInstance - Knex query builder or transaction instance
|
||||
*
|
||||
* @returns {Promise<any>} Result from handler function
|
||||
*
|
||||
* @example
|
||||
* // Transaction
|
||||
* const result = await execute({
|
||||
* type: 'transaction',
|
||||
* handler: async (trx) => {
|
||||
* const user = await trx('users').insert({ name: 'John' }).returning('*');
|
||||
* await trx('listings').insert({ seller_id: user[0].id, title: 'Test' });
|
||||
* return user;
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Raw builder (for complex queries)
|
||||
* const result = await execute({
|
||||
* type: 'raw-builder',
|
||||
* handler: async (knex) => {
|
||||
* return await knex('listings')
|
||||
* .select('listings.*', 'animals.*')
|
||||
* .join('animals', 'listings.animal_id', 'animals.id')
|
||||
* .where('listings.deleted', false);
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export async function execute(options) {
|
||||
const { type, handler } = options;
|
||||
|
||||
if (!handler || typeof handler !== 'function') {
|
||||
throw new Error('Handler function is required');
|
||||
}
|
||||
|
||||
if (type === 'transaction') {
|
||||
return await db.transaction(async (trx) => {
|
||||
return await handler(trx);
|
||||
});
|
||||
} else if (type === 'raw-builder') {
|
||||
return await handler(db);
|
||||
} else {
|
||||
throw new Error(`Unsupported execute type: ${type}. Use 'transaction' or 'raw-builder'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single record by ID
|
||||
* Convenience method for common use case
|
||||
*
|
||||
* @param {string} table - Table name
|
||||
* @param {string} id - Record ID
|
||||
* @param {string[]} [columns] - Columns to select
|
||||
* @returns {Promise<object|null>} Record or null if not found
|
||||
*/
|
||||
export async function findById(table, id, columns = '*') {
|
||||
const results = await select({
|
||||
table,
|
||||
columns,
|
||||
where: { id },
|
||||
limit: 1,
|
||||
});
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records matching conditions
|
||||
*
|
||||
* @param {string} table - Table name
|
||||
* @param {object} [where] - WHERE conditions
|
||||
* @returns {Promise<number>} Count of records
|
||||
*/
|
||||
export async function count(table, where = {}) {
|
||||
validateTable(table);
|
||||
|
||||
let query = db(table);
|
||||
query = applyWhereConditions(query, where);
|
||||
|
||||
const result = await query.count('* as count').first();
|
||||
return parseInt(result.count, 10);
|
||||
}
|
||||
|
||||
// Export all methods
|
||||
export default {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
delete: deleteRecord,
|
||||
execute,
|
||||
findById,
|
||||
count,
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import knex from 'knex';
|
||||
import 'dotenv/config';
|
||||
|
||||
/**
|
||||
* Knex.js configuration for query helper
|
||||
*
|
||||
* Uses the same database connection settings as pool.js
|
||||
* but creates a separate Knex instance for the query builder.
|
||||
*
|
||||
* Note: This is separate from db/pool.js which uses the native pg Pool.
|
||||
* Both can coexist - pool.js for existing raw SQL queries,
|
||||
* and this for the new JSON-based query helper.
|
||||
*/
|
||||
const config = {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: process.env.PGHOST || '127.0.0.1',
|
||||
port: Number(process.env.PGPORT || 5432),
|
||||
user: process.env.PGUSER || 'postgres',
|
||||
password: process.env.PGPASSWORD || 'postgres',
|
||||
database: process.env.PGDATABASE || 'postgres',
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const db = knex(config);
|
||||
|
||||
export default db;
|
||||
|
||||
|
|
@ -1,51 +1,46 @@
|
|||
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)
|
||||
// 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);
|
||||
const expiredListings = await trx('listings')
|
||||
.select('id', 'title', 'seller_id')
|
||||
.where({ status: 'active', deleted: false })
|
||||
.whereRaw("updated_at < NOW() - INTERVAL '48 hours'")
|
||||
.forUpdate()
|
||||
.skipLocked();
|
||||
|
||||
if (expiredListings.length === 0) {
|
||||
await client.query("COMMIT");
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`Found ${expiredListings.length} listings to expire.`);
|
||||
|
||||
// 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]);
|
||||
await trx('listings')
|
||||
.whereIn('id', expiredIds)
|
||||
.update({ status: 'expired' });
|
||||
|
||||
// 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 }]);
|
||||
await trx('notifications').insert({
|
||||
user_id: listing.seller_id,
|
||||
type: 'listing_expired',
|
||||
message,
|
||||
data: { listing_id: listing.id }
|
||||
});
|
||||
|
||||
// Real-time Socket Emit
|
||||
const socketId = getSocketId(listing.seller_id);
|
||||
|
|
@ -58,14 +53,15 @@ export const startExpirationJob = () => {
|
|||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log("Expiration check completed successfully.");
|
||||
return expiredListings;
|
||||
}
|
||||
});
|
||||
|
||||
if (result && result.length > 0) {
|
||||
console.log("Expiration check completed successfully.");
|
||||
}
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("Error in expiration job:", err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"listingId": {
|
||||
"is_mandatory_field": true,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"phone": {
|
||||
"is_mandatory_field": true,
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"is_mandatory_field": false,
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
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,
|
||||
CASE
|
||||
WHEN c.buyer_id = $1 THEN u_seller.avatar_url
|
||||
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,
|
||||
CASE
|
||||
WHEN c.buyer_id = $1 THEN u_seller.id
|
||||
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
|
||||
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);
|
||||
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,65 +115,60 @@ 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, [
|
||||
const messageData = {
|
||||
conversation_id,
|
||||
sender_id,
|
||||
receiver_id,
|
||||
message_type,
|
||||
content,
|
||||
media_url || null,
|
||||
media_type || null,
|
||||
media_metadata || null
|
||||
]);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
|
||||
};
|
||||
|
|
@ -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 *
|
||||
`;
|
||||
// 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]);
|
||||
|
||||
const values = [
|
||||
lat, lng, source_type, source_confidence, selected_location,
|
||||
is_saved_address, location_type, country, state, district, city_village, pincode,
|
||||
id
|
||||
];
|
||||
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" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
64
server.js
64
server.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue