Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
150ef00
chore: update env config and add domain entities
brandcentral-services Aug 1, 2026
6020654
Refactor code structure for improved readability and maintainability
brandcentral-services Aug 1, 2026
6957627
feat(pems): redesign Task Execution Center page (UI/UX v2)
brandcentral-services Aug 3, 2026
a279681
Add responsive styles for PEMS tasks page
brandcentral-services Aug 3, 2026
8a1532a
Merge branch 'arena/019fc604-retailops' of https://github.com/Brandce…
brandcentral-services Aug 3, 2026
df799f2
feat(pems): remove Board/Calendar views, refine quick-view pill styling
brandcentral-services Aug 3, 2026
f05a40e
fix(backend): startup crash on Node 26 — rate-limit IPv6 validation +…
brandcentral-services Aug 3, 2026
c90378c
fix(backend): correct ipKeyGenerator usage — pass req.ip directly
brandcentral-services Aug 3, 2026
de66b7a
docs(pems): add complete backend report for the PEMS module
brandcentral-services Aug 3, 2026
bdff9d9
docs(pems): add architecture & implementation plan for PEMS backend
brandcentral-services Aug 3, 2026
19736aa
feat(backend): M0 foundations — migration runner, transactions, cache…
brandcentral-services Aug 3, 2026
3f57d88
feat(backend): M2 contract alignment — bulk transition, REWORK, merges
brandcentral-services Aug 3, 2026
b907112
feat(backend): M3 automation engine — recurrence, SLA cron, emails
brandcentral-services Aug 3, 2026
506d9b6
feat(backend): M4 hardening — transactions, event-store locking, RBAC…
brandcentral-services Aug 3, 2026
7c287dc
feat(pems): M5 frontend alignment — bulk transition via new endpoint
brandcentral-services Aug 3, 2026
f085658
fix(backend): migration runner supports function-shaped migrations + …
brandcentral-services Aug 3, 2026
1b215d3
fix(backend): import requirePermission + requireTaskAccess in pemsRoutes
brandcentral-services Aug 3, 2026
e341e28
feat: maintenance mode — env-gated Under Maintenance screen + API 503
brandcentral-services Aug 3, 2026
4f4e7d4
feat: redesign Under Maintenance page — 'Closed for Restock' cinemati…
brandcentral-services Aug 3, 2026
2ddfa62
feat: professional maintenance page — real brand logo, restrained design
brandcentral-services Aug 3, 2026
228763d
feat: maintenance page — RetailOps-branded, project identity
brandcentral-services Aug 3, 2026
221c5b4
feat: add brand logo for Under Maintenance page
brandcentral-services Aug 3, 2026
c3c297c
Merge branch 'arena/019fc604-retailops' of https://github.com/Brandce…
brandcentral-services Aug 3, 2026
3acd817
Merge remote-tracking branch 'origin/develop' into arena/019fc604-ret…
brandcentral-services Aug 3, 2026
02e6dae
Merge remote-tracking branch 'origin/arena' into arena/019fc604-retai…
brandcentral-services Aug 3, 2026
39ba32a
Merge pull request #17 from Brandcentral-Pvt/arena/019fc604-retailops
arena-ai-coding-agent[bot] Aug 3, 2026
17b09ba
Merge remote-tracking branch 'origin/main' into arena/019fc604-retailops
brandcentral-services Aug 3, 2026
0400b1f
Merge pull request #19 from Brandcentral-Pvt/arena/019fc604-retailops
arena-ai-coding-agent[bot] Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,22 @@
# ── Frontend ────────────────────────────────────────────────────────────
VITE_API_URL=https://data.brandcentral.in/api
VITE_SOCKET_URL=https://data.brandcentral.in
# Maintenance mode — set to true to show ONLY the Under Maintenance screen
VITE_MAINTENANCE_MODE=false
# Headline — wrap one word in |pipes| to render it in brand-gold serif-italic
# VITE_MAINTENANCE_TITLE=We're |restocking| the shelves.
# VITE_MAINTENANCE_MESSAGE=Your custom copy here
# ETA accepts any length (ASAP, ~2–3 hours, etc.) — wraps cleanly
# VITE_MAINTENANCE_ETA=ASAP
# VITE_MAINTENANCE_EMAIL=developer@brandcentral.in
# Optional custom logo; falls back to the RetailOps wordmark on error
# VITE_MAINTENANCE_LOGO=https://brandcentral.in/wp-content/uploads/2024/09/logo.png

