Compare commits

...

15 Commits

Author SHA1 Message Date
ab1f9444f9 fix(ams): Resolve regularization timezone/weekend bugs and add unit test infrastructure
- Fix timezone shifting bug where IST dates were converted to UTC by toISOString()
- Add weekend validation to prevent regularization on weekly offs
- Fix partial update bug where attendance ledger wasn't updated if attendance_id was missing
- Implement Deno unit tests using dependency injection (__deps) and stub mocking
- Update gen_protos.sh to inject @ts-nocheck and fix Deno Buffer type errors
2026-08-01 15:27:31 +05:30
7d1b760934 Merge pull request 'added reports and regularization history' (#17) from feature/reportgeneration into main
Reviewed-on: #17
2026-07-31 17:49:39 +05:30
cc3d675727 added reports and regularization history 2026-07-31 17:49:38 +05:30
de6d25b256 Update README.md 2026-07-25 18:46:09 +05:30
c4df0e2741 Update README.md 2026-07-25 18:44:57 +05:30
cb7fd740d4 Update README.md 2026-07-25 18:33:42 +05:30
a9c02e86d2 Merge pull request 'feat: Refactor backend to gRPC microservices with strict API Gateway pattern' (#16) from hrms-project-refactor-Grpc into main
Reviewed-on: #16
Merge pull request: Refactor backend to gRPC microservices

This merge introduces a complete paradigm shift to API-First gRPC microservices.

Key additions include:
- Strict Protobuf contracts for all inter-service communication.
- A highly modular API Gateway separating AuthN (Identity), AuthZ (RBAC), and Routing.
- Complete database isolation by replacing cross-database SQL joins with gRPC internal API calls.
- A Mock SSO adapter layer future-proofed for Keycloak/OpenFGA integration.
- Idempotent employee status management via dedicated PATCH endpoints.
2026-07-25 18:09:03 +05:30
1a758c75ae feat: Refactor backend to gRPC microservices with strict API Gateway pattern
- Migrate EMS, AMS, and LMS from Oak HTTP servers to pure gRPC servers using @grpc/grpc-js
- Introduce API-First design with Protocol Buffers (.proto) for strict internal contracts
- Refactor API Gateway into modular layers (AuthN, AuthZ, Routes, Clients)
- Implement Mock SSO adapter for seamless future Keycloak integration
- Decouple databases completely by replacing cross-database SQL joins with gRPC inter-service calls
- Add internal gRPC metadata token for defense-in-depth security
- Replace destructive DELETE endpoint with idempotent PATCH /status endpoint
2026-07-25 18:15:23 +05:30
c21bbca78e Merge pull request 'feat: Refactor to true microservices architecture with API Gateway, DB isolation, and 3-layer domain structure' (#15) from hrms-project-refactor into main
Reviewed-on: #15

Merge pull request: Refactor to microservices architecture

This merge introduces a complete architectural overhaul of the HRMS backend, transitioning from a monolithic shared-database structure to a scalable, isolated microservices architecture.

Key additions in this merge:
- Centralized API Gateway with mocked Keycloak/OpenFGA SSO context injection.
- Strict network security via internal API tokens (preventing direct service access).
- Physical database isolation (3 separate MySQL containers for EMS, AMS, LMS).
- Decoupled inter-service communication via internal HTTP APIs instead of SQL joins.
- Strict 3-layer architecture (Controller -> Service -> Repository) across all modules.
- Odoo-inspired EMS schema (employment_terms & employee_assignments).
- Docker production setup and local dev orchestration scripts.
2026-07-17 16:16:11 +05:30
9e57dd7ab7 feat: Refactor to true microservices architecture with API Gateway, DB isolation, and 3-layer domain structure
- Restructure to Deno monorepo (services/*, packages/core)
- Add API Gateway with reverse proxy, CORS, and mocked SSO (Keycloak/OpenFGA)
- Decouple AMS & LMS from EMS database via internal HTTP APIs
- Split single MySQL DB into 3 isolated database containers
- Refactor all modules to strict 3-layer architecture (Controller -> Service -> Repository)
- Update EMS schema to Odoo-style split (employment_terms & employee_assignments)
- Add environment config (.env), internal API tokens, and Docker prod support
2026-07-17 16:20:02 +05:30
bf85053ba4 Merge pull request 'final fixes in ems, lms' (#14) from feature/ems-core-logic into main
Reviewed-on: #14
2026-07-15 10:54:10 +05:30
34cee06952 final fixes in ems, lms 2026-07-15 11:02:01 +05:30
db33948184 updated address field in updateemployee, manager bug in createContact 2026-07-09 18:51:11 +05:30
c8c645f7a6 Merge pull request 'updated address field in updateemployee, manager bug in createContact' (#13) from feature/ems-core-logic into main
Reviewed-on: #13
2026-07-09 18:43:59 +05:30
ec407364d9 Merge pull request 'imlemented attendance regularisation logic' (#12) from feature/ams-module into main
Reviewed-on: #12
2026-07-09 18:36:51 +05:30
110 changed files with 25471 additions and 3194 deletions

33
.env Normal file
View File

@ -0,0 +1,33 @@
# 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

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
# 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"]

19
Dockerfile.protos Normal file
View File

@ -0,0 +1,19 @@
# 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,2 +1,68 @@
# 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

@ -1,519 +0,0 @@
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

@ -1,326 +0,0 @@
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();
}
};

View File

@ -1,16 +0,0 @@
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 });

View File

@ -1,37 +0,0 @@
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

@ -0,0 +1,253 @@
-- 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

@ -0,0 +1,8 @@
# 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

@ -0,0 +1,8 @@
# 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,12 +1,18 @@
{
"workspace": [
"./shared",
"./ems-service",
"./ams-service",
"./lms-service"
"packages/core",
"services/ems",
"services/ams",
"services/lms",
"services/gateway"
],
"imports": {
"@oak/oak": "jsr:@oak/oak@^17.1.3",
"mysql2": "npm:mysql2@^3.0.0"
"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"
}
}

1170
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,9 @@
version: '3.8'
services:
hrms_db:
ems_db:
image: docker.io/library/mysql:8.4
container_name: hrms_db
container_name: hrms_ems_db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: hrms_ems
@ -12,9 +12,54 @@ services:
ports:
- "3306:3306"
volumes:
- ./init-db:/docker-entrypoint-initdb.d:Z
- hrms_db_data:/var/lib/mysql
- ./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
restart: unless-stopped
volumes:
hrms_db_data:
ems_db_data:
ams_db_data:
lms_db_data:

View File

@ -1,98 +0,0 @@
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

@ -1,346 +0,0 @@
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

@ -1,443 +0,0 @@
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

@ -1,173 +0,0 @@
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();
}
};

View File

@ -1,11 +0,0 @@
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 });

View File

@ -1,100 +0,0 @@
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;

View File

@ -1,69 +0,0 @@
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;

5006
generated/ams.ts Normal file

File diff suppressed because it is too large Load Diff

7257
generated/ems.ts Normal file

File diff suppressed because it is too large Load Diff

4558
generated/lms.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,10 @@ 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;
@ -16,6 +18,7 @@ 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;
@ -44,6 +47,7 @@ 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;
@ -52,8 +56,10 @@ 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
@ -63,8 +69,10 @@ 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;
@ -82,34 +90,61 @@ 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 & Operational Contracts
CREATE TABLE contracts (
contract_id INT AUTO_INCREMENT PRIMARY KEY,
-- 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,
employee_id INT NOT NULL,
department_id INT NOT NULL,
job_id INT NOT NULL,
reporting_to_id INT NULL,
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,
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,
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 (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT,
FOREIGN KEY (reporting_to_id) REFERENCES employees(employee_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 9. Department Managers (Junction Table)
-- 10. Department Managers (Junction Table with History)
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;
-- 10. System Sequences
-- 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
CREATE TABLE system_sequences (
sequence_id VARCHAR(50) PRIMARY KEY,
prefix VARCHAR(10) NOT NULL,

View File

@ -1,46 +0,0 @@
-- 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,8 +51,9 @@ CREATE TABLE processed_daily_attendance (
CREATE TABLE attendance_regularizations (
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
branch_id INT NOT NULL,
attendance_id INT NULL, -- Internal FK to processed ledger row
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
regularization_type ENUM('MISPUNCH', 'HALF_DAY', 'WFH_REQUEST', 'ON_DUTY') NOT NULL;
target_date DATE NOT NULL,
requested_check_in TIMESTAMP NULL,
requested_check_out TIMESTAMP NULL,

View File

@ -58,6 +58,8 @@ 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

@ -1,153 +0,0 @@
// 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

@ -1,179 +0,0 @@
// 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

@ -1,184 +0,0 @@
// 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

@ -1,209 +0,0 @@
// 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

@ -1,75 +0,0 @@
// 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" };
}
};

View File

@ -1,16 +0,0 @@
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 });

View File

@ -1,62 +0,0 @@
// 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;

66
packages/core/auth.ts Normal file
View File

@ -0,0 +1,66 @@
// 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();
};
};

9
packages/core/deno.json Normal file
View File

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

View File

@ -0,0 +1,42 @@
// 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 [];
}
};

287
protos/ams.proto Normal file
View File

@ -0,0 +1,287 @@
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;
}

414
protos/ems.proto Normal file
View File

@ -0,0 +1,414 @@
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;
}

276
protos/lms.proto Normal file
View File

@ -0,0 +1,276 @@
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;
}

24
scripts/gen_protos.sh Executable file
View File

@ -0,0 +1,24 @@
#!/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!"

15
services/ams/db.ts Normal file
View File

@ -0,0 +1,15 @@
// 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

@ -0,0 +1,202 @@
// 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

@ -0,0 +1,18 @@
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

@ -0,0 +1,119 @@
// 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: [] };
}
}
};

33
services/ams/main.ts Normal file
View File

@ -0,0 +1,33 @@
// 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

@ -0,0 +1,162 @@
// 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

@ -0,0 +1,55 @@
// 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

@ -0,0 +1,820 @@
// 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

@ -0,0 +1,102 @@
// 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

@ -0,0 +1,208 @@
// 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();
}
};

15
services/ems/db.ts Normal file
View File

@ -0,0 +1,15 @@
// 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

@ -0,0 +1,37 @@
// 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

@ -0,0 +1,26 @@
// 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

@ -0,0 +1,213 @@
// 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

@ -0,0 +1,101 @@
// 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: [] };
}
}
};

36
services/ems/main.ts Normal file
View File

@ -0,0 +1,36 @@
// 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

@ -0,0 +1,35 @@
// 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

@ -0,0 +1,47 @@
// 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

@ -0,0 +1,194 @@
// 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

@ -0,0 +1,31 @@
// 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

@ -0,0 +1,189 @@
// 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

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

View File

@ -0,0 +1,23 @@
// 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

@ -0,0 +1,19 @@
// 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

@ -0,0 +1,143 @@
// 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

@ -0,0 +1,13 @@
// 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

@ -0,0 +1,265 @@
// 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

@ -0,0 +1,70 @@
// 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

@ -0,0 +1,24 @@
// 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

@ -0,0 +1,50 @@
// 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

@ -0,0 +1,22 @@
// 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

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

View File

@ -0,0 +1,28 @@
// 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();
};

45
services/gateway/main.ts Normal file
View File

@ -0,0 +1,45 @@
// 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

@ -0,0 +1,232 @@
// 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

@ -0,0 +1,350 @@
// 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

@ -0,0 +1,231 @@
// 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

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

15
services/lms/db.ts Normal file
View File

@ -0,0 +1,15 @@
// 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

@ -0,0 +1,42 @@
// 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

@ -0,0 +1,49 @@
// 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

@ -0,0 +1,28 @@
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

@ -0,0 +1,32 @@
// 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

@ -0,0 +1,40 @@
// 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

@ -0,0 +1,21 @@
// 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: [] }; }
}
};

39
services/lms/main.ts Normal file
View File

@ -0,0 +1,39 @@
// 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

@ -0,0 +1,245 @@
// 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

@ -0,0 +1,25 @@
// 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 });
};

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