Skip to content

Commit 8d150d2

Browse files
parth0025claude
andcommitted
feat(pto): time-off / PTO with capacity reduction (SEC-08)
Track holidays/leave that reduce a user's available capacity. Backend (Modules/Pto): - pto_entries collection (8-edit registration; indexed userId+startDate, status). - helpers/ptoRules.js (pure): validation, Mon–Fri working-day counting, range overlap, and computeAvailableCapacity — available = workingDays × hours/day − APPROVED PTO hours in range (pending/rejected don't count; floors at 0). - controller: create (members request own/pending; owner/admin may create for others + set status), list (own for members, team for admins), approve/reject (owner/admin only), soft-delete (own or admin), and GET /pto/capacity — the done-when. companyId-scoped; mutations recorded to the SEC-04 audit trail. - routes + init; JWT+company via setMiddleware (prefix /api/v1/pto). Frontend: Settings -> Time Off (TimeOff.vue) — request form, this-month capacity card (working hours − time off = available), team/own schedule with approve/reject (admins) + delete. i18n in en.js. Verify: 32 suites / 369 tests green (incl. 13 pto-rules); frontend build clean. Test cases: .claude/test-cases/PtoCalendar.md (15). Done-when met: PTO entries reduce a user's available capacity (feeds REP-06). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70819fd commit 8d150d2

18 files changed

Lines changed: 652 additions & 1 deletion

File tree

.claude/test-cases/PtoCalendar.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Time-off / PTO — Test Cases
2+
3+
**Feature:** SEC-08 — time-off / PTO; approved entries reduce available capacity
4+
**Backend:** `Modules/Pto/{helpers/ptoRules.js,controller.js,routes.js,init.js}`, `pto_entries` collection, endpoints `POST/GET /api/v1/pto`, `GET /api/v1/pto/capacity`, `PUT /api/v1/pto/:id/status`, `DELETE /api/v1/pto/:id`
5+
**Frontend:** `frontend/src/views/Settings/TimeOff/TimeOff.vue` (Settings → Time Off)
6+
**Capacity:** available hours = working days (Mon–Fri) × hours/day − approved PTO hours in range (feeds REP-06 capacity planning).
7+
**Unit-tested:** `tests/pto-rules.test.js` (validation, working-day count, overlap hours, capacity math) — in the Node suite (32 suites / 369 tests green).
8+
**Legend:** ✅ Pass · ❌ Fail · ⬜ Not run
9+
10+
| ID | Title | Precondition | Steps | Expected | Actual | Status |
11+
|----|-------|--------------|-------|----------|--------|--------|
12+
| PTO-01 | Request time off | Logged in | Settings → Time Off; pick type, dates, hours/day; Request | Entry created with status **Pending**; appears in the list | ||
13+
| PTO-02 | Validation || Submit with end before start, or missing dates | Rejected with a clear message; nothing created | ||
14+
| PTO-03 | Member sees only own | Member (role 3) with others' PTO present | Open Time Off | Only the member's own entries are listed | ||
15+
| PTO-04 | Admin sees team | Owner/admin | Open Time Off | All members' entries listed; Approve/Reject shown on pending | ||
16+
| PTO-05 | Approve | Admin; a pending entry | Click Approve | Status → **Approved**; recorded in the audit log (`pto.approved`) | ||
17+
| PTO-06 | Reject | Admin; a pending entry | Click Reject | Status → **Rejected**; not counted against capacity | ||
18+
| PTO-07 | Approve is admin-only | Member | `PUT /api/v1/pto/:id/status` | `403` Owner/admin only | ||
19+
| PTO-08 | Capacity reduced by approved PTO | An approved entry this month | View "My capacity"; or `GET /pto/capacity?from=&to=` | Available = working-hours − approved PTO hours (e.g. 184 − 40 = 144 for a 5-day leave in a 23-weekday month) | ||
20+
| PTO-09 | Pending/rejected don't reduce | Pending or rejected entries only | Check capacity | `ptoHours` = 0; available = full capacity | ||
21+
| PTO-10 | Weekends excluded | Leave spanning a weekend | Check capacity | Only Mon–Fri days within the leave count | ||
22+
| PTO-11 | Delete own | Member; own entry | Delete | Entry soft-removed (deletedStatusKey=1); gone from list | ||
23+
| PTO-12 | Delete others blocked | Member; another user's entry id | `DELETE /api/v1/pto/:id` | `403` — can only remove own | ||
24+
| PTO-13 | Tenant isolation | Two companies | List/capacity as company A | Only company A's PTO visible (companyId-scoped) | ||
25+
| PTO-14 | Capacity never negative | Leave longer than the range | Check capacity | `availableHours` floors at 0 | ||
26+
| PTO-15 | Rules (unit) || `npx jest tests/pto-rules.test.js` | All green — validation, working-day count, overlap hours, approved-only capacity reduction, non-negative floor | ||
27+
28+
**Total:** 15 cases (1 unit-automated, 14 runtime/manual). Done-when met: PTO entries reduce a user's available capacity. A visual month-grid calendar is a possible follow-up; the schedule list + capacity readout cover the requirement.

