diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index a0ffdfd..1c674b9 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -68,7 +68,7 @@ Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.ser | 10 | **vendors** | [x] Done | CRUD + addresses/contacts/bank-details; encrypt bank accounts | | 11 | **purchase-orders** | [x] Done | Header + line items; submit/approve/reject/amend/cancel; PDF export | | 12 | **grn** | [x] Done | Receipt + PO status recalc + asset auto-creation | -| 13 | **assets** | [x] Done | CRUD + transfer + transfer history | +| 13 | **assets** | [x] Done | CRUD + transfer + AMC/service visits/insurance + expiry alerts | Current progress: @@ -80,7 +80,7 @@ Current progress: - [x] `vendors` module (CRUD, status, addresses, contacts, bank-details) - [x] `purchase-orders` module (CRUD, workflow, PDF) - [x] `grn` module (transactional receipt, PO status recalc, asset auto-creation) -- [x] `assets` module (CRUD, transfer, transfer history) +- [x] `assets` module (CRUD, transfer, AMC, service visits, insurance, alerts) - [x] `GET /auth/me` (current user + permissions for FE) ### Layer rules @@ -145,7 +145,7 @@ Base path: `/api/v1` · Auth: `Authorization: Bearer ` (except publ | [x] | GET | `/roles/cards` | view | Role cards (name, description, user/permission counts) | | [x] | GET | `/roles` | view | Paginated role list | | [x] | GET | `/roles/:id` | view | Role detail with permissions | -| [x] | POST | `/roles` | create | Create role | +| [x] | POST | `/roles` | create | Create role with optional initial permissions (`permission_ids[]` and/or `view_modules[]`) | | [x] | PUT | `/roles/:id` | edit | Update role | | [x] | DELETE | `/roles/:id` | delete | Soft delete (blocked if users assigned) | | [x] | PUT | `/roles/:id/permissions` | edit | Assign by `permission_ids[]` (replaces all) | @@ -250,6 +250,45 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | [x] | POST | `/assets/:id/transfer` | edit | Transfer asset | | [x] | GET | `/assets/:id/transfer-history` | view | Transfer history | +**AMC contracts** (`/assets/:id/amc`) + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/assets/:id/amc` | view | List AMC contracts | +| [x] | POST | `/assets/:id/amc` | create | Add AMC contract | +| [x] | GET | `/assets/:id/amc/:contractId` | view | AMC detail | +| [x] | PUT | `/assets/:id/amc/:contractId` | edit | Update AMC | +| [x] | PATCH | `/assets/:id/amc/:contractId/renew` | edit | Renew AMC (deactivate old, create new) | + +**Service visits** (`/assets/:id/service-visits`) + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/assets/:id/service-visits` | view | List visits | +| [x] | POST | `/assets/:id/service-visits` | create | Log visit | +| [x] | GET | `/assets/:id/service-visits/:visitId` | view | Visit detail | +| [x] | PUT | `/assets/:id/service-visits/:visitId` | edit | Update visit | +| [x] | PATCH | `/assets/:id/service-visits/:visitId/status` | edit | Update visit status | + +**Insurance** (`/assets/:id/insurance`) + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/assets/:id/insurance` | view | List policies | +| [x] | POST | `/assets/:id/insurance` | create | Add policy | +| [x] | GET | `/assets/:id/insurance/:policyId` | view | Policy detail | +| [x] | PUT | `/assets/:id/insurance/:policyId` | edit | Update policy | +| [x] | PATCH | `/assets/:id/insurance/:policyId/renew` | edit | Renew policy | + +**Alerts** (cross-asset) + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/assets/alerts/expiry` | view | AMC/Insurance/Warranty expiry (`?days=30/60/90&type=`) | +| [x] | GET | `/assets/alerts/service` | view | Overdue/upcoming service (`?status=OVERDUE`) | + +**DB patch:** run `scripts/patch-assets-amc-insurance.sql` then `scripts/patch-assets-views.sql` on deployed DB. + --- ### API progress summary @@ -264,8 +303,8 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | Vendors | 20 | 20 | [x] Done | | Purchase Orders | 11 | 11 | [x] Done | | GRN | 6 | 6 | [x] Done | -| Assets | 7 | 7 | [x] Done | -| **Total** | **139** | **139** | **[x] Phase 1 APIs complete** | +| Assets | 24 | 24 | [x] Done | +| **Total** | **156** | **156** | **[x] Phase 1 APIs + Asset extensions** | --- diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dea8f48..d008a30 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,17 +9,130 @@ datasource db { /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. model asset_attachments { - id BigInt @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) asset_id BigInt - attachment_type String @default("DOCUMENT") @db.VarChar(50) - file_name String @db.VarChar(255) - file_path String @db.VarChar(500) - file_type String? @db.VarChar(100) + attachment_type String @default("DOCUMENT") @db.VarChar(50) + file_name String @db.VarChar(255) + file_path String @db.VarChar(500) + file_type String? @db.VarChar(100) file_size Int? uploaded_by BigInt? - created_at DateTime @default(now()) @db.Timestamptz(6) - assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction) + amc_contract_id BigInt? + service_visit_id BigInt? + insurance_id BigInt? + created_at DateTime @default(now()) @db.Timestamptz(6) + asset_amc_contracts asset_amc_contracts? @relation(fields: [amc_contract_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + asset_insurance_policies asset_insurance_policies? @relation(fields: [insurance_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + asset_service_visits asset_service_visits? @relation(fields: [service_visit_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction) +} + +model asset_amc_contracts { + id BigInt @id @default(autoincrement()) + asset_id BigInt + vendor_id BigInt + contract_no String? @db.VarChar(100) + contract_type String @default("COMPREHENSIVE") @db.VarChar(30) + start_date DateTime @db.Date + end_date DateTime @db.Date + renewal_date DateTime? @db.Date + annual_cost Decimal @default(0) @db.Decimal(12, 2) + payment_frequency String @default("ANNUAL") @db.VarChar(20) + service_frequency String? @db.VarChar(20) + visits_per_year Int? + contact_person String? @db.VarChar(150) + contact_phone String? @db.VarChar(15) + contact_email String? @db.VarChar(150) + scope_of_work String? + exclusions String? + remarks String? + is_active Boolean @default(true) + created_by BigInt? + updated_by BigInt? + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + deleted_at DateTime? @db.Timestamptz(6) + asset_attachments asset_attachments[] + assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + users_asset_amc_contracts_created_byTousers users? @relation("asset_amc_contracts_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) + users_asset_amc_contracts_updated_byTousers users? @relation("asset_amc_contracts_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) + vendors vendors @relation(fields: [vendor_id], references: [id], onUpdate: NoAction) + asset_service_visits asset_service_visits[] + + @@index([asset_id], map: "idx_amc_asset_id") + @@index([vendor_id], map: "idx_amc_vendor_id") +} + +model asset_insurance_policies { + id BigInt @id @default(autoincrement()) + asset_id BigInt + policy_no String @db.VarChar(100) + insurer_name String @db.VarChar(200) + insurer_branch String? @db.VarChar(150) + insurer_contact String? @db.VarChar(150) + insurer_phone String? @db.VarChar(15) + insurer_email String? @db.VarChar(150) + policy_type String @default("FIRE_AND_ALLIED") @db.VarChar(50) + sum_insured Decimal @default(0) @db.Decimal(15, 2) + annual_premium Decimal @default(0) @db.Decimal(12, 2) + policy_start_date DateTime @db.Date + policy_end_date DateTime @db.Date + renewal_date DateTime? @db.Date + is_auto_renewal Boolean @default(false) + premium_paid Boolean @default(false) + premium_paid_date DateTime? @db.Date + remarks String? + is_active Boolean @default(true) + created_by BigInt? + updated_by BigInt? + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + deleted_at DateTime? @db.Timestamptz(6) + asset_attachments asset_attachments[] + assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + users_asset_insurance_policies_created_byTousers users? @relation("asset_insurance_policies_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) + users_asset_insurance_policies_updated_byTousers users? @relation("asset_insurance_policies_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) + + @@index([asset_id], map: "idx_insurance_asset_id") +} + +model asset_service_visits { + id BigInt @id @default(autoincrement()) + asset_id BigInt + amc_contract_id BigInt? + visit_type String @db.VarChar(30) + visit_date DateTime @db.Date + visit_number Int? + complaint_no String? @db.VarChar(50) + complaint_date DateTime? @db.Date + complaint_desc String? + engineer_name String? @db.VarChar(150) + engineer_phone String? @db.VarChar(15) + vendor_id BigInt? + work_done String? + parts_replaced String? + next_service_date DateTime? @db.Date + status String @default("COMPLETED") @db.VarChar(20) + downtime_hours Decimal? @default(0) @db.Decimal(6, 2) + service_cost Decimal? @default(0) @db.Decimal(12, 2) + is_under_amc Boolean @default(false) + asset_condition_after String? @db.VarChar(20) + remarks String? + created_by BigInt? + updated_by BigInt? + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + deleted_at DateTime? @db.Timestamptz(6) + asset_attachments asset_attachments[] + asset_amc_contracts asset_amc_contracts? @relation(fields: [amc_contract_id], references: [id], onUpdate: NoAction) + assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + users_asset_service_visits_created_byTousers users? @relation("asset_service_visits_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) + users_asset_service_visits_updated_byTousers users? @relation("asset_service_visits_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) + vendors vendors? @relation(fields: [vendor_id], references: [id], onUpdate: NoAction) + + @@index([asset_id], map: "idx_service_asset_id") + @@index([amc_contract_id], map: "idx_service_amc_id") } /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. @@ -95,11 +208,6 @@ model assets { depreciation_method String? @db.VarChar(10) salvage_value Decimal? @default(0) @db.Decimal(15, 4) warranty_expiry_date DateTime? @db.Date - amc_start_date DateTime? @db.Date - amc_end_date DateTime? @db.Date - amc_vendor_id BigInt? - insurance_policy_no String? @db.VarChar(100) - insurance_expiry_date DateTime? @db.Date condition String @default("NEW") @db.VarChar(20) status String @default("IN_USE") @db.VarChar(30) qr_code_value String? @db.VarChar(100) @@ -113,9 +221,11 @@ model assets { created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) + asset_amc_contracts asset_amc_contracts[] asset_attachments asset_attachments[] + asset_insurance_policies asset_insurance_policies[] + asset_service_visits asset_service_visits[] asset_transfers asset_transfers[] - vendors_assets_amc_vendor_idTovendors vendors? @relation("assets_amc_vendor_idTovendors", fields: [amc_vendor_id], references: [id], onUpdate: NoAction) asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction) users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction) users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) @@ -669,7 +779,8 @@ model users { employee_code String @unique @db.VarChar(30) full_name String @db.VarChar(200) email String @unique @db.VarChar(200) - mobile String? @db.VarChar(20) + mobile String? @db.VarChar(255) + mobile_index String? @db.VarChar(64) password_hash String @db.VarChar(255) role_id BigInt? department_id BigInt? @@ -687,6 +798,12 @@ model users { updated_at DateTime @default(now()) @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) asset_attachments asset_attachments[] + asset_amc_contracts_asset_amc_contracts_created_byTousers asset_amc_contracts[] @relation("asset_amc_contracts_created_byTousers") + asset_amc_contracts_asset_amc_contracts_updated_byTousers asset_amc_contracts[] @relation("asset_amc_contracts_updated_byTousers") + asset_insurance_policies_asset_insurance_policies_created_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_created_byTousers") + asset_insurance_policies_asset_insurance_policies_updated_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_updated_byTousers") + asset_service_visits_asset_service_visits_created_byTousers asset_service_visits[] @relation("asset_service_visits_created_byTousers") + asset_service_visits_asset_service_visits_updated_byTousers asset_service_visits[] @relation("asset_service_visits_updated_byTousers") asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers") asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers") asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers") @@ -853,8 +970,9 @@ model vendors { created_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6) deleted_at DateTime? @db.Timestamptz(6) - assets_assets_amc_vendor_idTovendors assets[] @relation("assets_amc_vendor_idTovendors") assets_assets_vendor_idTovendors assets[] @relation("assets_vendor_idTovendors") + asset_amc_contracts asset_amc_contracts[] + asset_service_visits asset_service_visits[] grn grn[] purchase_orders purchase_orders[] vendor_addresses vendor_addresses[] diff --git a/scripts/patch-assets-amc-insurance.sql b/scripts/patch-assets-amc-insurance.sql new file mode 100644 index 0000000..b100ca2 --- /dev/null +++ b/scripts/patch-assets-amc-insurance.sql @@ -0,0 +1,149 @@ +-- Asset module Phase 1.1: AMC / Service Visits / Insurance child tables +-- Run once: psql "$DATABASE_URL" -f scripts/patch-assets-amc-insurance.sql + +-- Remove inline AMC/insurance from assets +ALTER TABLE assets + DROP COLUMN IF EXISTS amc_start_date, + DROP COLUMN IF EXISTS amc_end_date, + DROP COLUMN IF EXISTS amc_vendor_id, + DROP COLUMN IF EXISTS insurance_policy_no, + DROP COLUMN IF EXISTS insurance_expiry_date; + +-- AMC contracts +CREATE TABLE IF NOT EXISTS asset_amc_contracts ( + id BIGSERIAL PRIMARY KEY, + asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + vendor_id BIGINT NOT NULL REFERENCES vendors(id) ON DELETE RESTRICT, + contract_no VARCHAR(100), + contract_type VARCHAR(30) NOT NULL DEFAULT 'COMPREHENSIVE' + CHECK (contract_type IN ('COMPREHENSIVE','LABOUR_ONLY','PARTS_ONLY','PREVENTIVE_ONLY')), + start_date DATE NOT NULL, + end_date DATE NOT NULL, + renewal_date DATE, + annual_cost NUMERIC(12,2) NOT NULL DEFAULT 0, + payment_frequency VARCHAR(20) NOT NULL DEFAULT 'ANNUAL' + CHECK (payment_frequency IN ('MONTHLY','QUARTERLY','HALF_YEARLY','ANNUAL')), + service_frequency VARCHAR(20) + CHECK (service_frequency IN ('MONTHLY','QUARTERLY','HALF_YEARLY','ANNUAL')), + visits_per_year INT, + contact_person VARCHAR(150), + contact_phone VARCHAR(15), + contact_email VARCHAR(150), + scope_of_work TEXT, + exclusions TEXT, + remarks TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_amc_asset_id ON asset_amc_contracts(asset_id); +CREATE INDEX IF NOT EXISTS idx_amc_vendor_id ON asset_amc_contracts(vendor_id); +CREATE INDEX IF NOT EXISTS idx_amc_end_date ON asset_amc_contracts(end_date) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_amc_is_active ON asset_amc_contracts(asset_id, is_active); + +DROP TRIGGER IF EXISTS trg_asset_amc_contracts_updated_at ON asset_amc_contracts; +CREATE TRIGGER trg_asset_amc_contracts_updated_at + BEFORE UPDATE ON asset_amc_contracts + FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at(); + +-- Service visits +CREATE TABLE IF NOT EXISTS asset_service_visits ( + id BIGSERIAL PRIMARY KEY, + asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + amc_contract_id BIGINT REFERENCES asset_amc_contracts(id) ON DELETE SET NULL, + visit_type VARCHAR(30) NOT NULL + CHECK (visit_type IN ('PREVENTIVE','BREAKDOWN','INSPECTION','INSTALLATION','CALIBRATION','OTHER')), + visit_date DATE NOT NULL, + visit_number INT, + complaint_no VARCHAR(50), + complaint_date DATE, + complaint_desc TEXT, + engineer_name VARCHAR(150), + engineer_phone VARCHAR(15), + vendor_id BIGINT REFERENCES vendors(id) ON DELETE SET NULL, + work_done TEXT, + parts_replaced TEXT, + next_service_date DATE, + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED' + CHECK (status IN ('SCHEDULED','IN_PROGRESS','COMPLETED','CANCELLED','PENDING_PARTS')), + downtime_hours NUMERIC(6,2) DEFAULT 0, + service_cost NUMERIC(12,2) DEFAULT 0, + is_under_amc BOOLEAN NOT NULL DEFAULT FALSE, + asset_condition_after VARCHAR(20) + CHECK (asset_condition_after IN ('GOOD','FAIR','POOR','NEEDS_REPLACEMENT')), + remarks TEXT, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_service_asset_id ON asset_service_visits(asset_id); +CREATE INDEX IF NOT EXISTS idx_service_amc_id ON asset_service_visits(amc_contract_id); +CREATE INDEX IF NOT EXISTS idx_service_visit_date ON asset_service_visits(visit_date); +CREATE INDEX IF NOT EXISTS idx_service_status ON asset_service_visits(status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_service_next_date ON asset_service_visits(next_service_date) WHERE deleted_at IS NULL; + +DROP TRIGGER IF EXISTS trg_asset_service_visits_updated_at ON asset_service_visits; +CREATE TRIGGER trg_asset_service_visits_updated_at + BEFORE UPDATE ON asset_service_visits + FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at(); + +-- Insurance policies +CREATE TABLE IF NOT EXISTS asset_insurance_policies ( + id BIGSERIAL PRIMARY KEY, + asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + policy_no VARCHAR(100) NOT NULL, + insurer_name VARCHAR(200) NOT NULL, + insurer_branch VARCHAR(150), + insurer_contact VARCHAR(150), + insurer_phone VARCHAR(15), + insurer_email VARCHAR(150), + policy_type VARCHAR(50) NOT NULL DEFAULT 'FIRE_AND_ALLIED' + CHECK (policy_type IN ('FIRE_AND_ALLIED','MACHINERY_BREAKDOWN','COMPREHENSIVE','THIRD_PARTY','VEHICLE','OTHER')), + sum_insured NUMERIC(15,2) NOT NULL DEFAULT 0, + annual_premium NUMERIC(12,2) NOT NULL DEFAULT 0, + policy_start_date DATE NOT NULL, + policy_end_date DATE NOT NULL, + renewal_date DATE, + is_auto_renewal BOOLEAN NOT NULL DEFAULT FALSE, + premium_paid BOOLEAN NOT NULL DEFAULT FALSE, + premium_paid_date DATE, + remarks TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_insurance_asset_id ON asset_insurance_policies(asset_id); +CREATE INDEX IF NOT EXISTS idx_insurance_end_date ON asset_insurance_policies(policy_end_date) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_insurance_is_active ON asset_insurance_policies(asset_id, is_active); + +DROP TRIGGER IF EXISTS trg_asset_insurance_policies_updated_at ON asset_insurance_policies; +CREATE TRIGGER trg_asset_insurance_policies_updated_at + BEFORE UPDATE ON asset_insurance_policies + FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at(); + +-- Extend asset_attachments +ALTER TABLE asset_attachments + ADD COLUMN IF NOT EXISTS amc_contract_id BIGINT REFERENCES asset_amc_contracts(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS service_visit_id BIGINT REFERENCES asset_service_visits(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS insurance_id BIGINT REFERENCES asset_insurance_policies(id) ON DELETE CASCADE; + +ALTER TABLE asset_attachments DROP CONSTRAINT IF EXISTS asset_attachments_attachment_type_check; +ALTER TABLE asset_attachments + ADD CONSTRAINT asset_attachments_attachment_type_check + CHECK (attachment_type IN ( + 'DOCUMENT','PHOTO','WARRANTY','INVOICE', + 'AMC_CONTRACT','SERVICE_REPORT','INSURANCE_POLICY','OTHER' + )); + +-- Views: run scripts/patch-assets-views.sql after this file if not using \i diff --git a/scripts/patch-assets-views.sql b/scripts/patch-assets-views.sql new file mode 100644 index 0000000..fec2123 --- /dev/null +++ b/scripts/patch-assets-views.sql @@ -0,0 +1,164 @@ +-- Asset alert and summary views (run from patch-assets-amc-insurance.sql or standalone) + +DROP VIEW IF EXISTS v_asset_expiry_alerts CASCADE; +DROP VIEW IF EXISTS v_asset_next_service CASCADE; +DROP VIEW IF EXISTS v_assets CASCADE; + +CREATE VIEW v_asset_expiry_alerts AS + SELECT + a.id AS asset_id, + a.asset_code, + a.asset_name, + ac_cat.name AS category_name, + p.name AS plant_name, + d.name AS department_name, + 'AMC'::text AS alert_type, + amc.id AS reference_id, + amc.contract_no AS reference_no, + v.vendor_name AS party_name, + amc.end_date AS expiry_date, + (amc.end_date - CURRENT_DATE) AS days_remaining, + CASE + WHEN (amc.end_date - CURRENT_DATE) <= 0 THEN 'EXPIRED' + WHEN (amc.end_date - CURRENT_DATE) <= 30 THEN 'CRITICAL' + WHEN (amc.end_date - CURRENT_DATE) <= 60 THEN 'WARNING' + WHEN (amc.end_date - CURRENT_DATE) <= 90 THEN 'INFO' + END AS alert_level + FROM asset_amc_contracts amc + JOIN assets a ON a.id = amc.asset_id + JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id + JOIN plants p ON p.id = a.plant_id + LEFT JOIN departments d ON d.id = a.department_id + LEFT JOIN vendors v ON v.id = amc.vendor_id + WHERE amc.is_active = TRUE AND amc.deleted_at IS NULL AND a.deleted_at IS NULL + AND (amc.end_date - CURRENT_DATE) <= 90 + +UNION ALL + + SELECT + a.id, a.asset_code, a.asset_name, ac_cat.name, p.name, d.name, + 'INSURANCE'::text, ins.id, ins.policy_no, ins.insurer_name, + ins.policy_end_date, (ins.policy_end_date - CURRENT_DATE), + CASE + WHEN (ins.policy_end_date - CURRENT_DATE) <= 0 THEN 'EXPIRED' + WHEN (ins.policy_end_date - CURRENT_DATE) <= 30 THEN 'CRITICAL' + WHEN (ins.policy_end_date - CURRENT_DATE) <= 60 THEN 'WARNING' + WHEN (ins.policy_end_date - CURRENT_DATE) <= 90 THEN 'INFO' + END + FROM asset_insurance_policies ins + JOIN assets a ON a.id = ins.asset_id + JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id + JOIN plants p ON p.id = a.plant_id + LEFT JOIN departments d ON d.id = a.department_id + WHERE ins.is_active = TRUE AND ins.deleted_at IS NULL AND a.deleted_at IS NULL + AND (ins.policy_end_date - CURRENT_DATE) <= 90 + +UNION ALL + + SELECT + a.id, a.asset_code, a.asset_name, ac_cat.name, p.name, d.name, + 'WARRANTY'::text, a.id, a.asset_code, 'Manufacturer Warranty'::varchar, + a.warranty_expiry_date, (a.warranty_expiry_date - CURRENT_DATE), + CASE + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 0 THEN 'EXPIRED' + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 30 THEN 'CRITICAL' + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 60 THEN 'WARNING' + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 90 THEN 'INFO' + END + FROM assets a + JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id + JOIN plants p ON p.id = a.plant_id + LEFT JOIN departments d ON d.id = a.department_id + WHERE a.warranty_expiry_date IS NOT NULL AND a.deleted_at IS NULL + AND (a.warranty_expiry_date - CURRENT_DATE) <= 90; + +CREATE VIEW v_asset_next_service AS +SELECT + a.id AS asset_id, + a.asset_code, + a.asset_name, + ac_cat.name AS category_name, + p.name AS plant_name, + d.name AS department_name, + sv.id AS last_visit_id, + sv.visit_date AS last_service_date, + sv.visit_type AS last_visit_type, + sv.next_service_date, + (sv.next_service_date - CURRENT_DATE) AS days_to_next_service, + CASE + WHEN sv.next_service_date < CURRENT_DATE THEN 'OVERDUE' + WHEN (sv.next_service_date - CURRENT_DATE) <= 7 THEN 'DUE_THIS_WEEK' + WHEN (sv.next_service_date - CURRENT_DATE) <= 30 THEN 'DUE_THIS_MONTH' + ELSE 'UPCOMING' + END AS service_status, + v.vendor_name AS service_vendor, + amc.contract_no AS amc_contract_no, + amc.end_date AS amc_end_date +FROM assets a +JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id +JOIN plants p ON p.id = a.plant_id +LEFT JOIN departments d ON d.id = a.department_id +JOIN LATERAL ( + SELECT * FROM asset_service_visits + WHERE asset_id = a.id AND deleted_at IS NULL AND next_service_date IS NOT NULL + ORDER BY visit_date DESC + LIMIT 1 +) sv ON TRUE +LEFT JOIN vendors v ON v.id = sv.vendor_id +LEFT JOIN asset_amc_contracts amc + ON amc.asset_id = a.id AND amc.is_active = TRUE AND amc.deleted_at IS NULL +WHERE a.deleted_at IS NULL AND a.status NOT IN ('DISPOSED','SCRAPPED'); + +CREATE VIEW v_assets AS +SELECT + a.id, + a.asset_code, + a.asset_name, + ac.name AS category_name, + a.brand_model, + a.serial_number, + a.status, + a.condition, + a.purchase_date, + a.purchase_cost, + a.warranty_expiry_date, + p.name AS plant_name, + d.name AS department_name, + w.name AS warehouse_name, + a.location_detail, + u.full_name AS assigned_to, + v.vendor_name AS supplier, + a.qr_code_value, + amc.contract_no AS amc_contract_no, + amc.end_date AS amc_expiry_date, + amc.contract_type AS amc_type, + amcv.vendor_name AS amc_vendor, + (amc.end_date - CURRENT_DATE) AS amc_days_remaining, + ins.policy_no AS insurance_policy_no, + ins.policy_end_date AS insurance_expiry_date, + ins.insurer_name, + ins.sum_insured, + (ins.policy_end_date - CURRENT_DATE) AS insurance_days_remaining, + lsv.visit_date AS last_service_date, + lsv.next_service_date, + (lsv.next_service_date - CURRENT_DATE) AS service_due_in_days, + a.created_at +FROM assets a +JOIN asset_categories ac ON ac.id = a.asset_category_id +JOIN plants p ON p.id = a.plant_id +LEFT JOIN departments d ON d.id = a.department_id +LEFT JOIN warehouses w ON w.id = a.warehouse_id +LEFT JOIN users u ON u.id = a.assigned_to_user_id +LEFT JOIN vendors v ON v.id = a.vendor_id +LEFT JOIN asset_amc_contracts amc + ON amc.asset_id = a.id AND amc.is_active = TRUE AND amc.deleted_at IS NULL +LEFT JOIN vendors amcv ON amcv.id = amc.vendor_id +LEFT JOIN asset_insurance_policies ins + ON ins.asset_id = a.id AND ins.is_active = TRUE AND ins.deleted_at IS NULL +LEFT JOIN LATERAL ( + SELECT visit_date, next_service_date + FROM asset_service_visits + WHERE asset_id = a.id AND deleted_at IS NULL + ORDER BY visit_date DESC LIMIT 1 +) lsv ON TRUE +WHERE a.deleted_at IS NULL; diff --git a/scripts/patch-users-mobile-encryption.sql b/scripts/patch-users-mobile-encryption.sql new file mode 100644 index 0000000..651813e --- /dev/null +++ b/scripts/patch-users-mobile-encryption.sql @@ -0,0 +1,12 @@ +-- Expand users.mobile for AES-256-GCM ciphertext and add blind index for lookup. +-- Run once on deployed DB: psql "$DATABASE_URL" -f scripts/patch-users-mobile-encryption.sql + +ALTER TABLE users + ALTER COLUMN mobile TYPE VARCHAR(255); + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS mobile_index VARCHAR(64); + +CREATE INDEX IF NOT EXISTS idx_users_mobile_index + ON users (mobile_index) + WHERE deleted_at IS NULL AND mobile_index IS NOT NULL; diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index b58c272..983241b 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -9,187 +9,225 @@ components: properties: asset_name: { type: string, example: 'CNC Lathe Machine' } asset_category_id: { type: integer, example: 1 } - brand_model: { type: string, example: 'Haas ST-20' } - manufacturer: { type: string, example: 'Haas Automation' } - serial_number: { type: string, example: SN-12345 } - part_number: { type: string, example: PN-9876 } plant_id: { type: integer, example: 1 } - department_id: { type: integer, nullable: true, example: 1 } - warehouse_id: { type: integer, nullable: true, example: 1 } - location_detail: { type: string, example: 'Production Bay 2' } - assigned_to_user_id: { type: integer, nullable: true, example: 1 } - vendor_id: { type: integer, nullable: true, example: 1 } - po_id: { type: integer, nullable: true } - grn_id: { type: integer, nullable: true } - grn_item_id: { type: integer, nullable: true } - purchase_date: { type: string, format: date, nullable: true } + warranty_expiry_date: { type: string, format: date, nullable: true } purchase_cost: { type: number, example: 850000 } - useful_life_years: { type: integer, example: 15 } - depreciation_method: { type: string, enum: [SLM, WDV], example: WDV } - salvage_value: { type: number, example: 50000 } - warranty_expiry_date: { type: string, format: date, nullable: true } - amc_start_date: { type: string, format: date, nullable: true } - amc_end_date: { type: string, format: date, nullable: true } - amc_vendor_id: { type: integer, nullable: true } - insurance_policy_no: { type: string, example: POL-2026-001 } - insurance_expiry_date: { type: string, format: date, nullable: true } - condition: { type: string, enum: [NEW, GOOD, FAIR, POOR], example: NEW } - status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED], example: IN_USE } - qr_code_value: { type: string, example: QR-ASSET-001 } - disposal_date: { type: string, format: date, nullable: true } - disposal_reason: { type: string, nullable: true } - disposal_value: { type: number, nullable: true } - remarks: { type: string, example: 'Capital asset' } - is_active: { type: boolean, example: true } - AssetsUpdateBody: + AmcContractBody: type: object - minProperties: 1 + required: [vendor_id, start_date, end_date] properties: - asset_name: { type: string } - asset_category_id: { type: integer } - brand_model: { type: string } - manufacturer: { type: string } - serial_number: { type: string } - part_number: { type: string } - plant_id: { type: integer } - department_id: { type: integer, nullable: true } - warehouse_id: { type: integer, nullable: true } - location_detail: { type: string } - assigned_to_user_id: { type: integer, nullable: true } - vendor_id: { type: integer, nullable: true } - po_id: { type: integer, nullable: true } - grn_id: { type: integer, nullable: true } - grn_item_id: { type: integer, nullable: true } - purchase_date: { type: string, format: date, nullable: true } - purchase_cost: { type: number } - useful_life_years: { type: integer, nullable: true } - depreciation_method: { type: string, enum: [SLM, WDV], nullable: true } - salvage_value: { type: number } - warranty_expiry_date: { type: string, format: date, nullable: true } - amc_start_date: { type: string, format: date, nullable: true } - amc_end_date: { type: string, format: date, nullable: true } - amc_vendor_id: { type: integer, nullable: true } - insurance_policy_no: { type: string } - insurance_expiry_date: { type: string, format: date, nullable: true } - condition: { type: string, enum: [NEW, GOOD, FAIR, POOR] } - status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED] } - qr_code_value: { type: string } - disposal_date: { type: string, format: date, nullable: true } - disposal_reason: { type: string } - disposal_value: { type: number, nullable: true } - remarks: { type: string } - is_active: { type: boolean } - AssetsTransferBody: + vendor_id: { type: integer, example: 1 } + contract_no: { type: string, example: AMC-2026-001 } + contract_type: { type: string, enum: [COMPREHENSIVE, LABOUR_ONLY, PARTS_ONLY, PREVENTIVE_ONLY] } + start_date: { type: string, format: date } + end_date: { type: string, format: date } + annual_cost: { type: number, example: 50000 } + ServiceVisitBody: type: object - required: [transfer_date] + required: [visit_type, visit_date] properties: - transfer_date: { type: string, format: date, example: '2026-06-18' } - to_plant_id: { type: integer, nullable: true, example: 2 } - to_department_id: { type: integer, nullable: true, example: 3 } - to_user_id: { type: integer, nullable: true, example: 5 } - to_warehouse_id: { type: integer, nullable: true, example: 1 } - reason: { type: string, example: 'Moved to new production line' } + visit_type: { type: string, enum: [PREVENTIVE, BREAKDOWN, INSPECTION, INSTALLATION, CALIBRATION, OTHER] } + visit_date: { type: string, format: date } + complaint_no: { type: string, example: CMP-001 } + work_done: { type: string } + next_service_date: { type: string, format: date, nullable: true } + status: { type: string, enum: [SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, PENDING_PARTS] } + InsurancePolicyBody: + type: object + required: [policy_no, insurer_name, policy_start_date, policy_end_date] + properties: + policy_no: { type: string, example: POL-2026-001 } + insurer_name: { type: string, example: 'ICICI Lombard' } + policy_type: { type: string, enum: [FIRE_AND_ALLIED, MACHINERY_BREAKDOWN, COMPREHENSIVE, THIRD_PARTY, VEHICLE, OTHER] } + sum_insured: { type: number, example: 1000000 } + policy_start_date: { type: string, format: date } + policy_end_date: { type: string, format: date } paths: - /assets: + /assets/alerts/expiry: get: tags: [Assets] - summary: List assets + summary: Expiry alerts (AMC, Insurance, Warranty) parameters: - - { name: page, in: query, schema: { type: integer, example: 1 } } - - { name: limit, in: query, schema: { type: integer, example: 20 } } - - { name: search, in: query, schema: { type: string, example: MCH- } } - - { name: status, in: query, schema: { type: string, example: IN_USE } } - - { name: condition, in: query, schema: { type: string, example: NEW } } - - { name: asset_category_id, in: query, schema: { type: integer } } - - { name: plant_id, in: query, schema: { type: integer } } - - { name: department_id, in: query, schema: { type: integer } } - - { name: is_active, in: query, schema: { type: boolean } } + - { name: days, in: query, schema: { type: integer, example: 30 } } + - { name: type, in: query, schema: { type: string, enum: [AMC, INSURANCE, WARRANTY] } } + - { name: page, in: query, schema: { type: integer } } + - { name: limit, in: query, schema: { type: integer } } responses: '200': - description: Assets fetched + description: Expiry alerts fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /assets/alerts/service: + get: + tags: [Assets] + summary: Service due/overdue alerts + parameters: + - { name: status, in: query, schema: { type: string, enum: [OVERDUE, DUE_THIS_WEEK, DUE_THIS_MONTH, UPCOMING] } } + responses: + '200': + description: Service alerts fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /assets/{id}/amc: + get: + tags: [Assets] + summary: List AMC contracts for asset + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: AMC contracts fetched content: application/json: schema: { $ref: '#/components/schemas/ApiResponse' } post: tags: [Assets] - summary: Create asset + summary: Create AMC contract + parameters: + - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AmcContractBody' } + responses: + '201': + description: AMC contract created + /assets/{id}/amc/{contractId}/renew: + patch: + tags: [Assets] + summary: Renew AMC contract + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: contractId, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [start_date, end_date] + properties: + start_date: { type: string, format: date } + end_date: { type: string, format: date } + responses: + '201': + description: AMC renewed + /assets/{id}/service-visits: + get: + tags: [Assets] + summary: List service visits + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: Service visits fetched + post: + tags: [Assets] + summary: Log service visit + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ServiceVisitBody' } + responses: + '201': + description: Service visit created + /assets/{id}/insurance: + get: + tags: [Assets] + summary: List insurance policies + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: Insurance policies fetched + post: + tags: [Assets] + summary: Create insurance policy + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/InsurancePolicyBody' } + responses: + '201': + description: Insurance policy created + /assets/{id}/insurance/{policyId}/renew: + patch: + tags: [Assets] + summary: Renew insurance policy + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: policyId, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [policy_start_date, policy_end_date] + properties: + policy_start_date: { type: string, format: date } + policy_end_date: { type: string, format: date } + responses: + '201': + description: Insurance renewed + /assets: + get: + tags: [Assets] + summary: List assets + responses: + '200': + description: Assets fetched + post: + tags: [Assets] + summary: Create asset + requestBody: content: application/json: schema: { $ref: '#/components/schemas/AssetsCreateBody' } responses: '201': description: Asset created - content: - application/json: - schema: { $ref: '#/components/schemas/ApiResponse' } /assets/{id}: get: tags: [Assets] - summary: Get asset detail + summary: Get asset parameters: - - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: Asset fetched - content: - application/json: - schema: { $ref: '#/components/schemas/ApiResponse' } put: tags: [Assets] summary: Update asset parameters: - - { name: id, in: path, required: true, schema: { type: string, example: '1' } } - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/AssetsUpdateBody' } + - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: Asset updated - content: - application/json: - schema: { $ref: '#/components/schemas/ApiResponse' } delete: tags: [Assets] summary: Soft delete asset parameters: - - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: Asset deleted - content: - application/json: - schema: { $ref: '#/components/schemas/ApiResponse' } /assets/{id}/transfer: post: tags: [Assets] - summary: Transfer asset location/assignment + summary: Transfer asset parameters: - - { name: id, in: path, required: true, schema: { type: string, example: '1' } } - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/AssetsTransferBody' } + - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: Asset transferred - content: - application/json: - schema: { $ref: '#/components/schemas/ApiResponse' } - /assets/{id}/transfer-history: - get: - tags: [Assets] - summary: Get asset transfer history - parameters: - - { name: id, in: path, required: true, schema: { type: string, example: '1' } } - responses: - '200': - description: Transfer history fetched - content: - application/json: - schema: { $ref: '#/components/schemas/ApiResponse' } diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 730ad51..0ba8cfe 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -361,6 +361,16 @@ components: name: { type: string, example: 'Purchase Manager' } description: { type: string, example: 'Manages purchase orders' } is_active: { type: boolean, example: true } + permission_ids: + type: array + description: Optional initial permissions by permission id (from GET /roles/permissions) + items: { type: integer } + example: [1, 7, 13] + view_modules: + type: array + description: Optional module codes to grant view-only access (matches create-role UI checkboxes) + items: { type: string } + example: [USERS, MASTERS, VENDOR, PURCHASE_ORDER] RolesUpdateBody: type: object minProperties: 1 diff --git a/src/modules/assets/assets-alerts.service.js b/src/modules/assets/assets-alerts.service.js new file mode 100644 index 0000000..e491c31 --- /dev/null +++ b/src/modules/assets/assets-alerts.service.js @@ -0,0 +1,74 @@ +const { Prisma } = require('@prisma/client'); +const prisma = require('../../config/prisma'); +const { getPagination } = require('../../utils/pagination'); + +const serializeRow = (row) => { + const out = {}; + for (const [key, value] of Object.entries(row)) { + out[key] = typeof value === 'bigint' ? value.toString() : value; + } + return out; +}; + +const getExpiryAlerts = async (query) => { + const days = Math.min(Math.max(Number(query.days) || 90, 1), 365); + const { page, limit, skip } = getPagination(query); + + const typeClause = query.type ? Prisma.sql`AND alert_type = ${query.type}` : Prisma.empty; + + const [rows, countResult] = await Promise.all([ + prisma.$queryRaw` + SELECT * + FROM v_asset_expiry_alerts + WHERE days_remaining <= ${days} + ${typeClause} + ORDER BY days_remaining ASC + LIMIT ${limit} OFFSET ${skip} + `, + prisma.$queryRaw` + SELECT COUNT(*)::int AS total + FROM v_asset_expiry_alerts + WHERE days_remaining <= ${days} + ${typeClause} + `, + ]); + + const total = countResult[0]?.total ?? 0; + return { + data: rows.map(serializeRow), + meta: { page, limit, total, days_filter: days }, + }; +}; + +const getServiceAlerts = async (query) => { + const { page, limit, skip } = getPagination(query); + + const statusClause = query.status + ? Prisma.sql`AND service_status = ${query.status}` + : Prisma.empty; + + const [rows, countResult] = await Promise.all([ + prisma.$queryRaw` + SELECT * + FROM v_asset_next_service + WHERE 1=1 + ${statusClause} + ORDER BY days_to_next_service ASC + LIMIT ${limit} OFFSET ${skip} + `, + prisma.$queryRaw` + SELECT COUNT(*)::int AS total + FROM v_asset_next_service + WHERE 1=1 + ${statusClause} + `, + ]); + + const total = countResult[0]?.total ?? 0; + return { + data: rows.map(serializeRow), + meta: { page, limit, total }, + }; +}; + +module.exports = { getExpiryAlerts, getServiceAlerts }; diff --git a/src/modules/assets/assets-amc.service.js b/src/modules/assets/assets-amc.service.js new file mode 100644 index 0000000..b3fc61c --- /dev/null +++ b/src/modules/assets/assets-amc.service.js @@ -0,0 +1,217 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { + toDateOnly, + assertReference, + getAssetOrThrow, + deactivateOtherActive, +} = require('./assets.helpers'); + +const amcInclude = { + vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, +}; + +const sanitizeAmc = (row) => { + if (!row) return null; + const { vendors, ...rest } = row; + return { ...rest, vendor: vendors || null }; +}; + +const buildAmcData = async (payload, assetId) => { + await assertReference('vendors', payload.vendor_id, 'vendor_id'); + return { + asset_id: BigInt(assetId), + vendor_id: BigInt(payload.vendor_id), + contract_no: payload.contract_no || null, + contract_type: payload.contract_type ?? 'COMPREHENSIVE', + start_date: toDateOnly(payload.start_date), + end_date: toDateOnly(payload.end_date), + renewal_date: payload.renewal_date ? toDateOnly(payload.renewal_date) : null, + annual_cost: payload.annual_cost ?? 0, + payment_frequency: payload.payment_frequency ?? 'ANNUAL', + service_frequency: payload.service_frequency || null, + visits_per_year: payload.visits_per_year ?? null, + contact_person: payload.contact_person || null, + contact_phone: payload.contact_phone || null, + contact_email: payload.contact_email || null, + scope_of_work: payload.scope_of_work || null, + exclusions: payload.exclusions || null, + remarks: payload.remarks || null, + is_active: payload.is_active ?? true, + }; +}; + +const getAmcOrThrow = async (assetId, contractId) => { + const row = await prisma.asset_amc_contracts.findFirst({ + where: { + id: BigInt(contractId), + asset_id: BigInt(assetId), + deleted_at: null, + }, + include: amcInclude, + }); + if (!row) throw new ApiError(404, 'AMC contract not found'); + return row; +}; + +const listAmcContracts = async (assetId) => { + await getAssetOrThrow(assetId); + const rows = await prisma.asset_amc_contracts.findMany({ + where: { asset_id: BigInt(assetId), deleted_at: null }, + include: amcInclude, + orderBy: [{ is_active: 'desc' }, { start_date: 'desc' }], + }); + return rows.map(sanitizeAmc); +}; + +const createAmcContract = async (assetId, payload, userId, requestId) => { + await getAssetOrThrow(assetId); + const data = await buildAmcData(payload, assetId); + + const created = await prisma.$transaction(async (tx) => { + if (data.is_active) { + await deactivateOtherActive(tx, 'asset_amc_contracts', assetId); + } + return tx.asset_amc_contracts.create({ + data: { + ...data, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + include: amcInclude, + }); + }); + + await auditLog({ + tableName: 'asset_amc_contracts', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeAmc(created), + userId, + requestId, + }); + + return sanitizeAmc(created); +}; + +const getAmcContract = async (assetId, contractId) => + sanitizeAmc(await getAmcOrThrow(assetId, contractId)); + +const updateAmcContract = async (assetId, contractId, payload, userId, requestId) => { + const existing = await getAmcOrThrow(assetId, contractId); + const merged = { + vendor_id: payload.vendor_id ?? existing.vendor_id, + contract_no: payload.contract_no !== undefined ? payload.contract_no : existing.contract_no, + contract_type: payload.contract_type ?? existing.contract_type, + start_date: payload.start_date ?? existing.start_date, + end_date: payload.end_date ?? existing.end_date, + renewal_date: payload.renewal_date !== undefined ? payload.renewal_date : existing.renewal_date, + annual_cost: payload.annual_cost ?? Number(existing.annual_cost), + payment_frequency: payload.payment_frequency ?? existing.payment_frequency, + service_frequency: + payload.service_frequency !== undefined + ? payload.service_frequency + : existing.service_frequency, + visits_per_year: + payload.visits_per_year !== undefined ? payload.visits_per_year : existing.visits_per_year, + contact_person: + payload.contact_person !== undefined ? payload.contact_person : existing.contact_person, + contact_phone: + payload.contact_phone !== undefined ? payload.contact_phone : existing.contact_phone, + contact_email: + payload.contact_email !== undefined ? payload.contact_email : existing.contact_email, + scope_of_work: + payload.scope_of_work !== undefined ? payload.scope_of_work : existing.scope_of_work, + exclusions: payload.exclusions !== undefined ? payload.exclusions : existing.exclusions, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + is_active: payload.is_active !== undefined ? payload.is_active : existing.is_active, + }; + + const data = await buildAmcData(merged, assetId); + + const updated = await prisma.$transaction(async (tx) => { + if (data.is_active) { + await deactivateOtherActive(tx, 'asset_amc_contracts', assetId, contractId); + } + return tx.asset_amc_contracts.update({ + where: { id: BigInt(contractId) }, + data: { ...data, updated_by: userId ? BigInt(userId) : null }, + include: amcInclude, + }); + }); + + await auditLog({ + tableName: 'asset_amc_contracts', + recordId: contractId, + action: 'UPDATE', + oldValue: sanitizeAmc(existing), + newValue: sanitizeAmc(updated), + userId, + requestId, + }); + + return sanitizeAmc(updated); +}; + +const renewAmcContract = async (assetId, contractId, payload, userId, requestId) => { + const existing = await getAmcOrThrow(assetId, contractId); + const renewPayload = { + vendor_id: payload.vendor_id ?? existing.vendor_id, + contract_no: payload.contract_no ?? existing.contract_no, + contract_type: payload.contract_type ?? existing.contract_type, + start_date: payload.start_date, + end_date: payload.end_date, + renewal_date: payload.renewal_date ?? payload.end_date, + annual_cost: payload.annual_cost ?? Number(existing.annual_cost), + payment_frequency: payload.payment_frequency ?? existing.payment_frequency, + service_frequency: payload.service_frequency ?? existing.service_frequency, + visits_per_year: payload.visits_per_year ?? existing.visits_per_year, + contact_person: payload.contact_person ?? existing.contact_person, + contact_phone: payload.contact_phone ?? existing.contact_phone, + contact_email: payload.contact_email ?? existing.contact_email, + scope_of_work: payload.scope_of_work ?? existing.scope_of_work, + exclusions: payload.exclusions ?? existing.exclusions, + remarks: payload.remarks ?? existing.remarks, + is_active: true, + }; + + const data = await buildAmcData(renewPayload, assetId); + + const created = await prisma.$transaction(async (tx) => { + await tx.asset_amc_contracts.update({ + where: { id: BigInt(contractId) }, + data: { is_active: false, updated_by: userId ? BigInt(userId) : null }, + }); + await deactivateOtherActive(tx, 'asset_amc_contracts', assetId, contractId); + return tx.asset_amc_contracts.create({ + data: { + ...data, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + include: amcInclude, + }); + }); + + await auditLog({ + tableName: 'asset_amc_contracts', + recordId: contractId, + action: 'RENEW', + oldValue: sanitizeAmc(existing), + newValue: sanitizeAmc(created), + userId, + requestId, + }); + + return sanitizeAmc(created); +}; + +module.exports = { + listAmcContracts, + createAmcContract, + getAmcContract, + updateAmcContract, + renewAmcContract, +}; diff --git a/src/modules/assets/assets-insurance.service.js b/src/modules/assets/assets-insurance.service.js new file mode 100644 index 0000000..0e5b1e2 --- /dev/null +++ b/src/modules/assets/assets-insurance.service.js @@ -0,0 +1,195 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { toDateOnly, getAssetOrThrow, deactivateOtherActive } = require('./assets.helpers'); + +const sanitizeInsurance = (row) => (row ? { ...row } : null); + +const buildInsuranceData = async (payload, assetId) => ({ + asset_id: BigInt(assetId), + policy_no: payload.policy_no, + insurer_name: payload.insurer_name, + insurer_branch: payload.insurer_branch || null, + insurer_contact: payload.insurer_contact || null, + insurer_phone: payload.insurer_phone || null, + insurer_email: payload.insurer_email || null, + policy_type: payload.policy_type ?? 'FIRE_AND_ALLIED', + sum_insured: payload.sum_insured ?? 0, + annual_premium: payload.annual_premium ?? 0, + policy_start_date: toDateOnly(payload.policy_start_date), + policy_end_date: toDateOnly(payload.policy_end_date), + renewal_date: payload.renewal_date ? toDateOnly(payload.renewal_date) : null, + is_auto_renewal: payload.is_auto_renewal ?? false, + premium_paid: payload.premium_paid ?? false, + premium_paid_date: payload.premium_paid_date ? toDateOnly(payload.premium_paid_date) : null, + remarks: payload.remarks || null, + is_active: payload.is_active ?? true, +}); + +const getInsuranceOrThrow = async (assetId, policyId) => { + const row = await prisma.asset_insurance_policies.findFirst({ + where: { + id: BigInt(policyId), + asset_id: BigInt(assetId), + deleted_at: null, + }, + }); + if (!row) throw new ApiError(404, 'Insurance policy not found'); + return row; +}; + +const listInsurancePolicies = async (assetId) => { + await getAssetOrThrow(assetId); + const rows = await prisma.asset_insurance_policies.findMany({ + where: { asset_id: BigInt(assetId), deleted_at: null }, + orderBy: [{ is_active: 'desc' }, { policy_start_date: 'desc' }], + }); + return rows.map(sanitizeInsurance); +}; + +const createInsurancePolicy = async (assetId, payload, userId, requestId) => { + await getAssetOrThrow(assetId); + const data = await buildInsuranceData(payload, assetId); + + const created = await prisma.$transaction(async (tx) => { + if (data.is_active) { + await deactivateOtherActive(tx, 'asset_insurance_policies', assetId); + } + return tx.asset_insurance_policies.create({ + data: { + ...data, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + }); + + await auditLog({ + tableName: 'asset_insurance_policies', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeInsurance(created), + userId, + requestId, + }); + + return sanitizeInsurance(created); +}; + +const getInsurancePolicy = async (assetId, policyId) => + sanitizeInsurance(await getInsuranceOrThrow(assetId, policyId)); + +const updateInsurancePolicy = async (assetId, policyId, payload, userId, requestId) => { + const existing = await getInsuranceOrThrow(assetId, policyId); + const merged = { + policy_no: payload.policy_no ?? existing.policy_no, + insurer_name: payload.insurer_name ?? existing.insurer_name, + insurer_branch: + payload.insurer_branch !== undefined ? payload.insurer_branch : existing.insurer_branch, + insurer_contact: + payload.insurer_contact !== undefined ? payload.insurer_contact : existing.insurer_contact, + insurer_phone: + payload.insurer_phone !== undefined ? payload.insurer_phone : existing.insurer_phone, + insurer_email: + payload.insurer_email !== undefined ? payload.insurer_email : existing.insurer_email, + policy_type: payload.policy_type ?? existing.policy_type, + sum_insured: payload.sum_insured ?? Number(existing.sum_insured), + annual_premium: payload.annual_premium ?? Number(existing.annual_premium), + policy_start_date: payload.policy_start_date ?? existing.policy_start_date, + policy_end_date: payload.policy_end_date ?? existing.policy_end_date, + renewal_date: payload.renewal_date !== undefined ? payload.renewal_date : existing.renewal_date, + is_auto_renewal: payload.is_auto_renewal ?? existing.is_auto_renewal, + premium_paid: payload.premium_paid ?? existing.premium_paid, + premium_paid_date: + payload.premium_paid_date !== undefined + ? payload.premium_paid_date + : existing.premium_paid_date, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + is_active: payload.is_active !== undefined ? payload.is_active : existing.is_active, + }; + + const data = await buildInsuranceData(merged, assetId); + + const updated = await prisma.$transaction(async (tx) => { + if (data.is_active) { + await deactivateOtherActive(tx, 'asset_insurance_policies', assetId, policyId); + } + return tx.asset_insurance_policies.update({ + where: { id: BigInt(policyId) }, + data: { ...data, updated_by: userId ? BigInt(userId) : null }, + }); + }); + + await auditLog({ + tableName: 'asset_insurance_policies', + recordId: policyId, + action: 'UPDATE', + oldValue: sanitizeInsurance(existing), + newValue: sanitizeInsurance(updated), + userId, + requestId, + }); + + return sanitizeInsurance(updated); +}; + +const renewInsurancePolicy = async (assetId, policyId, payload, userId, requestId) => { + const existing = await getInsuranceOrThrow(assetId, policyId); + const renewPayload = { + policy_no: payload.policy_no ?? existing.policy_no, + insurer_name: payload.insurer_name ?? existing.insurer_name, + insurer_branch: payload.insurer_branch ?? existing.insurer_branch, + insurer_contact: payload.insurer_contact ?? existing.insurer_contact, + insurer_phone: payload.insurer_phone ?? existing.insurer_phone, + insurer_email: payload.insurer_email ?? existing.insurer_email, + policy_type: payload.policy_type ?? existing.policy_type, + sum_insured: payload.sum_insured ?? Number(existing.sum_insured), + annual_premium: payload.annual_premium ?? Number(existing.annual_premium), + policy_start_date: payload.policy_start_date, + policy_end_date: payload.policy_end_date, + renewal_date: payload.renewal_date ?? payload.policy_end_date, + is_auto_renewal: payload.is_auto_renewal ?? existing.is_auto_renewal, + premium_paid: payload.premium_paid ?? false, + premium_paid_date: payload.premium_paid_date ?? null, + remarks: payload.remarks ?? existing.remarks, + is_active: true, + }; + + const data = await buildInsuranceData(renewPayload, assetId); + + const created = await prisma.$transaction(async (tx) => { + await tx.asset_insurance_policies.update({ + where: { id: BigInt(policyId) }, + data: { is_active: false, updated_by: userId ? BigInt(userId) : null }, + }); + await deactivateOtherActive(tx, 'asset_insurance_policies', assetId, policyId); + return tx.asset_insurance_policies.create({ + data: { + ...data, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + }); + + await auditLog({ + tableName: 'asset_insurance_policies', + recordId: policyId, + action: 'RENEW', + oldValue: sanitizeInsurance(existing), + newValue: sanitizeInsurance(created), + userId, + requestId, + }); + + return sanitizeInsurance(created); +}; + +module.exports = { + listInsurancePolicies, + createInsurancePolicy, + getInsurancePolicy, + updateInsurancePolicy, + renewInsurancePolicy, +}; diff --git a/src/modules/assets/assets-service-visits.service.js b/src/modules/assets/assets-service-visits.service.js new file mode 100644 index 0000000..768e107 --- /dev/null +++ b/src/modules/assets/assets-service-visits.service.js @@ -0,0 +1,204 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { toDateOnly, assertReference, getAssetOrThrow } = require('./assets.helpers'); + +const visitInclude = { + vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, + asset_amc_contracts: { select: { id: true, contract_no: true, end_date: true } }, +}; + +const sanitizeVisit = (row) => { + if (!row) return null; + const { vendors, asset_amc_contracts, ...rest } = row; + return { + ...rest, + vendor: vendors || null, + amc_contract: asset_amc_contracts || null, + }; +}; + +const buildVisitData = async (payload, assetId) => { + if (payload.amc_contract_id) { + const amc = await prisma.asset_amc_contracts.findFirst({ + where: { + id: BigInt(payload.amc_contract_id), + asset_id: BigInt(assetId), + deleted_at: null, + }, + }); + if (!amc) throw new ApiError(422, 'Invalid amc_contract_id for this asset'); + } + if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id'); + + return { + asset_id: BigInt(assetId), + amc_contract_id: payload.amc_contract_id ? BigInt(payload.amc_contract_id) : null, + visit_type: payload.visit_type, + visit_date: toDateOnly(payload.visit_date), + visit_number: payload.visit_number ?? null, + complaint_no: payload.complaint_no || null, + complaint_date: payload.complaint_date ? toDateOnly(payload.complaint_date) : null, + complaint_desc: payload.complaint_desc || null, + engineer_name: payload.engineer_name || null, + engineer_phone: payload.engineer_phone || null, + vendor_id: payload.vendor_id ? BigInt(payload.vendor_id) : null, + work_done: payload.work_done || null, + parts_replaced: payload.parts_replaced || null, + next_service_date: payload.next_service_date ? toDateOnly(payload.next_service_date) : null, + status: payload.status ?? 'COMPLETED', + downtime_hours: payload.downtime_hours ?? 0, + service_cost: payload.service_cost ?? 0, + is_under_amc: payload.is_under_amc ?? false, + asset_condition_after: payload.asset_condition_after || null, + remarks: payload.remarks || null, + }; +}; + +const getVisitOrThrow = async (assetId, visitId) => { + const row = await prisma.asset_service_visits.findFirst({ + where: { + id: BigInt(visitId), + asset_id: BigInt(assetId), + deleted_at: null, + }, + include: visitInclude, + }); + if (!row) throw new ApiError(404, 'Service visit not found'); + return row; +}; + +const listServiceVisits = async (assetId, query = {}) => { + await getAssetOrThrow(assetId); + const rows = await prisma.asset_service_visits.findMany({ + where: { + asset_id: BigInt(assetId), + deleted_at: null, + ...(query.status ? { status: query.status } : {}), + ...(query.visit_type ? { visit_type: query.visit_type } : {}), + }, + include: visitInclude, + orderBy: [{ visit_date: 'desc' }, { created_at: 'desc' }], + }); + return rows.map(sanitizeVisit); +}; + +const createServiceVisit = async (assetId, payload, userId, requestId) => { + await getAssetOrThrow(assetId); + const data = await buildVisitData(payload, assetId); + + const created = await prisma.asset_service_visits.create({ + data: { + ...data, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + include: visitInclude, + }); + + await auditLog({ + tableName: 'asset_service_visits', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeVisit(created), + userId, + requestId, + }); + + return sanitizeVisit(created); +}; + +const getServiceVisit = async (assetId, visitId) => + sanitizeVisit(await getVisitOrThrow(assetId, visitId)); + +const updateServiceVisit = async (assetId, visitId, payload, userId, requestId) => { + const existing = await getVisitOrThrow(assetId, visitId); + const merged = { + amc_contract_id: + payload.amc_contract_id !== undefined ? payload.amc_contract_id : existing.amc_contract_id, + visit_type: payload.visit_type ?? existing.visit_type, + visit_date: payload.visit_date ?? existing.visit_date, + visit_number: payload.visit_number !== undefined ? payload.visit_number : existing.visit_number, + complaint_no: payload.complaint_no !== undefined ? payload.complaint_no : existing.complaint_no, + complaint_date: + payload.complaint_date !== undefined ? payload.complaint_date : existing.complaint_date, + complaint_desc: + payload.complaint_desc !== undefined ? payload.complaint_desc : existing.complaint_desc, + engineer_name: + payload.engineer_name !== undefined ? payload.engineer_name : existing.engineer_name, + engineer_phone: + payload.engineer_phone !== undefined ? payload.engineer_phone : existing.engineer_phone, + vendor_id: payload.vendor_id !== undefined ? payload.vendor_id : existing.vendor_id, + work_done: payload.work_done !== undefined ? payload.work_done : existing.work_done, + parts_replaced: + payload.parts_replaced !== undefined ? payload.parts_replaced : existing.parts_replaced, + next_service_date: + payload.next_service_date !== undefined + ? payload.next_service_date + : existing.next_service_date, + status: payload.status ?? existing.status, + downtime_hours: payload.downtime_hours ?? Number(existing.downtime_hours ?? 0), + service_cost: payload.service_cost ?? Number(existing.service_cost ?? 0), + is_under_amc: payload.is_under_amc ?? existing.is_under_amc, + asset_condition_after: + payload.asset_condition_after !== undefined + ? payload.asset_condition_after + : existing.asset_condition_after, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + }; + + const data = await buildVisitData(merged, assetId); + + const updated = await prisma.asset_service_visits.update({ + where: { id: BigInt(visitId) }, + data: { ...data, updated_by: userId ? BigInt(userId) : null }, + include: visitInclude, + }); + + await auditLog({ + tableName: 'asset_service_visits', + recordId: visitId, + action: 'UPDATE', + oldValue: sanitizeVisit(existing), + newValue: sanitizeVisit(updated), + userId, + requestId, + }); + + return sanitizeVisit(updated); +}; + +const updateServiceVisitStatus = async (assetId, visitId, payload, userId, requestId) => { + const existing = await getVisitOrThrow(assetId, visitId); + + const updated = await prisma.asset_service_visits.update({ + where: { id: BigInt(visitId) }, + data: { + status: payload.status, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + updated_by: userId ? BigInt(userId) : null, + }, + include: visitInclude, + }); + + await auditLog({ + tableName: 'asset_service_visits', + recordId: visitId, + action: 'STATUS_CHANGE', + oldValue: sanitizeVisit(existing), + newValue: sanitizeVisit(updated), + userId, + requestId, + }); + + return sanitizeVisit(updated); +}; + +module.exports = { + listServiceVisits, + createServiceVisit, + getServiceVisit, + updateServiceVisit, + updateServiceVisitStatus, +}; diff --git a/src/modules/assets/assets.constants.js b/src/modules/assets/assets.constants.js index b7416d0..00dabeb 100644 --- a/src/modules/assets/assets.constants.js +++ b/src/modules/assets/assets.constants.js @@ -3,9 +3,47 @@ const ASSET_STATUSES = ['IN_USE', 'IDLE', 'UNDER_MAINTENANCE', 'DISPOSED', 'SCRA const DEPRECIATION_METHODS = ['SLM', 'WDV']; const DISPOSAL_STATUSES = ['DISPOSED', 'SCRAPPED']; +const AMC_CONTRACT_TYPES = ['COMPREHENSIVE', 'LABOUR_ONLY', 'PARTS_ONLY', 'PREVENTIVE_ONLY']; +const PAYMENT_FREQUENCIES = ['MONTHLY', 'QUARTERLY', 'HALF_YEARLY', 'ANNUAL']; +const SERVICE_FREQUENCIES = ['MONTHLY', 'QUARTERLY', 'HALF_YEARLY', 'ANNUAL']; + +const VISIT_TYPES = [ + 'PREVENTIVE', + 'BREAKDOWN', + 'INSPECTION', + 'INSTALLATION', + 'CALIBRATION', + 'OTHER', +]; +const VISIT_STATUSES = ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'PENDING_PARTS']; +const VISIT_CONDITION_AFTER = ['GOOD', 'FAIR', 'POOR', 'NEEDS_REPLACEMENT']; + +const INSURANCE_POLICY_TYPES = [ + 'FIRE_AND_ALLIED', + 'MACHINERY_BREAKDOWN', + 'COMPREHENSIVE', + 'THIRD_PARTY', + 'VEHICLE', + 'OTHER', +]; + +const ALERT_TYPES = ['AMC', 'INSURANCE', 'WARRANTY']; +const ALERT_LEVELS = ['EXPIRED', 'CRITICAL', 'WARNING', 'INFO']; +const SERVICE_ALERT_STATUSES = ['OVERDUE', 'DUE_THIS_WEEK', 'DUE_THIS_MONTH', 'UPCOMING']; + module.exports = { ASSET_CONDITIONS, ASSET_STATUSES, DEPRECIATION_METHODS, DISPOSAL_STATUSES, + AMC_CONTRACT_TYPES, + PAYMENT_FREQUENCIES, + SERVICE_FREQUENCIES, + VISIT_TYPES, + VISIT_STATUSES, + VISIT_CONDITION_AFTER, + INSURANCE_POLICY_TYPES, + ALERT_TYPES, + ALERT_LEVELS, + SERVICE_ALERT_STATUSES, }; diff --git a/src/modules/assets/assets.controller.js b/src/modules/assets/assets.controller.js index e654bcc..7335a5a 100644 --- a/src/modules/assets/assets.controller.js +++ b/src/modules/assets/assets.controller.js @@ -1,6 +1,10 @@ const asyncHandler = require('../../utils/asyncHandler'); const ApiResponse = require('../../utils/ApiResponse'); const service = require('./assets.service'); +const amcService = require('./assets-amc.service'); +const visitsService = require('./assets-service-visits.service'); +const insuranceService = require('./assets-insurance.service'); +const alertsService = require('./assets-alerts.service'); const create = asyncHandler(async (req, res) => { const data = await service.createAsset(req.body, req.user?.id, req.id); @@ -37,4 +41,160 @@ const transferHistory = asyncHandler(async (req, res) => { res.json(new ApiResponse(200, data, 'Asset transfer history fetched')); }); -module.exports = { create, list, getOne, update, remove, transfer, transferHistory }; +const listAmc = asyncHandler(async (req, res) => { + const data = await amcService.listAmcContracts(req.params.id); + res.json(new ApiResponse(200, data, 'AMC contracts fetched')); +}); + +const createAmc = asyncHandler(async (req, res) => { + const data = await amcService.createAmcContract(req.params.id, req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'AMC contract created successfully')); +}); + +const getAmc = asyncHandler(async (req, res) => { + const data = await amcService.getAmcContract(req.params.id, req.params.contractId); + res.json(new ApiResponse(200, data, 'AMC contract fetched')); +}); + +const updateAmc = asyncHandler(async (req, res) => { + const data = await amcService.updateAmcContract( + req.params.id, + req.params.contractId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'AMC contract updated successfully')); +}); + +const renewAmc = asyncHandler(async (req, res) => { + const data = await amcService.renewAmcContract( + req.params.id, + req.params.contractId, + req.body, + req.user?.id, + req.id + ); + res.status(201).json(new ApiResponse(201, data, 'AMC contract renewed successfully')); +}); + +const listVisits = asyncHandler(async (req, res) => { + const data = await visitsService.listServiceVisits(req.params.id, req.query); + res.json(new ApiResponse(200, data, 'Service visits fetched')); +}); + +const createVisit = asyncHandler(async (req, res) => { + const data = await visitsService.createServiceVisit( + req.params.id, + req.body, + req.user?.id, + req.id + ); + res.status(201).json(new ApiResponse(201, data, 'Service visit logged successfully')); +}); + +const getVisit = asyncHandler(async (req, res) => { + const data = await visitsService.getServiceVisit(req.params.id, req.params.visitId); + res.json(new ApiResponse(200, data, 'Service visit fetched')); +}); + +const updateVisit = asyncHandler(async (req, res) => { + const data = await visitsService.updateServiceVisit( + req.params.id, + req.params.visitId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'Service visit updated successfully')); +}); + +const updateVisitStatus = asyncHandler(async (req, res) => { + const data = await visitsService.updateServiceVisitStatus( + req.params.id, + req.params.visitId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'Service visit status updated')); +}); + +const listInsurance = asyncHandler(async (req, res) => { + const data = await insuranceService.listInsurancePolicies(req.params.id); + res.json(new ApiResponse(200, data, 'Insurance policies fetched')); +}); + +const createInsurance = asyncHandler(async (req, res) => { + const data = await insuranceService.createInsurancePolicy( + req.params.id, + req.body, + req.user?.id, + req.id + ); + res.status(201).json(new ApiResponse(201, data, 'Insurance policy created successfully')); +}); + +const getInsurance = asyncHandler(async (req, res) => { + const data = await insuranceService.getInsurancePolicy(req.params.id, req.params.policyId); + res.json(new ApiResponse(200, data, 'Insurance policy fetched')); +}); + +const updateInsurance = asyncHandler(async (req, res) => { + const data = await insuranceService.updateInsurancePolicy( + req.params.id, + req.params.policyId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'Insurance policy updated successfully')); +}); + +const renewInsurance = asyncHandler(async (req, res) => { + const data = await insuranceService.renewInsurancePolicy( + req.params.id, + req.params.policyId, + req.body, + req.user?.id, + req.id + ); + res.status(201).json(new ApiResponse(201, data, 'Insurance policy renewed successfully')); +}); + +const expiryAlerts = asyncHandler(async (req, res) => { + const result = await alertsService.getExpiryAlerts(req.query); + res.json(new ApiResponse(200, result.data, 'Expiry alerts fetched', result.meta)); +}); + +const serviceAlerts = asyncHandler(async (req, res) => { + const result = await alertsService.getServiceAlerts(req.query); + res.json(new ApiResponse(200, result.data, 'Service alerts fetched', result.meta)); +}); + +module.exports = { + create, + list, + getOne, + update, + remove, + transfer, + transferHistory, + listAmc, + createAmc, + getAmc, + updateAmc, + renewAmc, + listVisits, + createVisit, + getVisit, + updateVisit, + updateVisitStatus, + listInsurance, + createInsurance, + getInsurance, + updateInsurance, + renewInsurance, + expiryAlerts, + serviceAlerts, +}; diff --git a/src/modules/assets/assets.helpers.js b/src/modules/assets/assets.helpers.js new file mode 100644 index 0000000..7ae8c86 --- /dev/null +++ b/src/modules/assets/assets.helpers.js @@ -0,0 +1,61 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); + +const SOFT_DELETE_TABLES = new Set([ + 'vendors', + 'plants', + 'warehouses', + 'departments', + 'users', + 'purchase_orders', + 'grn', + 'grn_items', + 'asset_categories', +]); + +const toDateOnly = (value) => { + if (!value) return null; + const date = new Date(value); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +}; + +const assertReference = async (table, id, label, { requireActive = true } = {}) => { + if (!id) return null; + const row = await prisma[table].findFirst({ + where: { + id: BigInt(id), + ...(SOFT_DELETE_TABLES.has(table) ? { deleted_at: null } : {}), + }, + }); + if (!row) throw new ApiError(422, `Invalid ${label}`); + if (requireActive && row.is_active === false) throw new ApiError(422, `${label} is inactive`); + return row; +}; + +const getAssetOrThrow = async (id) => { + const asset = await prisma.assets.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + }); + if (!asset) throw new ApiError(404, 'Asset not found'); + return asset; +}; + +const deactivateOtherActive = async (tx, table, assetId, excludeId = null) => { + await tx[table].updateMany({ + where: { + asset_id: BigInt(assetId), + is_active: true, + deleted_at: null, + ...(excludeId ? { id: { not: BigInt(excludeId) } } : {}), + }, + data: { is_active: false }, + }); +}; + +module.exports = { + toDateOnly, + assertReference, + getAssetOrThrow, + deactivateOtherActive, + SOFT_DELETE_TABLES, +}; diff --git a/src/modules/assets/assets.routes.js b/src/modules/assets/assets.routes.js index b16c170..73bc929 100644 --- a/src/modules/assets/assets.routes.js +++ b/src/modules/assets/assets.routes.js @@ -8,6 +8,18 @@ const { updateAssetSchema, listAssetsQuerySchema, transferAssetSchema, + amcContractSchema, + updateAmcContractSchema, + renewAmcContractSchema, + serviceVisitSchema, + updateServiceVisitSchema, + serviceVisitStatusSchema, + listServiceVisitsQuerySchema, + insurancePolicySchema, + updateInsurancePolicySchema, + renewInsurancePolicySchema, + expiryAlertsQuerySchema, + serviceAlertsQuerySchema, } = require('./assets.validation'); const router = express.Router(); @@ -22,6 +34,87 @@ router.get( ); router.post('/', authorize('ASSET', 'create'), validate(createAssetSchema), controller.create); +router.get( + '/alerts/expiry', + authorize('ASSET', 'view'), + validate(expiryAlertsQuerySchema, 'query'), + controller.expiryAlerts +); +router.get( + '/alerts/service', + authorize('ASSET', 'view'), + validate(serviceAlertsQuerySchema, 'query'), + controller.serviceAlerts +); + +router.get('/:id/amc', authorize('ASSET', 'view'), controller.listAmc); +router.post( + '/:id/amc', + authorize('ASSET', 'create'), + validate(amcContractSchema), + controller.createAmc +); +router.get('/:id/amc/:contractId', authorize('ASSET', 'view'), controller.getAmc); +router.put( + '/:id/amc/:contractId', + authorize('ASSET', 'edit'), + validate(updateAmcContractSchema), + controller.updateAmc +); +router.patch( + '/:id/amc/:contractId/renew', + authorize('ASSET', 'edit'), + validate(renewAmcContractSchema), + controller.renewAmc +); + +router.get( + '/:id/service-visits', + authorize('ASSET', 'view'), + validate(listServiceVisitsQuerySchema, 'query'), + controller.listVisits +); +router.post( + '/:id/service-visits', + authorize('ASSET', 'create'), + validate(serviceVisitSchema), + controller.createVisit +); +router.get('/:id/service-visits/:visitId', authorize('ASSET', 'view'), controller.getVisit); +router.put( + '/:id/service-visits/:visitId', + authorize('ASSET', 'edit'), + validate(updateServiceVisitSchema), + controller.updateVisit +); +router.patch( + '/:id/service-visits/:visitId/status', + authorize('ASSET', 'edit'), + validate(serviceVisitStatusSchema), + controller.updateVisitStatus +); + +router.get('/:id/insurance', authorize('ASSET', 'view'), controller.listInsurance); +router.post( + '/:id/insurance', + authorize('ASSET', 'create'), + validate(insurancePolicySchema), + controller.createInsurance +); +router.get('/:id/insurance/:policyId', authorize('ASSET', 'view'), controller.getInsurance); +router.put( + '/:id/insurance/:policyId', + authorize('ASSET', 'edit'), + validate(updateInsurancePolicySchema), + controller.updateInsurance +); +router.patch( + '/:id/insurance/:policyId/renew', + authorize('ASSET', 'edit'), + validate(renewInsurancePolicySchema), + controller.renewInsurance +); + router.get('/:id/transfer-history', authorize('ASSET', 'view'), controller.transferHistory); router.post( '/:id/transfer', diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index 8fb9b38..d0ed14b 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -34,9 +34,6 @@ const assetInclude = { const assetDetailInclude = { ...assetInclude, - vendors_assets_amc_vendor_idTovendors: { - select: { id: true, vendor_code: true, vendor_name: true }, - }, users_assets_updated_byTousers: { select: { id: true, full_name: true } }, }; @@ -73,7 +70,6 @@ const sanitizeAsset = (asset) => { warehouses, users_assets_assigned_to_user_idTousers, vendors_assets_vendor_idTovendors, - vendors_assets_amc_vendor_idTovendors, purchase_orders, grn, users_assets_created_byTousers, @@ -89,7 +85,6 @@ const sanitizeAsset = (asset) => { warehouse: warehouses || null, assigned_to_user: users_assets_assigned_to_user_idTousers || null, vendor: vendors_assets_vendor_idTovendors || null, - amc_vendor: vendors_assets_amc_vendor_idTovendors || null, purchase_order: purchase_orders || null, grn: grn || null, created_by_user: users_assets_created_byTousers || null, @@ -100,7 +95,6 @@ const sanitizeAsset = (asset) => { warehouses: undefined, users_assets_assigned_to_user_idTousers: undefined, vendors_assets_vendor_idTovendors: undefined, - vendors_assets_amc_vendor_idTovendors: undefined, purchase_orders: undefined, users_assets_created_byTousers: undefined, users_assets_updated_byTousers: undefined, @@ -169,8 +163,6 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { if (payload.assigned_to_user_id) await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id'); if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id'); - if (payload.amc_vendor_id) - await assertReference('vendors', payload.amc_vendor_id, 'amc_vendor_id'); if (payload.po_id) await assertReference('purchase_orders', payload.po_id, 'po_id'); if (payload.grn_id) await assertReference('grn', payload.grn_id, 'grn_id'); if (payload.grn_item_id) @@ -210,13 +202,6 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { warranty_expiry_date: payload.warranty_expiry_date ? toDateOnly(payload.warranty_expiry_date) : null, - amc_start_date: payload.amc_start_date ? toDateOnly(payload.amc_start_date) : null, - amc_end_date: payload.amc_end_date ? toDateOnly(payload.amc_end_date) : null, - amc_vendor_id: payload.amc_vendor_id ? BigInt(payload.amc_vendor_id) : null, - insurance_policy_no: payload.insurance_policy_no || null, - insurance_expiry_date: payload.insurance_expiry_date - ? toDateOnly(payload.insurance_expiry_date) - : null, condition: payload.condition ?? (isCreate ? 'NEW' : undefined), status, qr_code_value: payload.qr_code_value || null, @@ -345,19 +330,6 @@ const updateAsset = async (id, payload, userId, requestId) => { payload.warranty_expiry_date !== undefined ? payload.warranty_expiry_date : existing.warranty_expiry_date, - amc_start_date: - payload.amc_start_date !== undefined ? payload.amc_start_date : existing.amc_start_date, - amc_end_date: payload.amc_end_date !== undefined ? payload.amc_end_date : existing.amc_end_date, - amc_vendor_id: - payload.amc_vendor_id !== undefined ? payload.amc_vendor_id : existing.amc_vendor_id, - insurance_policy_no: - payload.insurance_policy_no !== undefined - ? payload.insurance_policy_no - : existing.insurance_policy_no, - insurance_expiry_date: - payload.insurance_expiry_date !== undefined - ? payload.insurance_expiry_date - : existing.insurance_expiry_date, condition: payload.condition ?? existing.condition, status: payload.status ?? existing.status, qr_code_value: diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index 5fb0858..358c661 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -1,5 +1,18 @@ const Joi = require('joi'); -const { ASSET_CONDITIONS, ASSET_STATUSES, DEPRECIATION_METHODS } = require('./assets.constants'); +const { + ASSET_CONDITIONS, + ASSET_STATUSES, + DEPRECIATION_METHODS, + AMC_CONTRACT_TYPES, + PAYMENT_FREQUENCIES, + SERVICE_FREQUENCIES, + VISIT_TYPES, + VISIT_STATUSES, + VISIT_CONDITION_AFTER, + INSURANCE_POLICY_TYPES, + ALERT_TYPES, + SERVICE_ALERT_STATUSES, +} = require('./assets.constants'); const assetFields = { asset_name: Joi.string().max(200).required(), @@ -26,11 +39,6 @@ const assetFields = { .optional(), salvage_value: Joi.number().min(0).allow(null).optional(), warranty_expiry_date: Joi.date().iso().allow(null).optional(), - amc_start_date: Joi.date().iso().allow(null).optional(), - amc_end_date: Joi.date().iso().allow(null).optional(), - amc_vendor_id: Joi.number().integer().positive().allow(null).optional(), - insurance_policy_no: Joi.string().max(100).allow(null, '').optional(), - insurance_expiry_date: Joi.date().iso().allow(null).optional(), condition: Joi.string() .valid(...ASSET_CONDITIONS) .default('NEW'), @@ -69,11 +77,6 @@ const updateAssetSchema = Joi.object({ depreciation_method: assetFields.depreciation_method, salvage_value: assetFields.salvage_value, warranty_expiry_date: assetFields.warranty_expiry_date, - amc_start_date: assetFields.amc_start_date, - amc_end_date: assetFields.amc_end_date, - amc_vendor_id: assetFields.amc_vendor_id, - insurance_policy_no: assetFields.insurance_policy_no, - insurance_expiry_date: assetFields.insurance_expiry_date, condition: assetFields.condition.optional(), status: assetFields.status.optional(), qr_code_value: assetFields.qr_code_value, @@ -109,9 +112,193 @@ const transferAssetSchema = Joi.object({ reason: Joi.string().allow(null, '').optional(), }).or('to_plant_id', 'to_department_id', 'to_user_id', 'to_warehouse_id'); +const amcContractSchema = Joi.object({ + vendor_id: Joi.number().integer().positive().required(), + contract_no: Joi.string().max(100).allow(null, '').optional(), + contract_type: Joi.string() + .valid(...AMC_CONTRACT_TYPES) + .default('COMPREHENSIVE'), + start_date: Joi.date().iso().required(), + end_date: Joi.date().iso().required(), + renewal_date: Joi.date().iso().allow(null).optional(), + annual_cost: Joi.number().min(0).default(0), + payment_frequency: Joi.string() + .valid(...PAYMENT_FREQUENCIES) + .default('ANNUAL'), + service_frequency: Joi.string() + .valid(...SERVICE_FREQUENCIES) + .allow(null) + .optional(), + visits_per_year: Joi.number().integer().min(0).allow(null).optional(), + contact_person: Joi.string().max(150).allow(null, '').optional(), + contact_phone: Joi.string().max(15).allow(null, '').optional(), + contact_email: Joi.string().email().max(150).allow(null, '').optional(), + scope_of_work: Joi.string().allow(null, '').optional(), + exclusions: Joi.string().allow(null, '').optional(), + remarks: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().default(true), +}); + +const updateAmcContractSchema = amcContractSchema.fork( + ['vendor_id', 'start_date', 'end_date'], + (schema) => schema.optional() +); + +const renewAmcContractSchema = Joi.object({ + start_date: Joi.date().iso().required(), + end_date: Joi.date().iso().required(), + vendor_id: Joi.number().integer().positive().optional(), + contract_no: Joi.string().max(100).allow(null, '').optional(), + contract_type: Joi.string() + .valid(...AMC_CONTRACT_TYPES) + .optional(), + renewal_date: Joi.date().iso().allow(null).optional(), + annual_cost: Joi.number().min(0).optional(), + payment_frequency: Joi.string() + .valid(...PAYMENT_FREQUENCIES) + .optional(), + service_frequency: Joi.string() + .valid(...SERVICE_FREQUENCIES) + .allow(null) + .optional(), + visits_per_year: Joi.number().integer().min(0).allow(null).optional(), + contact_person: Joi.string().max(150).allow(null, '').optional(), + contact_phone: Joi.string().max(15).allow(null, '').optional(), + contact_email: Joi.string().email().max(150).allow(null, '').optional(), + scope_of_work: Joi.string().allow(null, '').optional(), + exclusions: Joi.string().allow(null, '').optional(), + remarks: Joi.string().allow(null, '').optional(), +}); + +const serviceVisitSchema = Joi.object({ + visit_type: Joi.string() + .valid(...VISIT_TYPES) + .required(), + visit_date: Joi.date().iso().required(), + amc_contract_id: Joi.number().integer().positive().allow(null).optional(), + visit_number: Joi.number().integer().min(1).allow(null).optional(), + complaint_no: Joi.string().max(50).allow(null, '').optional(), + complaint_date: Joi.date().iso().allow(null).optional(), + complaint_desc: Joi.string().allow(null, '').optional(), + engineer_name: Joi.string().max(150).allow(null, '').optional(), + engineer_phone: Joi.string().max(15).allow(null, '').optional(), + vendor_id: Joi.number().integer().positive().allow(null).optional(), + work_done: Joi.string().allow(null, '').optional(), + parts_replaced: Joi.string().allow(null, '').optional(), + next_service_date: Joi.date().iso().allow(null).optional(), + status: Joi.string() + .valid(...VISIT_STATUSES) + .default('COMPLETED'), + downtime_hours: Joi.number().min(0).default(0), + service_cost: Joi.number().min(0).default(0), + is_under_amc: Joi.boolean().default(false), + asset_condition_after: Joi.string() + .valid(...VISIT_CONDITION_AFTER) + .allow(null) + .optional(), + remarks: Joi.string().allow(null, '').optional(), +}); + +const updateServiceVisitSchema = serviceVisitSchema.fork(['visit_type', 'visit_date'], (s) => + s.optional() +); + +const serviceVisitStatusSchema = Joi.object({ + status: Joi.string() + .valid(...VISIT_STATUSES) + .required(), + remarks: Joi.string().allow(null, '').optional(), +}); + +const listServiceVisitsQuerySchema = Joi.object({ + status: Joi.string() + .valid(...VISIT_STATUSES) + .optional(), + visit_type: Joi.string() + .valid(...VISIT_TYPES) + .optional(), +}); + +const insurancePolicySchema = Joi.object({ + policy_no: Joi.string().max(100).required(), + insurer_name: Joi.string().max(200).required(), + insurer_branch: Joi.string().max(150).allow(null, '').optional(), + insurer_contact: Joi.string().max(150).allow(null, '').optional(), + insurer_phone: Joi.string().max(15).allow(null, '').optional(), + insurer_email: Joi.string().email().max(150).allow(null, '').optional(), + policy_type: Joi.string() + .valid(...INSURANCE_POLICY_TYPES) + .default('FIRE_AND_ALLIED'), + sum_insured: Joi.number().min(0).default(0), + annual_premium: Joi.number().min(0).default(0), + policy_start_date: Joi.date().iso().required(), + policy_end_date: Joi.date().iso().required(), + renewal_date: Joi.date().iso().allow(null).optional(), + is_auto_renewal: Joi.boolean().default(false), + premium_paid: Joi.boolean().default(false), + premium_paid_date: Joi.date().iso().allow(null).optional(), + remarks: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().default(true), +}); + +const updateInsurancePolicySchema = insurancePolicySchema.fork( + ['policy_no', 'insurer_name', 'policy_start_date', 'policy_end_date'], + (s) => s.optional() +); + +const renewInsurancePolicySchema = Joi.object({ + policy_start_date: Joi.date().iso().required(), + policy_end_date: Joi.date().iso().required(), + policy_no: Joi.string().max(100).optional(), + insurer_name: Joi.string().max(200).optional(), + insurer_branch: Joi.string().max(150).allow(null, '').optional(), + insurer_contact: Joi.string().max(150).allow(null, '').optional(), + insurer_phone: Joi.string().max(15).allow(null, '').optional(), + insurer_email: Joi.string().email().max(150).allow(null, '').optional(), + policy_type: Joi.string() + .valid(...INSURANCE_POLICY_TYPES) + .optional(), + sum_insured: Joi.number().min(0).optional(), + annual_premium: Joi.number().min(0).optional(), + renewal_date: Joi.date().iso().allow(null).optional(), + is_auto_renewal: Joi.boolean().optional(), + premium_paid: Joi.boolean().optional(), + premium_paid_date: Joi.date().iso().allow(null).optional(), + remarks: Joi.string().allow(null, '').optional(), +}); + +const expiryAlertsQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + days: Joi.number().integer().min(1).max(365).default(90), + type: Joi.string() + .valid(...ALERT_TYPES) + .optional(), +}); + +const serviceAlertsQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + status: Joi.string() + .valid(...SERVICE_ALERT_STATUSES) + .optional(), +}); + module.exports = { createAssetSchema, updateAssetSchema, listAssetsQuerySchema, transferAssetSchema, + amcContractSchema, + updateAmcContractSchema, + renewAmcContractSchema, + serviceVisitSchema, + updateServiceVisitSchema, + serviceVisitStatusSchema, + listServiceVisitsQuerySchema, + insurancePolicySchema, + updateInsurancePolicySchema, + renewInsurancePolicySchema, + expiryAlertsQuerySchema, + serviceAlertsQuerySchema, }; diff --git a/src/modules/roles/roles.service.js b/src/modules/roles/roles.service.js index d82f5c1..99e9bef 100644 --- a/src/modules/roles/roles.service.js +++ b/src/modules/roles/roles.service.js @@ -32,34 +32,108 @@ const sanitizeRole = (role) => { }; }; +const validatePermissionIds = async (permissionIds) => { + const uniqueIds = [...new Set((permissionIds || []).map((pid) => BigInt(pid)))]; + if (uniqueIds.length === 0) return []; + + const permissions = await prisma.permissions.findMany({ + where: { id: { in: uniqueIds }, is_active: true }, + }); + + if (permissions.length !== uniqueIds.length) { + throw new ApiError(422, 'One or more permission_ids are invalid'); + } + + return uniqueIds; +}; + +const resolveViewPermissionIdsForModules = async (moduleCodes) => { + if (!moduleCodes?.length) return []; + + const uniqueCodes = [...new Set(moduleCodes.map((code) => code.trim().toUpperCase()))]; + const permissions = await prisma.permissions.findMany({ + where: { + action: 'view', + is_active: true, + modules: { code: { in: uniqueCodes }, is_active: true }, + }, + select: { id: true, modules: { select: { code: true } } }, + }); + + const foundCodes = new Set(permissions.map((p) => p.modules.code)); + const missing = uniqueCodes.filter((code) => !foundCodes.has(code)); + if (missing.length) { + throw new ApiError(422, `Invalid or inactive view_modules: ${missing.join(', ')}`); + } + + return permissions.map((p) => p.id); +}; + +const resolveInitialPermissionIds = async (payload) => { + const idsFromModules = payload.view_modules?.length + ? await resolveViewPermissionIdsForModules(payload.view_modules) + : []; + + const mergedIds = [...(payload.permission_ids || []), ...idsFromModules.map((id) => Number(id))]; + return validatePermissionIds(mergedIds); +}; + const createRole = async (payload, userId, requestId) => { const existing = await prisma.roles.findFirst({ where: { name: payload.name, deleted_at: null }, }); if (existing) throw new ApiError(409, 'Role name already exists'); - const created = await prisma.roles.create({ - data: { - name: payload.name.trim(), - description: payload.description ?? null, - is_active: payload.is_active ?? true, - created_by: userId ? BigInt(userId) : null, - updated_by: userId ? BigInt(userId) : null, - }, - include: rolePermissionsInclude, + const permissionIds = await resolveInitialPermissionIds(payload); + + const created = await prisma.$transaction(async (tx) => { + const role = await tx.roles.create({ + data: { + name: payload.name.trim(), + description: payload.description ?? null, + is_active: payload.is_active ?? true, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + if (permissionIds.length > 0) { + await tx.role_permissions.createMany({ + data: permissionIds.map((permission_id) => ({ role_id: role.id, permission_id })), + }); + } + + return tx.roles.findFirst({ + where: { id: role.id }, + include: rolePermissionsInclude, + }); }); + const sanitized = sanitizeRole(created); + await auditLog({ tableName: 'roles', recordId: created.id, action: 'CREATE', oldValue: null, - newValue: sanitizeRole(created), + newValue: sanitized, userId, requestId, }); - return sanitizeRole(created); + if (permissionIds.length > 0) { + await auditLog({ + tableName: 'role_permissions', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: permissionIds.map((pid) => pid.toString()), + userId, + requestId, + }); + } + + return sanitized; }; const listRoles = async (query) => { @@ -207,18 +281,7 @@ const assignPermissions = async (id, permissionIds, userId, requestId) => { const role = await prisma.roles.findFirst({ where: { id: BigInt(id), deleted_at: null } }); if (!role) throw new ApiError(404, 'Role not found'); - const uniqueIds = [...new Set(permissionIds.map((pid) => BigInt(pid)))]; - - if (uniqueIds.length > 0) { - const permissions = await prisma.permissions.findMany({ - where: { id: { in: uniqueIds }, is_active: true }, - include: { modules: { select: { code: true } } }, - }); - - if (permissions.length !== uniqueIds.length) { - throw new ApiError(422, 'One or more permission_ids are invalid'); - } - } + const uniqueIds = await validatePermissionIds(permissionIds); const before = await prisma.role_permissions.findMany({ where: { role_id: BigInt(id) }, diff --git a/src/modules/roles/roles.validation.js b/src/modules/roles/roles.validation.js index 1e8588b..2db0676 100644 --- a/src/modules/roles/roles.validation.js +++ b/src/modules/roles/roles.validation.js @@ -4,6 +4,8 @@ const createRoleSchema = Joi.object({ name: Joi.string().max(100).required(), description: Joi.string().allow(null, '').optional(), is_active: Joi.boolean().default(true), + permission_ids: Joi.array().items(Joi.number().integer().positive()).optional(), + view_modules: Joi.array().items(Joi.string().max(50)).optional(), }); const updateRoleSchema = Joi.object({ diff --git a/src/modules/users/users.service.js b/src/modules/users/users.service.js index 67fa56c..9e8571d 100644 --- a/src/modules/users/users.service.js +++ b/src/modules/users/users.service.js @@ -4,7 +4,7 @@ const env = require('../../config/env'); const ApiError = require('../../utils/ApiError'); const auditLog = require('../../utils/auditLog'); const { getPagination } = require('../../utils/pagination'); -const { encrypt, decrypt } = require('../../utils/encryption'); +const { encrypt, decrypt, blindIndex } = require('../../utils/encryption'); const userInclude = { roles_users_role_idToroles: { select: { id: true, name: true } }, @@ -25,6 +25,7 @@ const sanitizeUser = (user) => { ...rest } = user; delete rest.password_hash; + delete rest.mobile_index; return { ...rest, @@ -125,7 +126,13 @@ const buildUserData = async (payload, { hashPassword = false } = {}) => { } if (Object.prototype.hasOwnProperty.call(data, 'mobile')) { - data.mobile = data.mobile ? encrypt(data.mobile) : null; + if (data.mobile) { + data.mobile_index = blindIndex(data.mobile); + data.mobile = encrypt(data.mobile); + } else { + data.mobile = null; + data.mobile_index = null; + } } for (const key of ['role_id', 'department_id', 'designation_id', 'plant_id', 'reporting_to']) {