Compare commits

...

6 Commits

Author SHA1 Message Date
0f2de751af Fix timezone mismatch and OAuth profile field access
- Add getLatestDateWithData() helper that queries the most recent date
  with price data instead of using server UTC time
- Cache latest date for 5 minutes to reduce database queries
- Update all date-dependent endpoints to use database-relative dates
- Fix OAuth callback profile field access (use profile.email instead
  of profile.emails[0].value)
- Remove loginCount from $setOnInsert to avoid MongoDB conflict error
2026-01-21 18:55:17 -07:00
89daa0c395 Add environment configuration logging at startup 2026-01-21 18:16:04 -07:00
baf54c90bc Add .dockerignore to prevent secrets from being copied into image 2026-01-21 18:14:52 -07:00
af52c33617 Add security fixes and deployment configuration
Security fixes:
- Remove hardcoded MongoDB credentials from server.js (fail fast in production)
- Add OAuth state parameter validation for CSRF protection
- Add input validation for unitCode parameter to prevent NoSQL injection
- Add isValidUnitCode helper function

Deployment:
- Update docker-compose.yml to use env_file and environment variables
- Create .env.example with all required configuration variables
2026-01-21 18:04:37 -07:00
ca281ba5b6 Add user activity logging system
- Create services/activityLogger.js with configurable log levels (all, navigation, none)
- Create routes/activity.js with endpoints for logging and admin statistics
- Integrate login/logout activity logging into auth routes
- Add TTL index for automatic 90-day cleanup of activity records
- Mount activity routes at /activity
2026-01-21 17:53:56 -07:00
67f2d9a12e Add Google OAuth authentication infrastructure
- Add passport, passport-google-oauth20, jsonwebtoken, cookie-parser, uuid deps
- Create config/auth.js with JWT, cookie, and OAuth configuration
- Create models/user.js with MongoDB user model and indexes
- Create middleware/passport.js with Google OAuth strategy
- Create middleware/auth.js with requireAuth middleware and sliding window refresh
- Create routes/auth.js with OAuth flow endpoints
- Update server.js to integrate auth, protect all data endpoints (except /health)
- Configure CORS for cookie-based authentication
2026-01-21 16:45:34 -07:00
13 changed files with 1247 additions and 59 deletions

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
# Environment and secrets
.env
.env.*
!.env.example
# Dependencies
node_modules
# Git
.git
.gitignore
# Development files
*.log
npm-debug.log*
.DS_Store
# Test files
test-endpoints.js
*.test.js
__tests__
# IDE
.vscode
.idea

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# MongoDB Connection
MONGO_URI=mongodb://username:password@host:27017
# Google OAuth Credentials (from Google Cloud Console)
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_CALLBACK_URL=https://apartments.maverickapplications.com/api/auth/google/callback
# JWT Configuration
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=generate-a-secure-random-string-minimum-32-characters
# Application URLs
FRONTEND_URL=https://apartments.maverickapplications.com
# Activity Logging Level: all, navigation, none
ACTIVITY_LOG_LEVEL=all
# Node Environment
NODE_ENV=production

24
config/auth.js Normal file
View File

@ -0,0 +1,24 @@
// Google OAuth and JWT configuration
module.exports = {
google: {
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: ['profile', 'email']
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d',
refreshThreshold: 24 * 60 * 60 // Refresh if token is older than 1 day (in seconds)
},
cookie: {
name: 'auth_token',
options: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
domain: process.env.NODE_ENV === 'production' ? '.maverickapplications.com' : undefined
}
}
};

View File

@ -3,10 +3,18 @@ services:
build: . build: .
container_name: apartment-api container_name: apartment-api
restart: unless-stopped restart: unless-stopped
env_file:
- .env
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=8080 # Changed from 3000 to 8080 - PORT=8080
- MONGO_URI=mongodb://admin:password123@mongodb:27017 - MONGO_URI=${MONGO_URI}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL}
- JWT_SECRET=${JWT_SECRET}
- FRONTEND_URL=${FRONTEND_URL}
- ACTIVITY_LOG_LEVEL=${ACTIVITY_LOG_LEVEL:-all}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik"

111
middleware/auth.js Normal file
View File

