Compare commits

..

No commits in common. "ab1f9444f9bbeac32ad692487e086ef53cc67409" and "8e6574c0b2de420557f4e88787296c17ec955981" have entirely different histories.

110 changed files with 3194 additions and 25471 deletions

33
.env
View File

@ -1,33 +0,0 @@
# Gateway
GATEWAY_PORT=8000
# Set to 'true' to use X-Mock headers for testing roles. Set to 'false' when real Keycloak is ready.
MOCK_SSO=true
# 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 Service URLs (Used for service-to-service Grpc calls)
EMS_URL=localhost:8001
AMS_URL=localhost:8002
LMS_URL=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

@ -1,19 +0,0 @@
# Dockerfile.protos
FROM node:18-alpine
ARG HTTP_PROXY
ARG HTTPS_PROXY
ENV http_proxy=$HTTP_PROXY
ENV https_proxy=$HTTPS_PROXY
RUN apk add --no-cache protobuf protobuf-dev curl
RUN npm install -g ts-proto
# Install grpcurl manually (not available via apk)
RUN curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.9.1/grpcurl_1.9.1_linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin grpcurl
ENV http_proxy=
ENV https_proxy=
CMD ["tail", "-f", "/dev/null"]

View File

@ -1,68 +1,2 @@
# hrms-backend
***
HRMS Backend - Development Setup Guide (gRPC Microservices)
The gRPC microservices refactor is complete. We've transitioned to an API-First architecture using Protocol Buffers, completely decoupled the databases, and implemented a clean API Gateway pattern.
Here is the step-by-step guide to setting up the development environment on your local machine. I have shared the SQL dump files separately for database seeding.
### Prerequisites
Please ensure you have the following installed:
1. **Git**
2. **Docker** (or Podman with `podman-compose`)
3. **Deno** (deno 2.9.1)
### Step 1: Clone and Configure
Clone the repository and navigate into the project folder:
```bash
git clone <your-repo-url>
cd hrms-backend
```
Make sure your `.env` file is present in the root directory. If not, create one based on the `.env.example` (I can provide the dev values if needed).
### Step 2: Start the Databases
We use isolated MySQL containers for each microservice. Start them in the background:
```bash
docker-compose up -d
```
*(If you are using Podman, use `podman-compose up -d`)*
### Step 3: Import the SQL Dumps
I have provided the SQL dump files separately (`hrms_ems_dump.sql`, `hrms_ams_dump.sql`, `hrms_lms_dump.sql`). Place them in the root of the project folder and run these commands to import the data into the respective containers:
```bash
# Import EMS Database (Port 3306)
docker exec -i hrms_ems_db mysql -u root -proot hrms_ems < hrms_ems_dump.sql
# Import AMS Database (Port 3307)
docker exec -i hrms_ams_db mysql -u root -proot hrms_ams < hrms_ams_dump.sql
# Import LMS Database (Port 3308)
docker exec -i hrms_lms_db mysql -u root -proot hrms_lms < hrms_lms_dump.sql
```
### Step 4: Build the Proto Compiler (One-time setup)
Because we use API-First design, the TypeScript types are generated from `.proto` files. We have a dedicated Docker container for this so you don't need to install any compilers globally.
Build and start the compiler container:
```bash
docker-compose build proto-compiler
docker-compose up -d proto-compiler
```
### Step 5: Generate the gRPC Code
Run the provided script to generate the Deno/TypeScript gRPC clients and server interfaces:
```bash
./scripts/gen_protos.sh
```
*(You will see a `generated/` folder populated with `.ts` files).*
### Step 6: Start the Backend Services
We have a convenience script that starts the 4 Deno services (API Gateway, EMS, AMS, LMS) simultaneously for hot-reloading.
```bash
./start-dev.sh
```
*(Alternatively, you can open 4 separate terminals and run `deno run -A --env-file=.env services/<module>/main.ts` in each).*
### Step 7: Testing the APIs (Postman)
The entire backend is now hidden behind the API Gateway on `http://localhost:8000`.

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();
}
};

16
ams-service/main.ts Normal file
View File

@ -0,0 +1,16 @@
import { Application } from "@oak/oak";
import router from "./routes.ts";
const app = new Application();
const PORT = 8002; // Running AMS on 8002 to avoid conflict with EMS on 8001
app.use(async (ctx, next) => {
console.log(`[AMS] ${ctx.request.method} ${ctx.request.url.pathname}`);
await next();
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log(`AMS Service running on http://localhost:${PORT}`);
await app.listen({ port: PORT });

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,18 +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/",
"@grpc/grpc-js": "npm:@grpc/grpc-js@^1.10.9",
"@bufbuild/protobuf": "npm:@bufbuild/protobuf@^2.2.0",
"protobufjs": "npm:protobufjs@^7.4.0",
"long": "npm:long@^5.2.3"
"mysql2": "npm:mysql2@^3.0.0"
}
}

1170
deno.lock generated

File diff suppressed because it is too large Load Diff

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,54 +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
restart: unless-stopped
proto-compiler:
build:
context: .
dockerfile: Dockerfile.protos
args:
HTTP_PROXY: "http://host.containers.internal:8118"
HTTPS_PROXY: "http://host.containers.internal:8118"
container_name: hrms_proto_compiler
volumes:
- .:/app:Z
working_dir: /app
- ./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,98 @@
import { getDbPool } from "../../shared/db.ts"
const pool = getDbPool("hrms_ems");
export const getEmployeeContracts = async (ctx: any) => {
const employeeId = ctx.params.id;
try {
const [rows] = await pool.query(
`SELECT
c.contract_id,
c.work_email,
c.date_joining,
c.probation_days,
c.status,
c.salary_structure_id,
d.name AS department,
j.title AS designation
FROM contracts c
JOIN employees e ON c.employee_id = e.employee_id
JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id
WHERE c.employee_id = ?
ORDER BY c.date_joining 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 any currently active contracts for this specific employee
await connection.execute(
`UPDATE contracts SET status = 'EXPIRED' WHERE employee_id = ? AND status = 'ACTIVE'`,
[data.employeeId]
);
// 2. Insert the brand new active contract
const [result] = await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
data.employeeId,
data.departmentId,
data.jobId,
data.dateJoining,
data.probationDays,
'ACTIVE',
data.salaryStructureId
]
);
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "New contract executed successfully. Previous contracts expired.",
contract_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,346 @@
import { getDbPool } from "../../shared/db.ts";
import { generateNextCode } from "../../shared/sequence.ts";
const pool = getDbPool("hrms_ems");
export const getEmployees = async (ctx: any) => {
try {
// FIX: Filter by ACTIVE contract to prevent duplicate rows for employees with past contracts
const [rows] = await pool.query(
`SELECT
e.employee_id,
e.employee_code,
p.first_name,
p.last_name,
d.name AS department,
j.title AS designation,
c.work_email
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id
WHERE e.is_active = true`,
);
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;
if (!empCode || empCode.trim() === "") {
empCode = await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [partnerResult] = 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 as any).insertId;
await connection.execute(
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
partnerId,
data.address.type,
data.address.doorNumber,
data.address.landmark,
data.address.line,
data.address.pincode,
data.address.district,
data.address.state,
],
);
const [employeeResult] = 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 = (employeeResult as any).insertId;
await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, probation_days, status, salary_structure_id, reporting_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
employeeId,
data.departmentId,
data.jobId,
data.work_email,
data.dateJoining,
data.probationDays,
"ACTIVE",
data.salaryStructureId,
data.reportingToId || null,
],
);
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Employee created successfully",
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. The Employee Code, Personal Email, or Phone number already exists.",
};
return;
}
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Transaction failed: " + errorMessage,
};
} finally {
connection.release();
}
};
export const getEmployeeById = async (ctx: any) => {
const id = ctx.params.id;
try {
// 1. Fetch core employee, partner, contract, and manager details
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,
c.work_email,
c.date_joining,
c.probation_days,
c.status AS contract_status,
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 contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id
LEFT JOIN employees mgr_e ON c.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];
// 2. Fetch all addresses for this partner separately to return as an array
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],
);
// 3. Combine into a single structured JSON response
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,
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, // Array of all addresses (PERMANENT, CURRENT, EMERGENCY)
},
};
} 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();
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" };
return;
}
const partnerId = employees[0].partner_id;
await connection.execute(
`UPDATE partners
SET first_name = ?, last_name = ?, personal_email = ?, personal_phone = ?
WHERE partner_id = ?`,
[
data.firstName,
data.lastName,
data.personalEmail,
data.personalPhone,
partnerId,
],
);
await connection.execute(
`UPDATE addresses
SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ?
WHERE partner_id = ? AND address_type = ?`,
[
data.address.doorNumber,
data.address.landmark,
data.address.line,
data.address.pincode,
data.address.district,
data.address.state,
partnerId,
data.address.type,
],
);
await connection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: "Employee profile updated successfully",
};
} catch (error) {
await connection.rollback();
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Update failed: " + (error as Error).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],
);
const updateResult = result as any;
if (updateResult.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,443 @@
import { getDbPool } from "../../shared/db.ts"
import {generateNextCode} from "../../shared/sequence.ts"
const pool = getDbPool("hrms_ems");
export const getCompanies = async (ctx: any) => {
try {
const [rows] = await pool.query(`SELECT company_id, name FROM companies`);
ctx.response.status = 200;
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 [rows] = await pool.query(`SELECT branch_id, branch_name,code, company_id FROM branches`);
ctx.response.status = 200;
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 [rows] = await pool.query(`SELECT department_id, name, parent_id FROM departments`);
ctx.response.status = 200;
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 getJobs = async (ctx: any) => {
try {
const [rows] = await pool.query(`SELECT job_id, title, department_id FROM job_positions`);
ctx.response.status = 200;
ctx.response.body = { success: true, 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();
try {
const [result] = await pool.execute(
`INSERT INTO companies (name, parent_id) VALUES (?, ?)`,
[data.name, data.parentId || null]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Company created", company_id: (result as any).insertId };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).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 = ? WHERE company_id = ?`,
[data.name, data.parentId || null, 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;
// Catch ON DELETE RESTRICT from employees table
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 no code is provided, automatically generate a sequential one
if (!branchCode || branchCode.trim() === "") {
// Get first 3 letters (e.g., "Bengaluru" -> "BEN")
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
// Generate: Sequence ID 'BRANCH_BEN', Prefix 'BEN-', Padding 3 -> Output: 'BEN-001'
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
}
try {
const [result] = await pool.execute(
`INSERT INTO branches (company_id, branch_name, code) VALUES (?, ?, ?)`,
[data.companyId, data.branchName, branchCode]
);
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Branch created successfully",
branch_id: (result as any).insertId,
code: branchCode
};
} catch (error) {
let errorMessage = (error as Error).message;
if (errorMessage.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: errorMessage };
}
};
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 = ? WHERE branch_id = ?`,
[data.companyId, data.branchName, data.code, 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;
// Catch ON DELETE RESTRICT from employees table
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;
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// 1. Insert the department
const [result] = await connection.execute(
`INSERT INTO departments (company_id, branch_id, name, parent_id) VALUES (?, ?, ?, ?)`,
[data.companyId, data.branchId, data.name, data.parentId ?? null]
);
const departmentId = (result as any).insertId;
// 2. If a manager is provided, add them to the junction table
if (data.managerId) {
await connection.execute(
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
[departmentId, data.managerId]
);
}
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Department created",
department_id: departmentId
};
} catch (error) {
await connection.rollback();
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).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();
// 1. Update core department details
const [result] = await connection.execute(
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ? WHERE department_id = ?`,
[data.companyId ?? null, data.branchId ?? null, data.name ?? null, data.parentId ?? null, 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;
}
// 2. Manage reassignment if a new manager is passed in the update
if (data.managerId) {
// For simplicity in this update endpoint, we overwrite the existing managers.
// You can create a dedicated POST /departments/:id/managers endpoint later for multi-manager logic.
await connection.execute(`DELETE FROM department_managers WHERE department_id = ?`, [id]);
await connection.execute(
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
[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;
// Catch ON DELETE RESTRICT from contracts table
if (errorMessage.includes("foreign key constraint fails")) {
errorMessage = "Cannot delete department. There are active or historical contracts tied to this department.";
}
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;
}
try {
const [result] = await pool.execute(
`INSERT INTO job_positions (department_id, title, description) VALUES (?, ?, ?)`,
[data.departmentId, data.title, data.description ?? null]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Job created", job_id: (result as any).insertId };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).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 = ? WHERE job_id = ?`,
[data.departmentId ?? null, data.title ?? null, data.description ?? null, 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 };
}
};
// Note: deleteJob function remains the same as before.
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;
// Catch ON DELETE RESTRICT from contracts table
if (errorMessage.includes("foreign key constraint fails")) {
errorMessage = "Cannot delete job position. It is currently linked to one or more employee contracts.";
}
ctx.response.status = 409;
ctx.response.body = { success: false, error: errorMessage };
}
};

View File

@ -0,0 +1,173 @@
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. Insert into partners
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. Ensure department exists (FIXED: Added branch_id to satisfy NOT NULL constraint)
const [deptResult]: any = await connection.execute(
`INSERT INTO departments (company_id, branch_id, name)
VALUES (1, 1, ?)
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
[department || "General"]
);
const departmentId = deptResult.insertId;
// 3. Ensure job position exists (FIXED: Linked to department_id instead of company_id)
const [jobResult]: any = await connection.execute(
`INSERT INTO job_positions (department_id, title)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
[departmentId, designation || "Trainee"]
);
const jobId = jobResult.insertId;
// 4. Create core Employee record
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. Establish operational Contract
await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, status, salary_structure_id)
VALUES (?, ?, ?, ?, '2026-01-01', 'ACTIVE', 100)
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
[employeeId, departmentId, jobId, email]
);
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 contractsUpdated = 0;
let departmentsUpdated = 0;
try {
await connection.beginTransaction();
// 1. Map Employees to their Managers (Contracts table)
for (const mapping of employeeHierarchy) {
const { emp_code, manager_code } = mapping;
if (!emp_code || !manager_code) continue;
const [result]: any = await connection.execute(
`UPDATE contracts c
JOIN employees e ON c.employee_id = e.employee_id
JOIN employees m ON m.employee_code = ?
SET c.reporting_to_id = m.employee_id
WHERE e.employee_code = ? AND c.status = 'ACTIVE'`,
[manager_code, emp_code]
);
if (result.affectedRows > 0) contractsUpdated++;
}
// 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;
// FIXED: Use department_managers junction table instead of departments.manager_id
if (manager_code) {
await connection.execute(
`INSERT IGNORE INTO department_managers (department_id, employee_id)
SELECT d.department_id, m.employee_id
FROM departments d
JOIN employees m ON m.employee_code = ?
WHERE d.name = ?`,
[manager_code, department_name]
);
}
// Update Parent Department Hierarchy
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: { contractsUpdated, 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();
}
};

11
ems-service/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { Application } from "@oak/oak";
import router from "./routes.ts";
const app = new Application();
// Register the routes and allowed methods from routes.ts
app.use(router.routes());
app.use(router.allowedMethods());
console.log("EMS Service running on http://localhost:8001");
await app.listen({ port: 8001 });

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

