From 14f83e1f12f7cdc95da850b943c09b0b3d63d87d Mon Sep 17 00:00:00 2001 From: portable Date: Mon, 26 Jan 2026 23:48:27 +0100 Subject: [PATCH] feat(transfers): add transfer management features including approval rules, scheduled transfers, and locking mechanism - Implemented QueryTransfersDto for querying transfers with filters. - Created RejectTransferDto for handling transfer rejection reasons. - Added UpdateApprovalRuleDto and UpdateTransferDto for updating existing records. - Developed TransferApprovalRule entity to manage approval conditions. - Established Transfer entity with various relationships and status management. - Introduced ScheduledTransferProcessor for processing scheduled transfers. - Created ApprovalRuleService for managing approval rules and evaluations. - Implemented ScheduledTransferService for checking and processing scheduled transfers. - Developed TransferLockService for locking assets during transfers. - Enhanced TransfersService with methods for creating, approving, rejecting, and executing transfers. - Added unit tests for TransfersController and TransfersService. - Configured TransferModule with necessary imports and providers. --- backend/package-lock.json | 326 +++++++-- backend/package.json | 12 +- backend/src/app.module.ts | 16 +- .../controllers/transfers.controller.ts | 112 +++ .../src/transfers/dto/approve-transfer.dto.ts | 7 + .../transfers/dto/create-approval-rule.dto.ts | 38 + .../src/transfers/dto/create-transfer.dto.ts | 92 +++ .../src/transfers/dto/query-transfers.dto.ts | 30 + .../src/transfers/dto/reject-transfer.dto.ts | 8 + .../transfers/dto/update-approval-rule.dto.ts | 4 + .../src/transfers/dto/update-transfer.dto.ts | 4 + .../entities/transfer-approval-rule.entity.ts | 52 ++ .../src/transfers/entities/transfer.entity.ts | 112 +++ .../scheduled-transfer.processor.ts | 75 ++ .../services/approval-rule.service.ts | 206 ++++++ .../services/scheduled-transfer.service.ts | 59 ++ .../services/transfer-lock.service.ts | 51 ++ .../transfers/services/transfers.service.ts | 656 ++++++++++++++++++ .../transfers/transfers.controller.spec.ts | 20 + backend/src/transfers/transfers.module.ts | 61 ++ .../src/transfers/transfers.service.spec.ts | 18 + 21 files changed, 1875 insertions(+), 84 deletions(-) create mode 100644 backend/src/transfers/controllers/transfers.controller.ts create mode 100644 backend/src/transfers/dto/approve-transfer.dto.ts create mode 100644 backend/src/transfers/dto/create-approval-rule.dto.ts create mode 100644 backend/src/transfers/dto/create-transfer.dto.ts create mode 100644 backend/src/transfers/dto/query-transfers.dto.ts create mode 100644 backend/src/transfers/dto/reject-transfer.dto.ts create mode 100644 backend/src/transfers/dto/update-approval-rule.dto.ts create mode 100644 backend/src/transfers/dto/update-transfer.dto.ts create mode 100644 backend/src/transfers/entities/transfer-approval-rule.entity.ts create mode 100644 backend/src/transfers/entities/transfer.entity.ts create mode 100644 backend/src/transfers/processors/scheduled-transfer.processor.ts create mode 100644 backend/src/transfers/services/approval-rule.service.ts create mode 100644 backend/src/transfers/services/scheduled-transfer.service.ts create mode 100644 backend/src/transfers/services/transfer-lock.service.ts create mode 100644 backend/src/transfers/services/transfers.service.ts create mode 100644 backend/src/transfers/transfers.controller.spec.ts create mode 100644 backend/src/transfers/transfers.module.ts create mode 100644 backend/src/transfers/transfers.service.spec.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index aec9163..6882ee9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.975.0", "@nestjs/bull": "^11.0.4", + "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -19,7 +20,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", - "@nestjs/schedule": "^6.0.1", + "@nestjs/schedule": "^6.1.0", "@nestjs/swagger": "^7.3.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", @@ -28,9 +29,12 @@ "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", "axios": "^1.6.0", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "bull": "^4.16.5", "bwip-js": "^4.7.0", + "cache-manager": "^7.2.8", + "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "date-fns": "^4.1.0", @@ -60,15 +64,17 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^3.0.0", "@types/bull": "^3.15.9", + "@types/cache-manager": "^4.0.6", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.3.1", + "@types/node": "^20.19.30", "@types/node-cron": "^3.0.11", - "@types/nodemailer": "^7.0.5", + "@types/nodemailer": "^7.0.9", "@types/otplib": "^7.0.0", "@types/papaparse": "^5.3.16", "@types/passport-jwt": "^4.0.1", @@ -510,58 +516,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sesv2": { - "version": "3.975.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.975.0.tgz", - "integrity": "sha512-4R+hR6N2LbvTIf6Y2e9b9PQlVkAD5WmSRMAGslul5L/jCE0LzOYC+4RQ7u5EOv0mERozcYleLPK2Zc0jTn4gTg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.1", - "@aws-sdk/credential-provider-node": "^3.972.1", - "@aws-sdk/middleware-host-header": "^3.972.1", - "@aws-sdk/middleware-logger": "^3.972.1", - "@aws-sdk/middleware-recursion-detection": "^3.972.1", - "@aws-sdk/middleware-user-agent": "^3.972.2", - "@aws-sdk/region-config-resolver": "^3.972.1", - "@aws-sdk/signature-v4-multi-region": "3.972.0", - "@aws-sdk/types": "^3.973.0", - "@aws-sdk/util-endpoints": "3.972.0", - "@aws-sdk/util-user-agent-browser": "^3.972.1", - "@aws-sdk/util-user-agent-node": "^3.972.1", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.21.1", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.11", - "@smithy/middleware-retry": "^4.4.27", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.10.12", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.26", - "@smithy/util-defaults-mode-node": "^4.2.29", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/client-sso": { "version": "3.974.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.974.0.tgz", @@ -1781,6 +1735,16 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cacheable/utils": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.3.tgz", + "integrity": "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==", + "license": "MIT", + "dependencies": { + "hashery": "^1.3.0", + "keyv": "^5.5.5" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2602,6 +2566,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@ljharb/through": { "version": "2.3.14", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", @@ -2734,6 +2704,19 @@ "@nestjs/core": "^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.0.tgz", + "integrity": "sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", + "cache-manager": ">=6", + "keyv": ">=5", + "rxjs": "^7.8.1" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -3019,12 +3002,12 @@ } }, "node_modules/@nestjs/schedule": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz", - "integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.0.tgz", + "integrity": "sha512-W25Ydc933Gzb1/oo7+bWzzDiOissE+h/dhIAPugA39b9MuIzBbLybuXpc1AjoQLczO3v0ldmxaffVl87W0uqoQ==", "license": "MIT", "dependencies": { - "cron": "4.3.3" + "cron": "4.3.5" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", @@ -4302,6 +4285,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bcryptjs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", @@ -4333,6 +4326,13 @@ "@types/redis": "^2.8.0" } }, + "node_modules/@types/cache-manager": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.6.tgz", + "integrity": "sha512-8qL93MF05/xrzFm/LSPtzNEOE1eQF3VwGHAcQEylgp5hDSTe41jtFwbSYAPfyYcVa28y1vYSjIt0c1fLLUiC/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4520,9 +4520,10 @@ } }, "node_modules/@types/node": { - "version": "20.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", - "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } @@ -4535,13 +4536,12 @@ "license": "MIT" }, "node_modules/@types/nodemailer": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.5.tgz", - "integrity": "sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", "dev": true, "license": "MIT", "dependencies": { - "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, @@ -5680,6 +5680,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -5968,6 +5982,110 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", + "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.3.3", + "keyv": "^5.5.5" + } + }, + "node_modules/cache-manager-redis-store": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-3.0.1.tgz", + "integrity": "sha512-o560kw+dFqusC9lQJhcm6L2F2fMKobJ5af+FoR2PdnMVdpQ3f3Bz6qzvObTGyvoazQJxjQNWgMQeChP4vRTuXQ==", + "license": "MIT", + "dependencies": { + "redis": "^4.3.1" + }, + "engines": { + "node": ">= 16.18.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/cache-manager-redis-store/node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/cache-manager-redis-store/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6512,9 +6630,9 @@ "devOptional": true }, "node_modules/cron": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz", - "integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.5.tgz", + "integrity": "sha512-hKPP7fq1+OfyCqoePkKfVq7tNAdFwiQORr4lZUHwrf0tebC65fYEeWgOrXOL6prn1/fegGOdTfrM6e34PJfksg==", "license": "MIT", "dependencies": { "@types/luxon": "~3.7.0", @@ -7695,6 +7813,16 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -7999,6 +8127,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8240,6 +8377,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", + "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.14.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8251,6 +8400,12 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", + "integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9407,7 +9562,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -9582,12 +9738,12 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/kleur": { @@ -10220,6 +10376,15 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "dev": true }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -10257,6 +10422,17 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", diff --git a/backend/package.json b/backend/package.json index a49e61c..157e35c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.975.0", "@nestjs/bull": "^11.0.4", + "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -30,7 +31,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", - "@nestjs/schedule": "^6.0.1", + "@nestjs/schedule": "^6.1.0", "@nestjs/swagger": "^7.3.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", @@ -39,9 +40,12 @@ "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", "axios": "^1.6.0", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "bull": "^4.16.5", "bwip-js": "^4.7.0", + "cache-manager": "^7.2.8", + "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "date-fns": "^4.1.0", @@ -71,15 +75,17 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^3.0.0", "@types/bull": "^3.15.9", + "@types/cache-manager": "^4.0.6", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.3.1", + "@types/node": "^20.19.30", "@types/node-cron": "^3.0.11", - "@types/nodemailer": "^7.0.5", + "@types/nodemailer": "^7.0.9", "@types/otplib": "^7.0.0", "@types/papaparse": "^5.3.16", "@types/passport-jwt": "^4.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 73d0e0f..1baf5a9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -34,6 +34,7 @@ import { ReportExecution } from './reports/entities/report-execution.entity'; // import { DocumentVersion } from './documents/entities/document-version.entity'; // import { DocumentAccessPermission } from './documents/entities/document-access-permission.entity'; // import { DocumentAuditLog } from './documents/entities/document-audit-log.entity'; +import { TransfersModule } from './transfers/transfers.module'; @Module({ imports: [ @@ -41,10 +42,12 @@ import { ReportExecution } from './reports/entities/report-execution.entity'; isGlobal: true, }), ScheduleModule.forRoot(), - ThrottlerModule.forRoot([{ - ttl: 60000, - limit: 10, - }]), + ThrottlerModule.forRoot([ + { + ttl: 60000, + limit: 10, + }, + ]), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -81,7 +84,8 @@ import { ReportExecution } from './reports/entities/report-execution.entity'; AuditLogsModule, AssetsModule, AnalyticsModule, - ReportsModule, // Add the Reports Module + ReportsModule, + TransfersModule, // Add the Reports Module ], controllers: [AppController], providers: [ @@ -92,4 +96,4 @@ import { ReportExecution } from './reports/entities/report-execution.entity'; AppService, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/transfers/controllers/transfers.controller.ts b/backend/src/transfers/controllers/transfers.controller.ts new file mode 100644 index 0000000..5a7ef5a --- /dev/null +++ b/backend/src/transfers/controllers/transfers.controller.ts @@ -0,0 +1,112 @@ +// src/transfers/controllers/transfer.controller.ts +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Request, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApprovalRuleService } from '../services/approval-rule.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { ApproveTransferDto } from '../dto/approve-transfer.dto'; +import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { CreateTransferDto } from '../dto/create-transfer.dto'; +import { QueryTransfersDto } from '../dto/query-transfers.dto'; +import { RejectTransferDto } from '../dto/reject-transfer.dto'; +import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; +import { TransferService } from '../services/transfers.service'; + +@Controller('api/v1/transfers') +@UseGuards(JwtAuthGuard) +export class TransferController { + constructor( + private readonly transferService: TransferService, + private readonly approvalRuleService: ApprovalRuleService, + ) {} + + @Post() + async create(@Body() createTransferDto: CreateTransferDto, @Request() req) { + return this.transferService.create(createTransferDto, req.user.id); + } + + @Get() + async findAll(@Query() query: QueryTransfersDto) { + return this.transferService.findAll(query); + } + + @Get('pending-approval') + async getPendingApprovals(@Request() req) { + return this.transferService.getPendingApprovals(req.user.id); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.transferService.findOne(id); + } + + @Put(':id/approve') + async approve( + @Param('id') id: string, + @Body() dto: ApproveTransferDto, + @Request() req, + ) { + return this.transferService.approve(id, req.user.id, dto); + } + + @Put(':id/reject') + async reject( + @Param('id') id: string, + @Body() dto: RejectTransferDto, + @Request() req, + ) { + return this.transferService.reject(id, req.user.id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async cancel(@Param('id') id: string, @Request() req) { + await this.transferService.cancel(id, req.user.id); + } + + @Post(':id/execute') + async execute(@Param('id') id: string, @Request() req) { + return this.transferService.executeTransfer(id, req.user.id); + } + + @Post(':id/undo') + async undo(@Param('id') id: string, @Request() req) { + return this.transferService.undoTransfer(id, req.user.id); + } + + // Approval Rules endpoints + @Post('approval-rules') + async createRule(@Body() dto: CreateApprovalRuleDto) { + return this.approvalRuleService.create(dto); + } + + @Get('approval-rules') + async findAllRules() { + return this.approvalRuleService.findAll(); + } + + @Put('approval-rules/:id') + async updateRule( + @Param('id') id: string, + @Body() dto: UpdateApprovalRuleDto, + ) { + return this.approvalRuleService.update(id, dto); + } + + @Delete('approval-rules/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteRule(@Param('id') id: string) { + await this.approvalRuleService.remove(id); + } +} diff --git a/backend/src/transfers/dto/approve-transfer.dto.ts b/backend/src/transfers/dto/approve-transfer.dto.ts new file mode 100644 index 0000000..d080037 --- /dev/null +++ b/backend/src/transfers/dto/approve-transfer.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class ApproveTransferDto { + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/transfers/dto/create-approval-rule.dto.ts b/backend/src/transfers/dto/create-approval-rule.dto.ts new file mode 100644 index 0000000..61cf9c1 --- /dev/null +++ b/backend/src/transfers/dto/create-approval-rule.dto.ts @@ -0,0 +1,38 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsBoolean, + IsNumber, + IsObject, + IsUUID, +} from 'class-validator'; +import { ApprovalConditions } from '../entities/transfer-approval-rule.entity'; + +export class CreateApprovalRuleDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsObject() + conditions: ApprovalConditions; + + @IsUUID() + approverRoleId: string; + + @IsOptional() + @IsUUID() + approverUserId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsNumber() + priority?: number; +} diff --git a/backend/src/transfers/dto/create-transfer.dto.ts b/backend/src/transfers/dto/create-transfer.dto.ts new file mode 100644 index 0000000..fad9827 --- /dev/null +++ b/backend/src/transfers/dto/create-transfer.dto.ts @@ -0,0 +1,92 @@ +// src/transfers/dto/create-transfer.dto.ts +import { + IsEnum, + IsUUID, + IsArray, + IsString, + IsOptional, + MinLength, + MaxLength, + IsDateString, + IsNotEmpty, + ArrayMinSize, + ValidateIf, +} from 'class-validator'; +import { TransferType } from '../entities/transfer.entity'; + +export class CreateTransferDto { + @IsEnum(TransferType) + transferType: TransferType; + + @IsArray() + @ArrayMinSize(1, { message: 'At least one asset must be selected' }) + @IsUUID('4', { each: true }) + assetIds: string[]; + + @ValidateIf( + (o) => + o.transferType === TransferType.USER || + o.transferType === TransferType.COMPLETE, + ) + @IsUUID() + @IsOptional() + fromUserId?: string; + + @ValidateIf( + (o) => + o.transferType === TransferType.USER || + o.transferType === TransferType.COMPLETE, + ) + @IsUUID() + @IsNotEmpty() + toUserId?: string; + + @ValidateIf( + (o) => + o.transferType === TransferType.DEPARTMENT || + o.transferType === TransferType.COMPLETE, + ) + @IsUUID() + @IsOptional() + fromDepartmentId?: string; + + @ValidateIf( + (o) => + o.transferType === TransferType.DEPARTMENT || + o.transferType === TransferType.COMPLETE, + ) + @IsUUID() + @IsNotEmpty() + toDepartmentId?: string; + + @ValidateIf( + (o) => + o.transferType === TransferType.LOCATION || + o.transferType === TransferType.COMPLETE, + ) + @IsUUID() + @IsOptional() + fromLocationId?: string; + + @ValidateIf( + (o) => + o.transferType === TransferType.LOCATION || + o.transferType === TransferType.COMPLETE, + ) + @IsUUID() + @IsNotEmpty() + toLocationId?: string; + + @IsString() + @MinLength(10, { message: 'Reason must be at least 10 characters' }) + @MaxLength(500, { message: 'Reason must not exceed 500 characters' }) + reason: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsDateString() + scheduledDate?: string; +} diff --git a/backend/src/transfers/dto/query-transfers.dto.ts b/backend/src/transfers/dto/query-transfers.dto.ts new file mode 100644 index 0000000..799b0c8 --- /dev/null +++ b/backend/src/transfers/dto/query-transfers.dto.ts @@ -0,0 +1,30 @@ +import { IsOptional, IsEnum, IsUUID, IsDateString } from 'class-validator'; +import { TransferStatus, TransferType } from '../entities/transfer.entity'; + +export class QueryTransfersDto { + @IsOptional() + @IsEnum(TransferStatus) + status?: TransferStatus; + + @IsOptional() + @IsEnum(TransferType) + transferType?: TransferType; + + @IsOptional() + @IsUUID() + requestedBy?: string; + + @IsOptional() + @IsDateString() + fromDate?: string; + + @IsOptional() + @IsDateString() + toDate?: string; + + @IsOptional() + page?: number = 1; + + @IsOptional() + limit?: number = 20; +} diff --git a/backend/src/transfers/dto/reject-transfer.dto.ts b/backend/src/transfers/dto/reject-transfer.dto.ts new file mode 100644 index 0000000..78fc040 --- /dev/null +++ b/backend/src/transfers/dto/reject-transfer.dto.ts @@ -0,0 +1,8 @@ +import { IsString, IsNotEmpty, MinLength } from 'class-validator'; + +export class RejectTransferDto { + @IsString() + @IsNotEmpty() + @MinLength(10, { message: 'Rejection reason must be at least 10 characters' }) + rejectionReason: string; +} diff --git a/backend/src/transfers/dto/update-approval-rule.dto.ts b/backend/src/transfers/dto/update-approval-rule.dto.ts new file mode 100644 index 0000000..74a4e9f --- /dev/null +++ b/backend/src/transfers/dto/update-approval-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateApprovalRuleDto } from './create-approval-rule.dto'; + +export class UpdateApprovalRuleDto extends PartialType(CreateApprovalRuleDto) {} diff --git a/backend/src/transfers/dto/update-transfer.dto.ts b/backend/src/transfers/dto/update-transfer.dto.ts new file mode 100644 index 0000000..0160edb --- /dev/null +++ b/backend/src/transfers/dto/update-transfer.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTransferDto } from './create-transfer.dto'; + +export class UpdateTransferDto extends PartialType(CreateTransferDto) {} diff --git a/backend/src/transfers/entities/transfer-approval-rule.entity.ts b/backend/src/transfers/entities/transfer-approval-rule.entity.ts new file mode 100644 index 0000000..1b8bb28 --- /dev/null +++ b/backend/src/transfers/entities/transfer-approval-rule.entity.ts @@ -0,0 +1,52 @@ +// src/transfers/entities/transfer-approval-rule.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Role } from '../../roles/entities/role.entity'; +import { User } from '../../users/entities/user.entity'; + +export interface ApprovalConditions { + minValue?: number; + maxValue?: number; + categories?: string[]; + departments?: string[]; + requiresAllConditions?: boolean; +} + +@Entity('transfer_approval_rules') +export class TransferApprovalRule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column('text', { nullable: true }) + description: string; + + @Column('jsonb') + conditions: ApprovalConditions; + + @ManyToOne(() => Role) + approverRole: Role; + + @ManyToOne(() => User, { nullable: true }) + approverUser: User; + + @Column({ default: true }) + isActive: boolean; + + @Column({ default: 0 }) + priority: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/transfers/entities/transfer.entity.ts b/backend/src/transfers/entities/transfer.entity.ts new file mode 100644 index 0000000..188b84a --- /dev/null +++ b/backend/src/transfers/entities/transfer.entity.ts @@ -0,0 +1,112 @@ +// src/transfers/entities/transfer.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + ManyToMany, + JoinTable, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { Asset } from '../../assets/entities/asset.entity'; +import { User } from '../../users/entities/user.entity'; +import { Department } from '../../departments/entities/department.entity'; +// import { Location } from '../../locations/entities/location.entity'; + +export enum TransferType { + USER = 'USER', + DEPARTMENT = 'DEPARTMENT', + LOCATION = 'LOCATION', + COMPLETE = 'COMPLETE', +} + +export enum TransferStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +@Entity('transfers') +@Index(['status']) +@Index(['requestedBy']) +@Index(['scheduledDate']) +export class Transfer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'enum', + enum: TransferType, + }) + transferType: TransferType; + + @ManyToMany(() => Asset, { eager: true }) + @JoinTable({ + name: 'transfer_assets', + joinColumn: { name: 'transfer_id' }, + inverseJoinColumn: { name: 'asset_id' }, + }) + assets: Asset[]; + + @ManyToOne(() => User, { nullable: true }) + fromUser: User; + + @ManyToOne(() => User, { nullable: true }) + toUser: User; + + @ManyToOne(() => Department, { nullable: true }) + fromDepartment: Department; + + @ManyToOne(() => Department, { nullable: true }) + toDepartment: Department; + + @ManyToOne(() => Location, { nullable: true }) + fromLocation: Location; + + @ManyToOne(() => Location, { nullable: true }) + toLocation: Location; + + @Column({ + type: 'enum', + enum: TransferStatus, + default: TransferStatus.PENDING, + }) + status: TransferStatus; + + @ManyToOne(() => User) + requestedBy: User; + + @ManyToOne(() => User, { nullable: true }) + approvedBy: User; + + @ManyToOne(() => User, { nullable: true }) + rejectedBy: User; + + @Column('text') + reason: string; + + @Column('text', { nullable: true }) + notes: string; + + @Column({ default: false }) + approvalRequired: boolean; + + @Column('text', { nullable: true }) + rejectionReason: string; + + @Column({ type: 'timestamp', nullable: true }) + scheduledDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/transfers/processors/scheduled-transfer.processor.ts b/backend/src/transfers/processors/scheduled-transfer.processor.ts new file mode 100644 index 0000000..453b6cc --- /dev/null +++ b/backend/src/transfers/processors/scheduled-transfer.processor.ts @@ -0,0 +1,75 @@ +// src/transfers/processors/scheduled-transfer.processor.ts +import { Processor, Process, OnQueueError, OnQueueFailed } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Transfer, TransferStatus } from '../entities/transfer.entity'; +import { TransferService } from '../services/transfers.service'; + +export interface ScheduledTransferJob { + transferId: string; +} + +@Processor('scheduled-transfers') +export class ScheduledTransferProcessor { + private readonly logger = new Logger(ScheduledTransferProcessor.name); + + constructor( + private readonly transferService: TransferService, + @InjectRepository(Transfer) + private transferRepository: Repository, + ) {} + + @Process('execute-scheduled-transfer') + async handleScheduledTransfer(job: Job): Promise { + const { transferId } = job.data; + this.logger.log(`Processing scheduled transfer: ${transferId}`); + + try { + const transfer = await this.transferRepository.findOne({ + where: { id: transferId }, + }); + + if (!transfer) { + this.logger.error(`Transfer ${transferId} not found`); + return; + } + + if (transfer.status !== TransferStatus.APPROVED) { + this.logger.warn( + `Transfer ${transferId} is not in APPROVED status: ${transfer.status}`, + ); + return; + } + + // Execute the transfer using system user (or configured admin) + await this.transferService.executeTransfer( + transferId, + transfer.requestedBy.id, + ); + this.logger.log( + `Successfully executed scheduled transfer: ${transferId}`, + ); + } catch (error) { + this.logger.error( + `Failed to execute scheduled transfer ${transferId}:`, + error, + ); + throw error; // Re-throw for Bull retry mechanism + } + } + + @OnQueueError() + onError(error: Error) { + this.logger.error('Queue error:', error); + } + + @OnQueueFailed() + onFailed(job: Job, error: Error) { + this.logger.error( + `Job ${job.id} failed for transfer ${job.data.transferId}:`, + error, + ); + } +} diff --git a/backend/src/transfers/services/approval-rule.service.ts b/backend/src/transfers/services/approval-rule.service.ts new file mode 100644 index 0000000..97599e9 --- /dev/null +++ b/backend/src/transfers/services/approval-rule.service.ts @@ -0,0 +1,206 @@ +// src/transfers/services/approval-rule.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TransferApprovalRule } from '../entities/transfer-approval-rule.entity'; +import { Transfer } from '../entities/transfer.entity'; +import { Role } from '../../roles/entities/role.entity'; +import { User } from '../../users/entities/user.entity'; +import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; + +@Injectable() +export class ApprovalRuleService { + constructor( + @InjectRepository(TransferApprovalRule) + private ruleRepository: Repository, + @InjectRepository(Role) + private roleRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + ) {} + + async create(dto: CreateApprovalRuleDto): Promise { + const approverRole = await this.roleRepository.findOne({ + where: { id: dto.approverRoleId }, + }); + if (!approverRole) { + throw new NotFoundException('Approver role not found'); + } + + let approverUser = null; + if (dto.approverUserId) { + approverUser = await this.userRepository.findOne({ + where: { id: dto.approverUserId }, + }); + if (!approverUser) { + throw new NotFoundException('Approver user not found'); + } + } + + const rule = this.ruleRepository.create({ + name: dto.name, + description: dto.description, + conditions: dto.conditions, + approverRole, + approverUser, + isActive: dto.isActive ?? true, + priority: dto.priority ?? 0, + }); + + return this.ruleRepository.save(rule); + } + + async findAll(): Promise { + return this.ruleRepository.find({ + relations: ['approverRole', 'approverUser'], + order: { priority: 'DESC', createdAt: 'DESC' }, + }); + } + + async findOne(id: string): Promise { + const rule = await this.ruleRepository.findOne({ + where: { id }, + relations: ['approverRole', 'approverUser'], + }); + + if (!rule) { + throw new NotFoundException('Approval rule not found'); + } + + return rule; + } + + async update( + id: string, + dto: UpdateApprovalRuleDto, + ): Promise { + const rule = await this.findOne(id); + + if (dto.approverRoleId) { + const approverRole = await this.roleRepository.findOne({ + where: { id: dto.approverRoleId }, + }); + if (!approverRole) { + throw new NotFoundException('Approver role not found'); + } + rule.approverRole = approverRole; + } + + if (dto.approverUserId) { + const approverUser = await this.userRepository.findOne({ + where: { id: dto.approverUserId }, + }); + if (!approverUser) { + throw new NotFoundException('Approver user not found'); + } + rule.approverUser = approverUser; + } + + Object.assign(rule, { + name: dto.name ?? rule.name, + description: dto.description ?? rule.description, + conditions: dto.conditions ?? rule.conditions, + isActive: dto.isActive ?? rule.isActive, + priority: dto.priority ?? rule.priority, + }); + + return this.ruleRepository.save(rule); + } + + async remove(id: string): Promise { + const rule = await this.findOne(id); + await this.ruleRepository.remove(rule); + } + + async evaluateRules(transfer: Transfer): Promise { + const activeRules = await this.ruleRepository.find({ + where: { isActive: true }, + relations: ['approverRole', 'approverUser'], + order: { priority: 'DESC' }, + }); + + if (activeRules.length === 0) { + return false; // No rules = no approval required + } + + for (const rule of activeRules) { + if (await this.matchesRule(transfer, rule)) { + return true; // At least one rule matched + } + } + + return false; + } + + async getMatchingRules(transfer: Transfer): Promise { + const activeRules = await this.ruleRepository.find({ + where: { isActive: true }, + relations: ['approverRole', 'approverUser'], + order: { priority: 'DESC' }, + }); + + const matchingRules = []; + for (const rule of activeRules) { + if (await this.matchesRule(transfer, rule)) { + matchingRules.push(rule); + } + } + + return matchingRules; + } + + private async matchesRule( + transfer: Transfer, + rule: TransferApprovalRule, + ): Promise { + const conditions = rule.conditions; + const requiresAll = conditions.requiresAllConditions ?? false; + + const checks: boolean[] = []; + + // Check minimum value + if (conditions.minValue !== undefined && conditions.minValue !== null) { + const totalValue = transfer.assets.reduce( + (sum, asset) => sum + (asset.value || 0), + 0, + ); + checks.push(totalValue >= conditions.minValue); + } + + // Check maximum value + if (conditions.maxValue !== undefined && conditions.maxValue !== null) { + const totalValue = transfer.assets.reduce( + (sum, asset) => sum + (asset.value || 0), + 0, + ); + checks.push(totalValue <= conditions.maxValue); + } + + // Check categories + if (conditions.categories && conditions.categories.length > 0) { + const hasMatchingCategory = transfer.assets.some((asset) => + conditions.categories.includes(asset.category?.name), + ); + checks.push(hasMatchingCategory); + } + + // Check departments + if (conditions.departments && conditions.departments.length > 0) { + const matchesDepartment = + (transfer.fromDepartment && + conditions.departments.includes(transfer.fromDepartment.name)) || + (transfer.toDepartment && + conditions.departments.includes(transfer.toDepartment.name)); + checks.push(matchesDepartment); + } + + // If no conditions were checked, return false + if (checks.length === 0) { + return false; + } + + // Apply AND or OR logic + return requiresAll ? checks.every((c) => c) : checks.some((c) => c); + } +} diff --git a/backend/src/transfers/services/scheduled-transfer.service.ts b/backend/src/transfers/services/scheduled-transfer.service.ts new file mode 100644 index 0000000..7b2ae52 --- /dev/null +++ b/backend/src/transfers/services/scheduled-transfer.service.ts @@ -0,0 +1,59 @@ +// src/transfers/services/scheduled-transfer.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual } from 'typeorm'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { Transfer, TransferStatus } from '../entities/transfer.entity'; + +@Injectable() +export class ScheduledTransferService { + private readonly logger = new Logger(ScheduledTransferService.name); + + constructor( + @InjectRepository(Transfer) + private transferRepository: Repository, + @InjectQueue('scheduled-transfers') + private scheduledTransfersQueue: Queue, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async processScheduledTransfers(): Promise { + this.logger.log('Checking for scheduled transfers...'); + + const now = new Date(); + const scheduledTransfers = await this.transferRepository.find({ + where: { + status: TransferStatus.APPROVED, + scheduledDate: LessThanOrEqual(now), + }, + relations: ['requestedBy'], + }); + + this.logger.log( + `Found ${scheduledTransfers.length} scheduled transfers to process`, + ); + + for (const transfer of scheduledTransfers) { + try { + await this.scheduledTransfersQueue.add( + 'execute-scheduled-transfer', + { transferId: transfer.id }, + { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + removeOnComplete: true, + removeOnFail: false, + }, + ); + this.logger.log(`Queued scheduled transfer: ${transfer.id}`); + } catch (error) { + this.logger.error(`Failed to queue transfer ${transfer.id}:`, error); + } + } + } +} diff --git a/backend/src/transfers/services/transfer-lock.service.ts b/backend/src/transfers/services/transfer-lock.service.ts new file mode 100644 index 0000000..cecf3ac --- /dev/null +++ b/backend/src/transfers/services/transfer-lock.service.ts @@ -0,0 +1,51 @@ +// src/transfers/services/transfer-lock.service.ts +import { Injectable, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; + +@Injectable() +export class TransferLockService { + private readonly LOCK_PREFIX = 'transfer:lock:asset:'; + private readonly LOCK_TTL = 3600000; // 1 hour in milliseconds + + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + async lockAssets(assetIds: string[], transferId: string): Promise { + for (const assetId of assetIds) { + const key = `${this.LOCK_PREFIX}${assetId}`; + await this.cacheManager.set(key, transferId, this.LOCK_TTL); + } + } + + async unlockAssets(assetIds: string[]): Promise { + for (const assetId of assetIds) { + const key = `${this.LOCK_PREFIX}${assetId}`; + await this.cacheManager.del(key); + } + } + + async checkAssetsLocked(assetIds: string[]): Promise { + const lockedAssets: string[] = []; + + for (const assetId of assetIds) { + const key = `${this.LOCK_PREFIX}${assetId}`; + const transferId = await this.cacheManager.get(key); + if (transferId) { + lockedAssets.push(assetId); + } + } + + return lockedAssets; + } + + async isAssetLocked(assetId: string): Promise { + const key = `${this.LOCK_PREFIX}${assetId}`; + const transferId = await this.cacheManager.get(key); + return !!transferId; + } + + async getAssetLock(assetId: string): Promise { + const key = `${this.LOCK_PREFIX}${assetId}`; + return this.cacheManager.get(key); + } +} diff --git a/backend/src/transfers/services/transfers.service.ts b/backend/src/transfers/services/transfers.service.ts new file mode 100644 index 0000000..18d16ee --- /dev/null +++ b/backend/src/transfers/services/transfers.service.ts @@ -0,0 +1,656 @@ +// src/transfers/services/transfer.service.ts +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, + Inject, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, DataSource } from 'typeorm'; +import { + Transfer, + TransferStatus, + TransferType, +} from '../entities/transfer.entity'; +import { Asset } from '../../assets/entities/asset.entity'; +import { User } from '../../users/entities/user.entity'; +import { Department } from '../../departments/entities/department.entity'; +// import { Location } from '../../locations/entities/location.entity'; +import { AssetHistoryService } from '../../assets/services/asset-history.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { ApproveTransferDto } from '../dto/approve-transfer.dto'; +import { CreateTransferDto } from '../dto/create-transfer.dto'; +import { QueryTransfersDto } from '../dto/query-transfers.dto'; +import { RejectTransferDto } from '../dto/reject-transfer.dto'; +import { ApprovalRuleService } from './approval-rule.service'; +import { TransferLockService } from './transfer-lock.service'; + +@Injectable() +export class TransferService { + constructor( + @InjectRepository(Transfer) + private transferRepository: Repository, + @InjectRepository(Asset) + private assetRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(Department) + private departmentRepository: Repository, + @InjectRepository(Location) + private locationRepository: Repository, + private dataSource: DataSource, + private approvalRuleService: ApprovalRuleService, + private transferLockService: TransferLockService, + private assetHistoryService: AssetHistoryService, + private eventEmitter: EventEmitter2, + @Inject(CACHE_MANAGER) private cacheManager: Cache, + ) {} + + async create( + createTransferDto: CreateTransferDto, + userId: string, + ): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Validate user exists + const requestedBy = await this.userRepository.findOne({ + where: { id: userId }, + }); + if (!requestedBy) { + throw new NotFoundException('User not found'); + } + + // Validate assets + const assets = await this.assetRepository.find({ + where: { id: In(createTransferDto.assetIds) }, + relations: ['category', 'assignedUser', 'department', 'location'], + }); + + if (assets.length !== createTransferDto.assetIds.length) { + throw new NotFoundException('One or more assets not found'); + } + + // Check for RETIRED assets + const retiredAssets = assets.filter( + (asset) => asset.status === 'RETIRED', + ); + if (retiredAssets.length > 0) { + throw new BadRequestException('Cannot transfer RETIRED assets'); + } + + // Check if assets are locked in other transfers + const lockedAssets = await this.transferLockService.checkAssetsLocked( + createTransferDto.assetIds, + ); + if (lockedAssets.length > 0) { + throw new BadRequestException( + `Assets are locked in another pending transfer: ${lockedAssets.join(', ')}`, + ); + } + + // Validate entities based on transfer type + await this.validateTransferEntities(createTransferDto); + + // Check for same source and destination + this.validateNotSameDestination(createTransferDto); + + // Create transfer + const transfer = this.transferRepository.create({ + transferType: createTransferDto.transferType, + assets, + reason: createTransferDto.reason, + notes: createTransferDto.notes, + requestedBy, + scheduledDate: createTransferDto.scheduledDate + ? new Date(createTransferDto.scheduledDate) + : null, + }); + + // Set from/to entities + await this.setTransferEntities(transfer, createTransferDto); + + // Evaluate approval rules + const approvalRequired = + await this.approvalRuleService.evaluateRules(transfer); + transfer.approvalRequired = approvalRequired; + + if (!approvalRequired) { + transfer.status = TransferStatus.APPROVED; + } + + // Lock assets + await this.transferLockService.lockAssets( + createTransferDto.assetIds, + transfer.id, + ); + + // Save transfer + const savedTransfer = await queryRunner.manager.save(transfer); + + await queryRunner.commitTransaction(); + + // Emit events + if (approvalRequired) { + this.eventEmitter.emit('transfer.approval-required', savedTransfer); + } else { + this.eventEmitter.emit('transfer.auto-approved', savedTransfer); + // Auto-execute if no scheduled date + if (!savedTransfer.scheduledDate) { + await this.executeTransfer(savedTransfer.id, userId); + } + } + + return this.findOne(savedTransfer.id); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + async findAll( + query: QueryTransfersDto, + ): Promise<{ data: Transfer[]; total: number }> { + const qb = this.transferRepository + .createQueryBuilder('transfer') + .leftJoinAndSelect('transfer.assets', 'assets') + .leftJoinAndSelect('transfer.requestedBy', 'requestedBy') + .leftJoinAndSelect('transfer.approvedBy', 'approvedBy') + .leftJoinAndSelect('transfer.fromUser', 'fromUser') + .leftJoinAndSelect('transfer.toUser', 'toUser') + .leftJoinAndSelect('transfer.fromDepartment', 'fromDepartment') + .leftJoinAndSelect('transfer.toDepartment', 'toDepartment') + .leftJoinAndSelect('transfer.fromLocation', 'fromLocation') + .leftJoinAndSelect('transfer.toLocation', 'toLocation'); + + if (query.status) { + qb.andWhere('transfer.status = :status', { status: query.status }); + } + + if (query.transferType) { + qb.andWhere('transfer.transferType = :transferType', { + transferType: query.transferType, + }); + } + + if (query.requestedBy) { + qb.andWhere('transfer.requestedBy.id = :requestedBy', { + requestedBy: query.requestedBy, + }); + } + + if (query.fromDate) { + qb.andWhere('transfer.createdAt >= :fromDate', { + fromDate: query.fromDate, + }); + } + + if (query.toDate) { + qb.andWhere('transfer.createdAt <= :toDate', { toDate: query.toDate }); + } + + const page = query.page || 1; + const limit = query.limit || 20; + + qb.skip((page - 1) * limit) + .take(limit) + .orderBy('transfer.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total }; + } + + async findOne(id: string): Promise { + const transfer = await this.transferRepository.findOne({ + where: { id }, + relations: [ + 'assets', + 'assets.category', + 'requestedBy', + 'approvedBy', + 'rejectedBy', + 'fromUser', + 'toUser', + 'fromDepartment', + 'toDepartment', + 'fromLocation', + 'toLocation', + ], + }); + + if (!transfer) { + throw new NotFoundException('Transfer not found'); + } + + return transfer; + } + + async approve( + id: string, + userId: string, + dto: ApproveTransferDto, + ): Promise { + const transfer = await this.findOne(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Transfer is not in pending status'); + } + + if (transfer.requestedBy.id === userId) { + throw new ForbiddenException('Cannot approve your own transfer request'); + } + + const approver = await this.userRepository.findOne({ + where: { id: userId }, + }); + if (!approver) { + throw new NotFoundException('Approver not found'); + } + + transfer.status = TransferStatus.APPROVED; + transfer.approvedBy = approver; + if (dto.notes) { + transfer.notes = transfer.notes + ? `${transfer.notes}\n${dto.notes}` + : dto.notes; + } + + await this.transferRepository.save(transfer); + + this.eventEmitter.emit('transfer.approved', transfer); + + // Auto-execute if no scheduled date + if (!transfer.scheduledDate) { + await this.executeTransfer(transfer.id, userId); + } + + return this.findOne(id); + } + + async reject( + id: string, + userId: string, + dto: RejectTransferDto, + ): Promise { + const transfer = await this.findOne(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Transfer is not in pending status'); + } + + if (transfer.requestedBy.id === userId) { + throw new ForbiddenException('Cannot reject your own transfer request'); + } + + const rejector = await this.userRepository.findOne({ + where: { id: userId }, + }); + if (!rejector) { + throw new NotFoundException('Rejector not found'); + } + + transfer.status = TransferStatus.REJECTED; + transfer.rejectedBy = rejector; + transfer.rejectionReason = dto.rejectionReason; + + await this.transferRepository.save(transfer); + + // Unlock assets + await this.transferLockService.unlockAssets( + transfer.assets.map((a) => a.id), + ); + + this.eventEmitter.emit('transfer.rejected', transfer); + + return this.findOne(id); + } + + async cancel(id: string, userId: string): Promise { + const transfer = await this.findOne(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Only pending transfers can be cancelled'); + } + + if (transfer.requestedBy.id !== userId) { + throw new ForbiddenException( + 'Only the requester can cancel the transfer', + ); + } + + transfer.status = TransferStatus.CANCELLED; + await this.transferRepository.save(transfer); + + // Unlock assets + await this.transferLockService.unlockAssets( + transfer.assets.map((a) => a.id), + ); + + this.eventEmitter.emit('transfer.cancelled', transfer); + + return this.findOne(id); + } + + async executeTransfer(id: string, userId: string): Promise { + // Implement idempotency check + const lockKey = `transfer:execute:${id}`; + const locked = await this.cacheManager.get(lockKey); + if (locked) { + throw new BadRequestException('Transfer execution already in progress'); + } + + await this.cacheManager.set(lockKey, true, 300000); // 5 minutes + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction('SERIALIZABLE'); + + try { + const transfer = await this.findOne(id); + + if (transfer.status === TransferStatus.COMPLETED) { + return transfer; // Idempotent + } + + if (transfer.status !== TransferStatus.APPROVED) { + throw new BadRequestException( + 'Transfer must be approved before execution', + ); + } + + // Update assets with pessimistic locking + for (const asset of transfer.assets) { + const lockedAsset = await queryRunner.manager.findOne(Asset, { + where: { id: asset.id }, + lock: { mode: 'pessimistic_write' }, + }); + + if (!lockedAsset) { + throw new NotFoundException(`Asset ${asset.id} not found`); + } + + // Update asset based on transfer type + if ( + transfer.transferType === TransferType.USER || + transfer.transferType === TransferType.COMPLETE + ) { + lockedAsset.assignedUser = transfer.toUser; + } + + if ( + transfer.transferType === TransferType.DEPARTMENT || + transfer.transferType === TransferType.COMPLETE + ) { + lockedAsset.department = transfer.toDepartment; + } + + if ( + transfer.transferType === TransferType.LOCATION || + transfer.transferType === TransferType.COMPLETE + ) { + lockedAsset.location = transfer.toLocation; + } + + await queryRunner.manager.save(lockedAsset); + + // Create asset history entry + await this.assetHistoryService.createHistory({ + asset: lockedAsset, + action: 'TRANSFERRED', + performedBy: await this.userRepository.findOne({ + where: { id: userId }, + }), + details: `Transfer #${transfer.id}: ${transfer.reason}`, + fromUser: transfer.fromUser, + toUser: transfer.toUser, + fromDepartment: transfer.fromDepartment, + toDepartment: transfer.toDepartment, + fromLocation: transfer.fromLocation, + toLocation: transfer.toLocation, + }); + } + + transfer.status = TransferStatus.COMPLETED; + transfer.completedAt = new Date(); + await queryRunner.manager.save(transfer); + + await queryRunner.commitTransaction(); + + // Unlock assets + await this.transferLockService.unlockAssets( + transfer.assets.map((a) => a.id), + ); + + this.eventEmitter.emit('transfer.completed', transfer); + + return this.findOne(id); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + await this.cacheManager.del(lockKey); + } + } + + async undoTransfer(id: string, userId: string): Promise { + const transfer = await this.findOne(id); + + if (transfer.status !== TransferStatus.COMPLETED) { + throw new BadRequestException('Only completed transfers can be undone'); + } + + const undoTimeLimit = 24 * 60 * 60 * 1000; // 24 hours + const timeSinceCompletion = Date.now() - transfer.completedAt.getTime(); + + if (timeSinceCompletion > undoTimeLimit) { + throw new BadRequestException('Undo time limit exceeded (24 hours)'); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Revert assets + for (const asset of transfer.assets) { + const lockedAsset = await queryRunner.manager.findOne(Asset, { + where: { id: asset.id }, + lock: { mode: 'pessimistic_write' }, + }); + + if ( + transfer.transferType === TransferType.USER || + transfer.transferType === TransferType.COMPLETE + ) { + lockedAsset.assignedUser = transfer.fromUser; + } + + if ( + transfer.transferType === TransferType.DEPARTMENT || + transfer.transferType === TransferType.COMPLETE + ) { + lockedAsset.department = transfer.fromDepartment; + } + + if ( + transfer.transferType === TransferType.LOCATION || + transfer.transferType === TransferType.COMPLETE + ) { + lockedAsset.location = transfer.fromLocation; + } + + await queryRunner.manager.save(lockedAsset); + + // Create history entry + await this.assetHistoryService.createHistory({ + asset: lockedAsset, + action: 'TRANSFER_UNDONE', + performedBy: await this.userRepository.findOne({ + where: { id: userId }, + }), + details: `Undone transfer #${transfer.id}`, + }); + } + + transfer.status = TransferStatus.CANCELLED; + transfer.notes = `${transfer.notes || ''}\nUndone by user on ${new Date().toISOString()}`; + await queryRunner.manager.save(transfer); + + await queryRunner.commitTransaction(); + + this.eventEmitter.emit('transfer.undone', transfer); + + return this.findOne(id); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + async getPendingApprovals(userId: string): Promise { + // Get user's roles + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Find transfers pending approval where user has approver role + const transfers = await this.transferRepository.find({ + where: { + status: TransferStatus.PENDING, + approvalRequired: true, + }, + relations: [ + 'assets', + 'requestedBy', + 'fromUser', + 'toUser', + 'fromDepartment', + 'toDepartment', + 'fromLocation', + 'toLocation', + ], + }); + + // Filter based on approval rules + const pendingForUser = []; + for (const transfer of transfers) { + const rules = await this.approvalRuleService.getMatchingRules(transfer); + const canApprove = rules.some( + (rule) => + user.roles.some((role) => role.id === rule.approverRole.id) || + (rule.approverUser && rule.approverUser.id === userId), + ); + + if (canApprove) { + pendingForUser.push(transfer); + } + } + + return pendingForUser; + } + + private async validateTransferEntities( + dto: CreateTransferDto, + ): Promise { + if (dto.toUserId) { + const user = await this.userRepository.findOne({ + where: { id: dto.toUserId }, + }); + if (!user) { + throw new NotFoundException('Destination user not found'); + } + } + + if (dto.toDepartmentId) { + const dept = await this.departmentRepository.findOne({ + where: { id: dto.toDepartmentId }, + }); + if (!dept) { + throw new NotFoundException('Destination department not found'); + } + } + + if (dto.toLocationId) { + const loc = await this.locationRepository.findOne({ + where: { id: dto.toLocationId }, + }); + if (!loc) { + throw new NotFoundException('Destination location not found'); + } + } + } + + private validateNotSameDestination(dto: CreateTransferDto): void { + if (dto.fromUserId && dto.toUserId && dto.fromUserId === dto.toUserId) { + throw new BadRequestException('Cannot transfer to same user'); + } + + if ( + dto.fromDepartmentId && + dto.toDepartmentId && + dto.fromDepartmentId === dto.toDepartmentId + ) { + throw new BadRequestException('Cannot transfer to same department'); + } + + if ( + dto.fromLocationId && + dto.toLocationId && + dto.fromLocationId === dto.toLocationId + ) { + throw new BadRequestException('Cannot transfer to same location'); + } + } + + private async setTransferEntities( + transfer: Transfer, + dto: CreateTransferDto, + ): Promise { + if (dto.fromUserId) { + transfer.fromUser = await this.userRepository.findOne({ + where: { id: dto.fromUserId }, + }); + } + if (dto.toUserId) { + transfer.toUser = await this.userRepository.findOne({ + where: { id: dto.toUserId }, + }); + } + if (dto.fromDepartmentId) { + transfer.fromDepartment = await this.departmentRepository.findOne({ + where: { id: dto.fromDepartmentId }, + }); + } + if (dto.toDepartmentId) { + transfer.toDepartment = await this.departmentRepository.findOne({ + where: { id: dto.toDepartmentId }, + }); + } + if (dto.fromLocationId) { + transfer.fromLocation = await this.locationRepository.findOne({ + where: { id: dto.fromLocationId }, + }); + } + if (dto.toLocationId) { + transfer.toLocation = await this.locationRepository.findOne({ + where: { id: dto.toLocationId }, + }); + } + } +} diff --git a/backend/src/transfers/transfers.controller.spec.ts b/backend/src/transfers/transfers.controller.spec.ts new file mode 100644 index 0000000..c6dacb0 --- /dev/null +++ b/backend/src/transfers/transfers.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransfersController } from './controllers/transfers.controller'; +import { TransfersService } from './transfers.service'; + +describe('TransfersController', () => { + let controller: TransfersController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TransfersController], + providers: [TransfersService], + }).compile(); + + controller = module.get(TransfersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/transfers/transfers.module.ts b/backend/src/transfers/transfers.module.ts new file mode 100644 index 0000000..b073665 --- /dev/null +++ b/backend/src/transfers/transfers.module.ts @@ -0,0 +1,61 @@ +// src/transfers/transfer.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bull'; +import { CacheModule } from '@nestjs/cache-manager'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; +import * as redisStore from 'cache-manager-redis-store'; + +import { Transfer } from './entities/transfer.entity'; +import { TransferApprovalRule } from './entities/transfer-approval-rule.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { User } from '../users/entities/user.entity'; +import { Department } from '../departments/entities/department.entity'; +// import { Location } from '../locations/entities/location.entity'; +import { Role } from '../roles/entities/role.entity'; + +import { TransferController } from './controllers/transfers.controller'; +import { TransferService } from './services/transfers.service'; +import { ApprovalRuleService } from './services/approval-rule.service'; +import { TransferLockService } from './services/transfer-lock.service'; +import { ScheduledTransferService } from './services/scheduled-transfer.service'; +import { ScheduledTransferProcessor } from './processors/scheduled-transfer.processor'; + +// import { AssetHistoryService } from '../assets/services/asset-history.service'; +// import { NotificationService } from '../notifications/notification.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Transfer, + TransferApprovalRule, + Asset, + User, + Department, + Location, + Role, + ]), + BullModule.registerQueue({ + name: 'scheduled-transfers', + }), + CacheModule.register({ + store: redisStore, + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + ttl: 3600, + }), + EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), + ], + controllers: [TransferController], + providers: [ + TransferService, + ApprovalRuleService, + TransferLockService, + ScheduledTransferService, + ScheduledTransferProcessor, + ], + exports: [TransferService, ApprovalRuleService], +}) +export class TransferModule {} diff --git a/backend/src/transfers/transfers.service.spec.ts b/backend/src/transfers/transfers.service.spec.ts new file mode 100644 index 0000000..2ea0c1d --- /dev/null +++ b/backend/src/transfers/transfers.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransfersService } from './transfers.service'; + +describe('TransfersService', () => { + let service: TransfersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TransfersService], + }).compile(); + + service = module.get(TransfersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +});