mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
initial
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env*
|
||||
*.log
|
||||
dist/
|
||||
coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('config', 'database.cjs'),
|
||||
'models-path': path.resolve('database', 'models'),
|
||||
'migrations-path': path.resolve('database', 'migrations'),
|
||||
'seeders-path': path.resolve('database', 'seeders'),
|
||||
};
|
||||
@@ -1,2 +1,757 @@
|
||||
# new_starr
|
||||
New STARR
|
||||
# STAR Phase 1 — Tier 1 Auth System
|
||||
## Technical Documentation
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Author:** rgrgogu
|
||||
**Date:** October 6, 2025
|
||||
**Stack:** Node.js · Express · Sequelize (PostgreSQL) · JWT · Google OAuth 2.0 · Nodemailer
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#1-architecture-overview)
|
||||
2. [Project Structure](#2-project-structure)
|
||||
3. [Setup & Installation](#3-setup--installation)
|
||||
4. [Environment Variables](#4-environment-variables)
|
||||
5. [Database Models](#5-database-models)
|
||||
6. [Security Layers](#6-security-layers)
|
||||
7. [Authentication Flows](#7-authentication-flows)
|
||||
8. [API Reference](#8-api-reference)
|
||||
9. [RBAC Permission Matrix](#9-rbac-permission-matrix)
|
||||
10. [File-by-File Documentation](#10-file-by-file-documentation)
|
||||
11. [Error Codes Reference](#11-error-codes-reference)
|
||||
12. [Extending the System](#12-extending-the-system)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client (SPA / Mobile / Browser) │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Express.js App (server.js) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ CORS │ │ Rate Limit│ │ Cookie │ │ Session │ │
|
||||
│ │ Guard │ │ (global) │ │ Parser │ │ (csurf) │ │
|
||||
│ └──────────┘ └───────────┘ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Routes │ │
|
||||
│ │ /api/auth/* → auth.routes.js │ │
|
||||
│ │ /api/client/* → client.routes.js │ │
|
||||
│ │ /api/staff/users/*→ staff.routes.js │ │
|
||||
│ │ /api/admin/* → admin.routes.js │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Middleware Chain (per-route) │ │
|
||||
│ │ authLimiter → csrfProtection → validate → authenticate │ │
|
||||
│ │ → requireRole() → Controller │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Services │ Utils │ │
|
||||
│ │ email.service.js │ token.util.js │ │
|
||||
│ │ │ otp.util.js │ │
|
||||
│ │ │ response.util.js │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┬───────────────────────┘
|
||||
│ Sequelize ORM
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ ┌──────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ users │ │ user_sessions │ │ user_groups / │ │
|
||||
│ │ │ │ │ │ user_group_members │ │
|
||||
│ └──────────┘ └─────────────────┘ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Project Structure
|
||||
|
||||
```
|
||||
new_starr/
|
||||
├── server.js ← App entry point
|
||||
├── package.json
|
||||
├── .env.example ← Copy to .env and fill values
|
||||
│
|
||||
├── config/
|
||||
│ ├── db.config.js ← Sequelize PostgreSQL instance
|
||||
│ └── passport.config.js ← Google OAuth 2.0 strategy
|
||||
│
|
||||
├── models/
|
||||
│ ├── users.mdl.js ← Users table (with OTP fields)
|
||||
│ ├── user_sessions.mdl.js ← Session audit table
|
||||
│ └── user_groups.mdl.js ← Groups + Junction table
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── auth.middleware.js ← JWT Bearer token verification
|
||||
│ ├── rbac.middleware.js ← Role-based access guards
|
||||
│ ├── csrf.middleware.js ← CSRF protection (csurf)
|
||||
│ ├── rateLimiter.middleware.js ← Per-route rate limits
|
||||
│ └── validate.middleware.js ← express-validator result handler
|
||||
│
|
||||
├── validators/
|
||||
│ ├── auth.validator.js ← register / login / OTP / password rules
|
||||
│ └── profile.validator.js ← profile update rules
|
||||
│
|
||||
├── controllers/
|
||||
│ ├── auth.controller.js ← Shared: register, OTP, login, refresh, logout
|
||||
│ ├── client/
|
||||
│ │ └── profile.controller.js ← Own profile + own sessions
|
||||
│ ├── staff/
|
||||
│ │ └── users.controller.js ← View/toggle non-admin users
|
||||
│ └── admin/
|
||||
│ └── users.controller.js ← Full CRUD + groups
|
||||
│
|
||||
├── routes/
|
||||
│ ├── auth.routes.js ← /api/auth/*
|
||||
│ ├── client/
|
||||
│ │ └── client.routes.js ← /api/client/*
|
||||
│ ├── staff/
|
||||
│ │ └── staff.routes.js ← /api/staff/users/*
|
||||
│ └── admin/
|
||||
│ └── admin.routes.js ← /api/admin/*
|
||||
│
|
||||
├── services/
|
||||
│ └── email.service.js ← Nodemailer OTP + welcome emails
|
||||
│
|
||||
└── utils/
|
||||
├── token.util.js ← JWT generate / verify / hash
|
||||
├── otp.util.js ← OTP generate / expiry / check
|
||||
└── response.util.js ← Standardised JSON responses
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Setup & Installation
|
||||
|
||||
```bash
|
||||
# 1. Clone / copy the project
|
||||
cd star-auth-system
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your DB, SMTP, Google OAuth, and JWT credentials
|
||||
|
||||
# 4. Ensure PostgreSQL is running and the DB exists
|
||||
createdb star_db
|
||||
|
||||
# 5. Start development server (auto-syncs models)
|
||||
npm run dev
|
||||
|
||||
# 6. Production
|
||||
npm start
|
||||
```
|
||||
|
||||
> **Note:** On first boot, Sequelize will create all tables automatically via `sequelize.sync({ alter: true })`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|---|---|---|
|
||||
| `NODE_ENV` | `development` or `production` | `development` |
|
||||
| `PORT` | HTTP port | `3000` |
|
||||
| `APP_URL` | Frontend/app URL (used for CORS) | `http://localhost:3000` |
|
||||
| `DB_HOST` | PostgreSQL host | `localhost` |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
| `DB_NAME` | Database name | `star_db` |
|
||||
| `DB_USER` | DB user | `postgres` |
|
||||
| `DB_PASSWORD` | DB password | `yourpassword` |
|
||||
| `JWT_SECRET` | Access token signing key (≥32 chars) | `your_super_secret_…` |
|
||||
| `JWT_EXPIRES_IN` | Access token TTL | `1d` |
|
||||
| `JWT_REFRESH_SECRET` | Refresh token signing key | `your_refresh_secret_…` |
|
||||
| `JWT_REFRESH_EXPIRES_IN` | Refresh token TTL | `7d` |
|
||||
| `SESSION_SECRET` | Express session secret (used by csurf) | `your_session_secret_…` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | from GCP Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | from GCP Console |
|
||||
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI | `/api/auth/google/callback` |
|
||||
| `SMTP_HOST` | Mail server host | `smtp.gmail.com` |
|
||||
| `SMTP_PORT` | Mail server port | `587` |
|
||||
| `SMTP_USER` | Mail username | `you@gmail.com` |
|
||||
| `SMTP_PASS` | Mail password / App Password | `xxxx xxxx xxxx xxxx` |
|
||||
| `EMAIL_FROM` | Sender address | `no-reply@star-system.com` |
|
||||
| `OTP_EXPIRY_MINUTES` | OTP validity in minutes | `10` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Models
|
||||
|
||||
### 5.1 `users` Table
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `user_id` | BIGINT PK | Auto-increment |
|
||||
| `email` | VARCHAR(255) UNIQUE | Validated email |
|
||||
| `password` | TEXT | bcrypt hash (null for Google users) |
|
||||
| `is_active` | BOOLEAN | Default `true` |
|
||||
| `is_verified` | BOOLEAN | Default `false`; set `true` on OTP verify |
|
||||
| `reg_type` | ENUM | `system` or `google` |
|
||||
| `acc_type` | ENUM | `user` / `staff` / `admin` |
|
||||
| `personal_info` | JSONB | Nested name, addresses, phone, avatar, album |
|
||||
| `otp_code` | VARCHAR(6) | Cleared after verification |
|
||||
| `otp_expires_at` | TIMESTAMP | Cleared after verification |
|
||||
| `createdAt` | TIMESTAMP | Auto |
|
||||
| `updatedAt` | TIMESTAMP | Auto |
|
||||
|
||||
### 5.2 `user_sessions` Table
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `session_id` | BIGINT PK | Auto-increment |
|
||||
| `user_id` | BIGINT FK | → users.user_id |
|
||||
| `login_info` | JSONB | `{ date, ip_address, device_info }` |
|
||||
| `logout_info` | JSONB | Populated on logout |
|
||||
| `refresh_token_hash` | TEXT | SHA-256 of refresh token |
|
||||
| `is_active` | BOOLEAN | `false` after logout |
|
||||
|
||||
### 5.3 `user_groups` + `user_group_members` Tables
|
||||
|
||||
Groups are named permission bundles. A user can belong to multiple groups.
|
||||
|
||||
```
|
||||
users ──< user_group_members >── user_groups
|
||||
(user_id, group_id,
|
||||
joined_at)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Security Layers
|
||||
|
||||
### 6.1 Rate Limiting
|
||||
|
||||
| Limiter | Routes | Window | Max |
|
||||
|---|---|---|---|
|
||||
| `globalLimiter` | All routes | 15 min | 1 000 |
|
||||
| `authLimiter` | `/register`, `/login` | 15 min | 20 |
|
||||
| `otpLimiter` | `/verify-otp`, `/resend-otp` | 15 min | 5 |
|
||||
| `sensitiveOpsLimiter` | `/change-password` | 60 min | 10 |
|
||||
|
||||
### 6.2 CSRF Protection
|
||||
|
||||
CSRF is provided via **csurf** (Double Submit Cookie pattern).
|
||||
|
||||
- Retrieve token: `GET /api/auth/csrf-token`
|
||||
- Send token: `X-CSRF-Token` header OR `_csrf` body field
|
||||
- Applied automatically to cookie-based flows
|
||||
|
||||
> **SPA / REST API clients using `Authorization: Bearer` are inherently CSRF-safe** because cross-site requests cannot set custom headers. CSRF is included for hybrid/SSR scenarios.
|
||||
|
||||
### 6.3 JWT Authentication
|
||||
|
||||
- **Access Token:** Short-lived (1 day). Sent as `Authorization: Bearer <token>`.
|
||||
- **Refresh Token:** Long-lived (7 days). SHA-256 hash stored in `user_sessions`. Rotated on every `/refresh` call.
|
||||
- **Session invalidation:** Logout, password change, and admin force-terminate all flip `is_active = false`.
|
||||
|
||||
### 6.4 Password Security
|
||||
|
||||
- bcryptjs with **cost factor 12**
|
||||
- Minimum 8 chars, must contain uppercase + digit
|
||||
- Current password required before change
|
||||
- All sessions invalidated after password change
|
||||
|
||||
### 6.5 RBAC Hierarchy
|
||||
|
||||
```
|
||||
admin ──► can do everything
|
||||
│
|
||||
staff ──► can view/toggle client+staff users; cannot touch admin accounts
|
||||
│
|
||||
user ──► can only manage own profile and sessions
|
||||
(client)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Authentication Flows
|
||||
|
||||
### 7.1 System Registration → OTP Verify → Auto-Login
|
||||
|
||||
```
|
||||
Client Server Email
|
||||
│ │ │
|
||||
│── POST /api/auth/register ──► │ │
|
||||
│ { email, password } │ │
|
||||
│ │── create user (unverified)│
|
||||
│ │── generate OTP ──────────►│
|
||||
│◄── 201 { email } ─────────── │ │
|
||||
│ │ │
|
||||
│── POST /api/auth/verify-otp ►│ │
|
||||
│ { email, otp } │ │
|
||||
│ │── validate OTP │
|
||||
│ │── mark is_verified=true │
|
||||
│ │── create session record │
|
||||
│ │── send welcome email ────►│
|
||||
│◄── 200 { accessToken, │ │
|
||||
│ refreshToken, │ │
|
||||
│ user } ─────────── │ │
|
||||
```
|
||||
|
||||
### 7.2 System Login
|
||||
|
||||
```
|
||||
Client Server
|
||||
│── POST /api/auth/login ─────►│
|
||||
│ { email, password } │
|
||||
│ │── lookup user by email
|
||||
│ │── bcrypt.compare(password, hash)
|
||||
│ │── create session record
|
||||
│◄── 200 { accessToken, │
|
||||
│ refreshToken, │
|
||||
│ session_id, user } ──│
|
||||
```
|
||||
|
||||
### 7.3 Google OAuth
|
||||
|
||||
```
|
||||
Client Google Server
|
||||
│── GET /api/auth/google ──────────────►│
|
||||
│ │── redirect to Google
|
||||
│◄─────── Google consent screen ───────│
|
||||
│── approve ──►[Google] ───callback──► │
|
||||
│ │── find or create user
|
||||
│ │── create session
|
||||
│◄── 200 { accessToken, refreshToken } ─│
|
||||
```
|
||||
|
||||
### 7.4 Token Refresh
|
||||
|
||||
```
|
||||
Client Server
|
||||
│── POST /api/auth/refresh ───►│
|
||||
│ { refreshToken } │
|
||||
│ │── verify JWT signature
|
||||
│ │── lookup session by token hash
|
||||
│ │── rotate refresh token
|
||||
│◄── 200 { accessToken, │
|
||||
│ refreshToken } ─────│
|
||||
```
|
||||
|
||||
### 7.5 Logout
|
||||
|
||||
```
|
||||
Client Server
|
||||
│── POST /api/auth/logout ────►│
|
||||
│ Authorization: Bearer ... │
|
||||
│ { session_id } │
|
||||
│ │── authenticate JWT
|
||||
│ │── set session.is_active=false
|
||||
│ │── save logout_info
|
||||
│◄── 200 "Logged out" ─────── │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API Reference
|
||||
|
||||
> **Base URL:** `http://localhost:3000/api`
|
||||
> **Auth header:** `Authorization: Bearer <accessToken>`
|
||||
|
||||
---
|
||||
|
||||
### 8.1 Auth Endpoints (Public)
|
||||
|
||||
#### `POST /auth/register`
|
||||
Register a new client account.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"email": "jane@example.com",
|
||||
"password": "Password1"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Registration successful. Please check your email for the OTP.",
|
||||
"data": { "email": "jane@example.com" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/verify-otp`
|
||||
Verify email with OTP. Returns tokens (auto-login).
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "email": "jane@example.com", "otp": "048291" }
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Email verified successfully. You are now logged in.",
|
||||
"data": {
|
||||
"accessToken": "eyJ...",
|
||||
"refreshToken": "eyJ...",
|
||||
"session_id": 1,
|
||||
"user": { "user_id": 1, "email": "jane@example.com", "acc_type": "user", ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/resend-otp`
|
||||
Resend OTP email (5 requests / 15 min limit).
|
||||
|
||||
**Request body:** `{ "email": "jane@example.com" }`
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/login`
|
||||
System (email + password) login.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "email": "jane@example.com", "password": "Password1" }
|
||||
```
|
||||
|
||||
**Response 200:** Same shape as verify-otp.
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/refresh`
|
||||
Rotate access + refresh tokens.
|
||||
|
||||
**Request body:** `{ "refreshToken": "eyJ..." }`
|
||||
|
||||
---
|
||||
|
||||
#### `GET /auth/google`
|
||||
Redirect to Google consent screen. No body needed.
|
||||
|
||||
---
|
||||
|
||||
#### `GET /auth/google/callback`
|
||||
Google OAuth redirect target. Returns tokens on success.
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/logout` *(requires Bearer token)*
|
||||
Invalidates the specified session.
|
||||
|
||||
**Request body:** `{ "session_id": 1 }`
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/change-password` *(requires Bearer token)*
|
||||
Change password. Invalidates all sessions.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "current_password": "OldPass1", "new_password": "NewPass2" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.2 Client Endpoints *(requires Bearer — any acc_type)*
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/client/profile` | Own profile |
|
||||
| PUT | `/client/profile` | Update own `personal_info` |
|
||||
| GET | `/client/sessions` | Own active sessions |
|
||||
| DELETE | `/client/sessions/:id` | Revoke one of own sessions |
|
||||
|
||||
**PUT /client/profile — Example body:**
|
||||
```json
|
||||
{
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Jane",
|
||||
"last_name": "Doe"
|
||||
},
|
||||
"occupation": "Engineer",
|
||||
"date_of_birth": "1995-06-15"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.3 Staff Endpoints *(requires Bearer — staff or admin)*
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/staff/users` | Paginated list (non-admins) |
|
||||
| GET | `/staff/users/:id` | Single non-admin user |
|
||||
| PUT | `/staff/users/:id/status` | Toggle `is_active` |
|
||||
| GET | `/staff/users/:id/sessions` | User's sessions |
|
||||
|
||||
**Query params for GET list:** `?page=1&limit=20`
|
||||
|
||||
**PUT /staff/users/:id/status — Body:** `{ "is_active": false }`
|
||||
|
||||
---
|
||||
|
||||
### 8.4 Admin Endpoints *(requires Bearer — admin only)*
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/admin/users` | All users (paginated) |
|
||||
| GET | `/admin/users/:id` | Any user with groups |
|
||||
| PUT | `/admin/users/:id` | Update `acc_type`, `is_active`, `personal_info` |
|
||||
| DELETE | `/admin/users/:id` | Hard delete user |
|
||||
| GET | `/admin/users/:id/sessions` | All sessions |
|
||||
| DELETE | `/admin/users/:id/sessions/:sid` | Force-terminate session |
|
||||
| GET | `/admin/groups` | List all groups |
|
||||
| POST | `/admin/groups` | Create group `{ name, description }` |
|
||||
| DELETE | `/admin/groups/:gid` | Delete group |
|
||||
| POST | `/admin/users/:id/groups/:gid` | Add user to group |
|
||||
| DELETE | `/admin/users/:id/groups/:gid` | Remove user from group |
|
||||
|
||||
**PUT /admin/users/:id — Allowed fields:**
|
||||
```json
|
||||
{
|
||||
"acc_type": "staff",
|
||||
"is_active": true,
|
||||
"personal_info": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. RBAC Permission Matrix
|
||||
|
||||
| Action | Client (`user`) | Staff (`staff`) | Admin (`admin`) |
|
||||
|---|:---:|:---:|:---:|
|
||||
| Register | ✅ | ✅ | ✅ |
|
||||
| Login | ✅ | ✅ | ✅ |
|
||||
| View own profile | ✅ | ✅ | ✅ |
|
||||
| Edit own profile | ✅ | ✅ | ✅ |
|
||||
| View own sessions | ✅ | ✅ | ✅ |
|
||||
| Revoke own session | ✅ | ✅ | ✅ |
|
||||
| Change own password | ✅ | ✅ | ✅ |
|
||||
| List all users | ❌ | ✅ (no admins) | ✅ |
|
||||
| View any user | ❌ | ✅ (no admins) | ✅ |
|
||||
| Toggle user status | ❌ | ✅ (no admins) | ✅ |
|
||||
| View user sessions | ❌ | ✅ (no admins) | ✅ |
|
||||
| Change user `acc_type` | ❌ | ❌ | ✅ |
|
||||
| View admin accounts | ❌ | ❌ | ✅ |
|
||||
| Hard delete users | ❌ | ❌ | ✅ |
|
||||
| Force-terminate session | ❌ | ❌ | ✅ |
|
||||
| Manage groups | ❌ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 10. File-by-File Documentation
|
||||
|
||||
### `server.js`
|
||||
**Purpose:** Application entry point. Bootstraps Express, registers global middleware (CORS, rate limiter, cookie-parser, session, Passport), mounts all routes, syncs DB models.
|
||||
**Key behaviors:**
|
||||
- Runs `sequelize.sync({ alter: true })` on startup (development) — safe column updates.
|
||||
- Registers a global 404 handler and error handler.
|
||||
- Enables `trust proxy` for correct IP detection behind load balancers.
|
||||
|
||||
---
|
||||
|
||||
### `config/db.config.js`
|
||||
**Purpose:** Creates and exports a single authenticated Sequelize instance.
|
||||
**Usage:** `const sequelize = require('./config/db.config');`
|
||||
|
||||
---
|
||||
|
||||
### `config/passport.config.js`
|
||||
**Purpose:** Registers the Google OAuth 2.0 strategy. Auto-creates user on first Google login with `reg_type='google'` and `is_verified=true`.
|
||||
**Usage:** Imported in `server.js`; used in `routes/auth.routes.js` via `passport.authenticate('google', ...)`.
|
||||
|
||||
---
|
||||
|
||||
### `models/users.mdl.js`
|
||||
**Purpose:** Defines the `users` table including OTP fields (`otp_code`, `otp_expires_at`) added for the verification flow.
|
||||
**Exported as:** `mdl_Users` (default)
|
||||
|
||||
---
|
||||
|
||||
### `models/user_sessions.mdl.js`
|
||||
**Purpose:** Audit log for logins/logouts. Stores `refresh_token_hash` for secure token rotation and session invalidation. Sets up `belongsTo/hasMany` with Users.
|
||||
**Exported as:** `mdl_UserSessions` (default)
|
||||
|
||||
---
|
||||
|
||||
### `models/user_groups.mdl.js`
|
||||
**Purpose:** `user_groups` and `user_group_members` junction table. Implements `belongsToMany` associations between Users and Groups.
|
||||
**Exported as:** `{ mdl_UserGroups, mdl_UserGroupMembers }`
|
||||
|
||||
---
|
||||
|
||||
### `middleware/auth.middleware.js`
|
||||
**Purpose:** Reads `Authorization: Bearer <token>`, calls `verifyAccessToken()`, loads the user from DB, and attaches it to `req.user`.
|
||||
**Exported:** `authenticate`
|
||||
|
||||
---
|
||||
|
||||
### `middleware/rbac.middleware.js`
|
||||
**Purpose:** Role guards that inspect `req.user.acc_type`. All guards must be used **after** `authenticate`.
|
||||
**Exported:** `requireClient()`, `requireStaff()`, `requireAdmin()`, `requireOwnerOrStaff()`, `requireOwnerOrAdmin()`
|
||||
|
||||
---
|
||||
|
||||
### `middleware/csrf.middleware.js`
|
||||
**Purpose:** Wraps `csurf` with a token endpoint and a dedicated error handler.
|
||||
**Exported:** `csrfProtection`, `getCsrfToken`, `csrfErrorHandler`
|
||||
|
||||
---
|
||||
|
||||
### `middleware/rateLimiter.middleware.js`
|
||||
**Purpose:** Four rate-limiter configurations for different route tiers.
|
||||
**Exported:** `globalLimiter`, `authLimiter`, `otpLimiter`, `sensitiveOpsLimiter`
|
||||
|
||||
---
|
||||
|
||||
### `middleware/validate.middleware.js`
|
||||
**Purpose:** Reads the `validationResult` from express-validator and returns 422 if any rule failed.
|
||||
**Exported:** `validate`
|
||||
|
||||
---
|
||||
|
||||
### `validators/auth.validator.js`
|
||||
**Purpose:** express-validator chains for `register`, `login`, `verify-otp`, `resend-otp`, `change-password`.
|
||||
**Usage:** Spread into route definitions: `router.post('/login', ...loginValidator, validate, handler)`
|
||||
|
||||
---
|
||||
|
||||
### `validators/profile.validator.js`
|
||||
**Purpose:** Validates `personal_info` fields on profile update requests.
|
||||
|
||||
---
|
||||
|
||||
### `utils/token.util.js`
|
||||
**Purpose:** JWT helpers.
|
||||
**Functions:**
|
||||
- `generateTokens(user)` → `{ accessToken, refreshToken }`
|
||||
- `verifyAccessToken(token)` → decoded payload (throws on expiry/invalid)
|
||||
- `verifyRefreshToken(token)` → decoded payload
|
||||
- `hashToken(token)` → SHA-256 hex string for DB storage
|
||||
|
||||
---
|
||||
|
||||
### `utils/otp.util.js`
|
||||
**Purpose:** Cryptographically secure OTP helpers.
|
||||
**Functions:**
|
||||
- `generateOTP()` → `"048291"` (6 digits, padded)
|
||||
- `getOTPExpiry(minutes)` → `Date` N minutes from now
|
||||
- `isOTPExpired(expiresAt)` → `boolean`
|
||||
|
||||
---
|
||||
|
||||
### `utils/response.util.js`
|
||||
**Purpose:** Standardised response envelope `{ status, message, data?, errors? }`.
|
||||
**Functions:** `success(res, msg, data, statusCode)`, `error(res, msg, statusCode, errors)`, `validationError(res, errors)`
|
||||
|
||||
---
|
||||
|
||||
### `services/email.service.js`
|
||||
**Purpose:** Nodemailer transporter with two email templates.
|
||||
**Functions:**
|
||||
- `sendOTPEmail(to, otp, expiryMinutes)` — styled HTML OTP email
|
||||
- `sendWelcomeEmail(to, name)` — post-verification welcome
|
||||
|
||||
---
|
||||
|
||||
### `controllers/auth.controller.js`
|
||||
**Purpose:** Shared auth logic: `register`, `verifyOTP`, `resendOTP`, `login`, `googleCallback`, `refreshToken`, `logout`, `changePassword`.
|
||||
|
||||
---
|
||||
|
||||
### `controllers/client/profile.controller.js`
|
||||
**Purpose:** Self-service profile actions for any authenticated user.
|
||||
**Functions:** `getProfile`, `updateProfile` (deep-merge), `getSessions`, `revokeSession`
|
||||
|
||||
---
|
||||
|
||||
### `controllers/staff/users.controller.js`
|
||||
**Purpose:** Staff-level user management (read-only + status toggle; no admin data).
|
||||
**Functions:** `getUsers`, `getUser`, `setUserStatus`, `getUserSessions`
|
||||
|
||||
---
|
||||
|
||||
### `controllers/admin/users.controller.js`
|
||||
**Purpose:** Full admin control over users, sessions, and groups.
|
||||
**Functions:** `getUsers`, `getUser`, `updateUser`, `deleteUser`, `getUserSessions`, `terminateSession`, `getGroups`, `createGroup`, `deleteGroup`, `addUserToGroup`, `removeUserFromGroup`
|
||||
|
||||
---
|
||||
|
||||
## 11. Error Codes Reference
|
||||
|
||||
| HTTP | Scenario |
|
||||
|---|---|
|
||||
| 400 | Bad request (missing field, wrong type, already verified) |
|
||||
| 401 | Missing/invalid/expired token, wrong credentials |
|
||||
| 403 | Account deactivated, wrong role, CSRF error |
|
||||
| 404 | Resource not found |
|
||||
| 409 | Email already registered |
|
||||
| 422 | Validation failed (see `errors[]` array) |
|
||||
| 429 | Rate limit exceeded |
|
||||
| 500 | Internal server error |
|
||||
|
||||
All error responses follow:
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Human-readable reason",
|
||||
"errors": [ ... ] // only on 422
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Extending the System
|
||||
|
||||
### Add a New Endpoint
|
||||
|
||||
1. Create validator rules in `validators/`
|
||||
2. Create or extend a controller in `controllers/<role>/`
|
||||
3. Add the route in `routes/<role>/<role>.routes.js` with appropriate middleware chain
|
||||
4. No changes to `server.js` needed if mounting the same base path
|
||||
|
||||
### Add a New Role
|
||||
|
||||
1. Add the ENUM value to `acc_type` in `users.mdl.js`
|
||||
2. Add a guard in `rbac.middleware.js` (e.g., `requireSuperAdmin()`)
|
||||
3. Create a dedicated route + controller folder
|
||||
|
||||
### Add Redis Session Store
|
||||
|
||||
Install `connect-redis` and update `server.js`:
|
||||
```js
|
||||
const RedisStore = require('connect-redis').default;
|
||||
const { createClient } = require('redis');
|
||||
const redisClient = createClient({ url: process.env.REDIS_URL });
|
||||
await redisClient.connect();
|
||||
app.use(session({ store: new RedisStore({ client: redisClient }), ... }));
|
||||
```
|
||||
|
||||
### Production Checklist
|
||||
|
||||
- [ ] Set `NODE_ENV=production`
|
||||
- [ ] Use strong `JWT_SECRET` (≥ 64 random characters)
|
||||
- [ ] Enable HTTPS / TLS (set `cookie.secure: true`)
|
||||
- [ ] Point `APP_URL` to your actual domain for CORS
|
||||
- [ ] Replace in-memory rate limiter with Redis store
|
||||
- [ ] Set up log rotation (replace `console.log` with Winston/Pino)
|
||||
- [ ] Use `sequelize.sync({ force: false, alter: false })` in production (run migrations instead)
|
||||
|
||||
---
|
||||
|
||||
*End of Documentation — STAR Phase 1 Auth System v1.0.0*
|
||||
@@ -0,0 +1,41 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: db.config.js
|
||||
* Type of Program: Configuration
|
||||
* Description: Sequelize PostgreSQL connection instance.
|
||||
* Reads credentials from environment variables and exposes a single
|
||||
* authenticated Sequelize instance used across all models.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const sequelize = require('./config/db.config');
|
||||
* // Then use sequelize.define() or import your models directly.
|
||||
***********************************************************************************************************************************************************************/
|
||||
require('dotenv').config();
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME,
|
||||
process.env.DB_USER,
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: 'postgres',
|
||||
dialectOptions: {
|
||||
ssl: {
|
||||
require: true,
|
||||
rejectUnauthorized: false, // or provide CA cert if strict
|
||||
},
|
||||
},
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
pool: {
|
||||
max: 10,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = sequelize;
|
||||
@@ -0,0 +1,70 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: passport.config.js
|
||||
* Type of Program: Configuration
|
||||
* Description: Passport.js strategy configuration.
|
||||
* Registers the Google OAuth 2.0 strategy. On first login via Google
|
||||
* a new user record is auto-created with reg_type="google".
|
||||
* Returns a signed JWT payload after successful authentication.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* In server.js:
|
||||
* require('./config/passport.config');
|
||||
* app.use(passport.initialize());
|
||||
* In routes:
|
||||
* router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
||||
* router.get('/google/callback', passport.authenticate('google', { session: false }), handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const passport = require('passport');
|
||||
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email) return done(new Error('No email returned from Google'), null);
|
||||
|
||||
let user = await mdl_Users.findOne({ where: { email } });
|
||||
|
||||
if (!user) {
|
||||
// Auto-register Google users as 'user' acc_type
|
||||
user = await mdl_Users.create({
|
||||
email,
|
||||
reg_type: 'google',
|
||||
acc_type: 'user',
|
||||
is_active: true,
|
||||
is_verified: true, // Google accounts are pre-verified
|
||||
personal_info: {
|
||||
name: {
|
||||
given_name: profile.name?.givenName || '',
|
||||
last_name: profile.name?.familyName || '',
|
||||
full_name: profile.displayName || '',
|
||||
},
|
||||
avatar: {
|
||||
url: profile.photos?.[0]?.value || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return done(null, false, { message: 'Account is deactivated.' });
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
module.exports = passport;
|
||||
@@ -0,0 +1,446 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level user management — full CRUD on any user.
|
||||
* Admins can: list all users, view any user, update acc_type,
|
||||
* activate/deactivate, hard-delete, manage groups.
|
||||
*
|
||||
* Endpoints (require authenticate → requireAdmin()):
|
||||
* GET /api/admin/users → all users (paginated)
|
||||
* GET /api/admin/users/:id → any user
|
||||
* PUT /api/admin/users/:id → update acc_type, is_active, personal_info
|
||||
* DELETE /api/admin/users/:id → hard delete
|
||||
* POST /api/admin/users/:id/groups/:gid → add user to group
|
||||
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
|
||||
* GET /api/admin/groups → list groups
|
||||
* POST /api/admin/groups → create group
|
||||
* DELETE /api/admin/groups/:gid → delete group
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, Sequelize } = require('sequelize')
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate, auditInclude } = require('../../utils/paginate.util');
|
||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
|
||||
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
|
||||
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ALL USERS]', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET single user ───────────────────────────────────────────────────────────
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.params.id, {
|
||||
attributes: { exclude: EXCLUDED },
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups', // ← matches the association alias
|
||||
through: { attributes: [] }
|
||||
}],
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', user);
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][GET USER]", err);
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT update any user ───────────────────────────────────────────────────────
|
||||
exports.updateUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const allowed = ['acc_type', 'is_active', 'personal_info'];
|
||||
const updates = {};
|
||||
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
|
||||
|
||||
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
|
||||
return R.error(res, 'Admins cannot change their own role.', 400);
|
||||
|
||||
// Enrich personal_info before saving
|
||||
if (updates.personal_info) {
|
||||
updates.personal_info = enrichPersonalInfo(updates.personal_info);
|
||||
}
|
||||
|
||||
updates.updatedBy = req.user.user_id; // ← who updated
|
||||
|
||||
await user.update(updates);
|
||||
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
|
||||
return R.success(res, 'User updated.', updated);
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][UPDATE USER]", err);
|
||||
return R.error(res, 'Could not update user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEACTIVATE user (soft delete) ────────────────────────────────────────────
|
||||
exports.deactivateUser = async (req, res) => {
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot deactivate your own account.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.is_active && user.deletedAt)
|
||||
return R.error(res, 'User is already deactivated.', 400);
|
||||
|
||||
await user.update({
|
||||
is_active: false,
|
||||
deletedBy: req.user.user_id, // ← who deactivated
|
||||
});
|
||||
|
||||
await user.destroy();
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
|
||||
return R.success(res, 'User deactivated successfully.');
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][DEACTIVATE USER]", err);
|
||||
return R.error(res, 'Could not deactivate user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE user ──────────────────────────────────────────────────────────────
|
||||
exports.restoreUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
||||
|
||||
await user.restore();
|
||||
await user.update({
|
||||
is_active: true,
|
||||
updatedBy: req.user.user_id, // ← who restored
|
||||
});
|
||||
|
||||
return R.success(res, 'User restored successfully.');
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][RESTORE USER]", err);
|
||||
return R.error(res, 'Could not restore user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
findOptions: {
|
||||
attributes: {
|
||||
include: [
|
||||
[
|
||||
Sequelize.literal(`(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM "user_group_members"
|
||||
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
|
||||
)`),
|
||||
'memberCount',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Groups retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET GROUPS]', err);
|
||||
return R.error(res, 'Could not retrieve groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
const members = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
findOptions: {
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroupMembers,
|
||||
where: { group_id: req.params.gid },
|
||||
attributes: [],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Group retrieved.', { group, members });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET GROUP]', err);
|
||||
return R.error(res, 'Could not retrieve group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createGroup = async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return R.error(res, 'Group name is required.', 400);
|
||||
|
||||
const group = await mdl_UserGroups.create({
|
||||
name,
|
||||
description,
|
||||
createdBy: req.user.user_id,
|
||||
});
|
||||
|
||||
return R.success(res, 'Group created.', group, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE GROUP]', err);
|
||||
return R.error(res, 'Could not create group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateGroup = async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
if (name !== undefined) group.name = name;
|
||||
if (description !== undefined) group.description = description;
|
||||
group.updatedBy = req.user.user_id;
|
||||
await group.save();
|
||||
|
||||
return R.success(res, 'Group updated.', group);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE GROUP]', err);
|
||||
return R.error(res, 'Could not update group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.deactivateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
|
||||
|
||||
await group.update({
|
||||
is_active: false,
|
||||
updatedBy: req.user.user_id,
|
||||
deletedBy: req.user.user_id,
|
||||
});
|
||||
await group.destroy();
|
||||
|
||||
return R.success(res, 'Group deactivated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DEACTIVATE GROUP]', err);
|
||||
return R.error(res, 'Could not deactivate group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_id: req.params.gid },
|
||||
paranoid: false, // ← needed to find soft-deleted rows
|
||||
});
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
||||
|
||||
await group.restore();
|
||||
await group.update({
|
||||
is_active: true,
|
||||
updatedBy: req.user.user_id,
|
||||
deletedBy: null,
|
||||
});
|
||||
|
||||
return R.success(res, 'Group restored.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][RESTORE GROUP]', err);
|
||||
return R.error(res, 'Could not restore group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Group membership ──────────────────────────────────────────────────────────
|
||||
// Get List of Members in a Group, to be added
|
||||
exports.getUsersNotInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
const members = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
|
||||
const memberIds = members.map(m => m.user_id);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
where: {
|
||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
||||
},
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
],
|
||||
});
|
||||
|
||||
return R.success(res, 'Users fetched.', users);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USERS NOT IN GROUP]', err);
|
||||
return R.error(res, 'Could not fetch users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Get List of Members in a Group, to be removed
|
||||
exports.getUsersInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
const group = await mdl_UserGroups.findByPk(group_id, {
|
||||
include: [{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
through: { attributes: [] },
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
return R.success(res, 'Group members fetched.', group.members);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USERS IN GROUP]', err);
|
||||
return R.error(res, 'Could not fetch group members.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.addUserToGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || user_ids.length === 0)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingUsers = await mdl_Users.findAll({
|
||||
where: { user_id: user_ids },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
|
||||
const existingUserIds = existingUsers.map(u => u.user_id);
|
||||
const notFound = user_ids.filter(id => !existingUserIds.includes(id));
|
||||
|
||||
if (notFound.length > 0)
|
||||
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
||||
|
||||
// Restore soft-deleted rows
|
||||
await mdl_UserGroupMembers.restore({
|
||||
where: { user_id: user_ids, group_id },
|
||||
});
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: null, updatedBy: req.user.user_id },
|
||||
{ where: { user_id: user_ids, group_id }, paranoid: false }
|
||||
);
|
||||
|
||||
// Insert any that didn't exist at all
|
||||
await mdl_UserGroupMembers.bulkCreate(
|
||||
user_ids.map(user_id => ({ user_id, group_id, createdBy: req.user.user_id })),
|
||||
{ ignoreDuplicates: true }
|
||||
);
|
||||
|
||||
return R.success(res, 'Users added to group.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ADD USERS TO GROUP]', err);
|
||||
return R.error(res, 'Could not add users to group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeUserFromGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || user_ids.length === 0)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingMembers = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: user_ids, group_id },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
|
||||
const existingMemberIds = existingMembers.map(m => m.user_id);
|
||||
const notFound = user_ids.filter(id => !existingMemberIds.includes(id));
|
||||
|
||||
if (notFound.length > 0)
|
||||
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: user_ids, group_id } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({
|
||||
where: { user_id: user_ids, group_id },
|
||||
});
|
||||
|
||||
return R.success(res, 'Users removed from group.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][REMOVE USERS FROM GROUP]', err);
|
||||
return R.error(res, 'Could not remove users from group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Admin sessions management ─────────────────────────────────────────────────
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.terminateSession = async (req, res) => {
|
||||
try {
|
||||
const session = await mdl_UserSessions.findByPk(req.params.sid);
|
||||
if (!session) return R.error(res, 'Session not found.', 404);
|
||||
await session.update({ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } });
|
||||
return R.success(res, 'Session terminated.');
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not terminate session.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.controller.js
|
||||
* Type of Program: Controller
|
||||
* Description: Handles all authentication flows:
|
||||
* 1. System Registration → OTP email → OTP Verify → Auto-Login
|
||||
* 2. System Login (verified users)
|
||||
* 3. Google OAuth callback
|
||||
* 4. Token Refresh
|
||||
* 5. Logout (invalidates session)
|
||||
* 6. OTP Resend
|
||||
* 7. Change Password
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* Mount via routes/auth.routes.js
|
||||
* POST /api/auth/register
|
||||
* POST /api/auth/verify-otp
|
||||
* POST /api/auth/resend-otp
|
||||
* POST /api/auth/login
|
||||
* POST /api/auth/refresh
|
||||
* POST /api/auth/logout
|
||||
* GET /api/auth/google
|
||||
* GET /api/auth/google/callback
|
||||
***********************************************************************************************************************************************************************/
|
||||
const bcrypt = require('bcryptjs');
|
||||
const sequelize = require('../config/db.config')
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
||||
const { generateTokens, verifyRefreshToken, hashToken } = require('../utils/token.util');
|
||||
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
|
||||
const sendEmail = require('../services/email.service');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const buildLoginInfo = (req) => ({
|
||||
date: new Date().toISOString(),
|
||||
ip_address: req.ip,
|
||||
device_info: req.headers['user-agent'] || 'unknown',
|
||||
});
|
||||
|
||||
const safeUser = (user, extraExclude = []) => {
|
||||
const u = user.toJSON ? user.toJSON() : { ...user };
|
||||
|
||||
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
|
||||
|
||||
return u;
|
||||
};
|
||||
|
||||
// ─── Register ──────────────────────────────────────────────────────────────────
|
||||
exports.register = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const existing = await mdl_Users.findOne({ where: { email } });
|
||||
if (existing) return R.error(res, 'Email is already registered.', 409);
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const otp = generateOTP();
|
||||
|
||||
const user = await mdl_Users.create({
|
||||
email,
|
||||
password: hashed,
|
||||
otp_code: otp,
|
||||
otp_expires_at: getOTPExpiry(),
|
||||
is_active: true,
|
||||
is_verified: false,
|
||||
reg_type: 'system',
|
||||
acc_type: 'user',
|
||||
createdBy: null,
|
||||
}, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
|
||||
email: user.email,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] register error:', err);
|
||||
return R.error(res, 'Registration failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Verify OTP ────────────────────────────────────────────────────────────────
|
||||
exports.verifyOTP = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email, otp } = req.body;
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
|
||||
|
||||
if (user.otp_code !== otp) return R.error(res, 'Invalid OTP.', 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||
|
||||
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
|
||||
|
||||
// Auto-login after verification
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
const session = await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: buildLoginInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
}, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "WELCOME", data: { name: email } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return R.success(res, 'Email verified successfully. You are now logged in.', {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
session_id: session.session_id,
|
||||
user: safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] verifyOTP error:', err);
|
||||
return R.error(res, 'OTP verification failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Resend OTP ────────────────────────────────────────────────────────────────
|
||||
exports.resendOTP = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_verified) return R.error(res, 'Account is already verified.', 400);
|
||||
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return R.success(res, 'A new OTP has been sent to your email.');
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] resendOTP error:', err);
|
||||
return R.error(res, 'Could not resend OTP.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── System Login ──────────────────────────────────────────────────────────────
|
||||
exports.login = async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'Invalid credentials.', 401);
|
||||
if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400);
|
||||
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
|
||||
if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403);
|
||||
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
||||
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
const session = await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: buildLoginInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return R.success(res, 'Login successful.', {
|
||||
accessToken,
|
||||
session_id: session.session_id,
|
||||
user: safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AUTH] login error:', err);
|
||||
return R.error(res, 'Login failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Google OAuth Callback ─────────────────────────────────────────────────────
|
||||
exports.googleCallback = async (req, res) => {
|
||||
try {
|
||||
const user = req.user; // set by passport
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
|
||||
await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: buildLoginInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
// In a real SPA: redirect with tokens in query or set httpOnly cookie
|
||||
return R.success(res, 'Google login successful.', {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AUTH] googleCallback error:', err);
|
||||
return R.error(res, 'Google authentication failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Refresh Token ─────────────────────────────────────────────────────────────
|
||||
exports.refreshToken = async (req, res) => {
|
||||
try {
|
||||
const refreshToken = req.cookies.refreshToken;
|
||||
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400);
|
||||
|
||||
console.log('[REFRESH] called, token tail:', refreshToken?.slice(-10))
|
||||
console.log('[REFRESH] hash:', hashToken(refreshToken))
|
||||
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
const tokenHash = hashToken(refreshToken);
|
||||
|
||||
console.log({ user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true })
|
||||
const session = await mdl_UserSessions.findOne({
|
||||
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true },
|
||||
});
|
||||
if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401);
|
||||
|
||||
const user = await mdl_Users.findByPk(decoded.user_id);
|
||||
if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401);
|
||||
|
||||
const tokens = generateTokens(user);
|
||||
|
||||
// Rotate refresh token
|
||||
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
|
||||
|
||||
console.log('[REFRESH] old token tail:', refreshToken?.slice(-10))
|
||||
console.log('[REFRESH] new token tail:', tokens.refreshToken?.slice(-10))
|
||||
console.log('[REFRESH] are they different:', refreshToken !== tokens.refreshToken)
|
||||
|
||||
res.cookie('refreshToken', tokens.refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
|
||||
} catch (err) {
|
||||
return R.error(res, 'Invalid or expired refresh token.', 401);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Logout ────────────────────────────────────────────────────────────────────
|
||||
exports.logout = async (req, res) => {
|
||||
try {
|
||||
const { session_id } = req.body;
|
||||
if (session_id) {
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: buildLoginInfo(req) },
|
||||
{ where: { session_id, user_id: req.user.user_id } }
|
||||
);
|
||||
}
|
||||
|
||||
res.clearCookie('refreshToken')
|
||||
res.clearCookie('_csrf')
|
||||
|
||||
return R.success(res, 'Logged out successfully.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] logout error:', err);
|
||||
return R.error(res, 'Logout failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Change Password ───────────────────────────────────────────────────────────
|
||||
exports.changePassword = async (req, res) => {
|
||||
try {
|
||||
const { current_password, new_password } = req.body;
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
if (user.reg_type === 'google')
|
||||
return R.error(res, 'Google accounts cannot change passwords here.', 400);
|
||||
|
||||
const match = await bcrypt.compare(current_password, user.password);
|
||||
if (!match) return R.error(res, 'Current password is incorrect.', 400);
|
||||
|
||||
const hashed = await bcrypt.hash(new_password, 12);
|
||||
await user.update({ password: hashed });
|
||||
|
||||
// Invalidate all sessions to force re-login
|
||||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||||
|
||||
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] changePassword error:', err);
|
||||
return R.error(res, 'Password change failed.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: profile.controller.js (client)
|
||||
* Type of Program: Controller
|
||||
* Description: Self-service profile management for CLIENT users.
|
||||
* All routes require: authenticate → requireClient()
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/client/profile → view own profile
|
||||
* PUT /api/client/profile → update personal_info
|
||||
* GET /api/client/sessions → view own active sessions
|
||||
* DELETE /api/client/sessions/:id → revoke a specific session
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
exports.getProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile retrieved.', user);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve profile.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT update own profile ────────────────────────────────────────────────────
|
||||
exports.updateProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
const { personal_info } = req.body;
|
||||
|
||||
// Deep-merge personal_info so partial updates don't wipe existing data
|
||||
const merged = {
|
||||
...(user.personal_info || {}),
|
||||
...(personal_info || {}),
|
||||
name: {
|
||||
...((user.personal_info?.name) || {}),
|
||||
...((personal_info?.name) || {}),
|
||||
},
|
||||
};
|
||||
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
|
||||
return R.success(res, 'Profile updated.', updated);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] updateProfile error:', err);
|
||||
return R.error(res, 'Profile update failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET own sessions ──────────────────────────────────────────────────────────
|
||||
exports.getSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.user.user_id, is_active: true },
|
||||
order: [['createdAt', 'DESC']],
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE revoke a session ───────────────────────────────────────────────────
|
||||
exports.revokeSession = async (req, res) => {
|
||||
try {
|
||||
const session = await mdl_UserSessions.findOne({
|
||||
where: { session_id: req.params.id, user_id: req.user.user_id },
|
||||
});
|
||||
if (!session) return R.error(res, 'Session not found.', 404);
|
||||
|
||||
await session.update({
|
||||
is_active: false,
|
||||
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
|
||||
});
|
||||
|
||||
return R.success(res, 'Session revoked.');
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not revoke session.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-level user management.
|
||||
* Staff can VIEW any client/staff user and perform limited actions.
|
||||
* Staff CANNOT modify admin accounts or assign/revoke admin roles.
|
||||
*
|
||||
* Endpoints (require authenticate → requireStaff()):
|
||||
* GET /api/staff/users → paginated user list (non-admins)
|
||||
* GET /api/staff/users/:id → view any non-admin user
|
||||
* PUT /api/staff/users/:id/status → activate / deactivate user
|
||||
* GET /api/staff/users/:id/sessions → view user sessions
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
|
||||
|
||||
// ─── GET paginated list (no admins) ────────────────────────────────────────────
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(100, parseInt(req.query.limit) || 20);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows } = await mdl_Users.findAndCountAll({
|
||||
where: { acc_type: { [Op.ne]: 'admin' } },
|
||||
attributes: { exclude: EXCLUDED },
|
||||
limit,
|
||||
offset,
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', {
|
||||
total: count,
|
||||
page,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
users: rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[STAFF] getUsers error:', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET single non-admin user ─────────────────────────────────────────────────
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
attributes: { exclude: EXCLUDED },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', user);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT activate / deactivate ─────────────────────────────────────────────────
|
||||
exports.setUserStatus = async (req, res) => {
|
||||
try {
|
||||
const { is_active } = req.body;
|
||||
if (typeof is_active !== 'boolean')
|
||||
return R.error(res, 'is_active must be a boolean.', 400);
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found or operation not permitted.', 404);
|
||||
|
||||
await user.update({ is_active });
|
||||
return R.success(res, `User ${is_active ? 'activated' : 'deactivated'}.`);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not update user status.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET user sessions ─────────────────────────────────────────────────────────
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff profile (re-use client controller) ──────────────────────────────────
|
||||
// Staff also manage their own profile through the same client endpoints.
|
||||
// No additional staff-specific profile endpoints needed.
|
||||
@@ -0,0 +1,81 @@
|
||||
export const emailTemplates = {
|
||||
OTP: ({ otp, expiryMinutes = 10 }) => ({
|
||||
subject: "Email OTP Verification - STARR System",
|
||||
title: "Email Verification",
|
||||
body: `
|
||||
<p>Dear User,</p>
|
||||
|
||||
<p>Please use the One-Time Password (OTP) below to verify your email address.
|
||||
This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
||||
|
||||
<div style="margin:24px 0;font-size:32px;font-weight:bold;letter-spacing:8px;text-align:center;color:#1d4ed8">
|
||||
${otp}
|
||||
</div>
|
||||
|
||||
<p style="font-size:13px;color:#6b7280">
|
||||
For security reasons, please do not share this code with anyone.
|
||||
If you did not request this, please contact the administrator.
|
||||
</p>
|
||||
`,
|
||||
}),
|
||||
|
||||
WELCOME: ({ name }) => ({
|
||||
subject: "Welcome to STARR System",
|
||||
title: `Welcome Aboard!`,
|
||||
body: `
|
||||
<p>Dear ${name},</p>
|
||||
|
||||
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
|
||||
|
||||
<p>You may now access your dashboard and begin using the available services.</p>
|
||||
|
||||
<p style="margin-top:16px">We look forward to supporting you.</p>
|
||||
`,
|
||||
}),
|
||||
|
||||
PASSWORD_CHANGED: () => ({
|
||||
subject: "Password Update Confirmation - STARR System",
|
||||
title: "Password Successfully Updated",
|
||||
body: `
|
||||
<p>Dear User,</p>
|
||||
|
||||
<p>This is to confirm that your account password has been successfully changed.</p>
|
||||
|
||||
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
|
||||
|
||||
<p style="margin-top:16px">For your security, we recommend using a strong and unique password.</p>
|
||||
`,
|
||||
}),
|
||||
|
||||
ADDED_TO_GROUP: ({ groupName }) => ({
|
||||
subject: "Group Assignment Notification",
|
||||
title: "Added to Group",
|
||||
body: `
|
||||
<p>Dear User,</p>
|
||||
|
||||
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
|
||||
|
||||
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
|
||||
|
||||
<p style="margin-top:16px">Please log in to your account to view group details.</p>
|
||||
`,
|
||||
}),
|
||||
|
||||
TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
|
||||
subject: "New Task Assignment",
|
||||
title: "Task Assigned",
|
||||
body: `
|
||||
<p>Dear User,</p>
|
||||
|
||||
<p>You have been assigned a new task in the STARR System.</p>
|
||||
|
||||
<div style="margin:16px 0;padding:12px;border:1px solid #e5e7eb;border-radius:6px">
|
||||
<strong>Task:</strong> ${taskTitle}
|
||||
</div>
|
||||
|
||||
<p><strong>Due Date:</strong> ${dueDate}</p>
|
||||
|
||||
<p style="margin-top:16px">Kindly ensure completion within the specified timeframe.</p>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: JWT authentication guard.
|
||||
* Reads the Bearer token from the Authorization header,
|
||||
* verifies it, attaches decoded payload to req.user,
|
||||
* and verifies the session is still active in the DB.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { authenticate } = require('../middleware/auth.middleware');
|
||||
* router.get('/profile', authenticate, handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { verifyAccessToken } = require('../utils/token.util');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
/**
|
||||
* Validates JWT and loads user from DB.
|
||||
* Attaches the full user record to req.user.
|
||||
*/
|
||||
const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer '))
|
||||
return R.error(res, 'No token provided.', 401);
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyAccessToken(token);
|
||||
|
||||
const user = await mdl_Users.findByPk(decoded.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
|
||||
if (!user) return R.error(res, 'User not found.', 401);
|
||||
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError')
|
||||
return R.error(res, 'Token expired. Please log in again.', 401);
|
||||
return R.error(res, 'Invalid token.', 401);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { authenticate };
|
||||
@@ -0,0 +1,53 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: csrf.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: CSRF protection using the `csurf` package (Double Submit Cookie pattern).
|
||||
* - csrfProtection → the csurf middleware instance (attach to state-changing routes)
|
||||
* - getCsrfToken → GET /csrf-token handler — sends the token to the client
|
||||
* - csrfErrorHandler → catches EBADCSRFTOKEN and returns a 403
|
||||
*
|
||||
* NOTE: Because we use stateless JWT (no server sessions), CSRF is only
|
||||
* relevant for cookie-based flows (e.g., CSRF token embedded in form headers).
|
||||
* For REST / SPA clients, the standard practice is to omit CSRF and rely on
|
||||
* the Authorization Bearer header (which is already CSRF-safe by design).
|
||||
* This file keeps CSRF available for SSR / hybrid flows.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* // 1. Mount the token endpoint (public):
|
||||
* app.get('/api/csrf-token', getCsrfToken);
|
||||
*
|
||||
* // 2. Apply to state-mutating cookie-based routes:
|
||||
* router.post('/login', csrfProtection, loginHandler);
|
||||
*
|
||||
* // 3. Register the error handler AFTER all routes:
|
||||
* app.use(csrfErrorHandler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const csurf = require('csurf');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
/** csurf instance — stores token in a signed cookie */
|
||||
const csrfProtection = csurf({ cookie: { httpOnly: true, sameSite: 'strict' } });
|
||||
|
||||
/**
|
||||
* GET /api/csrf-token
|
||||
* Returns the CSRF token the client must echo back on state-changing requests
|
||||
* via the `X-CSRF-Token` header or `_csrf` body field.
|
||||
*/
|
||||
const getCsrfToken = (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Error handler for invalid / missing CSRF tokens.
|
||||
* Must be registered as Express error-handling middleware (4 args).
|
||||
*/
|
||||
const csrfErrorHandler = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN')
|
||||
return R.error(res, 'Invalid or missing CSRF token.', 403);
|
||||
next(err);
|
||||
};
|
||||
|
||||
module.exports = { csrfProtection, getCsrfToken, csrfErrorHandler };
|
||||
@@ -0,0 +1,65 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: rateLimiter.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Express-rate-limit configurations for different route tiers.
|
||||
* - globalLimiter → applied to ALL routes (1000 req / 15 min)
|
||||
* - authLimiter → applied to login/register (20 req / 15 min)
|
||||
* - otpLimiter → applied to OTP send/verify (5 req / 15 min)
|
||||
* - sensitiveOpsLimiter → password change, account delete (10 req / hour)
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { authLimiter } = require('../middleware/rateLimiter.middleware');
|
||||
* router.post('/login', authLimiter, loginHandler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const windowMs15 = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
/** Applied globally in server.js */
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 1000,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { status: 'error', message: 'Too many requests, please try again later.' },
|
||||
});
|
||||
|
||||
/** Login & register routes */
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { status: 'error', message: 'Too many auth attempts. Please wait 15 minutes.' },
|
||||
});
|
||||
|
||||
/** OTP send / verify */
|
||||
const otpLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { status: 'error', message: 'Too many OTP requests. Please wait 15 minutes.' },
|
||||
});
|
||||
|
||||
/** Password change, account delete */
|
||||
const sensitiveOpsLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' },
|
||||
});
|
||||
|
||||
/** Admin routes — tighter than global */
|
||||
const adminLimiter = rateLimit({
|
||||
windowMs: windowMs15,
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { status: 'error', message: 'Too many admin requests. Please wait 15 minutes.' },
|
||||
});
|
||||
|
||||
module.exports = { globalLimiter, authLimiter, otpLimiter, sensitiveOpsLimiter, adminLimiter };
|
||||
@@ -0,0 +1,77 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: rbac.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: Role-Based Access Control (RBAC) guards.
|
||||
* Hierarchy: admin > staff > user (client)
|
||||
*
|
||||
* Exported guards:
|
||||
* - requireClient() → acc_type in ['user', 'staff', 'admin']
|
||||
* - requireStaff() → acc_type in ['staff', 'admin']
|
||||
* - requireAdmin() → acc_type === 'admin' only
|
||||
* - requireOwnerOrStaff() → owns the resource OR is staff/admin
|
||||
* - requireOwnerOrAdmin() → owns the resource OR is admin
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { requireAdmin, requireStaff } = require('../middleware/rbac.middleware');
|
||||
* // Must be used AFTER authenticate middleware
|
||||
* router.get('/users', authenticate, requireStaff(), handler);
|
||||
* router.delete('/users/:id', authenticate, requireAdmin(), handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const ROLES = { user: 1, staff: 2, admin: 3 };
|
||||
|
||||
/**
|
||||
* Generic role guard factory.
|
||||
* @param {string[]} allowed - list of acc_type values that can pass
|
||||
*/
|
||||
const requireRole = (allowed) => (req, res, next) => {
|
||||
if (!req.user)
|
||||
return R.error(res, 'Authentication required.', 401);
|
||||
|
||||
if (!allowed.includes(req.user.acc_type))
|
||||
return R.error(res, 'You do not have permission to access this resource.', 403);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/** Any logged-in user (client, staff, admin) */
|
||||
const requireClient = () => requireRole(['user', 'staff', 'admin']);
|
||||
|
||||
/** Staff or Admin */
|
||||
const requireStaff = () => requireRole(['staff', 'admin']);
|
||||
|
||||
/** Admin only */
|
||||
const requireAdmin = () => requireRole(['admin']);
|
||||
|
||||
/**
|
||||
* Allows resource owner OR staff/admin.
|
||||
* Reads the owner's user_id from req.params.user_id or req.params.id.
|
||||
*/
|
||||
const requireOwnerOrStaff = () => (req, res, next) => {
|
||||
if (!req.user) return R.error(res, 'Authentication required.', 401);
|
||||
const targetId = Number(req.params.user_id || req.params.id);
|
||||
const isOwner = req.user.user_id === targetId;
|
||||
const elevated = ['staff', 'admin'].includes(req.user.acc_type);
|
||||
if (!isOwner && !elevated)
|
||||
return R.error(res, 'You do not have permission.', 403);
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows resource owner OR admin only.
|
||||
*/
|
||||
const requireOwnerOrAdmin = () => (req, res, next) => {
|
||||
if (!req.user) return R.error(res, 'Authentication required.', 401);
|
||||
const targetId = Number(req.params.user_id || req.params.id);
|
||||
const isOwner = req.user.user_id === targetId;
|
||||
const isAdmin = req.user.acc_type === 'admin';
|
||||
if (!isOwner && !isAdmin)
|
||||
return R.error(res, 'You do not have permission.', 403);
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { requireClient, requireStaff, requireAdmin, requireOwnerOrStaff, requireOwnerOrAdmin };
|
||||
@@ -0,0 +1,25 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: validate.middleware.js
|
||||
* Type of Program: Middleware
|
||||
* Description: express-validator result checker.
|
||||
* Pairs with validators/*.validator.js — those files define the rules,
|
||||
* this file intercepts the request if any rule failed and returns 422.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { registerValidator } = require('../../validators/auth.validator');
|
||||
* const { validate } = require('../../middleware/validate.middleware');
|
||||
* router.post('/register', ...registerValidator, validate, handler);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { validationResult } = require('express-validator');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const validate = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty())
|
||||
return R.validationError(res, errors.array());
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { validate };
|
||||
@@ -0,0 +1,22 @@
|
||||
const excludeAttributes = [
|
||||
// Add here
|
||||
];
|
||||
|
||||
const jsonbSchemas = {
|
||||
// Add here
|
||||
};
|
||||
|
||||
// Different exclude sets per role
|
||||
const adminExclude = [
|
||||
...excludeAttributes,
|
||||
// admins can see audit fields, so nothing extra excluded
|
||||
];
|
||||
|
||||
const userExclude = [
|
||||
...excludeAttributes,
|
||||
// regular users cannot see audit trails
|
||||
"created_by", "updated_by", "deleted_by",
|
||||
"deleted_at",
|
||||
];
|
||||
|
||||
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas };
|
||||
@@ -0,0 +1,70 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: user_groups.mdl.js
|
||||
* Type of Program: Model
|
||||
* Description: Sequelize models for `user_groups` and `user_group_members` tables.
|
||||
* Implements a Many-to-Many self-contained group system.
|
||||
* Groups are used as named permission bundles.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/user_groups.mdl');
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const mdl_Users = require('./users.mdl');
|
||||
|
||||
// ─── UserGroups ────────────────────────────────────────────────────────────────
|
||||
const mdl_UserGroups = sequelize.define('UserGroup', {
|
||||
group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
name: { type: DataTypes.STRING(50), allowNull: false },
|
||||
description: { type: DataTypes.TEXT },
|
||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
||||
|
||||
// ── Audit trails ────────────────────────────────────────────────────────────
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: 'user_groups',
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
});
|
||||
|
||||
// ─── Junction: UserGroupMembers ────────────────────────────────────────────────
|
||||
const mdl_UserGroupMembers = sequelize.define('UserGroupMember', {
|
||||
group_id: { type: DataTypes.BIGINT, primaryKey: true },
|
||||
user_id: { type: DataTypes.BIGINT, primaryKey: true },
|
||||
joined_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
|
||||
|
||||
// ── Audit trails ────────────────────────────────────────────────────────────
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: 'user_group_members',
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
});
|
||||
|
||||
// ─── Associations ──────────────────────────────────────────────────────────────
|
||||
mdl_Users.belongsToMany(mdl_UserGroups, { through: mdl_UserGroupMembers, foreignKey: 'user_id', otherKey: 'group_id', as: 'groups' });
|
||||
mdl_UserGroups.belongsToMany(mdl_Users, { through: mdl_UserGroupMembers, foreignKey: 'group_id', otherKey: 'user_id', as: 'members', });
|
||||
|
||||
mdl_UserGroups.hasMany(mdl_UserGroupMembers, { foreignKey: 'group_id' });
|
||||
mdl_UserGroupMembers.belongsTo(mdl_UserGroups, { foreignKey: 'group_id' });
|
||||
|
||||
mdl_Users.hasMany(mdl_UserGroupMembers, { foreignKey: 'user_id' });
|
||||
mdl_UserGroupMembers.belongsTo(mdl_Users, { foreignKey: 'user_id' });
|
||||
|
||||
// models/users/user_groups.mdl.js — add at the bottom
|
||||
mdl_UserGroups.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
|
||||
mdl_UserGroups.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' });
|
||||
mdl_UserGroups.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' });
|
||||
|
||||
// Self-referencing associations for audit fields
|
||||
mdl_Users.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
|
||||
mdl_Users.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' });
|
||||
mdl_Users.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' });
|
||||
|
||||
module.exports = { mdl_UserGroups, mdl_UserGroupMembers };
|
||||
@@ -0,0 +1,60 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: user_sessions.mdl.js
|
||||
* Type of Program: Model
|
||||
* Description: Sequelize model for the `user_sessions` table.
|
||||
* Captures login and logout audit data (IP, geo, device) as JSONB.
|
||||
* Has a Many-to-One relationship with Users.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG DESCRIPTION
|
||||
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const mdl_Users = require('./users.mdl');
|
||||
|
||||
const mdl_UserSessions = sequelize.define('UserSessions', {
|
||||
session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: { model: mdl_Users, key: 'user_id' },
|
||||
},
|
||||
/**
|
||||
* login_info / logout_info JSONB:
|
||||
* {
|
||||
* date: ISO string,
|
||||
* ip_address: string,
|
||||
* country: string,
|
||||
* region: string,
|
||||
* city: string,
|
||||
* lat: number,
|
||||
* long: number,
|
||||
* device_info: { ua, browser, os, device }
|
||||
* forced_by: user_id
|
||||
* }
|
||||
*/
|
||||
login_info: { type: DataTypes.JSONB, allowNull: true },
|
||||
logout_info: { type: DataTypes.JSONB, allowNull: true },
|
||||
|
||||
// Store the refresh-token hash so we can invalidate individual sessions
|
||||
refresh_token_hash: { type: DataTypes.TEXT, allowNull: true },
|
||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
||||
|
||||
// ── Audit trails ────────────────────────────────────────────────────────────
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: 'user_sessions',
|
||||
timestamps: true,
|
||||
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
|
||||
});
|
||||
|
||||
// Associations
|
||||
mdl_UserSessions.belongsTo(mdl_Users, { foreignKey: 'user_id' });
|
||||
mdl_Users.hasMany(mdl_UserSessions, { foreignKey: 'user_id' });
|
||||
|
||||
module.exports = mdl_UserSessions;
|
||||
@@ -0,0 +1,47 @@
|
||||
const excludeAttributes = [
|
||||
"password", "otp_code", "otp_expires_at",
|
||||
"personal_info.name.given_name",
|
||||
"personal_info.name.middle_name",
|
||||
"personal_info.name.last_name",
|
||||
"personal_info.name.extension_name",
|
||||
"personal_info.addresses[].city",
|
||||
"personal_info.addresses[].country",
|
||||
"personal_info.addresses[].street",
|
||||
"personal_info.addresses[].zip",
|
||||
"personal_info.addresses[].address_type",
|
||||
"personal_info.addresses[].state",
|
||||
"personal_info.phone_number[].country_code",
|
||||
"personal_info.phone_number[].number",
|
||||
"personal_info.phone_number[].phone_type",
|
||||
];
|
||||
|
||||
const jsonbSchemas = {
|
||||
personal_info: {
|
||||
name: {
|
||||
full_name: { type: "text", label: "Full Name" },
|
||||
},
|
||||
date_of_birth: { type: "date", label: "Date of Birth" },
|
||||
occupation: { type: "text", label: "Occupation" },
|
||||
addresses: {
|
||||
full_address: { type: "text", label: "Full Address" },
|
||||
},
|
||||
phone_number: {
|
||||
full_number: { type: "text", label: "Phone Number" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Different exclude sets per role
|
||||
const adminExclude = [
|
||||
...excludeAttributes,
|
||||
// admins can see audit fields, so nothing extra excluded
|
||||
];
|
||||
|
||||
const userExclude = [
|
||||
...excludeAttributes,
|
||||
// regular users cannot see audit trails
|
||||
"created_by", "updated_by", "deleted_by",
|
||||
"deleted_at",
|
||||
];
|
||||
|
||||
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas };
|
||||
@@ -0,0 +1,59 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.mdl.js
|
||||
* Type of Program: Model
|
||||
* Description: Sequelize model for the `users` table.
|
||||
* Stores authentication credentials, account flags, registration type,
|
||||
* role/account type, and a flexible JSONB personal_info column.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG DESCRIPTION
|
||||
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const mdl_Users = sequelize.define('User', {
|
||||
user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID" },
|
||||
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true } },
|
||||
password: { type: DataTypes.TEXT, label: "Password" },
|
||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active" },
|
||||
is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified" },
|
||||
reg_type: { type: DataTypes.ENUM('system', 'google'), defaultValue: 'system', label: "Registration Type" },
|
||||
/**
|
||||
* acc_type drives RBAC:
|
||||
* - "user" → Client endpoints only
|
||||
* - "staff" → Client + Staff endpoints
|
||||
* - "admin" → All endpoints
|
||||
*/
|
||||
acc_type: { type: DataTypes.ENUM('user', 'staff', 'admin'), defaultValue: 'user', label: "Account Type" },
|
||||
/**
|
||||
* personal_info JSONB structure:
|
||||
* {
|
||||
* name: { given_name, middle_name, last_name, extension_name, full_name },
|
||||
* occupation: string,
|
||||
* addresses: [{ street, city, state, zip, country, address_type, full_address }],
|
||||
* phone_number: [{ number, country_code, phone_type, full_number }],
|
||||
* date_of_birth: date,
|
||||
* avatar: { mime_type, name, size, url, uuid },
|
||||
* album: [{ uuid, file_url, original_name, uploaded_at, order_index }]
|
||||
* }
|
||||
*/
|
||||
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info" },
|
||||
|
||||
// OTP fields (stored temporarily during verification flow)
|
||||
otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" },
|
||||
otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" },
|
||||
|
||||
// ── Audit trails ────────────────────────────────────────────────────────────
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: 'users',
|
||||
timestamps: true,
|
||||
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
|
||||
});
|
||||
|
||||
module.exports = mdl_Users;
|
||||
Generated
+2399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "star-auth-system",
|
||||
"version": "1.0.0",
|
||||
"description": "STAR Phase 1 - Tier 1 Auth System with CSRF, Rate Limiting, RBAC, Google & System Auth",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"csurf": "^1.11.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.10.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^7.0.1",
|
||||
"google-auth-library": "^9.0.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"nodemailer": "^6.9.5",
|
||||
"passport": "^0.6.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"rate-limit-redis": "^4.0.0",
|
||||
"redis": "^4.6.7",
|
||||
"sequelize": "^6.32.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: admin.routes.js
|
||||
* Type of Program: Router
|
||||
* Description: Protected routes accessible by admin only — full control.
|
||||
*
|
||||
* Route Map:
|
||||
* Users:
|
||||
* GET /api/admin/users → all users
|
||||
* GET /api/admin/users/:id → single user with groups
|
||||
* PUT /api/admin/users/:id → update user (acc_type, status, profile)
|
||||
* DELETE /api/admin/users/:id → hard delete user
|
||||
* GET /api/admin/users/:id/sessions → view all sessions
|
||||
* DELETE /api/admin/users/:id/sessions/:sid → force-terminate a session
|
||||
*
|
||||
* Groups:
|
||||
* GET /api/admin/groups → list groups
|
||||
* POST /api/admin/groups → create group
|
||||
* DELETE /api/admin/groups/:gid → delete group
|
||||
* POST /api/admin/users/:id/groups/:gid → add user to group
|
||||
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
|
||||
*
|
||||
* Guards: authenticate → requireAdmin()
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const usersCtrl = require('../../controllers/admin/users.controller');
|
||||
const { authenticate } = require('../../middleware/auth.middleware');
|
||||
const { requireAdmin } = require('../../middleware/rbac.middleware');
|
||||
const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||
|
||||
// Apply adminLimiter to ALL admin routes
|
||||
router.use(authenticate, requireAdmin(), adminLimiter);
|
||||
|
||||
// ── User management ────────────────────────────────────────────────────────────
|
||||
router.get('/users', usersCtrl.getUsers);
|
||||
router.get('/users/:id', usersCtrl.getUser);
|
||||
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
|
||||
router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete
|
||||
router.post('/users/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); // restore
|
||||
|
||||
// ── Session management ─────────────────────────────────────────────────────────
|
||||
router.get('/users/:id/sessions', usersCtrl.getUserSessions);
|
||||
router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession);
|
||||
|
||||
// ─── Groups Management ───────────────────────────────────────────────────────────────
|
||||
router.get('/groups', usersCtrl.getGroups);
|
||||
router.get('/groups/:gid', usersCtrl.getGroup);
|
||||
router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup);
|
||||
router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup);
|
||||
router.patch('/groups/:gid/deactivate', sensitiveOpsLimiter, usersCtrl.deactivateGroup);
|
||||
router.patch('/groups/:gid/restore', sensitiveOpsLimiter, usersCtrl.restoreGroup);
|
||||
|
||||
// ─── Group membership ──────────────────────────────────────────────────────────
|
||||
router.get('/groups/:gid/users', usersCtrl.getUsersInGroup);
|
||||
router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup);
|
||||
router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup);
|
||||
router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,61 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.routes.js
|
||||
* Type of Program: Router
|
||||
* Description: Public authentication routes (no auth required).
|
||||
* CSRF protection is applied to state-mutating endpoints.
|
||||
*
|
||||
* Route Map:
|
||||
* GET /api/auth/csrf-token → get CSRF token (for cookie-based clients)
|
||||
* POST /api/auth/register → system registration
|
||||
* POST /api/auth/verify-otp → OTP verification + auto-login
|
||||
* POST /api/auth/resend-otp → resend OTP email
|
||||
* POST /api/auth/login → system login
|
||||
* POST /api/auth/refresh → refresh access token
|
||||
* POST /api/auth/logout → logout (requires authenticate)
|
||||
* POST /api/auth/change-password → change password (requires authenticate)
|
||||
* GET /api/auth/google → initiate Google OAuth
|
||||
* GET /api/auth/google/callback → Google OAuth callback
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const router = express.Router();
|
||||
|
||||
const authCtrl = require('../controllers/auth.controller');
|
||||
const { authenticate } = require('../middleware/auth.middleware');
|
||||
const { authLimiter, otpLimiter, sensitiveOpsLimiter } = require('../middleware/rateLimiter.middleware');
|
||||
const { csrfProtection, getCsrfToken } = require('../middleware/csrf.middleware');
|
||||
const { validate } = require('../middleware/validate.middleware');
|
||||
const {
|
||||
registerValidator, loginValidator,
|
||||
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
||||
} = require('../validators/auth.validator');
|
||||
|
||||
// ── CSRF token (GET — no CSRF needed on GETs) ──────────────────────────────────
|
||||
router.get('/csrf-token', csrfProtection, getCsrfToken);
|
||||
|
||||
// ── System auth ────────────────────────────────────────────────────────────────
|
||||
router.post('/register', ...registerValidator, validate, authCtrl.register);
|
||||
router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP);
|
||||
router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP);
|
||||
router.post('/login', ...loginValidator, validate, authCtrl.login);
|
||||
router.post('/refresh', authCtrl.refreshToken);
|
||||
router.post('/logout', authenticate, authLimiter, authCtrl.logout);
|
||||
router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword);
|
||||
|
||||
// ── Google OAuth ───────────────────────────────────────────────────────────────
|
||||
router.get('/google',
|
||||
authLimiter,
|
||||
passport.authenticate('google', { scope: ['profile', 'email'], session: false })
|
||||
);
|
||||
router.get('/google/callback',
|
||||
passport.authenticate('google', { session: false, failureRedirect: '/api/auth/google/failed' }),
|
||||
authCtrl.googleCallback
|
||||
);
|
||||
router.get('/google/failed', (req, res) => {
|
||||
res.status(401).json({ status: 'error', message: 'Google authentication failed.' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,34 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: client.routes.js
|
||||
* Type of Program: Router
|
||||
* Description: Protected routes accessible by any authenticated user (client, staff, admin).
|
||||
*
|
||||
* Route Map:
|
||||
* GET /api/client/profile → view own profile
|
||||
* PUT /api/client/profile → update own profile
|
||||
* GET /api/client/sessions → view own active sessions
|
||||
* DELETE /api/client/sessions/:id → revoke own session
|
||||
*
|
||||
* Guards: authenticate → requireClient()
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const profileCtrl = require('../../controllers/client/profile.controller');
|
||||
const { authenticate } = require('../../middleware/auth.middleware');
|
||||
const { requireClient } = require('../../middleware/rbac.middleware');
|
||||
const { validate } = require('../../middleware/validate.middleware');
|
||||
const { updateProfileValidator } = require('../../validators/profile.validator');
|
||||
|
||||
// Apply auth + role to all routes in this file
|
||||
router.use(authenticate, requireClient());
|
||||
|
||||
router.get('/profile', profileCtrl.getProfile);
|
||||
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
|
||||
router.get('/sessions', profileCtrl.getSessions);
|
||||
router.delete('/sessions/:id', profileCtrl.revokeSession);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,33 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: staff.routes.js
|
||||
* Type of Program: Router
|
||||
* Description: Protected routes accessible by staff and admin only.
|
||||
* Staff can view non-admin users and toggle their status.
|
||||
* Staff cannot view or modify admins.
|
||||
*
|
||||
* Route Map:
|
||||
* GET /api/staff/users → paginated user list (non-admins)
|
||||
* GET /api/staff/users/:id → view a specific non-admin user
|
||||
* PUT /api/staff/users/:id/status → activate or deactivate a user
|
||||
* GET /api/staff/users/:id/sessions → view user's sessions
|
||||
*
|
||||
* Guards: authenticate → requireStaff()
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const usersCtrl = require('../../controllers/staff/users.controller');
|
||||
const { authenticate } = require('../../middleware/auth.middleware');
|
||||
const { requireStaff } = require('../../middleware/rbac.middleware');
|
||||
|
||||
router.use(authenticate, requireStaff());
|
||||
|
||||
router.get('/users/', usersCtrl.getUsers);
|
||||
router.get('/users/:id', usersCtrl.getUser);
|
||||
router.put('/users/:id/status', usersCtrl.setUserStatus);
|
||||
router.get('/users/:id/sessions', usersCtrl.getUserSessions);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,115 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: server.js
|
||||
* Type of Program: Application Entry Point
|
||||
* Description: Bootstraps the Express application.
|
||||
* Registers global middleware (CORS, rate-limit, cookie-parser,
|
||||
* session, Passport) and mounts all route groups.
|
||||
* Syncs Sequelize models to the database on startup.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO RUN:
|
||||
* 1. cp .env.example .env (fill in your values)
|
||||
* 2. npm install
|
||||
* 3. npm run dev (development)
|
||||
* npm start (production)
|
||||
***********************************************************************************************************************************************************************/
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const session = require('express-session');
|
||||
const passport = require('./config/passport.config');
|
||||
const sequelize = require('./config/db.config');
|
||||
|
||||
// ── Middleware ──────────────────────────────────────────────────────────────────
|
||||
const { globalLimiter } = require('./middleware/rateLimiter.middleware');
|
||||
const { csrfErrorHandler } = require('./middleware/csrf.middleware');
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────────
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
const clientRoutes = require('./routes/client/client.routes');
|
||||
const staffRoutes = require('./routes/staff/staff.routes');
|
||||
const adminRoutes = require('./routes/admin/admin.routes');
|
||||
|
||||
// ── Models (ensure associations are loaded) ────────────────────────────────────
|
||||
require('./models/users/users.mdl');
|
||||
require('./models/users/user_sessions.mdl');
|
||||
require('./models/users/user_groups.mdl');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Global Middleware Stack
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
app.set('trust proxy', 1); // Required for rate-limiter behind proxies/load balancers
|
||||
|
||||
app.use(cors({
|
||||
origin: process.env.APP_URL || '*',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Session — used only by csurf; JWT handles auth state
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' },
|
||||
}));
|
||||
|
||||
app.use(passport.initialize());
|
||||
|
||||
// Global rate limiter (1000 req / 15 min)
|
||||
app.use(globalLimiter);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Routes
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/client', clientRoutes);
|
||||
app.use('/api/staff', staffRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString(), env: process.env.NODE_ENV });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ status: 'error', message: 'Route not found.' });
|
||||
});
|
||||
|
||||
// CSRF error handler (must be after routes)
|
||||
app.use(csrfErrorHandler);
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[UNHANDLED ERROR]', err);
|
||||
res.status(500).json({ status: 'error', message: 'Internal server error.' });
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Database Sync + Listen
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connected.');
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 STARR Server running on http://localhost:${PORT}`);
|
||||
console.log(` Environment : ${process.env.NODE_ENV}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to start server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,99 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: email.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Nodemailer-based email service.
|
||||
* Provides:
|
||||
* - sendOTPEmail() → sends a 6-digit OTP verification email
|
||||
* - sendWelcomeEmail() → sent after successful email verification
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const emailService = require('../services/email.service');
|
||||
* await emailService.sendOTPEmail(user.email, otp);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const nodemailer = require('nodemailer');
|
||||
const { emailTemplates } = require('../data/email_body.data')
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
const buildEmailTemplate = ({ title, body }) => {
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:auto;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden">
|
||||
|
||||
<!-- LOGO -->
|
||||
<div style="padding:20px;text-align:center;background:#232f3e">
|
||||
<img src="${process.env.APP_LOGO_URL}" alt="Philproperties" style="height:42px" />
|
||||
</div>
|
||||
|
||||
<!-- CONTENT -->
|
||||
<div style="padding:26px">
|
||||
<h2 style="color:#111827;margin-bottom:14px">${title}</h2>
|
||||
${body}
|
||||
|
||||
<!-- FORMAL CLOSING -->
|
||||
<div style="margin-top:24px">
|
||||
<p style="margin:0">Regards,</p>
|
||||
<p style="margin:4px 0 0;font-weight:600;color:#111827">
|
||||
Philproperties IT Team
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div style="padding:14px;text-align:center;font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb">
|
||||
This is an automated message from STARR System. Please do not reply.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a 6-digit OTP to the given email address.
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} otp - 6-digit code
|
||||
* @param {number} expiryMinutes
|
||||
*/
|
||||
const sendEmail = async ({ to, type, data = {} }) => {
|
||||
try {
|
||||
const templateFn = emailTemplates[type];
|
||||
|
||||
if (!templateFn) {
|
||||
throw new Error(`Email template "${type}" not found`);
|
||||
}
|
||||
|
||||
const { subject, title, body } = templateFn(data);
|
||||
|
||||
const html = buildEmailTemplate({ title, body });
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
transporter.sendMail(
|
||||
{
|
||||
from: {
|
||||
name: "STARR System",
|
||||
address: "do-not-reply@philproperties.com",
|
||||
},
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
},
|
||||
(err, info) => {
|
||||
if (err) return reject(err);
|
||||
resolve(info);
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = sendEmail;
|
||||
@@ -0,0 +1,81 @@
|
||||
// utils/queryBuilder.js
|
||||
const { Sequelize, Op } = require("sequelize");
|
||||
|
||||
/**
|
||||
* Builds a Sequelize `where` clause from an array of filters.
|
||||
*
|
||||
* @param {Array<{ id: string, value: any }>} filters
|
||||
* @returns {Object} Sequelize where clause
|
||||
*/
|
||||
function buildWhere(filters = [], allowedFields = new Set()) {
|
||||
const where = [];
|
||||
|
||||
for (const { id, value } of filters) {
|
||||
if (!id || value === undefined || value === null || value === "") continue;
|
||||
|
||||
// Reject fields not in whitelist (if whitelist is provided)
|
||||
if (allowedFields.size && !allowedFields.has(id)) continue;
|
||||
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
|
||||
const conditions = values.map((v) =>
|
||||
id.startsWith("personal_info.")
|
||||
? Sequelize.where(
|
||||
Sequelize.json(`personal_info.${id.replace("personal_info.", "")}`),
|
||||
{ [Op.iLike]: `%${v}%` }
|
||||
)
|
||||
: Sequelize.where(
|
||||
Sequelize.cast(Sequelize.col(id), "TEXT"),
|
||||
{ [Op.iLike]: `%${v}%` }
|
||||
)
|
||||
);
|
||||
|
||||
where.push({ [Op.or]: conditions });
|
||||
}
|
||||
|
||||
return where.length ? { [Op.and]: where } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Sequelize `order` clause from an array of sort descriptors.
|
||||
* Falls back to [["createdAt", "DESC"]] if no valid sort entries.
|
||||
*
|
||||
* @param {Array<{ id: string, desc: boolean }>} sort
|
||||
* @returns {Array} Sequelize order clause
|
||||
*/
|
||||
function buildOrder(sort = [], allowedFields = new Set()) {
|
||||
const order = [];
|
||||
|
||||
for (const { id, desc } of sort) {
|
||||
if (!id) continue;
|
||||
|
||||
// Reject fields not in whitelist (if whitelist is provided)
|
||||
if (allowedFields.size && !allowedFields.has(id)) continue;
|
||||
|
||||
order.push(
|
||||
id.startsWith("personal_info.")
|
||||
? [Sequelize.json(id), desc ? "DESC" : "ASC"]
|
||||
: [id, desc ? "DESC" : "ASC"]
|
||||
);
|
||||
}
|
||||
|
||||
return order.length ? order : [["createdAt", "DESC"]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper — returns both where and order in one call.
|
||||
*
|
||||
* @param {Array} filters
|
||||
* @param {Array} sort
|
||||
* @returns {{ where: Object, order: Array }}
|
||||
*/
|
||||
function buildQuery(filters = [], sort = [], allowedFields = []) {
|
||||
const fieldSet = new Set(allowedFields);
|
||||
|
||||
return {
|
||||
where: buildWhere(filters, fieldSet),
|
||||
order: buildOrder(sort, fieldSet),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildWhere, buildOrder, buildQuery };
|
||||
@@ -0,0 +1,73 @@
|
||||
const { Sequelize } = require('sequelize')
|
||||
|
||||
/**
|
||||
* Strips a key from every element in a JSONB array using PostgreSQL's
|
||||
* jsonb_agg + #- operator in a subquery.
|
||||
*
|
||||
* @param {string} column - JSONB column e.g. "personal_info"
|
||||
* @param {string} arrayField - array field name e.g. "addresses"
|
||||
* @param {string[]} keys - keys to strip from each array element e.g. ["street", "zip"]
|
||||
* @returns {string} SQL fragment
|
||||
*/
|
||||
function buildArrayStrip(column, arrayField, keys) {
|
||||
const keyRemovals = keys.reduce(
|
||||
(acc, key) => `(${acc} #- '{${key}}')`,
|
||||
"elem"
|
||||
);
|
||||
|
||||
return `(
|
||||
SELECT jsonb_agg(${keyRemovals})
|
||||
FROM jsonb_array_elements("${column}"->'${arrayField}') AS elem
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Sequelize literal that strips JSONB paths at the DB level.
|
||||
* Supports:
|
||||
* - nested keys: "personal_info.name.given_name"
|
||||
* - array item keys: "personal_info.addresses[].street"
|
||||
*
|
||||
* @param {string} column - JSONB column name e.g. "personal_info"
|
||||
* @param {string[]} excludePaths - dot-notation paths
|
||||
* @returns {Array|null} Sequelize literal attribute tuple
|
||||
*/
|
||||
function excludeJsonbPaths(column, excludePaths = []) {
|
||||
// Separate nested paths from array paths
|
||||
const nestedPaths = excludePaths.filter(
|
||||
(p) => p.startsWith(`${column}.`) && !p.includes("[]")
|
||||
);
|
||||
const arrayPaths = excludePaths.filter(
|
||||
(p) => p.startsWith(`${column}.`) && p.includes("[]")
|
||||
);
|
||||
|
||||
// Group array paths by their field name
|
||||
// e.g. { addresses: ["street", "zip"], phone_number: ["country_code"] }
|
||||
const arrayGroups = {};
|
||||
for (const path of arrayPaths) {
|
||||
const stripped = path.replace(`${column}.`, ""); // addresses[].street
|
||||
const [arrayField, key] = stripped.split("[]."); // ["addresses", "street"]
|
||||
if (!arrayGroups[arrayField]) arrayGroups[arrayField] = [];
|
||||
arrayGroups[arrayField].push(key);
|
||||
}
|
||||
|
||||
if (!nestedPaths.length && !Object.keys(arrayGroups).length) return null;
|
||||
|
||||
// Start with the column and chain #- for nested paths
|
||||
let literal = `"${column}"`;
|
||||
|
||||
// Strip nested keys first
|
||||
for (const path of nestedPaths) {
|
||||
const keys = path.replace(`${column}.`, "").split(".");
|
||||
literal = `(${literal} #- '{${keys.join(",")}}')`;
|
||||
}
|
||||
|
||||
// Then rebuild array fields with stripped keys using jsonb_set
|
||||
for (const [arrayField, keys] of Object.entries(arrayGroups)) {
|
||||
const arrayStrip = buildArrayStrip(column, arrayField, keys);
|
||||
literal = `jsonb_set(${literal}, '{${arrayField}}', COALESCE(${arrayStrip}, '[]'))`;
|
||||
}
|
||||
|
||||
return [Sequelize.literal(literal), column];
|
||||
}
|
||||
|
||||
module.exports = { excludeJsonbPaths };
|
||||
@@ -0,0 +1,162 @@
|
||||
// utils/modelToAttributes.js
|
||||
const { DataTypes } = require("sequelize");
|
||||
|
||||
/**
|
||||
* Maps Sequelize DataType to a simple UI type string.
|
||||
*/
|
||||
function resolveType(dataType) {
|
||||
if (!dataType) return "text";
|
||||
|
||||
const type = dataType.constructor?.key || dataType.key || "";
|
||||
|
||||
if (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number";
|
||||
if (["DATE", "DATEONLY"].includes(type)) return "date";
|
||||
if (["BOOLEAN"].includes(type)) return "enum";
|
||||
if (["ENUM"].includes(type)) return "enum";
|
||||
if (["JSONB", "JSON"].includes(type)) return "jsonb";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves options (e.g. enum choices) from a Sequelize field definition.
|
||||
*/
|
||||
function resolveOptions(dataType) {
|
||||
if (!dataType) return {};
|
||||
|
||||
const type = dataType.constructor?.key || dataType.key || "";
|
||||
|
||||
if (type === "ENUM") {
|
||||
return { choices: dataType.values ?? [] };
|
||||
}
|
||||
|
||||
if (type === "BOOLEAN") {
|
||||
return { choices: ["true", "false"] };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a camelCase or snake_case field name to a readable label.
|
||||
*/
|
||||
function toLabel(field) {
|
||||
return field
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens JSONB field paths from a schema definition.
|
||||
*
|
||||
* @param {Object} jsonbSchema - e.g. { name: { given_name, full_name }, date_of_birth }
|
||||
* @param {string} prefix - e.g. "personal_info"
|
||||
* @returns {Array} flat attribute entries for each leaf path
|
||||
*/
|
||||
function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
|
||||
const result = [];
|
||||
|
||||
for (const [key, value] of Object.entries(jsonbSchema)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (exclude.includes(path)) continue;
|
||||
|
||||
// Nested object (no `type` key = it's a group, not a leaf)
|
||||
if (value && typeof value === "object" && !value.type) {
|
||||
result.push(...flattenJsonb(value, path, exclude));
|
||||
} else {
|
||||
result.push({
|
||||
name: value?.label || toLabel(key), // <-- prefer label
|
||||
type: value?.type ?? "text",
|
||||
field: path,
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an attributes array from a Sequelize model + optional JSONB schema map.
|
||||
*
|
||||
* @param {Object} model - Sequelize model (e.g. mdl_Users)
|
||||
* @param {Object} jsonbSchemas - map of JSONB field names to their schema definition
|
||||
* e.g. { personal_info: { name: { full_name: "text", given_name: "text" }, date_of_birth: "date" } }
|
||||
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
|
||||
* @returns {Array} attributes array
|
||||
*/
|
||||
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) {
|
||||
const rawAttrs = model.rawAttributes || model.tableAttributes;
|
||||
const attributes = [];
|
||||
|
||||
const defaultTimestampLabels = {
|
||||
createdAt: "Created",
|
||||
modifiedAt: "Modified",
|
||||
updatedAt: "Modified",
|
||||
deletedAt: "Deleted",
|
||||
createdBy: "Created By",
|
||||
updatedBy: "Modified By",
|
||||
deletedBy: "Deleted By",
|
||||
...timestampLabels,
|
||||
};
|
||||
|
||||
// Audit fields excluded from normal loop — handled separately at the end
|
||||
const auditFields = [
|
||||
'createdAt', 'updatedAt', 'modifiedAt',
|
||||
'deletedAt',
|
||||
'createdBy', 'updatedBy', 'deletedBy',
|
||||
];
|
||||
|
||||
// Ordered audit sequence
|
||||
const auditSequence = [
|
||||
{ field: 'updatedAt', type: 'date' },
|
||||
{ field: 'modifiedAt', type: 'date' },
|
||||
{ field: 'updatedBy', type: 'text' },
|
||||
{ field: 'createdAt', type: 'date' },
|
||||
{ field: 'createdBy', type: 'text' },
|
||||
{ field: 'deletedAt', type: 'date' },
|
||||
{ field: 'deletedBy', type: 'text' },
|
||||
];
|
||||
|
||||
// ── Normal fields (excluding audit) ─────────────────────────────────────────
|
||||
for (const [field, def] of Object.entries(rawAttrs)) {
|
||||
if (exclude.includes(field)) continue;
|
||||
if (auditFields.includes(field)) continue; // skip audit — added later in order
|
||||
|
||||
const dataType = def.type;
|
||||
const type = resolveType(def);
|
||||
|
||||
if (type === "jsonb" && jsonbSchemas[field]) {
|
||||
attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "jsonb") continue;
|
||||
|
||||
attributes.push({
|
||||
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
|
||||
type,
|
||||
field,
|
||||
options: resolveOptions(def),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Audit fields in correct sequence ────────────────────────────────────────
|
||||
for (const { field, type } of auditSequence) {
|
||||
if (exclude.includes(field)) continue;
|
||||
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
|
||||
|
||||
attributes.push({
|
||||
name: defaultTimestampLabels[field],
|
||||
type,
|
||||
field,
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
module.exports = { modelToAttributes };
|
||||
@@ -0,0 +1,44 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: otp.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: One-Time Password (OTP) generation and validation helpers.
|
||||
* - generateOTP() → 6-digit numeric string
|
||||
* - getOTPExpiry() → Date object N minutes from now
|
||||
* - isOTPExpired() → boolean check on the stored expiry
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
|
||||
* user.otp_code = generateOTP();
|
||||
* user.otp_expires_at = getOTPExpiry();
|
||||
***********************************************************************************************************************************************************************/
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure 6-digit OTP.
|
||||
* @returns {string} e.g. "048291"
|
||||
*/
|
||||
const generateOTP = () => {
|
||||
const bytes = crypto.randomBytes(3); // 3 bytes = 0–16777215
|
||||
const num = bytes.readUIntBE(0, 3) % 1_000_000; // force to 0–999999
|
||||
return num.toString().padStart(6, '0');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Date object N minutes in the future.
|
||||
* @param {number} [minutes=10]
|
||||
* @returns {Date}
|
||||
*/
|
||||
const getOTPExpiry = (minutes = Number(process.env.OTP_EXPIRY_MINUTES) || 10) => {
|
||||
return new Date(Date.now() + minutes * 60 * 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the stored OTP has expired.
|
||||
* @param {Date|string} expiresAt
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isOTPExpired = (expiresAt) => !expiresAt || new Date() > new Date(expiresAt);
|
||||
|
||||
module.exports = { generateOTP, getOTPExpiry, isOTPExpired };
|
||||
@@ -0,0 +1,120 @@
|
||||
// utils/paginate.util.js
|
||||
const { Sequelize } = require('sequelize');
|
||||
const { modelToAttributes } = require('./modelToAttributes');
|
||||
const { excludeJsonbPaths } = require('./excludeJsonbPaths');
|
||||
const { buildQuery } = require('./buildQuery.util');
|
||||
|
||||
const PAGE_START = 1;
|
||||
const PAGE_SIZE = 10;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
function safeParseJSON(value, fallback = []) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates audit subquery attributes for createdBy, updatedBy, deletedBy
|
||||
*
|
||||
* @param {Object} mdl_Users - Users model
|
||||
* @param {string} parentAlias - Sequelize model alias e.g. 'User', 'UserGroup'
|
||||
* @returns {Array} - Sequelize attribute include array
|
||||
*/
|
||||
function auditInclude(mdl_Users, parentAlias = 'User') {
|
||||
const tableName = mdl_Users.getTableName();
|
||||
|
||||
const fullNameSubquery = (foreignKey) =>
|
||||
Sequelize.literal(`(
|
||||
SELECT (u."personal_info"->>'name')::jsonb->>'full_name'
|
||||
FROM "${tableName}" AS u
|
||||
WHERE u."user_id" = "${parentAlias}"."${foreignKey}"
|
||||
LIMIT 1
|
||||
)`);
|
||||
|
||||
return {
|
||||
attributes: [
|
||||
[fullNameSubquery('createdBy'), 'createdByName'],
|
||||
[fullNameSubquery('updatedBy'), 'updatedByName'],
|
||||
[fullNameSubquery('deletedBy'), 'deletedByName'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable paginated findAndCountAll
|
||||
*
|
||||
* @param {Object} model - Sequelize model
|
||||
* @param {Object} req - Express request
|
||||
* @param {Object} options
|
||||
* @param {string[]} options.excludeAttributes - fields to exclude
|
||||
* @param {Object} options.jsonbSchemas - JSONB schema map
|
||||
* @param {string} options.jsonbColumn - JSONB column name e.g. "personal_info"
|
||||
* @param {Object} options.findOptions - extra Sequelize options (include, where, etc.)
|
||||
* @param {Object} options.auditOptions - { mdl_Users, parentAlias } to auto-include audit subqueries
|
||||
*/
|
||||
async function paginate(model, req, {
|
||||
excludeAttributes = [],
|
||||
jsonbSchemas = {},
|
||||
jsonbColumn = null,
|
||||
findOptions = {},
|
||||
auditOptions = null, // ← { mdl_Users, parentAlias }
|
||||
} = {}) {
|
||||
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START);
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT);
|
||||
const offset = (page - PAGE_START) * limit;
|
||||
|
||||
const filters = safeParseJSON(req.query.filters);
|
||||
const sort = safeParseJSON(req.query.sort);
|
||||
|
||||
const topLevelExclude = excludeAttributes.filter((f) => !f.includes('.'));
|
||||
const jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
|
||||
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null;
|
||||
|
||||
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas });
|
||||
const ALLOWED_FIELDS = attributes.map((a) => a.field);
|
||||
|
||||
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);
|
||||
|
||||
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions
|
||||
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
|
||||
const auditAttrs = auditOptions
|
||||
? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes
|
||||
: [];
|
||||
const extraIncludes = findOptions.attributes?.include ?? [];
|
||||
const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes];
|
||||
|
||||
const { attributes: _attr, ...restFindOptions } = findOptions;
|
||||
|
||||
const { count, rows } = await model.findAndCountAll({
|
||||
...restFindOptions,
|
||||
where: { ...where, ...(restFindOptions.where ?? {}) },
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
attributes: {
|
||||
exclude: topLevelExclude,
|
||||
include: mergedAttributeIncludes,
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(count / limit);
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalRecords: count,
|
||||
totalPages,
|
||||
hasPrevPage: page > PAGE_START,
|
||||
hasNextPage: page < totalPages,
|
||||
},
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { paginate, auditInclude, safeParseJSON };
|
||||
@@ -0,0 +1,59 @@
|
||||
// utils/personalInfo.util.js
|
||||
|
||||
/**
|
||||
* Computes full_name, full_address, full_number
|
||||
* from a personal_info object before saving to DB.
|
||||
*
|
||||
* @param {Object} personalInfo
|
||||
* @returns {Object} enriched personal_info
|
||||
*/
|
||||
function enrichPersonalInfo(personalInfo = {}) {
|
||||
if (!personalInfo || typeof personalInfo !== 'object') return personalInfo;
|
||||
|
||||
const pi = { ...personalInfo };
|
||||
|
||||
// ── Full Name ────────────────────────────────────────────────────────────────
|
||||
if (pi.name) {
|
||||
const { last_name, given_name, middle_name, extension_name } = pi.name;
|
||||
|
||||
pi.name = {
|
||||
...pi.name,
|
||||
full_name: [
|
||||
last_name ? `${last_name},` : null,
|
||||
given_name,
|
||||
middle_name,
|
||||
extension_name,
|
||||
]
|
||||
.filter((v) => v && v.trim() !== '')
|
||||
.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Full Addresses ───────────────────────────────────────────────────────────
|
||||
if (Array.isArray(pi.addresses)) {
|
||||
pi.addresses = pi.addresses.map((addr) => ({
|
||||
...addr,
|
||||
full_address: [
|
||||
addr.street,
|
||||
addr.city,
|
||||
addr.state,
|
||||
addr.country,
|
||||
addr.zip,
|
||||
]
|
||||
.filter((v) => v && v.trim() !== '')
|
||||
.join(', '),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Full Phone Numbers ───────────────────────────────────────────────────────
|
||||
if (Array.isArray(pi.phone_number)) {
|
||||
pi.phone_number = pi.phone_number.map((phone) => ({
|
||||
...phone,
|
||||
full_number: `${phone.country_code || ''}${phone.number || ''}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return pi;
|
||||
}
|
||||
|
||||
module.exports = { enrichPersonalInfo };
|
||||
@@ -0,0 +1,28 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: response.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Standardised HTTP response helpers.
|
||||
* Wraps all responses in a consistent envelope:
|
||||
* { status, message, data?, errors? }
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const R = require('../utils/response.util');
|
||||
* return R.success(res, 'User created', user, 201);
|
||||
* return R.error(res, 'Not found', 404);
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const success = (res, message = 'OK', data = null, statusCode = 200) =>
|
||||
res.status(statusCode).json({ status: 'success', message, data });
|
||||
|
||||
const error = (res, message = 'An error occurred', statusCode = 500, errors = null) => {
|
||||
const body = { status: 'error', message };
|
||||
if (errors) body.errors = errors;
|
||||
return res.status(statusCode).json(body);
|
||||
};
|
||||
|
||||
const validationError = (res, errors) =>
|
||||
res.status(422).json({ status: 'error', message: 'Validation failed', errors });
|
||||
|
||||
module.exports = { success, error, validationError };
|
||||
@@ -0,0 +1,70 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: token.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: JWT access & refresh token helpers.
|
||||
* - generateTokens() → produces { accessToken, refreshToken }
|
||||
* - verifyAccessToken() → validates and returns decoded payload
|
||||
* - verifyRefreshToken() → validates the long-lived refresh token
|
||||
* - hashToken() → SHA-256 hash used to store refresh tokens in DB
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { generateTokens, verifyAccessToken } = require('../utils/token.util');
|
||||
* const { accessToken, refreshToken } = generateTokens(user);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generates a signed access token and a signed refresh token.
|
||||
* @param {object} user - Sequelize User instance
|
||||
* @returns {{ accessToken: string, refreshToken: string }}
|
||||
*/
|
||||
const generateTokens = (user) => {
|
||||
const payload = {
|
||||
user_id: user.user_id,
|
||||
email: user.email,
|
||||
acc_type: user.acc_type,
|
||||
reg_type: user.reg_type,
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
|
||||
});
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ user_id: user.user_id },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies an access token.
|
||||
* @param {string} token
|
||||
* @returns {object} decoded payload
|
||||
* @throws if invalid / expired
|
||||
*/
|
||||
const verifyAccessToken = (token) =>
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
/**
|
||||
* Verifies a refresh token.
|
||||
* @param {string} token
|
||||
* @returns {object} decoded payload
|
||||
*/
|
||||
const verifyRefreshToken = (token) =>
|
||||
jwt.verify(token, process.env.JWT_REFRESH_SECRET);
|
||||
|
||||
/**
|
||||
* SHA-256 hash a token string for safe DB storage.
|
||||
* @param {string} token
|
||||
* @returns {string} hex digest
|
||||
*/
|
||||
const hashToken = (token) =>
|
||||
crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken };
|
||||
@@ -0,0 +1,46 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.validator.js
|
||||
* Type of Program: Validator
|
||||
* Description: express-validator rule chains for authentication endpoints.
|
||||
* - registerValidator → POST /auth/register
|
||||
* - loginValidator → POST /auth/login
|
||||
* - verifyOTPValidator → POST /auth/verify-otp
|
||||
* - resendOTPValidator → POST /auth/resend-otp
|
||||
* - changePassValidator → POST /auth/change-password
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { body } = require('express-validator');
|
||||
|
||||
const registerValidator = [
|
||||
body('email').isEmail().withMessage('Valid email is required.'),
|
||||
body('password')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('Password must be at least 8 characters.')
|
||||
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter.')
|
||||
.matches(/[0-9]/).withMessage('Password must contain a number.'),
|
||||
];
|
||||
|
||||
const loginValidator = [
|
||||
body('email').isEmail().withMessage('Valid email is required.'),
|
||||
body('password').notEmpty().withMessage('Password is required.'),
|
||||
];
|
||||
|
||||
const verifyOTPValidator = [
|
||||
body('email').isEmail(),
|
||||
body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'),
|
||||
];
|
||||
|
||||
const resendOTPValidator = [
|
||||
body('email').isEmail().withMessage('Valid email is required.'),
|
||||
];
|
||||
|
||||
const changePassValidator = [
|
||||
body('current_password').notEmpty().withMessage('Current password is required.'),
|
||||
body('new_password')
|
||||
.isLength({ min: 8 }).withMessage('New password must be at least 8 characters.')
|
||||
.matches(/[A-Z]/).withMessage('Must contain an uppercase letter.')
|
||||
.matches(/[0-9]/).withMessage('Must contain a number.'),
|
||||
];
|
||||
|
||||
module.exports = { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator };
|
||||
@@ -0,0 +1,19 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: profile.validator.js
|
||||
* Type of Program: Validator
|
||||
* Description: express-validator rule chains for profile update endpoints.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { body } = require('express-validator');
|
||||
|
||||
const updateProfileValidator = [
|
||||
body('personal_info').optional().isObject().withMessage('personal_info must be an object.'),
|
||||
body('personal_info.name').optional().isObject(),
|
||||
body('personal_info.name.given_name').optional().isString().trim().isLength({ max: 100 }),
|
||||
body('personal_info.name.last_name').optional().isString().trim().isLength({ max: 100 }),
|
||||
body('personal_info.occupation').optional().isString().trim().isLength({ max: 150 }),
|
||||
body('personal_info.date_of_birth').optional().isDate().withMessage('date_of_birth must be a valid date.'),
|
||||
];
|
||||
|
||||
module.exports = { updateProfileValidator };
|
||||
Reference in New Issue
Block a user