@ -0,0 +1,100 @@
import { Router } from "@oak/oak";
import {
getEmployees,
createEmployee,
getEmployeeById,
updateEmployee,
deleteEmployee
} 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";
// Shared authentication and authorization middleware
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
const router = new Router();
// ============================================================================
// EMPLOYEE CORE DOMAIN
// Manages the corporate identity (employees) and personal data (partners).
// ============================================================================
// Retrieve a list of all active employees. Visible to management and administrators.
router.get("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN, AppRole.MANAGER]), getEmployees);
// Retrieve a single employee's comprehensive profile (identity, address, contract). Accessible to any authenticated user.
router.get("/api/v1/employees/:id", requireAuth, getEmployeeById);
// Create a new employee profile and initial contract. Restricted to administrators.
router.post("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createEmployee);
// Update an existing employee's personal or address data. Restricted to administrators.
router.put("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateEmployee);
// Soft-delete (deactivate) an employee account. Restricted to administrators.
router.delete("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteEmployee);
// ============================================================================
// CONTRACT & ASSIGNMENT DOMAIN
// Manages operational assignments, reporting hierarchies, and job roles.
// ============================================================================
// Retrieve the contract history for an individual employee.
router.get("/api/v1/employees/:id/contracts", requireAuth, getEmployeeContracts);
// Expire current active contracts and execute a new employment agreement.
router.post("/api/v1/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
// ============================================================================
// ORGANIZATIONAL LOOKUP DOMAIN
// Read-only reference endpoints for the frontend UI to populate select options.
// ============================================================================
// Fetch legal entities and parent group structures.
router.get("/api/v1/companies", requireAuth, getCompanies);
router.post("/api/v1/companies", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createCompany);
router.put("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateCompany);
router.delete("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteCompany);
// Fetch structural branch offices and physical locations.
router.get("/api/v1/branches", requireAuth, getBranches);
router.post("/api/v1/branches", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createBranch);
router.put("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateBranch);
router.delete("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteBranch);
// List corporate departments and organizational chart reporting lines.
router.get("/api/v1/departments", requireAuth, getDepartments);
router.post("/api/v1/departments", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createDepartment);
router.put("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateDepartment);
router.delete("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteDepartment);
// List company designations and employment titles.
router.get("/api/v1/jobs", requireAuth, getJobs);
router.post("/api/v1/jobs", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createJob);
router.put("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateJob);
router.delete("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteJob);
// ============================================================================
// SYSTEM & MIGRATION DOMAIN
// High-risk administrative endpoints for bulk data execution.
// ============================================================================
// Process raw bulk data to seed initial employee/partner structures into the DB.
router.post("/api/v1/system/seed-employees", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkSeedEmployees);
// Execute mapping script to establish reporting_to_id and manager_id links.
router.post("/api/v1/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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,10 +4,8 @@ USE hrms_ems;
-- 1. Legal Entities
CREATE TABLE companies (
company_id INT AUTO_INCREMENT PRIMARY KEY,
company_code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
parent_id INT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES companies(company_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -18,7 +16,6 @@ CREATE TABLE branches (
company_id INT NOT NULL,
branch_name VARCHAR(100) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -47,7 +44,6 @@ CREATE TABLE addresses (
pincode VARCHAR(10) NOT NULL,
district VARCHAR(50) NOT NULL,
state VARCHAR(50) NOT NULL,
UNIQUE KEY uq_partner_address_type (partner_id, address_type),
FOREIGN KEY (partner_id) REFERENCES partners(partner_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -56,10 +52,8 @@ CREATE TABLE departments (
department_id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
branch_id INT NOT NULL,
department_code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
parent_id INT NULL,
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
@ -69,10 +63,8 @@ CREATE TABLE departments (
CREATE TABLE job_positions (
job_id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
job_code VARCHAR(20) NOT NULL UNIQUE,
title VARCHAR(50) NOT NULL,
description TEXT NULL,
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -90,61 +82,34 @@ CREATE TABLE employees (
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 8. Legal & Compensation Terms (Changes rarely)
CREATE TABLE employment_terms (
term_id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
work_email VARCHAR(100) NOT NULL,
date_joining DATE NOT NULL,
date_ended DATE NULL,
probation_days INT DEFAULT 90,
salary_structure_id INT NOT NULL,
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 9. Organizational Assignments (Changes on promotion/transfer)
CREATE TABLE employee_assignments (
assignment_id INT AUTO_INCREMENT PRIMARY KEY,
-- 8. Legal & Operational Contracts
CREATE TABLE contracts (
contract_id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
department_id INT NOT NULL,
job_id INT NOT NULL,
reporting_to_id INT NULL,
change_reason ENUM('HIRE', 'TRANSFER', 'PROMOTION', 'MANAGER_CHANGE', 'REORG') NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE NULL,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
work_email VARCHAR(100) NOT NULL UNIQUE,
date_joining DATE NOT NULL,
probation_days INT DEFAULT 90,
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
salary_structure_id INT NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT,
FOREIGN KEY (reporting_to_id) REFERENCES employees(employee_id) ON DELETE SET NULL
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 10. Department Managers (Junction Table with History)
-- 9. Department Managers (Junction Table)
CREATE TABLE department_managers (
department_id INT NOT NULL,
employee_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
removed_at TIMESTAMP NULL,
PRIMARY KEY (department_id, employee_id),
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 11. Branch Admins (For AMS/LMS routing)
CREATE TABLE branch_admins (
employee_id INT NOT NULL,
branch_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (employee_id, branch_id),
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 12. System Sequences
-- 10. System Sequences
CREATE TABLE system_sequences (
sequence_id VARCHAR(50) PRIMARY KEY,
prefix VARCHAR(10) NOT NULL,

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,9 +51,8 @@ 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', 'HALF_DAY', 'WFH_REQUEST', 'ON_DUTY') NOT NULL;
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
target_date DATE NOT NULL,
requested_check_in TIMESTAMP NULL,
requested_check_out TIMESTAMP NULL,

View File

@ -58,8 +58,6 @@ CREATE TABLE leave_applications (
status ENUM('DRAFT', 'PENDING', 'PENDING_LOP', 'APPROVED', 'APPROVED_LOP', 'REJECTED', 'CANCELLED') DEFAULT 'PENDING',
manager_approved_by INT NULL, -- Logical Reference to EMS employees table (Approver)
hr_approved_by INT NULL,
approver_role VARCHAR(20) NOT NULL DEFAULT 'MANAGER',
applicant_role VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE',
FOREIGN KEY (leave_type_id) REFERENCES leave_types(leave_type_id) ON DELETE RESTRICT,
INDEX idx_lms_status_lookup (employee_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

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,179 @@
// 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];
// 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`,
// [user.company_id, currentYear, user.branch_id, today] // Assuming auth.ts injects company_id and branch_id
// );
// 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" };
// }
// };
/**
* 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" };
}
};

16
lms-service/main.ts Normal file
View File

@ -0,0 +1,16 @@
import { Application } from "@oak/oak";
import router from "./routes.ts";
const app = new Application();
const PORT = 8003; // Running LMS on 8003 to avoid conflict with EMS on 8001 and AMS on 8002
app.use(async (ctx, next) => {
console.log(`[LMS] ${ctx.request.method} ${ctx.request.url.pathname}`);
await next();
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log(`LMS Service running on http://localhost:${PORT}`);
await app.listen({ port: PORT });

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,42 +0,0 @@
// packages/core/internal-client.ts
import * as grpc from "@grpc/grpc-js";
import { InternalServiceClient } from "../../generated/ems.ts";
const EMS_URL = Deno.env.get("EMS_URL") || "localhost:8001";
const internalClient = new InternalServiceClient(EMS_URL, grpc.credentials.createInsecure());
// Helper to promisify gRPC calls
const callGrpc = (method: string, payload: any) => {
return new Promise((resolve, reject) => {
(internalClient as any)[method](payload, (err: any, response: any) => {
if (err) reject(err);
else resolve(response);
});
});
};
/**
* Fetches the manager ID for a specific employee from EMS via gRPC.
*/
export const getEmployeeManagerId = async (employeeId: number): Promise<number | null> => {
try {
const response: any = await callGrpc("getManager", { id: employeeId });
return response.managerId || null;
} catch (error) {
console.error("Failed to fetch manager from EMS via gRPC:", error);
return null;
}
};
/**
* Fetches a roster of active employees from EMS via gRPC.
*/
export const getEmployeeRoster = async (ids: number[] = []): Promise<any[]> => {
try {
const response: any = await callGrpc("getRoster", { ids });
return response.data || [];
} catch (error) {
console.error("Failed to fetch roster from EMS via gRPC:", error);
return [];
}
};

View File

@ -1,287 +0,0 @@
syntax = "proto3";
package ams;
// ==========================================
// SERVICES
// ==========================================
service AttendanceService {
rpc GetRawLogs(Empty) returns (RawLogsResponse);
rpc ProcessDailyAttendance(ProcessAttendanceRequest) returns (SuccessResponse);
rpc GetEmployeeSummary(SummaryRequest) returns (SummaryResponse);
rpc GetDailyReport(DailyReportRequest) returns (DailyReportResponse);
rpc GetAdminRangeReport(RangeReportRequest) returns (AdminRangeReportResponse);
rpc GetSingleEmployeeRangeReport(SingleRangeReportRequest) returns (SingleRangeReportResponse);
rpc ExportDailyReport(DailyReportRequest) returns (ExportReportResponse);
rpc ExportAdminRangeReport(RangeReportRequest) returns (ExportReportResponse);
}
service RegularizationService {
rpc CreateRegularizationRequest(CreateRegRequest) returns (SuccessResponse);
rpc GetPendingRegularizations(PendingRegRequest) returns (PendingRegResponse);
rpc ReviewRegularizationRequest(ReviewRegRequest) returns (SuccessResponse);
rpc GetAllRegularizations(Empty) returns (AllRegularizationsResponse); // NEW
}
service DashboardService {
rpc GetDashboardMetrics(DashboardRequest) returns (DashboardMetricsResponse);
}
// ==========================================
// COMMON MESSAGES
// ==========================================
message Empty {}
message SuccessResponse {
bool success = 1;
string message = 2;
}
// ==========================================
// ATTENDANCE MESSAGES
// ==========================================
message ProcessAttendanceRequest {
optional string work_date = 1;
optional string start_date = 2;
optional string end_date = 3;
}
message RawLog {
int32 id = 1;
string attendance_time = 2;
string device_id = 3;
string employee_code = 4;
string employee_name = 5;
}
message RawLogsResponse {
bool success = 1;
repeated RawLog data = 2;
}
message SummaryRequest {
int32 employee_id = 1;
string start_date = 2;
string end_date = 3;
}
message SummaryMetrics {
int32 total_days_tracked = 1;
double total_hours_worked = 2;
int32 full_days = 3;
int32 half_days = 4;
int32 absences = 5;
int32 mispunches = 6;
int32 late_arrivals = 7;
}
message AttendanceRecord {
int32 attendance_id = 1;
string work_date = 2;
string check_in = 3;
string check_out = 4;
double worked_hours = 5;
string check_in_status = 6;
string final_status = 7;
}
message SummaryResponse {
bool success = 1;
SummaryMetrics metrics = 2;
repeated AttendanceRecord history = 3;
}
// --- DAILY REPORT ---
message DailyReportRequest {
string date = 1;
optional int32 company_id = 2;
optional int32 branch_id = 3;
optional int32 department_id = 4;
optional string designation = 5; // Job Title
optional string employee_name = 6;
optional string employee_code = 7;
string file_type = 8;
}
message DailySummary {
int32 total_active_workforce = 1;
int32 present = 2;
int32 late = 3;
int32 mispunches = 4;
int32 absent = 5;
}
message DailyRosterEmployee {
int32 employee_id = 1;
string employee_code = 2;
string full_name = 3;
optional int32 attendance_id = 4;
string status = 5;
optional string check_in = 6;
optional string check_out = 7;
string worked_hours = 8;
string punctuality = 9;
string company_name = 10; // NEW
string branch_name = 11; // NEW
string department_name = 12; // NEW
string designation = 13; // NEW (Job Title)
}
message DailyReportResponse {
bool success = 1;
string date = 2;
DailySummary summary = 3;
repeated DailyRosterEmployee roster = 4;
}
// --- ADMIN RANGE REPORT ---
message RangeReportRequest {
string from_date = 1;
string to_date = 2;
optional int32 branch_id = 3;
optional int32 department_id = 4;
optional int32 company_id = 5;
optional string designation = 6;
optional string employee_name = 7;
optional string employee_code = 8;
string file_type = 9; // <--- ADD THIS LINE
}
message RangeMetrics {
int32 full_days = 1;
int32 half_days = 2;
int32 mispunches = 3;
int32 late_arrivals = 4;
}
message GlobalSummary {
int32 total_records_evaluated = 1;
int32 full_days = 2;
int32 half_days = 3;
int32 late_instances = 4;
int32 mispunches = 5;
}
message AdminRangeEmployee {
int32 employee_id = 1;
string employee_code = 2;
string first_name = 3;
string last_name = 4;
string branch_name = 5;
string department_name = 6;
string company_name = 7; // NEW
string designation = 8; // NEW
RangeMetrics range_metrics = 9;
repeated AttendanceRecord attendance_history = 10;
}
message AdminRangeReportResponse {
bool success = 1;
string from_date = 2;
string to_date = 3;
GlobalSummary global_summary = 4;
repeated AdminRangeEmployee report = 5;
}
// --- SINGLE EMPLOYEE RANGE REPORT ---
message SingleRangeReportRequest {
int32 employee_id = 1;
string from_date = 2;
string to_date = 3;
}
message SingleRangeEmployee {
int32 employee_id = 1;
string employee_code = 2;
string first_name = 3;
string last_name = 4;
}
message SingleRangeReportResponse {
bool success = 1;
SingleRangeEmployee employee = 2;
string from_date = 3;
string to_date = 4;
int32 total_days = 5;
repeated AttendanceRecord history = 6;
}
// ==========================================
// DASHBOARD MESSAGES
// ==========================================
message DashboardRequest {
string date = 1;
optional int32 company_id = 2;
optional int32 branch_id = 3;
optional int32 department_id = 4;
}
message DashboardMetricsResponse {
bool success = 1;
int32 total_employees = 2;
int32 total_checked_in = 3;
int32 total_not_checked_in = 4;
}
// ==========================================
// REGULARIZATION MESSAGES
// ==========================================
message CreateRegRequest {
optional int32 attendance_id = 1;
int32 employee_id = 2;
string regularization_type = 3;
string target_date = 4;
optional string requested_check_in = 5;
optional string requested_check_out = 6;
string reason = 7;
}
message PendingRegRequest {
string user_role = 1;
repeated int32 managed_branch_ids = 2;
}
message PendingRegData {
int32 regularization_id = 1;
int32 employee_id = 2;
string target_date = 3;
string regularization_type = 4;
optional string requested_check_in = 5;
optional string requested_check_out = 6;
string reason = 7;
string status = 8;
int32 branch_id = 9;
string employee_code = 10;
string first_name = 11;
string last_name = 12;
string branch_name = 13;
string reviewer_name = 14;
}
message PendingRegResponse {
bool success = 1;
repeated PendingRegData data = 2;
}
message ReviewRegRequest {
int32 regularization_id = 1;
string action = 2;
int32 reviewer_id = 3;
string user_role = 4;
repeated int32 managed_branch_ids = 5;
}
message AllRegularizationsResponse {
bool success = 1;
repeated PendingRegData data = 2;
}
// Add to common messages
message ExportReportResponse {
bytes file_content = 1;
string file_name = 2;
}

View File

@ -1,414 +0,0 @@
syntax = "proto3";
package ems;
// ==========================================
// SERVICES
// ==========================================
service EmployeeService {
rpc GetEmployees(GetEmployeesRequest) returns (EmployeesListResponse);
rpc GetEmployeeById(IdRequest) returns (EmployeeDetailResponse);
rpc GetEmployeeHistory(IdRequest) returns (EmployeeHistoryResponse);
rpc CreateEmployee(CreateEmployeeRequest) returns (CreateEmployeeResponse);
rpc UpdateEmployee(UpdateEmployeeRequest) returns (SuccessResponse);
}
service InternalService {
rpc GetRoster(RosterRequest) returns (RosterResponse);
rpc GetManager(IdRequest) returns (ManagerResponse);
}
service ContractService {
rpc GetEmployeeContracts(IdRequest) returns (ContractsResponse);
rpc CreateContract(CreateContractRequest) returns (ContractCreateResponse);
}
service DashboardService {
rpc GetDashboardMetrics(DashboardRequest) returns (DashboardMetricsResponse);
}
service LookupService {
rpc GetCompanies(LookupRequest) returns (CompaniesResponse);
rpc CreateCompany(CompanyData) returns (LookupCreateResponse);
rpc UpdateCompany(UpdateLookupRequest) returns (SuccessResponse);
rpc DeleteCompany(IdRequest) returns (SuccessResponse);
rpc GetBranches(LookupRequest) returns (BranchesResponse);
rpc CreateBranch(BranchData) returns (LookupCreateResponse);
rpc UpdateBranch(UpdateLookupRequest) returns (SuccessResponse);
rpc DeleteBranch(IdRequest) returns (SuccessResponse);
rpc GetDepartments(LookupRequest) returns (DepartmentsResponse);
rpc CreateDepartment(DepartmentData) returns (LookupCreateResponse);
rpc UpdateDepartment(UpdateLookupRequest) returns (SuccessResponse);
rpc DeleteDepartment(IdRequest) returns (SuccessResponse);
rpc GetJobs(LookupRequest) returns (JobsResponse);
rpc CreateJob(JobData) returns (LookupCreateResponse);
rpc UpdateJob(UpdateLookupRequest) returns (SuccessResponse);
rpc DeleteJob(IdRequest) returns (SuccessResponse);
rpc GetDepartmentManagers(DepartmentManagersRequest) returns (ManagersResponse);
}
// ==========================================
// COMMON MESSAGES
// ==========================================
message SuccessResponse {
bool success = 1;
string message = 2;
}
message IdRequest {
int32 id = 1;
}
message LookupRequest {
optional int32 company_id = 1;
optional int32 branch_id = 2;
optional int32 department_id = 3;
optional bool is_active = 4;
optional string sort_by = 5;
optional string sort_order = 6;
}
message LookupCreateResponse {
bool success = 1;
string message = 2;
int32 id = 3;
string code = 4;
}
message UpdateLookupRequest {
int32 id = 1;
CompanyData company_data = 2;
BranchData branch_data = 3;
DepartmentData department_data = 4;
JobData job_data = 5;
}
// ==========================================
// EMPLOYEE MESSAGES
// ==========================================
message GetEmployeesRequest {
optional int32 company_id = 1;
optional int32 branch_id = 2;
optional int32 department_id = 3;
optional bool is_active = 4;
}
message EmployeeSummary {
int32 employee_id = 1;
string employee_code = 2;
bool is_active = 3;
string first_name = 4; // CHANGED from full_name
string last_name = 5; // NEW
string work_email = 6; // Renumbered
string job_name = 7;
string department_name = 8;
string branch_name = 9;
string company_name = 10;
}
message EmployeesListResponse {
bool success = 1;
int32 count = 2;
repeated EmployeeSummary data = 3;
}
message Address {
string type = 1;
string door_number = 2;
string landmark = 3;
string line = 4;
string pincode = 5;
string district = 6;
string state = 7;
}
message EmployeeDetail {
int32 employee_id = 1;
string employee_code = 2;
bool is_active = 3;
string first_name = 4;
string last_name = 5;
string dob = 6;
string gender = 7;
string personal_email = 8;
string personal_phone = 9;
string work_email = 10;
string date_joining = 11;
int32 probation_days = 12;
string contract_status = 13;
int32 salary_structure_id = 14;
string department = 15;
int32 department_id = 16;
string job_name = 17; // RENAMED from designation
int32 job_id = 18;
string manager_first_name = 19;
string manager_last_name = 20;
string manager_employee_code = 21;
repeated Address addresses = 22;
string company_name = 23; // ADDED
int32 company_id = 24; // ADDED
string branch_name = 25; // ADDED
int32 branch_id = 26; // ADDED
}
message EmployeeDetailResponse {
bool success = 1;
EmployeeDetail data = 2;
}
message AssignmentHistory {
int32 assignment_id = 1;
string change_reason = 2;
string effective_from = 3;
string effective_to = 4;
bool is_current = 5;
string department = 6;
string job_title = 7;
string manager_first_name = 8;
string manager_last_name = 9;
}
message EmployeeHistoryResponse {
bool success = 1;
repeated AssignmentHistory history = 2;
}
message CreateEmployeeRequest {
string first_name = 1;
string last_name = 2;
string dob = 3;
string gender = 4;
string personal_email = 5;
string personal_phone = 6;
Address address = 7;
optional int32 company_id = 8;
optional int32 branch_id = 9;
optional int32 department_id = 10;
optional int32 job_id = 11;
string work_email = 12;
string date_joining = 13;
optional int32 probation_days = 14;
optional int32 salary_structure_id = 15;
optional int32 reporting_to_id = 16;
optional bool is_active = 17;
}
message CreateEmployeeResponse {
bool success = 1;
string message = 2;
int32 employee_id = 3;
string employee_code = 4;
}
message UpdateEmployeeRequest {
int32 id = 1;
CreateEmployeeRequest data = 2;
}
// ==========================================
// LOOKUP MESSAGES
// ==========================================
message CompanyData {
string name = 1;
optional int32 parent_id = 2;
bool is_active = 3;
optional string company_code = 4; // Used for create
}
message Company {
int32 company_id = 1;
string company_code = 2;
string name = 3;
bool is_active = 4;
}
message CompaniesResponse {
bool success = 1;
repeated Company data = 2;
}
message BranchData {
int32 company_id = 1;
string branch_name = 2;
string code = 3;
bool is_active = 4;
}
message Branch {
int32 branch_id = 1;
string code = 2;
string branch_name = 3;
bool is_active = 4;
string company_code = 5;
string company_name = 6;
}
message BranchesResponse {
bool success = 1;
repeated Branch data = 2;
}
message DepartmentData {
int32 company_id = 1;
int32 branch_id = 2;
string name = 3;
optional int32 parent_id = 4;
optional int32 manager_id = 5;
bool is_active = 6;
optional string department_code = 7;
}
message Department {
int32 department_id = 1;
string department_code = 2;
string department_name = 3;
bool is_active = 4;
string branch_name = 5;
string company_name = 6;
string managers = 7;
}
message DepartmentsResponse {
bool success = 1;
repeated Department data = 2;
}
message JobData {
int32 department_id = 1;
string title = 2;
string description = 3;
bool is_active = 4;
optional string job_code = 5;
}
message Job {
int32 job_id = 1;
string job_code = 2;
string job_name = 3;
bool is_active = 4;
string department_name = 5;
string branch_name = 6;
string company_name = 7;
string description = 8;
}
message JobsResponse {
bool success = 1;
repeated Job data = 2;
}
// ==========================================
// INTERNAL SERVICE MESSAGES
// ==========================================
message RosterRequest {
repeated int32 ids = 1;
}
message RosterEmployee {
int32 employee_id = 1;
string employee_code = 2;
bool is_active = 3;
string full_name = 4;
int32 branch_id = 5;
string branch_name = 6;
int32 company_id = 7;
string company_name = 8;
string department_name = 9;
int32 department_id = 10;
string job_title = 11;
string manager_name = 12;
string manager_id = 13;
}
message RosterResponse {
bool success = 1;
repeated RosterEmployee data = 2;
}
message ManagerResponse {
bool success = 1;
int32 manager_id = 2;
}
// ==========================================
// CONTRACT MESSAGES
// ==========================================
message Contract {
int32 term_id = 1;
string work_email = 2;
string date_joining = 3;
int32 probation_days = 4;
string status = 5;
int32 salary_structure_id = 6;
int32 assignment_id = 7;
string change_reason = 8;
string effective_from = 9;
string effective_to = 10;
bool is_current = 11;
string department = 12;
string designation = 13;
}
message ContractsResponse {
bool success = 1;
int32 count = 2;
repeated Contract data = 3;
}
message CreateContractRequest {
int32 employee_id = 1;
int32 department_id = 2;
int32 job_id = 3;
optional int32 reporting_to_id = 4;
string change_reason = 5;
}
message ContractCreateResponse {
bool success = 1;
string message = 2;
int32 assignment_id = 3;
}
// ==========================================
// DASHBOARD MESSAGES
// ==========================================
message DashboardRequest {
optional int32 company_id = 1;
optional int32 branch_id = 2;
optional int32 department_id = 3;
}
message DashboardMetricsResponse {
bool success = 1;
int32 total_companies = 2;
int32 total_branches = 3;
int32 total_departments = 4;
int32 total_jobs = 5;
int32 total_employees = 6;
}
// ==========================================
// MANAGER MESSAGES
// ==========================================
message ManagerData {
int32 employee_id = 1;
string employee_code = 2;
string first_name = 3;
string last_name = 4;
int32 department_id = 5;
string department_name = 6;
}
message ManagersResponse {
bool success = 1;
repeated ManagerData data = 2;
}
message DepartmentManagersRequest {
int32 department_id = 1;
}

View File

@ -1,276 +0,0 @@
syntax = "proto3";
package lms;
// ==========================================
// SERVICES
// ==========================================
service AdminService {
rpc CreateLeaveType(LeaveTypeData) returns (CreateResponse);
rpc GetLeaveTypes(CompanyIdRequest) returns (LeaveTypesResponse);
rpc CreatePolicyRule(PolicyRuleData) returns (CreateResponse);
rpc CreateCompanyHoliday(HolidayList) returns (SuccessResponse);
rpc UpdateWorkSettings(WorkSettingsData) returns (SuccessResponse);
}
service EmployeeService {
rpc GetLeaveBalances(EmployeeActionRequest) returns (BalancesResponse);
rpc GetLeaveHistory(EmployeeActionRequest) returns (HistoryResponse);
rpc GetValidOptionalHolidays(UserContextRequest) returns (HolidaysResponse);
}
service LeaveService {
rpc ApplyForLeave(LeaveApplicationPayload) returns (ApplyLeaveResponse);
}
service ManagerService {
rpc GetPendingManagerLeaves(UserContextRequest) returns (PendingLeavesResponse);
rpc ApproveLeaveManager(ManagerActionRequest) returns (SuccessResponse);
rpc RejectLeaveManager(ManagerActionRequest) returns (SuccessResponse);
}
service ReportsService {
rpc GetLedgerReport(LedgerReportRequest) returns (LedgerReportResponse);
}
// ==========================================
// COMMON MESSAGES
// ==========================================
message SuccessResponse {
bool success = 1;
string message = 2;
}
message CreateResponse {
bool success = 1;
string message = 2;
int32 id = 3;
}
message CompanyIdRequest {
int32 company_id = 1;
}
message UserContextRequest {
int32 employee_id = 1;
string role = 2;
string managed_branches = 3; // Comma-separated string
optional int32 target_employee_id = 4; // Used when managers/admins query other employees
optional int32 year = 5;
}
message EmployeeActionRequest {
int32 employee_id = 1;
int32 year = 2;
}
// ==========================================
// ADMIN MESSAGES
// ==========================================
message LeaveTypeData {
int32 company_id = 1;
string name = 2;
bool requires_allocation = 3;
bool carry_over_allowed = 4;
double max_carry_over_days = 5;
}
message LeaveType {
int32 leave_type_id = 1;
string name = 2;
bool requires_allocation = 3;
bool carry_over_allowed = 4;
double max_carry_over_days = 5;
}
message LeaveTypesResponse {
bool success = 1;
repeated LeaveType data = 2;
}
message PolicyRuleData {
int32 leave_type_id = 1;
int32 company_id = 2;
optional int32 branch_id = 3;
int32 calendar_year = 4;
double yearly_allowance = 5;
optional double max_days_per_month = 6;
optional double max_consecutive_days = 7;
bool apply_sandwich_policy = 8;
}
message HolidayData {
int32 company_id = 1;
optional int32 branch_id = 2;
int32 calendar_year = 3;
string holiday_date = 4;
string holiday_type = 5;
string holiday_name = 6;
}
message HolidayList {
repeated HolidayData holidays = 1;
}
message WorkSettingsData {
int32 company_id = 1;
optional int32 branch_id = 2;
string weekly_off_days = 3; // JSON string e.g. "[0,6]"
string effective_from = 4;
}
// ==========================================
// EMPLOYEE MESSAGES
// ==========================================
message BalanceData {
int32 leave_type_id = 1;
string leave_type_name = 2;
double granted_days = 3;
double used_days = 4;
double available_balance = 5;
}
message BalancesResponse {
bool success = 1;
repeated BalanceData balances = 2;
}
message HistoryData {
int32 application_id = 1;
string leave_type_name = 2;
string date_from = 3;
string date_to = 4;
double number_of_days = 5;
string status = 6;
string reason = 7;
}
message HistoryResponse {
bool success = 1;
repeated HistoryData history = 2;
}
message HolidayInfo {
int32 holiday_id = 1;
string holiday_name = 2;
string holiday_date = 3;
}
message HolidaysResponse {
bool success = 1;
repeated HolidayInfo holidays = 2;
}
// ==========================================
// LEAVE APPLICATION MESSAGES
// ==========================================
message LeaveApplicationPayload {
int32 employee_id = 1;
int32 leave_type_id = 2;
int32 company_id = 3;
int32 branch_id = 4;
string date_from = 5;
string date_to = 6;
bool is_half_day = 7;
string reason = 8;
string applicant_role = 9; // NEW
}
message ApplyLeaveResponse {
bool success = 1;
string message = 2;
double total_requested = 3;
double paid_days = 4;
double lop_days = 5;
repeated int32 application_ids = 6;
}
// ==========================================
// MANAGER MESSAGES
// ==========================================
message ManagerActionRequest {
int32 manager_id = 1;
int32 application_id = 2;
optional string reason = 3;
string user_role = 4; // NEW
}
message PendingLeaveData {
int32 application_id = 1;
int32 employee_id = 2;
int32 leave_type_id = 3;
string leave_type = 4;
string date_from = 5;
string date_to = 6;
double number_of_days = 7;
string reason = 8;
string status = 9;
string employee_code = 10;
string first_name = 11;
string last_name = 12;
}
message PendingLeavesResponse {
bool success = 1;
repeated PendingLeaveData data = 2;
}
// ==========================================
// REPORTS MESSAGES
// ==========================================
message LedgerReportRequest {
int32 month = 1;
int32 year = 2;
int32 company_id = 3;
}
message LedgerData {
int32 employee_id = 1;
string leave_type = 2;
double total_granted = 3;
double total_used = 4;
double opening_balance = 5;
double closing_balance = 6;
string employee_code = 7;
string employee_name = 8;
}
message LedgerReportResponse {
bool success = 1;
int32 report_month = 2;
int32 report_year = 3;
repeated LedgerData data = 4;
}
// ==========================================
// HR MANAGER SERVICE (NEW)
// ==========================================
service HRManagerService {
rpc GetAllLeaveApplications(EmptyRequest) returns (AllApplicationsResponse);
}
message EmptyRequest {}
message AllApplicationData {
int32 application_id = 1;
int32 employee_id = 2;
string leave_type = 3;
string date_from = 4;
string date_to = 5;
double number_of_days = 6;
string status = 7;
string reason = 8;
string employee_code = 9;
string first_name = 10;
string last_name = 11;
string department_name = 12;
string job_title = 13;
string manager_name = 14;
}
message AllApplicationsResponse {
bool success = 1;
repeated AllApplicationData data = 2;
}

View File

@ -1,24 +0,0 @@
#!/bin/bash
set -e
echo "🔨 Generating gRPC code using the persistent proto-compiler container..."
mkdir -p generated
podman exec hrms_proto_compiler sh -c "
protoc \
--plugin=protoc-gen-ts_proto=/usr/local/bin/protoc-gen-ts_proto \
--ts_proto_out=./generated \
--ts_proto_opt=esModuleInterop=true,useOptionals=true,outputClientImpl=grpc-js,outputServices=grpc-js,env=node \
-I ./protos \
./protos/*.proto
"
# FIX: Prepend // @ts-nocheck to all generated files to prevent Deno strict type-checking errors
for file in generated/*.ts; do
if [ -f "$file" ]; then
# Check if the first line is not already @ts-nocheck
if ! head -n 1 "$file" | grep -q "@ts-nocheck"; then
echo "// @ts-nocheck" | cat - "$file" > temp && mv temp "$file"
fi
fi
done
echo "âś… gRPC code generation complete!"

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,202 +0,0 @@
// services/ams/handlers/attendance.handler.ts
import * as AttendanceService from "../services/attendance.service.ts";
export const AttendanceServiceImplementation = {
async getRawLogs(req: any) {
try {
const data = await AttendanceService.getRawLogs();
const mappedData = data.map((log: any) => ({
id: Number(log.id) || 0,
attendanceTime: log.attendance_time ? new Date(log.attendance_time).toISOString() : "",
deviceId: log.device_id || "",
employeeCode: log.employee_code || "",
employeeName: log.employee_name || ""
}));
return { success: true, data: mappedData };
} catch (error: any) {
console.error("gRPC getRawLogs Error:", error);
return { success: false, data: [] };
}
},
async processDailyAttendance(req: any) {
try {
// FIX: Map camelCase gRPC payload back to snake_case for the DB service layer
const dbPayload = {
work_date: req.workDate,
start_date: req.startDate,
end_date: req.endDate
};
const result = await AttendanceService.processDailyAttendance(dbPayload);
return { success: true, message: `Successfully processed ${result.processed} logs across ${result.days} days.` };
} catch (error: any) {
console.error("gRPC processDailyAttendance Error:", error);
return { success: false, message: error.message };
}
},
async getEmployeeSummary(req: any) {
try {
const data = await AttendanceService.getEmployeeSummary(Number(req.employeeId), req.startDate, req.endDate);
const metrics = data.metrics || {};
const mappedMetrics = {
totalDaysTracked: Number(metrics.total_days_tracked) || 0,
totalHoursWorked: Number(metrics.total_hours_worked) || 0,
fullDays: Number(metrics.full_days) || 0,
halfDays: Number(metrics.half_days) || 0,
absences: Number(metrics.absences) || 0,
mispunches: Number(metrics.mispunches) || 0,
lateArrivals: Number(metrics.late_arrivals) || 0
};
const mappedHistory = (data.history || []).map((h: any) => ({
attendanceId: Number(h.attendance_id) || 0,
workDate: h.work_date || "",
checkIn: h.check_in || "",
checkOut: h.check_out || "",
workedHours: Number(h.worked_hours) || 0,
checkInStatus: h.check_in_status || "",
finalStatus: h.final_status || ""
}));
return { success: true, metrics: mappedMetrics, history: mappedHistory };
} catch (error: any) {
console.error("gRPC getEmployeeSummary Error:", error);
// FIX: Use undefined instead of null for Protobuf nested messages
return { success: false, metrics: undefined, history: [] };
}
},
async getDailyReport(req: any) {
try {
const data = await AttendanceService.getDailyReport(req);
const summary = data.summary || {};
const mappedSummary = {
totalActiveWorkforce: Number(summary.total_active_workforce) || 0,
present: Number(summary.present) || 0,
late: Number(summary.late) || 0,
mispunches: Number(summary.mispunches) || 0,
absent: Number(summary.absent) || 0
};
const mappedRoster = (data.roster || []).map((r: any) => ({
employeeId: Number(r.employee_id) || 0,
employeeCode: r.employee_code || "",
fullName: r.full_name || "",
attendanceId: r.attendance_id ? Number(r.attendance_id) : undefined,
status: r.status || "",
checkIn: r.check_in || undefined,
checkOut: r.check_out || undefined,
workedHours: r.worked_hours ? String(r.worked_hours) : "0.00",
punctuality: r.punctuality || "N/A",
companyName: r.company_name || "",
branchName: r.branch_name || "",
departmentName: r.department_name || "",
designation: r.designation || ""
}));
return { success: true, date: data.date || "", summary: mappedSummary, roster: mappedRoster };
} catch (error: any) {
console.error("gRPC getDailyReport Error:", error);
return { success: false, date: "", summary: undefined, roster: [] };
}
},
async getAdminRangeReport(req: any) {
try {
const data = await AttendanceService.getAdminRangeReport(req);
const globalSummary = data.global_summary || {};
const mappedGlobalSummary = {
totalRecordsEvaluated: Number(globalSummary.total_records_evaluated) || 0,
fullDays: Number(globalSummary.full_days) || 0,
halfDays: Number(globalSummary.half_days) || 0,
lateInstances: Number(globalSummary.late_instances) || 0,
mispunches: Number(globalSummary.mispunches) || 0
};
const mappedReport = (data.report || []).map((emp: any) => ({
employeeId: Number(emp.employee_id) || 0,
employeeCode: emp.employee_code || "",
firstName: emp.first_name || "",
lastName: emp.last_name || "",
branchName: emp.branch_name || "",
departmentName: emp.department_name || "",
companyName: emp.company_name || "",
designation: emp.designation || "",
rangeMetrics: {
fullDays: Number(emp.range_metrics?.full_days) || 0,
halfDays: Number(emp.range_metrics?.half_days) || 0,
mispunches: Number(emp.range_metrics?.mispunches) || 0,
lateArrivals: Number(emp.range_metrics?.late_arrivals) || 0
},
attendanceHistory: (emp.attendance_history || []).map((h: any) => ({
attendanceId: Number(h.attendance_id) || 0,
workDate: h.work_date || "",
checkIn: h.check_in || "",
checkOut: h.check_out || "",
workedHours: Number(h.worked_hours) || 0,
checkInStatus: h.check_in_status || "",
finalStatus: h.final_status || ""
}))
}));
return {
success: true,
fromDate: data.date_range?.from || req.fromDate,
toDate: data.date_range?.to || req.toDate,
globalSummary: mappedGlobalSummary,
report: mappedReport
};
} catch (error: any) {
console.error("gRPC getAdminRangeReport Error:", error);
return { success: false, fromDate: "", toDate: "", globalSummary: undefined, report: [] };
}
},
async getSingleEmployeeRangeReport(req: any) {
try {
const data = await AttendanceService.getSingleEmployeeRangeReport(Number(req.employeeId), req.fromDate, req.toDate);
const emp = data.employee || {};
const mappedEmployee = {
employeeId: Number(emp.employee_id) || 0,
employeeCode: emp.employee_code || "",
firstName: emp.first_name || "",
lastName: emp.last_name || ""
};
const mappedHistory = (data.history || []).map((h: any) => ({
attendanceId: Number(h.attendance_id) || 0,
workDate: h.work_date || "",
checkIn: h.check_in || "",
checkOut: h.check_out || "",
workedHours: Number(h.worked_hours) || 0,
checkInStatus: h.check_in_status || "",
finalStatus: h.final_status || ""
}));
return {
success: true,
employee: mappedEmployee,
fromDate: data.range?.from || req.fromDate,
toDate: data.range?.to || req.toDate,
totalDays: Number(data.total_days) || 0,
history: mappedHistory
};
} catch (error: any) {
console.error("gRPC getSingleEmployeeRangeReport Error:", error);
return { success: false, employee: undefined, fromDate: "", toDate: "", totalDays: 0, history: [] };
}
},
// Add these to AttendanceServiceImplementation
async exportDailyReport(req: any) {
try {
const result = await AttendanceService.exportDailyReport(req);
return { success: true, fileContent: result.file_content, fileName: result.file_name };
} catch (error: any) {
console.error("gRPC exportDailyReport Error:", error);
return { success: false, fileName: "" };
}
},
async exportAdminRangeReport(req: any) {
try {
const result = await AttendanceService.exportAdminRangeReport(req);
return { success: true, fileContent: result.file_content, fileName: result.file_name };
} catch (error: any) {
console.error("gRPC exportAdminRangeReport Error:", error);
return { success: false, fileName: "" };
}
}
};

View File

@ -1,18 +0,0 @@
import * as AttendanceService from "../services/attendance.service.ts";
export const DashboardServiceImplementation = {
async getDashboardMetrics(req: any) {
try {
const data = await AttendanceService.getDashboardMetrics(req);
return {
success: true,
totalEmployees: Number(data.total_employees) || 0,
totalCheckedIn: Number(data.total_checked_in) || 0,
totalNotCheckedIn: Number(data.total_not_checked_in) || 0
};
} catch (error: any) {
console.error("gRPC getDashboardMetrics Error:", error);
return { success: false, totalEmployees: 0, totalCheckedIn: 0, totalNotCheckedIn: 0 };
}
}
};

View File

@ -1,119 +0,0 @@
// services/ams/handlers/regularization.handler.ts
import * as RegService from "../services/regularization.service.ts";
// FIX: Timezone safe date formatters
const formatDateTime = (dateVal: any): string => {
if (!dateVal) return "";
if (dateVal instanceof Date) {
const offset = dateVal.getTimezoneOffset() * 60000;
const localDate = new Date(dateVal.getTime() - offset);
return localDate.toISOString().replace("T", " ").substring(0, 19);
}
return String(dateVal).replace("T", " ").substring(0, 19);
};
const formatDate = (dateVal: any): string => {
if (!dateVal) return "";
if (dateVal instanceof Date) {
const offset = dateVal.getTimezoneOffset() * 60000;
const localDate = new Date(dateVal.getTime() - offset);
return localDate.toISOString().split("T")[0];
}
return String(dateVal).split("T")[0];
};
export const RegularizationServiceImplementation = {
async createRegularizationRequest(req: any) {
try {
const dbPayload = {
attendance_id: req.attendanceId || null,
employee_id: req.employeeId,
regularization_type: req.regularizationType,
target_date: req.targetDate,
requested_check_in: req.requestedCheckIn || null,
requested_check_out: req.requestedCheckOut || null,
reason: req.reason,
};
await RegService.createRegularizationRequest(dbPayload);
return { success: true, message: "Regularization request submitted successfully." };
} catch (error: any) {
console.error("gRPC createRegularizationRequest Error:", error);
return { success: false, message: error.message };
}
},
async getPendingRegularizations(req: any) {
try {
const user = {
role: req.userRole,
managed_branches: req.managedBranchIds || []
};
const data = await RegService.getPendingRegularizations(user);
const mappedData = data.map((reg: any) => ({
regularizationId: Number(reg.regularization_id) || 0,
employeeId: Number(reg.employee_id) || 0,
targetDate: formatDate(reg.target_date),
regularizationType: reg.regularization_type || "",
requestedCheckIn: formatDateTime(reg.requested_check_in) || undefined,
requestedCheckOut: formatDateTime(reg.requested_check_out) || undefined,
reason: reg.reason || "",
status: reg.status || "",
branchId: Number(reg.branch_id) || 0,
employeeCode: reg.employee_code || "",
firstName: reg.first_name || "",
lastName: reg.last_name || "",
branchName: reg.branch_name || ""
}));
return { success: true, data: mappedData };
} catch (error: any) {
console.error("gRPC getPendingRegularizations Error:", error);
return { success: false, data: [] };
}
},
async reviewRegularizationRequest(req: any) {
try {
const user = {
employee_id: Number(req.reviewerId) || 0,
role: req.userRole,
managed_branches: req.managedBranchIds || []
};
const body = {
regularization_id: Number(req.regularizationId) || 0,
action: req.action
};
await RegService.reviewRegularizationRequest(user, body);
return { success: true, message: "Request has been successfully processed." };
} catch (error: any) {
console.error("gRPC reviewRegularizationRequest Error:", error);
return { success: false, message: error.message };
}
},
async getAllRegularizations(req: any) {
try {
const data = await RegService.getAllRegularizations();
const mappedData = data.map((reg: any) => ({
regularizationId: Number(reg.regularization_id) || 0,
employeeId: Number(reg.employee_id) || 0,
targetDate: formatDate(reg.target_date),
regularizationType: reg.regularization_type || "",
requestedCheckIn: formatDateTime(reg.requested_check_in) || "",
requestedCheckOut: formatDateTime(reg.requested_check_out) || "",
reason: reg.reason || "",
status: reg.status || "",
branchId: Number(reg.branch_id) || 0,
employeeCode: reg.employee_code || "",
firstName: reg.first_name || "",
lastName: reg.last_name || "",
branchName: reg.branch_name || "N/A",
reviewerName: reg.reviewer_name || "N/A"
}));
return { success: true, data: mappedData };
} catch (e: any) {
console.error("gRPC getAllRegularizations Error:", e);
return { success: false, data: [] };
}
}
};

View File

@ -1,33 +0,0 @@
// services/ams/main.ts
import * as grpc from "@grpc/grpc-js";
import { AttendanceServiceService, RegularizationServiceService, DashboardServiceService } from "../../generated/ams.ts";
import { AttendanceServiceImplementation } from "./handlers/attendance.handler.ts";
import { RegularizationServiceImplementation } from "./handlers/regularization.handler.ts";
import { DashboardServiceImplementation } from "./handlers/dashboard.handler.ts";
const PORT = Number(Deno.env.get("AMS_PORT")) || 8002;
const wrap = (impl: any) => {
const wrapped: any = {};
for (const key in impl) {
wrapped[key] = async (call: any, callback: any) => {
try {
const res = await impl[key](call.request);
callback(null, res);
} catch (err: any) {
callback(err);
}
};
}
return wrapped;
};
const server = new grpc.Server();
server.addService(AttendanceServiceService, wrap(AttendanceServiceImplementation));
server.addService(RegularizationServiceService, wrap(RegularizationServiceImplementation));
server.addService(DashboardServiceService, wrap(DashboardServiceImplementation));
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) { console.error("Failed to start gRPC server:", err); return; }
console.log(`🚀 AMS gRPC Service running on port ${port}`);
});

View File

@ -1,162 +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(",");
// console.log("DB Query Params:", { fromDate, toDate, employeeIds });
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 query = `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`;
let rows;
if (conn) {
[rows] = await conn.execute(query, [branchId, companyId]);
} else {
[rows] = await pool.execute(query, [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]
);
};
// Fetch the applicable policy for the company/branch
export const findAttendancePolicy = async (companyId: number, branchId: number) => {
const [rows]: any = await pool.execute(
`SELECT * FROM attendance_policies
WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) AND is_active = TRUE
ORDER BY branch_id DESC LIMIT 1`,
[companyId, branchId]
);
// Fallback to hardcoded defaults if HR hasn't configured it yet
return rows[0] || { min_hours_full_day: 7.0, min_hours_half_day: 4.0, late_grace_period_minutes: 10, max_lates_allowed_per_month: 2 };
};
// Count how many lates the employee has already had this month
export const countLatesThisMonth = async (employeeId: number, targetDate: string, conn: any) => {
const dateObj = new Date(targetDate);
const year = dateObj.getFullYear();
const month = dateObj.getMonth() + 1; // 1-12
const firstDayOfMonth = `${year}-${String(month).padStart(2, '0')}-01`;
const [rows]: any = await conn.execute(
`SELECT COUNT(*) as late_count
FROM processed_daily_attendance
WHERE employee_id = ? AND check_in_status = 'LATE'
AND work_date >= ? AND work_date < ?`,
[employeeId, firstDayOfMonth, targetDate]
);
return rows[0]?.late_count || 0;
};

View File

@ -1,55 +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 || null, 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`;
// FIX: Cast result as any[]
const [rows]: any[] = await pool.query(query, params);
return rows;
};
export const findRegularizationById = async (id: number, conn: any) => {
// FIX: Cast result as 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]
);
};
export const findAllRegularizations = async () => {
// FIX: Cast result as any[]
const [rows]: any[] = await pool.execute(
`SELECT regularization_id, employee_id, target_date, regularization_type,
requested_check_in, requested_check_out, reason, status, branch_id, reviewed_by_id
FROM attendance_regularizations
ORDER BY target_date DESC`
);
return rows;
};

View File

@ -1,820 +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";
import ExcelJS from "npm:exceljs@4.4.0";
import PDFDocument from "npm:pdfkit@0.13.0";
// Helper function to draw tables in PDF
function drawPdfTable(doc: any, headers: string[], rows: any[], columnWidths: number[]) {
const startX = doc.page.margins.left;
let y = doc.y;
const cellPadding = 5;
// 1. Draw Headers
doc.font('Helvetica-Bold').fontSize(9);
let headerHeight = 20;
headers.forEach((header, i) => {
const textHeight = doc.heightOfString(header, { width: columnWidths[i] - cellPadding * 2 });
headerHeight = Math.max(headerHeight, textHeight + cellPadding * 2);
});
let x = startX;
headers.forEach((header, i) => {
// Dark Green background, White text, Black border (Matching Excel)
doc.fill('#063B00').rect(x, y, columnWidths[i], headerHeight).fill();
doc.fillColor('#FFFFFF').text(header, x + cellPadding, y + cellPadding, { width: columnWidths[i] - cellPadding * 2, align: 'center' });
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, columnWidths[i], headerHeight).stroke();
x += columnWidths[i];
});
y += headerHeight;
// 2. Draw Rows
doc.font('Helvetica').fontSize(8);
rows.forEach((row: any) => {
let rowHeight = 15;
row.forEach((cell: any, i: number) => {
const textHeight = doc.heightOfString(String(cell ?? '-'), { width: columnWidths[i] - cellPadding * 2 });
rowHeight = Math.max(rowHeight, textHeight + cellPadding * 2);
});
if (y + rowHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
y = doc.page.margins.top;
}
x = startX;
row.forEach((cell: any, i: number) => {
let textColor = '#000000'; // Default black
let bgColor = null;
// Apply background and text colors for the Status column (always the last column)
if (i === headers.length - 1) {
const statusStr = String(cell ?? '').toUpperCase();
if (statusStr === 'FULL_DAY') {
bgColor = '#C6EFCE'; textColor = '#006100';
} else if (statusStr === 'ABSENT') {
bgColor = '#FFC7CE'; textColor = '#9C0006';
} else if (statusStr === 'HALF_DAY') {
bgColor = '#FFEB9C'; textColor = '#9C6500';
} else if (statusStr === 'MISPUNCH') {
bgColor = '#B1A0C7'; textColor = '#4B2E83';
} else if (statusStr === 'LATE') {
bgColor = '#FFC000'; textColor = '#000000';
}
}
// Draw background color if applicable
if (bgColor) {
doc.fillColor(bgColor).rect(x, y, columnWidths[i], rowHeight).fill();
}
// Draw text
doc.fillColor(textColor).text(String(cell ?? '-'), x + cellPadding, y + cellPadding, { width: columnWidths[i] - cellPadding * 2 });
// Draw black border for rows
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, columnWidths[i], rowHeight).stroke();
x += columnWidths[i];
});
y += rowHeight;
});
doc.y = y + 10;
}
// Helper function specifically for Excel Daily Report generation
async function generateExcelDailyReport(roster: any[], formattedDate: string, dayName: string, generatedDate: string, summary: any): Promise<Uint8Array> {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Daily Report');
// Merge cells across 12 columns (A to L)
worksheet.mergeCells('A1:L1');
const titleCell = worksheet.getCell('A1');
titleCell.value = 'Report Name : Daily Attendance Report';
titleCell.font = { size: 11, bold: true };
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
worksheet.mergeCells('A2:L2');
const dateCell = worksheet.getCell('A2');
dateCell.value = `Date : ${formattedDate} (${dayName})`;
dateCell.font = { size: 11, bold: true };
dateCell.alignment = { vertical: 'middle', horizontal: 'left' };
worksheet.mergeCells('A3:L3');
const genDateCell = worksheet.getCell('A3');
genDateCell.value = `Generated Date : ${generatedDate}`;
genDateCell.font = { size: 11, bold: true };
genDateCell.alignment = { vertical: 'middle', horizontal: 'left' };
// NEW: Summary Metrics Row
worksheet.mergeCells('A4:L4');
const summaryCell = worksheet.getCell('A4');
summaryCell.value = `Total employees: ${summary.total_active_workforce || 0} | Present: ${summary.present || 0} | Late: ${summary.late || 0} | Mispunches: ${summary.mispunches || 0} | Absent: ${summary.absent || 0}`;
summaryCell.font = { size: 11, bold: true, color: { argb: 'FF000000' } }; // Black text
summaryCell.alignment = { vertical: 'middle', horizontal: 'left' };
worksheet.getRow(5).height = 10; // Spacer row
// Row 6: Headers (12 columns)
const headerRow = worksheet.getRow(6);
headerRow.values = ['Date', 'Day', 'Emp Code', 'Name', 'Company', 'Branch', 'Department', 'Job Role', 'Check-In', 'Check-Out', 'Worked Hours', 'Status'];
// Style Header Row (Green Background, White Text)
headerRow.eachCell((cell) => {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: '063B00' } // Dark Green
};
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; // White text
cell.alignment = { vertical: 'middle', horizontal: 'center' };
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
});
headerRow.height = 20;
// Add Data Rows starting from Row 7
roster.forEach((r: any, index: number) => {
const rowIndex = 7 + index;
const row = worksheet.getRow(rowIndex);
row.values = [
formattedDate,
dayName,
r.employee_code,
r.full_name,
r.company_name || '-',
r.branch_name || '-',
r.department_name || '-',
r.designation || '-',
r.check_in ? r.check_in.substring(0, 5) : '-',
r.check_out ? r.check_out.substring(0, 5) : '-',
r.worked_hours,
r.status
];
// Add borders to data cells
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
cell.alignment = { vertical: 'middle', horizontal: 'left' };
// Apply colors to the Status column (Column 12 / 'L')
if (colNumber === 12) {
const statusStr = (r.status || '').toUpperCase();
if (statusStr === 'FULL_DAY') {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; // Light Green background
cell.font = { color: { argb: 'FF006100' }, bold: true }; // Dark Green text
} else if (statusStr === 'ABSENT') {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC7CE' } }; // Light Red background
cell.font = { color: { argb: 'FF9C0006' }, bold: true }; // Dark Red text
} else if (statusStr === 'HALF_DAY') {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFEB9C' } }; // Light Orange/Yellow background
cell.font = { color: { argb: 'FF9C6500' }, bold: true }; // Dark Orange text
} else if (statusStr === 'MISPUNCH') {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFB1A0C7' } }; // Light Purple background
cell.font = { color: { argb: 'FF4B2E83' }, bold: true }; // Dark Purple text
} else if (statusStr === 'LATE') {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC000' } }; // Gold background
cell.font = { color: { argb: 'FF000000' }, bold: true }; // Black text
}
}
});
row.commit();
});
// Set 12 Column Widths (Increased Day width to 70 to prevent "Wednesday" text wrapping)
worksheet.columns = [
{ width: 12 }, // Date
{ width: 65 }, // Day
{ width: 12 }, // Emp Code
{ width: 25 }, // Name
{ width: 22 }, // Company
{ width: 18 }, // Branch
{ width: 22 }, // Department
{ width: 25 }, // Job Role
{ width: 12 }, // Check-In
{ width: 12 }, // Check-Out
{ width: 12 }, // Worked Hours
{ width: 12 }, // Status
];
const buffer = await workbook.xlsx.writeBuffer();
return new Uint8Array(buffer);
}
// Helper function specifically for Excel Monthly Report generation (Matrix Layout)
async function generateExcelMonthlyReport(report: any[], dates: string[], formattedFromDate: string, formattedToDate: string, generatedDate: string): Promise<Uint8Array> {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Monthly Attendance');
const numDates = dates.length;
const totalCols = 1 + numDates;
// Top Level Headings
worksheet.mergeCells(1, 1, 1, totalCols);
const titleCell = worksheet.getCell(1, 1);
titleCell.value = 'Report Name : Monthly Attendance Report';
titleCell.font = { size: 11, bold: true };
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
worksheet.mergeCells(2, 1, 2, totalCols);
const periodCell = worksheet.getCell(2, 1);
periodCell.value = `Period : ${formattedFromDate} To ${formattedToDate}`;
periodCell.font = { size: 11, bold: true };
periodCell.alignment = { vertical: 'middle', horizontal: 'left' };
worksheet.mergeCells(3, 1, 3, totalCols);
const genDateCell = worksheet.getCell(3, 1);
genDateCell.value = `Generated Date : ${generatedDate}`;
genDateCell.font = { size: 11, bold: true };
genDateCell.alignment = { vertical: 'middle', horizontal: 'left' };
// Column Widths
worksheet.getColumn(1).width = 12;
for (let i = 0; i < numDates; i++) {
worksheet.getColumn(i + 2).width = 10;
}
let currentRow = 5;
report.forEach((emp: any) => {
const history = emp.attendance_history || [];
// --- Employee Summary Header ---
worksheet.mergeCells(currentRow, 1, currentRow, totalCols);
const empCell = worksheet.getCell(currentRow, 1);
empCell.value = `Employee Code: ${emp.employee_code} | Name: ${emp.first_name} ${emp.last_name} | Company: ${emp.company_name || '-'} | Branch: ${emp.branch_name || '-'} | Department: ${emp.department_name || '-'} | Job Role: ${emp.designation || '-'} | Full Days: ${emp.range_metrics?.full_days || 0} | Half Days: ${emp.range_metrics?.half_days || 0} | Mispunches: ${emp.range_metrics?.mispunches || 0} | Late Arrivals: ${emp.range_metrics?.late_arrivals || 0}`;
empCell.font = { bold: true, size: 10 };
empCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2F2F2' } };
empCell.alignment = { vertical: 'middle', horizontal: 'left' };
currentRow++;
// --- Table Headers (Field | Date 1 | Date 2 | ...) ---
const headerRow = worksheet.getRow(currentRow);
headerRow.getCell(1).value = 'Field';
for (let i = 0; i < numDates; i++) {
const dateObj = new Date(dates[i] + 'T00:00:00');
const dayStr = dateObj.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
headerRow.getCell(i + 2).value = `${dayStr}\n${dayName}`;
}
headerRow.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '063B00' } }; // Dark Green
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; // White text
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = { top: {style:'thin', color:{argb:'FF000000'}}, left: {style:'thin', color:{argb:'FF000000'}}, bottom: {style:'thin', color:{argb:'FF000000'}}, right: {style:'thin', color:{argb:'FF000000'}} };
});
headerRow.height = 25;
currentRow++;
// --- Data Rows (Check-In, Check-Out, Hours, Status) ---
const fields = ['Check-In', 'Check-Out', 'Hours', 'Status'];
fields.forEach((field) => {
const row = worksheet.getRow(currentRow);
row.getCell(1).value = field;
row.getCell(1).font = { bold: true };
row.getCell(1).alignment = { horizontal: 'left' };
row.getCell(1).border = { top: {style:'thin', color:{argb:'FF000000'}}, left: {style:'thin', color:{argb:'FF000000'}}, bottom: {style:'thin', color:{argb:'FF000000'}}, right: {style:'thin', color:{argb:'FF000000'}} };
for (let i = 0; i < numDates; i++) {
const log = history.find((h: any) => h.work_date === dates[i]);
const cell = row.getCell(i + 2);
if (log) {
if (field === 'Check-In') cell.value = log.check_in ? log.check_in.substring(0, 5) : '-';
else if (field === 'Check-Out') cell.value = log.check_out ? log.check_out.substring(0, 5) : '-';
else if (field === 'Hours') cell.value = log.worked_hours;
else if (field === 'Status') {
cell.value = log.final_status;
const statusStr = (log.final_status || '').toUpperCase();
if (statusStr === 'FULL_DAY') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFC6EFCE'} }; cell.font = { color:{argb:'FF006100'}, bold:true }; }
else if (statusStr === 'ABSENT') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFFFC7CE'} }; cell.font = { color:{argb:'FF9C0006'}, bold:true }; }
else if (statusStr === 'HALF_DAY') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFFFEB9C'} }; cell.font = { color:{argb:'FF9C6500'}, bold:true }; }
else if (statusStr === 'MISPUNCH') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFB1A0C7'} }; cell.font = { color:{argb:'FF4B2E83'}, bold:true }; }
else if (statusStr === 'LATE') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFFFC000'} }; cell.font = { color:{argb:'FF000000'}, bold:true }; }
}
} else {
cell.value = '-';
}
cell.border = { top: {style:'thin', color:{argb:'FF000000'}}, left: {style:'thin', color:{argb:'FF000000'}}, bottom: {style:'thin', color:{argb:'FF000000'}}, right: {style:'thin', color:{argb:'FF000000'}} };
cell.alignment = { horizontal: 'center' };
}
currentRow++;
});
currentRow++; // empty row between employees
});
const buffer = await workbook.xlsx.writeBuffer();
return new Uint8Array(buffer);
}
// Helper function to apply filters to the roster
const applyRosterFilters = (roster: any[], filters: any) => {
let filtered = [...roster];
if (filters.companyId) filtered = filtered.filter(e => e.companyId === Number(filters.companyId));
if (filters.branchId) filtered = filtered.filter(e => e.branchId === Number(filters.branchId));
if (filters.departmentId) filtered = filtered.filter(e => e.departmentId === Number(filters.departmentId));
if (filters.designation) {
const desigStr = String(filters.designation).toLowerCase();
const desigNum = Number(filters.designation);
filtered = filtered.filter(e =>
e.jobTitle?.toLowerCase().includes(desigStr) ||
e.jobTitleId === desigNum ||
e.jobRoleId === desigNum
);
}
if (filters.employeeName) filtered = filtered.filter(e => e.fullName?.toLowerCase().includes(filters.employeeName.toLowerCase()) ?? false);
if (filters.employeeCode) filtered = filtered.filter(e => e.employeeCode?.toLowerCase().includes(filters.employeeCode.toLowerCase()) ?? false);
return filtered;
};
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) => {
console.log("AMS Worker Received Body:", JSON.stringify(body));
let datesToProcess: string[] = [];
if (body.work_date && body.work_date.length > 0) {
datesToProcess.push(body.work_date);
} else if (body.start_date?.length > 0 && body.end_date?.length > 0) {
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();
console.log(`[AMS Worker] Fetched ${employeeRoster.length} employees from EMS.`);
if (employeeRoster.length > 0) {
console.log("[AMS Worker] First employee in roster:", employeeRoster[0]);
}
const employeeMap = new Map(employeeRoster.map((e: any) => [e.employeeCode, e]));
for (const targetDate of datesToProcess) {
const rawLogs = await AttendanceRepo.findRawLogsByDate(targetDate);
if (rawLogs.length === 0) continue;
console.log(`[AMS Worker] Date ${targetDate}: Found ${rawLogs.length} raw logs.`);
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) {
continue;
}
if (!employee.isActive) {
continue;
}
const punches = groupedLogs[empCode];
const shift = await AttendanceRepo.findShiftDetails(employee.branchId, employee.companyId, connection);
const policy = await AttendanceRepo.findAttendancePolicy(employee.companyId, employee.branchId);
const effectiveGracePeriod = policy.late_grace_period_minutes ?? shift.grace_period_minutes;
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();
let lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
if (shift.is_night_shift && lastPunchMs < firstPunchMs) {
lastPunchMs += 24 * 60 * 60 * 1000;
}
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
if (worked_hours >= Number(policy.min_hours_full_day)) final_status = "FULL_DAY";
else if (worked_hours >= Number(policy.min_hours_half_day)) final_status = "HALF_DAY";
else final_status = "ABSENT";
const rawCheckInTimeStr = 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 + (effectiveGracePeriod * 60);
if (punchTotalSeconds > shiftCutoffSeconds) {
check_in_status = "LATE";
}
if (check_in_status === "LATE" && final_status === "FULL_DAY") {
const latesThisMonth = await AttendanceRepo.countLatesThisMonth(employee.employeeId, targetDate, connection);
if (latesThisMonth >= policy.max_lates_allowed_per_month) {
final_status = "HALF_DAY";
}
}
} else if (punches.length === 1) {
check_in = punches[0];
check_in_status = "MISPUNCH";
final_status = "MISPUNCH";
}
await AttendanceRepo.upsertProcessedAttendance({
employeeId: employee.employeeId,
company_id: employee.companyId,
branch_id: employee.branchId,
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 (req: any) => {
const attendanceRows = await AttendanceRepo.findDailyAttendance(req.date);
let employeeRoster = await getEmployeeRoster();
employeeRoster = applyRosterFilters(employeeRoster, req);
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.employeeId);
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.employeeId,
employee_code: emp.employeeCode,
full_name: emp.fullName,
attendance_id, status, check_in, check_out, worked_hours, punctuality,
company_name: emp.companyName,
branch_name: emp.branchName,
department_name: emp.departmentName,
designation: emp.jobTitle
};
});
return { date: req.date, summary, roster };
};
export const getAdminRangeReport = async (req: any) => {
let employeeRoster = await getEmployeeRoster();
employeeRoster = applyRosterFilters(employeeRoster, req);
if (employeeRoster.length === 0) return { date_range: { from: req.fromDate, to: req.toDate }, global_summary: { total_records_evaluated: 0, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 }, report: [] };
const employeeIds = employeeRoster.map((e: any) => e.employeeId);
const attendanceRows = await AttendanceRepo.findAttendanceByEmployeeIds(employeeIds, req.fromDate, req.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.employeeId] || [];
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.employeeId,
employee_code: emp.employeeCode,
first_name: emp.fullName?.split(" ")[0] || "",
last_name: emp.fullName?.split(" ").slice(1).join(" ") || "",
branch_name: emp.branchName,
department_name: emp.departmentName,
company_name: emp.companyName,
designation: emp.jobTitle,
range_metrics: individualMetrics,
attendance_history: logs
};
});
return { date_range: { from: req.fromDate, to: req.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.employeeId,
employee_code: empProfile.employeeCode,
first_name: empProfile.fullName?.split(" ")[0] || "",
last_name: empProfile.fullName?.split(" ").slice(1).join(" ") || ""
},
range: { from: fromDate, to: toDate },
total_days: logs.length,
history: logs
};
};
export const getDashboardMetrics = async (req: any) => {
let employeeRoster = await getEmployeeRoster();
employeeRoster = applyRosterFilters(employeeRoster, req);
const attendanceRows = await AttendanceRepo.findDailyAttendance(req.date);
const checkedInIds = new Set(attendanceRows.map((r: any) => r.employee_id));
const totalEmployees = employeeRoster.length;
const totalCheckedIn = employeeRoster.filter((emp: any) => checkedInIds.has(emp.employeeId)).length;
const totalNotCheckedIn = totalEmployees - totalCheckedIn;
return {
total_employees: totalEmployees,
total_checked_in: totalCheckedIn,
total_not_checked_in: totalNotCheckedIn
};
};
export const exportDailyReport = async (req: any) => {
const data = await getDailyReport(req);
const roster = data.roster || [];
const summary = data.summary || {};
const fileType = req.fileType || 'excel';
let file_content: Uint8Array;
let file_name: string;
const reportDate = new Date(req.date + 'T00:00:00');
const formattedDate = reportDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const dayName = reportDate.toLocaleDateString('en-US', { weekday: 'long' });
const generatedDate = new Date().toLocaleString('en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
if (fileType === 'pdf') {
const doc = new PDFDocument({ margin: 30, size: 'A4', layout: 'landscape' });
const chunks: Uint8Array[] = [];
doc.on('data', (chunk: Uint8Array) => chunks.push(chunk));
doc.fontSize(10).fillColor('#000000').text(`Report Name : Daily Attendance Report`, { align: 'left' });
doc.fontSize(10).fillColor('#000000').text(`Date : ${formattedDate} (${dayName})`, { align: 'left' });
doc.fontSize(10).fillColor('#000000').text(`Generated Date : ${generatedDate}`, { align: 'left' });
// Add Summary Metrics Line (Black color)
doc.fontSize(10).fillColor('#000000').text(`Total employees: ${summary.total_active_workforce || 0} | Present: ${summary.present || 0} | Late: ${summary.late || 0} | Mispunches: ${summary.mispunches || 0} | Absent: ${summary.absent || 0}`, { align: 'left' });
doc.moveDown();
const headers = ['Date', 'Day', 'Emp Code', 'Name', 'Company', 'Branch', 'Department', 'Job Role', 'Check-In', 'Check-Out', 'Hrs', 'Status'];
const rows = roster.map((r: any) => [
formattedDate, dayName, r.employee_code, r.full_name, r.company_name || '-', r.branch_name || '-',
r.department_name || '-', r.designation || '-',
r.check_in ? r.check_in.substring(0, 5) : '-',
r.check_out ? r.check_out.substring(0, 5) : '-',
r.worked_hours, r.status
]);
// INCREASED Day column width to 70 to prevent "Wednesday" from wrapping
drawPdfTable(doc, headers, rows, [55, 70, 50, 90, 70, 60, 70, 80, 60, 65, 40, 60]);
doc.end();
await new Promise<void>((resolve) => doc.on('end', resolve));
const totalLength = chunks.reduce((acc, val) => acc + val.length, 0);
file_content = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
file_content.set(chunk, offset);
offset += chunk.length;
}
file_name = `Daily_Report_${req.date}.pdf`;
} else {
file_content = await generateExcelDailyReport(roster, formattedDate, dayName, generatedDate, summary);
file_name = `Daily_Report_${req.date}.xlsx`;
}
return { file_content, file_name };
};
export const exportAdminRangeReport = async (req: any) => {
const data = await getAdminRangeReport(req);
const report = data.report || [];
const fileType = req.fileType || 'excel';
let file_content: Uint8Array;
let file_name: string;
const formattedFromDate = new Date(req.fromDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const formattedToDate = new Date(req.toDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const generatedDate = new Date().toLocaleString('en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
const dates = getDaysArray(req.fromDate, req.toDate);
if (fileType === 'pdf') {
const doc = new PDFDocument({ margin: 30, size: 'A4', layout: 'landscape' });
const chunks: Uint8Array[] = [];
doc.on('data', (chunk: Uint8Array) => chunks.push(chunk));
// PDF Top Level Headings
doc.fontSize(10).fillColor('#000000').text('Report Name : Monthly Attendance Report', { align: 'left' });
doc.fontSize(10).fillColor('#000000').text(`Period : ${formattedFromDate} To ${formattedToDate}`, { align: 'left' });
doc.fontSize(10).fillColor('#000000').text(`Generated Date : ${generatedDate}`, { align: 'left' });
doc.moveDown();
const numDates = dates.length;
const fieldColWidth = 50;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const dateColWidth = Math.max(18, (pageWidth - fieldColWidth) / numDates);
let y = doc.y;
report.forEach((emp: any) => {
const history = emp.attendance_history || [];
// Check page break
if (y + 80 > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
y = doc.page.margins.top;
}
// --- Employee Summary Header ---
// Added "Name" and changed abbreviations to full words to match Excel logic
doc.font('Helvetica-Bold').fontSize(8).fillColor('#000000');
const summaryText = `Emp Code: ${emp.employee_code} | Name: ${emp.first_name} ${emp.last_name} | Company: ${emp.company_name || '-'} | Branch: ${emp.branch_name || '-'} | Dept: ${emp.department_name || '-'} | Role: ${emp.designation || '-'} | Full: ${emp.range_metrics?.full_days || 0} | Half: ${emp.range_metrics?.half_days || 0} | Mispunches: ${emp.range_metrics?.mispunches || 0} | Late: ${emp.range_metrics?.late_arrivals || 0}`;
doc.fill('#F2F2F2').rect(doc.page.margins.left, y, pageWidth, 18).fill();
doc.fillColor('#000000').text(summaryText, doc.page.margins.left + 5, y + 4, { width: pageWidth - 10 });
y += 20;
// --- Table Header (Field | Date 1 | Date 2 | ...) ---
doc.font('Helvetica-Bold').fontSize(6);
let x = doc.page.margins.left;
doc.fill('#063B00').rect(x, y, fieldColWidth, 16).fill();
doc.fillColor('#FFFFFF').text('Field', x + 2, y + 4, { width: fieldColWidth - 4, align: 'center' });
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, fieldColWidth, 16).stroke();
x += fieldColWidth;
for (let i = 0; i < numDates; i++) {
const dateObj = new Date(dates[i] + 'T00:00:00');
const dayStr = dateObj.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
doc.fill('#063B00').rect(x, y, dateColWidth, 16).fill();
doc.fillColor('#FFFFFF').text(dayStr, x + 1, y + 2, { width: dateColWidth - 2, align: 'center' });
doc.fillColor('#FFFFFF').text(dayName, x + 1, y + 8, { width: dateColWidth - 2, align: 'center' });
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, dateColWidth, 16).stroke();
x += dateColWidth;
}
y += 16;
// --- Data Rows (Check-In, Check-Out, Hours, Status) ---
const fields = ['Check-In', 'Check-Out', 'Hours', 'Status'];
doc.font('Helvetica').fontSize(6);
fields.forEach((field) => {
if (y + 14 > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
y = doc.page.margins.top;
}
let xRow = doc.page.margins.left;
doc.fillColor('#000000').text(field, xRow + 2, y + 3, { width: fieldColWidth - 4, align: 'left' });
doc.lineWidth(0.5).strokeColor('#000000').rect(xRow, y, fieldColWidth, 14).stroke();
xRow += fieldColWidth;
for (let i = 0; i < numDates; i++) {
const log = history.find((h: any) => h.work_date === dates[i]);
let text = '-';
let textColor = '#000000';
let bgColor = null;
if (log) {
if (field === 'Check-In') text = log.check_in ? log.check_in.substring(0, 5) : '-';
else if (field === 'Check-Out') text = log.check_out ? log.check_out.substring(0, 5) : '-';
else if (field === 'Hours') text = log.worked_hours ? Number(log.worked_hours).toFixed(2) : '0.00';
else if (field === 'Status') {
// Converted text to F, H, M, A, L while keeping the same colors
const statusStr = (log.final_status || '').toUpperCase();
if (statusStr === 'FULL_DAY') { text = 'F'; bgColor = '#C6EFCE'; textColor = '#006100'; }
else if (statusStr === 'ABSENT') { text = 'A'; bgColor = '#FFC7CE'; textColor = '#9C0006'; }
else if (statusStr === 'HALF_DAY') { text = 'H'; bgColor = '#FFEB9C'; textColor = '#9C6500'; }
else if (statusStr === 'MISPUNCH') { text = 'M'; bgColor = '#B1A0C7'; textColor = '#4B2E83'; }
else if (statusStr === 'LATE') { text = 'L'; bgColor = '#FFC000'; textColor = '#000000'; }
else { text = '-'; }
}
}
if (bgColor) {
doc.fill(bgColor).rect(xRow, y, dateColWidth, 14).fill();
}
doc.fillColor(textColor).text(text, xRow + 1, y + 3, { width: dateColWidth - 2, align: 'center' });
doc.lineWidth(0.5).strokeColor('#000000').rect(xRow, y, dateColWidth, 14).stroke();
xRow += dateColWidth;
}
y += 14;
});
y += 10;
});
doc.end();
await new Promise<void>((resolve) => doc.on('end', resolve));
const totalLength = chunks.reduce((acc, val) => acc + val.length, 0);
file_content = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
file_content.set(chunk, offset);
offset += chunk.length;
}
file_name = `Monthly_Report_${req.fromDate}_to_${req.toDate}.pdf`;
} else {
// Use the new Excel helper function for Matrix format
file_content = await generateExcelMonthlyReport(report, dates, formattedFromDate, formattedToDate, generatedDate);
file_name = `Monthly_Report_${req.fromDate}_to_${req.toDate}.xlsx`;
}
return { file_content, file_name };
};

View File

@ -1,102 +0,0 @@
// services/ams/services/regularization.service.test.ts
import { assertEquals, assertRejects} from "https://deno.land/std@0.224.0/assert/mod.ts";
import { stub } from "https://deno.land/std@0.224.0/testing/mock.ts";
import * as RegService from "./regularization.service.ts";
import { __deps } from "./regularization.service.ts";
import { formatDateTime } from "./regularization.service.ts";
Deno.test("createRegularizationRequest should throw error if target_date is a Sunday", async () => {
const payload = {
employee_id: 135,
reason: "Forgot to punch",
regularization_type: "MISPUNCH",
target_date: "2026-07-12", // This is a Sunday
};
// We expect this promise to reject with a specific error
await assertRejects(
() => RegService.createRegularizationRequest(payload),
Error,
"Cannot apply for regularization on a weekly off (Sunday)."
);
});
Deno.test("createRegularizationRequest should throw error if missing required fields", async () => {
const payload = {
employee_id: 135,
// missing reason and target_date
};
await assertRejects(
() => RegService.createRegularizationRequest(payload as any),
Error,
"Missing required fields."
);
});
Deno.test("createRegularizationRequest should throw error if employee is not found in EMS", async () => {
const payload = {
employee_id: 999,
reason: "Forgot to punch",
regularization_type: "MISPUNCH",
target_date: "2026-07-15",
};
// FIX: Stub the method on the __deps object
const rosterStub = stub(__deps, "getEmployeeRoster", () => Promise.resolve([]));
try {
await assertRejects(
() => RegService.createRegularizationRequest(payload),
Error,
"Employee not found in EMS."
);
} finally {
rosterStub.restore();
}
});
Deno.test("createRegularizationRequest should succeed and insert into DB for valid data", async () => {
const payload = {
employee_id: 135,
reason: "Forgot to punch",
regularization_type: "MISPUNCH",
target_date: "2026-07-15",
requested_check_in: "2026-07-15 10:00:00",
requested_check_out: "2026-07-15 19:00:00"
};
// FIX: Stub the methods on the __deps object
const rosterStub = stub(__deps, "getEmployeeRoster", () =>
Promise.resolve([{ employeeId: 135, branchId: 1 } as any])
);
let insertedData: any = null;
const insertStub = stub(__deps, "insertRegularization", async (data: any) => {
insertedData = data;
return Promise.resolve();
});
try {
await RegService.createRegularizationRequest(payload);
assertEquals(insertedData.employee_id, 135);
assertEquals(insertedData.branch_id, 1);
assertEquals(insertedData.target_date, "2026-07-15");
} finally {
rosterStub.restore();
insertStub.restore();
}
});
Deno.test("formatDateTime should not shift the date forward due to timezone", () => {
// Simulate MySQL returning a Date object for "2026-07-12 18:30:00" local time
const mysqlDateObject = new Date(2026, 6, 12, 18, 30, 0); // Month is 0-indexed (6 = July)
const formattedString = formatDateTime(mysqlDateObject);
// Assert it stays on the 12th, doesn't shift to the 13th
assertEquals(formattedString?.startsWith("2026-07-12"), true);
});

View File

@ -1,208 +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 InternalClient from "core/internal-client.ts"; // CHANGED TO NAMESPACE
import * as RegRepo from "../repositories/regularization.repository.ts";
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
// FIX: Dependency Injection Object for test mocking
export const __deps = {
getEmployeeRoster: InternalClient.getEmployeeRoster,
insertRegularization: RegRepo.insertRegularization,
findPendingRegularizations: RegRepo.findPendingRegularizations,
findAllRegularizations: RegRepo.findAllRegularizations,
findRegularizationById: RegRepo.findRegularizationById,
updateRegularizationStatus: RegRepo.updateRegularizationStatus,
findShiftDetails: AttendanceRepo.findShiftDetails,
updateProcessedAttendanceById: AttendanceRepo.updateProcessedAttendanceById,
};
// FIX: Timezone safe formatter for recalculating attendance
export const formatDateTime = (dateVal: any): string | undefined => {
if (!dateVal) return undefined;
if (dateVal instanceof Date) {
const offset = dateVal.getTimezoneOffset() * 60000;
const localDate = new Date(dateVal.getTime() - offset);
return localDate.toISOString().replace("T", " ").substring(0, 19);
}
return String(dateVal).replace("T", " ").substring(0, 19);
};
export const createRegularizationRequest = async (data: any) => {
if (!data.employee_id || !data.reason || !data.regularization_type || !data.target_date) {
throw new Error("Missing required fields.");
}
// FIX: Weekend validation check
const targetDateObj = new Date(data.target_date);
const dayOfWeek = targetDateObj.getDay(); // 0 = Sunday, 6 = Saturday
// Assuming Sunday (0) is the default weekly off.
if (dayOfWeek === 0) {
throw new Error("Cannot apply for regularization on a weekly off (Sunday).");
}
// Validate requested check-in date matches target date
if (data.requested_check_in) {
const checkInDate = new Date(data.requested_check_in).toISOString().split('T')[0];
if (checkInDate !== data.target_date) {
throw new Error("Requested Check-In date does not match the Target Date.");
}
}
const roster = await __deps.getEmployeeRoster([data.employee_id]);
if (roster.length === 0) throw new Error("Employee not found in EMS.");
const branch_id = roster[0].branchId || 0;
await __deps.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 __deps.findPendingRegularizations(branchIds);
if (rows.length === 0) return [];
const employeeIds = [...new Set(rows.map((r: any) => Number(r.employee_id)))] as number[];
const roster = await __deps.getEmployeeRoster(employeeIds);
const rosterMap = new Map(roster.map((e: any) => [e.employeeId, e]));
return rows.map((req: any) => {
const emp = rosterMap.get(req.employee_id);
return {
...req,
employee_code: emp?.employeeCode || "N/A",
first_name: emp?.fullName?.split(" ")[0] || "",
last_name: emp?.fullName?.split(" ").slice(1).join(" ") || "",
branch_name: emp?.branchName || "N/A",
};
});
};
export const getAllRegularizations = async () => {
const rows = await __deps.findAllRegularizations();
if (rows.length === 0) return [];
const allIds = new Set<number>();
rows.forEach((r: any) => {
if (r.employee_id) allIds.add(Number(r.employee_id));
if (r.reviewed_by_id) allIds.add(Number(r.reviewed_by_id));
});
const roster = await __deps.getEmployeeRoster(Array.from(allIds));
const rosterMap = new Map(roster.map((e: any) => [e.employeeId, e]));
return rows.map((req: any) => {
const emp = rosterMap.get(Number(req.employee_id));
const reviewer = rosterMap.get(Number(req.reviewed_by_id));
return {
...req,
employee_code: emp?.employeeCode || "N/A",
first_name: emp?.fullName?.split(" ")[0] || "",
last_name: emp?.fullName?.split(" ").slice(1).join(" ") || "",
branch_name: emp?.branchName || "N/A",
reviewer_name: reviewer?.fullName || "Pending Review"
};
});
};
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 __deps.findRegularizationById(data.regularization_id, connection);
if (!request) throw new Error("Regularization request not found.");
if (user.role !== AppRole.DIRECTOR) {
if (user.role !== AppRole.HR_MANAGER) {
throw new Error("Access Denied: Only HR Managers and Directors can review requests.");
}
if (!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.");
// FIX: Use __deps
await __deps.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 rawCheckIn = formatDateTime(request.requested_check_in);
const rawCheckOut = formatDateTime(request.requested_check_out);
// FIX: Use __deps
const roster = await __deps.getEmployeeRoster([request.employee_id]);
const emp = roster[0];
if (emp) {
// FIX: Use __deps
const shift = await __deps.findShiftDetails(emp.branchId, emp.companyId, connection);
if (rawCheckIn && rawCheckOut) {
const checkInMs = new Date(rawCheckIn.replace(" ", "T")).getTime();
const checkOutMs = new Date(rawCheckOut.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 timePart = rawCheckIn.split(" ")[1];
const [punchH, punchM, punchS] = timePart.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";
}
let attendanceIdToUpdate = request.attendance_id;
if (!attendanceIdToUpdate) {
const [attRows]: any[] = await connection.execute(
`SELECT attendance_id FROM processed_daily_attendance WHERE employee_id = ? AND work_date = ?`,
[request.employee_id, request.target_date]
);
if (attRows.length > 0) attendanceIdToUpdate = attRows[0].attendance_id;
}
if (attendanceIdToUpdate && (rawCheckIn || rawCheckOut)) {
// FIX: Use __deps
await __deps.updateProcessedAttendanceById(attendanceIdToUpdate, {
requested_check_in: rawCheckIn,
requested_check_out: rawCheckOut,
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,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,37 +0,0 @@
// services/ems/contract-handlers.ts
import * as ContractService from "../services/contract.service.ts";
export const ContractServiceImplementation = {
async getEmployeeContracts(req: any) {
try {
const data = await ContractService.getEmployeeContracts(Number(req.id));
const mappedData = data.map((c: any) => ({
termId: Number(c.term_id) || 0,
workEmail: c.work_email || "",
dateJoining: c.date_joining ? new Date(c.date_joining).toISOString().split('T')[0] : "",
probationDays: Number(c.probation_days) || 0,
status: c.status || "",
salaryStructureId: Number(c.salary_structure_id) || 0,
assignmentId: Number(c.assignment_id) || 0,
changeReason: c.change_reason || "",
effectiveFrom: c.effective_from ? new Date(c.effective_from).toISOString().split('T')[0] : "",
effectiveTo: c.effective_to ? new Date(c.effective_to).toISOString().split('T')[0] : "",
isCurrent: !!c.is_current,
department: c.department || "",
designation: c.designation || ""
}));
return { success: true, count: Number(mappedData.length), data: mappedData };
} catch (error: any) {
return { success: false, count: 0, data: [] };
}
},
async createContract(req: any) {
try {
const result = await ContractService.createContract(req);
return { success: true, message: "New assignment executed successfully", assignmentId: result.assignment_id };
} catch (error: any) {
return { success: false, message: error.message, assignmentId: 0 };
}
}
};

View File

@ -1,26 +0,0 @@
// services/ems/dashboard-handlers.ts
import * as DashboardService from "../services/dashboard.service.ts";
const mapToParams = (obj: any) => {
return {
get: (key: string) => obj[key] !== undefined ? String(obj[key]) : null
};
};
export const DashboardServiceImplementation = {
async getDashboardMetrics(req: any) {
try {
const data = await DashboardService.getDashboardMetrics(mapToParams(req));
return {
success: true,
totalCompanies: Number(data.total_companies) || 0,
totalBranches: Number(data.total_branches) || 0,
totalDepartments: Number(data.total_departments) || 0,
totalJobs: Number(data.total_jobs) || 0,
totalEmployees: Number(data.total_employees) || 0
};
} catch (error: any) {
return { success: false, totalCompanies: 0, totalBranches: 0, totalDepartments: 0, totalJobs: 0, totalEmployees: 0 };
}
}
};

View File

@ -1,213 +0,0 @@
// services/ems/grpc-handlers.ts
import * as EmployeeService from "../services/employee.service.ts";
import * as InternalService from "../services/internal.service.ts";
const mapToParams = (obj: any) => {
return {
get: (key: string) => obj[key] !== undefined ? String(obj[key]) : null
};
};
export const EmployeeServiceImplementation = {
async getEmployees(req: any) {
try {
const params = mapToParams(req);
const data = await EmployeeService.getEmployees(params);
const mappedData = data.map((emp: any) => ({
employeeId: Number(emp.employee_id) || 0,
employeeCode: emp.employee_code || "",
isActive: !!emp.is_active,
firstName: emp.first_name || "",
lastName: emp.last_name || "",
workEmail: emp.work_email || "",
jobName: emp.job_name || "",
departmentName: emp.department_name || "",
branchName: emp.branch_name || "",
companyName: emp.company_name || ""
}));
return { success: true, count: Number(mappedData.length), data: mappedData };
} catch (error: any) {
console.error("gRPC getEmployees Error:", error);
return { success: false, count: 0, data: [] };
}
},
async getEmployeeById(req: any) {
try {
const emp = await EmployeeService.getEmployeeById(Number(req.id));
// Helper to format MySQL Date objects to YYYY-MM-DD strings
const formatDate = (dateVal: any) => {
if (!dateVal) return "";
return dateVal instanceof Date ? dateVal.toISOString().split('T')[0] : String(dateVal);
};
// The service layer returns: emp.contract = { work_email, date_joining, department, job_id, manager, etc. }
// We need to flatten it to match the Protobuf EmployeeDetail message.
const mappedData = {
employeeId: Number(emp.employee_id) || 0,
employeeCode: emp.employee_code || "",
isActive: !!emp.is_active,
firstName: emp.first_name || "",
lastName: emp.last_name || "",
dob: formatDate(emp.dob),
gender: emp.gender || "",
personalEmail: emp.personal_email || "",
personalPhone: emp.personal_phone || "",
workEmail: emp.contract?.work_email || "",
dateJoining: formatDate(emp.contract?.date_joining),
probationDays: Number(emp.contract?.probation_days) || 0,
contractStatus: emp.contract?.status || "",
salaryStructureId: Number(emp.contract?.salary_structure_id) || 0,
companyName: emp.company_name || "", // Pulled from DB query via JOIN
companyId: Number(emp.company_id) || 0,
branchName: emp.branch_name || "", // Pulled from DB query via JOIN
branchId: Number(emp.branch_id) || 0,
department: emp.contract?.department || "",
departmentId: Number(emp.contract?.department_id) || 0,
jobName: emp.contract?.designation || "", // Mapped from designation
jobId: Number(emp.contract?.job_id) || 0,
managerFirstName: emp.contract?.manager?.first_name || "",
managerLastName: emp.contract?.manager?.last_name || "",
managerEmployeeCode: emp.contract?.manager?.employee_code || "",
addresses: (emp.addresses || []).map((a: any) => ({
type: a.address_type || "",
doorNumber: a.door_number || "",
landmark: a.landmark || "",
line: a.address_line || "",
pincode: a.pincode || "",
district: a.district || "",
state: a.state || ""
}))
};
return { success: true, data: mappedData };
} catch (error: any) {
console.error("gRPC getEmployeeById Error:", error);
return { success: false, data: null };
}
},
async getEmployeeHistory(req: any) {
try {
const history = await EmployeeService.getEmployeeHistory(Number(req.id));
const mappedHistory = history.map((h: any) => ({
assignmentId: Number(h.assignment_id) || 0,
changeReason: h.change_reason || "",
effectiveFrom: h.effective_from || "",
effectiveTo: h.effective_to || "",
isCurrent: !!h.is_current,
department: h.department || "",
jobTitle: h.job_title || "",
managerFirstName: h.manager_first_name || "",
managerLastName: h.manager_last_name || ""
}));
return { success: true, history: mappedHistory };
} catch (error: any) {
return { success: false, history: [] };
}
},
async createEmployee(req: any) {
try {
const dbPayload = {
firstName: req.firstName,
lastName: req.lastName,
dob: req.dob,
gender: req.gender,
personalEmail: req.personalEmail,
personalPhone: req.personalPhone,
address: req.address,
companyId: req.companyId,
branchId: req.branchId,
departmentId: req.departmentId,
jobId: req.jobId,
work_email: req.workEmail,
dateJoining: req.dateJoining,
probationDays: req.probationDays,
salaryStructureId: req.salaryStructureId,
reportingToId: req.reportingToId || null,
};
const result = await EmployeeService.createEmployee(dbPayload);
return {
success: true,
message: "Employee created",
employeeId: result.employee_id,
employeeCode: result.employee_code
};
} catch (error: any) {
return { success: false, message: error.message, employeeId: 0, employeeCode: "" };
}
},
async updateEmployee(req: any) {
try {
const dbPayload = {
firstName: req.data?.firstName,
lastName: req.data?.lastName,
dob: req.data?.dob,
gender: req.data?.gender,
personalEmail: req.data?.personalEmail,
personalPhone: req.data?.personalPhone,
address: req.data?.address,
companyId: req.data?.companyId,
branchId: req.data?.branchId,
departmentId: req.data?.departmentId,
jobId: req.data?.jobId,
work_email: req.data?.workEmail,
dateJoining: req.data?.dateJoining,
probationDays: req.data?.probationDays,
salaryStructureId: req.data?.salaryStructureId,
reportingToId: req.data?.reportingToId || null,
isActive: req.data?.isActive,
};
// Capture the returned message from the service layer
const message = await EmployeeService.updateEmployee(Number(req.id), dbPayload);
return {
success: true,
message: message // Use the dynamic message here!
};
} catch (error: any) {
return { success: false, message: error.message };
}
},
};
export const InternalServiceImplementation = {
async getRoster(req: any) {
try {
const ids = req.ids || [];
const data = await InternalService.getRoster(ids.length > 0 ? ids.join(",") : undefined);
const mappedData = data.map((emp: any) => ({
employeeId: Number(emp.employee_id) || 0,
employeeCode: emp.employee_code || "",
isActive: !!emp.is_active,
fullName: emp.full_name || "",
branchId: Number(emp.branch_id) || 0,
branchName: emp.branch_name || "",
companyId: Number(emp.company_id) || 0,
companyName: emp.company_name || "",
departmentName: emp.department_name || "",
departmentId: Number(emp.department_id) || 0,
jobTitle: emp.job_title || "",
managerName: emp.manager_name || "No Manager Assigned",
managerId: Number(emp.manager_id) || 0
}));
return { success: true, data: mappedData };
} catch (error: any) {
return { success: false, data: [] };
}
},
async getManager(req: any) {
try {
const data = await InternalService.getManager(Number(req.id));
return { success: true, managerId: Number(data.manager_id) || 0 };
} catch (error: any) {
return { success: false, managerId: 0 };
}
}
};

View File

@ -1,101 +0,0 @@
// services/ems/lookup-handlers.ts
import * as LookupService from "../services/lookup.service.ts";
const mapToParams = (obj: any) => {
return {
get: (key: string) => obj[key] !== undefined ? String(obj[key]) : null
};
};
export const LookupServiceImplementation = {
// Companies
async getCompanies(req: any) {
const data = await LookupService.getCompanies(mapToParams(req));
return { success: true, data: data.map((c: any) => ({ companyId: c.company_id, companyCode: c.company_code, name: c.name, isActive: !!c.is_active })) };
},
async createCompany(req: any) {
try {
const result = await LookupService.createCompany(req);
return { success: true, message: "Company created", id: result.company_id, code: result.company_code };
} catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
},
async updateCompany(req: any) {
try { await LookupService.updateCompany(req.id, req.companyData); return { success: true, message: "Updated" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
async deleteCompany(req: any) {
try { await LookupService.deleteCompany(req.id); return { success: true, message: "Deleted" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
// Branches
async getBranches(req: any) {
const data = await LookupService.getBranches(mapToParams(req));
return { success: true, data: data.map((b: any) => ({ branchId: b.branch_id, code: b.code, branchName: b.branch_name, isActive: !!b.is_active, companyCode: b.company_code, companyName: b.company_name })) };
},
async createBranch(req: any) {
try { const r = await LookupService.createBranch(req); return { success: true, message: "Branch created", id: r.branch_id, code: r.code }; }
catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
},
async updateBranch(req: any) {
try { await LookupService.updateBranch(req.id, req.branchData); return { success: true, message: "Updated" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
async deleteBranch(req: any) {
try { await LookupService.deleteBranch(req.id); return { success: true, message: "Deleted" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
// Departments
async getDepartments(req: any) {
const data = await LookupService.getDepartments(mapToParams(req));
return { success: true, data: data.map((d: any) => ({ departmentId: d.department_id, departmentCode: d.department_code, departmentName: d.department_name || "", isActive: !!d.is_active, branchName: d.branch_name, companyName: d.company_name, managers: d.managers || "" })) };
},
async createDepartment(req: any) {
try { const r = await LookupService.createDepartment(req); return { success: true, message: "Dept created", id: r.department_id, code: r.department_code }; }
catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
},
async updateDepartment(req: any) {
try { await LookupService.updateDepartment(req.id, req.departmentData); return { success: true, message: "Updated" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
async deleteDepartment(req: any) {
try { await LookupService.deleteDepartment(req.id); return { success: true, message: "Deleted" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
// Jobs
async getJobs(req: any) {
const data = await LookupService.getJobs(mapToParams(req));
return { success: true, data: data.map((j: any) => ({ jobId: j.job_id, jobCode: j.job_code, jobName: j.job_name || "", isActive: !!j.is_active, departmentName: j.department_name, branchName: j.branch_name, companyName: j.company_name, description: j.description || "" })) };
},
async createJob(req: any) {
try { const r = await LookupService.createJob(req); return { success: true, message: "Job created", id: r.job_id, code: r.job_code }; }
catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
},
async updateJob(req: any) {
try { await LookupService.updateJob(req.id, req.jobData); return { success: true, message: "Updated" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
async deleteJob(req: any) {
try { await LookupService.deleteJob(req.id); return { success: true, message: "Deleted" }; }
catch (e: any) { return { success: false, message: e.message }; }
},
// NEW: Department Managers
async getDepartmentManagers(req: any) {
try {
const data = await LookupService.getDepartmentManagers(Number(req.departmentId));
const mappedData = data.map((m: any) => ({
employeeId: Number(m.employee_id) || 0,
employeeCode: m.employee_code || "",
firstName: m.first_name || "",
lastName: m.last_name || "",
departmentId: Number(m.department_id) || 0,
departmentName: m.department_name || ""
}));
return { success: true, data: mappedData };
} catch (e: any) {
return { success: false, data: [] };
}
}
};

View File

@ -1,36 +0,0 @@
// services/ems/main.ts
import * as grpc from "@grpc/grpc-js";
import { EmployeeServiceService, InternalServiceService, LookupServiceService, ContractServiceService, DashboardServiceService } from "../../generated/ems.ts";
import { EmployeeServiceImplementation, InternalServiceImplementation } from "./handlers/employee.handler.ts";
import { LookupServiceImplementation } from "./handlers/lookup.handler.ts";
import { ContractServiceImplementation } from "./handlers/contract.handler.ts";
import { DashboardServiceImplementation } from "./handlers/dashboard.handler.ts";
const PORT = Number(Deno.env.get("EMS_PORT")) || 8001;
const wrap = (impl: any) => {
const wrapped: any = {};
for (const key in impl) {
wrapped[key] = async (call: any, callback: any) => {
try {
const res = await impl[key](call.request);
callback(null, res);
} catch (err: any) {
callback(err);
}
};
}
return wrapped;
};
const server = new grpc.Server();
server.addService(EmployeeServiceService, wrap(EmployeeServiceImplementation));
server.addService(InternalServiceService, wrap(InternalServiceImplementation));
server.addService(LookupServiceService, wrap(LookupServiceImplementation));
server.addService(ContractServiceService, wrap(ContractServiceImplementation));
server.addService(DashboardServiceService, wrap(DashboardServiceImplementation));
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) { console.error("Failed to start gRPC server:", err); return; }
console.log(`🚀 EMS gRPC Service running on port ${port}`);
});

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,194 +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,
p.first_name, p.last_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 job_name, j.job_id,
c.name AS company_name, c.company_id,
b.branch_name, b.branch_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 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
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) => {
let query = "UPDATE partners SET ";
const values = [];
const fields = [];
// Only add to query if the field is actually provided and not an empty string
if (data.firstName !== undefined && data.firstName !== "") { fields.push("first_name = ?"); values.push(data.firstName); }
if (data.lastName !== undefined && data.lastName !== "") { fields.push("last_name = ?"); values.push(data.lastName); }
if (data.dob !== undefined && data.dob !== "") { fields.push("dob = ?"); values.push(data.dob); }
if (data.gender !== undefined && data.gender !== "") { fields.push("gender = ?"); values.push(data.gender); }
if (data.personalEmail !== undefined && data.personalEmail !== "") { fields.push("personal_email = ?"); values.push(data.personalEmail); }
if (data.personalPhone !== undefined && data.personalPhone !== "") { fields.push("personal_phone = ?"); values.push(data.personalPhone); }
if (fields.length === 0) return; // Nothing to update
query += fields.join(", ");
query += " WHERE partner_id = ?";
values.push(partnerId);
await conn.execute(query, values);
};
export const updateEmploymentTerms = async (employeeId: number, data: any, conn: any) => {
let query = "UPDATE employment_terms SET ";
const values = [];
const fields = [];
if (data.work_email !== undefined && data.work_email !== "") { fields.push("work_email = ?"); values.push(data.work_email); }
if (data.probationDays !== undefined && data.probationDays !== null) { fields.push("probation_days = ?"); values.push(data.probationDays); }
if (data.salaryStructureId !== undefined && data.salaryStructureId !== null) { fields.push("salary_structure_id = ?"); values.push(data.salaryStructureId); }
if (fields.length === 0) return;
query += fields.join(", ");
query += " WHERE employee_id = ? AND status = 'ACTIVE'";
values.push(employeeId);
await conn.execute(query, values);
};
export const updateEmployeeAssignment = async (employeeId: number, data: any, conn: any) => {
let query = "UPDATE employee_assignments SET ";
const values = [];
const fields = [];
if (data.departmentId !== undefined && data.departmentId !== null) { fields.push("department_id = ?"); values.push(data.departmentId); }
if (data.jobId !== undefined && data.jobId !== null) { fields.push("job_id = ?"); values.push(data.jobId); }
// reportingToId can be null to clear a manager, so we check if it's explicitly undefined
if (data.reportingToId !== undefined) { fields.push("reporting_to_id = ?"); values.push(data.reportingToId); }
if (fields.length === 0) return;
query += fields.join(", ");
query += " WHERE employee_id = ? AND is_current = TRUE";
values.push(employeeId);
await conn.execute(query, values);
};
export const updateEmployeeStatus = async (employeeId: number, isActive: boolean, conn: any) => {
await conn.execute(
`UPDATE employees SET is_active = ? WHERE employee_id = ?`,
[isActive, employeeId]
);
};

View File

@ -1,31 +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_WS(' ', 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,
CONCAT_WS(' ', mgr_p.first_name, mgr_p.last_name) AS manager_name,
ea.reporting_to_id AS manager_id
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
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.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;
};

View File

@ -1,189 +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;
};
export const findDepartmentManagers = async (departmentId: number) => {
const [rows] = await pool.query(
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name,
d.department_id, d.name AS department_name
FROM department_managers dm
JOIN employees e ON dm.employee_id = e.employee_id
JOIN partners p ON e.partner_id = p.partner_id
JOIN departments d ON dm.department_id = d.department_id
WHERE dm.is_current = TRUE AND e.is_active = TRUE AND dm.department_id = ?`,
[departmentId]
);
return rows;
};

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,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,143 +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,
// FIX: Pass the company and branch fields through to the handler
company_name: employee.company_name,
company_id: employee.company_id,
branch_name: employee.branch_name,
branch_id: employee.branch_id,
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.job_name,
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();
}
};
// updateEmployee as the sole handler for both profile and status:
// Change the return type to string so we can pass the message back
export const updateEmployee = async (id: number, data: any): Promise<string> => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
if (!partnerId) throw new Error("Employee not found");
// 1. Update Profile Data (only if profile fields are provided)
const hasProfileData = data.firstName || data.lastName || data.dob || data.gender || data.personalEmail || data.personalPhone;
if (hasProfileData) {
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, connection);
const hasAssignmentData = (data.departmentId !== undefined) || (data.jobId !== undefined) || (data.reportingToId !== undefined);
if (hasAssignmentData) {
await EmpRepo.updateEmployeeAssignment(id, data, connection);
}
// Default message
let responseMessage = "Employee profile updated successfully";
// 2. Update Status (if provided) - SMART IDEMPOTENCY CHECK
if (data.isActive !== undefined && data.isActive !== null) {
// Fetch current status
const [rows]: any = await connection.execute(`SELECT is_active FROM employees WHERE employee_id = ?`, [id]);
if (rows.length === 0) throw new Error("Employee not found");
const currentStatus = !!rows[0].is_active; // Convert tinyint to boolean
if (currentStatus === data.isActive) {
// No DB update needed, just change the response message
responseMessage = `Employee is already ${data.isActive ? 'active' : 'inactive'}. No status change made.`;
} else {
// Status is different, proceed with update
await EmpRepo.updateEmployeeStatus(id, data.isActive, connection);
responseMessage = `Employee ${data.isActive ? 'activated' : 'deactivated'} successfully.`;
}
}
await connection.commit();
return responseMessage; // Return the specific message
} 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();
}
};

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,265 +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;
}
};
export const getDepartmentManagers = async (departmentId: number) => {
return await LookupRepo.findDepartmentManagers(departmentId);
};

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,24 +0,0 @@
// services/gateway/authorization/middleware.ts
import { Context, Next } from "@oak/oak";
export enum AppRole {
DIRECTOR = "DIRECTOR",
HR_MANAGER = "HR_MANAGER",
MANAGER = "MANAGER",
EMPLOYEE = "EMPLOYEE",
}
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,50 +0,0 @@
// services/gateway/clients/index.ts
import * as grpc from "@grpc/grpc-js";
import { config } from "../config.ts";
// EMS Clients
import { EmployeeServiceClient, LookupServiceClient, ContractServiceClient, DashboardServiceClient as EmsDashboardClient } from "../../../generated/ems.ts";
// AMS Clients
import { AttendanceServiceClient, RegularizationServiceClient, DashboardServiceClient as AmsDashboardClient } from "../../../generated/ams.ts";
// LMS Clients
import { AdminServiceClient, EmployeeServiceClient as LmsEmployeeClient, LeaveServiceClient, ManagerServiceClient, ReportsServiceClient } from "../../../generated/lms.ts";
import { HRManagerServiceClient } from "../../../generated/lms.ts";
const creds = grpc.credentials.createInsecure();
export const clients = {
ems: {
employee: new EmployeeServiceClient(config.urls.ems, creds),
lookup: new LookupServiceClient(config.urls.ems, creds),
contract: new ContractServiceClient(config.urls.ems, creds),
dashboard: new EmsDashboardClient(config.urls.ems, creds),
},
ams: {
attendance: new AttendanceServiceClient(config.urls.ams, creds),
regularization: new RegularizationServiceClient(config.urls.ams, creds),
dashboard: new AmsDashboardClient(config.urls.ams, creds),
},
lms: {
admin: new AdminServiceClient(config.urls.lms, creds),
employee: new LmsEmployeeClient(config.urls.lms, creds),
leave: new LeaveServiceClient(config.urls.lms, creds),
manager: new ManagerServiceClient(config.urls.lms, creds),
reports: new ReportsServiceClient(config.urls.lms, creds),
hrManager: new HRManagerServiceClient(config.urls.lms, creds)
}
};
// Shared helper to promisify gRPC calls AND inject internal token metadata
export const callGrpc = (client: any, method: string, payload: any) => {
return new Promise((resolve, reject) => {
// Create metadata object and inject the internal token
const metadata = new grpc.Metadata();
metadata.add('x-internal-token', config.internalToken);
// Pass metadata as the 3rd argument to the gRPC method
client[method](payload, metadata, (err: any, response: any) => {
if (err) reject(err);
else resolve(response);
});
});
};

View File

@ -1,22 +0,0 @@
// services/gateway/config.ts
const requiredEnvVars = [
"EMS_URL", "AMS_URL", "LMS_URL", "INTERNAL_SERVICE_TOKEN", "GATEWAY_PORT"
];
for (const envVar of requiredEnvVars) {
if (!Deno.env.get(envVar)) {
throw new Error(`[Gateway] Missing required environment variable: ${envVar}. Application cannot start.`);
}
}
export const config = {
port: Number(Deno.env.get("GATEWAY_PORT")),
mockSso: Deno.env.get("MOCK_SSO") === "true",
internalToken: Deno.env.get("INTERNAL_SERVICE_TOKEN")!,
urls: {
ems: Deno.env.get("EMS_URL")!,
ams: Deno.env.get("AMS_URL")!,
lms: Deno.env.get("LMS_URL")!,
}
};

View File

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

View File

@ -1,28 +0,0 @@
// services/gateway/identity/middleware.ts
import { Context, Next } from "@oak/oak";
import { config } from "../config.ts";
export interface UserContext {
id: string;
role: string;
managedBranches: string;
}
export const identityMiddleware = async (ctx: Context, next: Next) => {
if (config.mockSso) {
// MOCK ADAPTER: Reads debug headers
ctx.state.user = {
id: ctx.request.headers.get("X-Mock-User-Id") || "126",
role: ctx.request.headers.get("X-Mock-User-Role") || "EMPLOYEE",
managedBranches: ctx.request.headers.get("X-Mock-Branches") || "1"
} as UserContext;
} else {
// PRODUCTION ADAPTER (Keycloak JWT validation goes here)
// const jwt = ctx.request.headers.get("Authorization")?.split(" ")[1];
// ctx.state.user = await validateKeycloakJWT(jwt);
ctx.response.status = 401;
ctx.response.body = { error: "Real SSO not implemented yet." };
return;
}
await next();
};

View File

@ -1,45 +0,0 @@
// services/gateway/main.ts
import { Application } from "@oak/oak";
import { config } from "./config.ts";
import { identityMiddleware } from "./identity/middleware.ts";
// Import modular routes
import emsRoutes from "./routes/ems.routes.ts";
import amsRoutes from "./routes/ams.routes.ts";
import lmsRoutes from "./routes/lms.routes.ts";
const app = new Application();
// 1. Global CORS & Error Handling
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, X-Mock-User-Id, X-Mock-User-Role, X-Mock-Branches");
if (ctx.request.method === "OPTIONS") {
ctx.response.status = 204;
return;
}
try {
await next();
} catch (err) {
console.error("[Gateway Error]", err);
ctx.response.status = 500;
ctx.response.body = { success: false, message: "Internal Gateway Error" };
}
});
// 2. AuthN Layer (Identity Resolution)
app.use(identityMiddleware);
// 3. AuthZ & Routing Layer (Mount module routers)
app.use(emsRoutes.routes());
app.use(emsRoutes.allowedMethods());
app.use(amsRoutes.routes());
app.use(amsRoutes.allowedMethods());
app.use(lmsRoutes.routes());
app.use(lmsRoutes.allowedMethods());
console.log(`🚀 API Gateway running on http://localhost:${config.port}`);
await app.listen({ port: config.port });

View File

@ -1,232 +0,0 @@
// services/gateway/routes/ams.routes.ts
import { Router } from "@oak/oak";
import { clients, callGrpc } from "../clients/index.ts";
import { AppRole, requireRole } from "../authorization/middleware.ts";
import { getJsonBody } from "../utils/http.ts";
const router = new Router();
// ==========================================
// ATTENDANCE ROUTES (AMS)
// ==========================================
router.get("/api/ams/attendance/logs", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ams.attendance, "getRawLogs", {});
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ams/attendance/process-daily", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
workDate: body.work_date || undefined,
startDate: body.start_date || undefined,
endDate: body.end_date || undefined
};
const res: any = await callGrpc(clients.ams.attendance, "processDailyAttendance", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/my-summary", async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
employeeId: Number(p.get("employee_id")) || 0,
startDate: p.get("start_date") || "",
endDate: p.get("end_date") || ""
};
const res: any = await callGrpc(clients.ams.attendance, "getEmployeeSummary", req);
ctx.response.body = { success: res.success, metrics: res.metrics, history: res.history };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/daily-report", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
date: p.get("date") || "",
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
designation: p.get("designation") || undefined,
employeeName: p.get("employee_name") || undefined,
employeeCode: p.get("employee_code") || undefined
};
const res: any = await callGrpc(clients.ams.attendance, "getDailyReport", req);
ctx.response.body = { success: res.success, date: res.date, summary: res.summary, roster: res.roster };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/admin-report", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
fromDate: p.get("from_date") || "",
toDate: p.get("to_date") || "",
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
designation: p.get("designation") || undefined,
employeeName: p.get("employee_name") || undefined,
employeeCode: p.get("employee_code") || undefined
};
const res: any = await callGrpc(clients.ams.attendance, "getAdminRangeReport", req);
ctx.response.body = {
success: res.success,
date_range: { from: res.fromDate, to: res.toDate },
global_summary: res.globalSummary,
report: res.report
};
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/employee-range-report", async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
employeeId: Number(p.get("employee_id")) || 0,
fromDate: p.get("from_date") || "",
toDate: p.get("to_date") || ""
};
const res: any = await callGrpc(clients.ams.attendance, "getSingleEmployeeRangeReport", req);
ctx.response.body = {
success: res.success,
employee: res.employee,
range: { from: res.fromDate, to: res.toDate },
total_days: res.totalDays,
history: res.history
};
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// REGULARIZATION ROUTES (AMS)
// ==========================================
router.post("/api/ams/attendance/regularize", async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
attendanceId: body.attendance_id ? Number(body.attendance_id) : undefined,
employeeId: Number(body.employee_id) || 0,
regularizationType: body.regularization_type || "",
targetDate: body.target_date || "",
requestedCheckIn: body.requested_check_in || undefined,
requestedCheckOut: body.requested_check_out || undefined,
reason: body.reason || ""
};
const res: any = await callGrpc(clients.ams.regularization, "createRegularizationRequest", reqPayload);
ctx.response.status = 201;
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/regularize/pending", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const reqPayload = {
userRole: ctx.state.user.role,
managedBranchIds: ctx.state.user.managedBranches.split(",").map(Number)
};
const res: any = await callGrpc(clients.ams.regularization, "getPendingRegularizations", reqPayload);
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ams/attendance/regularize/review", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
regularizationId: Number(body.regularization_id) || 0,
action: body.action || "",
reviewerId: Number(ctx.state.user.id),
userRole: ctx.state.user.role,
managedBranchIds: ctx.state.user.managedBranches.split(",").map(Number)
};
const res: any = await callGrpc(clients.ams.regularization, "reviewRegularizationRequest", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// DASHBOARD ROUTES (AMS)
// ==========================================
router.get("/api/ams/attendance/dashboard/metrics", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
date: p.get("date") || "",
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined
};
const res: any = await callGrpc(clients.ams.dashboard, "getDashboardMetrics", req);
ctx.response.body = {
success: res.success,
data: {
total_employees: res.totalEmployees,
total_checked_in: res.totalCheckedIn,
total_not_checked_in: res.totalNotCheckedIn
}
};
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/regularize/history", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ams.regularization, "getAllRegularizations", {});
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ams/attendance/daily-report/export", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
date: p.get("date") || "",
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
designation: p.get("designation") || undefined,
employeeName: p.get("employee_name") || undefined,
employeeCode: p.get("employee_code") || undefined,
fileType: p.get("file_type") || "excel" // NEW
};
const res: any = await callGrpc(clients.ams.attendance, "exportDailyReport", req);
ctx.response.headers.set('Content-Disposition', `attachment; filename="${res.fileName}"`);
ctx.response.headers.set('Content-Type', 'application/octet-stream');
ctx.response.body = res.fileContent;
} catch (e: any) {
ctx.response.status = 500;
ctx.response.body = { error: e.message };
}
});
router.get("/api/ams/attendance/admin-report/export", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = {
fromDate: p.get("from_date") || "",
toDate: p.get("to_date") || "",
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
designation: p.get("designation") || undefined,
employeeName: p.get("employee_name") || undefined,
employeeCode: p.get("employee_code") || undefined,
fileType: p.get("file_type") || "excel" // NEW
};
const res: any = await callGrpc(clients.ams.attendance, "exportAdminRangeReport", req);
ctx.response.headers.set('Content-Disposition', `attachment; filename="${res.fileName}"`);
ctx.response.headers.set('Content-Type', 'application/octet-stream');
ctx.response.body = res.fileContent;
} catch (e: any) {
ctx.response.status = 500;
ctx.response.body = { error: e.message };
}
});
export default router;

View File

@ -1,350 +0,0 @@
// services/gateway/routes/ems.routes.ts
import { Router } from "@oak/oak";
import { clients, callGrpc } from "../clients/index.ts";
import { AppRole, requireRole } from "../authorization/middleware.ts";
import { getJsonBody } from "../utils/http.ts";
const router = new Router();
// ==========================================
// EMPLOYEE ROUTES (EMS)
// ==========================================
router.get("/api/ems/employees", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const req = { companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined, isActive: p.get("is_active") ? p.get("is_active") === 'true' : undefined };
const res: any = await callGrpc(clients.ems.employee, "getEmployees", req);
ctx.response.body = { success: res.success, count: res.count, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ems/employees/:id", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.employee, "getEmployeeById", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/ems/employees/:id/history", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.employee, "getEmployeeHistory", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, history: res.history };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ems/employees", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
firstName: body.firstName || "", lastName: body.lastName || "", dob: body.dob || "", gender: body.gender || "",
personalEmail: body.personalEmail || "", personalPhone: body.personalPhone || "",
address: body.address || {},
companyId: Number(body.companyId) || 0, branchId: Number(body.branchId) || 0, departmentId: Number(body.departmentId) || 0, jobId: Number(body.jobId) || 0,
workEmail: body.workEmail || body.work_email || "", dateJoining: body.dateJoining || "",
probationDays: Number(body.probationDays) || 0, salaryStructureId: Number(body.salaryStructureId) || 0,
reportingToId: body.reportingToId ? Number(body.reportingToId) : undefined,
};
const res: any = await callGrpc(clients.ems.employee, "createEmployee", reqPayload);
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message, employee_id: res.employeeId, employee_code: res.employeeCode };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.put("/api/ems/employees/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
id: Number(ctx.params.id),
data: {
firstName: body.firstName || "", lastName: body.lastName || "", dob: body.dob || "", gender: body.gender || "",
personalEmail: body.personalEmail || "", personalPhone: body.personalPhone || "",
address: body.address || {},
companyId: Number(body.companyId) || 0, branchId: Number(body.branchId) || 0, departmentId: Number(body.departmentId) || 0, jobId: Number(body.jobId) || 0,
workEmail: body.workEmail || body.work_email || "", dateJoining: body.dateJoining || "",
probationDays: Number(body.probationDays) || 0, salaryStructureId: Number(body.salaryStructureId) || 0,
reportingToId: body.reportingToId ? Number(body.reportingToId) : undefined,
}
};
const res: any = await callGrpc(clients.ems.employee, "updateEmployee", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// FIX: Replaced DELETE with a dedicated PATCH endpoint for status management
router.patch("/api/ems/employees/:id/status", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
id: Number(ctx.params.id),
data: {
isActive: body.isActive !== undefined ? !!body.isActive : undefined,
}
};
const res: any = await callGrpc(clients.ems.employee, "updateEmployee", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// COMPANY ROUTES (EMS)
// ==========================================
router.get("/api/ems/companies", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "getCompanies", {});
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ems/companies", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
name: body.name || "",
parentId: body.parentId ? Number(body.parentId) : undefined,
isActive: !!body.isActive,
companyCode: body.companyCode || undefined
};
const res: any = await callGrpc(clients.ems.lookup, "createCompany", reqPayload);
ctx.response.status = 201;
ctx.response.body = { success: res.success, message: res.message, company_id: res.id, company_code: res.code };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.put("/api/ems/companies/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
id: Number(ctx.params.id),
companyData: {
name: body.name || "",
parentId: body.parentId ? Number(body.parentId) : undefined,
isActive: !!body.isActive,
companyCode: body.companyCode || undefined
}
};
const res: any = await callGrpc(clients.ems.lookup, "updateCompany", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.delete("/api/ems/companies/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "deleteCompany", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// BRANCH ROUTES (EMS)
// ==========================================
router.get("/api/ems/branches", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "getBranches", {});
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ems/branches", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
companyId: Number(body.companyId) || 0,
branchName: body.branchName || "",
code: body.code || undefined,
isActive: !!body.isActive
};
const res: any = await callGrpc(clients.ems.lookup, "createBranch", reqPayload);
ctx.response.status = 201;
ctx.response.body = { success: res.success, message: res.message, branch_id: res.id, code: res.code };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.put("/api/ems/branches/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
id: Number(ctx.params.id),
branchData: {
companyId: Number(body.companyId) || 0,
branchName: body.branchName || "",
code: body.code || "",
isActive: !!body.isActive
}
};
const res: any = await callGrpc(clients.ems.lookup, "updateBranch", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.delete("/api/ems/branches/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "deleteBranch", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// DEPARTMENT ROUTES (EMS)
// ==========================================
router.get("/api/ems/departments", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "getDepartments", {});
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ems/departments", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
companyId: Number(body.companyId) || 0,
branchId: Number(body.branchId) || 0,
name: body.name || "",
parentId: body.parentId ? Number(body.parentId) : undefined,
managerId: body.managerId ? Number(body.managerId) : undefined,
isActive: !!body.isActive,
departmentCode: body.departmentCode || undefined
};
const res: any = await callGrpc(clients.ems.lookup, "createDepartment", reqPayload);
ctx.response.status = 201;
ctx.response.body = { success: res.success, message: res.message, department_id: res.id, department_code: res.code };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.put("/api/ems/departments/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
id: Number(ctx.params.id),
departmentData: {
companyId: Number(body.companyId) || 0,
branchId: Number(body.branchId) || 0,
name: body.name || "",
parentId: body.parentId ? Number(body.parentId) : undefined,
managerId: body.managerId ? Number(body.managerId) : undefined,
isActive: !!body.isActive
}
};
const res: any = await callGrpc(clients.ems.lookup, "updateDepartment", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.delete("/api/ems/departments/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "deleteDepartment", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// JOB ROUTES (EMS)
// ==========================================
router.get("/api/ems/jobs", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "getJobs", {});
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ems/jobs", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
departmentId: Number(body.departmentId) || 0,
title: body.title || "",
description: body.description || "",
isActive: !!body.isActive,
jobCode: body.jobCode || undefined
};
const res: any = await callGrpc(clients.ems.lookup, "createJob", reqPayload);
ctx.response.status = 201;
ctx.response.body = { success: res.success, message: res.message, job_id: res.id, job_code: res.code };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.put("/api/ems/jobs/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
id: Number(ctx.params.id),
jobData: {
departmentId: Number(body.departmentId) || 0,
title: body.title || "",
description: body.description || "",
isActive: !!body.isActive
}
};
const res: any = await callGrpc(clients.ems.lookup, "updateJob", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.delete("/api/ems/jobs/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.lookup, "deleteJob", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// CONTRACT ROUTES (EMS)
// ==========================================
router.get("/api/ems/employees/:id/contracts", async (ctx: any) => {
try {
const res: any = await callGrpc(clients.ems.contract, "getEmployeeContracts", { id: Number(ctx.params.id) });
ctx.response.body = { success: res.success, count: res.count, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/ems/contracts", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
employeeId: Number(body.employeeId) || 0,
departmentId: Number(body.departmentId) || 0,
jobId: Number(body.jobId) || 0,
reportingToId: body.reportingToId ? Number(body.reportingToId) : undefined,
changeReason: body.changeReason || "TRANSFER"
};
const res: any = await callGrpc(clients.ems.contract, "createContract", reqPayload);
ctx.response.status = 201;
ctx.response.body = { success: res.success, message: res.message, assignment_id: res.assignmentId };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// DASHBOARD ROUTES (EMS)
// ==========================================
router.get("/api/ems/dashboard/metrics", async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const reqPayload = {
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined
};
const res: any = await callGrpc(clients.ems.dashboard, "getDashboardMetrics", reqPayload);
ctx.response.body = {
success: res.success,
data: {
total_companies: res.totalCompanies,
total_branches: res.totalBranches,
total_departments: res.totalDepartments,
total_jobs: res.totalJobs,
total_employees: res.totalEmployees
}
};
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// MANAGERS ROUTE (EMS)
// ==========================================
router.get("/api/ems/managers", async (ctx: any) => {
try {
const departmentId = Number(ctx.request.url.searchParams.get("department_id")) || 0;
const res: any = await callGrpc(clients.ems.lookup, "getDepartmentManagers", { departmentId });
ctx.response.body = { success: res.success, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
export default router;

View File

@ -1,231 +0,0 @@
// services/gateway/routes/lms.routes.ts
import { Router } from "@oak/oak";
import { clients, callGrpc } from "../clients/index.ts";
import { AppRole, requireRole } from "../authorization/middleware.ts";
import { getJsonBody } from "../utils/http.ts";
const router = new Router();
// ==========================================
// LMS ADMIN CONFIG ROUTES
// ==========================================
router.post("/api/lms/config/types", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
companyId: Number(body.company_id) || 0,
name: body.name || "",
requiresAllocation: !!body.requires_allocation,
carryOverAllowed: !!body.carry_over_allowed,
maxCarryOverDays: Number(body.max_carry_over_days) || 0
};
const res: any = await callGrpc(clients.lms.admin, "createLeaveType", reqPayload);
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message, leave_type_id: res.id };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/lms/config/types", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const companyId = Number(ctx.request.url.searchParams.get("company_id")) || 0;
const res: any = await callGrpc(clients.lms.admin, "getLeaveTypes", { companyId });
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/lms/config/rules", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
leaveTypeId: Number(body.leave_type_id) || 0,
companyId: Number(body.company_id) || 0,
branchId: body.branch_id ? Number(body.branch_id) : undefined,
calendarYear: Number(body.calendar_year) || 0,
yearlyAllowance: Number(body.yearly_allowance) || 0,
maxDaysPerMonth: body.max_days_per_month ? Number(body.max_days_per_month) : undefined,
maxConsecutiveDays: body.max_consecutive_days ? Number(body.max_consecutive_days) : undefined,
applySandwichPolicy: !!body.apply_sandwich_policy
};
const res: any = await callGrpc(clients.lms.admin, "createPolicyRule", reqPayload);
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message, rule_id: res.id };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.post("/api/lms/config/holidays", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
holidays: body.map((h: any) => ({
companyId: Number(h.company_id) || 0,
branchId: h.branch_id ? Number(h.branch_id) : undefined,
calendarYear: Number(h.calendar_year) || 0,
holidayDate: h.holiday_date || "",
holidayType: h.holiday_type || "MANDATORY",
holidayName: h.holiday_name || ""
}))
};
const res: any = await callGrpc(clients.lms.admin, "createCompanyHoliday", reqPayload);
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.put("/api/lms/config/work-settings", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
companyId: Number(body.company_id) || 0,
branchId: body.branch_id ? Number(body.branch_id) : undefined,
weeklyOffDays: JSON.stringify(body.weekly_off_days || []),
effectiveFrom: body.effective_from || ""
};
const res: any = await callGrpc(clients.lms.admin, "updateWorkSettings", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// LMS EMPLOYEE ACTION ROUTES
// ==========================================
router.get("/api/lms/leaves/balances", async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const reqPayload = {
employeeId: Number(ctx.state.user.id),
role: ctx.state.user.role,
targetEmployeeId: p.get("employee_id") ? Number(p.get("employee_id")) : undefined,
year: p.get("year") ? Number(p.get("year")) : undefined
};
const res: any = await callGrpc(clients.lms.employee, "getLeaveBalances", reqPayload);
ctx.response.body = { success: res.success, balances: res.balances };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/lms/leaves/history", async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const reqPayload = {
employeeId: Number(ctx.state.user.id),
role: ctx.state.user.role,
targetEmployeeId: p.get("employee_id") ? Number(p.get("employee_id")) : undefined,
year: p.get("year") ? Number(p.get("year")) : undefined
};
const res: any = await callGrpc(clients.lms.employee, "getLeaveHistory", reqPayload);
ctx.response.body = { success: res.success, count: res.history.length, history: res.history };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
router.get("/api/lms/holidays/valid-optional", async (ctx: any) => {
try {
const reqPayload = {
employeeId: Number(ctx.state.user.id),
role: ctx.state.user.role,
managedBranches: ctx.state.user.managedBranches
};
const res: any = await callGrpc(clients.lms.employee, "getValidOptionalHolidays", reqPayload);
ctx.response.body = { success: res.success, count: res.holidays.length, holidays: res.holidays };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// Inside /api/lms/leaves/apply
router.post("/api/lms/leaves/apply", async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
employeeId: Number(body.employee_id) || 0,
leaveTypeId: Number(body.leave_type_id) || 0,
companyId: Number(body.company_id) || 0,
branchId: Number(body.branch_id) || 0,
dateFrom: body.date_from || "",
dateTo: body.date_to || "",
isHalfDay: !!body.is_half_day,
reason: body.reason || "",
applicantRole: ctx.state.user.role // <--- ADD THIS LINE
};
const res: any = await callGrpc(clients.lms.leave, "applyForLeave", reqPayload);
ctx.response.status = 201;
ctx.response.body = {
success: res.success,
message: res.message,
total_requested: res.totalRequested,
paid_days: res.paidDays,
lop_days: res.lopDays,
application_ids: res.applicationIds
};
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// LMS MANAGER ACTION ROUTES
// ==========================================
router.get("/api/lms/manager/pending", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const reqPayload = {
employeeId: Number(ctx.state.user.id),
role: ctx.state.user.role,
managedBranches: ctx.state.user.managedBranches
};
const res: any = await callGrpc(clients.lms.manager, "getPendingManagerLeaves", reqPayload);
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// Inside /api/lms/manager/leaves/:applicationId/approve
router.post("/api/lms/manager/leaves/:applicationId/approve", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const reqPayload = {
managerId: Number(ctx.state.user.id),
applicationId: Number(ctx.params.applicationId),
userRole: ctx.state.user.role // INJECT ROLE
};
const res: any = await callGrpc(clients.lms.manager, "approveLeaveManager", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// Inside /api/lms/manager/leaves/:applicationId/reject
router.post("/api/lms/manager/leaves/:applicationId/reject", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
try {
const body = await getJsonBody(ctx);
const reqPayload = {
managerId: Number(ctx.state.user.id),
applicationId: Number(ctx.params.applicationId),
reason: body.reason || "",
userRole: ctx.state.user.role // INJECT ROLE
};
const res: any = await callGrpc(clients.lms.manager, "rejectLeaveManager", reqPayload);
ctx.response.body = { success: res.success, message: res.message };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// ==========================================
// LMS REPORTS ROUTES
// ==========================================
router.get("/api/lms/admin/reports/ob-cb", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
try {
const p = ctx.request.url.searchParams;
const reqPayload = {
month: Number(p.get("month")) || 0,
year: Number(p.get("year")) || 0,
companyId: Number(p.get("company_id")) || 0
};
const res: any = await callGrpc(clients.lms.reports, "getLedgerReport", reqPayload);
ctx.response.body = {
success: res.success,
report_month: res.reportMonth,
report_year: res.reportYear,
count: res.data.length,
data: res.data
};
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
// Add this route for HR to see all applications
router.get("/api/lms/hr/applications", requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), async (ctx: any) => {
try {
const res: any = await callGrpc(clients.lms.hrManager, "getAllLeaveApplications", {});
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
});
export default router;

View File

@ -1,8 +0,0 @@
// services/gateway/utils/http.ts
export const getJsonBody = async (ctx: any) => {
try {
return await ctx.request.body.json();
} catch {
return {};
}
};

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,42 +0,0 @@
// services/lms/handlers/admin.handler.ts
import * as AdminService from "../services/admin.service.ts";
export const AdminServiceImplementation = {
async createLeaveType(req: any) {
try {
const id = await AdminService.createLeaveType(req);
return { success: true, message: "Leave type created", id };
} catch (e: any) { return { success: false, message: e.message, id: 0 }; }
},
async getLeaveTypes(req: any) {
try {
const data = await AdminService.getLeaveTypes(Number(req.companyId));
const mappedData = data.map((lt: any) => ({
leaveTypeId: Number(lt.leave_type_id) || 0,
name: lt.name || "",
requiresAllocation: !!lt.requires_allocation,
carryOverAllowed: !!lt.carry_over_allowed,
maxCarryOverDays: Number(lt.max_carry_over_days) || 0
}));
return { success: true, data: mappedData };
} catch (e: any) { return { success: false, data: [] }; }
},
async createPolicyRule(req: any) {
try {
const id = await AdminService.createPolicyRule(req);
return { success: true, message: "Policy rule created", id };
} catch (e: any) { return { success: false, message: e.message, id: 0 }; }
},
async createCompanyHoliday(req: any) {
try {
await AdminService.createCompanyHoliday(req.holidays || []);
return { success: true, message: "Holidays inserted successfully." };
} catch (e: any) { return { success: false, message: e.message }; }
},
async updateWorkSettings(req: any) {
try {
await AdminService.updateWorkSettings({ ...req, weekly_off_days: JSON.parse(req.weeklyOffDays || "[]") });
return { success: true, message: "Work settings updated successfully." };
} catch (e: any) { return { success: false, message: e.message }; }
}
};

View File

@ -1,49 +0,0 @@
// services/lms/handlers/employee.handler.ts
import * as EmployeeService from "../services/employee.service.ts";
export const EmployeeServiceImplementation = {
async getLeaveBalances(req: any) {
try {
const user = { employee_id: req.targetEmployeeId || req.employeeId, role: req.role };
const params = { get: (k: string) => k === 'year' ? String(req.year) : null };
const data = await EmployeeService.getLeaveBalances(user, params);
const mappedData = data.map((b: any) => ({
leaveTypeId: Number(b.leave_type_id) || 0,
leaveTypeName: b.leave_type_name || "",
grantedDays: Number(b.granted_days) || 0,
usedDays: Number(b.used_days) || 0,
availableBalance: Number(b.available_balance) || 0
}));
return { success: true, balances: mappedData };
} catch (e: any) { return { success: false, balances: [] }; }
},
async getLeaveHistory(req: any) {
try {
const user = { employee_id: req.targetEmployeeId || req.employeeId, role: req.role };
const params = { get: (k: string) => k === 'year' ? String(req.year) : null };
const data = await EmployeeService.getLeaveHistory(user, params);
const mappedData = data.map((h: any) => ({
applicationId: Number(h.application_id) || 0,
leaveTypeName: h.leave_type_name || "",
dateFrom: h.date_from || "",
dateTo: h.date_to || "",
numberOfDays: Number(h.number_of_days) || 0,
status: h.status || "",
reason: h.reason || ""
}));
return { success: true, history: mappedData };
} catch (e: any) { return { success: false, history: [] }; }
},
async getValidOptionalHolidays(req: any) {
try {
const user = { employee_id: req.employeeId, role: req.role, company_id: 1, branch_id: 1 }; // Mock company/branch for now
const data = await EmployeeService.getValidOptionalHolidays(user);
const mappedData = data.map((h: any) => ({
holidayId: Number(h.holiday_id) || 0,
holidayName: h.holiday_name || "",
holidayDate: h.holiday_date || ""
}));
return { success: true, holidays: mappedData };
} catch (e: any) { return { success: false, holidays: [] }; }
}
};

View File

@ -1,28 +0,0 @@
import * as HRManagerService from "../services/hr_manager.service.ts";
export const HRManagerServiceImplementation = {
async getAllLeaveApplications(req: any) {
try {
const data = await HRManagerService.getAllApplications();
const mappedData = data.map((p: any) => ({
applicationId: Number(p.application_id) || 0,
employeeId: Number(p.employee_id) || 0,
leaveType: p.leave_type || "",
dateFrom: p.date_from || "",
dateTo: p.date_to || "",
numberOfDays: Number(p.number_of_days) || 0,
status: p.status || "",
reason: p.reason || "", // Ensure reason is mapped
employeeCode: p.employee_code || "",
firstName: p.first_name || "",
lastName: p.last_name || "",
departmentName: p.department_name || "",
jobTitle: p.job_title || "",
managerName: p.manager_name || "N/A" // Ensure managerName is mapped
}));
return { success: true, data: mappedData };
} catch (e: any) {
return { success: false, data: [] };
}
}
};

View File

@ -1,32 +0,0 @@
// services/lms/handlers/leave.handler.ts
import * as LeaveService from "../services/leave.service.ts";
export const LeaveServiceImplementation = {
async applyForLeave(req: any) {
try {
const dbPayload = {
employee_id: req.employeeId,
leave_type_id: req.leaveTypeId,
company_id: req.companyId,
branch_id: req.branchId,
date_from: req.dateFrom,
date_to: req.dateTo,
is_half_day: req.isHalfDay,
reason: req.reason,
applicant_role: req.applicantRole // NEW
};
const result = await LeaveService.applyForLeave(dbPayload);
return {
success: true,
message: "Leave application submitted successfully.",
totalRequested: Number(result.total_requested) || 0,
paidDays: Number(result.paid_days) || 0,
lopDays: Number(result.lop_days) || 0,
applicationIds: result.application_ids || []
};
} catch (e: any) {
return { success: false, message: e.message, totalRequested: 0, paidDays: 0, lopDays: 0, applicationIds: [] };
}
}
};

View File

@ -1,40 +0,0 @@
// services/lms/handlers/manager.handler.ts
import * as ManagerService from "../services/manager.service.ts";
export const ManagerServiceImplementation = {
async getPendingManagerLeaves(req: any) {
try {
const user = { employee_id: req.employeeId, role: req.role };
const data = await ManagerService.getPendingApprovals(user);
const mappedData = data.map((p: any) => ({
applicationId: Number(p.application_id) || 0,
employeeId: Number(p.employee_id) || 0,
leaveTypeId: Number(p.leave_type_id) || 0,
leaveType: p.leave_type || "",
dateFrom: p.date_from || "",
dateTo: p.date_to || "",
numberOfDays: Number(p.number_of_days) || 0,
reason: p.reason || "",
status: p.status || "",
employeeCode: p.employee_code || "",
firstName: p.first_name || "",
lastName: p.last_name || ""
}));
return { success: true, data: mappedData };
} catch (e: any) { return { success: false, data: [] }; }
},
async approveLeaveManager(req: any) {
try {
const user = { employee_id: req.managerId, role: req.userRole }; // Pass userRole
await ManagerService.approveLeave(user, Number(req.applicationId));
return { success: true, message: "Leave approved successfully." };
} catch (e: any) { return { success: false, message: e.message }; }
},
async rejectLeaveManager(req: any) {
try {
const user = { employee_id: req.managerId, role: req.userRole }; // Pass userRole
await ManagerService.rejectLeave(user, Number(req.applicationId));
return { success: true, message: "Leave rejected successfully." };
} catch (e: any) { return { success: false, message: e.message }; }
}
};

View File

@ -1,21 +0,0 @@
// services/lms/handlers/reports.handler.ts
import * as ReportsService from "../services/reports.service.ts";
export const ReportsServiceImplementation = {
async getLedgerReport(req: any) {
try {
const data = await ReportsService.getLedgerReport(Number(req.month), Number(req.year), Number(req.companyId));
const mappedData = data.map((l: any) => ({
employeeId: Number(l.employee_id) || 0,
leaveType: l.leave_type || "",
totalGranted: Number(l.total_granted) || 0,
totalUsed: Number(l.total_used) || 0,
openingBalance: Number(l.opening_balance) || 0,
closingBalance: Number(l.closing_balance) || 0,
employeeCode: l.employee_code || "",
employeeName: l.employee_name || ""
}));
return { success: true, reportMonth: Number(req.month), reportYear: Number(req.year), data: mappedData };
} catch (e: any) { return { success: false, reportMonth: 0, reportYear: 0, data: [] }; }
}
};

View File

@ -1,39 +0,0 @@
// services/lms/main.ts
import * as grpc from "@grpc/grpc-js";
import { AdminServiceService, EmployeeServiceService, LeaveServiceService, ManagerServiceService, ReportsServiceService, HRManagerServiceService } from "../../generated/lms.ts";
import { AdminServiceImplementation } from "./handlers/admin.handler.ts";
import { EmployeeServiceImplementation } from "./handlers/employee.handler.ts";
import { LeaveServiceImplementation } from "./handlers/leave.handler.ts";
import { ManagerServiceImplementation } from "./handlers/manager.handler.ts";
import { ReportsServiceImplementation } from "./handlers/reports.handler.ts";
import { HRManagerServiceImplementation } from "./handlers/hr_manager.handler.ts";
const PORT = Number(Deno.env.get("LMS_PORT")) || 8003;
const wrap = (impl: any) => {
const wrapped: any = {};
for (const key in impl) {
wrapped[key] = async (call: any, callback: any) => {
try {
const res = await impl[key](call.request);
callback(null, res);
} catch (err: any) {
callback(err);
}
};
}
return wrapped;
};
const server = new grpc.Server();
server.addService(AdminServiceService, wrap(AdminServiceImplementation));
server.addService(EmployeeServiceService, wrap(EmployeeServiceImplementation));
server.addService(LeaveServiceService, wrap(LeaveServiceImplementation));
server.addService(ManagerServiceService, wrap(ManagerServiceImplementation));
server.addService(ReportsServiceService, wrap(ReportsServiceImplementation));
server.addService(HRManagerServiceService, wrap(HRManagerServiceImplementation));
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) { console.error("Failed to start gRPC server:", err); return; }
console.log(`🚀 LMS gRPC Service running on port ${port}`);
});

View File

@ -1,245 +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);
};
// Update insertLeaveApplication
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, applicant_role, approver_role) 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, data.applicant_role, data.approver_role]
);
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]);
};
// Update rejectLeaveApplication to allow any authorized role to reject
export const rejectLeaveApplication = async (applicationId: number, approverRole: string) => {
let query = `UPDATE leave_applications SET status = 'REJECTED' WHERE application_id = ? AND status IN ('PENDING', 'PENDING_LOP') AND approver_role = ?`;
const [result] = await pool.execute(query, [applicationId, approverRole]);
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;
};
// ==========================================
// HR MANAGER ACTIONS
// ==========================================
export const findAllApplications = async () => {
const [rows] = await pool.execute(
`SELECT la.application_id, la.employee_id, lt.name AS leave_type,
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
ORDER BY la.date_from DESC`
);
return rows;
};
// Replace findPendingManagerLeaves with dynamic routing
export const findPendingForApprover = async (approverId: number, approverRole: string) => {
let query = `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.status IN ('PENDING', 'PENDING_LOP')`;
const params: any[] = [];
if (approverRole === 'MANAGER') {
query += ` AND la.approver_role = 'MANAGER' AND la.manager_approved_by = ?`;
params.push(approverId);
} else if (approverRole === 'HR_MANAGER') {
query += ` AND la.approver_role = 'HR_MANAGER'`;
} else if (approverRole === 'DIRECTOR') {
query += ` AND la.approver_role = 'DIRECTOR'`;
} else {
return [];
}
query += ` ORDER BY la.date_from ASC`;
const [rows] = await pool.execute(query, params);
return rows;
};

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.HR_MANAGER || user.role === AppRole.DIRECTOR || 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.HR_MANAGER || user.role === AppRole.DIRECTOR || 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,24 +0,0 @@
import { getEmployeeRoster } from "core/internal-client.ts";
import * as LmsRepo from "../repositories/lms.repository.ts";
export const getAllApplications = async () => {
const rows = await LmsRepo.findAllApplications();
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.employeeId, e]));
return rows.map((app: any) => {
const emp = rosterMap.get(app.employee_id);
return {
...app,
employee_code: emp?.employeeCode || "N/A",
first_name: emp?.fullName?.split(" ")[0] || "",
last_name: emp?.fullName?.split(" ").slice(1).join(" ") || "",
department_name: emp?.departmentName || "N/A",
job_title: emp?.jobTitle || "N/A",
manager_name: emp?.managerName || "N/A" // <--- ADD THIS LINE!
};
});
};

Some files were not shown because too many files have changed in this diff Show More