Compare commits
3 Commits
add-soones
...
d6e56a6e40
| Author | SHA1 | Date | |
|---|---|---|---|
| d6e56a6e40 | |||
| ef4ddc0de0 | |||
| 04a78a21cc |
25
.dockerignore
Normal file
25
.dockerignore
Normal 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
20
.env.example
Normal 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
|
||||||
717
AUTH.md
Normal file
717
AUTH.md
Normal file
@ -0,0 +1,717 @@
|
|||||||
|
# Google OAuth Authentication Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document details the Google OAuth authentication implementation for the Apartment Dashboard application. **All pages and API endpoints require authentication** - unauthenticated users are redirected to a login page.
|
||||||
|
|
||||||
|
> **Status:** Core authentication is complete and deployed. Admin dashboard and future enhancements are documented but not yet implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
0. [Project Context](#0-project-context)
|
||||||
|
1. [Implementation Status](#1-implementation-status)
|
||||||
|
2. [Authentication Scope](#2-authentication-scope)
|
||||||
|
3. [Technical Architecture](#3-technical-architecture)
|
||||||
|
4. [Implementation Details](#4-implementation-details)
|
||||||
|
5. [User Activity Logging](#5-user-activity-logging)
|
||||||
|
6. [Security Measures](#6-security-measures)
|
||||||
|
7. [Deployment Configuration](#7-deployment-configuration)
|
||||||
|
8. [Test Checklist](#8-test-checklist)
|
||||||
|
9. [Future: Admin Dashboard](#9-future-admin-dashboard)
|
||||||
|
10. [Future: Adding Apple Sign-In](#10-future-adding-apple-sign-in)
|
||||||
|
11. [Future: Partial Public Access](#11-future-partial-public-access)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Project Context
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
TowersPriceWebApp/
|
||||||
|
├── apartment-dashboard/ # Frontend (React/Vite)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.jsx # Main app (~1300 lines)
|
||||||
|
│ │ ├── main.jsx # Entry point (wrapped with AuthProvider)
|
||||||
|
│ │ ├── index.css # Tailwind imports
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ │ └── AuthContext.jsx # Auth state management
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useAuth.js # Auth hook
|
||||||
|
│ │ │ └── useActivityTracker.js # Activity tracking hook
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ └── Login.jsx # Login page with Google button
|
||||||
|
│ │ └── components/
|
||||||
|
│ │ └── ProtectedRoute.jsx # Route guard component
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── nginx.conf
|
||||||
|
│
|
||||||
|
├── apartment-dashboard-api/ # Backend (Express.js)
|
||||||
|
│ ├── server.js # Main server (~1700 lines)
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── auth.js # JWT, cookie, OAuth config
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── user.js # User CRUD operations
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── passport.js # Google OAuth strategy
|
||||||
|
│ │ └── auth.js # JWT verification middleware
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js # OAuth routes
|
||||||
|
│ │ └── activity.js # Activity logging routes
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── activityLogger.js # Activity logging service
|
||||||
|
│ ├── .env.example # Environment template
|
||||||
|
│ ├── .dockerignore # Prevents secrets in image
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
│
|
||||||
|
└── AUTH.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Version |
|
||||||
|
|-------|------------|---------|
|
||||||
|
| Frontend Framework | React | 19.1.0 |
|
||||||
|
| Frontend Build | Vite | 7.0.4 |
|
||||||
|
| Frontend Routing | React Router DOM | 7.12.0 |
|
||||||
|
| Frontend Styling | Tailwind CSS | 3.4.17 |
|
||||||
|
| Frontend Charts | Recharts | 3.1.0 |
|
||||||
|
| Backend Framework | Express.js | 4.18.2 |
|
||||||
|
| Database | MongoDB | 6.3.0 (driver) |
|
||||||
|
| Auth Library | Passport.js | passport-google-oauth20 |
|
||||||
|
| Token Management | jsonwebtoken | HTTP-only cookies |
|
||||||
|
| Reverse Proxy | Traefik | (with Let's Encrypt SSL) |
|
||||||
|
| Static Server | Nginx | (serves built React app) |
|
||||||
|
|
||||||
|
### URLs and Domains
|
||||||
|
|
||||||
|
| Environment | Frontend URL | API URL |
|
||||||
|
|-------------|--------------|---------|
|
||||||
|
| Production | `https://apartments.maverickapplications.com` | `https://apartments.maverickapplications.com/api` |
|
||||||
|
| Development | `http://localhost:5173` (Vite) | `http://localhost:3000` |
|
||||||
|
|
||||||
|
### MongoDB Details
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Database name | `apartments` |
|
||||||
|
| Data collections | `units_migration_test`, `unit_prices_migration_test`, `daily_summaries` |
|
||||||
|
| Auth collections | `users`, `user_activity` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Implementation Status
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Google OAuth login | Done | Passport.js with state parameter CSRF protection |
|
||||||
|
| JWT in HTTP-only cookies | Done | 7-day expiry with sliding window refresh |
|
||||||
|
| User model with upsert | Done | findOneAndUpdate with $setOnInsert pattern |
|
||||||
|
| Protected API endpoints | Done | All data endpoints require valid JWT |
|
||||||
|
| Activity logging | Done | Configurable levels, TTL indexes for cleanup |
|
||||||
|
| Frontend auth context | Done | React Context with useAuth hook |
|
||||||
|
| Protected routes | Done | ProtectedRoute component with redirect |
|
||||||
|
| Login page | Done | Google Sign-In button with error display |
|
||||||
|
| Security hardening | Done | OAuth state, input validation, .dockerignore |
|
||||||
|
| Docker deployment | Done | env_file configuration, production settings |
|
||||||
|
|
||||||
|
### Not Yet Implemented
|
||||||
|
|
||||||
|
| Feature | Priority | Notes |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| Admin dashboard | Low | User management, activity viewer |
|
||||||
|
| Apple Sign-In | Future | Requires Apple Developer account |
|
||||||
|
| Partial public access | Future | Blurred preview for unauthenticated users |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication Scope
|
||||||
|
|
||||||
|
### What Requires Authentication
|
||||||
|
|
||||||
|
**All pages and API endpoints require authentication.** Unauthenticated users see only the login page.
|
||||||
|
|
||||||
|
| Page/Feature | Unauthenticated | Authenticated |
|
||||||
|
|--------------|-----------------|---------------|
|
||||||
|
| Login Page (`/login`) | Accessible | Redirects to `/` |
|
||||||
|
| Overview Page (`/`) | Redirect to login | Fully accessible |
|
||||||
|
| Units Page (`/units`) | Redirect to login | Fully accessible |
|
||||||
|
| Analytics Page (`/analytics`) | Redirect to login | Fully accessible |
|
||||||
|
| All API Endpoints | 401 Unauthorized | Accessible |
|
||||||
|
|
||||||
|
### Protected API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health → Public (health check only)
|
||||||
|
GET /api/daily-summary → Protected
|
||||||
|
GET /api/price-history → Protected
|
||||||
|
GET /api/available-units → Protected
|
||||||
|
GET /api/recent-activity → Protected
|
||||||
|
GET /api/plan-stats → Protected
|
||||||
|
GET /api/unit/:code/history → Protected
|
||||||
|
GET /api/analytics → Protected
|
||||||
|
GET /api/best-deals → Protected
|
||||||
|
GET /api/price-drops → Protected
|
||||||
|
GET /api/stale-inventory → Protected
|
||||||
|
GET /api/market-insights → Protected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /auth/google → Initiates OAuth flow (sets state cookie)
|
||||||
|
GET /auth/google/callback → Handles callback (validates state, sets JWT)
|
||||||
|
GET /auth/me → Returns current user info
|
||||||
|
GET /auth/status → Returns { authenticated: true/false }
|
||||||
|
POST /auth/logout → Clears auth cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Technical Architecture
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Google OAuth Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. User clicks "Sign in with Google" │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 2. Frontend redirects to: /api/auth/google │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 3. Backend generates state parameter, stores in cookie │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 4. Backend redirects to Google's OAuth consent screen │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 5. User authenticates with Google │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 6. Google redirects to: /api/auth/google/callback?code=XXX&state=YYY │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 7. Backend validates state parameter against cookie (CSRF protection) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 8. Backend exchanges code for tokens, fetches user profile │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 9. Backend creates/updates user in MongoDB (upsert) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 10. Backend creates JWT, sets HTTP-only cookie │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 11. Backend redirects to frontend │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 12. Frontend AuthContext checks /auth/status, renders authenticated UI │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Refresh Strategy (Sliding Window)
|
||||||
|
|
||||||
|
Active users get their tokens refreshed automatically to avoid abrupt logouts.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Sliding Window Refresh │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. User makes authenticated request │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 2. Auth middleware validates JWT │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 3. Check: Is user still active? (isActive field) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────┴────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ No ▼ Yes ▼ │
|
||||||
|
│ Clear cookie 4. Check token age: (now - iat) > 1 day? │
|
||||||
|
│ Return 401 │ │
|
||||||
|
│ ┌────┴────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ No ▼ Yes ▼ │
|
||||||
|
│ Continue 5. Issue new JWT with fresh 7-day expiry │
|
||||||
|
│ normally │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 6. Set new cookie in response │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Scenario | Token Age | Action |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| User active daily | < 1 day since last refresh | No refresh |
|
||||||
|
| User returns after 2 days | 2 days old | Refresh token |
|
||||||
|
| User returns after 8 days | Expired | 401, redirect to login |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Details
|
||||||
|
|
||||||
|
### Auth Configuration (`config/auth.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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 // 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Model (`models/user.js`)
|
||||||
|
|
||||||
|
Uses MongoDB native driver with upsert pattern:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function findOrCreateUser(db, profile) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await db.collection('users').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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `loginCount` must NOT be in `$setOnInsert` - it conflicts with `$inc`.
|
||||||
|
|
||||||
|
### Passport Strategy (`middleware/passport.js`)
|
||||||
|
|
||||||
|
Safely extracts profile data from Google:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth State Validation (`routes/auth.js`)
|
||||||
|
|
||||||
|
CSRF protection using state parameter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initiate OAuth - generate and store state
|
||||||
|
router.get('/google', (req, res, next) => {
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback - validate state before processing
|
||||||
|
router.get('/google/callback', (req, res, next) => {
|
||||||
|
const stateFromCookie = req.cookies.oauth_state;
|
||||||
|
const stateFromQuery = req.query.state;
|
||||||
|
res.clearCookie('oauth_state');
|
||||||
|
|
||||||
|
if (!stateFromCookie || !stateFromQuery || stateFromCookie !== stateFromQuery) {
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
|
||||||
|
}
|
||||||
|
// ... proceed with authentication
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Auth Context (`context/AuthContext.jsx`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuthStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/status`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.authenticated) {
|
||||||
|
setUser(data.user);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
window.location.href = `${API_BASE}/auth/google`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Indexes
|
||||||
|
|
||||||
|
Created automatically on server startup:
|
||||||
|
|
||||||
|
**Users Collection:**
|
||||||
|
- `{ googleId: 1 }` - unique
|
||||||
|
- `{ email: 1 }` - unique
|
||||||
|
- `{ isActive: 1, lastLoginAt: -1 }` - compound for queries
|
||||||
|
|
||||||
|
**User Activity Collection:**
|
||||||
|
- `{ userId: 1, timestamp: -1 }` - user activity queries
|
||||||
|
- `{ timestamp: 1 }` - TTL index (90-day auto-cleanup)
|
||||||
|
- `{ action: 1, timestamp: -1 }` - action type queries
|
||||||
|
- `{ sessionId: 1 }` - session grouping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. User Activity Logging
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Controlled by `ACTIVITY_LOG_LEVEL` environment variable:
|
||||||
|
|
||||||
|
| Level | What's Logged |
|
||||||
|
|-------|---------------|
|
||||||
|
| `all` | All user interactions (default) |
|
||||||
|
| `navigation` | Only page views and route changes |
|
||||||
|
| `none` | Logging disabled |
|
||||||
|
|
||||||
|
### Activity Types
|
||||||
|
|
||||||
|
| Action | When Logged | Metadata |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| `LOGIN` | User completes OAuth | `{ method: 'google' }` |
|
||||||
|
| `LOGOUT` | User logs out | `{}` |
|
||||||
|
| `PAGE_VIEW` | Page load | `{ path }` |
|
||||||
|
| `NAVIGATION` | Route change | `{ from, to }` |
|
||||||
|
|
||||||
|
### Activity Schema
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
userId: ObjectId,
|
||||||
|
sessionId: String, // UUID for grouping
|
||||||
|
action: String,
|
||||||
|
metadata: Object,
|
||||||
|
page: String,
|
||||||
|
timestamp: Date,
|
||||||
|
userAgent: String,
|
||||||
|
ip: String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Cleanup
|
||||||
|
|
||||||
|
Activity records are automatically deleted after 90 days via MongoDB TTL index.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Security Measures
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
|
||||||
|
| Security Feature | Implementation |
|
||||||
|
|------------------|----------------|
|
||||||
|
| OAuth state parameter | Random 32-byte hex, stored in cookie, validated on callback |
|
||||||
|
| HTTP-only cookies | JWT not accessible via JavaScript |
|
||||||
|
| Secure cookies (production) | Only sent over HTTPS |
|
||||||
|
| SameSite=Lax | CSRF protection for cookies |
|
||||||
|
| Input validation | Unit codes validated with regex pattern |
|
||||||
|
| isActive check | Disabled users rejected on every request |
|
||||||
|
| .dockerignore | Prevents .env and secrets from being copied into images |
|
||||||
|
| Environment validation | Server exits if MONGO_URI missing in production |
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const isValidUnitCode = (unitCode) => {
|
||||||
|
return typeof unitCode === 'string' &&
|
||||||
|
unitCode.length > 0 &&
|
||||||
|
unitCode.length <= 20 &&
|
||||||
|
/^[A-Za-z0-9_-]+$/.test(unitCode);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const corsOptions = {
|
||||||
|
origin: process.env.FRONTEND_URL, // Exact origin, not '*'
|
||||||
|
credentials: true, // Required for cookies
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
exposedHeaders: ['set-cookie']
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deployment Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Backend `.env` (see `.env.example` for template):
|
||||||
|
|
||||||
|
```env
|
||||||
|
# MongoDB
|
||||||
|
MONGO_URI=mongodb+srv://...
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=xxx
|
||||||
|
GOOGLE_CALLBACK_URL=https://apartments.maverickapplications.com/api/auth/google/callback
|
||||||
|
|
||||||
|
# JWT (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
|
||||||
|
JWT_SECRET=your-secure-random-string
|
||||||
|
|
||||||
|
# App
|
||||||
|
FRONTEND_URL=https://apartments.maverickapplications.com
|
||||||
|
|
||||||
|
# Activity Logging
|
||||||
|
ACTIVITY_LOG_LEVEL=all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
apartment-api:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Google Cloud Console Setup
|
||||||
|
|
||||||
|
Required redirect URIs:
|
||||||
|
```
|
||||||
|
https://apartments.maverickapplications.com/api/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
OAuth consent screen:
|
||||||
|
- User type: External
|
||||||
|
- Scopes: `email`, `profile` (non-sensitive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Checklist
|
||||||
|
|
||||||
|
### Completed Tests
|
||||||
|
|
||||||
|
- [x] Google OAuth login flow works end-to-end
|
||||||
|
- [x] JWT cookie is set with correct flags (httpOnly, secure, sameSite)
|
||||||
|
- [x] Protected endpoints return 401 without valid token
|
||||||
|
- [x] User data persists across sessions (cookie survives browser close)
|
||||||
|
- [x] Activity logging captures LOGIN/LOGOUT events
|
||||||
|
- [x] Data loads correctly regardless of server timezone
|
||||||
|
- [x] OAuth state parameter prevents CSRF
|
||||||
|
- [x] User upsert creates new users and updates existing
|
||||||
|
- [x] Sliding window token refresh extends sessions
|
||||||
|
- [x] Logout clears cookie and redirects to login
|
||||||
|
|
||||||
|
### Production Verification
|
||||||
|
|
||||||
|
- [x] OAuth redirect URIs match production domain
|
||||||
|
- [x] HTTPS enforced
|
||||||
|
- [x] Environment variables properly set
|
||||||
|
- [x] MongoDB indexes created on startup
|
||||||
|
- [x] No sensitive data in console logs
|
||||||
|
- [x] .dockerignore prevents secrets in image
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Future: Admin Dashboard
|
||||||
|
|
||||||
|
**Priority: Low**
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
|
||||||
|
1. **User Management**
|
||||||
|
- View all registered users
|
||||||
|
- See last login, login count
|
||||||
|
- Enable/disable users
|
||||||
|
|
||||||
|
2. **Activity Viewer**
|
||||||
|
- Recent activity stream
|
||||||
|
- Filter by user, action, date
|
||||||
|
|
||||||
|
3. **Usage Statistics**
|
||||||
|
- Active users (daily/weekly/monthly)
|
||||||
|
- Most common actions
|
||||||
|
- Peak usage times
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
|
||||||
|
- Add `role: 'admin'` to user document
|
||||||
|
- Create `requireAdmin` middleware
|
||||||
|
- Manually set initial admin via database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Future: Adding Apple Sign-In
|
||||||
|
|
||||||
|
**Priority: Future**
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. Apple Developer account ($99/year)
|
||||||
|
2. Service ID for web authentication
|
||||||
|
3. Private key for token verification
|
||||||
|
|
||||||
|
### Changes Needed
|
||||||
|
|
||||||
|
1. Install `passport-apple`
|
||||||
|
2. Add Apple strategy to passport
|
||||||
|
3. Add routes: `/auth/apple`, `/auth/apple/callback`
|
||||||
|
4. Update user model for multiple providers
|
||||||
|
5. Add "Sign in with Apple" button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Future: Partial Public Access
|
||||||
|
|
||||||
|
**Priority: Future**
|
||||||
|
|
||||||
|
Allow unauthenticated users to see a blurred preview of the overview page.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- Summary cards visible
|
||||||
|
- Charts and detailed data blurred with CSS
|
||||||
|
- Overlay with "Sign in to view" prompt
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. Make `/api/daily-summary` public
|
||||||
|
2. Create `AuthBlurOverlay` component
|
||||||
|
3. Wrap protected sections on overview page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Log
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Token storage | HTTP-only cookie | More secure than localStorage |
|
||||||
|
| Token expiry | 7 days with sliding refresh | Balance security and UX |
|
||||||
|
| Disabled user handling | Silent logout | No error messaging needed |
|
||||||
|
| State parameter | Random 32-byte hex | Industry standard CSRF protection |
|
||||||
|
| Activity cleanup | 90-day TTL | Automatic via MongoDB index |
|
||||||
|
| Date handling | Use latest database date | Handles server timezone mismatch |
|
||||||
|
| User upsert | findOneAndUpdate | Atomic create/update operation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| 401 on all endpoints | Missing/invalid JWT | Check cookie is set, not expired |
|
||||||
|
| OAuth callback 500 | Profile field access error | Use optional chaining on profile fields |
|
||||||
|
| Empty data arrays | Timezone mismatch | Server uses latest date from database |
|
||||||
|
| MongoDB conflict error | loginCount in $setOnInsert | Remove from $setOnInsert, use only $inc |
|
||||||
|
| Cookie not set | CORS misconfiguration | Ensure credentials: 'include' on all fetches |
|
||||||
|
| State validation fails | Cookie expired or cleared | State cookie has 5-minute lifetime |
|
||||||
24
config/auth.js
Normal file
24
config/auth.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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
111
middleware/auth.js
Normal 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
36
middleware/passport.js
Normal 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
128
models/user.js
Normal 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
233
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
11
package.json
11
package.json
@ -17,10 +17,15 @@
|
|||||||
"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-rate-limit": "^7.1.5"
|
"express": "^4.18.2",
|
||||||
|
"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"
|
||||||
|
|||||||
179
routes/activity.js
Normal file
179
routes/activity.js
Normal 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
154
routes/auth.js
Normal 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;
|
||||||
189
server.js
189
server.js
@ -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([
|
||||||
@ -302,6 +398,7 @@ app.get('/available-units', async (req, res) => {
|
|||||||
area: "$area",
|
area: "$area",
|
||||||
community: "$community",
|
community: "$community",
|
||||||
available: "$available",
|
available: "$available",
|
||||||
|
soonest: "$soonest",
|
||||||
currentPrice: "$currentPrice.price",
|
currentPrice: "$currentPrice.price",
|
||||||
minPrice: "$priceStats.minPrice",
|
minPrice: "$priceStats.minPrice",
|
||||||
maxPrice: "$priceStats.maxPrice",
|
maxPrice: "$priceStats.maxPrice",
|
||||||
@ -335,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 })
|
||||||
@ -362,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([
|
||||||
@ -604,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)
|
||||||
@ -733,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([
|
||||||
{
|
{
|
||||||
@ -796,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([
|
||||||
@ -921,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]);
|
||||||
}
|
}
|
||||||
@ -1018,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;
|
||||||
|
|
||||||
@ -1144,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 [
|
||||||
@ -1354,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([
|
||||||
{
|
{
|
||||||
@ -1540,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
161
services/activityLogger.js
Normal 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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user