@ -0,0 +1,111 @@
const jwt = require('jsonwebtoken');
const authConfig = require('../config/auth');
const { findById } = require('../models/user');
/**
* Check if a token should be refreshed based on its age
* @param {Object} decoded - Decoded JWT payload
* @returns {boolean} True if token should be refreshed
*/
const shouldRefreshToken = (decoded) => {
const tokenAge = Math.floor(Date.now() / 1000) - decoded.iat;
return tokenAge > authConfig.jwt.refreshThreshold;
};
/**
* Generate a new JWT token for a user
* @param {string|ObjectId} userId - User's MongoDB _id
* @returns {string} JWT token
*/
const generateToken = (userId) => {
return jwt.sign(
{ userId: userId.toString() },
authConfig.jwt.secret,
{ expiresIn: authConfig.jwt.expiresIn }
);
};
/**
* Authentication middleware
* Validates JWT token from cookie and attaches user to request
* Implements sliding window token refresh
*
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* @param {Function} next - Express next function
*/
const requireAuth = async (req, res, next) => {
const token = req.cookies[authConfig.cookie.name];
// No token present
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
// Verify the token
const decoded = jwt.verify(token, authConfig.jwt.secret);
// Get database instance
const db = req.app.locals.db;
// Fetch user from database
const user = await findById(db, decoded.userId);
// User not found
if (!user) {
res.clearCookie(authConfig.cookie.name);
return res.status(401).json({ error: 'Authentication required' });
}
// User is disabled (silent logout)
if (!user.isActive) {
res.clearCookie(authConfig.cookie.name);
return res.status(401).json({ error: 'Authentication required' });
}
// Attach user to request
req.user = user;
// Sliding window token refresh
if (shouldRefreshToken(decoded)) {
const newToken = generateToken(user._id);
res.cookie(authConfig.cookie.name, newToken, authConfig.cookie.options);
}
next();
} catch (err) {
// Token verification failed (invalid or expired)
res.clearCookie(authConfig.cookie.name);
return res.status(401).json({ error: 'Invalid token' });
}
};
/**
* Admin authorization middleware
* Must be used after requireAuth middleware
* Checks if authenticated user has admin role
*
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* @param {Function} next - Express next function
*/
const requireAdmin = (req, res, next) => {
// Check if user is attached (requireAuth should be called first)
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
// Check if user has admin role
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
module.exports = {
requireAuth,
requireAdmin,
generateToken
};

36
middleware/passport.js Normal file
View File

@ -0,0 +1,36 @@
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const authConfig = require('../config/auth');
const { findOrCreateUser } = require('../models/user');
const configurePassport = (passport, db) => {
passport.use(new GoogleStrategy({
clientID: authConfig.google.clientID,
clientSecret: authConfig.google.clientSecret,
callbackURL: authConfig.google.callbackURL
},
async (accessToken, refreshToken, profile, done) => {
try {
// Safely extract email
const email = profile.emails?.[0]?.value;
if (!email) {
return done(new Error('No email found in Google profile'), null);
}
const userProfile = {
googleId: profile.id,
email: email,
name: profile.displayName,
picture: profile.photos?.[0]?.value || null
};
const user = await findOrCreateUser(db, userProfile);
done(null, user);
} catch (error) {
console.error('Passport strategy error:', error);
done(error, null);
}
}));
};
module.exports = { configurePassport };

128
models/user.js Normal file
View File

@ -0,0 +1,128 @@
const { ObjectId } = require('mongodb');
const USER_COLLECTION = 'users';
/**
* Find or create a user based on Google OAuth profile
* Uses upsert pattern to handle both new and returning users
*
* @param {Db} db - MongoDB database instance
* @param {Object} profile - Google OAuth profile
* @param {string} profile.id - Google's unique user ID
* @param {string} profile.displayName - User's display name
* @param {Array} profile.emails - Array of email objects
* @param {Array} profile.photos - Array of photo objects
* @returns {Promise<Object>} User document
*/
async function findOrCreateUser(db, profile) {
const now = new Date();
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
{ googleId: profile.googleId },
{
$set: {
email: profile.email,
name: profile.name,
picture: profile.picture || null,
lastLoginAt: now
},
$inc: { loginCount: 1 },
$setOnInsert: {
googleId: profile.googleId,
isActive: true,
role: 'user',
createdAt: now
}
},
{
upsert: true,
returnDocument: 'after'
}
);
return result;
}
/**
* Find a user by Google ID
*
* @param {Db} db - MongoDB database instance
* @param {string} googleId - Google's unique user ID
* @returns {Promise<Object|null>} User document or null
*/
async function findByGoogleId(db, googleId) {
return await db.collection(USER_COLLECTION).findOne({ googleId });
}
/**
* Find a user by MongoDB ObjectId
*
* @param {Db} db - MongoDB database instance
* @param {string|ObjectId} id - User's MongoDB _id
* @returns {Promise<Object|null>} User document or null
*/
async function findById(db, id) {
// Convert string to ObjectId if needed
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
return await db.collection(USER_COLLECTION).findOne({ _id: objectId });
}
/**
* Enable or disable a user account
*
* @param {Db} db - MongoDB database instance
* @param {string|ObjectId} id - User's MongoDB _id
* @param {boolean} isActive - New active status
* @returns {Promise<Object>} Update result
*/
async function setUserActive(db, id, isActive) {
// Convert string to ObjectId if needed
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
{ _id: objectId },
{ $set: { isActive } },
{ returnDocument: 'after' }
);
return result;
}
/**
* Create required indexes for the users collection
* Should be called once during application startup
*
* @param {Db} db - MongoDB database instance
* @returns {Promise<void>}
*/
async function createIndexes(db) {
const collection = db.collection(USER_COLLECTION);
// Unique index on googleId for OAuth lookups
await collection.createIndex(
{ googleId: 1 },
{ unique: true }
);
// Unique index on email for user identification
await collection.createIndex(
{ email: 1 },
{ unique: true }
);
// Compound index for querying active users sorted by last login
await collection.createIndex(
{ isActive: 1, lastLoginAt: -1 }
);
console.log('User collection indexes created successfully');
}
module.exports = {
USER_COLLECTION,
findOrCreateUser,
findByGoogleId,
findById,
setUserActive,
createIndexes
};

233
package-lock.json generated
View File

@ -9,10 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"mongodb": "^6.3.0" "jsonwebtoken": "^9.0.3",
"mongodb": "^6.3.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
@ -85,6 +90,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -155,6 +169,12 @@
"node": ">=16.20.1" "node": ">=16.20.1"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -255,6 +275,28 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -316,6 +358,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -702,6 +753,97 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -925,6 +1067,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -967,12 +1115,75 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@ -1097,7 +1308,6 @@
"version": "7.7.2", "version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@ -1339,6 +1549,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -1364,6 +1580,19 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -4,28 +4,33 @@
"description": "API backend for Country Club Towers & Gardens apartment dashboard", "description": "API backend for Country Club Towers & Gardens apartment dashboard",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [ "keywords": [
"apartments", "apartments",
"api", "api",
"real-estate", "real-estate",
"monitoring" "monitoring"
], ],
"author": "Stephen", "author": "Stephen",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2", "cookie-parser": "^1.4.7",
"mongodb": "^6.3.0", "cors": "^2.8.5",
"cors": "^2.8.5", "express": "^4.18.2",
"express-rate-limit": "^7.1.5" "express-rate-limit": "^7.1.5",
"jsonwebtoken": "^9.0.3",
"mongodb": "^6.3.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
} }

179
routes/activity.js Normal file
View File

@ -0,0 +1,179 @@
const express = require('express');
const { ObjectId } = require('mongodb');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { logActivity, ACTIONS, ACTIVITY_COLLECTION } = require('../services/activityLogger');
const router = express.Router();
/**
* POST /log - Log frontend activity
* Protected with requireAuth
*/
router.post('/log', requireAuth, async (req, res) => {
try {
const { action, metadata } = req.body;
// Validate action is in ACTIONS object
if (!action || !Object.values(ACTIONS).includes(action)) {
return res.status(400).json({ error: 'Invalid action type' });
}
const db = req.app.locals.db;
// Log the activity with merged metadata
await logActivity(
db,
req.user._id,
action,
{ ...metadata, page: metadata?.page },
req
);
res.json({ success: true });
} catch (error) {
console.error('Error logging activity:', error);
res.status(500).json({ error: 'Failed to log activity' });
}
});
/**
* GET /user/:userId - Get activity for specific user
* Protected with requireAuth and requireAdmin
*/
router.get('/user/:userId', requireAuth, requireAdmin, async (req, res) => {
try {
const { userId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const skip = parseInt(req.query.skip) || 0;
const db = req.app.locals.db;
// Convert userId to ObjectId
let userObjectId;
try {
userObjectId = new ObjectId(userId);
} catch (error) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
// Find activities for the user
const activities = await db
.collection(ACTIVITY_COLLECTION)
.find({ userId: userObjectId })
.sort({ timestamp: -1 })
.skip(skip)
.limit(limit)
.toArray();
// Get total count
const total = await db
.collection(ACTIVITY_COLLECTION)
.countDocuments({ userId: userObjectId });
res.json({ activities, total });
} catch (error) {
console.error('Error fetching user activity:', error);
res.status(500).json({ error: 'Failed to fetch user activity' });
}
});
/**
* GET /recent - Get recent activity across all users
* Protected with requireAuth and requireAdmin
*/
router.get('/recent', requireAuth, requireAdmin, async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50;
const skip = parseInt(req.query.skip) || 0;
const db = req.app.locals.db;
// Find all activities sorted by timestamp
const activities = await db
.collection(ACTIVITY_COLLECTION)
.find({})
.sort({ timestamp: -1 })
.skip(skip)
.limit(limit)
.toArray();
// Get total count
const total = await db.collection(ACTIVITY_COLLECTION).countDocuments({});
res.json({ activities, total });
} catch (error) {
console.error('Error fetching recent activity:', error);
res.status(500).json({ error: 'Failed to fetch recent activity' });
}
});
/**
* GET /stats - Get activity statistics
* Protected with requireAuth and requireAdmin
*/
router.get('/stats', requireAuth, requireAdmin, async (req, res) => {
try {
const db = req.app.locals.db;
// Calculate date 7 days ago
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Aggregate activity counts by action type in last 7 days
const actionCountsResult = await db
.collection(ACTIVITY_COLLECTION)
.aggregate([
{
$match: {
timestamp: { $gte: sevenDaysAgo }
}
},
{
$group: {
_id: '$action',
count: { $sum: 1 }
}
}
])
.toArray();
// Convert array to object
const actionCounts = {};
actionCountsResult.forEach(item => {
actionCounts[item._id] = item.count;
});
// Count unique active users in last 7 days
const activeUsersResult = await db
.collection(ACTIVITY_COLLECTION)
.aggregate([
{
$match: {
timestamp: { $gte: sevenDaysAgo }
}
},
{
$group: {
_id: '$userId'
}
},
{
$count: 'total'
}
])
.toArray();
const activeUsers = activeUsersResult.length > 0 ? activeUsersResult[0].total : 0;
res.json({
actionCounts,
activeUsers,
period: '7d'
});
} catch (error) {
console.error('Error fetching activity stats:', error);
res.status(500).json({ error: 'Failed to fetch activity statistics' });
}
});
module.exports = router;

