first commit after crearting ligin signup and token estoring in db
This commit is contained in:
commit
036b47235a
140
.cursor/rules/project_rules.mdc
Normal file
140
.cursor/rules/project_rules.mdc
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
RULE-001: API Design Standards
|
||||||
|
yamlrule_id: API-001
|
||||||
|
category: API Design
|
||||||
|
applies_to: All API endpoints
|
||||||
|
requirements:
|
||||||
|
- RESTful design principles
|
||||||
|
- Consistent response format (status, message, data, timestamp)
|
||||||
|
- Proper HTTP status codes
|
||||||
|
- API versioning (v1, v2)
|
||||||
|
- OpenAPI/Swagger documentation
|
||||||
|
validation:
|
||||||
|
- Success responses must follow {status, message, data, timestamp}
|
||||||
|
- Error responses must include error code and details
|
||||||
|
- Version must be in the endpoint path (/api/v1/...)
|
||||||
|
|
||||||
|
RULE-002: Authentication & Security
|
||||||
|
yamlrule_id: AUTH-002
|
||||||
|
category: Security
|
||||||
|
applies_to: All protected endpoints
|
||||||
|
requirements:
|
||||||
|
- JWT validation middleware
|
||||||
|
- OAuth 2.0 for external service integrations
|
||||||
|
- Token refresh mechanism
|
||||||
|
- Encrypted storage of OAuth tokens in MySQL
|
||||||
|
- Rate limiting by user/IP
|
||||||
|
validation:
|
||||||
|
- All routes must check for valid JWT
|
||||||
|
- OAuth tokens refreshed before expiry
|
||||||
|
- Failed login attempts logged with user/IP
|
||||||
|
|
||||||
|
RULE-003: n8n Integration Standards
|
||||||
|
yamlrule_id: N8N-003
|
||||||
|
category: Integration
|
||||||
|
applies_to: All external system connections via n8n
|
||||||
|
requirements:
|
||||||
|
- Standardized n8n workflows for Zoho, QuickBooks, HubSpot, BambooHR
|
||||||
|
- Webhook verification for data pushed into backend
|
||||||
|
- Error handling and retry logic in workflows
|
||||||
|
- Async processing for heavy sync tasks
|
||||||
|
- Logging of workflow execution status
|
||||||
|
validation:
|
||||||
|
- All workflows must use verified webhooks
|
||||||
|
- Workflow failures must trigger error events
|
||||||
|
- Retry mechanism configured for transient API failures
|
||||||
|
|
||||||
|
RULE-004: Database Operations
|
||||||
|
yamlrule_id: DB-004
|
||||||
|
category: Database
|
||||||
|
applies_to: All MySQL interactions
|
||||||
|
requirements:
|
||||||
|
- Use Sequelize ORM (or Prisma) for MySQL operations
|
||||||
|
- Strong relational schema with constraints
|
||||||
|
- Data validation at model level
|
||||||
|
- Indexing for frequently queried fields
|
||||||
|
- Soft delete for critical data
|
||||||
|
- Audit trail for token and integration logs
|
||||||
|
validation:
|
||||||
|
- All models must define schema + validations
|
||||||
|
- Foreign keys must enforce data integrity
|
||||||
|
- Sensitive data encrypted at rest
|
||||||
|
- Audit tables must log all changes in integrations
|
||||||
|
|
||||||
|
RULE-005: Background Jobs & Scheduling
|
||||||
|
yamlrule_id: JOB-005
|
||||||
|
category: Background Processing
|
||||||
|
applies_to: All scheduled tasks
|
||||||
|
requirements:
|
||||||
|
- Job queue implementation (Bull/Agenda/Redis Queue)
|
||||||
|
- Scheduled sync jobs with external services
|
||||||
|
- Dead letter queue for failed jobs
|
||||||
|
- Error handling and retry policies
|
||||||
|
- Monitoring + alerting for failed jobs
|
||||||
|
validation:
|
||||||
|
- Jobs must define timeout & retries
|
||||||
|
- Failed jobs logged with execution context
|
||||||
|
- DLQ retention period configured
|
||||||
|
|
||||||
|
RULE-006: Caching Strategy
|
||||||
|
yamlrule_id: CACHE-006
|
||||||
|
category: Performance
|
||||||
|
applies_to: All cache operations
|
||||||
|
requirements:
|
||||||
|
- Redis for session storage and caching API responses
|
||||||
|
- TTL-based cache expiration
|
||||||
|
- Cache invalidation strategies for sync jobs
|
||||||
|
- Cache key naming: {service}:{operation}:{identifier}
|
||||||
|
validation:
|
||||||
|
- Cache entries must always include TTL
|
||||||
|
- Sync workflows must invalidate outdated cache
|
||||||
|
- Cache hit/miss ratio tracked in monitoring
|
||||||
|
|
||||||
|
RULE-007: Error Handling & Logging
|
||||||
|
yamlrule_id: ERROR-007
|
||||||
|
category: Reliability
|
||||||
|
applies_to: All backend services
|
||||||
|
requirements:
|
||||||
|
- Centralized error middleware
|
||||||
|
- Structured logging with correlation IDs
|
||||||
|
- Error classification: system, integration, validation
|
||||||
|
- Log levels: ERROR, WARN, INFO, DEBUG
|
||||||
|
- Log rotation & retention policies
|
||||||
|
validation:
|
||||||
|
- Logs must never include sensitive tokens
|
||||||
|
- All errors logged with workflow ID (if integration-related)
|
||||||
|
- Error responses must mask internal details
|
||||||
|
|
||||||
|
RULE-008: Workflow Integration (n8n Specific)
|
||||||
|
yamlrule_id: WORKFLOW-008
|
||||||
|
category: Integration
|
||||||
|
applies_to: All workflows triggered via n8n
|
||||||
|
requirements:
|
||||||
|
- Secure webhook verification
|
||||||
|
- Async workflow execution for long tasks
|
||||||
|
- Workflow status tracking in MySQL
|
||||||
|
- Monitoring for success/failure metrics
|
||||||
|
- Recovery mechanisms for failed sync
|
||||||
|
validation:
|
||||||
|
- Workflows must store run status in DB
|
||||||
|
- Webhook events verified via signatures
|
||||||
|
- Failures trigger retry + alert notification
|
||||||
|
|
||||||
|
RULE-009: Environment Configuration
|
||||||
|
yamlrule_id: CONFIG-009
|
||||||
|
category: Configuration
|
||||||
|
applies_to: All environments
|
||||||
|
requirements:
|
||||||
|
- Environment-specific configs (dev/stage/prod)
|
||||||
|
- Secrets in Vault/ENV, not in source code
|
||||||
|
- Feature flagging for experimental services
|
||||||
|
- Health check endpoints
|
||||||
|
- Graceful shutdown on service stop
|
||||||
|
validation:
|
||||||
|
- Secrets must be injected at runtime
|
||||||
|
- Health checks must validate DB, Redis, and n8n connectivity
|
||||||
|
- Feature flags documented per environment
|
||||||
195
.cursor/rules/project_structure.mdc
Normal file
195
.cursor/rules/project_structure.mdc
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
RULE-001: Root Structure
|
||||||
|
yamlrule_id: FS-001
|
||||||
|
category: Folder Structure
|
||||||
|
applies_to: Project Root
|
||||||
|
requirements:
|
||||||
|
- Must include the following top-level folders:
|
||||||
|
- /src → All application code
|
||||||
|
- /config → Configuration files
|
||||||
|
- /scripts → Utility scripts (migrations, seeds)
|
||||||
|
- /tests → Unit & integration tests
|
||||||
|
- /docs → Documentation & API specs
|
||||||
|
- Must include the following root files:
|
||||||
|
- package.json
|
||||||
|
- .env.example
|
||||||
|
- README.md
|
||||||
|
- tsconfig.json (if TypeScript)
|
||||||
|
validation:
|
||||||
|
- No business logic in root folder
|
||||||
|
- Config files must not include secrets
|
||||||
|
|
||||||
|
RULE-002: API Layer
|
||||||
|
yamlrule_id: FS-002
|
||||||
|
category: Folder Structure
|
||||||
|
applies_to: /src/api
|
||||||
|
requirements:
|
||||||
|
- /src/api must contain:
|
||||||
|
- /routes → API route definitions
|
||||||
|
- /controllers → Request handlers
|
||||||
|
- /middlewares → Shared middleware (auth, rate limit, logging)
|
||||||
|
- /validators → Request validation schemas
|
||||||
|
validation:
|
||||||
|
- Routes must only delegate to controllers
|
||||||
|
- Controllers must not include business logic
|
||||||
|
- Middleware must be reusable across services
|
||||||
|
|
||||||
|
RULE-003: Authentication
|
||||||
|
yamlrule_id: FS-003
|
||||||
|
category: Security
|
||||||
|
applies_to: /src/auth
|
||||||
|
requirements:
|
||||||
|
- Must include:
|
||||||
|
- jwt.service.js (JWT management)
|
||||||
|
- oauth.service.js (OAuth 2.0 flows)
|
||||||
|
- session.service.js (session handling)
|
||||||
|
- Must store provider configs in /config/auth.js
|
||||||
|
validation:
|
||||||
|
- Auth services must not contain route logic
|
||||||
|
- Token utilities must be stateless
|
||||||
|
|
||||||
|
RULE-004: Business Logic Layer
|
||||||
|
yamlrule_id: FS-004
|
||||||
|
category: Application Logic
|
||||||
|
applies_to: /src/services
|
||||||
|
requirements:
|
||||||
|
- Each domain service in its own folder:
|
||||||
|
- /dashboard → Dashboard services
|
||||||
|
- /integration → Integration orchestration
|
||||||
|
- /reporting → Reporting/aggregation (future BI)
|
||||||
|
- Business logic implemented as service classes/functions
|
||||||
|
validation:
|
||||||
|
- Services must not directly access DB models
|
||||||
|
- Services must call repository layer
|
||||||
|
|
||||||
|
RULE-005: Integration Layer (n8n & APIs)
|
||||||
|
yamlrule_id: FS-005
|
||||||
|
category: Integration
|
||||||
|
applies_to: /src/integrations
|
||||||
|
requirements:
|
||||||
|
- Must include subfolders per integration:
|
||||||
|
- /zoho
|
||||||
|
- /quickbooks
|
||||||
|
- /hubspot
|
||||||
|
- /bamboohr
|
||||||
|
- /n8n
|
||||||
|
- Each integration must contain:
|
||||||
|
- client.js → API client
|
||||||
|
- mapper.js → Data mapping/transformation
|
||||||
|
- handler.js → Webhook/event handler
|
||||||
|
validation:
|
||||||
|
- No direct DB writes inside client files
|
||||||
|
- Handlers must go through service layer
|
||||||
|
|
||||||
|
RULE-006: Data Persistence Layer
|
||||||
|
yamlrule_id: FS-006
|
||||||
|
category: Data Layer
|
||||||
|
applies_to: /src/data
|
||||||
|
requirements:
|
||||||
|
- Must include:
|
||||||
|
- /models → Sequelize/Prisma models
|
||||||
|
- /repositories → Data access logic
|
||||||
|
- /migrations → Database migrations
|
||||||
|
- /seeds → Initial test/demo data
|
||||||
|
validation:
|
||||||
|
- Repositories must be the only layer accessing models
|
||||||
|
- No raw queries in services (must go through repository)
|
||||||
|
|
||||||
|
RULE-007: Background Jobs
|
||||||
|
yamlrule_id: FS-007
|
||||||
|
category: Jobs
|
||||||
|
applies_to: /src/jobs
|
||||||
|
requirements:
|
||||||
|
- Must include:
|
||||||
|
- /workers → Queue processors
|
||||||
|
- /schedulers → Cron/scheduled tasks
|
||||||
|
- /queues → Job definitions
|
||||||
|
validation:
|
||||||
|
- Jobs must not block main thread
|
||||||
|
- Workers must log execution status
|
||||||
|
|
||||||
|
RULE-008: Utilities & Shared Modules
|
||||||
|
yamlrule_id: FS-008
|
||||||
|
category: Utilities
|
||||||
|
applies_to: /src/utils
|
||||||
|
requirements:
|
||||||
|
- Must include:
|
||||||
|
- logger.js
|
||||||
|
- error-handler.js
|
||||||
|
- constants.js
|
||||||
|
- helpers.js
|
||||||
|
- Utilities must not depend on services
|
||||||
|
validation:
|
||||||
|
- Utilities must be stateless
|
||||||
|
- Logger must include correlation IDs
|
||||||
|
|
||||||
|
RULE-009: Configuration
|
||||||
|
yamlrule_id: FS-009
|
||||||
|
category: Configuration
|
||||||
|
applies_to: /config
|
||||||
|
requirements:
|
||||||
|
- Must include:
|
||||||
|
- database.js (MySQL config)
|
||||||
|
- redis.js (cache config)
|
||||||
|
- auth.js (OAuth provider config)
|
||||||
|
- app.js (app-level configs)
|
||||||
|
- Must support multiple environments (dev/stage/prod)
|
||||||
|
validation:
|
||||||
|
- No hardcoded secrets
|
||||||
|
- Configs must be environment-driven
|
||||||
|
|
||||||
|
RULE-010: Testing
|
||||||
|
yamlrule_id: FS-010
|
||||||
|
category: Testing
|
||||||
|
applies_to: /tests
|
||||||
|
requirements:
|
||||||
|
- Must include:
|
||||||
|
- /unit → Unit tests per service
|
||||||
|
- /integration → API & DB integration tests
|
||||||
|
- /mocks → Mock data
|
||||||
|
- Must use Jest or Mocha
|
||||||
|
validation:
|
||||||
|
- All controllers must have unit tests
|
||||||
|
- Critical integrations must have mock-based tests
|
||||||
|
|
||||||
|
/CentralizedReportingBackend
|
||||||
|
/src
|
||||||
|
/api
|
||||||
|
/routes
|
||||||
|
/controllers
|
||||||
|
/middlewares
|
||||||
|
/validators
|
||||||
|
/auth
|
||||||
|
/services
|
||||||
|
/dashboard
|
||||||
|
/integration
|
||||||
|
/integrations
|
||||||
|
/zoho
|
||||||
|
/quickbooks
|
||||||
|
/hubspot
|
||||||
|
/bamboohr
|
||||||
|
/n8n
|
||||||
|
/data
|
||||||
|
/models
|
||||||
|
/repositories
|
||||||
|
/migrations
|
||||||
|
/seeds
|
||||||
|
/jobs
|
||||||
|
/workers
|
||||||
|
/schedulers
|
||||||
|
/queues
|
||||||
|
/utils
|
||||||
|
/config
|
||||||
|
/scripts
|
||||||
|
/tests
|
||||||
|
/unit
|
||||||
|
/integration
|
||||||
|
/mocks
|
||||||
|
/docs
|
||||||
|
package.json
|
||||||
|
README.md
|
||||||
|
.env.example
|
||||||
83
.gitignore
vendored
Normal file
83
.gitignore
vendored
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
uploads/
|
||||||
|
npm-debug.log*
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
tmp/
|
||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
*.pbxuser
|
||||||
|
!default.pbxuser
|
||||||
|
*.mode1v3
|
||||||
|
!default.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
!default.mode2v3
|
||||||
|
*.perspectivev3
|
||||||
|
!default.perspectivev3
|
||||||
|
xcuserdata
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
DerivedData
|
||||||
|
*.hmap
|
||||||
|
*.ipa
|
||||||
|
*.xcuserstate
|
||||||
|
**/.xcode.env.local
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
*.keystore
|
||||||
|
!debug.keystore
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# node.js
|
||||||
|
#
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
#
|
||||||
|
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
|
||||||
|
# screenshots whenever they are needed.
|
||||||
|
# For more information about the recommended setup visit:
|
||||||
|
# https://docs.fastlane.tools/best-practices/source-control/
|
||||||
|
|
||||||
|
**/fastlane/report.xml
|
||||||
|
**/fastlane/Preview.html
|
||||||
|
**/fastlane/screenshots
|
||||||
|
**/fastlane/test_output
|
||||||
|
|
||||||
|
# Bundle artifact
|
||||||
|
*.jsbundle
|
||||||
|
|
||||||
|
# Ruby / CocoaPods
|
||||||
|
**/Pods/
|
||||||
|
/vendor/bundle/
|
||||||
|
|
||||||
|
# Temporary files created by Metro to check the health of the file watcher
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Centralized Reporting Backend
|
||||||
|
|
||||||
|
Quick start
|
||||||
|
|
||||||
|
1. Copy env
|
||||||
|
- `cp .env.example .env`
|
||||||
|
2. Start MySQL and Redis
|
||||||
|
3. Run migration
|
||||||
|
- `node src/db/migrate.js`
|
||||||
|
4. Start dev server
|
||||||
|
- `npm run dev`
|
||||||
|
|
||||||
|
API
|
||||||
|
|
||||||
|
- Health: `GET /health`
|
||||||
|
- Users:
|
||||||
|
- `POST /api/v1/users/register` { email, password, firstName?, lastName? }
|
||||||
|
- `GET /api/v1/users/me` (Bearer token required)
|
||||||
|
- `PUT /api/v1/users/me` (Bearer token, form-data `profilePicture`)
|
||||||
|
- `DELETE /api/v1/users/me` (Bearer token)
|
||||||
9
config/app.js
Normal file
9
config/app.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT || '4000', 10),
|
||||||
|
apiPrefix: process.env.API_PREFIX || '/api/v1',
|
||||||
|
appName: process.env.APP_NAME || 'CentralizedReporting'
|
||||||
|
};
|
||||||
|
|
||||||
11
config/database.js
Normal file
11
config/database.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
module.exports = {
|
||||||
|
username: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || 'Admin@123',
|
||||||
|
database: process.env.DB_NAME || 'centralized_reporting',
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||||
|
dialect: 'mysql',
|
||||||
|
logging: false,
|
||||||
|
};
|
||||||
|
|
||||||
2559
package-lock.json
generated
Normal file
2559
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "centralized-reporting-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Centralized Reporting Backend - Express + Sequelize + MySQL + Redis",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"migrate:sync": "node scripts/sync.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-async-errors": "^3.1.1",
|
||||||
|
"express-rate-limit": "^8.1.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"ioredis": "^5.7.0",
|
||||||
|
"joi": "^18.0.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"mysql2": "^3.14.5",
|
||||||
|
"sequelize": "^6.37.7",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/api/controllers/authController.js
Normal file
52
src/api/controllers/authController.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const { success } = require('../../utils/response');
|
||||||
|
const repo = require('../../data/repositories/userRepository');
|
||||||
|
const jwtService = require('../../auth/jwt.service');
|
||||||
|
const session = require('../../auth/session.service');
|
||||||
|
|
||||||
|
async function login(req, res) {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
const user = await repo.findByEmail(email);
|
||||||
|
if (!user) return res.status(401).json({ status: 'error', message: 'Invalid credentials', errorCode: 'BAD_CREDENTIALS', timestamp: new Date().toISOString() });
|
||||||
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!ok) return res.status(401).json({ status: 'error', message: 'Invalid credentials', errorCode: 'BAD_CREDENTIALS', timestamp: new Date().toISOString() });
|
||||||
|
const accessToken = jwtService.sign({ uuid: user.uuid, role: user.role });
|
||||||
|
const refreshToken = jwtService.sign({ uuid: user.uuid, type: 'refresh' }, { expiresIn: '7d' });
|
||||||
|
await session.storeRefreshToken(user.uuid, refreshToken);
|
||||||
|
const displayName = [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
res.json(
|
||||||
|
success('Logged in', {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
uuid: user.uuid,
|
||||||
|
email: user.email,
|
||||||
|
displayName,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(req, res) {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
try {
|
||||||
|
const payload = jwtService.verify(refreshToken);
|
||||||
|
if (payload.type !== 'refresh') throw new Error('Invalid token');
|
||||||
|
const stored = await session.getRefreshToken(payload.uuid);
|
||||||
|
if (stored !== refreshToken) return res.status(401).json({ status: 'error', message: 'Invalid refresh token', errorCode: 'INVALID_REFRESH', timestamp: new Date().toISOString() });
|
||||||
|
const accessToken = jwtService.sign({ uuid: payload.uuid, role: payload.role });
|
||||||
|
res.json(success('Token refreshed', { accessToken }));
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Invalid refresh token', errorCode: 'INVALID_REFRESH', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(req, res) {
|
||||||
|
await session.revokeRefreshToken(req.user.uuid);
|
||||||
|
res.json(success('Logged out'));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { login, refresh, logout };
|
||||||
|
|
||||||
73
src/api/controllers/userController.js
Normal file
73
src/api/controllers/userController.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const { success, failure } = require('../../utils/response');
|
||||||
|
const service = require('../../services/userService');
|
||||||
|
const axios = require('axios');
|
||||||
|
const userAuthTokenRepo = require('../../data/repositories/userAuthTokenRepository');
|
||||||
|
const { encrypt } = require('../../utils/crypto');
|
||||||
|
|
||||||
|
async function register(req, res) {
|
||||||
|
const user = await service.registerUser(req.body);
|
||||||
|
res.status(201).json(success('User registered', { uuid: user.uuid, email: user.email }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function me(req, res) {
|
||||||
|
const user = await service.getProfile(req.user.uuid);
|
||||||
|
res.json(success('Profile', user));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMe(req, res) {
|
||||||
|
const updates = { ...req.body };
|
||||||
|
if (req.file) {
|
||||||
|
updates.profilePicture = `/uploads/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
const user = await service.updateProfile(req.user.uuid, updates);
|
||||||
|
res.json(success('Profile updated', user));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMe(req, res) {
|
||||||
|
await service.removeUser(req.user.uuid);
|
||||||
|
res.json(success('Account removed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register, me, updateMe, removeMe };
|
||||||
|
|
||||||
|
// Exchange Zoho authorization code for tokens and persist
|
||||||
|
async function exchangeZohoToken(req, res) {
|
||||||
|
const { authorization_code, id, service_name } = req.body;
|
||||||
|
// Optional: ensure the id belongs to the authenticated user (if business rule requires)
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('code', authorization_code);
|
||||||
|
params.append('client_id', process.env.ZOHO_CLIENT_ID);
|
||||||
|
params.append('client_secret', process.env.ZOHO_CLIENT_SECRET);
|
||||||
|
params.append('redirect_uri', process.env.ZOHO_REDIRECT_URI || 'centralizedreportingsystem://oauth/callback');
|
||||||
|
params.append('grant_type', 'authorization_code');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await axios.post('https://accounts.zoho.com/oauth/v2/token', params.toString(), {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
});
|
||||||
|
const data = resp.data || {};
|
||||||
|
// Handle cases where Zoho returns an error payload (e.g., { error: 'invalid_code' })
|
||||||
|
if (data.error === 'invalid_code' || !data.access_token) {
|
||||||
|
return res.status(400).json(
|
||||||
|
failure('Invalid authorization code', 'ZOHO_INVALID_CODE', data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { access_token, refresh_token, expires_in } = data;
|
||||||
|
|
||||||
|
const expiresAt = expires_in ? new Date(Date.now() + expires_in * 1000) : null;
|
||||||
|
await userAuthTokenRepo.createToken({
|
||||||
|
userId: id,
|
||||||
|
serviceName: service_name,
|
||||||
|
accessToken: encrypt(access_token),
|
||||||
|
refreshToken: refresh_token ? encrypt(refresh_token) : null,
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(success('Zoho tokens stored', data));
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json(failure('Zoho token exchange failed', 'ZOHO_TOKEN_EXCHANGE_FAILED', e.response?.data || e.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.exchangeZohoToken = exchangeZohoToken;
|
||||||
18
src/api/middlewares/auth.js
Normal file
18
src/api/middlewares/auth.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const config = require('../../config');
|
||||||
|
|
||||||
|
module.exports = function auth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Unauthorized', errorCode: 'NO_TOKEN', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, config.auth.jwtSecret);
|
||||||
|
req.user = payload;
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Invalid token', errorCode: 'INVALID_TOKEN', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
20
src/api/middlewares/errorHandler.js
Normal file
20
src/api/middlewares/errorHandler.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const { failure } = require('../utils/response');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = function errorHandler(err, req, res, next) {
|
||||||
|
const correlationId = logger.getCorrelationId(req);
|
||||||
|
const status = err.status || 500;
|
||||||
|
const errorCode = err.code || 'INTERNAL_SERVER_ERROR';
|
||||||
|
const message = status === 500 ? 'Something went wrong' : err.message || 'Error';
|
||||||
|
|
||||||
|
logger.error('Request failed', {
|
||||||
|
correlationId,
|
||||||
|
path: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
stack: status === 500 ? err.stack : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(status).json(failure(message, errorCode));
|
||||||
|
};
|
||||||
24
src/api/routes/authRoutes.js
Normal file
24
src/api/routes/authRoutes.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Joi = require('joi');
|
||||||
|
const { login, refresh, logout } = require('../controllers/authController');
|
||||||
|
const auth = require('../middlewares/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function validate(schema) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true });
|
||||||
|
if (error) return res.status(400).json({ status: 'error', message: 'Validation failed', errorCode: 'VALIDATION_ERROR', details: error.details, timestamp: new Date().toISOString() });
|
||||||
|
req.body = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSchema = Joi.object({ email: Joi.string().email().required(), password: Joi.string().required() });
|
||||||
|
const refreshSchema = Joi.object({ refreshToken: Joi.string().required() });
|
||||||
|
|
||||||
|
router.post('/login', validate(loginSchema), login);
|
||||||
|
router.post('/refresh', validate(refreshSchema), refresh);
|
||||||
|
router.post('/logout', auth, logout);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
42
src/api/routes/userRoutes.js
Normal file
42
src/api/routes/userRoutes.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const { register, me, updateMe, removeMe, exchangeZohoToken } = require('../controllers/userController');
|
||||||
|
const auth = require('../middlewares/auth');
|
||||||
|
const { registerSchema, updateSchema } = require('../validators/userValidator');
|
||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, path.join(process.cwd(), 'uploads')),
|
||||||
|
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
|
||||||
|
});
|
||||||
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
function validate(schema) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const toValidate = req.method === 'GET' ? req.query : req.body;
|
||||||
|
const { error, value } = schema.validate(toValidate, { abortEarly: false, stripUnknown: true });
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Validation failed', errorCode: 'VALIDATION_ERROR', details: error.details, timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
if (req.method === 'GET') req.query = value; else req.body = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/register', validate(registerSchema), register);
|
||||||
|
router.get('/me', auth, me);
|
||||||
|
router.put('/me', auth, upload.single('profilePicture'), validate(updateSchema), updateMe);
|
||||||
|
router.delete('/me', auth, removeMe);
|
||||||
|
|
||||||
|
// OAuth token exchange (Zoho request currently)
|
||||||
|
const zohoTokenSchema = Joi.object({
|
||||||
|
authorization_code: Joi.string().required(),
|
||||||
|
id: Joi.number().required(),
|
||||||
|
service_name: Joi.string().valid('zoho', 'keka', 'bamboohr', 'hubspot', 'other').required()
|
||||||
|
});
|
||||||
|
router.post('/zoho/token', auth, validate(zohoTokenSchema), exchangeZohoToken);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
17
src/api/validators/userValidator.js
Normal file
17
src/api/validators/userValidator.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
const registerSchema = Joi.object({
|
||||||
|
email: Joi.string().email().required(),
|
||||||
|
password: Joi.string().min(6).required(),
|
||||||
|
firstName: Joi.string().allow('', null),
|
||||||
|
lastName: Joi.string().allow('', null)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = Joi.object({
|
||||||
|
email: Joi.string().email(),
|
||||||
|
firstName: Joi.string().allow('', null),
|
||||||
|
lastName: Joi.string().allow('', null)
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { registerSchema, updateSchema };
|
||||||
|
|
||||||
44
src/app.js
Normal file
44
src/app.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
require('express-async-errors');
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const cors = require('cors');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { success } = require('./utils/response');
|
||||||
|
const config = require('./config');
|
||||||
|
const userRoutes = require('./api/routes/userRoutes');
|
||||||
|
const authRoutes = require('./api/routes/authRoutes');
|
||||||
|
const sequelize = require('./db/pool');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||||
|
|
||||||
|
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
|
||||||
|
app.use(limiter);
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send('Welcome to Centralized Reporting System Backend');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
res.json(success('OK', { db: 'up', env: config.app.env }));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ status: 'error', message: 'DB check failed', errorCode: 'HEALTH_FAIL', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(`${config.app.apiPrefix}/auth`, authRoutes);
|
||||||
|
app.use(`${config.app.apiPrefix}/users`, userRoutes);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
13
src/auth/jwt.service.js
Normal file
13
src/auth/jwt.service.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
function sign(payload, opts = {}) {
|
||||||
|
return jwt.sign(payload, config.auth.jwtSecret, { expiresIn: config.auth.jwtExpiresIn, ...opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify(token) {
|
||||||
|
return jwt.verify(token, config.auth.jwtSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sign, verify };
|
||||||
|
|
||||||
69
src/auth/session.service.js
Normal file
69
src/auth/session.service.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
const Redis = require('ioredis');
|
||||||
|
const config = require('../config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const REDIS_ENABLED = (process.env.REDIS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
let redis = null;
|
||||||
|
let memoryStore = new Map();
|
||||||
|
|
||||||
|
if (REDIS_ENABLED) {
|
||||||
|
redis = new Redis({
|
||||||
|
host: config.redis.host,
|
||||||
|
port: config.redis.port,
|
||||||
|
password: config.redis.password,
|
||||||
|
lazyConnect: true,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy: () => null
|
||||||
|
});
|
||||||
|
redis.on('error', (err) => {
|
||||||
|
logger.warn('Redis error (using fallback if needed)', { message: err.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureRedis() {
|
||||||
|
if (!REDIS_ENABLED || !redis) return null;
|
||||||
|
if (redis.status !== 'ready') {
|
||||||
|
try {
|
||||||
|
await redis.connect();
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Redis connect failed, using in-memory fallback', { message: e.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeRefreshToken(userUuid, token, ttlSeconds = 60 * 60 * 24 * 7) {
|
||||||
|
const key = `auth:refresh:${userUuid}`;
|
||||||
|
const client = await ensureRedis();
|
||||||
|
if (client) return client.set(key, token, 'EX', ttlSeconds);
|
||||||
|
memoryStore.set(key, { token, expiresAt: Date.now() + ttlSeconds * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRefreshToken(userUuid) {
|
||||||
|
const key = `auth:refresh:${userUuid}`;
|
||||||
|
const client = await ensureRedis();
|
||||||
|
if (client) return client.get(key);
|
||||||
|
const item = memoryStore.get(key);
|
||||||
|
if (!item) return null;
|
||||||
|
if (item.expiresAt < Date.now()) { memoryStore.delete(key); return null; }
|
||||||
|
return item.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeRefreshToken(userUuid) {
|
||||||
|
const key = `auth:refresh:${userUuid}`;
|
||||||
|
const client = await ensureRedis();
|
||||||
|
if (client) return client.del(key);
|
||||||
|
memoryStore.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ping() {
|
||||||
|
const client = await ensureRedis();
|
||||||
|
if (client) return client.ping();
|
||||||
|
return 'PONG';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { storeRefreshToken, getRefreshToken, revokeRefreshToken, ping };
|
||||||
|
|
||||||
22
src/config/index.js
Normal file
22
src/config/index.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const appConfig = require('../../config/app');
|
||||||
|
const dbConfig = require('../../config/database');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
app: appConfig,
|
||||||
|
db: dbConfig,
|
||||||
|
auth: {
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'changeme',
|
||||||
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1d'
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined
|
||||||
|
},
|
||||||
|
n8n: {
|
||||||
|
baseUrl: process.env.N8N_BASE_URL || 'http://localhost:5678',
|
||||||
|
webhookSecret: process.env.N8N_WEBHOOK_SECRET || 'changeme'
|
||||||
|
}
|
||||||
|
};
|
||||||
31
src/data/models/user.js
Normal file
31
src/data/models/user.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const { DataTypes, Model } = require('sequelize');
|
||||||
|
const sequelize = require('../../db/pool');
|
||||||
|
|
||||||
|
class User extends Model {}
|
||||||
|
|
||||||
|
User.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true },
|
||||||
|
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
|
||||||
|
passwordHash: { field: 'password_hash', type: DataTypes.STRING(255), allowNull: false },
|
||||||
|
firstName: { field: 'first_name', type: DataTypes.STRING(100), allowNull: true },
|
||||||
|
lastName: { field: 'last_name', type: DataTypes.STRING(100), allowNull: true },
|
||||||
|
profilePicture: { field: 'profile_picture', type: DataTypes.STRING(512), allowNull: true },
|
||||||
|
role: { type: DataTypes.ENUM('admin', 'manager', 'user'), defaultValue: 'user', allowNull: false },
|
||||||
|
isActive: { field: 'is_active', type: DataTypes.BOOLEAN, defaultValue: true },
|
||||||
|
createdAt: { field: 'created_at', type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||||
|
updatedAt: { field: 'updated_at', type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||||
|
deletedAt: { field: 'deleted_at', type: DataTypes.DATE, allowNull: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'User',
|
||||||
|
tableName: 'users',
|
||||||
|
paranoid: true,
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = User;
|
||||||
|
|
||||||
45
src/data/models/userAuthToken.js
Normal file
45
src/data/models/userAuthToken.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const { DataTypes, Model } = require('sequelize');
|
||||||
|
const sequelize = require('../../db/pool');
|
||||||
|
const User = require('./user');
|
||||||
|
|
||||||
|
class UserAuthToken extends Model {}
|
||||||
|
|
||||||
|
UserAuthToken.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
userId: {
|
||||||
|
field: 'user_id',
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: 'users', key: 'id' },
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
serviceName: {
|
||||||
|
field: 'service_name',
|
||||||
|
type: DataTypes.ENUM('zoho', 'keka', 'bamboohr', 'hubspot', 'other'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
accessToken: { field: 'access_token', type: DataTypes.TEXT, allowNull: false },
|
||||||
|
refreshToken: { field: 'refresh_token', type: DataTypes.TEXT, allowNull: true },
|
||||||
|
expiresAt: { field: 'expires_at', type: DataTypes.DATE, allowNull: true },
|
||||||
|
createdAt: { field: 'created_at', type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||||
|
updatedAt: { field: 'updated_at', type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'UserAuthToken',
|
||||||
|
tableName: 'user_auth_tokens',
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: false,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['user_id'] },
|
||||||
|
{ fields: ['service_name'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
UserAuthToken.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
|
module.exports = UserAuthToken;
|
||||||
|
|
||||||
|
|
||||||
13
src/data/repositories/userAuthTokenRepository.js
Normal file
13
src/data/repositories/userAuthTokenRepository.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const UserAuthToken = require('../models/userAuthToken');
|
||||||
|
|
||||||
|
async function createToken(payload) {
|
||||||
|
return UserAuthToken.create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByUserAndService(userId, serviceName) {
|
||||||
|
return UserAuthToken.findOne({ where: { userId, serviceName } });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createToken, findByUserAndService };
|
||||||
|
|
||||||
|
|
||||||
30
src/data/repositories/userRepository.js
Normal file
30
src/data/repositories/userRepository.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const User = require('../models/user');
|
||||||
|
|
||||||
|
async function createUser(payload) {
|
||||||
|
return User.create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByEmail(email) {
|
||||||
|
return User.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByUuid(uuid) {
|
||||||
|
return User.findOne({ where: { uuid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateByUuid(uuid, updates) {
|
||||||
|
const user = await findByUuid(uuid);
|
||||||
|
if (!user) return null;
|
||||||
|
await user.update(updates);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByUuid(uuid) {
|
||||||
|
const user = await findByUuid(uuid);
|
||||||
|
if (!user) return null;
|
||||||
|
await user.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createUser, findByEmail, findByUuid, updateByUuid, deleteByUuid };
|
||||||
|
|
||||||
32
src/db/migrate.js
Normal file
32
src/db/migrate.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
const dbConfig = require('../../config/database');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const sequelize = new Sequelize(dbConfig.database, dbConfig.username, dbConfig.password, {
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
dialect: 'mysql',
|
||||||
|
logging: dbConfig.logging
|
||||||
|
});
|
||||||
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
|
const files = fs.readdirSync(migrationsDir)
|
||||||
|
.filter((f) => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const sqlPath = path.join(migrationsDir, file);
|
||||||
|
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||||
|
await sequelize.query(sql);
|
||||||
|
}
|
||||||
|
await sequelize.close();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Migrations completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
14
src/db/migrations/001_create_users.sql
Normal file
14
src/db/migrations/001_create_users.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(100) NULL,
|
||||||
|
last_name VARCHAR(100) NULL,
|
||||||
|
profile_picture VARCHAR(512) NULL,
|
||||||
|
role ENUM('admin','manager','user') NOT NULL DEFAULT 'user',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
13
src/db/migrations/002_create_user_auth_tokens.sql
Normal file
13
src/db/migrations/002_create_user_auth_tokens.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_auth_tokens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
service_name ENUM('zoho','keka','bamboohr','hubspot','other') NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NULL,
|
||||||
|
expires_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_user_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
12
src/db/pool.js
Normal file
12
src/db/pool.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
const dbConfig = require('../../config/database');
|
||||||
|
|
||||||
|
const sequelize = new Sequelize(dbConfig.database, dbConfig.username, dbConfig.password, {
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
dialect: 'mysql',
|
||||||
|
logging: dbConfig.logging,
|
||||||
|
pool: { max: 10, min: 0, acquire: 30000, idle: 10000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = sequelize;
|
||||||
21
src/middlewares/errorHandler.js
Normal file
21
src/middlewares/errorHandler.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const { failure } = require('../utils/response');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = function errorHandler(err, req, res, next) {
|
||||||
|
const correlationId = logger.getCorrelationId(req);
|
||||||
|
const status = err.status || 500;
|
||||||
|
const errorCode = err.code || 'INTERNAL_SERVER_ERROR';
|
||||||
|
const message = status === 500 ? 'Something went wrong' : err.message || 'Error';
|
||||||
|
|
||||||
|
logger.error('Request failed', {
|
||||||
|
correlationId,
|
||||||
|
path: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
stack: status === 500 ? err.stack : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(status).json(failure(message, errorCode));
|
||||||
|
};
|
||||||
|
|
||||||
15
src/server.js
Normal file
15
src/server.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const app = require('./app');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const server = app.listen(config.app.port, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Server listening on port ${config.app.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
});
|
||||||
52
src/services/userService.js
Normal file
52
src/services/userService.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const repo = require('../data/repositories/userRepository');
|
||||||
|
|
||||||
|
async function registerUser({ email, password, firstName, lastName }) {
|
||||||
|
const exists = await repo.findByEmail(email);
|
||||||
|
if (exists) {
|
||||||
|
const err = new Error('Email already in use');
|
||||||
|
err.status = 409;
|
||||||
|
err.code = 'EMAIL_TAKEN';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const user = await repo.createUser({ email, passwordHash, firstName, lastName });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProfile(uuid) {
|
||||||
|
const user = await repo.findByUuid(uuid);
|
||||||
|
if (!user) {
|
||||||
|
const err = new Error('User not found');
|
||||||
|
err.status = 404;
|
||||||
|
err.code = 'USER_NOT_FOUND';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(uuid, updates) {
|
||||||
|
const allowed = ['email', 'firstName', 'lastName', 'profilePicture'];
|
||||||
|
const filtered = Object.fromEntries(Object.entries(updates).filter(([k]) => allowed.includes(k)));
|
||||||
|
const user = await repo.updateByUuid(uuid, filtered);
|
||||||
|
if (!user) {
|
||||||
|
const err = new Error('User not found');
|
||||||
|
err.status = 404;
|
||||||
|
err.code = 'USER_NOT_FOUND';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(uuid) {
|
||||||
|
const ok = await repo.deleteByUuid(uuid);
|
||||||
|
if (!ok) {
|
||||||
|
const err = new Error('User not found');
|
||||||
|
err.status = 404;
|
||||||
|
err.code = 'USER_NOT_FOUND';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerUser, getProfile, updateProfile, removeUser };
|
||||||
|
|
||||||
27
src/utils/crypto.js
Normal file
27
src/utils/crypto.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const algorithm = 'aes-256-gcm';
|
||||||
|
const key = crypto.createHash('sha256').update(process.env.ENCRYPTION_KEY || 'changeme').digest();
|
||||||
|
|
||||||
|
function encrypt(plaintext) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(ciphertext) {
|
||||||
|
const buf = Buffer.from(ciphertext, 'base64');
|
||||||
|
const iv = buf.subarray(0, 12);
|
||||||
|
const authTag = buf.subarray(12, 28);
|
||||||
|
const encrypted = buf.subarray(28);
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { encrypt, decrypt };
|
||||||
|
|
||||||
|
|
||||||
27
src/utils/logger.js
Normal file
27
src/utils/logger.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
|
||||||
|
function getCorrelationId(req) {
|
||||||
|
if (!req) return uuidv4();
|
||||||
|
const headerId = req.headers['x-correlation-id'];
|
||||||
|
if (headerId) return headerId;
|
||||||
|
const existing = req.correlationId;
|
||||||
|
if (existing) return existing;
|
||||||
|
const generated = randomUUID();
|
||||||
|
req.correlationId = generated;
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(level, message, meta = {}) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const payload = { level, message, timestamp, ...meta };
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
info: (msg, meta) => log('INFO', msg, meta),
|
||||||
|
warn: (msg, meta) => log('WARN', msg, meta),
|
||||||
|
error: (msg, meta) => log('ERROR', msg, meta),
|
||||||
|
debug: (msg, meta) => log('DEBUG', msg, meta),
|
||||||
|
getCorrelationId
|
||||||
|
};
|
||||||
9
src/utils/response.js
Normal file
9
src/utils/response.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
function success(message, data = null) {
|
||||||
|
return { status: 'success', message, data, timestamp: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(message, errorCode = 'GENERIC_ERROR', details = null) {
|
||||||
|
return { status: 'error', message, errorCode, details, timestamp: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { success, failure };
|
||||||
Loading…
Reference in New Issue
Block a user