Config/collections.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const dbCollections = {
7676
SSO_CONFIGS: "sso_configs",
7777
AUDIT_LOGS: "audit_logs",
7878
SCIM_CONFIGS: "scim_configs",
79+
PTO_ENTRIES: "pto_entries",
7980
}
8081

8182
/** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/

Config/schemaType.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const SCHEMA_TYPE = {
7575
SSO_CONFIGS: "sso_configs",
7676
AUDIT_LOGS: "audit_logs",
7777
SCIM_CONFIGS: "scim_configs",
78+
PTO_ENTRIES: "pto_entries",
7879
}
7980

8081
module.exports = {

Config/setMiddleware.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ const verifyJWTTokenWithCRoute = [
178178
// auth (company resolved from the token) and are intentionally NOT listed.
179179
'/api/v2/scim/config',
180180
'/api/v2/scim/token',
181+
// Time-off / PTO (Modules/Pto) — JWT+company; prefix-matches /pto, /pto/:id,
182+
// /pto/:id/status, /pto/capacity. Role checks are enforced in-controller.
183+
'/api/v1/pto',
181184
];
182185
const verifyJWTToken = [
183186
"/api/v2/company/delete",

Modules/Pto/controller.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
const mongoose = require('mongoose');
2+
const { SCHEMA_TYPE } = require('../../Config/schemaType');
3+
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
4+
const { getRoleType, isPrivileged } = require('../../Config/permissionGuard');
5+
const { removeCache } = require('../../utils/commonFunctions');
6+
const logger = require('../../Config/loggerConfig');
7+
const R = require('./helpers/ptoRules');
8+
9+
const companyOf = (req) => req.headers['companyid'] || (req.body && req.body.companyId) || (req.query && req.query.companyId);
10+
const audit = (req, entry) => { try { require('../Audit/recorder').recordAuditFromReq(req, entry); } catch (e) { /* best-effort */ } };
11+
12+
// POST /api/v1/pto — create a PTO entry. A member creates their own (pending);
13+
// owner/admin may create for another user and/or set status (e.g. auto-approve).
14+
exports.createPto = async (req, res) => {
15+
try {
16+
const companyId = companyOf(req);
17+
if (!companyId) return res.status(400).json({ status: false, statusText: 'companyId is required.' });
18+
const roleType = await getRoleType(companyId, req.uid);
19+
const privileged = isPrivileged(roleType);
20+
const body = req.body || {};
21+
const targetUser = privileged && body.userId ? String(body.userId) : String(req.uid);
22+
const status = privileged && body.status ? body.status : 'pending';
23+
const check = R.validatePtoEntry({ ...body, userId: targetUser, status });
24+
if (!check.valid) return res.status(400).json({ status: false, statusText: check.errors.join('; ') });
25+
const data = { ...check.value, createdBy: String(req.uid || ''), deletedStatusKey: 0 };
26+
if (check.value.status === 'approved') data.approvedBy = String(req.uid || '');
27+
const saved = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.PTO_ENTRIES, data }, 'save');
28+
removeCache(`pto:${companyId}`);
29+
audit(req, { action: 'pto.create', entityType: 'pto', entityId: String(saved._id), meta: { type: data.type, userId: targetUser } });
30+
return res.status(201).json({ status: true, statusText: 'PTO entry created.', data: saved });
31+
} catch (e) { logger.error(`createPto: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); }
32+
};
33+
34+
// GET /api/v1/pto?userId=&from=&to=&status= — list (calendar feed). A member sees
35+
// their own; owner/admin see everyone (optionally filtered by userId).
36+
exports.listPto = async (req, res) => {
37+
try {
38+
const companyId = companyOf(req);
39+
if (!companyId) return res.status(400).json({ status: false, statusText: 'companyId is required.' });
40+
const roleType = await getRoleType(companyId, req.uid);
41+
const privileged = isPrivileged(roleType);
42+
const q = req.query || {};
43+
const match = { deletedStatusKey: { $ne: 1 } };
44+
if (privileged) { if (q.userId) match.userId = String(q.userId); }
45+
else { match.userId = String(req.uid); }
46+
if (q.status) match.status = String(q.status);
47+
if (q.from || q.to) {
48+
const and = [];
49+
if (q.to) and.push({ startDate: { $lte: new Date(q.to) } });
50+
if (q.from) and.push({ endDate: { $gte: new Date(q.from) } });
51+
if (and.length) match.$and = and;
52+
}
53+
const rows = await MongoDbCrudOpration(companyId, {
54+
type: SCHEMA_TYPE.PTO_ENTRIES, data: [match, {}, { sort: { startDate: -1 } }],
55+
}, 'find');
56+
return res.json({ status: true, data: rows || [] });
57+
} catch (e) { logger.error(`listPto: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); }
58+
};
59+
60+
// PUT /api/v1/pto/:id/status — approve / reject (owner/admin only).
61+
exports.updatePtoStatus = async (req, res) => {
62+
try {
63+
const companyId = companyOf(req);
64+
if (!companyId) return res.status(400).json({ status: false, statusText: 'companyId is required.' });
65+
const roleType = await getRoleType(companyId, req.uid);
66+
if (!isPrivileged(roleType)) return res.status(403).json({ status: false, statusText: 'Owner/admin only.' });
67+
const status = String(req.body.status || '');
68+
if (!R.PTO_STATUS.includes(status)) return res.status(400).json({ status: false, statusText: 'Invalid status.' });
69+
const id = req.params.id;
70+
const updated = await MongoDbCrudOpration(companyId, {
71+
type: SCHEMA_TYPE.PTO_ENTRIES,
72+
data: [{ _id: new mongoose.Types.ObjectId(String(id)) }, { $set: { status, approvedBy: String(req.uid || '') } }, { returnDocument: 'after' }],
73+
}, 'findOneAndUpdate');
74+
if (!updated) return res.status(404).json({ status: false, statusText: 'Not found.' });
75+
removeCache(`pto:${companyId}`);
76+
audit(req, { action: `pto.${status}`, entityType: 'pto', entityId: String(id) });
77+
return res.json({ status: true, statusText: `PTO ${status}.`, data: updated });
78+
} catch (e) { logger.error(`updatePtoStatus: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); }
79+
};
80+
81+
// DELETE /api/v1/pto/:id — soft-delete. The entry's owner, or any owner/admin.
82+
exports.deletePto = async (req, res) => {
83+
try {
84+
const companyId = companyOf(req);
85+
if (!companyId) return res.status(400).json({ status: false, statusText: 'companyId is required.' });
86+
const roleType = await getRoleType(companyId, req.uid);
87+
const privileged = isPrivileged(roleType);
88+
const id = req.params.id;
89+
const entry = await MongoDbCrudOpration(companyId, {
90+
type: SCHEMA_TYPE.PTO_ENTRIES, data: [{ _id: new mongoose.Types.ObjectId(String(id)) }],
91+
}, 'findOne');
92+
if (!entry) return res.status(404).json({ status: false, statusText: 'Not found.' });
93+
if (!privileged && String(entry.userId) !== String(req.uid)) {
94+
return res.status(403).json({ status: false, statusText: 'You can only remove your own time off.' });
95+
}
96+
await MongoDbCrudOpration(companyId, {
97+
type: SCHEMA_TYPE.PTO_ENTRIES,
98+
data: [{ _id: new mongoose.Types.ObjectId(String(id)) }, { $set: { deletedStatusKey: 1 } }],
99+
}, 'updateOne');
100+
removeCache(`pto:${companyId}`);
101+
return res.json({ status: true, statusText: 'PTO entry removed.' });
102+
} catch (e) { logger.error(`deletePto: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); }
103+
};
104+
105+
// GET /api/v1/pto/capacity?userId=&from=&to=&hoursPerDay= — available capacity for
106+
// a user over a range, with APPROVED PTO subtracted. This is the SEC-08 done-when:
107+
// PTO entries reduce a user's available capacity (feeds REP-06 capacity planning).
108+
exports.getCapacity = async (req, res) => {
109+
try {
110+
const companyId = companyOf(req);
111+
if (!companyId) return res.status(400).json({ status: false, statusText: 'companyId is required.' });
112+
const roleType = await getRoleType(companyId, req.uid);
113+
const privileged = isPrivileged(roleType);
114+
const q = req.query || {};
115+
const userId = privileged && q.userId ? String(q.userId) : String(req.uid);
116+
if (!q.from || !q.to) return res.status(400).json({ status: false, statusText: 'from and to are required.' });
117+
const workingHoursPerDay = Number(q.hoursPerDay) > 0 ? Number(q.hoursPerDay) : R.DEFAULT_HOURS_PER_DAY;
118+
const entries = await MongoDbCrudOpration(companyId, {
119+
type: SCHEMA_TYPE.PTO_ENTRIES,
120+
data: [{
121+
userId, status: 'approved', deletedStatusKey: { $ne: 1 },
122+
startDate: { $lte: new Date(q.to) }, endDate: { $gte: new Date(q.from) },
123+
}],
124+
}, 'find');
125+
const capacity = R.computeAvailableCapacity({
126+
rangeStart: q.from, rangeEnd: q.to, ptoEntries: entries || [], workingHoursPerDay,
127+
});
128+
return res.json({ status: true, data: { userId, from: q.from, to: q.to, ...capacity } });
129+
} catch (e) { logger.error(`getCapacity: ${e.message}`); return res.status(500).json({ status: false, statusText: e.message }); }
130+
};

