Compare commits

..

No commits in common. "c21bbca78e945ed11a57789cc1b22f5754ae8076" and "bf85053ba43cc92b7f77c4e37204581e0afe179c" have entirely different histories.

91 changed files with 2937 additions and 4023 deletions

25
.env
View File

@ -1,25 +0,0 @@
# Gateway
GATEWAY_PORT=8000
# Microservice Ports
EMS_PORT=8001
AMS_PORT=8002
LMS_PORT=8003
# Internal Service URLs (Used for service-to-service HTTP calls)
EMS_URL=http://localhost:8001
AMS_URL=http://localhost:8002
LMS_URL=http://localhost:8003
# Internal API Token (Prevents direct access bypassing the Gateway)
INTERNAL_SERVICE_TOKEN=super-secret-dev-token-123
# Database
DB_HOST=127.0.0.1
DB_USER=admin
DB_PASSWORD=admin123
# Database Ports (Local Dev)
EMS_DB_PORT=3306
AMS_DB_PORT=3307
LMS_DB_PORT=3308

View File

@ -1,18 +0,0 @@
# Use the official Deno image (alpine for smaller size)
FROM denoland/deno:latest
# Set the working directory inside the container
WORKDIR /app
# Copy the entire monorepo into the container
# This includes deno.json, packages/core, and all services
COPY . .
# Cache the dependencies for all services so they start instantly
RUN deno cache services/gateway/main.ts \
services/ems/main.ts \
services/ams/main.ts \
services/lms/main.ts
# The default command (docker-compose will override this for each service)
CMD ["run", "-A", "services/gateway/main.ts"]

View File