# ── Backend Server ──────────────────────────────────────────────────────
NODE_ENV=production
PORT=3001
# Maintenance mode — when true the backend returns 503 for all /api/* calls
MAINTENANCE_MODE=false

# ── Database (SQL Server) ──────────────────────────────────────────────
DB_HOST=your-db-host.database.windows.net
Expand Down
4 changes: 4 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Environment variables for production
# Maintenance mode — true = only the Under Maintenance screen is served
VITE_MAINTENANCE_MODE=false
MAINTENANCE_MODE=false

NODE_ENV=production
PORT=3000

Expand Down
4 changes: 4 additions & 0 deletions .env.staging.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Environment variables for staging
# Maintenance mode — true = only the Under Maintenance screen is served
VITE_MAINTENANCE_MODE=false
MAINTENANCE_MODE=false

NODE_ENV=staging
PORT=3000

Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ Thumbs.db
.idea/
*.sw?

# .NET build artifacts and IDE files
dotnet/**/bin/
dotnet/**/obj/
dotnet/.vs/
dotnet/**/*.user
dotnet/**/*.suo
dotnet/**/*.userprefs
dotnet/**/*.vsidx

# Uploads and temp files
backend/uploads/*
!backend/uploads/.gitkeep
Expand Down
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ DB_NAME=retailops
DB_PORT=1433
DB_POOL_MAX=50
DB_POOL_MIN=5
# Encrypt connection (true/false). If your SQL Server uses a self-signed
# certificate, set DB_ENCRYPT=true and DB_TRUST_SERVER_CERT=true.
DB_ENCRYPT=true
DB_TRUST_SERVER_CERT=true

# JWT Authentication
JWT_SECRET=
Expand Down
40 changes: 40 additions & 0 deletions backend/__tests__/unit/pemsPolicy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const { canActOnTask, userIdOf, roleNameOf } = require('../../services/pems/pemsPolicy');

const globalUser = { Id: 'u-admin', role: { name: 'admin' } };
const assignee = { Id: 'u-assignee', role: { name: 'brand_manager' } };
const reviewer = { Id: 'u-reviewer', role: { name: 'brand_manager' } };
const stranger = { Id: 'u-stranger', role: { name: 'brand_manager' } };
const task = { Id: 't1', AssignedTo: 'u-assignee', ReviewerId: 'u-reviewer' };

describe('pemsPolicy', () => {
it('resolves user id across Id/_id/id shapes', () => {
expect(userIdOf({ Id: 'a' })).toBe('a');
expect(userIdOf({ _id: 'b' })).toBe('b');
expect(userIdOf({ id: 'c' })).toBe('c');
expect(userIdOf({})).toBeNull();
});

it('normalizes role names', () => {
expect(roleNameOf({ role: { name: 'Admin' } })).toBe('admin');
expect(roleNameOf({ role: { Name: 'SUPER_ADMIN' } })).toBe('super_admin');
expect(roleNameOf({ role: 'operational_manager' })).toBe('operational_manager');
});

it('allows global roles (admin, super_admin, developer, operational_manager)', () => {
expect(canActOnTask({ Id: 'x', role: { name: 'admin' } }, task)).toBe(true);
expect(canActOnTask({ Id: 'x', role: { name: 'super_admin' } }, task)).toBe(true);
expect(canActOnTask({ Id: 'x', role: { name: 'developer' } }, task)).toBe(true);
expect(canActOnTask({ Id: 'x', role: { name: 'operational_manager' } }, task)).toBe(true);
});

it('allows the assignee and reviewer', () => {
expect(canActOnTask(assignee, task)).toBe(true);
expect(canActOnTask(reviewer, task)).toBe(true);
});

it('denies strangers and anonymous users', () => {
expect(canActOnTask(stranger, task)).toBe(false);
expect(canActOnTask(null, task)).toBe(false);
expect(canActOnTask({ Id: 'u-assignee', role: { name: 'brand_manager' } }, null)).toBe(false);
});
});
39 changes: 39 additions & 0 deletions backend/__tests__/unit/recurrenceService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { computeNextOccurrence } = require('../../services/pems/recurrenceService');

describe('computeNextOccurrence', () => {
const from = new Date('2026-08-03T10:00:00Z'); // Monday

it('advances daily frequency by 1 day', () => {
const next = computeNextOccurrence('DAILY', null, from);
expect(next.getTime()).toBe(new Date('2026-08-04T10:00:00Z').getTime());
});

it('advances weekly frequency by 7 days', () => {
const next = computeNextOccurrence('WEEKLY', null, from);
expect(next.getTime()).toBe(new Date('2026-08-10T10:00:00Z').getTime());
});

it('advances monthly frequency by 1 month', () => {
const next = computeNextOccurrence('MONTHLY', null, from);
expect(next.getTime()).toBe(new Date('2026-09-03T10:00:00Z').getTime());
});

it('parses customCron via cron-parser for CUSTOM frequency', () => {
const next = computeNextOccurrence('CUSTOM', '0 9 * * 1', from); // Mondays at 09:00 (local tz)
// Next Monday after 2026-08-03T10:00 (already past 09:00) = 2026-08-10
expect(next.getDay()).toBe(1); // Monday
expect(next.getMinutes()).toBe(0); // :00
expect(next > from).toBe(true);
expect(next.getTime()).toBeGreaterThanOrEqual(new Date('2026-08-09T00:00:00Z').getTime());
});

it('falls back to frequency math when cron-parser is unavailable or cron is invalid', () => {
const next = computeNextOccurrence('CUSTOM', 'not-a-cron', from);
expect(next.getTime()).toBe(new Date('2026-08-10T10:00:00Z').getTime()); // default +7d
});

it('ignores customCron for non-CUSTOM frequencies', () => {
const next = computeNextOccurrence('WEEKLY', '0 9 * * 1', from);
expect(next.getTime()).toBe(new Date('2026-08-10T10:00:00Z').getTime());
});
});
52 changes: 52 additions & 0 deletions backend/__tests__/unit/workflowEngine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,55 @@ describe('WorkflowEngine', () => {
});
});
});

describe('getTransitionTimestamps', () => {
const { getTransitionTimestamps } = require('../../services/pems/workflowEngine');
const now = new Date('2026-08-03T12:00:00Z');

it('stamps AssignedAt on ASSIGNED', () => {
const t = getTransitionTimestamps('DRAFT', 'ASSIGNED', now);
expect(t.AssignedAt).toEqual(now);
expect(t.CompletedAt).toBeUndefined();
});

it('stamps StartedAt on IN_PROGRESS', () => {
const t = getTransitionTimestamps('ACCEPTED', 'IN_PROGRESS', now);
expect(t.StartedAt).toEqual(now);
expect(t.ReviewedAt).toBeUndefined();
});

it('stamps ReviewedAt + CompletedAt on APPROVED (regression: CompletedAt was never set)', () => {
const t = getTransitionTimestamps('UNDER_REVIEW', 'APPROVED', now);
expect(t.ReviewedAt).toEqual(now);
expect(t.CompletedAt).toEqual(now);
});

it('stamps ReviewedAt and clears CompletedAt on REJECTED', () => {
const t = getTransitionTimestamps('UNDER_REVIEW', 'REJECTED', now);
expect(t.ReviewedAt).toEqual(now);
expect(t.CompletedAt).toBeNull();
});

it('returns no timestamps for non-timestamped transitions (e.g. REWORK)', () => {
const t = getTransitionTimestamps('REJECTED', 'REWORK', now);
expect(Object.keys(t)).toHaveLength(0);
});
});

describe('resolveReviewTransition', () => {
const { resolveReviewTransition } = require('../../services/pems/workflowEngine');

it('maps APPROVE to APPROVED', () => {
expect(resolveReviewTransition('APPROVE')).toBe('APPROVED');
});

it('maps REWORK to REWORK (regression: was collapsed into REJECTED)', () => {
expect(resolveReviewTransition('REWORK')).toBe('REWORK');
});

it('maps anything else to REJECTED', () => {
expect(resolveReviewTransition('REJECT')).toBe('REJECTED');
expect(resolveReviewTransition(undefined)).toBe('REJECTED');
expect(resolveReviewTransition('BOGUS')).toBe('REJECTED');
});
});
42 changes: 28 additions & 14 deletions backend/config/env.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
const net = require('net');

const requiredEnvVars = [
'DB_USER',
'DB_PASSWORD',
'DB_SERVER',
'JWT_SECRET',
'JWT_REFRESH_SECRET'
'JWT_SECRET'
];

const missing = requiredEnvVars.filter(key => !process.env[key]);
Expand All @@ -13,6 +14,25 @@ if (missing.length > 0) {
process.exit(1);
}

if (!process.env.JWT_REFRESH_SECRET) {
console.warn('⚠️ JWT_REFRESH_SECRET not set — falling back to JWT_SECRET. Add JWT_REFRESH_SECRET to backend/.env for a dedicated refresh-token signing key.');
process.env.JWT_REFRESH_SECRET = process.env.JWT_SECRET;
}

// Node 24+/26 refuses TLS `servername` set to an IP literal. For IP hosts,
// TLS is disabled unless DB_ENCRYPT=true is set explicitly.
const isIpLiteral = (host) => !!host && net.isIP(host) !== 0;

function resolveSqlOptions(host, { trustServerCertificate = false } = {}) {
const encryptOverride = process.env.DB_ENCRYPT;
return {
encrypt: encryptOverride !== undefined ? encryptOverride === 'true' : !isIpLiteral(host),
trustServerCertificate,
enableArithAbort: true,
useUTC: false
};
}

module.exports = {
port: parseInt(process.env.PORT || '3001', 10),
db: {
Expand All @@ -21,12 +41,9 @@ module.exports = {
server: process.env.DB_SERVER,
database: process.env.DB_NAME || 'retailops',
port: parseInt(process.env.DB_PORT || '1433', 10),
options: {
encrypt: true,
trustServerCertificate: false,
enableArithAbort: true,
useUTC: false
},
options: resolveSqlOptions(process.env.DB_SERVER, {
trustServerCertificate: process.env.DB_TRUST_SERVER_CERT === 'true'
}),
pool: {
max: parseInt(process.env.DB_POOL_MAX || '50', 10),
min: parseInt(process.env.DB_POOL_MIN || '5', 10),
Expand All @@ -41,12 +58,9 @@ module.exports = {
server: process.env.DB_READER_SERVER,
database: process.env.DB_READER_NAME || process.env.DB_NAME || 'retailops',
port: parseInt(process.env.DB_READER_PORT || process.env.DB_PORT || '1433', 10),
options: {
encrypt: true,
trustServerCertificate: false,
enableArithAbort: true,
useUTC: false
},
options: resolveSqlOptions(process.env.DB_READER_SERVER, {
trustServerCertificate: process.env.DB_TRUST_SERVER_CERT === 'true'
}),
pool: {
max: parseInt(process.env.DB_READER_POOL_MAX || '100', 10),
min: parseInt(process.env.DB_READER_POOL_MIN || '10', 10),
Expand Down
8 changes: 7 additions & 1 deletion backend/controllers/pems/dashboardController.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,14 @@ exports.getLiveTasks = async (req, res) => {
const pool = await getPool();
const { department, sellerId, assignedTo, status, priority } = req.query;

// Pagination guard: default 15, max 50
let limit = parseInt(req.query.limit, 10);
if (!Number.isFinite(limit) || limit < 1) limit = 15;
if (limit > 50) limit = 50;

let where = "WHERE i.Status NOT IN ('APPROVED','CANCELLED')";
const req_ = pool.request();
req_.input('limit', sql.Int, limit);

if (department) { where += ' AND i.Department = @department'; req_.input('department', sql.NVarChar, department); }
if (sellerId) { where += ' AND i.SellerId = @sellerId'; req_.input('sellerId', sql.VarChar, sellerId); }
Expand All @@ -239,7 +245,7 @@ exports.getLiveTasks = async (req, res) => {
if (priority) { where += ' AND i.Priority = @priority'; req_.input('priority', sql.VarChar, priority); }

const result = await req_.query(`
SELECT TOP 15 i.Id, i.InstanceCode, i.Title, i.Department, i.SellerName, i.AssigneeName,
SELECT TOP (@limit) i.Id, i.InstanceCode, i.Title, i.Department, i.SellerName, i.AssigneeName,
i.Status, i.ReviewStatus, i.Priority, i.Frequency,
i.Target, i.Achievement, i.AchievementPct, i.Variance, i.ProgressPct,
i.SLAStatus, i.SLAHours, i.DueDate, i.CreatedAt,
Expand Down
39 changes: 21 additions & 18 deletions backend/controllers/pems/pemsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,6 @@ exports.deleteTemplate = async (req, res) => {
}
};

exports.getFilterOptions = async (req, res) => {
res.json({
success: true,
data: {
frequencies: Object.values(FREQUENCIES),
categories: Object.values(CATEGORIES),
priorities: Object.values(PRIORITIES),
statuses: Object.values(WORKFLOW_STATUSES),
}
});
};

// ═══════════════════════════════════════════════════════
// TASK INSTANCES
// ═══════════════════════════════════════════════════════
Expand Down Expand Up @@ -121,6 +109,21 @@ exports.transitionStatus = async (req, res) => {
}
};

exports.bulkTransition = async (req, res) => {
try {
const { ids, toStatus, details } = req.body;
const result = await pemsService.bulkTransition(
ids, toStatus,
getUserId(req), getUserName(req) || req.user?.email, req.user?.role,
details
);
res.json({ success: true, data: result });
} catch (err) {
console.error('bulkTransition error:', err);
res.status(400).json({ success: false, error: err.message });
}
};

exports.updateAchievement = async (req, res) => {
try {
const { achievement } = req.body;
Expand Down Expand Up @@ -183,12 +186,12 @@ exports.submitReview = async (req, res) => {
reviewerName: getUserName(req) || req.user?.email,
});

// Auto-transition based on decision
if (req.body.decision === 'APPROVE') {
await pemsService.transitionStatus(req.body.taskInstanceId, 'APPROVED', getUserId(req), getUserName(req), req.user?.role, req.body.feedback);
} else {
await pemsService.transitionStatus(req.body.taskInstanceId, 'REJECTED', getUserId(req), getUserName(req), req.user?.role, req.body.feedback);
}
// Review insert + workflow transition applied atomically
await pemsService.submitReviewAndTransition(req.body, {
id: getUserId(req),
name: getUserName(req) || req.user?.email,
role: req.user?.role,
});

res.json({ success: true, data: result });
} catch (err) {
Expand Down
Loading
Loading