Modules/Pto/helpers/ptoRules.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SEC-08 — pure time-off / PTO rules (no DB, no I/O). The capacity math here is
2+
// the heart of the feature: approved PTO reduces a user's available capacity.
3+
// Unit-tested in tests/pto-rules.test.js.
4+
5+
const PTO_TYPES = ['vacation', 'sick', 'holiday', 'personal', 'unpaid'];
6+
const PTO_STATUS = ['pending', 'approved', 'rejected'];
7+
const DEFAULT_HOURS_PER_DAY = 8;
8+
const DEFAULT_WEEKEND = [0, 6]; // Sun, Sat
9+
10+
const toDate = (v) => {
11+
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
12+
if (v === null || v === undefined || v === '') return null;
13+
const d = new Date(v);
14+
return isNaN(d.getTime()) ? null : d;
15+
};
16+
17+
// Normalize to UTC midnight — PTO is tracked at whole-day granularity.
18+
const startOfDay = (v) => {
19+
const d = toDate(v);
20+
if (!d) return null;
21+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
22+
};
23+
24+
const validatePtoEntry = (entry = {}) => {
25+
const errors = [];
26+
const start = startOfDay(entry.startDate);
27+
const end = startOfDay(entry.endDate);
28+
if (!entry.userId) errors.push('userId is required');
29+
if (!start) errors.push('a valid startDate is required');
30+
if (!end) errors.push('a valid endDate is required');
31+
if (start && end && end < start) errors.push('endDate must be on or after startDate');
32+
const type = PTO_TYPES.includes(entry.type) ? entry.type : 'vacation';
33+
let hoursPerDay = Number(entry.hoursPerDay);
34+
if (!hoursPerDay || hoursPerDay <= 0 || hoursPerDay > 24) hoursPerDay = DEFAULT_HOURS_PER_DAY;
35+
const status = PTO_STATUS.includes(entry.status) ? entry.status : 'pending';
36+
return {
37+
valid: errors.length === 0,
38+
errors,
39+
value: errors.length ? null : {
40+
userId: String(entry.userId),
41+
type,
42+
status,
43+
startDate: start,
44+
endDate: end,
45+
hoursPerDay,
46+
reason: entry.reason ? String(entry.reason).slice(0, 500) : '',
47+
},
48+
};
49+
};
50+
51+
// Whole days, inclusive.
52+
const inclusiveDays = (start, end) => {
53+
const s = startOfDay(start);
54+
const e = startOfDay(end);
55+
if (!s || !e || e < s) return 0;
56+
return Math.round((e - s) / 86400000) + 1;
57+
};
58+
59+
// Working days (Mon–Fri by default), inclusive.
60+
const workingDaysBetween = (start, end, weekendDays = DEFAULT_WEEKEND) => {
61+
const s = startOfDay(start);
62+
const e = startOfDay(end);
63+
if (!s || !e || e < s) return 0;
64+
let count = 0;
65+
const cur = new Date(s);
66+
while (cur <= e) {
67+
if (!weekendDays.includes(cur.getUTCDay())) count++;
68+
cur.setUTCDate(cur.getUTCDate() + 1);
69+
}
70+
return count;
71+
};
72+
73+
// Inclusive overlap of an entry with [rangeStart, rangeEnd] → { start, end } | null.
74+
const overlapRange = (entry, rangeStart, rangeEnd) => {
75+
const es = startOfDay(entry && entry.startDate);
76+
const ee = startOfDay(entry && entry.endDate);
77+
const rs = startOfDay(rangeStart);
78+
const re = startOfDay(rangeEnd);
79+
if (!es || !ee || !rs || !re) return null;
80+
const start = es > rs ? es : rs;
81+
const end = ee < re ? ee : re;
82+
if (end < start) return null;
83+
return { start, end };
84+
};
85+
86+
// Hours an entry consumes within a window = working days in the overlap × hoursPerDay.
87+
const ptoHoursInRange = (entry, rangeStart, rangeEnd, weekendDays = DEFAULT_WEEKEND) => {
88+
const ov = overlapRange(entry, rangeStart, rangeEnd);
89+
if (!ov) return 0;
90+
const hpd = Number(entry && entry.hoursPerDay) > 0 ? Number(entry.hoursPerDay) : DEFAULT_HOURS_PER_DAY;
91+
return workingDaysBetween(ov.start, ov.end, weekendDays) * hpd;
92+
};
93+
94+
// Available capacity over [rangeStart, rangeEnd], subtracting APPROVED PTO only.
95+
const computeAvailableCapacity = ({
96+
rangeStart,
97+
rangeEnd,
98+
ptoEntries = [],
99+
workingHoursPerDay = DEFAULT_HOURS_PER_DAY,
100+
weekendDays = DEFAULT_WEEKEND,
101+
} = {}) => {
102+
const workingDays = workingDaysBetween(rangeStart, rangeEnd, weekendDays);
103+
const totalCapacityHours = workingDays * workingHoursPerDay;
104+
const ptoHours = (ptoEntries || [])
105+
.filter((e) => e && e.status === 'approved')
106+
.reduce((sum, e) => sum + ptoHoursInRange(e, rangeStart, rangeEnd, weekendDays), 0);
107+
const availableHours = Math.max(0, totalCapacityHours - ptoHours);
108+
return { workingDays, totalCapacityHours, ptoHours, availableHours };
109+
};
110+
111+
module.exports = {
112+
PTO_TYPES,
113+
PTO_STATUS,
114+
DEFAULT_HOURS_PER_DAY,
115+
DEFAULT_WEEKEND,
116+
startOfDay,
117+
validatePtoEntry,
118+
inclusiveDays,
119+
workingDaysBetween,
120+
overlapRange,
121+
ptoHoursInRange,
122+
computeAvailableCapacity,
123+
};