@ -0,0 +1,519 @@
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
const amsDb = getDbPool("hrms_ams");
const emsDb = getDbPool("hrms_ems");
/**
* Fetch top 20 raw biometric records for debugging/auditing
*/
export const getRawLogs = async (ctx: Context) => {
try {
const [rows] = await amsDb.execute(
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
data: rows,
};
} catch (error) {
console.error("DB Error:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, message: "Database query failed" };
}
};
// Helper function to generate an array of dates between two boundaries
const getDaysArray = (start: string, end: string): string[] => {
const arr: string[] = [];
const dt = new Date(start);
const endDt = new Date(end);
while (dt <= endDt) {
arr.push(new Date(dt).toISOString().split('T')[0]);
dt.setDate(dt.getDate() + 1);
}
return arr;
};
export const processDailyAttendance = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
let datesToProcess: string[] = [];
// 1. Determine date execution scope
if (body.work_date) {
datesToProcess.push(body.work_date);
} else if (body.start_date && body.end_date) {
datesToProcess = getDaysArray(body.start_date, body.end_date);
} else {
// Safely pull unique dates using direct string formatting in SQL to avoid Deno timezone shifting bugs
const [dateRows]: any = await amsDb.execute(
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
);
// Explicitly guarantee we extract the exact date string format without hitting Deno Date constructor limits
datesToProcess = dateRows.map((r: any) => {
if (r.raw_date instanceof Date) {
// Fallback if the driver parses it into a Date object despite format rules
const offset = r.raw_date.getTimezoneOffset();
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split('T')[0];
}
return String(r.raw_date); // Clean 'YYYY-MM-DD' string representation
});
}
if (datesToProcess.length === 0) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "No dates found to process." };
return;
}
const amsConnection = await amsDb.getConnection();
let totalProcessedRecords = 0;
try {
await amsConnection.beginTransaction();
// Loop through every single date sequentially
for (const targetDate of datesToProcess) {
// 2. Fetch raw logs specifically grouped for this targetDate
const [rawLogs]: any = await amsConnection.execute(
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
FROM attendance_raw_logs
WHERE DATE(attendance_time) = ?
ORDER BY employee_code, attendance_time ASC`,
[targetDate]
);
if (rawLogs.length === 0) continue;
// Group raw logs by employee_code for this specific day
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
if (!acc[log.employee_code]) acc[log.employee_code] = [];
acc[log.employee_code].push(log.att_time);
return acc;
}, {});
// 3. Process calculations per employee for this targetDate
for (const empCode in groupedLogs) {
// Look up employee metadata from EMS
const [empRows]: any = await emsDb.execute(
`SELECT employee_id, is_active FROM employees WHERE employee_code = ?`,
[empCode]
);
if (empRows.length === 0 || !empRows[0].is_active) {
console.log(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`);
continue;
}
const employeeId = empRows[0].employee_id;
const punches = groupedLogs[empCode];
// Fetch shift details (Defaulting to shift_id 1 for evaluation)
const [shiftRows]: any = await amsConnection.execute(
`SELECT start_time, grace_period_minutes FROM shifts WHERE shift_id = 1`
);
const shift = shiftRows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
let check_in = null;
let check_out = null;
let worked_hours = 0.0;
let check_in_status = "ON_TIME";
let final_status = "ABSENT";
if (punches.length >= 2) {
check_in = punches[0];
check_out = punches[punches.length - 1];
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
const rawCheckInTimeStr = check_in.split(' ')[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
check_in_status = "LATE";
}
if (worked_hours >= 7.0) final_status = "FULL_DAY";
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
else final_status = "ABSENT";
} else if (punches.length === 1) {
check_in = punches[0];
check_in_status = "MISPUNCH";
final_status = "MISPUNCH";
}
// 4. Upsert calculations atomically into the processed daily ledger
await amsConnection.execute(
`INSERT INTO processed_daily_attendance
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
VALUES (?, 1, 1, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
check_in = VALUES(check_in),
check_out = VALUES(check_out),
worked_hours = VALUES(worked_hours),
check_in_status = VALUES(check_in_status),
final_status = VALUES(final_status)`,
[employeeId, targetDate, check_in, check_out, worked_hours, check_in_status, final_status]
);
totalProcessedRecords++;
}
}
await amsConnection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: `Successfully processed ${totalProcessedRecords} logs across ${datesToProcess.length} days.`
};
} catch (error) {
await amsConnection.rollback();
console.error("Bulk processing failed:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
} finally {
amsConnection.release();
}
};
export const getEmployeeSummary = async (ctx: Context) => {
const params = ctx.request.url.searchParams;
const employeeId = params.get("employee_id");
const startDate = params.get("start_date");
const endDate = params.get("end_date");
// Basic validation
if (!employeeId || !startDate || !endDate) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
message: "Missing required query parameters. Expected: employee_id, start_date, and end_date."
};
return;
}
try {
// Fetch the individual breakdown rows for the period
const [rows]: any = await amsDb.execute(
`SELECT
attendance_id,
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours,
check_in_status,
final_status
FROM processed_daily_attendance
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
ORDER BY work_date DESC`,
[employeeId, startDate, endDate]
);
// Dynamic metrics aggregation block
const summaryMetrics = rows.reduce(
(acc: any, row: any) => {
acc.total_days_tracked++;
acc.total_hours_worked += Number(row.worked_hours || 0);
// Count up categorical operational states
if (row.final_status === "FULL_DAY") acc.full_days++;
else if (row.final_status === "HALF_DAY") acc.half_days++;
else if (row.final_status === "ABSENT") acc.absences++;
else if (row.final_status === "MISPUNCH") acc.mispunches++;
if (row.check_in_status === "LATE") acc.late_arrivals++;
return acc;
},
{
total_days_tracked: 0,
total_hours_worked: 0,
full_days: 0,
half_days: 0,
absences: 0,
mispunches: 0,
late_arrivals: 0,
}
);
// Format final working duration to a neat 2-decimal scale
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
ctx.response.status = 200;
ctx.response.body = {
success: true,
metrics: summaryMetrics,
history: rows
};
} catch (error) {
console.error("Failed to fetch employee summary:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
export const getDailyReport = async (ctx: Context) => {
const params = ctx.request.url.searchParams;
const targetDate = params.get("date");
if (!targetDate) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing required 'date' query parameter (YYYY-MM-DD)." };
return;
}
try {
// 1. Grab all calculated attendance states for this date from AMS
const [attendanceRows]: any = await amsDb.execute(
`SELECT employee_id, attendance_id,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE work_date = ?`,
[targetDate]
);
// 2. Fetch active corporate names and metadata mappings from EMS
const [employeeRows]: any = await emsDb.execute(
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
FROM employees e
INNER JOIN partners p ON e.partner_id = p.partner_id
WHERE e.is_active = TRUE`
);
// Map the attendance records by employee_id for O(1) matching speed
const attendanceMap = attendanceRows.reduce((acc: any, row: any) => {
acc[row.employee_id] = row;
return acc;
}, {});
// Summary metrics counter block for the Admin dashboard cards
const summary = {
total_active_workforce: employeeRows.length,
present: 0,
late: 0,
mispunches: 0,
absent: 0
};
// 3. Seamlessly blend employee profiles with attendance states
const roster = employeeRows.map((emp: any) => {
const attendance = attendanceMap[emp.employee_id];
let status = "ABSENT";
let check_in = null;
let check_out = null;
let worked_hours = "0.00";
let punctuality = "N/A";
let attendance_id = null;
if (attendance) {
status = attendance.final_status;
check_in = attendance.check_in;
check_out = attendance.check_out;
worked_hours = attendance.worked_hours;
punctuality = attendance.check_in_status;
attendance_id = attendance.attendance_id;
// Process summary counter evaluations
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
if (status === "MISPUNCH") summary.mispunches++;
if (punctuality === "LATE") summary.late++;
} else {
summary.absent++;
}
return {
employee_id: emp.employee_id,
employee_code: emp.employee_code,
full_name: `${emp.first_name} ${emp.last_name}`,
attendance_id,
status,
check_in,
check_out,
worked_hours,
punctuality
};
});
ctx.response.status = 200;
ctx.response.body = {
success: true,
date: targetDate,
summary,
roster
};
} catch (error) {
console.error("Failed to generate daily admin report:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
export const getAdminRangeReport = async (ctx: Context) => {
const params = ctx.request.url.searchParams;
const fromDate = params.get("from_date");
const toDate = params.get("to_date");
const branchId = params.get("branch_id"); // Optional filter
const departmentId = params.get("department_id"); // Optional filter
if (!fromDate || !toDate) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing required date boundaries: from_date and to_date (YYYY-MM-DD)." };
return;
}
try {
// 1. Dynamically build the EMS query based on branch or department selection
let emsQuery = `
SELECT e.employee_id, e.employee_code, p.first_name, p.last_name, b.branch_name, d.name as department_name
FROM employees e
INNER JOIN partners p ON e.partner_id = p.partner_id
INNER JOIN branches b ON e.branch_id = b.branch_id
INNER JOIN contracts c ON e.employee_id = c.employee_id
INNER JOIN departments d ON c.department_id = d.department_id
WHERE e.is_active = TRUE AND c.status = 'ACTIVE'
`;
const emsParams: any[] = [];
if (branchId) {
emsQuery += ` AND e.branch_id = ?`;
emsParams.push(branchId);
}
if (departmentId) {
emsQuery += ` AND c.department_id = ?`;
emsParams.push(departmentId);
}
const [employees]: any = await emsDb.execute(emsQuery, emsParams);
if (employees.length === 0) {
ctx.response.status = 200;
ctx.response.body = { success: true, summary: {}, report: [] };
return;
}
const employeeIds = employees.map((e: any) => e.employee_id);
// 2. Query AMS for all processed ledger entries matching these exact employees in the date range
// Using a parameterized string hack for the IN clause since Deno MySQL handles arrays explicitly
const placeholders = employeeIds.map(() => "?").join(",");
const [attendanceRows]: any = await amsDb.execute(
`SELECT
employee_id,
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
ORDER BY work_date ASC`,
[fromDate, toDate, ...employeeIds]
);
// Group logs by employee_id for structural assembly
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
if (!acc[row.employee_id]) acc[row.employee_id] = [];
acc[row.employee_id].push(row);
return acc;
}, {});
// Overall global counters for the Admin's upper summary ribbon card metrics
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
const report = employees.map((emp: any) => {
const logs = attendanceGrouped[emp.employee_id] || [];
// Calculate individual metrics summary for this specific employee across the range
const individualMetrics = logs.reduce((acc: any, log: any) => {
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
return acc;
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
return {
...emp,
range_metrics: individualMetrics,
attendance_history: logs
};
});
ctx.response.status = 200;
ctx.response.body = { success: true, date_range: { from: fromDate, to: toDate }, global_summary: globalSummary, report };
} catch (error) {
console.error("Admin range report compilation failed:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
export const getSingleEmployeeRangeReport = async (ctx: Context) => {
const params = ctx.request.url.searchParams;
const employeeId = params.get("employee_id");
const fromDate = params.get("from_date");
const toDate = params.get("to_date");
if (!employeeId || !fromDate || !toDate) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing query parameters. Required: employee_id, from_date, to_date." };
return;
}
try {
// Grab profile data
const [empProfile]: any = await emsDb.execute(
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
FROM employees e INNER JOIN partners p ON e.partner_id = p.partner_id WHERE e.employee_id = ?`,
[employeeId]
);
if (empProfile.length === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Employee profile not found." };
return;
}
// Grab attendance logs for date-to-date query window
const [logs]: any = await amsDb.execute(
`SELECT
attendance_id,
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
ORDER BY work_date ASC`,
[employeeId, fromDate, toDate]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
employee: empProfile[0],
range: { from: fromDate, to: toDate },
total_days: logs.length,
history: logs
};
} catch (error) {
console.error("Single employee range query failed:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};

View File

@ -0,0 +1,326 @@
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
import { AppRole } from "../../shared/auth.ts"; // Importing roles for security checks
const amsDb = getDbPool("hrms_ams");
const emsDb = getDbPool("hrms_ems"); // Used for cross-database joins
/**
* EMPLOYEE: Create a new regularization request
*/
export const createRegularizationRequest = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing request body." };
return;
}
const body = await ctx.request.body.json();
const {
attendance_id,
employee_id,
regularization_type,
target_date,
requested_check_in,
requested_check_out,
reason,
} = body;
if (!employee_id || !reason || !regularization_type || !target_date) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
message:
"Missing required fields: employee_id, target_date, regularization_type, and reason.",
};
return;
}
try {
// Fetch employee's branch_id from EMS so AMS knows which branch this request belongs to
const [empRows]: any = await emsDb.execute(
`SELECT branch_id FROM employees WHERE employee_id = ?`,
[employee_id],
);
if (empRows.length === 0) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
message: "Employee not found in EMS.",
};
return;
}
const branch_id = empRows[0].branch_id;
// Insert into AMS with the cached branch_id
await amsDb.execute(
`INSERT INTO attendance_regularizations
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
[
attendance_id || null,
employee_id,
branch_id,
regularization_type,
target_date,
requested_check_in || null,
requested_check_out || null,
reason,
],
);
ctx.response.status = 201;
ctx.response.body = {
success: true,
message:
"Regularization request submitted successfully and is pending approval.",
};
} catch (error) {
console.error("Failed to submit regularization:", error);
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
};
/**
* ADMIN: Fetch pending regularizations based on their assigned branches
*/
export const getPendingRegularizations = async (ctx: Context) => {
const user = ctx.state.user;
try {
let query = `
SELECT
r.regularization_id,
r.employee_id,
r.target_date,
r.regularization_type,
r.requested_check_in,
r.requested_check_out,
r.reason,
e.employee_code,
p.first_name,
p.last_name,
b.branch_name
FROM attendance_regularizations r
INNER JOIN hrms_ems.employees e ON r.employee_id = e.employee_id
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
INNER JOIN hrms_ems.branches b ON r.branch_id = b.branch_id
WHERE r.status = 'PENDING'
`;
const params: any[] = [];
// If the user is an ADMIN (not SUPER_ADMIN), restrict to their managed branches
if (user.role !== AppRole.SUPER_ADMIN) {
const branches = user.managed_branches;
if (!branches || branches.length === 0) {
// If an admin has no branches assigned, return empty list
ctx.response.status = 200;
ctx.response.body = { success: true, data: [] };
return;
}
const placeholders = branches.map(() => "?").join(",");
query += ` AND r.branch_id IN (${placeholders})`;
params.push(...branches);
}
query += ` ORDER BY r.target_date DESC`;
const [rows]: any = await amsDb.execute(query, params);
ctx.response.status = 200;
ctx.response.body = {
success: true,
count: rows.length,
data: rows,
};
} catch (error) {
console.error("Failed to fetch pending regularizations:", error);
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
};
/**
* ADMIN: Review (Approve/Reject) a regularization request
*/
export const reviewRegularizationRequest = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing request body." };
return;
}
const body = await ctx.request.body.json();
const user = ctx.state.user; // The admin reviewing it
const { regularization_id, action } = body; // action can be 'APPROVED' or 'REJECTED'
if (!regularization_id || !["APPROVED", "REJECTED"].includes(action)) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
message: "Invalid payload. Required: regularization_id, action.",
};
return;
}
const amsConnection = await amsDb.getConnection();
try {
await amsConnection.beginTransaction();
// 1. Fetch the regularization request details
const [requestRows]: any = await amsConnection.execute(
`SELECT regularization_id, attendance_id, branch_id, requested_check_in, requested_check_out, status
FROM attendance_regularizations WHERE regularization_id = ?`,
[regularization_id],
);
if (requestRows.length === 0) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
message: "Regularization request not found.",
};
await amsConnection.rollback();
return;
}
const request = requestRows[0];
// SECURITY CHECK: Ensure the admin actually manages this branch
if (
user.role !== AppRole.SUPER_ADMIN &&
!user.managed_branches.includes(request.branch_id)
) {
ctx.response.status = 403;
ctx.response.body = {
success: false,
message: "Access Denied: You do not manage this branch.",
};
await amsConnection.rollback();
return;
}
if (request.status !== "PENDING") {
ctx.response.status = 400;
ctx.response.body = {
success: false,
message: "This request has already been processed.",
};
await amsConnection.rollback();
return;
}
// 2. Update the regularization request record status and reviewer ID
await amsConnection.execute(
`UPDATE attendance_regularizations
SET status = ?, reviewed_by_id = ?
WHERE regularization_id = ?`,
[action, user.employee_id, regularization_id],
);
// 3. If APPROVED, dynamically recalculate and overwrite the target day's processed ledger row
if (action === "APPROVED") {
let worked_hours = 0.0;
let final_status = "FULL_DAY";
let check_in_status = "ON_TIME";
// Fetch shift details (Fallback pattern)
const [empRows]: any = await amsConnection.execute(
`SELECT company_id, branch_id FROM hrms_ems.employees WHERE employee_id = ?`,
[requestRows[0].employee_id],
);
const emp = empRows[0];
const [shiftRows]: any = await amsConnection.execute(
`SELECT start_time, grace_period_minutes FROM shifts
WHERE (branch_id = ? OR branch_id IS NULL) AND company_id = ?
ORDER BY branch_id IS NULL ASC LIMIT 1`,
[emp.branch_id, emp.company_id],
);
const shift = shiftRows[0] ||
{ start_time: "10:00:00", grace_period_minutes: 10 };
if (request.requested_check_in && request.requested_check_out) {
const checkInMs = new Date(request.requested_check_in.replace(" ", "T"))
.getTime();
const checkOutMs = new Date(
request.requested_check_out.replace(" ", "T"),
).getTime();
// Handle potential night shift edge case
const effectiveCheckOutMs = checkOutMs < checkInMs
? checkOutMs + (24 * 60 * 60 * 1000)
: checkOutMs;
worked_hours = Math.round(
((effectiveCheckOutMs - checkInMs) / (1000 * 60 * 60)) * 100,
) / 100;
if (worked_hours >= 7.0) final_status = "FULL_DAY";
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
else final_status = "ABSENT";
const rawCheckInTimeStr = request.requested_check_in.split(" ")[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(":").map(
Number,
);
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(
Number,
);
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 +
(shift.grace_period_minutes * 60);
if (punchTotalSeconds > shiftCutoffSeconds) {
check_in_status = "LATE";
}
}
// Only update the ledger if an actual attendance record exists
if (request.attendance_id) {
await amsConnection.execute(
`UPDATE processed_daily_attendance
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
WHERE attendance_id = ?`,
[
request.requested_check_in,
request.requested_check_out,
worked_hours,
final_status,
check_in_status,
request.attendance_id,
],
);
}
}
await amsConnection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: `Request has been successfully ${action.toLowerCase()}.`,
};
} catch (error) {
await amsConnection.rollback();
console.error("Admin review action failed:", error);
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
} finally {
amsConnection.release();
}
};

37
ams-service/routes.ts Normal file
View File

@ -0,0 +1,37 @@
import { Router } from "@oak/oak";
import {
getRawLogs,
processDailyAttendance,
getEmployeeSummary,
getDailyReport,
getAdminRangeReport,
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
import {createRegularizationRequest,
getPendingRegularizations,
reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
const router = new Router();
const apiV1 = new Router();
apiV1.get("/attendance/logs", getRawLogs);
apiV1.post("/attendance/process-daily", processDailyAttendance);
// Regularization Routes
apiV1.post("/attendance/regularize", requireAuth, requireRole([AppRole.EMPLOYEE, AppRole.ADMIN]), createRegularizationRequest);
// Admin fetches requests assigned to their branches
apiV1.get("/attendance/regularize/pending", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getPendingRegularizations);
// Admin reviews requests
apiV1.post("/attendance/regularize/review", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), reviewRegularizationRequest);
apiV1.get("/attendance/my-summary", getEmployeeSummary);
apiV1.get("/attendance/daily-report", getDailyReport);
apiV1.get("/attendance/admin-report", getAdminRangeReport);
apiV1.get("/attendance/employee-range-report", getSingleEmployeeRangeReport);
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
export default router;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,253 +0,0 @@
-- MySQL dump 10.13 Distrib 8.4.10, for Linux (x86_64)
--
-- Host: localhost Database: hrms_lms
-- ------------------------------------------------------
-- Server version 8.4.10
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `branch_work_settings`
--
DROP TABLE IF EXISTS `branch_work_settings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `branch_work_settings` (
`setting_id` int NOT NULL AUTO_INCREMENT,
`company_id` int NOT NULL,
`branch_id` int DEFAULT NULL,
`weekly_off_days` json NOT NULL,
`effective_from` date NOT NULL,
PRIMARY KEY (`setting_id`),
KEY `idx_work_settings` (`company_id`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `branch_work_settings`
--
LOCK TABLES `branch_work_settings` WRITE;
/*!40000 ALTER TABLE `branch_work_settings` DISABLE KEYS */;
INSERT INTO `branch_work_settings` VALUES (1,1,NULL,'[0]','2026-01-01');
/*!40000 ALTER TABLE `branch_work_settings` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `company_holidays`
--
DROP TABLE IF EXISTS `company_holidays`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `company_holidays` (
`holiday_id` int NOT NULL AUTO_INCREMENT,
`company_id` int NOT NULL,
`branch_id` int DEFAULT NULL,
`calendar_year` int NOT NULL,
`holiday_date` date NOT NULL,
`holiday_type` enum('MANDATORY','OPTIONAL') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANDATORY',
`holiday_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`holiday_id`),
KEY `idx_holiday_resolver` (`company_id`,`branch_id`,`calendar_year`,`holiday_date`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `company_holidays`
--
LOCK TABLES `company_holidays` WRITE;
/*!40000 ALTER TABLE `company_holidays` DISABLE KEYS */;
INSERT INTO `company_holidays` VALUES (1,1,NULL,2026,'2026-08-15','MANDATORY','Independence Day');
/*!40000 ALTER TABLE `company_holidays` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `leave_allocations`
--
DROP TABLE IF EXISTS `leave_allocations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `leave_allocations` (
`allocation_id` int NOT NULL AUTO_INCREMENT,
`employee_id` int NOT NULL,
`leave_type_id` int NOT NULL,
`company_id` int NOT NULL,
`calendar_year` int NOT NULL,
`granted_days` decimal(4,1) NOT NULL,
`used_days` decimal(4,1) DEFAULT '0.0',
`status` enum('DRAFT','ACTIVE','EXPIRED') COLLATE utf8mb4_unicode_ci DEFAULT 'ACTIVE',
PRIMARY KEY (`allocation_id`),
KEY `leave_type_id` (`leave_type_id`),
KEY `idx_emp_balance` (`employee_id`,`calendar_year`,`leave_type_id`),
CONSTRAINT `leave_allocations_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE RESTRICT
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `leave_allocations`
--
LOCK TABLES `leave_allocations` WRITE;
/*!40000 ALTER TABLE `leave_allocations` DISABLE KEYS */;
INSERT INTO `leave_allocations` VALUES (1,1,1,1,2026,10.0,0.0,'ACTIVE');
/*!40000 ALTER TABLE `leave_allocations` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `leave_applications`
--
DROP TABLE IF EXISTS `leave_applications`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `leave_applications` (
`application_id` int NOT NULL AUTO_INCREMENT,
`employee_id` int NOT NULL,
`leave_type_id` int NOT NULL,
`company_id` int NOT NULL,
`date_from` timestamp NOT NULL,
`date_to` timestamp NOT NULL,
`number_of_days` decimal(4,1) NOT NULL,
`reason` text COLLATE utf8mb4_unicode_ci NOT NULL,
`status` enum('DRAFT','PENDING','PENDING_LOP','APPROVED','APPROVED_LOP','REJECTED','CANCELLED') COLLATE utf8mb4_unicode_ci DEFAULT 'PENDING',
`manager_approved_by` int DEFAULT NULL,
`hr_approved_by` int DEFAULT NULL,
PRIMARY KEY (`application_id`),
KEY `leave_type_id` (`leave_type_id`),
KEY `idx_lms_status_lookup` (`employee_id`,`status`),
CONSTRAINT `leave_applications_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `leave_applications`
--
LOCK TABLES `leave_applications` WRITE;
/*!40000 ALTER TABLE `leave_applications` DISABLE KEYS */;
/*!40000 ALTER TABLE `leave_applications` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `leave_ledger_transactions`
--
DROP TABLE IF EXISTS `leave_ledger_transactions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `leave_ledger_transactions` (
`transaction_id` int NOT NULL AUTO_INCREMENT,
`employee_id` int NOT NULL,
`leave_type_id` int NOT NULL,
`application_id` int DEFAULT NULL,
`calendar_year` int NOT NULL,
`calendar_month` int NOT NULL,
`transaction_type` enum('CREDIT','DEBIT','LAPSE') COLLATE utf8mb4_unicode_ci NOT NULL,
`days` decimal(4,1) NOT NULL,
`opening_balance` decimal(4,1) NOT NULL,
`closing_balance` decimal(4,1) NOT NULL,
`transaction_date` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`remarks` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`transaction_id`),
KEY `leave_type_id` (`leave_type_id`),
KEY `application_id` (`application_id`),
KEY `idx_ledger_lookup` (`employee_id`,`calendar_year`,`calendar_month`),
CONSTRAINT `leave_ledger_transactions_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`),
CONSTRAINT `leave_ledger_transactions_ibfk_2` FOREIGN KEY (`application_id`) REFERENCES `leave_applications` (`application_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `leave_ledger_transactions`
--
LOCK TABLES `leave_ledger_transactions` WRITE;
/*!40000 ALTER TABLE `leave_ledger_transactions` DISABLE KEYS */;
/*!40000 ALTER TABLE `leave_ledger_transactions` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `leave_policy_rules`
--
DROP TABLE IF EXISTS `leave_policy_rules`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `leave_policy_rules` (
`rule_id` int NOT NULL AUTO_INCREMENT,
`leave_type_id` int NOT NULL,
`company_id` int NOT NULL,
`branch_id` int DEFAULT NULL,
`calendar_year` int NOT NULL,
`yearly_allowance` decimal(4,1) NOT NULL,
`max_days_per_month` decimal(4,1) DEFAULT NULL,
`max_consecutive_days` decimal(4,1) DEFAULT NULL,
`apply_sandwich_policy` tinyint(1) DEFAULT '0',
PRIMARY KEY (`rule_id`),
KEY `leave_type_id` (`leave_type_id`),
KEY `idx_policy_lookup` (`company_id`,`branch_id`,`calendar_year`,`leave_type_id`),
CONSTRAINT `leave_policy_rules_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `leave_policy_rules`
--
LOCK TABLES `leave_policy_rules` WRITE;
/*!40000 ALTER TABLE `leave_policy_rules` DISABLE KEYS */;
INSERT INTO `leave_policy_rules` VALUES (1,1,1,NULL,2026,10.0,2.0,NULL,1),(2,2,1,NULL,2026,4.0,NULL,NULL,0);
/*!40000 ALTER TABLE `leave_policy_rules` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `leave_types`
--
DROP TABLE IF EXISTS `leave_types`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `leave_types` (
`leave_type_id` int NOT NULL AUTO_INCREMENT,
`company_id` int NOT NULL,
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`requires_allocation` tinyint(1) DEFAULT '1',
`carry_over_allowed` tinyint(1) DEFAULT '0',
`max_carry_over_days` decimal(3,1) DEFAULT '0.0',
PRIMARY KEY (`leave_type_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `leave_types`
--
LOCK TABLES `leave_types` WRITE;
/*!40000 ALTER TABLE `leave_types` DISABLE KEYS */;
INSERT INTO `leave_types` VALUES (1,1,'Casual Leave',1,0,0.0),(2,1,'Optional Leave',1,0,0.0);
/*!40000 ALTER TABLE `leave_types` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-07-16 10:02:14

View File

@ -1,8 +0,0 @@
# 1. Export EMS Database
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_ems' > hrms_ems_dump.sql
# 2. Export AMS Database
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_ams' > hrms_ams_dump.sql
# 3. Export LMS Database
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_lms' > hrms_lms_dump.sql

View File

@ -1,8 +0,0 @@
# 1. Import EMS Data
docker exec -i hrms_ems_db mysql -u root -proot hrms_ems < hrms_ems_dump.sql
# 2. Import AMS Data
docker exec -i hrms_ams_db mysql -u root -proot hrms_ams < hrms_ams_dump.sql
# 3. Import LMS Data
docker exec -i hrms_lms_db mysql -u root -proot hrms_lms < hrms_lms_dump.sql

View File

@ -1,14 +1,12 @@
{
"workspace": [
"packages/core",
"services/ems",
"services/ams",
"services/lms",
"services/gateway"
"./shared",
"./ems-service",
"./ams-service",
"./lms-service"
],
"imports": {
"@oak/oak": "jsr:@oak/oak@^17.1.3",
"mysql2": "npm:mysql2@^3.0.0",
"core/": "./packages/core/"
"mysql2": "npm:mysql2@^3.0.0"
}
}

4
deno.lock generated
View File

@ -89,8 +89,8 @@
"is-property"
]
},
"iconv-lite@0.7.3": {
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"iconv-lite@0.7.2": {
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dependencies": [
"safer-buffer"
]

View File

@ -1,9 +1,9 @@
version: '3.8'
services:
ems_db:
hrms_db:
image: docker.io/library/mysql:8.4
container_name: hrms_ems_db
container_name: hrms_db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: hrms_ems
@ -12,41 +12,9 @@ services:
ports:
- "3306:3306"
volumes:
- ./init-db/01-init-ems.sql:/docker-entrypoint-initdb.d/01-init-ems.sql:Z
- ems_db_data:/var/lib/mysql
restart: unless-stopped
ams_db:
image: docker.io/library/mysql:8.4
container_name: hrms_ams_db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: hrms_ams
MYSQL_USER: admin
MYSQL_PASSWORD: admin123
ports:
- "3307:3306" # Maps host 3307 to container 3306
volumes:
- ./init-db/02-init-ams.sql:/docker-entrypoint-initdb.d/02-init-ams.sql:Z
- ams_db_data:/var/lib/mysql
restart: unless-stopped
lms_db:
image: docker.io/library/mysql:8.4
container_name: hrms_lms_db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: hrms_lms
MYSQL_USER: admin
MYSQL_PASSWORD: admin123
ports:
- "3308:3306" # Maps host 3308 to container 3306
volumes:
- ./init-db/03-init-lms.sql:/docker-entrypoint-initdb.d/03-init-lms.sql:Z
- lms_db_data:/var/lib/mysql
- ./init-db:/docker-entrypoint-initdb.d:Z
- hrms_db_data:/var/lib/mysql
restart: unless-stopped
volumes:
ems_db_data:
ams_db_data:
lms_db_data:
hrms_db_data:

View File

@ -0,0 +1,68 @@
import { getDbPool } from "../../shared/db.ts"
const pool = getDbPool("hrms_ems");
export const getEmployeeContracts = async (ctx: any) => {
const employeeId = ctx.params.id;
try {
// Fetches combined history of terms and assignments
const [rows] = await pool.query(
`SELECT
et.term_id, et.work_email, et.date_joining, et.probation_days, et.status, et.salary_structure_id,
ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
d.name AS department, j.title AS designation
FROM employment_terms et
JOIN employee_assignments ea ON et.employee_id = ea.employee_id
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE et.employee_id = ?
ORDER BY et.date_joining DESC, ea.effective_from DESC`,
[employeeId]
);
ctx.response.status = 200;
ctx.response.body = { success: true, count: (rows as any[]).length, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const createContract = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// 1. Expire old assignment
await connection.execute(
`UPDATE employee_assignments SET is_current = FALSE, effective_to = CURDATE() WHERE employee_id = ? AND is_current = TRUE`,
[data.employeeId]
);
// 2. Insert new assignment (Promotion/Transfer)
const [result] = await connection.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, ?, ?, CURDATE(), TRUE)`,
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.changeReason || 'TRANSFER']
);
// Optional: If compensation changes, you would expire old employment_terms and insert new ones here.
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "New assignment executed successfully. Previous assignments expired.",
assignment_id: (result as any).insertId
};
} catch (error) {
await connection.rollback();
ctx.response.status = 500;
ctx.response.body = { success: false, error: "Transaction failed: " + (error as Error).message };
} finally { connection.release(); }
};

View File

@ -0,0 +1,66 @@
// dashboard.controller.ts
import { getDbPool } from "../../shared/db.ts";
const pool = getDbPool("hrms_ems");
export const getDashboardMetrics = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
const companyId = params.get('company_id');
const branchId = params.get('branch_id');
const departmentId = params.get('department_id');
// Build dynamic WHERE clauses for each entity type based on the filters provided
let companyFilter = " WHERE 1=1";
let branchFilter = " WHERE 1=1";
let deptFilter = " WHERE 1=1";
let jobFilter = " WHERE 1=1";
let empFilter = " WHERE e.is_active = TRUE";
const valuesC: any[] = [];
const valuesB: any[] = [];
const valuesD: any[] = [];
const valuesJ: any[] = [];
const valuesE: any[] = [];
if (companyId) {
branchFilter += " AND company_id = ?"; valuesB.push(companyId);
deptFilter += " AND company_id = ?"; valuesD.push(companyId);
jobFilter += " AND d.company_id = ?"; valuesJ.push(companyId);
empFilter += " AND e.company_id = ?"; valuesE.push(companyId);
}
if (branchId) {
deptFilter += " AND branch_id = ?"; valuesD.push(branchId);
jobFilter += " AND d.branch_id = ?"; valuesJ.push(branchId);
empFilter += " AND e.branch_id = ?"; valuesE.push(branchId);
}
if (departmentId) {
jobFilter += " AND j.department_id = ?"; valuesJ.push(departmentId);
empFilter += " AND ea.department_id = ?"; valuesE.push(departmentId);
}
// Execute all 5 count queries in parallel for maximum speed
const [cRows]: any = await pool.query(`SELECT COUNT(*) as count FROM companies ${companyFilter}`, valuesC);
const [bRows]: any = await pool.query(`SELECT COUNT(*) as count FROM branches ${branchFilter}`, valuesB);
const [dRows]: any = await pool.query(`SELECT COUNT(*) as count FROM departments ${deptFilter}`, valuesD);
const [jRows]: any = await pool.query(`SELECT COUNT(*) as count FROM job_positions j JOIN departments d ON j.department_id = d.department_id ${jobFilter}`, valuesJ);
const [eRows]: any = await pool.query(`SELECT COUNT(*) as count FROM employees e JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE ${empFilter}`, valuesE);
ctx.response.body = {
success: true,
data: {
total_companies: cRows[0].count,
total_branches: bRows[0].count,
total_departments: dRows[0].count,
total_jobs: jRows[0].count,
total_employees: eRows[0].count
}
};
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};

View File

@ -0,0 +1,310 @@
import { getDbPool } from "../../shared/db.ts";
import { generateNextCode } from "../../shared/sequence.ts";
const pool = getDbPool("hrms_ems");
export const getEmployees = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
let query = `SELECT e.employee_id, e.employee_code, e.is_active,
CONCAT(p.first_name, ' ', p.last_name) AS full_name,
et.work_email, -- <-- ADDED WORK EMAIL HERE
j.title AS job_name, d.name AS department_name,
b.branch_name, c.name AS company_name
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN companies c ON e.company_id = c.company_id
JOIN branches b ON e.branch_id = b.branch_id
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE' -- <-- ADDED JOIN
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE 1=1`;
const values: any[] = [];
if (params.get('company_id')) { query += ` AND e.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('branch_id')) { query += ` AND e.branch_id = ?`; values.push(params.get('branch_id')); }
if (params.get('department_id')) { query += ` AND ea.department_id = ?`; values.push(params.get('department_id')); }
if (params.get('is_active')) { query += ` AND e.is_active = ?`; values.push(params.get('is_active') === 'true'); }
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, count: (rows as any[]).length, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const createEmployee = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
let empCode = data.employeeCode || await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [partnerResult]: any = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) VALUES (?, ?, ?, ?, ?, ?)`,
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone]
);
const partnerId = partnerResult.insertId;
const [empResult]: any = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active) VALUES (?, ?, ?, ?, ?)`,
[empCode, partnerId, data.companyId, data.branchId, true]
);
const employeeId = empResult.insertId;
await connection.execute(
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, ?, ?, 'ACTIVE', ?)`,
[employeeId, data.work_email, data.dateJoining, data.probationDays, data.salaryStructureId]
);
await connection.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, ?, 'HIRE', ?, TRUE)`,
[employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.dateJoining]
);
await connection.commit();
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Employee created", employee_id: employeeId, employee_code: empCode };
} catch (error) {
await connection.rollback();
let errorMessage = (error as Error).message;
if (errorMessage.includes("Duplicate entry")) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: "A unique constraint failed. Employee Code, Email, or Phone already exists." };
return;
}
ctx.response.status = 500;
ctx.response.body = { success: false, error: "Transaction failed: " + errorMessage };
} finally { connection.release(); }
};
export const getEmployeeHistory = async (ctx: any) => {
const id = ctx.params.id;
try {
const [rows] = await pool.query(
`SELECT ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
d.name AS department, j.title AS job_title,
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name
FROM employee_assignments ea
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
WHERE ea.employee_id = ?
ORDER BY ea.effective_from DESC`,
[id]
);
ctx.response.body = { success: true, history: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const getEmployeeById = async (ctx: any) => {
const id = ctx.params.id;
try {
// FIX: Querying the new split tables (employment_terms & employee_assignments)
const [empRows] = await pool.query(
`SELECT
e.employee_id, e.employee_code, e.is_active,
p.first_name, p.last_name, p.dob, p.gender, p.personal_email, p.personal_phone,
et.work_email, et.date_joining, et.probation_days, et.status AS contract_status, et.salary_structure_id,
d.name AS department, d.department_id, j.title AS designation, j.job_id,
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name, mgr_e.employee_code AS manager_employee_code
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
WHERE e.employee_id = ?`,
[id]
);
const empData = empRows as any[];
if (empData.length === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Employee not found" }; return; }
const employee = empData[0];
const [addrRows] = await pool.query(
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
FROM addresses
WHERE partner_id = (SELECT partner_id FROM employees WHERE employee_id = ?)`,
[id]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
data: {
employee_id: employee.employee_id, employee_code: employee.employee_code, is_active: employee.is_active,
first_name: employee.first_name, last_name: employee.last_name, dob: employee.dob, gender: employee.gender,
personal_email: employee.personal_email, personal_phone: employee.personal_phone,
contract: {
work_email: employee.work_email, date_joining: employee.date_joining, probation_days: employee.probation_days,
status: employee.contract_status, salary_structure_id: employee.salary_structure_id,
department: employee.department, department_id: employee.department_id, designation: employee.designation, job_id: employee.job_id,
manager: employee.manager_employee_code ? { employee_code: employee.manager_employee_code, first_name: employee.manager_first_name, last_name: employee.manager_last_name } : null,
},
addresses: addrRows,
},
};
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const updateEmployee = async (ctx: any) => {
const id = ctx.params.id;
const body = ctx.request.body;
if (body.type() !== "json") {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "Invalid JSON body" };
return;
}
const data = await body.json();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// 1. Check if employee exists and get partner_id
const [employeeRows] = await connection.execute(
`SELECT partner_id FROM employees WHERE employee_id = ?`,
[id],
);
const employees = employeeRows as any[];
if (employees.length === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Employee not found" };
await connection.rollback();
return;
}
const partnerId = employees[0].partner_id;
// 2. Update Partners table (Added dob and gender)
await connection.execute(
`UPDATE partners
SET first_name = ?, last_name = ?, dob = ?, gender = ?, personal_email = ?, personal_phone = ?
WHERE partner_id = ?`,
[
data.firstName,
data.lastName,
data.dob,
data.gender,
data.personalEmail,
data.personalPhone,
partnerId,
],
);
// 3. Upsert Address
if (data.address) {
await connection.execute(
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
door_number = VALUES(door_number),
landmark = VALUES(landmark),
address_line = VALUES(address_line),
pincode = VALUES(pincode),
district = VALUES(district),
state = VALUES(state)`,
[
partnerId,
data.address.type,
data.address.doorNumber,
data.address.landmark,
data.address.line,
data.address.pincode,
data.address.district,
data.address.state,
]
);
}
// 4. Update Employment Terms (Work Email)
if (data.work_email) {
await connection.execute(
`UPDATE employment_terms
SET work_email = ?
WHERE employee_id = ? AND status = 'ACTIVE'`,
[data.work_email, id]
);
}
// 5. Update Employee Assignments (Department, Job, Manager)
// We update the current active assignment directly.
// (If this is a formal promotion/transfer, the frontend should use the POST /contracts endpoint instead to preserve history)
if (data.departmentId || data.jobId || data.reportingToId) {
await connection.execute(
`UPDATE employee_assignments
SET department_id = ?, job_id = ?, reporting_to_id = ?
WHERE employee_id = ? AND is_current = TRUE`,
[
data.departmentId,
data.jobId,
data.reportingToId || null,
id
]
);
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: "Employee profile updated successfully",
};
} catch (error) {
await connection.rollback();
// Gracefully catch unique constraint violations on Email/Phone
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error: "Update failed: The Personal Email, Phone, or Work Email already belongs to another employee."
};
return;
}
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Update failed: " + err.message,
};
} finally {
connection.release();
}
};
export const deleteEmployee = async (ctx: any) => {
const id = ctx.params.id;
try {
const [result] = await pool.execute(`UPDATE employees SET is_active = false WHERE employee_id = ?`, [id]);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Employee not found" }; return; }
ctx.response.status = 200;
ctx.response.body = { success: true, message: "Employee account deactivated successfully" };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: "Deactivation failed: " + (error as Error).message };
}
};

View File

@ -0,0 +1,343 @@
import { getDbPool } from "../../shared/db.ts"
import { generateNextCode } from "../../shared/sequence.ts"
const pool = getDbPool("hrms_ems");
// Dynamic helper to build WHERE clauses
const buildFilter = (params: URLSearchParams, allowedFilters: string[]) => {
let clause = " WHERE 1=1";
const values: any[] = [];
allowedFilters.forEach(filter => {
const value = params.get(filter);
if (value) { clause += ` AND ${filter} = ?`; values.push(value); }
});
return { clause, values };
};
export const getCompanies = async (ctx: any) => {
try {
const { clause, values } = buildFilter(ctx.request.url.searchParams, ['is_active']);
const [rows] = await pool.query(`SELECT company_id, company_code, name, is_active FROM companies ${clause}`, values);
ctx.response.body = { success: true, data: rows };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const getBranches = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
let query = `SELECT b.branch_id, b.code, b.branch_name, b.is_active, c.company_code, c.name AS company_name
FROM branches b JOIN companies c ON b.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
if (params.get('company_id')) { query += ` AND b.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('is_active')) { query += ` AND b.is_active = ?`; values.push(params.get('is_active') === 'true'); }
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, data: rows };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const getDepartments = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
let query = `SELECT d.department_id, d.department_code, d.name AS department_name, d.is_active,
b.branch_name, c.name AS company_name,
(SELECT GROUP_CONCAT(CONCAT(p.first_name, ' ', p.last_name) SEPARATOR ', ')
FROM department_managers dm
JOIN employees e ON dm.employee_id = e.employee_id
JOIN partners p ON e.partner_id = p.partner_id
WHERE dm.department_id = d.department_id AND dm.is_current = TRUE) AS managers
FROM departments d JOIN branches b ON d.branch_id = b.branch_id JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
// Filtering
if (params.get('company_id')) { query += ` AND d.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('branch_id')) { query += ` AND d.branch_id = ?`; values.push(params.get('branch_id')); }
if (params.get('is_active')) { query += ` AND d.is_active = ?`; values.push(params.get('is_active') === 'true'); }
// Sorting (Safe mapping to prevent SQL injection)
const sortByParam = params.get('sort_by') || 'department_name';
const sortOrderParam = params.get('sort_order') === 'desc' ? 'DESC' : 'ASC';
const validSortColumns: Record<string, string> = {
'department_name': 'd.name', 'department_code': 'd.department_code', 'company_name': 'c.name', 'branch_name': 'b.branch_name'
};
const sortColumn = validSortColumns[sortByParam] || 'd.name';
query += ` ORDER BY ${sortColumn} ${sortOrderParam}`;
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, count: rows.length, data: rows };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const getJobs = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
let query = `SELECT j.job_id, j.job_code, j.title AS job_name, j.is_active,
d.name AS department_name, b.branch_name, c.name AS company_name
FROM job_positions j JOIN departments d ON j.department_id = d.department_id
JOIN branches b ON d.branch_id = b.branch_id JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
// Filtering
if (params.get('company_id')) { query += ` AND d.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('branch_id')) { query += ` AND d.branch_id = ?`; values.push(params.get('branch_id')); }
if (params.get('department_id')) { query += ` AND j.department_id = ?`; values.push(params.get('department_id')); }
// Sorting (Safe mapping)
const sortByParam = params.get('sort_by') || 'job_name';
const sortOrderParam = params.get('sort_order') === 'desc' ? 'DESC' : 'ASC';
const validSortColumns: Record<string, string> = {
'job_name': 'j.title', 'job_code': 'j.job_code', 'department_name': 'd.name'
};
const sortColumn = validSortColumns[sortByParam] || 'j.title';
query += ` ORDER BY ${sortColumn} ${sortOrderParam}`;
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, count: rows.length, data: rows };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
// ==========================================
// COMPANIES MANAGEMENT
// ==========================================
export const createCompany = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
let companyCode = data.companyCode || await generateNextCode("COMPANY_MAIN", "CO-", 3);
try {
const [result] = await pool.execute(
`INSERT INTO companies (company_code, name, parent_id, is_active) VALUES (?, ?, ?, ?)`,
[companyCode, data.name, data.parentId || null, data.isActive ?? true]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Company created", company_id: (result as any).insertId, company_code: companyCode };
} catch (error) {
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: `The generated company code '${companyCode}' already exists.` };
return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.message };
}
};
export const updateCompany = async (ctx: any) => {
const id = ctx.params.id;
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
try {
const [result] = await pool.execute(
`UPDATE companies SET name = ?, parent_id = ?, is_active = ? WHERE company_id = ?`,
[data.name, data.parentId || null, data.isActive, id]
);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Company not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Company updated successfully" };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const deleteCompany = async (ctx: any) => {
const id = ctx.params.id;
try {
const [result] = await pool.execute(`DELETE FROM companies WHERE company_id = ?`, [id]);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Company not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Company deleted successfully" };
} catch (error) {
let errorMessage = (error as Error).message;
if (errorMessage.includes("foreign key constraint fails")) errorMessage = "Cannot delete company. It is currently assigned to one or more employees.";
ctx.response.status = 409; ctx.response.body = { success: false, error: errorMessage };
}
};
// ==========================================
// BRANCHES MANAGEMENT
// ==========================================
export const createBranch = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
let branchCode = data.code;
if (!branchCode || branchCode.trim() === "") {
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
}
try {
const [result] = await pool.execute(
`INSERT INTO branches (company_id, branch_name, code, is_active) VALUES (?, ?, ?, ?)`,
[data.companyId, data.branchName, branchCode, data.isActive ?? true]
);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Branch created successfully", branch_id: (result as any).insertId, code: branchCode };
} catch (error) {
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409; ctx.response.body = { success: false, error: `The branch code '${branchCode}' already exists.` }; return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.message };
}
};
export const updateBranch = async (ctx: any) => {
const id = ctx.params.id; const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
try {
const [result] = await pool.execute(
`UPDATE branches SET company_id = ?, branch_name = ?, code = ?, is_active = ? WHERE branch_id = ?`,
[data.companyId, data.branchName, data.code, data.isActive, id]
);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Branch not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Branch updated successfully" };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const deleteBranch = async (ctx: any) => {
const id = ctx.params.id;
try {
const [result] = await pool.execute(`DELETE FROM branches WHERE branch_id = ?`, [id]);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Branch not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Branch deleted successfully" };
} catch (error) {
let errorMessage = (error as Error).message;
if (errorMessage.includes("foreign key constraint fails")) errorMessage = "Cannot delete branch. It is currently assigned to one or more employees.";
ctx.response.status = 409; ctx.response.body = { success: false, error: errorMessage };
}
};
// ==========================================
// DEPARTMENTS MANAGEMENT
// ==========================================
export const createDepartment = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
if (!data.companyId || !data.branchId || !data.name) { ctx.response.status = 400; ctx.response.body = { success: false, error: "companyId, branchId, and name are required." }; return; }
let deptCode = data.departmentCode || await generateNextCode("DEPARTMENT_MAIN", "DEPT-", 3);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.execute(
`INSERT INTO departments (company_id, branch_id, department_code, name, parent_id, is_active) VALUES (?, ?, ?, ?, ?, ?)`,
[data.companyId, data.branchId, deptCode, data.name, data.parentId ?? null, data.isActive ?? true]
);
const departmentId = (result as any).insertId;
if (data.managerId) {
await connection.execute(
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)`,
[departmentId, data.managerId]
);
}
await connection.commit();
ctx.response.status = 201; ctx.response.body = { success: true, message: "Department created", department_id: departmentId, department_code: deptCode };
} catch (error) {
await connection.rollback();
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: `The generated department code '${deptCode}' already exists. Please sync your database sequences.` };
return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.message };
} finally { connection.release(); }
};
export const updateDepartment = async (ctx: any) => {
const id = ctx.params.id; const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.execute(
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ?, is_active = ? WHERE department_id = ?`,
[data.companyId ?? null, data.branchId ?? null, data.name ?? null, data.parentId ?? null, data.isActive, id]
);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Department not found" }; await connection.rollback(); connection.release(); return; }
if (data.managerId) {
await connection.execute(`UPDATE department_managers SET is_current = FALSE, removed_at = NOW() WHERE department_id = ? AND is_current = TRUE`, [id]);
await connection.execute(
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)
ON DUPLICATE KEY UPDATE is_current = TRUE, removed_at = NULL`,
[id, data.managerId]
);
}
await connection.commit(); ctx.response.status = 200; ctx.response.body = { success: true, message: "Department updated successfully" };
} catch (error) {
await connection.rollback(); ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message };
} finally { connection.release(); }
};
export const deleteDepartment = async (ctx: any) => {
const id = ctx.params.id;
try {
const [result] = await pool.execute(`DELETE FROM departments WHERE department_id = ?`, [id]);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Department not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Department deleted successfully" };
} catch (error) {
let errorMessage = (error as Error).message;
if (errorMessage.includes("foreign key constraint fails")) errorMessage = "Cannot delete department. There are active assignments tied to it.";
ctx.response.status = 409; ctx.response.body = { success: false, error: errorMessage };
}
};
// ==========================================
// JOB POSITIONS MANAGEMENT
// ==========================================
export const createJob = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
if (!data.departmentId || !data.title) { ctx.response.status = 400; ctx.response.body = { success: false, error: "departmentId and title are required fields." }; return; }
let jobCode = data.jobCode || await generateNextCode("JOB_MAIN", "JOB-", 3);
try {
const [result] = await pool.execute(
`INSERT INTO job_positions (department_id, job_code, title, description, is_active) VALUES (?, ?, ?, ?, ?)`,
[data.departmentId, jobCode, data.title, data.description ?? null, data.isActive ?? true]
);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Job created", job_id: (result as any).insertId, job_code: jobCode };
} catch (error) {
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: `The generated job code '${jobCode}' already exists. Please sync your database sequences.` };
return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.message };
}
};
export const updateJob = async (ctx: any) => {
const id = ctx.params.id; const body = ctx.request.body;
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
try {
const [result] = await pool.execute(
`UPDATE job_positions SET department_id = ?, title = ?, description = ?, is_active = ? WHERE job_id = ?`,
[data.departmentId ?? null, data.title ?? null, data.description ?? null, data.isActive, id]
);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Job not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Job updated successfully" };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const deleteJob = async (ctx: any) => {
const id = ctx.params.id;
try {
const [result] = await pool.execute(`DELETE FROM job_positions WHERE job_id = ?`, [id]);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Job not found" }; return; }
ctx.response.status = 200; ctx.response.body = { success: true, message: "Job deleted successfully" };
} catch (error) {
let errorMessage = (error as Error).message;
if (errorMessage.includes("foreign key constraint fails")) errorMessage = "Cannot delete job position. It is currently linked to active employee assignments.";
ctx.response.status = 409; ctx.response.body = { success: false, error: errorMessage };
}
};

View File

@ -0,0 +1,155 @@
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
const emsPool = getDbPool("hrms_ems");
export const bulkSeedEmployees = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : [];
if (!Array.isArray(body) || body.length === 0) { ctx.response.status = 400; ctx.response.body = { success: false, message: "Provide an array of employee logs." }; return; }
const connection = await emsPool.getConnection();
let insertedCount = 0;
try {
await connection.beginTransaction();
for (const item of body) {
const { emp_code, name, designation, department, email } = item;
const nameParts = name.trim().split(" ");
const firstName = nameParts[0] || "Employee";
const lastName = nameParts.slice(1).join(" ") || "LNU";
// 1. Partner
const [partnerResult]: any = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
[firstName, lastName, `personal.${email}`, `MOCK_${emp_code}`]
);
const partnerId = partnerResult.insertId;
// 2. Department
const [deptResult]: any = await connection.execute(
`INSERT INTO departments (company_id, branch_id, department_code, name)
VALUES (1, 1, CONCAT('DEPT-', LEFT(?, 3)), ?)
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
[department || "General", department || "General"]
);
const departmentId = deptResult.insertId;
// 3. Job
const [jobResult]: any = await connection.execute(
`INSERT INTO job_positions (department_id, job_code, title)
VALUES (?, CONCAT('JOB-', LEFT(?, 3)), ?)
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
[departmentId, designation || "Trainee", designation || "Trainee"]
);
const jobId = jobResult.insertId;
// 4. Employee
const [empResult]: any = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
VALUES (?, ?, 1, 1, TRUE)
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
[emp_code, partnerId]
);
const employeeId = empResult.insertId;
// 5. Terms & Assignments (Replaces old contracts insert)
await connection.execute(
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, '2026-01-01', 90, 'ACTIVE', 100)
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
[employeeId, email]
);
await connection.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, NULL, 'HIRE', '2026-01-01', TRUE)
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id), job_id = VALUES(job_id)`,
[employeeId, departmentId, jobId]
);
insertedCount++;
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = { success: true, message: `Successfully seeded/aligned ${insertedCount} normalized employee profiles.` };
} catch (error) {
await connection.rollback();
console.error("Seeding failed, changes rolled back:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.status = 500;
ctx.response.body = { success: false, error: errorMessage };
} finally { connection.release(); }
};
export const bulkMapHierarchy = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) { ctx.response.status = 400; ctx.response.body = { success: false, message: "Provide hierarchy mapping arrays." }; return; }
const connection = await emsPool.getConnection();
let assignmentsUpdated = 0;
let departmentsUpdated = 0;
try {
await connection.beginTransaction();
// 1. Map Employees to their Managers (Updating active assignments)
for (const mapping of employeeHierarchy) {
const { emp_code, manager_code } = mapping;
if (!emp_code || !manager_code) continue;
const [result]: any = await connection.execute(
`UPDATE employee_assignments ea
JOIN employees e ON ea.employee_id = e.employee_id
JOIN employees m ON m.employee_code = ?
SET ea.reporting_to_id = m.employee_id
WHERE e.employee_code = ? AND ea.is_current = TRUE`,
[manager_code, emp_code]
);
if (result.affectedRows > 0) assignmentsUpdated++;
}
// 2. Map Departments to Managers and Parent Departments
for (const dept of departmentHierarchy) {
const { department_name, manager_code, parent_department_name } = dept;
if (!department_name) continue;
if (manager_code) {
await connection.execute(
`INSERT IGNORE INTO department_managers (department_id, employee_id, is_current)
SELECT d.department_id, m.employee_id, TRUE
FROM departments d
JOIN employees m ON m.employee_code = ?
WHERE d.name = ?`,
[manager_code, department_name]
);
}
if (parent_department_name) {
await connection.execute(
`UPDATE departments d
JOIN departments p ON p.name = ?
SET d.parent_id = p.department_id
WHERE d.name = ?`,
[parent_department_name, department_name]
);
}
departmentsUpdated++;
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = { success: true, message: "Hierarchy mapping completed successfully.", metrics: { assignmentsUpdated, departmentsProcessed: departmentsUpdated } };
} catch (error) {
await connection.rollback();
console.error("Hierarchy mapping failed:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.status = 500;
ctx.response.body = { success: false, error: errorMessage };
} finally { connection.release(); }
};

63
ems-service/routes.ts Normal file
View File

@ -0,0 +1,63 @@
import { Router } from "@oak/oak";
import {getDashboardMetrics} from "./controllers/dashboard.controller.ts"
import {
getEmployees, createEmployee, getEmployeeById, updateEmployee, deleteEmployee, getEmployeeHistory
} from "./controllers/employee.controller.ts";
import {
getCompanies, createCompany, updateCompany, deleteCompany,
getBranches, createBranch, updateBranch, deleteBranch,
getDepartments, createDepartment, updateDepartment, deleteDepartment,
getJobs, createJob, updateJob, deleteJob
} from "./controllers/lookup.controller.ts";
import {
getEmployeeContracts, createContract
} from "./controllers/contract.controller.ts";
import {
bulkSeedEmployees, bulkMapHierarchy
} from "./controllers/system.controller.ts";
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
const router = new Router();
// EMPLOYEE CORE
router.get("/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN, AppRole.MANAGER]), getEmployees);
router.get("/employees/:id", requireAuth, getEmployeeById);
router.get("/employees/:id/history", requireAuth, getEmployeeHistory);
router.post("/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createEmployee);
router.put("/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateEmployee);
router.delete("/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteEmployee);
// CONTRACT & ASSIGNMENT DOMAIN (Promotions/Transfers)
router.get("/employees/:id/contracts", requireAuth, getEmployeeContracts);
router.post("/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
// ORGANIZATIONAL LOOKUP DOMAIN
router.get("/companies", requireAuth, getCompanies);
router.post("/companies", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createCompany);
router.put("/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateCompany);
router.delete("/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteCompany);
router.get("/branches", requireAuth, getBranches);
router.post("/branches", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createBranch);
router.put("/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateBranch);
router.delete("/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteBranch);
router.get("/departments", requireAuth, getDepartments);
router.post("/departments", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createDepartment);
router.put("/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateDepartment);
router.delete("/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteDepartment);
router.get("/jobs", requireAuth, getJobs);
router.post("/jobs", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createJob);
router.put("/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateJob);
router.delete("/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteJob);
// DASHBOARD METRICS
// Example: GET /dashboard/metrics?company_id=1&branch_id=2
router.get("/dashboard/metrics", requireAuth, getDashboardMetrics);
// SYSTEM & MIGRATION DOMAIN
router.post("/system/seed-employees", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkSeedEmployees);
router.post("/system/map-hierarchy", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkMapHierarchy);
export default router;

69
ems-service/routes.ts.bkp Normal file
View File

@ -0,0 +1,69 @@
import { Router } from "@oak/oak";
import {
getEmployees,
createEmployee,
getEmployeeById,
updateEmployee,
deleteEmployee
} from "./controllers/employee.controller.ts";
import {
getCompanies,
getBranches,
getDepartments,
getJobs
} from "./controllers/lookup.controller.ts";
import {
getEmployeeContracts,
createContract
} from "./controllers/contract.controller.ts";
import {
bulkSeedEmployees,
bulkMapHierarchy
} from "./controllers/system.controller.ts"
const router = new Router();
// --- Employee Core Endpoints ---
// Retrieve all employees
router.get("/api/v1/employees", getEmployees);
// Retrieve a single employee's comprehensive profile
router.get("/api/v1/employees/:id", getEmployeeById);
// Create a new employee
router.post("/api/v1/employees", createEmployee);
// Update an existing employee
router.put("/api/v1/employees/:id", updateEmployee);
// Deactivate an employee
router.delete("/api/v1/employees/:id", deleteEmployee);
// --- Contract Endpoints ---
//Retrieve contract history for an individual employee
router.get("/api/v1/employees/:id/contracts", getEmployeeContracts);
//Terminate, transition, or execute a new employment agreement.
router.post("/api/v1/contracts", createContract);
// --- Organizational Lookup Endpoints ---
// Fetch legal entities and parent group structures.
router.get("/api/v1/companies", getCompanies);
//Fetch structural branch offices
router.get("/api/v1/branches", getBranches);
//List corporate departments and organizational chart reporting lines
router.get("/api/v1/departments", getDepartments);
//List company designations and employment titles.
router.get("/api/v1/jobs", getJobs);
router.post("/api/v1/system/seed-employees", bulkSeedEmployees);
router.post("/api/v1/system/map-hierarchy", bulkMapHierarchy);
export default router;

46
init-db/02-dummy-data.sql Normal file
View File

@ -0,0 +1,46 @@
-- Select the right database context
USE hrms_ems;
-- 1. Create the Company
INSERT INTO companies (company_id, name, parent_id)
VALUES (1, 'CliniLaunch Research', NULL);
-- 2. Create a Branch
INSERT INTO branches (branch_id, company_id, branch_name, code)
VALUES (1, 1, 'Bengaluru Main', 'BLR01');
-- 3. Define Departments
INSERT INTO departments (department_id, company_id, name, parent_id, manager_id)
VALUES
(1, 1, 'Training (PT)', NULL, NULL),
(2, 1, 'IT', NULL, NULL);
-- 4. Authorize Job Positions
INSERT INTO job_positions (job_id, company_id, title, description)
VALUES
(1, 1, 'Executive-II (PT)', 'Part-time executive role for training.'),
(2, 1, 'Trainee', 'Entry level IT trainee.');
-- 5. Create Partners (The personal identity)
INSERT INTO partners (partner_id, first_name, last_name, dob, gender, personal_email, personal_phone, profile_picture_url)
VALUES
(1, 'Blessy', 'Paul', '1995-06-15', 'Female', 'blessy.personal@example.com', '9876543210', NULL),
(2, 'Adhvaidh', 'Prasad', '1998-11-22', 'Male', 'adhvaidh.personal@example.com', '8765432109', NULL);
-- 6. Add Addresses for the Partners
INSERT INTO addresses (address_id, partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES
(1, 1, 'CURRENT', '42/A', 'Near Metro', 'MG Road', '560001', 'Bengaluru', 'Karnataka'),
(2, 2, 'PERMANENT', '15-B', 'Tech Park', 'Whitefield', '560066', 'Bengaluru', 'Karnataka');
-- 7. Employ the Partners
INSERT INTO employees (employee_id, employee_code, partner_id, company_id, branch_id, is_active)
VALUES
(1, 'CLRI18', 1, 1, 1, TRUE),
(2, 'CLRI298', 2, 1, 1, TRUE);
-- 8. Assign Contracts
INSERT INTO contracts (contract_id, employee_id, department_id, job_id, reporting_to_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES
(1, 1, 1, 1, NULL, 'Blessy.Paul@clinilaunchresearch.in', '2023-01-10', 90, 'ACTIVE', 101),
(2, 2, 2, 2, 1, 'adhvaidh.P@clinilaunchresearch.in', '2023-05-20', 180, 'ACTIVE', 102);

View File

@ -51,7 +51,6 @@ CREATE TABLE processed_daily_attendance (
CREATE TABLE attendance_regularizations (
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
branch_id INT NOT NULL,
attendance_id INT NULL, -- Internal FK to processed ledger row
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
target_date DATE NOT NULL,

View File

@ -0,0 +1,153 @@
// lms-service/controllers/admin.controller.ts
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
const db = getDbPool("hrms_lms");
// ==========================================
// LEAVE TYPES
// ==========================================
export const createLeaveType = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing body" };
return;
}
const body = await ctx.request.body.json();
const { company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days } = body;
try {
const [result]: any = await db.execute(
`INSERT INTO leave_types (company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days)
VALUES (?, ?, ?, ?, ?)`,
[company_id, name, requires_allocation ?? true, carry_over_allowed ?? false, max_carry_over_days ?? 0.0]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Leave type created", leave_type_id: result.insertId };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
export const getLeaveTypes = async (ctx: Context) => {
const params = ctx.request.url.searchParams;
const companyId = params.get("company_id");
try {
const [rows]: any = await db.execute(
`SELECT * FROM leave_types WHERE company_id = ?`,
[companyId]
);
ctx.response.status = 200;
ctx.response.body = { success: true, count: rows.length, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
// ==========================================
// POLICY RULES
// ==========================================
export const createPolicyRule = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing body" };
return;
}
const body = await ctx.request.body.json();
try {
const [result]: any = await db.execute(
`INSERT INTO leave_policy_rules
(leave_type_id, company_id, branch_id, calendar_year, yearly_allowance, max_days_per_month, max_consecutive_days, apply_sandwich_policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
body.leave_type_id,
body.company_id,
body.branch_id || null,
body.calendar_year,
body.yearly_allowance,
body.max_days_per_month || null,
body.max_consecutive_days || null,
body.apply_sandwich_policy ?? false
]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Policy rule created", rule_id: result.insertId };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
// ==========================================
// HOLIDAY CALENDAR
// ==========================================
export const createCompanyHoliday = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing body" };
return;
}
const body = await ctx.request.body.json(); // Expects an array of holiday objects for bulk insert
if (!Array.isArray(body)) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Expected an array of holidays for bulk insert." };
return;
}
try {
// Map the array of objects to a flat array for MySQL bulk insert
const values = body.map(h => [
h.company_id, h.branch_id || null, h.calendar_year, h.holiday_date, h.holiday_type || 'MANDATORY', h.holiday_name
]);
await db.query(
`INSERT INTO company_holidays (company_id, branch_id, calendar_year, holiday_date, holiday_type, holiday_name) VALUES ?`,
[values]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: `${values.length} holidays inserted successfully.` };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
// ==========================================
// WORK SETTINGS
// ==========================================
export const updateWorkSettings = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing body" };
return;
}
const body = await ctx.request.body.json();
const { company_id, branch_id, weekly_off_days, effective_from } = body;
// weekly_off_days should be an array like [0] for Sunday. MySQL JSON column accepts stringified arrays.
const offDaysJson = JSON.stringify(weekly_off_days);
try {
// Upsert logic: If settings exist for this branch/company, update them. Otherwise, insert.
await db.execute(
`INSERT INTO branch_work_settings (company_id, branch_id, weekly_off_days, effective_from)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
weekly_off_days = VALUES(weekly_off_days), effective_from = VALUES(effective_from)`,
[company_id, branch_id || null, offDaysJson, effective_from]
);
ctx.response.status = 200;
ctx.response.body = { success: true, message: "Work settings updated successfully." };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};

View File

@ -0,0 +1,140 @@
// lms-service/controllers/employee.controller.ts
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
import { AppRole } from "../../shared/auth.ts";
const db = getDbPool("hrms_lms");
/**
* EMPLOYEE: Fetch leave balances for the dashboard
* Query Params: ?year=2026 (optional, defaults to current year)
*/
export const getLeaveBalances = async (ctx: Context) => {
const user = ctx.state.user;
const params = ctx.request.url.searchParams;
// Determine target employee ID (Admins can check others, Employees can only check themselves)
let targetEmployeeId = user.employee_id;
if ((user.role === AppRole.ADMIN || user.role === AppRole.SUPER_ADMIN || user.role === AppRole.MANAGER) && params.get("employee_id")) {
targetEmployeeId = Number(params.get("employee_id"));
}
const year = Number(params.get("year")) || new Date().getFullYear();
try {
const [rows]: any = await db.execute(
`SELECT
lt.leave_type_id,
lt.name AS leave_type_name,
la.granted_days,
la.used_days,
(la.granted_days - la.used_days) AS available_balance
FROM leave_allocations la
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.employee_id = ? AND la.calendar_year = ? AND la.status = 'ACTIVE'`,
[targetEmployeeId, year]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
employee_id: targetEmployeeId,
year: year,
balances: rows
};
} catch (error) {
console.error("Failed to fetch leave balances:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
/**
* EMPLOYEE: Fetch leave application history
* Query Params: ?year=2026 (optional, defaults to current year)
*/
export const getLeaveHistory = async (ctx: Context) => {
const user = ctx.state.user;
const params = ctx.request.url.searchParams;
let targetEmployeeId = user.employee_id;
if ((user.role === AppRole.ADMIN || user.role === AppRole.SUPER_ADMIN || user.role === AppRole.MANAGER) && params.get("employee_id")) {
targetEmployeeId = Number(params.get("employee_id"));
}
const year = Number(params.get("year")) || new Date().getFullYear();
try {
const [rows]: any = await db.execute(
`SELECT
la.application_id,
lt.name AS leave_type_name,
DATE_FORMAT(la.date_from, '%Y-%m-%d') as date_from,
DATE_FORMAT(la.date_to, '%Y-%m-%d') as date_to,
la.number_of_days,
la.status,
la.reason
FROM leave_applications la
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.employee_id = ? AND YEAR(la.date_from) = ?
ORDER BY la.date_from DESC`,
[targetEmployeeId, year]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
employee_id: targetEmployeeId,
year: year,
count: rows.length,
history: rows
};
} catch (error) {
console.error("Failed to fetch leave history:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
/**
* EMPLOYEE: Fetch upcoming optional holidays
*/
export const getValidOptionalHolidays = async (ctx: Context) => {
const user = ctx.state.user;
const currentYear = new Date().getFullYear();
const today = new Date().toISOString().split('T')[0];
// FIX: Fallback to null if undefined to prevent MySQL driver crashes
const companyId = user.company_id ?? null;
const branchId = user.branch_id ?? null;
try {
// Fetch optional holidays for the employee's company/branch that are today or in the future
const [rows]: any = await db.execute(
`SELECT
holiday_id,
holiday_name,
DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
FROM company_holidays
WHERE company_id = ?
AND calendar_year = ?
AND holiday_type = 'OPTIONAL'
AND (branch_id = ? OR branch_id IS NULL)
AND holiday_date >= ?
ORDER BY holiday_date ASC`,
[companyId, currentYear, branchId, today]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
count: rows.length,
holidays: rows
};
} catch (error) {
console.error("Failed to fetch optional holidays:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};

View File

@ -0,0 +1,184 @@
// lms-service/controllers/leave.controller.ts
import { getDbPool } from "../../shared/db.ts";
import { calculateLeaveDays } from '../../shared/calendar.service.ts';
const db = getDbPool("hrms_lms");
/**
* Payload interface expected from the Frontend UI
*/
export interface LeaveApplicationPayload {
employee_id: number;
leave_type_id: number;
company_id: number;
branch_id: number; // Used for fetching specific holiday/work settings
date_from: string; // YYYY-MM-DD
date_to: string; // YYYY-MM-DD
is_half_day: boolean;
reason: string;
}
export const applyForLeave = async (ctx: any) => {
try {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing request body" };
return;
}
const payload: LeaveApplicationPayload = await ctx.request.body.json();
const currentYear = new Date(payload.date_from).getFullYear();
const currentMonth = new Date(payload.date_from).getMonth() + 1; // 1-12
// 1. Fetch Configuration & Rules
const [branchSettingsRows]: any = await db.execute(
`SELECT weekly_off_days FROM branch_work_settings WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
[payload.company_id, payload.branch_id]
);
const branchSettingsRaw = branchSettingsRows[0];
const workSettings = { weeklyOffDays: branchSettingsRaw?.weekly_off_days || [0] };
const [holidays]: any = await db.execute(
`SELECT holiday_date FROM company_holidays WHERE company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL)`,
[payload.company_id, currentYear, payload.branch_id]
);
const [policyRows]: any = await db.execute(
`SELECT max_days_per_month, apply_sandwich_policy FROM leave_policy_rules WHERE leave_type_id = ? AND company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
[payload.leave_type_id, payload.company_id, currentYear, payload.branch_id]
);
const policy = policyRows[0];
if (!policy) {
ctx.response.status = 400;
ctx.response.body = { error: "Leave policy not configured for this type/year." };
return;
}
// 2. Calculate Requested Days using Shared Service
let requestedDays = 0;
if (payload.is_half_day) {
if (payload.date_from !== payload.date_to) {
ctx.response.status = 400;
ctx.response.body = { error: "Half-day leaves must be on the same date." };
return;
}
requestedDays = 0.5;
} else {
requestedDays = calculateLeaveDays(
payload.date_from,
payload.date_to,
workSettings,
holidays,
policy.apply_sandwich_policy
);
}
if (requestedDays === 0) {
ctx.response.status = 400;
ctx.response.body = { error: "Selected dates fall entirely on weekends/holidays with no sandwich policy applied." };
return;
}
// 3. Fetch Balances & Monthly Limits
// FIX: Fetch raw balance first
const [balanceRows]: any = await db.execute(
`SELECT (granted_days - used_days) AS raw_balance FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE'`,
[payload.employee_id, payload.leave_type_id, currentYear]
);
const rawBalance = Number(balanceRows[0]?.raw_balance || 0);
// FIX: Fetch pending leaves to prevent over-application
const [pendingRows]: any = await db.execute(
`SELECT SUM(number_of_days) as pending_days FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND YEAR(date_from) = ? AND status IN ('PENDING', 'PENDING_LOP')`,
[payload.employee_id, payload.leave_type_id, currentYear]
);
const pendingDays = Number(pendingRows[0]?.pending_days || 0);
// Calculate actual available balance
const availableBalance = rawBalance - pendingDays;
// Check how many days the employee has already taken this month for this leave type
const [usageRows]: any = await db.execute(
`SELECT SUM(number_of_days) as used_this_month FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND MONTH(date_from) = ? AND YEAR(date_from) = ? AND status IN ('APPROVED', 'APPROVED_LOP', 'PENDING', 'PENDING_LOP')`,
[payload.employee_id, payload.leave_type_id, currentMonth, currentYear]
);
const usedThisMonth = Number(usageRows[0]?.used_this_month || 0);
// 4. Determine Paid vs LOP Split
let paidDays = 0;
let lopDays = 0;
const remainingMonthlyLimit = policy.max_days_per_month !== null
? Math.max(0, policy.max_days_per_month - usedThisMonth)
: requestedDays; // If no limit, they can take all requests if balance permits
const maxAllowedPaid = Math.min(availableBalance, remainingMonthlyLimit);
if (requestedDays <= maxAllowedPaid) {
paidDays = requestedDays;
} else {
paidDays = maxAllowedPaid;
lopDays = requestedDays - paidDays;
}
// 5. Fetch Manager ID via Cross-Database Query (Fixes hardcoded HTTP call)
const [managerRows]: any = await db.execute(
`SELECT c.reporting_to_id
FROM hrms_ems.employees e
JOIN hrms_ems.contracts c ON e.employee_id = c.employee_id
WHERE e.employee_id = ? AND c.status = 'ACTIVE'`,
[payload.employee_id]
);
const managerId = managerRows[0]?.reporting_to_id || null;
// 6. Execute Database Transaction for Auto-Split
const connection = await db.getConnection();
await connection.beginTransaction();
try {
const insertedIds = [];
// Insert Paid Record
if (paidDays > 0) {
const [result]: any = await connection.execute(
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?)`,
[payload.employee_id, payload.leave_type_id, payload.company_id, payload.date_from, payload.date_to, paidDays, payload.reason, managerId]
);
insertedIds.push(result.insertId);
}
// Insert LOP Record (Auto-Split)
if (lopDays > 0) {
const [result]: any = await connection.execute(
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING_LOP', ?)`,
[payload.employee_id, payload.leave_type_id, payload.company_id, payload.date_from, payload.date_to, lopDays, payload.reason, managerId]
);
insertedIds.push(result.insertId);
}
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
message: "Leave application submitted successfully.",
total_requested: requestedDays,
paid_days: paidDays,
lop_days: lopDays,
application_ids: insertedIds
};
} catch (dbError) {
await connection.rollback();
throw dbError;
} finally {
connection.release();
}
} catch (error) {
ctx.response.status = 500;
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.body = { error: "Internal Server Error", details: errorMessage };
}
};

View File

@ -0,0 +1,209 @@
// lms-service/controllers/manager.controller.ts
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
const db = getDbPool("hrms_lms");
/**
* MANAGER: Fetch all pending leave applications for subordinates
*/
export const getPendingManagerLeaves = async (ctx: Context) => {
const user = ctx.state.user; // Logged-in Manager's data from auth.ts
try {
// Cross-database join to get applicant details from EMS
const [rows]: any = await db.execute(
`SELECT
la.application_id,
la.employee_id,
la.leave_type_id,
lt.name AS leave_type,
la.date_from,
la.date_to,
la.number_of_days,
la.reason,
la.status,
e.employee_code,
p.first_name,
p.last_name
FROM leave_applications la
INNER JOIN hrms_ems.employees e ON la.employee_id = e.employee_id
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.manager_approved_by = ?
AND la.status IN ('PENDING', 'PENDING_LOP')
ORDER BY la.date_from ASC`,
[user.employee_id]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
count: rows.length,
data: rows
};
} catch (error) {
console.error("Failed to fetch manager pending leaves:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
/**
* MANAGER: Approve a leave request (Triggers Ledger DEBIT & Balance Update)
*/
export const approveLeaveManager = async (ctx: Context) => {
const applicationId = ctx.params.applicationId;
const user = ctx.state.user; // The Manager approving it
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 1. Lock the leave application row for update to prevent race conditions
const [leaveRows]: any = await connection.execute(
`SELECT * FROM leave_applications WHERE application_id = ? FOR UPDATE`,
[applicationId]
);
if (leaveRows.length === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Leave application not found." };
await connection.rollback();
return;
}
const leave = leaveRows[0];
// Security: Ensure this manager is actually assigned to this leave
if (leave.manager_approved_by !== user.employee_id) {
ctx.response.status = 403;
ctx.response.body = { success: false, message: "Access Denied: You are not the assigned manager for this leave." };
await connection.rollback();
return;
}
// Prevent double-approval
if (!['PENDING', 'PENDING_LOP'].includes(leave.status)) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: `Leave is already processed. Current status: ${leave.status}` };
await connection.rollback();
return;
}
// 2. Determine new status
const newStatus = leave.status === 'PENDING_LOP' ? 'APPROVED_LOP' : 'APPROVED';
// 3. Update Leave Application Status
await connection.execute(
`UPDATE leave_applications SET status = ? WHERE application_id = ?`,
[newStatus, applicationId]
);
// 4. Ledger & Balance Updates (Only for Paid Leaves, skip for LOP)
if (newStatus === 'APPROVED') {
const currentYear = new Date(leave.date_from).getFullYear();
const currentMonth = new Date(leave.date_from).getMonth() + 1;
// Fetch current balance to calculate Opening Balance (OB) for the ledger
const [allocRows]: any = await connection.execute(
`SELECT granted_days, used_days FROM leave_allocations
WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE' FOR UPDATE`,
[leave.employee_id, leave.leave_type_id, currentYear]
);
if (allocRows.length > 0) {
const alloc = allocRows[0];
const openingBalance = Number(alloc.granted_days) - Number(alloc.used_days);
const closingBalance = openingBalance - Number(leave.number_of_days);
// A. Write to Immutable Ledger (The Excel OB/CB replacement)
await connection.execute(
`INSERT INTO leave_ledger_transactions
(employee_id, leave_type_id, application_id, calendar_year, calendar_month, transaction_type, days, opening_balance, closing_balance, remarks)
VALUES (?, ?, ?, ?, ?, 'DEBIT', ?, ?, ?, ?)`,
[
leave.employee_id,
leave.leave_type_id,
applicationId,
currentYear,
currentMonth,
leave.number_of_days,
openingBalance,
closingBalance,
`Approved by Manager ID: ${user.employee_id}`
]
);
// B. Update the Allocations Table (Speeds up frontend dashboard loads)
await connection.execute(
`UPDATE leave_allocations SET used_days = used_days + ?
WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ?`,
[leave.number_of_days, leave.employee_id, leave.leave_type_id, currentYear]
);
}
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: "Leave approved successfully.",
new_status: newStatus
};
} catch (error) {
await connection.rollback();
console.error("Manager approval failed:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
} finally {
connection.release();
}
};
/**
* MANAGER: Reject a leave request
*/
export const rejectLeaveManager = async (ctx: Context) => {
const applicationId = ctx.params.applicationId;
const user = ctx.state.user;
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing rejection reason in body." };
return;
}
const body = await ctx.request.body.json();
const rejectionReason = body.reason;
try {
const [result]: any = await db.execute(
`UPDATE leave_applications
SET status = 'REJECTED'
WHERE application_id = ? AND manager_approved_by = ? AND status IN ('PENDING', 'PENDING_LOP')`,
[applicationId, user.employee_id]
);
if (result.affectedRows === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Leave not found, already processed, or you lack permission." };
return;
}
// Optional: You could log this rejection in an audit table or leave_remarks table here
// using the `rejectionReason` variable.
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: "Leave rejected successfully."
};
} catch (error) {
console.error("Manager rejection failed:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};

View File

@ -0,0 +1,75 @@
// lms-service/controllers/reports.controller.ts
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
const db = getDbPool("hrms_lms");
/**
* ADMIN: Generate the Excel-style OB/CB Report for a specific month
* Query: ?month=5&year=2026&company_id=1
*/
export const getLedgerReport = async (ctx: Context) => {
const params = ctx.request.url.searchParams;
const month = Number(params.get("month"));
const year = Number(params.get("year"));
const companyId = Number(params.get("company_id"));
if (!month || !year || !companyId) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing required query params: month, year, company_id" };
return;
}
try {
// This query ensures every employee is listed, even if they had 0 transactions that month.
// It fetches the closing balance of the LAST transaction BEFORE the month started (OB)
// And the closing balance of the LAST transaction UP TO the END of the month (CB).
const [rows]: any = await db.execute(
`SELECT
e.employee_id,
e.employee_code,
CONCAT(p.first_name, ' ', p.last_name) AS employee_name,
lt.name AS leave_type,
la.granted_days AS total_granted,
la.used_days AS total_used,
-- Opening Balance: Closing balance of the last transaction BEFORE the target month
COALESCE((
SELECT t_ob.closing_balance FROM leave_ledger_transactions t_ob
WHERE t_ob.employee_id = la.employee_id AND t_ob.leave_type_id = la.leave_type_id
AND (t_ob.calendar_year < ? OR (t_ob.calendar_year = ? AND t_ob.calendar_month < ?))
ORDER BY t_ob.transaction_date DESC LIMIT 1
), la.granted_days) AS opening_balance,
-- Closing Balance: Closing balance of the last transaction UP TO the END of the target month
COALESCE((
SELECT t_cb.closing_balance FROM leave_ledger_transactions t_cb
WHERE t_cb.employee_id = la.employee_id AND t_cb.leave_type_id = la.leave_type_id
AND (t_cb.calendar_year < ? OR (t_cb.calendar_year = ? AND t_cb.calendar_month <= ?))
ORDER BY t_cb.transaction_date DESC LIMIT 1
), la.granted_days) AS closing_balance
FROM leave_allocations la
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
INNER JOIN hrms_ems.employees e ON la.employee_id = e.employee_id
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
WHERE la.calendar_year = ? AND la.company_id = ?`,
[year, year, month, year, year, month, year, companyId]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
report_month: month,
report_year: year,
count: rows.length,
data: rows
};
} catch (error) {
console.error("Failed to generate OB/CB report:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};

62
lms-service/routes.ts Normal file
View File

@ -0,0 +1,62 @@
// lms-service/routes.ts
import { Router } from "@oak/oak";
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
import { applyForLeave } from "./controllers/leave.controller.ts";
import {
getPendingManagerLeaves,
approveLeaveManager,
rejectLeaveManager
} from "./controllers/manager.controller.ts";
import {
getLeaveBalances,
getLeaveHistory,
getValidOptionalHolidays
} from "./controllers/employee.controller.ts";
import {
createLeaveType,
getLeaveTypes,
createPolicyRule,
createCompanyHoliday,
updateWorkSettings
} from "./controllers/admin.controller.ts";
import { getLedgerReport } from "./controllers/reports.controller.ts";
const router = new Router();
const apiV1 = new Router();
// ==========================================
// 1. Employee Leave Actions (All Staff)
// ==========================================
// Core application endpoint (Handles auto-splits, LOP, and sandwich logic)
apiV1.post("/lms/leaves/apply", requireAuth, applyForLeave);
// Employee dashboard endpoints
apiV1.get("/lms/leaves/balances", requireAuth, getLeaveBalances);
apiV1.get("/lms/leaves/history", requireAuth, getLeaveHistory);
apiV1.get("/lms/holidays/valid-optional", requireAuth, getValidOptionalHolidays);
// ==========================================
// 2. Manager Actions
// ==========================================
apiV1.get("/lms/manager/pending", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), getPendingManagerLeaves);
apiV1.post("/lms/manager/leaves/:applicationId/approve", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), approveLeaveManager);
apiV1.post("/lms/manager/leaves/:applicationId/reject", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), rejectLeaveManager);
// ==========================================
// 3. Admin Setup & Configuration
// ==========================================
apiV1.post("/lms/config/types", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createLeaveType);
apiV1.get("/lms/config/types", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getLeaveTypes);
apiV1.post("/lms/config/rules", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createPolicyRule);
apiV1.post("/lms/config/holidays", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createCompanyHoliday);
apiV1.put("/lms/config/work-settings", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), updateWorkSettings);
// ==========================================
// 4. Admin / HR Reporting
// ==========================================
// Upcoming endpoints to build:
apiV1.get("/lms/admin/reports/ob-cb", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getLedgerReport);
// router.post("/api/v1/lms/admin/leaves/:applicationId/approve", approveLeaveAdmin);
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
export default router;

View File

@ -1,66 +0,0 @@
// packages/core/auth.ts
import { Context, Next } from "@oak/oak";
export enum AppRole {
DIRECTOR = "DIRECTOR",
HR_MANAGER = "HR_MANAGER",
MANAGER = "MANAGER",
EMPLOYEE = "EMPLOYEE",
}
// 1. Authentication & Internal Security Middleware
export const requireAuth = async (ctx: Context, next: Next) => {
const internalToken = ctx.request.headers.get("X-Internal-Token");
const expectedToken = Deno.env.get("INTERNAL_SERVICE_TOKEN");
if (!expectedToken || internalToken !== expectedToken) {
ctx.response.status = 403;
ctx.response.body = { success: false, message: "Forbidden: Direct access to microservices is not allowed." };
return;
}
const userId = ctx.request.headers.get("X-User-Id");
const userRoleHeader = ctx.request.headers.get("X-User-Role") as string;
const managedBranchesHeader = ctx.request.headers.get("X-Managed-Branches");
if (!userId || !userRoleHeader) {
ctx.response.status = 401;
ctx.response.body = { success: false, message: "Unauthorized: Missing user context" };
return;
}
// Map string header to Enum (with fallback for backward compatibility)
let userRole: AppRole;
if (userRoleHeader === "DIRECTOR" || userRoleHeader === "SUPER_ADMIN") userRole = AppRole.DIRECTOR;
else if (userRoleHeader === "HR_MANAGER" || userRoleHeader === "ADMIN") userRole = AppRole.HR_MANAGER;
else if (userRoleHeader === "MANAGER") userRole = AppRole.MANAGER;
else userRole = AppRole.EMPLOYEE;
const managedBranches = managedBranchesHeader
? managedBranchesHeader.split(",").map(Number).filter(n => !isNaN(n))
: [];
ctx.state.user = {
employee_id: Number(userId),
role: userRole,
managed_branches: managedBranches
};
await next();
};
// 2. Authorization Middleware
export const requireRole = (allowedRoles: AppRole[]) => {
return async (ctx: Context, next: Next) => {
const userRole = ctx.state.user?.role as AppRole;
if (!userRole || !allowedRoles.includes(userRole)) {
ctx.response.status = 403;
ctx.response.body = {
success: false,
message: "Access Denied: You do not have the required permissions."
};
return;
}
await next();
};
};

View File

@ -1,9 +0,0 @@
{
"name": "@shared/utils",
"version": "0.1.0",
"exports": {
"./auth": "./auth.ts",
"./internal-client": "./internal-client.ts",
"./calendar": "./calendar.service.ts"
}
}

View File

@ -1,48 +0,0 @@
// packages/core/internal-client.ts
const EMS_BASE_URL = Deno.env.get("EMS_URL") || "http://localhost:8001";
const INTERNAL_TOKEN = Deno.env.get("INTERNAL_SERVICE_TOKEN") || "";
const getInternalHeaders = () => ({
"X-Internal-Token": INTERNAL_TOKEN,
"Content-Type": "application/json"
});
/**
* Fetches the manager ID for a specific employee from EMS.
*/
export const getEmployeeManagerId = async (employeeId: number): Promise<number | null> => {
try {
const response = await fetch(`${EMS_BASE_URL}/internal/employees/${employeeId}/manager`, {
headers: getInternalHeaders()
});
if (!response.ok) return null;
const json = await response.json();
return json.data?.manager_id || null;
} catch (error) {
console.error("Failed to fetch manager from EMS:", error);
return null;
}
};
/**
* Fetches a roster of active employees from EMS.
*/
export const getEmployeeRoster = async (ids: number[] = []): Promise<any[]> => {
try {
let url = `${EMS_BASE_URL}/internal/employees/roster`;
if (ids.length > 0) {
url += `?ids=${ids.join(",")}`;
}
const response = await fetch(url, {
headers: getInternalHeaders()
});
if (!response.ok) return [];
const json = await response.json();
return json.data || [];
} catch (error) {
console.error("Failed to fetch roster from EMS:", error);
return [];
}
};

View File

@ -1,73 +0,0 @@
// services/ams/controllers/attendance.controller.ts
import * as AttendanceService from "../services/attendance.service.ts";
export const getRawLogs = async (ctx: any) => {
try {
const data = await AttendanceService.getRawLogs();
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, message: error.message };
}
};
export const processDailyAttendance = async (ctx: any) => {
try {
let body = {};
if (ctx.request.hasBody) {
body = await ctx.request.body.json();
}
const result = await AttendanceService.processDailyAttendance(body);
ctx.response.body = { success: true, message: `Successfully processed ${result.processed} logs across ${result.days} days.` };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};
export const getEmployeeSummary = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
const employeeId = Number(params.get("employee_id"));
const startDate = params.get("start_date");
const endDate = params.get("end_date");
if (!employeeId || !startDate || !endDate) throw new Error("Missing required query parameters.");
const data = await AttendanceService.getEmployeeSummary(employeeId, startDate, endDate);
ctx.response.body = { success: true, ...data };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};
export const getDailyReport = async (ctx: any) => {
try {
const targetDate = ctx.request.url.searchParams.get("date");
if (!targetDate) throw new Error("Missing required 'date' query parameter.");
const data = await AttendanceService.getDailyReport(targetDate);
ctx.response.body = { success: true, ...data };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};
export const getAdminRangeReport = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
const data = await AttendanceService.getAdminRangeReport(
params.get("from_date"), params.get("to_date"), params.get("branch_id"), params.get("department_id")
);
ctx.response.body = { success: true, ...data };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};
export const getSingleEmployeeRangeReport = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
const data = await AttendanceService.getSingleEmployeeRangeReport(
Number(params.get("employee_id")), params.get("from_date"), params.get("to_date")
);
ctx.response.body = { success: true, ...data };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,45 +0,0 @@
// services/ams/controllers/regularization.controller.ts
import * as RegService from "../services/regularization.service.ts";
const getJsonBody = async (ctx: any) => {
try {
return await ctx.request.body.json();
} catch (e) {
throw new Error("Invalid JSON body. Check Postman variables and raw JSON format.");
}
};
export const createRegularizationRequest = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await RegService.createRegularizationRequest(body);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Regularization request submitted successfully." };
} catch (error: any) {
ctx.response.status = 400;
ctx.response.body = { success: false, error: error.message };
}
};
export const getPendingRegularizations = async (ctx: any) => {
try {
const data = await RegService.getPendingRegularizations(ctx.state.user);
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const reviewRegularizationRequest = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await RegService.reviewRegularizationRequest(ctx.state.user, body);
ctx.response.body = { success: true, message: `Request has been successfully processed.` };
} catch (error: any) {
if (error.message.includes("Access Denied")) ctx.response.status = 403;
else if (error.message.includes("not found")) ctx.response.status = 404;
else ctx.response.status = 400;
ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,15 +0,0 @@
// services/ams/db.ts
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: Deno.env.get("DB_HOST") || "127.0.0.1",
port: Number(Deno.env.get("AMS_DB_PORT")) || 3307,
user: Deno.env.get("DB_USER") || "admin",
password: Deno.env.get("DB_PASSWORD") || "admin123",
database: "hrms_ams",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export default pool;

View File

@ -1,126 +0,0 @@
// services/ams/repositories/attendance.repository.ts
import pool from "../db.ts";
export const findRawLogs = async () => {
const [rows] = await pool.execute(
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
);
return rows;
};
export const findDistinctRawDates = async () => {
const [rows]: any = await pool.execute(
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
);
return rows.map((r: any) => {
if (r.raw_date instanceof Date) {
const offset = r.raw_date.getTimezoneOffset();
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split('T')[0];
}
return String(r.raw_date);
});
};
export const findRawLogsByDate = async (targetDate: string) => {
const [rows]: any = await pool.execute(
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
FROM attendance_raw_logs
WHERE DATE(attendance_time) = ?
ORDER BY employee_code, attendance_time ASC`,
[targetDate]
);
return rows;
};
export const upsertProcessedAttendance = async (data: any, conn: any) => {
await conn.execute(
`INSERT INTO processed_daily_attendance
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
check_in = VALUES(check_in),
check_out = VALUES(check_out),
worked_hours = VALUES(worked_hours),
check_in_status = VALUES(check_in_status),
final_status = VALUES(final_status)`,
[data.employeeId, data.company_id, data.branch_id, data.targetDate, data.check_in, data.check_out, data.worked_hours, data.check_in_status, data.final_status]
);
};
export const findEmployeeSummary = async (employeeId: number, startDate: string, endDate: string) => {
const [rows]: any = await pool.execute(
`SELECT
attendance_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
ORDER BY work_date DESC`,
[employeeId, startDate, endDate]
);
return rows;
};
export const findDailyAttendance = async (targetDate: string) => {
const [rows]: any = await pool.execute(
`SELECT employee_id, attendance_id,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE work_date = ?`,
[targetDate]
);
return rows;
};
export const findAttendanceByEmployeeIds = async (employeeIds: number[], fromDate: string, toDate: string) => {
if (employeeIds.length === 0) return [];
const placeholders = employeeIds.map(() => "?").join(",");
const [rows]: any = await pool.execute(
`SELECT
employee_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
ORDER BY work_date ASC`,
[fromDate, toDate, ...employeeIds]
);
return rows;
};
export const findSingleEmployeeAttendance = async (employeeId: number, fromDate: string, toDate: string) => {
const [rows]: any = await pool.execute(
`SELECT
attendance_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
worked_hours, check_in_status, final_status
FROM processed_daily_attendance
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
ORDER BY work_date ASC`,
[employeeId, fromDate, toDate]
);
return rows;
};
export const findShiftDetails = async (branchId: number, companyId: number, conn?: any) => {
const execute = conn ? conn.execute : pool.execute;
const [rows]: any = await execute(
`SELECT start_time, grace_period_minutes FROM shifts
WHERE (branch_id = ? OR branch_id IS NULL) AND company_id = ?
ORDER BY branch_id IS NULL ASC LIMIT 1`,
[branchId, companyId]
);
return rows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
};
export const updateProcessedAttendanceById = async (attendanceId: number, data: any, conn: any) => {
await conn.execute(
`UPDATE processed_daily_attendance
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
WHERE attendance_id = ?`,
[data.requested_check_in, data.requested_check_out, data.worked_hours, data.final_status, data.check_in_status, attendanceId]
);
};

View File

@ -1,42 +0,0 @@
// services/ams/repositories/regularization.repository.ts
import pool from "../db.ts";
export const insertRegularization = async (data: any) => {
await pool.execute(
`INSERT INTO attendance_regularizations
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
[data.attendance_id || null, data.employee_id, data.branch_id, data.regularization_type, data.target_date, data.requested_check_in || null, data.requested_check_out || null, data.reason]
);
};
export const findPendingRegularizations = async (branchIds: number[] | null) => {
let query = `SELECT regularization_id, employee_id, target_date, regularization_type, requested_check_in, requested_check_out, reason, status, branch_id FROM attendance_regularizations WHERE status = 'PENDING'`;
const params: any[] = [];
if (branchIds && branchIds.length > 0) {
const placeholders = branchIds.map(() => "?").join(",");
query += ` AND branch_id IN (${placeholders})`;
params.push(...branchIds);
}
query += ` ORDER BY target_date DESC`;
const [rows] = await pool.query(query, params);
return rows;
};
export const findRegularizationById = async (id: number, conn: any) => {
const [rows]: any = await conn.execute(
`SELECT regularization_id, attendance_id, branch_id, employee_id, requested_check_in, requested_check_out, status
FROM attendance_regularizations WHERE regularization_id = ?`,
[id]
);
return rows[0];
};
export const updateRegularizationStatus = async (id: number, status: string, reviewerId: number, conn: any) => {
await conn.execute(
`UPDATE attendance_regularizations SET status = ?, reviewed_by_id = ? WHERE regularization_id = ?`,
[status, reviewerId, id]
);
};

View File

@ -1,23 +0,0 @@
// services/ams/routes.ts
import { Router } from "@oak/oak";
import { requireAuth, requireRole, AppRole } from "core/auth.ts";
import * as AttCtrl from "./controllers/attendance.controller.ts";
import * as RegCtrl from "./controllers/regularization.controller.ts";
const router = new Router();
router.get("/attendance/logs", AttCtrl.getRawLogs);
router.post("/attendance/process-daily", AttCtrl.processDailyAttendance);
// Regularization Routes
router.post("/attendance/regularize", requireAuth, requireRole([AppRole.EMPLOYEE, AppRole.HR_MANAGER]), RegCtrl.createRegularizationRequest);
router.get("/attendance/regularize/pending", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), RegCtrl.getPendingRegularizations);
router.post("/attendance/regularize/review", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), RegCtrl.reviewRegularizationRequest);
router.get("/attendance/my-summary", AttCtrl.getEmployeeSummary);
router.get("/attendance/daily-report", AttCtrl.getDailyReport);
router.get("/attendance/admin-report", AttCtrl.getAdminRangeReport);
router.get("/attendance/employee-range-report", AttCtrl.getSingleEmployeeRangeReport);
export default router;

View File

@ -1,215 +0,0 @@
// services/ams/services/attendance.service.ts
import pool from "../db.ts";
import { getEmployeeRoster } from "core/internal-client.ts";
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
const getDaysArray = (start: string, end: string): string[] => {
const arr: string[] = [];
const dt = new Date(start);
const endDt = new Date(end);
while (dt <= endDt) {
arr.push(new Date(dt).toISOString().split('T')[0]);
dt.setDate(dt.getDate() + 1);
}
return arr;
};
export const getRawLogs = async () => {
return await AttendanceRepo.findRawLogs();
};
export const processDailyAttendance = async (body: any) => {
let datesToProcess: string[] = [];
if (body.work_date) {
datesToProcess.push(body.work_date);
} else if (body.start_date && body.end_date) {
datesToProcess = getDaysArray(body.start_date, body.end_date);
} else {
datesToProcess = await AttendanceRepo.findDistinctRawDates();
}
if (datesToProcess.length === 0) throw new Error("No dates found to process.");
const connection = await pool.getConnection();
let totalProcessedRecords = 0;
try {
await connection.beginTransaction();
const employeeRoster = await getEmployeeRoster();
const employeeMap = new Map(employeeRoster.map((e: any) => [e.employee_code, e]));
for (const targetDate of datesToProcess) {
const rawLogs = await AttendanceRepo.findRawLogsByDate(targetDate);
if (rawLogs.length === 0) continue;
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
if (!acc[log.employee_code]) acc[log.employee_code] = [];
acc[log.employee_code].push(log.att_time);
return acc;
}, {});
for (const empCode in groupedLogs) {
const employee = employeeMap.get(empCode);
if (!employee || !employee.is_active) continue;
const punches = groupedLogs[empCode];
const shift = await AttendanceRepo.findShiftDetails(employee.branch_id, employee.company_id, connection);
let check_in = null, check_out = null, worked_hours = 0.0;
let check_in_status = "ON_TIME", final_status = "ABSENT";
if (punches.length >= 2) {
check_in = punches[0];
check_out = punches[punches.length - 1];
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
const rawCheckInTimeStr = check_in.split(' ')[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
check_in_status = "LATE";
}
if (worked_hours >= 7.0) final_status = "FULL_DAY";
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
else final_status = "ABSENT";
} else if (punches.length === 1) {
check_in = punches[0];
check_in_status = "MISPUNCH";
final_status = "MISPUNCH";
}
await AttendanceRepo.upsertProcessedAttendance({
employeeId: employee.employee_id,
company_id: employee.company_id,
branch_id: employee.branch_id,
targetDate, check_in, check_out, worked_hours, check_in_status, final_status
}, connection);
totalProcessedRecords++;
}
}
await connection.commit();
return { processed: totalProcessedRecords, days: datesToProcess.length };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
export const getEmployeeSummary = async (employeeId: number, startDate: string, endDate: string) => {
const rows = await AttendanceRepo.findEmployeeSummary(employeeId, startDate, endDate);
const summaryMetrics = rows.reduce((acc: any, row: any) => {
acc.total_days_tracked++;
acc.total_hours_worked += Number(row.worked_hours || 0);
if (row.final_status === "FULL_DAY") acc.full_days++;
else if (row.final_status === "HALF_DAY") acc.half_days++;
else if (row.final_status === "ABSENT") acc.absences++;
else if (row.final_status === "MISPUNCH") acc.mispunches++;
if (row.check_in_status === "LATE") acc.late_arrivals++;
return acc;
}, {
total_days_tracked: 0, total_hours_worked: 0, full_days: 0, half_days: 0, absences: 0, mispunches: 0, late_arrivals: 0
});
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
return { metrics: summaryMetrics, history: rows };
};
export const getDailyReport = async (targetDate: string) => {
const attendanceRows = await AttendanceRepo.findDailyAttendance(targetDate);
const employeeRoster = await getEmployeeRoster();
const employeeMap = new Map(employeeRoster.map((e: any) => [e.employee_id, e]));
const summary = { total_active_workforce: employeeRoster.length, present: 0, late: 0, mispunches: 0, absent: 0 };
const roster = employeeRoster.map((emp: any) => {
const attendance = attendanceRows.find((a: any) => a.employee_id === emp.employee_id);
let status = "ABSENT", check_in = null, check_out = null, worked_hours = "0.00", punctuality = "N/A", attendance_id = null;
if (attendance) {
status = attendance.final_status;
check_in = attendance.check_in;
check_out = attendance.check_out;
worked_hours = attendance.worked_hours;
punctuality = attendance.check_in_status;
attendance_id = attendance.attendance_id;
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
if (status === "MISPUNCH") summary.mispunches++;
if (punctuality === "LATE") summary.late++;
} else {
summary.absent++;
}
return {
employee_id: emp.employee_id, employee_code: emp.employee_code, full_name: emp.full_name,
attendance_id, status, check_in, check_out, worked_hours, punctuality
};
});
return { date: targetDate, summary, roster };
};
export const getAdminRangeReport = async (fromDate: string, toDate: string, branchId?: string, departmentId?: string) => {
let employeeRoster = await getEmployeeRoster();
if (branchId) employeeRoster = employeeRoster.filter((e: any) => e.branch_id === Number(branchId));
if (departmentId) employeeRoster = employeeRoster.filter((e: any) => e.department_id === Number(departmentId));
if (employeeRoster.length === 0) return { summary: {}, report: [] };
const employeeIds = employeeRoster.map((e: any) => e.employee_id);
const attendanceRows = await AttendanceRepo.findAttendanceByEmployeeIds(employeeIds, fromDate, toDate);
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
if (!acc[row.employee_id]) acc[row.employee_id] = [];
acc[row.employee_id].push(row);
return acc;
}, {});
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
const report = employeeRoster.map((emp: any) => {
const logs = attendanceGrouped[emp.employee_id] || [];
const individualMetrics = logs.reduce((acc: any, log: any) => {
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
return acc;
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
return {
employee_id: emp.employee_id, employee_code: emp.employee_code,
first_name: emp.full_name.split(" ")[0], last_name: emp.full_name.split(" ").slice(1).join(" "),
branch_name: emp.branch_name, department_name: emp.department_name,
range_metrics: individualMetrics, attendance_history: logs
};
});
return { date_range: { from: fromDate, to: toDate }, global_summary: globalSummary, report };
};
export const getSingleEmployeeRangeReport = async (employeeId: number, fromDate: string, toDate: string) => {
const roster = await getEmployeeRoster([employeeId]);
const empProfile = roster[0];
if (!empProfile) throw new Error("Employee profile not found.");
const logs = await AttendanceRepo.findSingleEmployeeAttendance(employeeId, fromDate, toDate);
return {
employee: {
employee_id: empProfile.employee_id, employee_code: empProfile.employee_code,
first_name: empProfile.full_name.split(" ")[0], last_name: empProfile.full_name.split(" ").slice(1).join(" ")
},
range: { from: fromDate, to: toDate },
total_days: logs.length,
history: logs
};
};

View File

@ -1,112 +0,0 @@
// services/ams/services/regularization.service.ts
import pool from "../db.ts";
import { AppRole } from "core/auth.ts";
import { getEmployeeRoster } from "core/internal-client.ts";
import * as RegRepo from "../repositories/regularization.repository.ts";
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
export const createRegularizationRequest = async (data: any) => {
if (!data.employee_id || !data.reason || !data.regularization_type || !data.target_date) {
throw new Error("Missing required fields.");
}
const roster = await getEmployeeRoster([data.employee_id]);
if (roster.length === 0) throw new Error("Employee not found in EMS.");
const branch_id = roster[0].branch_id;
await RegRepo.insertRegularization({ ...data, branch_id });
return true;
};
export const getPendingRegularizations = async (user: any) => {
let branchIds: number[] | null = null;
if (user.role !== AppRole.DIRECTOR) {
branchIds = user.managed_branches;
if (!branchIds || branchIds.length === 0) return [];
}
const rows = await RegRepo.findPendingRegularizations(branchIds);
if (rows.length === 0) return [];
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
const roster = await getEmployeeRoster(employeeIds);
const rosterMap = new Map(roster.map((e: any) => [e.employee_id, e]));
return rows.map((req: any) => {
const emp = rosterMap.get(req.employee_id);
return {
...req,
employee_code: emp?.employee_code || "N/A",
first_name: emp?.full_name?.split(" ")[0] || "",
last_name: emp?.full_name?.split(" ").slice(1).join(" ") || "",
branch_name: emp?.branch_name || "N/A",
};
});
};
export const reviewRegularizationRequest = async (user: any, data: any) => {
if (!data.regularization_id || !["APPROVED", "REJECTED"].includes(data.action)) {
throw new Error("Invalid payload.");
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const request = await RegRepo.findRegularizationById(data.regularization_id, connection);
if (!request) throw new Error("Regularization request not found.");
if (user.role !== AppRole.DIRECTOR && !user.managed_branches.includes(request.branch_id)) {
throw new Error("Access Denied: You do not manage this branch.");
}
if (request.status !== "PENDING") throw new Error("This request has already been processed.");
await RegRepo.updateRegularizationStatus(data.regularization_id, data.action, user.employee_id, connection);
if (data.action === "APPROVED") {
let worked_hours = 0.0, final_status = "FULL_DAY", check_in_status = "ON_TIME";
const roster = await getEmployeeRoster([request.employee_id]);
const emp = roster[0];
if (emp) {
const shift = await AttendanceRepo.findShiftDetails(emp.branch_id, emp.company_id, connection);
if (request.requested_check_in && request.requested_check_out) {
const checkInMs = new Date(request.requested_check_in.replace(" ", "T")).getTime();
const checkOutMs = new Date(request.requested_check_out.replace(" ", "T")).getTime();
const effectiveCheckOutMs = checkOutMs < checkInMs ? checkOutMs + (24 * 60 * 60 * 1000) : checkOutMs;
worked_hours = Math.round(((effectiveCheckOutMs - checkInMs) / (1000 * 60 * 60)) * 100) / 100;
if (worked_hours >= 7.0) final_status = "FULL_DAY";
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
else final_status = "ABSENT";
const rawCheckInTimeStr = request.requested_check_in.split(" ")[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(":").map(Number);
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(Number);
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 + (shift.grace_period_minutes * 60);
if (punchTotalSeconds > shiftCutoffSeconds) {
check_in_status = "LATE";
}
}
}
if (request.attendance_id) {
await AttendanceRepo.updateProcessedAttendanceById(request.attendance_id, {
requested_check_in: request.requested_check_in,
requested_check_out: request.requested_check_out,
worked_hours, final_status, check_in_status
}, connection);
}
}
await connection.commit();
return true;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};

View File

@ -1,23 +0,0 @@
// services/ems/controllers/contract.controller.ts
import * as ContractService from "../services/contract.service.ts";
export const getEmployeeContracts = async (ctx: any) => {
try {
const data = await ContractService.getEmployeeContracts(Number(ctx.params.id));
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createContract = async (ctx: any) => {
try {
const body = await ctx.request.body.json();
const result = await ContractService.createContract(body);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "New assignment executed successfully. Previous assignments expired.", ...result };
} catch (error: any) {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "Invalid JSON body or internal error." };
}
};

View File

@ -1,11 +0,0 @@
// services/ems/controllers/dashboard.controller.ts
import * as DashboardService from "../services/dashboard.service.ts";
export const getDashboardMetrics = async (ctx: any) => {
try {
const data = await DashboardService.getDashboardMetrics(ctx.request.url.searchParams);
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,81 +0,0 @@
// services/ems/controllers/employee.controller.ts
import * as EmployeeService from "../services/employee.service.ts";
// Helper for parsing JSON body in Oak v17
const getJsonBody = async (ctx: any) => {
try {
// FIX: Oak v17 ctx.request.body is an object with a .json() method
return await ctx.request.body.json();
} catch (e) {
console.error("Body parse error:", e);
throw new Error("Invalid JSON body. Make sure you are sending raw JSON.");
}
};
export const getEmployees = async (ctx: any) => {
try {
const data = await EmployeeService.getEmployees(ctx.request.url.searchParams);
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const getEmployeeById = async (ctx: any) => {
try {
const data = await EmployeeService.getEmployeeById(Number(ctx.params.id));
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 404;
ctx.response.body = { success: false, error: error.message };
}
};
export const getEmployeeHistory = async (ctx: any) => {
try {
const data = await EmployeeService.getEmployeeHistory(Number(ctx.params.id));
ctx.response.body = { success: true, history: data };
} catch (error: any) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const createEmployee = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const result = await EmployeeService.createEmployee(body);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Employee created", ...result };
} catch (error: any) {
if (error.message.includes("already exists")) ctx.response.status = 409;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const updateEmployee = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await EmployeeService.updateEmployee(Number(ctx.params.id), body);
ctx.response.body = { success: true, message: "Employee updated successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("already belongs")) ctx.response.status = 409;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const deleteEmployee = async (ctx: any) => {
try {
await EmployeeService.deleteEmployee(Number(ctx.params.id));
ctx.response.body = { success: true, message: "Employee deactivated successfully" };
} catch (error: any) {
ctx.response.status = 404;
ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,20 +0,0 @@
// services/ems/controllers/internal.controller.ts
import * as InternalService from "../services/internal.service.ts";
export const getInternalEmployeeRoster = async (ctx: any) => {
try {
const data = await InternalService.getRoster(ctx.request.url.searchParams.get("ids"));
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const getInternalManager = async (ctx: any) => {
try {
const data = await InternalService.getManager(Number(ctx.params.id));
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 404; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,215 +0,0 @@
// services/ems/controllers/lookup.controller.ts
import * as LookupService from "../services/lookup.service.ts";
// Helper for parsing JSON body in Oak v17
const getJsonBody = async (ctx: any) => {
try {
// FIX: Oak v17 ctx.request.body is an object with a .json() method
return await ctx.request.body.json();
} catch (e) {
console.error("Body parse error:", e);
throw new Error("Invalid JSON body. Make sure you are sending raw JSON.");
}
};
// ==========================================
// COMPANIES
// ==========================================
export const getCompanies = async (ctx: any) => {
try {
const data = await LookupService.getCompanies(ctx.request.url.searchParams);
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createCompany = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const result = await LookupService.createCompany(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Company created", ...result };
} catch (error: any) {
if (error.message.includes("already exists")) ctx.response.status = 409;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const updateCompany = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await LookupService.updateCompany(Number(ctx.params.id), body);
ctx.response.body = { success: true, message: "Company updated successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const deleteCompany = async (ctx: any) => {
try {
await LookupService.deleteCompany(Number(ctx.params.id));
ctx.response.body = { success: true, message: "Company deleted successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
// ==========================================
// BRANCHES
// ==========================================
export const getBranches = async (ctx: any) => {
try {
const data = await LookupService.getBranches(ctx.request.url.searchParams);
ctx.response.body = { success: true, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createBranch = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const result = await LookupService.createBranch(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Branch created successfully", ...result };
} catch (error: any) {
if (error.message.includes("already exists")) ctx.response.status = 409;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const updateBranch = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await LookupService.updateBranch(Number(ctx.params.id), body);
ctx.response.body = { success: true, message: "Branch updated successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const deleteBranch = async (ctx: any) => {
try {
await LookupService.deleteBranch(Number(ctx.params.id));
ctx.response.body = { success: true, message: "Branch deleted successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
// ==========================================
// DEPARTMENTS
// ==========================================
export const getDepartments = async (ctx: any) => {
try {
const data = await LookupService.getDepartments(ctx.request.url.searchParams);
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createDepartment = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const result = await LookupService.createDepartment(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Department created", ...result };
} catch (error: any) {
if (error.message.includes("already exists")) ctx.response.status = 409;
else if (error.message.includes("required")) ctx.response.status = 400;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const updateDepartment = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await LookupService.updateDepartment(Number(ctx.params.id), body);
ctx.response.body = { success: true, message: "Department updated successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const deleteDepartment = async (ctx: any) => {
try {
await LookupService.deleteDepartment(Number(ctx.params.id));
ctx.response.body = { success: true, message: "Department deleted successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
// ==========================================
// JOBS
// ==========================================
export const getJobs = async (ctx: any) => {
try {
const data = await LookupService.getJobs(ctx.request.url.searchParams);
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createJob = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const result = await LookupService.createJob(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Job created", ...result };
} catch (error: any) {
if (error.message.includes("already exists")) ctx.response.status = 409;
else if (error.message.includes("required")) ctx.response.status = 400;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const updateJob = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await LookupService.updateJob(Number(ctx.params.id), body);
ctx.response.body = { success: true, message: "Job updated successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};
export const deleteJob = async (ctx: any) => {
try {
await LookupService.deleteJob(Number(ctx.params.id));
ctx.response.body = { success: true, message: "Job deleted successfully" };
} catch (error: any) {
if (error.message.includes("not found")) ctx.response.status = 404;
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
else ctx.response.status = 500;
ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,22 +0,0 @@
// services/ems/controllers/system.controller.ts
import * as SystemService from "../services/system.service.ts";
export const bulkSeedEmployees = async (ctx: any) => {
try {
const body = await ctx.request.body.json();
const result = await SystemService.bulkSeedEmployees(body);
ctx.response.body = { success: true, message: `Successfully seeded/aligned ${result.count} normalized employee profiles.` };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body or internal error." };
}
};
export const bulkMapHierarchy = async (ctx: any) => {
try {
const body = await ctx.request.body.json();
const metrics = await SystemService.bulkMapHierarchy(body);
ctx.response.body = { success: true, message: "Hierarchy mapping completed successfully.", metrics };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body or internal error." };
}
};

View File

@ -1,15 +0,0 @@
// services/ems/db.ts
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: Deno.env.get("DB_HOST") || "127.0.0.1",
port: Number(Deno.env.get("EMS_DB_PORT")) || 3306,
user: Deno.env.get("DB_USER") || "admin",
password: Deno.env.get("DB_PASSWORD") || "admin123",
database: "hrms_ems",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export default pool;

View File

@ -1,35 +0,0 @@
// services/ems/repositories/contract.repository.ts
import pool from "../db.ts";
export const findContractsByEmployeeId = async (employeeId: number) => {
const [rows] = await pool.query(
`SELECT
et.term_id, et.work_email, et.date_joining, et.probation_days, et.status, et.salary_structure_id,
ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
d.name AS department, j.title AS designation
FROM employment_terms et
JOIN employee_assignments ea ON et.employee_id = ea.employee_id
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE et.employee_id = ?
ORDER BY et.date_joining DESC, ea.effective_from DESC`,
[employeeId]
);
return rows;
};
export const expireCurrentAssignment = async (employeeId: number, conn: any) => {
await conn.execute(
`UPDATE employee_assignments SET is_current = FALSE, effective_to = CURDATE() WHERE employee_id = ? AND is_current = TRUE`,
[employeeId]
);
};
export const insertNewAssignment = async (data: any, conn: any) => {
const [result] = await conn.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, ?, ?, CURDATE(), TRUE)`,
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.changeReason || 'TRANSFER']
);
return result.insertId;
};

View File

@ -1,47 +0,0 @@
// services/ems/repositories/dashboard.repository.ts
import pool from "../db.ts";
const buildFilters = (filters: any) => {
let clause = " WHERE 1=1";
const values: any[] = [];
if (filters.company_id) { clause += " AND company_id = ?"; values.push(filters.company_id); }
if (filters.branch_id) { clause += " AND branch_id = ?"; values.push(filters.branch_id); }
return { clause, values };
};
export const countCompanies = async () => {
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM companies`);
return rows[0].count;
};
export const countBranches = async (filters: any) => {
const { clause, values } = buildFilters(filters);
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM branches ${clause}`, values);
return rows[0].count;
};
export const countDepartments = async (filters: any) => {
const { clause, values } = buildFilters(filters);
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM departments ${clause}`, values);
return rows[0].count;
};
export const countJobs = async (filters: any) => {
let clause = " WHERE 1=1";
const values: any[] = [];
if (filters.company_id) { clause += " AND d.company_id = ?"; values.push(filters.company_id); }
if (filters.branch_id) { clause += " AND d.branch_id = ?"; values.push(filters.branch_id); }
if (filters.department_id) { clause += " AND j.department_id = ?"; values.push(filters.department_id); }
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM job_positions j JOIN departments d ON j.department_id = d.department_id ${clause}`, values);
return rows[0].count;
};
export const countEmployees = async (filters: any) => {
let clause = " WHERE e.is_active = TRUE";
const values: any[] = [];
if (filters.company_id) { clause += " AND e.company_id = ?"; values.push(filters.company_id); }
if (filters.branch_id) { clause += " AND e.branch_id = ?"; values.push(filters.branch_id); }
if (filters.department_id) { clause += " AND ea.department_id = ?"; values.push(filters.department_id); }
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM employees e JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE ${clause}`, values);
return rows[0].count;
};

View File

@ -1,148 +0,0 @@
// services/ems/repositories/employee.repository.ts
import pool from "../db.ts";
export const findEmployees = async (filters: any) => {
let query = `SELECT e.employee_id, e.employee_code, e.is_active,
CONCAT(p.first_name, ' ', p.last_name) AS full_name,
et.work_email, j.title AS job_name, d.name AS department_name,
b.branch_name, c.name AS company_name
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN companies c ON e.company_id = c.company_id
JOIN branches b ON e.branch_id = b.branch_id
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE 1=1`;
const values: any[] = [];
if (filters.company_id) { query += ` AND e.company_id = ?`; values.push(filters.company_id); }
if (filters.branch_id) { query += ` AND e.branch_id = ?`; values.push(filters.branch_id); }
if (filters.department_id) { query += ` AND ea.department_id = ?`; values.push(filters.department_id); }
if (filters.is_active !== undefined) { query += ` AND e.is_active = ?`; values.push(filters.is_active); }
const [rows] = await pool.query(query, values);
return rows;
};
export const findEmployeeById = async (id: number) => {
const [empRows] = await pool.query(
`SELECT e.employee_id, e.employee_code, e.is_active, p.first_name, p.last_name, p.dob, p.gender,
p.personal_email, p.personal_phone, et.work_email, et.date_joining, et.probation_days,
et.status AS contract_status, et.salary_structure_id, d.name AS department, d.department_id,
j.title AS designation, j.job_id, mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name,
mgr_e.employee_code AS manager_employee_code
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
WHERE e.employee_id = ?`,
[id]
);
return (empRows as any[])[0];
};
export const findAddressesByPartnerId = async (partnerId: number) => {
const [rows] = await pool.query(
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
FROM addresses WHERE partner_id = ?`,
[partnerId]
);
return rows;
};
export const findEmployeeHistory = async (id: number) => {
const [rows] = await pool.query(
`SELECT ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
d.name AS department, j.title AS job_title,
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name
FROM employee_assignments ea
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
WHERE ea.employee_id = ?
ORDER BY ea.effective_from DESC`,
[id]
);
return rows;
};
export const findPartnerIdByEmployeeId = async (employeeId: number) => {
const [rows] = await pool.query(`SELECT partner_id FROM employees WHERE employee_id = ?`, [employeeId]);
return (rows as any[])[0]?.partner_id;
};
export const insertPartner = async (data: any, conn: any) => {
const [result] = await conn.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) VALUES (?, ?, ?, ?, ?, ?)`,
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone]
);
return result.insertId;
};
export const upsertAddress = async (partnerId: number, data: any, conn: any) => {
await conn.execute(
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
door_number = VALUES(door_number), landmark = VALUES(landmark), address_line = VALUES(address_line),
pincode = VALUES(pincode), district = VALUES(district), state = VALUES(state)`,
[partnerId, data.type, data.doorNumber, data.landmark, data.line, data.pincode, data.district, data.state]
);
};
export const insertEmployee = async (data: any, conn: any) => {
const [result] = await conn.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active) VALUES (?, ?, ?, ?, ?)`,
[data.empCode, data.partnerId, data.companyId, data.branchId, true]
);
return result.insertId;
};
export const insertEmploymentTerms = async (data: any, conn: any) => {
await conn.execute(
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, ?, ?, 'ACTIVE', ?)`,
[data.employeeId, data.work_email, data.dateJoining, data.probationDays, data.salaryStructureId]
);
};
export const insertEmployeeAssignment = async (data: any, conn: any) => {
await conn.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, ?, 'HIRE', ?, TRUE)`,
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.dateJoining]
);
};
export const updatePartner = async (partnerId: number, data: any, conn: any) => {
await conn.execute(
`UPDATE partners SET first_name = ?, last_name = ?, dob = ?, gender = ?, personal_email = ?, personal_phone = ? WHERE partner_id = ?`,
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone, partnerId]
);
};
export const updateEmploymentTerms = async (employeeId: number, workEmail: string, conn: any) => {
await conn.execute(
`UPDATE employment_terms SET work_email = ? WHERE employee_id = ? AND status = 'ACTIVE'`,
[workEmail, employeeId]
);
};
export const updateEmployeeAssignment = async (employeeId: number, data: any, conn: any) => {
await conn.execute(
`UPDATE employee_assignments SET department_id = ?, job_id = ?, reporting_to_id = ? WHERE employee_id = ? AND is_current = TRUE`,
[data.departmentId, data.jobId, data.reportingToId || null, employeeId]
);
};
export const deactivateEmployee = async (id: number) => {
const [result] = await pool.execute(`UPDATE employees SET is_active = false WHERE employee_id = ?`, [id]);
return result.affectedRows > 0;
};

View File

@ -1,37 +0,0 @@
// services/ems/repositories/internal.repository.ts
import pool from "../db.ts";
export const findInternalRoster = async (ids?: number[]) => {
let query = `
SELECT e.employee_id, e.employee_code, e.is_active, CONCAT(p.first_name, ' ', p.last_name) AS full_name,
b.branch_id, b.branch_name, c.company_id, c.name AS company_name,
d.name AS department_name, d.department_id, j.title AS job_title
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN companies c ON e.company_id = c.company_id
JOIN branches b ON e.branch_id = b.branch_id
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE e.is_active = TRUE`;
const values: any[] = [];
if (ids && ids.length > 0) {
const placeholders = ids.map(() => "?").join(",");
query += ` AND e.employee_id IN (${placeholders})`;
values.push(...ids);
}
const [rows] = await pool.query(query, values);
return rows;
};
export const findInternalManager = async (employeeId: number) => {
const [rows] = await pool.query(
`SELECT ea.reporting_to_id AS manager_id
FROM employee_assignments ea
WHERE ea.employee_id = ? AND ea.is_current = TRUE`,
[employeeId]
);
return (rows as any[])[0];
};

View File

@ -1,175 +0,0 @@
// services/ems/repositories/lookup.repository.ts
import pool from "../db.ts";
// ==========================================
// COMPANIES
// ==========================================
export const findCompanies = async (filters: any) => {
let query = "SELECT company_id, company_code, name, is_active FROM companies WHERE 1=1";
const values: any[] = [];
if (filters.is_active !== undefined) { query += " AND is_active = ?"; values.push(filters.is_active); }
const [rows] = await pool.query(query, values);
return rows;
};
export const insertCompany = async (data: any) => {
const [result] = await pool.execute(
`INSERT INTO companies (company_code, name, parent_id, is_active) VALUES (?, ?, ?, ?)`,
[data.company_code, data.name, data.parent_id, data.is_active]
);
return result;
};
export const updateCompanyById = async (id: number, data: any) => {
const [result] = await pool.execute(
`UPDATE companies SET name = ?, parent_id = ?, is_active = ? WHERE company_id = ?`,
[data.name, data.parent_id, data.is_active, id]
);
return result;
};
export const deleteCompanyById = async (id: number) => {
const [result] = await pool.execute(`DELETE FROM companies WHERE company_id = ?`, [id]);
return result;
};
// ==========================================
// BRANCHES
// ==========================================
export const findBranches = async (filters: any) => {
let query = `SELECT b.branch_id, b.code, b.branch_name, b.is_active, c.company_code, c.name AS company_name
FROM branches b JOIN companies c ON b.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
if (filters.company_id) { query += " AND b.company_id = ?"; values.push(filters.company_id); }
if (filters.is_active !== undefined) { query += " AND b.is_active = ?"; values.push(filters.is_active); }
const [rows] = await pool.query(query, values);
return rows;
};
export const insertBranch = async (data: any) => {
const [result] = await pool.execute(
`INSERT INTO branches (company_id, branch_name, code, is_active) VALUES (?, ?, ?, ?)`,
[data.company_id, data.branch_name, data.code, data.is_active]
);
return result;
};
export const updateBranchById = async (id: number, data: any) => {
const [result] = await pool.execute(
`UPDATE branches SET company_id = ?, branch_name = ?, code = ?, is_active = ? WHERE branch_id = ?`,
[data.company_id, data.branch_name, data.code, data.is_active, id]
);
return result;
};
export const deleteBranchById = async (id: number) => {
const [result] = await pool.execute(`DELETE FROM branches WHERE branch_id = ?`, [id]);
return result;
};
// ==========================================
// DEPARTMENTS
// ==========================================
export const findDepartments = async (filters: any, sort: { column: string; order: string }) => {
let query = `SELECT d.department_id, d.department_code, d.name AS department_name, d.is_active,
b.branch_name, c.name AS company_name,
(SELECT GROUP_CONCAT(CONCAT(p.first_name, ' ', p.last_name) SEPARATOR ', ')
FROM department_managers dm
JOIN employees e ON dm.employee_id = e.employee_id
JOIN partners p ON e.partner_id = p.partner_id
WHERE dm.department_id = d.department_id AND dm.is_current = TRUE) AS managers
FROM departments d
JOIN branches b ON d.branch_id = b.branch_id
JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
if (filters.company_id) { query += " AND d.company_id = ?"; values.push(filters.company_id); }
if (filters.branch_id) { query += " AND d.branch_id = ?"; values.push(filters.branch_id); }
if (filters.is_active !== undefined) { query += " AND d.is_active = ?"; values.push(filters.is_active); }
query += ` ORDER BY ${sort.column} ${sort.order}`;
const [rows] = await pool.query(query, values);
return rows;
};
export const insertDepartment = async (data: any, conn: any) => {
const [result]: any = await conn.execute(
`INSERT INTO departments (company_id, branch_id, department_code, name, parent_id, is_active) VALUES (?, ?, ?, ?, ?, ?)`,
[data.company_id, data.branch_id, data.department_code, data.name, data.parent_id, data.is_active]
);
return result.insertId;
};
export const insertDepartmentManager = async (deptId: number, empId: number, conn: any) => {
await conn.execute(
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)`,
[deptId, empId]
);
};
export const updateDepartmentById = async (id: number, data: any, conn: any) => {
const [result] = await conn.execute(
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ?, is_active = ? WHERE department_id = ?`,
[data.company_id, data.branch_id, data.name, data.parent_id, data.is_active, id]
);
return result;
};
export const expireDepartmentManagers = async (deptId: number, conn: any) => {
await conn.execute(`UPDATE department_managers SET is_current = FALSE, removed_at = NOW() WHERE department_id = ? AND is_current = TRUE`, [deptId]);
};
export const upsertDepartmentManager = async (deptId: number, empId: number, conn: any) => {
await conn.execute(
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)
ON DUPLICATE KEY UPDATE is_current = TRUE, removed_at = NULL`,
[deptId, empId]
);
};
export const deleteDepartmentById = async (id: number) => {
const [result] = await pool.execute(`DELETE FROM departments WHERE department_id = ?`, [id]);
return result;
};
// ==========================================
// JOBS
// ==========================================
export const findJobs = async (filters: any, sort: { column: string; order: string }) => {
let query = `SELECT j.job_id, j.job_code, j.title AS job_name, j.is_active,
d.name AS department_name, b.branch_name, c.name AS company_name
FROM job_positions j
JOIN departments d ON j.department_id = d.department_id
JOIN branches b ON d.branch_id = b.branch_id
JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
if (filters.company_id) { query += " AND d.company_id = ?"; values.push(filters.company_id); }
if (filters.branch_id) { query += " AND d.branch_id = ?"; values.push(filters.branch_id); }
if (filters.department_id) { query += " AND j.department_id = ?"; values.push(filters.department_id); }
query += ` ORDER BY ${sort.column} ${sort.order}`;
const [rows] = await pool.query(query, values);
return rows;
};
export const insertJob = async (data: any) => {
const [result] = await pool.execute(
`INSERT INTO job_positions (department_id, job_code, title, description, is_active) VALUES (?, ?, ?, ?, ?)`,
[data.department_id, data.job_code, data.title, data.description, data.is_active]
);
return result;
};
export const updateJobById = async (id: number, data: any) => {
const [result] = await pool.execute(
`UPDATE job_positions SET department_id = ?, title = ?, description = ?, is_active = ? WHERE job_id = ?`,
[data.department_id, data.title, data.description, data.is_active, id]
);
return result;
};
export const deleteJobById = async (id: number) => {
const [result] = await pool.execute(`DELETE FROM job_positions WHERE job_id = ?`, [id]);
return result;
};

View File

@ -1,93 +0,0 @@
// services/ems/repositories/system.repository.ts
import pool from "../db.ts";
export const upsertPartner = async (data: any, conn: any) => {
const [result]: any = await conn.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
[data.firstName, data.lastName, data.email, data.phone]
);
return result.insertId;
};
export const upsertDepartment = async (name: string, conn: any) => {
const [result]: any = await conn.execute(
`INSERT INTO departments (company_id, branch_id, department_code, name)
VALUES (1, 1, CONCAT('DEPT-', LEFT(?, 3)), ?)
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
[name, name]
);
return result.insertId;
};
export const upsertJob = async (deptId: number, title: string, conn: any) => {
const [result]: any = await conn.execute(
`INSERT INTO job_positions (department_id, job_code, title)
VALUES (?, CONCAT('JOB-', LEFT(?, 3)), ?)
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
[deptId, title, title]
);
return result.insertId;
};
export const upsertEmployee = async (empCode: string, partnerId: number, conn: any) => {
const [result]: any = await conn.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
VALUES (?, ?, 1, 1, TRUE)
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
[empCode, partnerId]
);
return result.insertId;
};
export const upsertEmploymentTerms = async (employeeId: number, email: string, conn: any) => {
await conn.execute(
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, '2026-01-01', 90, 'ACTIVE', 100)
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
[employeeId, email]
);
};
export const upsertAssignment = async (employeeId: number, deptId: number, jobId: number, conn: any) => {
await conn.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, NULL, 'HIRE', '2026-01-01', TRUE)
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id), job_id = VALUES(job_id)`,
[employeeId, deptId, jobId]
);
};
export const updateAssignmentManager = async (empCode: string, managerCode: string, conn: any) => {
const [result]: any = await conn.execute(
`UPDATE employee_assignments ea
JOIN employees e ON ea.employee_id = e.employee_id
JOIN employees m ON m.employee_code = ?
SET ea.reporting_to_id = m.employee_id
WHERE e.employee_code = ? AND ea.is_current = TRUE`,
[managerCode, empCode]
);
return result.affectedRows;
};
export const upsertDepartmentManager = async (deptName: string, managerCode: string, conn: any) => {
await conn.execute(
`INSERT IGNORE INTO department_managers (department_id, employee_id, is_current)
SELECT d.department_id, m.employee_id, TRUE
FROM departments d
JOIN employees m ON m.employee_code = ?
WHERE d.name = ?`,
[managerCode, deptName]
);
};
export const updateDepartmentParent = async (deptName: string, parentName: string, conn: any) => {
await conn.execute(
`UPDATE departments d
JOIN departments p ON p.name = ?
SET d.parent_id = p.department_id
WHERE d.name = ?`,
[parentName, deptName]
);
};

View File

@ -1,58 +0,0 @@
// services/ems/routes.ts
import { Router } from "@oak/oak";
import { requireAuth, requireRole, AppRole } from "core/auth.ts";
import * as EmpCtrl from "./controllers/employee.controller.ts";
import * as LookupCtrl from "./controllers/lookup.controller.ts";
import * as ContractCtrl from "./controllers/contract.controller.ts";
import * as DashboardCtrl from "./controllers/dashboard.controller.ts";
import * as InternalCtrl from "./controllers/internal.controller.ts";
import * as SystemCtrl from "./controllers/system.controller.ts";
const router = new Router();
// INTERNAL API
router.get("/internal/employees/roster", InternalCtrl.getInternalEmployeeRoster);
router.get("/internal/employees/:id/manager", InternalCtrl.getInternalManager);
// EMPLOYEE CORE
router.get("/employees", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), EmpCtrl.getEmployees);
router.get("/employees/:id", requireAuth, EmpCtrl.getEmployeeById);
router.get("/employees/:id/history", requireAuth, EmpCtrl.getEmployeeHistory);
router.post("/employees", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), EmpCtrl.createEmployee);
router.put("/employees/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), EmpCtrl.updateEmployee);
router.delete("/employees/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), EmpCtrl.deleteEmployee);
// CONTRACT & ASSIGNMENT DOMAIN
router.get("/employees/:id/contracts", requireAuth, ContractCtrl.getEmployeeContracts);
router.post("/contracts", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), ContractCtrl.createContract);
// ORGANIZATIONAL LOOKUP DOMAIN
router.get("/companies", requireAuth, LookupCtrl.getCompanies);
router.post("/companies", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createCompany);
router.put("/companies/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateCompany);
router.delete("/companies/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteCompany);
router.get("/branches", requireAuth, LookupCtrl.getBranches);
router.post("/branches", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createBranch);
router.put("/branches/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateBranch);
router.delete("/branches/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteBranch);
router.get("/departments", requireAuth, LookupCtrl.getDepartments);
router.post("/departments", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createDepartment);
router.put("/departments/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateDepartment);
router.delete("/departments/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteDepartment);
router.get("/jobs", requireAuth, LookupCtrl.getJobs);
router.post("/jobs", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createJob);
router.put("/jobs/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateJob);
router.delete("/jobs/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteJob);
// DASHBOARD METRICS
router.get("/dashboard/metrics", requireAuth, DashboardCtrl.getDashboardMetrics);
// SYSTEM & MIGRATION DOMAIN
router.post("/system/seed-employees", requireAuth, requireRole([AppRole.DIRECTOR]), SystemCtrl.bulkSeedEmployees);
router.post("/system/map-hierarchy", requireAuth, requireRole([AppRole.DIRECTOR]), SystemCtrl.bulkMapHierarchy);
export default router;

View File

@ -1,23 +0,0 @@
// services/ems/services/contract.service.ts
import pool from "../db.ts";
import * as ContractRepo from "../repositories/contract.repository.ts";
export const getEmployeeContracts = async (employeeId: number) => {
return await ContractRepo.findContractsByEmployeeId(employeeId);
};
export const createContract = async (data: any) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await ContractRepo.expireCurrentAssignment(data.employeeId, connection);
const assignmentId = await ContractRepo.insertNewAssignment(data, connection);
await connection.commit();
return { assignment_id: assignmentId };
} catch (error: any) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};

View File

@ -1,19 +0,0 @@
// services/ems/services/dashboard.service.ts
import * as DashboardRepo from "../repositories/dashboard.repository.ts";
export const getDashboardMetrics = async (params: any) => {
const filters: any = {};
if (params.get('company_id')) filters.company_id = params.get('company_id');
if (params.get('branch_id')) filters.branch_id = params.get('branch_id');
if (params.get('department_id')) filters.department_id = params.get('department_id');
const [total_companies, total_branches, total_departments, total_jobs, total_employees] = await Promise.all([
DashboardRepo.countCompanies(),
DashboardRepo.countBranches(filters),
DashboardRepo.countDepartments(filters),
DashboardRepo.countJobs(filters),
DashboardRepo.countEmployees(filters)
]);
return { total_companies, total_branches, total_departments, total_jobs, total_employees };
};

View File

@ -1,107 +0,0 @@
// services/ems/services/employee.service.ts
import pool from "../db.ts";
import { generateNextCode } from "../sequence.ts";
import * as EmpRepo from "../repositories/employee.repository.ts";
export const getEmployees = async (params: any) => {
const filters: any = {};
if (params.get('company_id')) filters.company_id = params.get('company_id');
if (params.get('branch_id')) filters.branch_id = params.get('branch_id');
if (params.get('department_id')) filters.department_id = params.get('department_id');
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
return await EmpRepo.findEmployees(filters);
};
export const getEmployeeById = async (id: number) => {
const employee = await EmpRepo.findEmployeeById(id);
if (!employee) throw new Error("Employee not found");
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
const addresses = await EmpRepo.findAddressesByPartnerId(partnerId);
return {
employee_id: employee.employee_id,
employee_code: employee.employee_code,
is_active: employee.is_active,
first_name: employee.first_name,
last_name: employee.last_name,
dob: employee.dob,
gender: employee.gender,
personal_email: employee.personal_email,
personal_phone: employee.personal_phone,
contract: {
work_email: employee.work_email,
date_joining: employee.date_joining,
probation_days: employee.probation_days,
status: employee.contract_status,
salary_structure_id: employee.salary_structure_id,
department: employee.department,
department_id: employee.department_id,
designation: employee.designation,
job_id: employee.job_id,
manager: employee.manager_employee_code ? {
employee_code: employee.manager_employee_code,
first_name: employee.manager_first_name,
last_name: employee.manager_last_name
} : null,
},
addresses: addresses,
};
};
export const getEmployeeHistory = async (id: number) => {
return await EmpRepo.findEmployeeHistory(id);
};
export const createEmployee = async (data: any) => {
const empCode = data.employeeCode || await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const partnerId = await EmpRepo.insertPartner(data, connection);
if (data.address) await EmpRepo.upsertAddress(partnerId, data.address, connection);
const employeeId = await EmpRepo.insertEmployee({ ...data, empCode, partnerId }, connection);
await EmpRepo.insertEmploymentTerms({ ...data, employeeId }, connection);
await EmpRepo.insertEmployeeAssignment({ ...data, employeeId }, connection);
await connection.commit();
return { employee_id: employeeId, employee_code: empCode };
} catch (error: any) {
await connection.rollback();
if (error.code === 'ER_DUP_ENTRY') throw new Error("Employee Code, Email, or Phone already exists.");
throw error;
} finally {
connection.release();
}
};
export const updateEmployee = async (id: number, data: any) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
if (!partnerId) throw new Error("Employee not found");
await EmpRepo.updatePartner(partnerId, data, connection);
if (data.address) await EmpRepo.upsertAddress(partnerId, data.address, connection);
if (data.work_email) await EmpRepo.updateEmploymentTerms(id, data.work_email, connection);
if (data.departmentId || data.jobId || data.reportingToId) await EmpRepo.updateEmployeeAssignment(id, data, connection);
await connection.commit();
} catch (error: any) {
await connection.rollback();
if (error.code === 'ER_DUP_ENTRY') throw new Error("Email or Phone already belongs to another employee.");
throw error;
} finally {
connection.release();
}
};
export const deleteEmployee = async (id: number) => {
const success = await EmpRepo.deactivateEmployee(id);
if (!success) throw new Error("Employee not found");
return true;
};

View File

@ -1,13 +0,0 @@
// services/ems/services/internal.service.ts
import * as InternalRepo from "../repositories/internal.repository.ts";
export const getRoster = async (idsParam?: string) => {
const ids = idsParam ? idsParam.split(",").map(Number) : undefined;
return await InternalRepo.findInternalRoster(ids);
};
export const getManager = async (employeeId: number) => {
const data = await InternalRepo.findInternalManager(employeeId);
if (!data) throw new Error("Employee not found");
return data;
};

View File

@ -1,261 +0,0 @@
// services/ems/services/lookup.service.ts
import pool from "../db.ts";
import { generateNextCode } from "../sequence.ts";
import * as LookupRepo from "../repositories/lookup.repository.ts";
// ==========================================
// COMPANIES
// ==========================================
export const getCompanies = async (params: any) => {
const filters: any = {};
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
return await LookupRepo.findCompanies(filters);
};
export const createCompany = async (data: any) => {
const companyCode = data.companyCode || await generateNextCode("COMPANY_MAIN", "CO-", 3);
try {
const result = await LookupRepo.insertCompany({
company_code: companyCode,
name: data.name,
parent_id: data.parentId || null,
is_active: data.isActive ?? true
});
return { company_id: result.insertId, company_code: companyCode };
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Company code '${companyCode}' already exists.`);
throw error;
}
};
export const updateCompany = async (id: number, data: any) => {
const result = await LookupRepo.updateCompanyById(id, {
name: data.name,
parent_id: data.parentId || null,
is_active: data.isActive
});
if (result.affectedRows === 0) throw new Error("Company not found");
return true;
};
export const deleteCompany = async (id: number) => {
try {
const result = await LookupRepo.deleteCompanyById(id);
if (result.affectedRows === 0) throw new Error("Company not found");
return true;
} catch (error: any) {
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete company. It is currently assigned to one or more employees.");
throw error;
}
};
// ==========================================
// BRANCHES
// ==========================================
export const getBranches = async (params: any) => {
const filters: any = {};
if (params.get('company_id')) filters.company_id = params.get('company_id');
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
return await LookupRepo.findBranches(filters);
};
export const createBranch = async (data: any) => {
let branchCode = data.code;
if (!branchCode || branchCode.trim() === "") {
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
}
try {
const result = await LookupRepo.insertBranch({
company_id: data.companyId,
branch_name: data.branchName,
code: branchCode,
is_active: data.isActive ?? true
});
return { branch_id: result.insertId, code: branchCode };
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Branch code '${branchCode}' already exists.`);
throw error;
}
};
export const updateBranch = async (id: number, data: any) => {
const result = await LookupRepo.updateBranchById(id, {
company_id: data.companyId,
branch_name: data.branchName,
code: data.code,
is_active: data.isActive
});
if (result.affectedRows === 0) throw new Error("Branch not found");
return true;
};
export const deleteBranch = async (id: number) => {
try {
const result = await LookupRepo.deleteBranchById(id);
if (result.affectedRows === 0) throw new Error("Branch not found");
return true;
} catch (error: any) {
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete branch. It is currently assigned to one or more employees.");
throw error;
}
};
// ==========================================
// DEPARTMENTS
// ==========================================
const mapDeptSortColumn = (sortBy: string): string => {
const validColumns: Record<string, string> = {
'department_name': 'd.name', 'department_code': 'd.department_code', 'company_name': 'c.name', 'branch_name': 'b.branch_name'
};
return validColumns[sortBy] || 'd.name';
};
export const getDepartments = async (params: any) => {
const filters: any = {};
if (params.get('company_id')) filters.company_id = Number(params.get('company_id'));
if (params.get('branch_id')) filters.branch_id = Number(params.get('branch_id'));
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
const sort = {
column: mapDeptSortColumn(params.get('sort_by') || 'department_name'),
order: params.get('sort_order') === 'desc' ? 'DESC' : 'ASC'
};
return await LookupRepo.findDepartments(filters, sort);
};
export const createDepartment = async (data: any) => {
if (!data.companyId || !data.branchId || !data.name) throw new Error("companyId, branchId, and name are required.");
const deptCode = data.departmentCode || await generateNextCode("DEPARTMENT_MAIN", "DEPT-", 3);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const departmentId = await LookupRepo.insertDepartment({
company_id: data.companyId,
branch_id: data.branchId,
department_code: deptCode,
name: data.name,
parent_id: data.parentId ?? null,
is_active: data.isActive ?? true
}, connection);
if (data.managerId) {
await LookupRepo.insertDepartmentManager(departmentId, data.managerId, connection);
}
await connection.commit();
return { department_id: departmentId, department_code: deptCode };
} catch (error: any) {
await connection.rollback();
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Department code '${deptCode}' already exists.`);
throw error;
} finally {
connection.release();
}
};
export const updateDepartment = async (id: number, data: any) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await LookupRepo.updateDepartmentById(id, {
company_id: data.companyId ?? null,
branch_id: data.branchId ?? null,
name: data.name ?? null,
parent_id: data.parentId ?? null,
is_active: data.isActive
}, connection);
if (result.affectedRows === 0) throw new Error("Department not found");
if (data.managerId) {
await LookupRepo.expireDepartmentManagers(id, connection);
await LookupRepo.upsertDepartmentManager(id, data.managerId, connection);
}
await connection.commit();
return true;
} catch (error: any) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
export const deleteDepartment = async (id: number) => {
try {
const result = await LookupRepo.deleteDepartmentById(id);
if (result.affectedRows === 0) throw new Error("Department not found");
return true;
} catch (error: any) {
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete department. There are active assignments tied to it.");
throw error;
}
};
// ==========================================
// JOBS
// ==========================================
const mapJobSortColumn = (sortBy: string): string => {
const validColumns: Record<string, string> = {
'job_name': 'j.title', 'job_code': 'j.job_code', 'department_name': 'd.name'
};
return validColumns[sortBy] || 'j.title';
};
export const getJobs = async (params: any) => {
const filters: any = {};
if (params.get('company_id')) filters.company_id = Number(params.get('company_id'));
if (params.get('branch_id')) filters.branch_id = Number(params.get('branch_id'));
if (params.get('department_id')) filters.department_id = Number(params.get('department_id'));
const sort = {
column: mapJobSortColumn(params.get('sort_by') || 'job_name'),
order: params.get('sort_order') === 'desc' ? 'DESC' : 'ASC'
};
return await LookupRepo.findJobs(filters, sort);
};
export const createJob = async (data: any) => {
if (!data.departmentId || !data.title) throw new Error("departmentId and title are required.");
const jobCode = data.jobCode || await generateNextCode("JOB_MAIN", "JOB-", 3);
try {
const result = await LookupRepo.insertJob({
department_id: data.departmentId,
job_code: jobCode,
title: data.title,
description: data.description ?? null,
is_active: data.isActive ?? true
});
return { job_id: result.insertId, job_code: jobCode };
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Job code '${jobCode}' already exists.`);
throw error;
}
};
export const updateJob = async (id: number, data: any) => {
const result = await LookupRepo.updateJobById(id, {
department_id: data.departmentId ?? null,
title: data.title ?? null,
description: data.description ?? null,
is_active: data.isActive
});
if (result.affectedRows === 0) throw new Error("Job not found");
return true;
};
export const deleteJob = async (id: number) => {
try {
const result = await LookupRepo.deleteJobById(id);
if (result.affectedRows === 0) throw new Error("Job not found");
return true;
} catch (error: any) {
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete job position. It is currently linked to active employee assignments.");
throw error;
}
};

View File

@ -1,70 +0,0 @@
// services/ems/services/system.service.ts
import pool from "../db.ts";
import * as SystemRepo from "../repositories/system.repository.ts";
export const bulkSeedEmployees = async (body: any[]) => {
if (!Array.isArray(body) || body.length === 0) throw new Error("Provide an array of employee logs.");
const connection = await pool.getConnection();
let insertedCount = 0;
try {
await connection.beginTransaction();
for (const item of body) {
const { emp_code, name, designation, department, email } = item;
const nameParts = name.trim().split(" ");
const firstName = nameParts[0] || "Employee";
const lastName = nameParts.slice(1).join(" ") || "LNU";
const partnerId = await SystemRepo.upsertPartner({ firstName, lastName, email: `personal.${email}`, phone: `MOCK_${emp_code}` }, connection);
const departmentId = await SystemRepo.upsertDepartment(department || "General", connection);
const jobId = await SystemRepo.upsertJob(departmentId, designation || "Trainee", connection);
const employeeId = await SystemRepo.upsertEmployee(emp_code, partnerId, connection);
await SystemRepo.upsertEmploymentTerms(employeeId, email, connection);
await SystemRepo.upsertAssignment(employeeId, departmentId, jobId, connection);
insertedCount++;
}
await connection.commit();
return { count: insertedCount };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
export const bulkMapHierarchy = async (body: any) => {
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) throw new Error("Provide hierarchy mapping arrays.");
const connection = await pool.getConnection();
let assignmentsUpdated = 0;
let departmentsUpdated = 0;
try {
await connection.beginTransaction();
for (const mapping of employeeHierarchy) {
if (mapping.emp_code && mapping.manager_code) {
const affected = await SystemRepo.updateAssignmentManager(mapping.emp_code, mapping.manager_code, connection);
if (affected > 0) assignmentsUpdated++;
}
}
for (const dept of departmentHierarchy) {
if (!dept.department_name) continue;
if (dept.manager_code) await SystemRepo.upsertDepartmentManager(dept.department_name, dept.manager_code, connection);
if (dept.parent_department_name) await SystemRepo.updateDepartmentParent(dept.department_name, dept.parent_department_name, connection);
departmentsUpdated++;
}
await connection.commit();
return { assignmentsUpdated, departmentsProcessed: departmentsUpdated };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};

View File

@ -1,5 +0,0 @@
{
"name": "@service/gateway",
"version": "0.1.0",
"exports": "./main.ts"
}

View File

@ -1,99 +0,0 @@
// services/gateway/main.ts
import { Application, Router } from "@oak/oak";
const router = new Router();
// Read config from environment variables
const EMS_URL = Deno.env.get("EMS_URL") || "http://localhost:8001";
const AMS_URL = Deno.env.get("AMS_URL") || "http://localhost:8002";
const LMS_URL = Deno.env.get("LMS_URL") || "http://localhost:8003";
const INTERNAL_TOKEN = Deno.env.get("INTERNAL_SERVICE_TOKEN") || "";
const PORT = Number(Deno.env.get("GATEWAY_PORT")) || 8000;
// MOCK SSO MIDDLEWARE
router.use(async (ctx, next) => {
const isAuthenticated = true; // Simulating a valid JWT
if (!isAuthenticated) {
ctx.response.status = 401;
ctx.response.body = { success: false, message: "Unauthorized: Invalid or missing token" };
return;
}
// FIX: Store context in state instead of modifying immutable request headers
ctx.state.user = {
id: "135",
role: "HR_MANAGER", // Changed from ADMIN
managed_branches: "1,2"
};
await next();
});
// DYNAMIC REVERSE PROXY
const proxyTo = async (ctx: any, targetBaseUrl: string, pathPrefix: string) => {
const url = new URL(ctx.request.url);
const targetPath = url.pathname.replace(pathPrefix, "") || "/";
const targetUrl = `${targetBaseUrl}${targetPath}${url.search}`;
const headers = new Headers(ctx.request.headers);
headers.delete("host");
headers.set("X-Internal-Token", INTERNAL_TOKEN);
if (ctx.state.user) {
headers.set("X-User-Id", ctx.state.user.id);
headers.set("X-User-Role", ctx.state.user.role);
headers.set("X-Managed-Branches", ctx.state.user.managed_branches);
}
const reqInit: RequestInit = {
method: ctx.request.method,
headers: headers,
};
// FIX: Correct Oak v17 syntax for reading body as text
if (ctx.request.method !== "GET" && ctx.request.method !== "HEAD" && ctx.request.hasBody) {
try {
const bodyText = await ctx.request.body.text();
reqInit.body = bodyText;
} catch (e) {
console.error("Error reading body in Gateway:", e);
}
}
try {
const response = await fetch(targetUrl, reqInit);
ctx.response.status = response.status;
ctx.response.headers.set("Content-Type", response.headers.get("Content-Type") || "application/json");
ctx.response.body = response.body;
} catch (error) {
console.error(`Proxy error to ${targetUrl}:`, error);
ctx.response.status = 502;
ctx.response.body = { success: false, message: "Bad Gateway: Microservice unavailable" };
}
};
router.all("/api/ems/(.*)", async (ctx) => proxyTo(ctx, EMS_URL, "/api/ems"));
router.all("/api/ams/(.*)", async (ctx) => proxyTo(ctx, AMS_URL, "/api/ams"));
router.all("/api/lms/(.*)", async (ctx) => proxyTo(ctx, LMS_URL, "/api/lms"));
const app = new Application();
// CORS
app.use(async (ctx, next) => {
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (ctx.request.method === "OPTIONS") {
ctx.response.status = 204;
return;
}
await next();
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log(`API Gateway running on http://localhost:${PORT}`);
await app.listen({ port: PORT });

View File

@ -1,57 +0,0 @@
// services/lms/controllers/admin.controller.ts
import * as AdminService from "../services/admin.service.ts";
const getJsonBody = async (ctx: any) => {
try { return await ctx.request.body.json(); }
catch { throw new Error("Invalid JSON body. Check Postman variables and raw JSON format."); }
};
export const createLeaveType = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const id = await AdminService.createLeaveType(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Leave type created", leave_type_id: id };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const getLeaveTypes = async (ctx: any) => {
try {
const companyId = Number(ctx.request.url.searchParams.get("company_id"));
const data = await AdminService.getLeaveTypes(companyId);
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createPolicyRule = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const id = await AdminService.createPolicyRule(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: "Policy rule created", rule_id: id };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const createCompanyHoliday = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await AdminService.createCompanyHoliday(body);
ctx.response.status = 201; ctx.response.body = { success: true, message: `${body.length} holidays inserted successfully.` };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};
export const updateWorkSettings = async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
await AdminService.updateWorkSettings(body);
ctx.response.body = { success: true, message: "Work settings updated successfully." };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,29 +0,0 @@
// services/lms/controllers/employee.controller.ts
import * as EmployeeService from "../services/employee.service.ts";
export const getLeaveBalances = async (ctx: any) => {
try {
const data = await EmployeeService.getLeaveBalances(ctx.state.user, ctx.request.url.searchParams);
ctx.response.body = { success: true, balances: data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const getLeaveHistory = async (ctx: any) => {
try {
const data = await EmployeeService.getLeaveHistory(ctx.state.user, ctx.request.url.searchParams);
ctx.response.body = { success: true, count: data.length, history: data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const getValidOptionalHolidays = async (ctx: any) => {
try {
const data = await EmployeeService.getValidOptionalHolidays(ctx.state.user);
ctx.response.body = { success: true, count: data.length, holidays: data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,17 +0,0 @@
// services/lms/controllers/leave.controller.ts
import * as LeaveService from "../services/leave.service.ts";
export const applyForLeave = async (ctx: any) => {
try {
let body;
try { body = await ctx.request.body.json(); }
catch { throw new Error("Invalid JSON body. Check Postman variables and raw JSON format."); }
const result = await LeaveService.applyForLeave(body);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Leave application submitted successfully.", ...result };
} catch (error: any) {
ctx.response.status = 400;
ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,36 +0,0 @@
// services/lms/controllers/manager.controller.ts
import * as ManagerService from "../services/manager.service.ts";
export const getPendingManagerLeaves = async (ctx: any) => {
try {
const data = await ManagerService.getPendingManagerLeaves(ctx.state.user);
ctx.response.body = { success: true, count: data.length, data };
} catch (error: any) {
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
}
};
export const approveLeaveManager = async (ctx: any) => {
try {
const result = await ManagerService.approveLeaveManager(ctx.state.user, Number(ctx.params.applicationId));
ctx.response.body = { success: true, message: "Leave approved successfully.", ...result };
} catch (error: any) {
if (error.message.includes("Access Denied")) ctx.response.status = 403;
else if (error.message.includes("not found")) ctx.response.status = 404;
else ctx.response.status = 400;
ctx.response.body = { success: false, error: error.message };
}
};
export const rejectLeaveManager = async (ctx: any) => {
try {
// We still need to parse the body to prevent Oak from crashing, even if we only use the applicationId from the URL
try { await ctx.request.body.json(); }
catch { throw new Error("Invalid JSON body."); }
await ManagerService.rejectLeaveManager(ctx.state.user, Number(ctx.params.applicationId));
ctx.response.body = { success: true, message: "Leave rejected successfully." };
} catch (error: any) {
ctx.response.status = 404; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,18 +0,0 @@
// services/lms/controllers/reports.controller.ts
import * as ReportsService from "../services/reports.service.ts";
export const getLedgerReport = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
const month = Number(params.get("month"));
const year = Number(params.get("year"));
const companyId = Number(params.get("company_id"));
if (!month || !year || !companyId) throw new Error("Missing required query params: month, year, company_id");
const data = await ReportsService.getLedgerReport(month, year, companyId);
ctx.response.body = { success: true, report_month: month, report_year: year, count: data.length, data };
} catch (error: any) {
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
}
};

View File

@ -1,15 +0,0 @@
// services/lms/db.ts
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: Deno.env.get("DB_HOST") || "127.0.0.1",
port: Number(Deno.env.get("LMS_DB_PORT")) || 3308,
user: Deno.env.get("DB_USER") || "admin",
password: Deno.env.get("DB_PASSWORD") || "admin123",
database: "hrms_lms",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
export default pool;

View File

@ -1,194 +0,0 @@
// services/lms/repositories/lms.repository.ts
import pool from "../db.ts";
// ==========================================
// CONFIG (Types, Rules, Holidays, Settings)
// ==========================================
export const insertLeaveType = async (data: any) => {
const [result] = await pool.execute(
`INSERT INTO leave_types (company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days) VALUES (?, ?, ?, ?, ?)`,
[data.company_id, data.name, data.requires_allocation ?? true, data.carry_over_allowed ?? false, data.max_carry_over_days ?? 0.0]
);
return result.insertId;
};
export const findLeaveTypes = async (companyId: number) => {
const [rows] = await pool.execute(`SELECT * FROM leave_types WHERE company_id = ?`, [companyId]);
return rows;
};
export const insertPolicyRule = async (data: any) => {
const [result] = await pool.execute(
`INSERT INTO leave_policy_rules (leave_type_id, company_id, branch_id, calendar_year, yearly_allowance, max_days_per_month, max_consecutive_days, apply_sandwich_policy) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[data.leave_type_id, data.company_id, data.branch_id || null, data.calendar_year, data.yearly_allowance, data.max_days_per_month || null, data.max_consecutive_days || null, data.apply_sandwich_policy ?? false]
);
return result.insertId;
};
export const bulkInsertHolidays = async (values: any[]) => {
await pool.query(`INSERT INTO company_holidays (company_id, branch_id, calendar_year, holiday_date, holiday_type, holiday_name) VALUES ?`, [values]);
};
export const upsertWorkSettings = async (data: any) => {
await pool.execute(
`INSERT INTO branch_work_settings (company_id, branch_id, weekly_off_days, effective_from) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE weekly_off_days = VALUES(weekly_off_days), effective_from = VALUES(effective_from)`,
[data.company_id, data.branch_id || null, data.offDaysJson, data.effective_from]
);
};
// ==========================================
// EMPLOYEE DASHBOARD
// ==========================================
export const findLeaveBalances = async (employeeId: number, year: number) => {
const [rows] = await pool.execute(
`SELECT lt.leave_type_id, lt.name AS leave_type_name, la.granted_days, la.used_days, (la.granted_days - la.used_days) AS available_balance
FROM leave_allocations la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.employee_id = ? AND la.calendar_year = ? AND la.status = 'ACTIVE'`,
[employeeId, year]
);
return rows;
};
export const findLeaveHistory = async (employeeId: number, year: number) => {
const [rows] = await pool.execute(
`SELECT la.application_id, lt.name AS leave_type_name, DATE_FORMAT(la.date_from, '%Y-%m-%d') as date_from, DATE_FORMAT(la.date_to, '%Y-%m-%d') as date_to, la.number_of_days, la.status, la.reason
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.employee_id = ? AND YEAR(la.date_from) = ? ORDER BY la.date_from DESC`,
[employeeId, year]
);
return rows;
};
export const findOptionalHolidays = async (companyId: number, branchId: number | null, year: number, today: string) => {
const [rows] = await pool.execute(
`SELECT holiday_id, holiday_name, DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
FROM company_holidays
WHERE company_id = ? AND calendar_year = ? AND holiday_type = 'OPTIONAL' AND (branch_id = ? OR branch_id IS NULL) AND holiday_date >= ?
ORDER BY holiday_date ASC`,
[companyId, year, branchId, today]
);
return rows;
};
// ==========================================
// LEAVE APPLICATION
// ==========================================
export const findWorkSettings = async (companyId: number, branchId: number) => {
const [rows]: any = await pool.execute(
`SELECT weekly_off_days FROM branch_work_settings WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
[companyId, branchId]
);
return rows[0];
};
export const findHolidays = async (companyId: number, year: number, branchId: number) => {
const [rows] = await pool.execute(
`SELECT holiday_date FROM company_holidays WHERE company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL)`,
[companyId, year, branchId]
);
return rows;
};
export const findPolicyRule = async (leaveTypeId: number, companyId: number, year: number, branchId: number) => {
const [rows]: any = await pool.execute(
`SELECT max_days_per_month, apply_sandwich_policy FROM leave_policy_rules WHERE leave_type_id = ? AND company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
[leaveTypeId, companyId, year, branchId]
);
return rows[0];
};
export const findRawBalance = async (employeeId: number, leaveTypeId: number, year: number) => {
const [rows]: any = await pool.execute(
`SELECT (granted_days - used_days) AS raw_balance FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE'`,
[employeeId, leaveTypeId, year]
);
return rows[0]?.raw_balance || 0;
};
export const findPendingDays = async (employeeId: number, leaveTypeId: number, year: number) => {
const [rows]: any = await pool.execute(
`SELECT SUM(number_of_days) as pending_days FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND YEAR(date_from) = ? AND status IN ('PENDING', 'PENDING_LOP')`,
[employeeId, leaveTypeId, year]
);
return Number(rows[0]?.pending_days || 0);
};
export const findMonthlyUsage = async (employeeId: number, leaveTypeId: number, month: number, year: number) => {
const [rows]: any = await pool.execute(
`SELECT SUM(number_of_days) as used_this_month FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND MONTH(date_from) = ? AND YEAR(date_from) = ? AND status IN ('APPROVED', 'APPROVED_LOP', 'PENDING', 'PENDING_LOP')`,
[employeeId, leaveTypeId, month, year]
);
return Number(rows[0]?.used_this_month || 0);
};
export const insertLeaveApplication = async (data: any, conn: any) => {
const [result] = await conn.execute(
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[data.employee_id, data.leave_type_id, data.company_id, data.date_from, data.date_to, data.number_of_days, data.reason, data.status, data.manager_id]
);
return result.insertId;
};
// ==========================================
// MANAGER ACTIONS
// ==========================================
export const findPendingManagerLeaves = async (managerId: number) => {
const [rows] = await pool.execute(
`SELECT la.application_id, la.employee_id, la.leave_type_id, lt.name AS leave_type, la.date_from, la.date_to, la.number_of_days, la.reason, la.status
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.manager_approved_by = ? AND la.status IN ('PENDING', 'PENDING_LOP') ORDER BY la.date_from ASC`,
[managerId]
);
return rows;
};
export const findLeaveForUpdate = async (applicationId: number, conn: any) => {
const [rows]: any = await conn.execute(`SELECT * FROM leave_applications WHERE application_id = ? FOR UPDATE`, [applicationId]);
return rows[0];
};
export const updateLeaveStatus = async (applicationId: number, status: string, conn: any) => {
await conn.execute(`UPDATE leave_applications SET status = ? WHERE application_id = ?`, [status, applicationId]);
};
export const findAllocForUpdate = async (employeeId: number, leaveTypeId: number, year: number, conn: any) => {
const [rows]: any = await conn.execute(
`SELECT granted_days, used_days FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE' FOR UPDATE`,
[employeeId, leaveTypeId, year]
);
return rows[0];
};
export const insertLedgerTransaction = async (data: any, conn: any) => {
await conn.execute(
`INSERT INTO leave_ledger_transactions (employee_id, leave_type_id, application_id, calendar_year, calendar_month, transaction_type, days, opening_balance, closing_balance, remarks) VALUES (?, ?, ?, ?, ?, 'DEBIT', ?, ?, ?, ?)`,
[data.employee_id, data.leave_type_id, data.application_id, data.year, data.month, data.days, data.opening_balance, data.closing_balance, data.remarks]
);
};
export const updateAllocationUsedDays = async (employeeId: number, leaveTypeId: number, year: number, days: number, conn: any) => {
await conn.execute(`UPDATE leave_allocations SET used_days = used_days + ? WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ?`, [days, employeeId, leaveTypeId, year]);
};
export const rejectLeaveApplication = async (applicationId: number, managerId: number) => {
const [result] = await pool.execute(
`UPDATE leave_applications SET status = 'REJECTED' WHERE application_id = ? AND manager_approved_by = ? AND status IN ('PENDING', 'PENDING_LOP')`,
[applicationId, managerId]
);
return result.affectedRows > 0;
};
// ==========================================
// REPORTS
// ==========================================
export const findLedgerReportData = async (year: number, month: number, companyId: number) => {
const [rows] = await pool.execute(
`SELECT la.employee_id, lt.name AS leave_type, la.granted_days AS total_granted, la.used_days AS total_used,
COALESCE((SELECT t_ob.closing_balance FROM leave_ledger_transactions t_ob WHERE t_ob.employee_id = la.employee_id AND t_ob.leave_type_id = la.leave_type_id AND (t_ob.calendar_year < ? OR (t_ob.calendar_year = ? AND t_ob.calendar_month < ?)) ORDER BY t_ob.transaction_date DESC LIMIT 1), la.granted_days) AS opening_balance,
COALESCE((SELECT t_cb.closing_balance FROM leave_ledger_transactions t_cb WHERE t_cb.employee_id = la.employee_id AND t_cb.leave_type_id = la.leave_type_id AND (t_cb.calendar_year < ? OR (t_cb.calendar_year = ? AND t_cb.calendar_month <= ?)) ORDER BY t_cb.transaction_date DESC LIMIT 1), la.granted_days) AS closing_balance
FROM leave_allocations la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
WHERE la.calendar_year = ? AND la.company_id = ?`,
[year, year, month, year, year, month, year, companyId]
);
return rows;
};

View File

@ -1,34 +0,0 @@
// services/lms/routes.ts
import { Router } from "@oak/oak";
import { requireAuth, requireRole, AppRole } from "core/auth.ts";
import { applyForLeave } from "./controllers/leave.controller.ts";
import { getPendingManagerLeaves, approveLeaveManager, rejectLeaveManager } from "./controllers/manager.controller.ts";
import { getLeaveBalances, getLeaveHistory, getValidOptionalHolidays } from "./controllers/employee.controller.ts";
import { createLeaveType, getLeaveTypes, createPolicyRule, createCompanyHoliday, updateWorkSettings } from "./controllers/admin.controller.ts";
import { getLedgerReport } from "./controllers/reports.controller.ts";
const router = new Router();
// 1. Employee Leave Actions
router.post("/lms/leaves/apply", requireAuth, applyForLeave);
router.get("/lms/leaves/balances", requireAuth, getLeaveBalances);
router.get("/lms/leaves/history", requireAuth, getLeaveHistory);
router.get("/lms/holidays/valid-optional", requireAuth, getValidOptionalHolidays);
// 2. Manager Actions
router.get("/lms/manager/pending", requireAuth, requireRole([AppRole.MANAGER, AppRole.HR_MANAGER, AppRole.DIRECTOR]), getPendingManagerLeaves);
router.post("/lms/manager/leaves/:applicationId/approve", requireAuth, requireRole([AppRole.MANAGER, AppRole.HR_MANAGER, AppRole.DIRECTOR]), approveLeaveManager);
router.post("/lms/manager/leaves/:applicationId/reject", requireAuth, requireRole([AppRole.MANAGER, AppRole.HR_MANAGER, AppRole.DIRECTOR]), rejectLeaveManager);
// 3. Admin Setup & Configuration
router.post("/lms/config/types", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), createLeaveType);
router.get("/lms/config/types", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), getLeaveTypes);
router.post("/lms/config/rules", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), createPolicyRule);
router.post("/lms/config/holidays", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), createCompanyHoliday);
router.put("/lms/config/work-settings", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), updateWorkSettings);
// 4. Admin / HR Reporting
router.get("/lms/admin/reports/ob-cb", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), getLedgerReport);
export default router;

View File

@ -1,25 +0,0 @@
// services/lms/services/admin.service.ts
import * as LmsRepo from "../repositories/lms.repository.ts";
export const createLeaveType = async (data: any) => {
return await LmsRepo.insertLeaveType(data);
};
export const getLeaveTypes = async (companyId: number) => {
return await LmsRepo.findLeaveTypes(companyId);
};
export const createPolicyRule = async (data: any) => {
return await LmsRepo.insertPolicyRule(data);
};
export const createCompanyHoliday = async (body: any[]) => {
if (!Array.isArray(body)) throw new Error("Expected an array of holidays.");
const values = body.map(h => [h.company_id, h.branch_id || null, h.calendar_year, h.holiday_date, h.holiday_type || 'MANDATORY', h.holiday_name]);
return await LmsRepo.bulkInsertHolidays(values);
};
export const updateWorkSettings = async (data: any) => {
const offDaysJson = JSON.stringify(data.weekly_off_days);
return await LmsRepo.upsertWorkSettings({ ...data, offDaysJson });
};

View File

@ -1,29 +0,0 @@
// services/lms/services/employee.service.ts
import { AppRole } from "core/auth.ts";
import * as LmsRepo from "../repositories/lms.repository.ts";
export const getLeaveBalances = async (user: any, params: any) => {
let targetEmployeeId = user.employee_id;
if ((user.role === AppRole.ADMIN || user.role === AppRole.SUPER_ADMIN || user.role === AppRole.MANAGER) && params.get("employee_id")) {
targetEmployeeId = Number(params.get("employee_id"));
}
const year = Number(params.get("year")) || new Date().getFullYear();
return await LmsRepo.findLeaveBalances(targetEmployeeId, year);
};
export const getLeaveHistory = async (user: any, params: any) => {
let targetEmployeeId = user.employee_id;
if ((user.role === AppRole.ADMIN || user.role === AppRole.SUPER_ADMIN || user.role === AppRole.MANAGER) && params.get("employee_id")) {
targetEmployeeId = Number(params.get("employee_id"));
}
const year = Number(params.get("year")) || new Date().getFullYear();
return await LmsRepo.findLeaveHistory(targetEmployeeId, year);
};
export const getValidOptionalHolidays = async (user: any) => {
const currentYear = new Date().getFullYear();
const today = new Date().toISOString().split('T')[0];
const companyId = user.company_id ?? null;
const branchId = user.branch_id ?? null;
return await LmsRepo.findOptionalHolidays(companyId, branchId, currentYear, today);
};

View File

@ -1,76 +0,0 @@
// services/lms/services/leave.service.ts
import pool from "../db.ts";
import { calculateLeaveDays } from "core/calendar.service.ts";
import { getEmployeeManagerId } from "core/internal-client.ts";
import * as LmsRepo from "../repositories/lms.repository.ts";
export const applyForLeave = async (payload: any) => {
const currentYear = new Date(payload.date_from).getFullYear();
const currentMonth = new Date(payload.date_from).getMonth() + 1;
// 1. Fetch Config & Rules
const branchSettingsRaw = await LmsRepo.findWorkSettings(payload.company_id, payload.branch_id);
const workSettings = { weeklyOffDays: branchSettingsRaw?.weekly_off_days || [0] };
const holidays = await LmsRepo.findHolidays(payload.company_id, currentYear, payload.branch_id);
const policy = await LmsRepo.findPolicyRule(payload.leave_type_id, payload.company_id, currentYear, payload.branch_id);
if (!policy) throw new Error("Leave policy not configured for this type/year.");
// 2. Calculate Requested Days
let requestedDays = 0;
if (payload.is_half_day) {
if (payload.date_from !== payload.date_to) throw new Error("Half-day leaves must be on the same date.");
requestedDays = 0.5;
} else {
requestedDays = calculateLeaveDays(payload.date_from, payload.date_to, workSettings, holidays, policy.apply_sandwich_policy);
}
if (requestedDays === 0) throw new Error("Selected dates fall entirely on weekends/holidays with no sandwich policy applied.");
// 3. Fetch Balances & Limits
const rawBalance = Number(await LmsRepo.findRawBalance(payload.employee_id, payload.leave_type_id, currentYear) || 0);
const pendingDays = await LmsRepo.findPendingDays(payload.employee_id, payload.leave_type_id, currentYear);
const availableBalance = rawBalance - pendingDays;
const usedThisMonth = await LmsRepo.findMonthlyUsage(payload.employee_id, payload.leave_type_id, currentMonth, currentYear);
// 4. Determine Paid vs LOP Split
let paidDays = 0, lopDays = 0;
const remainingMonthlyLimit = policy.max_days_per_month !== null ? Math.max(0, policy.max_days_per_month - usedThisMonth) : requestedDays;
const maxAllowedPaid = Math.min(availableBalance, remainingMonthlyLimit);
if (requestedDays <= maxAllowedPaid) {
paidDays = requestedDays;
} else {
paidDays = maxAllowedPaid;
lopDays = requestedDays - paidDays;
}
// 5. Fetch Manager ID via HTTP
const managerId = await getEmployeeManagerId(payload.employee_id);
// 6. Execute DB Transaction
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const insertedIds = [];
if (paidDays > 0) {
const id = await LmsRepo.insertLeaveApplication({ ...payload, number_of_days: paidDays, status: 'PENDING', manager_id: managerId }, connection);
insertedIds.push(id);
}
if (lopDays > 0) {
const id = await LmsRepo.insertLeaveApplication({ ...payload, number_of_days: lopDays, status: 'PENDING_LOP', manager_id: managerId }, connection);
insertedIds.push(id);
}
await connection.commit();
return { total_requested: requestedDays, paid_days: paidDays, lop_days: lopDays, application_ids: insertedIds };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};

View File

@ -1,72 +0,0 @@
// services/lms/services/manager.service.ts
import pool from "../db.ts";
import { getEmployeeRoster } from "core/internal-client.ts";
import * as LmsRepo from "../repositories/lms.repository.ts";
export const getPendingManagerLeaves = async (user: any) => {
const rows = await LmsRepo.findPendingManagerLeaves(user.employee_id);
if (rows.length === 0) return [];
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
const roster = await getEmployeeRoster(employeeIds);
const rosterMap = new Map(roster.map((e: any) => [e.employee_id, e]));
return rows.map((app: any) => {
const emp = rosterMap.get(app.employee_id);
return {
...app,
employee_code: emp?.employee_code || "N/A",
first_name: emp?.full_name?.split(" ")[0] || "",
last_name: emp?.full_name?.split(" ").slice(1).join(" ") || "",
};
});
};
export const approveLeaveManager = async (user: any, applicationId: number) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const leave = await LmsRepo.findLeaveForUpdate(applicationId, connection);
if (!leave) throw new Error("Leave application not found.");
if (leave.manager_approved_by !== user.employee_id) throw new Error("Access Denied: You are not the assigned manager.");
if (!['PENDING', 'PENDING_LOP'].includes(leave.status)) throw new Error(`Leave is already processed. Current status: ${leave.status}`);
const newStatus = leave.status === 'PENDING_LOP' ? 'APPROVED_LOP' : 'APPROVED';
await LmsRepo.updateLeaveStatus(applicationId, newStatus, connection);
if (newStatus === 'APPROVED') {
const currentYear = new Date(leave.date_from).getFullYear();
const currentMonth = new Date(leave.date_from).getMonth() + 1;
const alloc = await LmsRepo.findAllocForUpdate(leave.employee_id, leave.leave_type_id, currentYear, connection);
if (alloc) {
const openingBalance = Number(alloc.granted_days) - Number(alloc.used_days);
const closingBalance = openingBalance - Number(leave.number_of_days);
await LmsRepo.insertLedgerTransaction({
employee_id: leave.employee_id, leave_type_id: leave.leave_type_id, application_id: applicationId,
year: currentYear, month: currentMonth, days: leave.number_of_days,
opening_balance: openingBalance, closing_balance: closingBalance,
remarks: `Approved by Manager ID: ${user.employee_id}`
}, connection);
await LmsRepo.updateAllocationUsedDays(leave.employee_id, leave.leave_type_id, currentYear, leave.number_of_days, connection);
}
}
await connection.commit();
return { new_status: newStatus };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
export const rejectLeaveManager = async (user: any, applicationId: number) => {
const success = await LmsRepo.rejectLeaveApplication(applicationId, user.employee_id);
if (!success) throw new Error("Leave not found, already processed, or you lack permission.");
return true;
};

View File

@ -1,21 +0,0 @@
// services/lms/services/reports.service.ts
import { getEmployeeRoster } from "core/internal-client.ts";
import * as LmsRepo from "../repositories/lms.repository.ts";
export const getLedgerReport = async (month: number, year: number, companyId: number) => {
const rows = await LmsRepo.findLedgerReportData(year, month, companyId);
if (rows.length === 0) return [];
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
const roster = await getEmployeeRoster(employeeIds);
const rosterMap = new Map(roster.map((e: any) => [e.employee_id, e]));
return rows.map((row: any) => {
const emp = rosterMap.get(row.employee_id);
return {
...row,
employee_code: emp?.employee_code || "N/A",
employee_name: emp?.full_name || "Unknown",
};
});
};

65
shared/auth.ts Normal file
View File

@ -0,0 +1,65 @@
// shared/auth.ts
import { Context, Next } from "@oak/oak";
import { getDbPool } from "./db.ts";
export enum AppRole {
SUPER_ADMIN = "SUPER_ADMIN",
ADMIN = "ADMIN",
MANAGER = "MANAGER",
EMPLOYEE = "EMPLOYEE",
}
// 1. Authentication Middleware
export const requireAuth = async (ctx: Context, next: Next) => {
const isAuthenticated = true;
if (!isAuthenticated) {
ctx.response.status = 401;
ctx.response.body = { success: false, message: "Missing or invalid token" };
return;
}
// FIX: Explicitly define the type as AppRole so TypeScript allows comparisons
const mockRole: AppRole = AppRole.ADMIN;
const mockEmployeeId = 135;
let managedBranches: number[] = [];
if (mockRole !== AppRole.SUPER_ADMIN) {
try {
const emsDb = getDbPool("hrms_ems");
const [rows]: any = await emsDb.execute(
`SELECT branch_id FROM branch_admins WHERE employee_id = ?`,
[mockEmployeeId]
);
managedBranches = rows.map((r: any) => r.branch_id);
if (managedBranches.length === 0) managedBranches = [1];
} catch (error) {
console.error("Failed to fetch branch admin mappings", error);
}
}
ctx.state.user = {
employee_id: mockEmployeeId,
role: mockRole,
managed_branches: managedBranches
};
await next();
};
// 2. Authorization Middleware
export const requireRole = (allowedRoles: AppRole[]) => {
return async (ctx: Context, next: Next) => {
const userRole = ctx.state.user?.role;
if (!userRole || !allowedRoles.includes(userRole)) {
ctx.response.status = 403;
ctx.response.body = {
success: false,
message: "Access Denied: You do not have the required permissions."
};
return;
}
await next();
};
};

23
shared/db.ts Normal file
View File

@ -0,0 +1,23 @@
import mysql from "mysql2/promise";
const dbConfig = {
host: "127.0.0.1",
port: 3306,
user: "admin",
password: "admin123",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
};
const pools: Record<string, mysql.Pool> = {};
export const getDbPool = (databaseName: string): mysql.Pool => {
if (!pools[databaseName]) {
pools[databaseName] = mysql.createPool({
...dbConfig,
database: databaseName,
});
}
return pools[databaseName];
};

8
shared/deno.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "@shared/utils",
"version": "0.1.0",
"exports": {
"./db": "./db.ts",
"./types": "./types.ts"
}
}

View File

@ -1,5 +1,7 @@
// services/ems/sequence.ts
import pool from "./db.ts";
// shared/sequence.ts
import { getDbPool } from "./db.ts";
const pool = getDbPool("hrms_ems");
/**
* Generates a sequential code for a given entity.

View File

@ -1,74 +0,0 @@
#!/bin/bash
# HRMS Local Development Startup Script
# 1. Load environment variables from .env
if [ -f .env ]; then
# 'set -a' automatically exports all variables loaded from the .env file
set -a
source .env
set +a
else
echo "❌ Error: .env file not found in the current directory!"
exit 1
fi
# List of all available services
ALL_SERVICES=("gateway" "ems" "ams" "lms")
# Determine which services to run based on user input
if [ $# -eq 0 ]; then
SERVICES_TO_RUN=("${ALL_SERVICES[@]}")
else
SERVICES_TO_RUN=("$@")
fi
# Variables to track running processes
PIDS=""
ACTIVE_SERVICES=()
echo "🚀 Starting Deno Microservices..."
# 2. Start the requested services
for service in "${SERVICES_TO_RUN[@]}"; do
# Convert service name to uppercase to match .env format (e.g., ems -> EMS_PORT)
upper_service=$(echo "$service" | tr '[:lower:]' '[:upper:]')
port_var="${upper_service}_PORT"
port_val="${!port_var}"
# Check if the port variable actually exists in the .env file
if [ -n "$port_val" ]; then
echo "Starting $service on port $port_val..."
deno run -A --env-file=.env "services/${service}/main.ts" &
pid=$!
PIDS="$PIDS $pid"
ACTIVE_SERVICES+=("$service")
else
echo "⚠️ Warning: Unknown service '$service' or missing ${port_var} in .env. Skipping."
fi
done
# Check if anything actually started
if [ -z "$PIDS" ]; then
echo "❌ No valid services started. Exiting."
exit 1
fi
# 3. Print the dynamic port mapping using .env variables
echo "✅ Selected services are up!"
echo "----------------------------------------"
for service in "${ACTIVE_SERVICES[@]}"; do
upper_service=$(echo "$service" | tr '[:lower:]' '[:upper:]')
port_var="${upper_service}_PORT"
# Print nicely formatted columns
printf "%-10s http://localhost:%s\n" "$service:" "${!port_var}"
done
echo "----------------------------------------"
echo "Press CTRL+C to stop the running services."
# 4. Trap CTRL+C to kill ONLY the processes we just started
trap "echo -e '\n🛑 Shutting down services...' && kill $PIDS 2>/dev/null; exit" SIGINT SIGTERM
# Keep the script running so logs are visible
wait