154
routes/auth.js Normal file
View File

@ -0,0 +1,154 @@
const express = require('express');
const passport = require('passport');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const authConfig = require('../config/auth');
const { requireAuth, generateToken } = require('../middleware/auth');
const { logActivity, ACTIONS } = require('../services/activityLogger');
const router = express.Router();
/**
* GET /auth/google
* Initiates Google OAuth flow with state parameter for CSRF protection
*/
router.get('/google', (req, res, next) => {
// Generate cryptographically secure state parameter
const state = crypto.randomBytes(32).toString('hex');
// Store state in a short-lived cookie for validation
res.cookie('oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 5 * 60 * 1000 // 5 minutes
});
passport.authenticate('google', {
scope: authConfig.google.scope,
session: false,
state: state
})(req, res, next);
});
/**
* GET /auth/google/callback
* Handles OAuth callback from Google
* Validates state parameter for CSRF protection
* On success: generates JWT, sets cookie, redirects to frontend
* On failure: redirects to login with error
*/
router.get('/google/callback', (req, res, next) => {
// Validate state parameter to prevent CSRF
const stateFromCookie = req.cookies.oauth_state;
const stateFromQuery = req.query.state;
// Clear the state cookie immediately
res.clearCookie('oauth_state');
if (!stateFromCookie || !stateFromQuery || stateFromCookie !== stateFromQuery) {
console.warn('OAuth state mismatch - potential CSRF attack');
return res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
}
// State is valid, proceed with authentication
passport.authenticate('google', {
session: false,
failureRedirect: `${process.env.FRONTEND_URL}/login?error=auth_failed`
})(req, res, next);
}, async (req, res) => {
try {
const user = req.user;
// Check if email is verified (if available in profile)
if (req.authInfo && req.authInfo.emails && req.authInfo.emails[0]) {
const emailVerified = req.authInfo.emails[0].verified !== false; // Default to true if not present
if (!emailVerified) {
return res.redirect(`${process.env.FRONTEND_URL}/login?error=unverified`);
}
}
// Check if user account is active
if (!user.isActive) {
return res.redirect(`${process.env.FRONTEND_URL}/login`);
}
// Generate JWT token
const token = generateToken(user._id);
// Set auth cookie
res.cookie(authConfig.cookie.name, token, authConfig.cookie.options);
// Log login activity
const db = req.app.locals.db;
await logActivity(db, user._id, ACTIONS.LOGIN, { method: 'google' }, req);
// Redirect to frontend
res.redirect(process.env.FRONTEND_URL);
} catch (err) {
console.error('OAuth callback error:', err);
res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`);
}
}
);
/**
* GET /auth/me
* Returns current authenticated user's data
* Protected route - requires valid JWT
*/
router.get('/me', requireAuth, (req, res) => {
res.json({
user: {
id: req.user._id,
email: req.user.email,
name: req.user.name,
picture: req.user.picture,
role: req.user.role
}
});
});
/**
* POST /auth/logout
* Clears authentication cookie
* Protected route - requires valid JWT
*/
router.post('/logout', requireAuth, async (req, res) => {
// Log logout activity before clearing cookie
const db = req.app.locals.db;
await logActivity(db, req.user._id, ACTIONS.LOGOUT, {}, req);
// Clear the auth cookie
res.clearCookie(authConfig.cookie.name, {
...authConfig.cookie.options,
maxAge: 0
});
res.json({ message: 'Logged out successfully' });
});
/**
* GET /auth/status
* Returns authentication status without requiring middleware
* Used for quick frontend checks
*/
router.get('/status', (req, res) => {
const token = req.cookies[authConfig.cookie.name];
// No token present
if (!token) {
return res.json({ authenticated: false });
}
try {
// Verify the token
jwt.verify(token, authConfig.jwt.secret);
return res.json({ authenticated: true });
} catch (err) {
// Token invalid or expired
return res.json({ authenticated: false });
}
});
module.exports = router;

188
server.js
View File

@ -2,12 +2,35 @@ const express = require('express');
const { MongoClient } = require('mongodb'); const { MongoClient } = require('mongodb');
const cors = require('cors'); const cors = require('cors');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const passport = require('passport');
const { configurePassport } = require('./middleware/passport');
const { requireAuth } = require('./middleware/auth');
const authRoutes = require('./routes/auth');
const activityRoutes = require('./routes/activity');
const { createIndexes } = require('./models/user');
const { createActivityIndexes } = require('./services/activityLogger');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// Configuration // Configuration
const MONGO_URI = process.env.MONGO_URI || "mongodb://admin:password123@localhost:27017"; if (!process.env.MONGO_URI && process.env.NODE_ENV === 'production') {
console.error('❌ MONGO_URI environment variable is required in production');
process.exit(1);
}
const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017";
// Log environment configuration status (safe - no secret values)
console.log('📋 Environment Configuration:');
console.log(` NODE_ENV: ${process.env.NODE_ENV || 'development'}`);
console.log(` MONGO_URI: ${MONGO_URI ? '✓ Set' : '✗ Missing'}`);
console.log(` GOOGLE_CLIENT_ID: ${process.env.GOOGLE_CLIENT_ID ? '✓ Set' : '✗ Missing'}`);
console.log(` GOOGLE_CLIENT_SECRET: ${process.env.GOOGLE_CLIENT_SECRET ? '✓ Set' : '✗ Missing'}`);
console.log(` GOOGLE_CALLBACK_URL: ${process.env.GOOGLE_CALLBACK_URL || '✗ Missing'}`);
console.log(` JWT_SECRET: ${process.env.JWT_SECRET ? `✓ Set (${process.env.JWT_SECRET.length} chars)` : '✗ Missing'}`);
console.log(` FRONTEND_URL: ${process.env.FRONTEND_URL || 'http://localhost:5173 (default)'}`);
console.log(` ACTIVITY_LOG_LEVEL: ${process.env.ACTIVITY_LOG_LEVEL || 'all (default)'}`);
const DB_NAME = "apartments"; const DB_NAME = "apartments";
const UNITS_COLLECTION = "units_migration_test"; const UNITS_COLLECTION = "units_migration_test";
const PRICES_COLLECTION = "unit_prices_migration_test"; const PRICES_COLLECTION = "unit_prices_migration_test";
@ -16,7 +39,17 @@ const DAILY_SUMMARIES_COLLECTION = "daily_summaries";
console.log(`🔗 Connecting to MongoDB: ${MONGO_URI.includes('localhost') ? 'Local MongoDB' : 'Atlas MongoDB'}`); console.log(`🔗 Connecting to MongoDB: ${MONGO_URI.includes('localhost') ? 'Local MongoDB' : 'Atlas MongoDB'}`);
// Middleware // Middleware
app.use(cors()); const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOptions = {
origin: FRONTEND_URL,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['set-cookie']
};
app.use(cors(corsOptions));
app.use(cookieParser());
app.use(express.json()); app.use(express.json());
// Rate limiting // Rate limiting
@ -26,6 +59,9 @@ const limiter = rateLimit({
}); });
app.use(limiter); app.use(limiter);
// Initialize Passport
app.use(passport.initialize());
// MongoDB connection // MongoDB connection
let db; let db;
let client; let client;
@ -35,23 +71,81 @@ async function connectToMongoDB() {
client = new MongoClient(MONGO_URI); client = new MongoClient(MONGO_URI);
await client.connect(); await client.connect();
db = client.db(DB_NAME); db = client.db(DB_NAME);
app.locals.db = db;
console.log('✅ Successfully connected to MongoDB'); console.log('✅ Successfully connected to MongoDB');
// Configure Passport and create indexes after DB connection
configurePassport(passport, db);
await createIndexes(db);
await createActivityIndexes(db);
console.log('✅ Passport configured and indexes created');
} catch (error) { } catch (error) {
console.error('❌ Failed to connect to MongoDB:', error); console.error('❌ Failed to connect to MongoDB:', error);
process.exit(1); process.exit(1);
} }
} }
// Helper function to get today's date // Helper function to get today's date in UTC
const getTodayDate = () => new Date().toISOString().split('T')[0]; const getTodayDateUTC = () => new Date().toISOString().split('T')[0];
// Helper function to get yesterday's date // Cache for the most recent date with data
const getYesterdayDate = () => { let cachedLatestDate = null;
const yesterday = new Date(); let cachedLatestDateTimestamp = 0;
yesterday.setDate(yesterday.getDate() - 1); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
return yesterday.toISOString().split('T')[0];
// Helper function to get the most recent date with data in the database
// This handles timezone mismatches between server and data collection
const getLatestDateWithData = async () => {
const now = Date.now();
// Return cached value if still valid
if (cachedLatestDate && (now - cachedLatestDateTimestamp) < CACHE_TTL) {
return cachedLatestDate;
}
try {
const result = await db.collection(PRICES_COLLECTION)
.find({})
.sort({ date_checked: -1 })
.limit(1)
.project({ date_checked: 1 })
.toArray();
if (result.length > 0) {
cachedLatestDate = result[0].date_checked;
cachedLatestDateTimestamp = now;
return cachedLatestDate;
}
} catch (error) {
console.error('Error fetching latest date with data:', error);
}
// Fallback to UTC today if no data found
return getTodayDateUTC();
}; };
// Helper function to get yesterday relative to the latest date with data
const getYesterdayDate = (today) => {
const todayDate = new Date(today + 'T00:00:00Z');
todayDate.setDate(todayDate.getDate() - 1);
return todayDate.toISOString().split('T')[0];
};
// Helper function to validate unit code (prevents NoSQL injection)
const isValidUnitCode = (unitCode) => {
// Allow alphanumeric characters, hyphens, and underscores, max 20 chars
return typeof unitCode === 'string' &&
unitCode.length > 0 &&
unitCode.length <= 20 &&
/^[A-Za-z0-9_-]+$/.test(unitCode);
};
// Mount auth routes
app.use('/auth', authRoutes);
// Mount activity routes
app.use('/activity', activityRoutes);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
@ -62,7 +156,7 @@ app.get('/health', (req, res) => {
}); });
// Get last scrape time // Get last scrape time
app.get('/last-scrape', async (req, res) => { app.get('/last-scrape', requireAuth, async (req, res) => {
try { try {
// Get the most recent price record using _id (ObjectId contains timestamp) // Get the most recent price record using _id (ObjectId contains timestamp)
const lastRecord = await db.collection(PRICES_COLLECTION) const lastRecord = await db.collection(PRICES_COLLECTION)
@ -94,9 +188,9 @@ app.get('/last-scrape', async (req, res) => {
}); });
// Get daily summary (matches your daily_summaries collection) // Get daily summary (matches your daily_summaries collection)
app.get('/daily-summary', async (req, res) => { app.get('/daily-summary', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
// Try to get today's summary first // Try to get today's summary first
let summary = await db.collection(DAILY_SUMMARIES_COLLECTION) let summary = await db.collection(DAILY_SUMMARIES_COLLECTION)
@ -133,14 +227,16 @@ app.get('/daily-summary', async (req, res) => {
}); });
// Get price history (last 15 days of average prices) // Get price history (last 15 days of average prices)
app.get('/price-history', async (req, res) => { app.get('/price-history', requireAuth, async (req, res) => {
try { try {
const days = parseInt(req.query.days) || 15; const days = parseInt(req.query.days) || 15;
const latestDate = await getLatestDateWithData();
// Generate date range // Generate date range ending at the latest date with data
const dates = []; const dates = [];
const baseDate = new Date(latestDate + 'T00:00:00Z');
for (let i = days - 1; i >= 0; i--) { for (let i = days - 1; i >= 0; i--) {
const date = new Date(); const date = new Date(baseDate);
date.setDate(date.getDate() - i); date.setDate(date.getDate() - i);
dates.push(date.toISOString().split('T')[0]); dates.push(date.toISOString().split('T')[0]);
} }
@ -188,9 +284,9 @@ app.get('/price-history', async (req, res) => {
}); });
// Get available units with current prices // Get available units with current prices
app.get('/available-units', async (req, res) => { app.get('/available-units', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
// Get units that have prices today (excluding furnished units) // Get units that have prices today (excluding furnished units)
const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([ const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([
@ -336,10 +432,15 @@ app.get('/available-units', async (req, res) => {
}); });
// Get full price history for a specific unit // Get full price history for a specific unit
app.get('/unit/:unitCode/price-history', async (req, res) => { app.get('/unit/:unitCode/price-history', requireAuth, async (req, res) => {
try { try {
const { unitCode } = req.params; const { unitCode } = req.params;
// Validate unitCode to prevent NoSQL injection
if (!isValidUnitCode(unitCode)) {
return res.status(400).json({ error: 'Invalid unit code format' });
}
const priceHistory = await db.collection(PRICES_COLLECTION) const priceHistory = await db.collection(PRICES_COLLECTION)
.find({ unit_code: unitCode }) .find({ unit_code: unitCode })
.sort({ date_checked: 1 }) .sort({ date_checked: 1 })
@ -363,9 +464,9 @@ app.get('/unit/:unitCode/price-history', async (req, res) => {
}); });
// Get comprehensive analytics data // Get comprehensive analytics data
app.get('/analytics', async (req, res) => { app.get('/analytics', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
// 1. Monthly Price Trends - aggregate all prices by month // 1. Monthly Price Trends - aggregate all prices by month
const monthlyTrends = await db.collection(PRICES_COLLECTION).aggregate([ const monthlyTrends = await db.collection(PRICES_COLLECTION).aggregate([
@ -605,10 +706,10 @@ app.get('/analytics', async (req, res) => {
}); });
// Get recent activity (new units, rented units, price changes) // Get recent activity (new units, rented units, price changes)
app.get('/recent-activity', async (req, res) => { app.get('/recent-activity', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
const yesterday = getYesterdayDate(); const yesterday = getYesterdayDate(today);
// Get today's summary for new/rented units // Get today's summary for new/rented units
const todaySummary = await db.collection(DAILY_SUMMARIES_COLLECTION) const todaySummary = await db.collection(DAILY_SUMMARIES_COLLECTION)
@ -734,9 +835,9 @@ app.get('/recent-activity', async (req, res) => {
}); });
// Get plan statistics // Get plan statistics
app.get('/plan-stats', async (req, res) => { app.get('/plan-stats', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
const planStats = await db.collection(UNITS_COLLECTION).aggregate([ const planStats = await db.collection(UNITS_COLLECTION).aggregate([
{ {
@ -797,9 +898,9 @@ app.get('/plan-stats', async (req, res) => {
// Expanded api // Expanded api
// Get best deals (units priced below their historical average) // Get best deals (units priced below their historical average)
app.get('/best-deals', async (req, res) => { app.get('/best-deals', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
const limit = parseInt(req.query.limit) || 5; const limit = parseInt(req.query.limit) || 5;
const bestDeals = await db.collection(UNITS_COLLECTION).aggregate([ const bestDeals = await db.collection(UNITS_COLLECTION).aggregate([
@ -922,16 +1023,17 @@ app.get('/best-deals', async (req, res) => {
}); });
// Get price drops (units with recent price decreases) // Get price drops (units with recent price decreases)
app.get('/price-drops', async (req, res) => { app.get('/price-drops', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
const daysBack = parseInt(req.query.days) || 7; const daysBack = parseInt(req.query.days) || 7;
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
// Generate date range for the last N days // Generate date range for the last N days relative to latest data date
const dates = []; const dates = [];
const baseDate = new Date(today + 'T00:00:00Z');
for (let i = 0; i < daysBack; i++) { for (let i = 0; i < daysBack; i++) {
const date = new Date(); const date = new Date(baseDate);
date.setDate(date.getDate() - i); date.setDate(date.getDate() - i);
dates.push(date.toISOString().split('T')[0]); dates.push(date.toISOString().split('T')[0]);
} }
@ -1019,9 +1121,9 @@ app.get('/price-drops', async (req, res) => {
}); });
// Get stale inventory (units on market for a long time) // Get stale inventory (units on market for a long time)
app.get('/stale-inventory', async (req, res) => { app.get('/stale-inventory', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
const minDays = parseInt(req.query.minDays) || 10; const minDays = parseInt(req.query.minDays) || 10;
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
@ -1145,9 +1247,9 @@ app.get('/stale-inventory', async (req, res) => {
}); });
// Get market insights and predictions // Get market insights and predictions
app.get('/market-insights', async (req, res) => { app.get('/market-insights', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
// Get various market metrics // Get various market metrics
const [ const [
@ -1355,10 +1457,10 @@ app.get('/market-insights', async (req, res) => {
}); });
// Enhanced available units with more details // Enhanced available units with more details
app.get('/available-units-enhanced', async (req, res) => { app.get('/available-units-enhanced', requireAuth, async (req, res) => {
try { try {
const today = getTodayDate(); const today = await getLatestDateWithData();
const yesterday = getYesterdayDate(); const yesterday = getYesterdayDate(today);
const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([ const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([
{ {
@ -1541,10 +1643,16 @@ app.get('/available-units-enhanced', async (req, res) => {
}); });
// Get unit details by unit code // Get unit details by unit code
app.get('/unit/:unitCode', async (req, res) => { app.get('/unit/:unitCode', requireAuth, async (req, res) => {
try { try {
const { unitCode } = req.params; const { unitCode } = req.params;
const today = getTodayDate();
// Validate unitCode to prevent NoSQL injection
if (!isValidUnitCode(unitCode)) {
return res.status(400).json({ error: 'Invalid unit code format' });
}
const today = await getLatestDateWithData();
const unit = await db.collection(UNITS_COLLECTION).aggregate([ const unit = await db.collection(UNITS_COLLECTION).aggregate([
{ $match: { unit_code: unitCode } }, { $match: { unit_code: unitCode } },

161
services/activityLogger.js Normal file
View File

@ -0,0 +1,161 @@
const { ObjectId } = require('mongodb');
// Collection name
const ACTIVITY_COLLECTION = 'user_activity';
// Activity action constants
const ACTIONS = {
LOGIN: 'login',
LOGOUT: 'logout',
PAGE_VIEW: 'page_view',
NAVIGATION: 'navigation',
UNIT_VIEW: 'unit_view',
UNIT_WATCH: 'unit_watch',
FILTER_CHANGE: 'filter_change',
SORT_CHANGE: 'sort_change',
SEARCH: 'search',
EXPORT: 'export',
VIEW_TOGGLE: 'view_toggle'
};
// Log levels define which actions should be logged
const LOG_LEVELS = {
all: [
'login',
'logout',
'page_view',
'navigation',
'filter_change',
'sort_change',
'search',
'unit_view',
'unit_watch',
'export',
'view_toggle'
],
navigation: [
'login',
'logout',
'page_view',
'navigation'
],
none: []
};
/**
* Get the current activity log level from environment variable
* @returns {string} Current log level ('all', 'navigation', or 'none')
*/
function getActivityLevel() {
const level = process.env.ACTIVITY_LOG_LEVEL || 'all';
// Validate level exists, default to 'all' if invalid
if (!LOG_LEVELS[level]) {
console.warn(`Invalid ACTIVITY_LOG_LEVEL: ${level}, defaulting to 'all'`);
return 'all';
}
return level;
}
/**
* Check if an action should be logged based on current log level
* @param {string} action - Action to check
* @returns {boolean} True if action should be logged
*/
function shouldLog(action) {
const level = getActivityLevel();
const allowedActions = LOG_LEVELS[level];
return allowedActions.includes(action);
}
/**
* Log a user activity to the database
* @param {Db} db - MongoDB database instance
* @param {string} userId - User ID (will be converted to ObjectId)
* @param {string} action - Action type (use ACTIONS constants)
* @param {Object} metadata - Additional action-specific data
* @param {Object} req - Express request object (for IP and user agent)
* @returns {Promise<void>}
*/
async function logActivity(db, userId, action, metadata = {}, req = null) {
// Only log if this action type is enabled at current log level
if (!shouldLog(action)) {
return;
}
try {
// Extract IP address (handle proxy forwarding)
let ip = null;
if (req) {
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null;
}
// Extract user agent
const userAgent = req?.headers?.['user-agent'] || null;
// Create activity document
const activityDoc = {
userId: new ObjectId(userId),
action: action,
metadata: metadata || {},
page: metadata?.page || null,
timestamp: new Date(),
userAgent: userAgent,
ip: ip
};
// Insert into user_activity collection
await db.collection(ACTIVITY_COLLECTION).insertOne(activityDoc);
} catch (error) {
// Log error but don't throw - activity logging should not break the main flow
console.error('Error logging activity:', error);
}
}
/**
* Create required indexes for the user_activity collection
* @param {Db} db - MongoDB database instance
* @returns {Promise<void>}
*/
async function createActivityIndexes(db) {
try {
const collection = db.collection(ACTIVITY_COLLECTION);
// Index for user activity queries (get all activities for a user, sorted by time)
await collection.createIndex(
{ userId: 1, timestamp: -1 },
{ name: 'userId_timestamp' }
);
// Index for action type queries (get all activities of a certain type)
await collection.createIndex(
{ action: 1, timestamp: -1 },
{ name: 'action_timestamp' }
);
// TTL index to automatically delete activities older than 90 days
await collection.createIndex(
{ timestamp: 1 },
{
name: 'timestamp_ttl',
expireAfterSeconds: 7776000 // 90 days = 90 * 24 * 60 * 60
}
);
console.log('Activity collection indexes created successfully');
} catch (error) {
console.error('Error creating activity indexes:', error);
throw error;
}
}
module.exports = {
ACTIVITY_COLLECTION,
ACTIONS,
LOG_LEVELS,
getActivityLevel,
shouldLog,
logActivity,
createActivityIndexes
};