Modules/Pto/init.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => {
4+
routes.init(app);
5+
}

Modules/Pto/routes.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const ctrl = require('./controller');
2+
3+
exports.init = (app) => {
4+
// JWT + companyId enforced in setMiddleware (prefix /api/v1/pto). Role checks
5+
// (approve = owner/admin; members manage their own) are enforced in-controller.
6+
app.post('/api/v1/pto', ctrl.createPto);
7+
app.get('/api/v1/pto', ctrl.listPto);
8+
// Registered before any ':id' route so "capacity" isn't captured as an id.
9+
app.get('/api/v1/pto/capacity', ctrl.getCapacity);
10+
app.put('/api/v1/pto/:id/status', ctrl.updatePtoStatus);
11+
app.delete('/api/v1/pto/:id', ctrl.deletePto);
12+
};

frontend/src/components/templates/Settings/Settings.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@
195195
icon: require("@/assets/images/svg/workspaceSecurity.svg"),
196196
permissions:['settings.settings_security_permissions'],
197197
activeIcon: require("@/assets/images/svg/workspaceSecurityActive.svg")
198+
},{
199+
label: "Time Off",
200+
to: {name: "TimeOff"},
201+
icon: require("@/assets/images/svg/WorkspaceSettingsInactive.svg"),
202+
isVisible:true,
203+
activeIcon: require("@/assets/images/svg/WorkspaceSettings.svg")
198204
},{
199205
label: UserName,
200206
to: {name: UserName},

frontend/src/config/env.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ module.exports.SSO_CONFIG = '/api/v2/sso/config';
184184
module.exports.AUDIT_LOGS = '/api/v1/audit-logs';
185185
module.exports.SCIM_CONFIG = '/api/v2/scim/config';
186186
module.exports.SCIM_TOKEN = '/api/v2/scim/token';
187+
module.exports.PTO = '/api/v1/pto';
187188
module.exports.MAIN_CHATS = '/api/v1/main-chats'
188189
module.exports.ACTIVITYLOG = '/api/v1/activity-log'
189190
module.exports.SETTING_CATEGORY = '/api/v1/setting/category';

0 commit comments

Comments
 (0)