refactor(shifts): massive do-over of the whole module. exposed delete route only and simplified find and create/update functions.

This commit is contained in:
Matthieu Haineault 2025-10-20 14:59:24 -04:00
parent bba6c84b6f
commit 7537c2ff0d
34 changed files with 1148 additions and 915 deletions

300
package-lock.json generated
View File

@ -17,7 +17,7 @@
"@nestjs/platform-express": "^11.1.6",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.14.0",
"@prisma/client": "^6.17.1",
"bullmq": "^5.58.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
@ -54,7 +54,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.17.0",
"prisma": "^6.17.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
@ -1751,6 +1751,15 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",
"integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==",
"dev": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@inquirer/checkbox": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.9.tgz",
@ -1797,14 +1806,14 @@
}
},
"node_modules/@inquirer/core": {
"version": "10.1.14",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.14.tgz",
"integrity": "sha512-Ma+ZpOJPewtIYl6HZHZckeX1STvDnHTCB2GVINNUlSEn2Am6LddWwfPkIGY0IUFVjUUrr/93XlBwTK6mfLjf0A==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz",
"integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==",
"dev": true,
"dependencies": {
"@inquirer/figures": "^1.0.12",
"@inquirer/type": "^3.0.7",
"ansi-escapes": "^4.3.2",
"@inquirer/ansi": "^1.0.1",
"@inquirer/figures": "^1.0.14",
"@inquirer/type": "^3.0.9",
"cli-width": "^4.1.0",
"mute-stream": "^2.0.0",
"signal-exit": "^4.1.0",
@ -1824,14 +1833,14 @@
}
},
"node_modules/@inquirer/editor": {
"version": "4.2.14",
"resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.14.tgz",
"integrity": "sha512-yd2qtLl4QIIax9DTMZ1ZN2pFrrj+yL3kgIWxm34SS6uwCr0sIhsNyudUjAo5q3TqI03xx4SEBkUJqZuAInp9uA==",
"version": "4.2.21",
"resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.21.tgz",
"integrity": "sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ==",
"dev": true,
"dependencies": {
"@inquirer/core": "^10.1.14",
"@inquirer/type": "^3.0.7",
"external-editor": "^3.1.0"
"@inquirer/core": "^10.3.0",
"@inquirer/external-editor": "^1.0.2",
"@inquirer/type": "^3.0.9"
},
"engines": {
"node": ">=18"
@ -1867,10 +1876,47 @@
}
}
},
"node_modules/@inquirer/external-editor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz",
"integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==",
"dev": true,
"dependencies": {
"chardet": "^2.1.0",
"iconv-lite": "^0.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@inquirer/external-editor/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dev": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@inquirer/figures": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz",
"integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==",
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz",
"integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==",
"dev": true,
"engines": {
"node": ">=18"
@ -2039,9 +2085,9 @@
}
},
"node_modules/@inquirer/type": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz",
"integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz",
"integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==",
"dev": true,
"engines": {
"node": ">=18"
@ -3222,9 +3268,9 @@
}
},
"node_modules/@nestjs/common": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.3.tgz",
"integrity": "sha512-ogEK+GriWodIwCw6buQ1rpcH4Kx+G7YQ9EwuPySI3rS05pSdtQ++UhucjusSI9apNidv+QURBztJkRecwwJQXg==",
"version": "11.1.6",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.6.tgz",
"integrity": "sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ==",
"dependencies": {
"file-type": "21.0.0",
"iterare": "1.2.1",
@ -3266,9 +3312,9 @@
}
},
"node_modules/@nestjs/core": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.3.tgz",
"integrity": "sha512-5lTni0TCh8x7bXETRD57pQFnKnEg1T6M+VLE7wAmyQRIecKQU+2inRGZD+A4v2DC1I04eA0WffP0GKLxjOKlzw==",
"version": "11.1.6",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.6.tgz",
"integrity": "sha512-siWX7UDgErisW18VTeJA+x+/tpNZrJewjTBsRPF3JVxuWRuAB1kRoiJcxHgln8Lb5UY9NdvklITR84DUEXD0Cg==",
"hasInstallScript": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
@ -3306,11 +3352,11 @@
}
},
"node_modules/@nestjs/jwt": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.0.tgz",
"integrity": "sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==",
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.1.tgz",
"integrity": "sha512-HXSsc7SAnCnjA98TsZqrE7trGtHDnYXWp4Ffy6LwSmck1QvbGYdMzBquXofX5l6tIRpeY4Qidl2Ti2CVG77Pdw==",
"dependencies": {
"@types/jsonwebtoken": "9.0.7",
"@types/jsonwebtoken": "9.0.10",
"jsonwebtoken": "9.0.2"
},
"peerDependencies": {
@ -3366,11 +3412,11 @@
}
},
"node_modules/@nestjs/schedule": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.0.tgz",
"integrity": "sha512-aQySMw6tw2nhitELXd3EiRacQRgzUKD9mFcUZVOJ7jPLqIBvXOyvRWLsK9SdurGA+jjziAlMef7iB5ZEFFoQpw==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz",
"integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==",
"dependencies": {
"cron": "4.3.0"
"cron": "4.3.3"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
@ -3470,16 +3516,16 @@
}
},
"node_modules/@nestjs/swagger": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.0.tgz",
"integrity": "sha512-5wolt8GmpNcrQv34tIPUtPoV1EeFbCetm40Ij3+M0FNNnf2RJ3FyWfuQvI8SBlcJyfaounYVTKzKHreFXsUyOg==",
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.1.tgz",
"integrity": "sha512-1MS7xf0pzc1mofG53xrrtrurnziafPUHkqzRm4YUVPA/egeiMaSerQBD/feiAeQ2BnX0WiLsTX4HQFO0icvOjQ==",
"dependencies": {
"@microsoft/tsdoc": "0.15.1",
"@nestjs/mapped-types": "2.1.0",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "8.2.0",
"swagger-ui-dist": "5.21.0"
"path-to-regexp": "8.3.0",
"swagger-ui-dist": "5.29.4"
},
"peerDependencies": {
"@fastify/static": "^8.0.0",
@ -3501,10 +3547,19 @@
}
}
},
"node_modules/@nestjs/swagger/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@nestjs/testing": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.3.tgz",
"integrity": "sha512-CeXG6/eEqgFIkPkmU00y18Dd3DLOIDFhPItzJK1SWckKo6IhcnfoRJzGx75bmuvUMjb51j6An96S/+MJ2ty9jA==",
"version": "11.1.6",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.6.tgz",
"integrity": "sha512-srYzzDNxGvVCe1j0SpTS9/ix75PKt6Sn6iMaH1rpJ6nj2g8vwNrhK0CoJJXvpCYgrnI+2WES2pprYnq8rAMYHA==",
"dev": true,
"dependencies": {
"tslib": "2.8.1"
@ -3612,9 +3667,9 @@
}
},
"node_modules/@prisma/client": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz",
"integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
"hasInstallScript": true,
"engines": {
"node": ">=18.18"
@ -3633,9 +3688,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz",
"integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.1.tgz",
"integrity": "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA==",
"devOptional": true,
"dependencies": {
"c12": "3.1.0",
@ -3645,48 +3700,48 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz",
"integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.1.tgz",
"integrity": "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ==",
"devOptional": true
},
"node_modules/@prisma/engines": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz",
"integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.1.tgz",
"integrity": "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/fetch-engine": "6.17.0",
"@prisma/get-platform": "6.17.0"
"@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/fetch-engine": "6.17.1",
"@prisma/get-platform": "6.17.1"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz",
"integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==",
"version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac.tgz",
"integrity": "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg==",
"devOptional": true
},
"node_modules/@prisma/fetch-engine": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz",
"integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.1.tgz",
"integrity": "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/get-platform": "6.17.0"
"@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/get-platform": "6.17.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz",
"integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.1.tgz",
"integrity": "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.17.0"
"@prisma/debug": "6.17.1"
}
},
"node_modules/@scarf/scarf": {
@ -4268,17 +4323,18 @@
"dev": true
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz",
"integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==",
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw=="
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="
},
"node_modules/@types/methods": {
"version": "1.1.4",
@ -4292,6 +4348,11 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@ -6169,9 +6230,9 @@
}
},
"node_modules/chardet": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz",
"integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==",
"dev": true
},
"node_modules/chokidar": {
@ -6614,12 +6675,12 @@
"dev": true
},
"node_modules/cron": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.3.0.tgz",
"integrity": "sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz",
"integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==",
"dependencies": {
"@types/luxon": "~3.6.0",
"luxon": "~3.6.0"
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
@ -7493,32 +7554,6 @@
"node": ">=4"
}
},
"node_modules/external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"dev": true,
"dependencies": {
"chardet": "^0.7.0",
"iconv-lite": "^0.4.24",
"tmp": "^0.0.33"
},
"engines": {
"node": ">=4"
}
},
"node_modules/external-editor/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
@ -9648,9 +9683,9 @@
}
},
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
@ -10231,15 +10266,6 @@
"node": ">=8"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
@ -10677,14 +10703,14 @@
}
},
"node_modules/prisma": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz",
"integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.1.tgz",
"integrity": "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/config": "6.17.0",
"@prisma/engines": "6.17.0"
"@prisma/config": "6.17.1",
"@prisma/engines": "6.17.1"
},
"bin": {
"prisma": "build/index.js"
@ -11791,9 +11817,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz",
"integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==",
"version": "5.29.4",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.4.tgz",
"integrity": "sha512-gJFDz/gyLOCQtWwAgqs6Rk78z9ONnqTnlW11gimG9nLap8drKa3AJBKpzIQMIjl5PD2Ix+Tn+mc/tfoT2tgsng==",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
@ -12055,18 +12081,6 @@
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"devOptional": true
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dev": true,
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",

View File

@ -49,7 +49,7 @@
"@nestjs/platform-express": "^11.1.6",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.14.0",
"@prisma/client": "^6.17.1",
"bullmq": "^5.58.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
@ -86,7 +86,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.17.0",
"prisma": "^6.17.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

View File

@ -1,6 +1,6 @@
import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
import { ShiftsCommandService } from "src/modules/shifts/_deprecated-files/shifts-command.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveTypes } from "@prisma/client";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";

View File

@ -6,11 +6,11 @@ import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
import { TimesheetsModule } from "../timesheets/timesheets.module";
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
import { ShiftsCommandService } from "../shifts/_deprecated-files/shifts-command.service";
import { SharedModule } from "../shared/shared.module";
import { PrismaService } from "src/prisma/prisma.service";
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
import { ShiftsHelpersService } from "../shifts/helpers/shifts.helpers";
import { ShiftsHelpersService } from "../shifts/_deprecated-files/shifts.helpers";
@Module({
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],

View File

@ -2,10 +2,14 @@ export class Session {
user_id: number;
}
export class Timesheets {
employee_fullname: string;
timesheets: Timesheet[];
}
export class Timesheet {
timesheet_id: number;
is_approved: boolean;
days: TimesheetDay[];
weekly_hours: TotalHours[];
weekly_expenses: TotalExpenses[];
@ -36,9 +40,9 @@ export class TotalExpenses {
}
export class Shift {
date: Date;
start_time: Date;
end_time: Date;
date: string;
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
is_approved: boolean;

View File

@ -1,13 +1,17 @@
//newer version that uses Express session data
import { Controller } from "@nestjs/common";
import { ShiftService } from "../services/shift.service";
import { Controller, Delete, Param } from "@nestjs/common";
import { ShiftsUpsertService } from "../services/shifts-upsert.service";
import { Shifts } from "@prisma/client";
@Controller('shifts')
@Controller('shift')
export class ShiftController {
constructor(private readonly service: ShiftService){}
constructor(
private readonly upser_service: ShiftsUpsertService,
){}
@Delete(':shift_id')
remove(@Param('shift_id') shift_id: number): Promise<Shifts>{
return this.upser_service.deleteShift(shift_id);
}
}

View File

@ -1,87 +0,0 @@
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ShiftsCommandService } from "../services/shifts-command.service";
import { ShiftsQueryService } from "../services/shifts-query.service";
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('shifts')
export class ShiftsController {
constructor(
private readonly shiftsService: ShiftsQueryService,
private readonly shiftsCommandService: ShiftsCommandService,
){}
@Put('upsert/:email')
async upsert_by_date(
@Param('email') email_param: string,
@Query('action') action: UpsertAction,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.upsertShifts(email_param, action, payload);
}
@Delete('delete/:email/:date')
async remove(
@Param('email') email: string,
@Param('date') date: string,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.deleteShift(email, date, payload);
}
@Patch('approval/:id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
return this.shiftsCommandService.updateApproval(id, isApproved);
}
@Get('summary')
async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
return this.shiftsService.getSummary(query.period_id);
}
@Get('export.csv')
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
const rows = await this.shiftsService.getSummary(query.period_id);
//CSV Headers
const header = [
'full_name',
'supervisor',
'total_regular_hrs',
'total_evening_hrs',
'total_overtime_hrs',
'total_expenses',
'total_mileage',
'is_validated'
].join(',') + '\n';
//CSV rows
const body = rows.map(r => {
const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
return [
esc(r.full_name),
esc(r.supervisor),
r.total_regular_hrs.toFixed(2),
r.total_evening_hrs.toFixed(2),
r.total_overtime_hrs.toFixed(2),
r.total_expenses.toFixed(2),
r.total_mileage.toFixed(2),
r.is_approved,
].join(',');
}).join('\n');
return Buffer.from('\uFEFF' + header + body, 'utf8');
}
}

View File

@ -1,22 +0,0 @@
//newer version that uses Express session data
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
export class createShift {
timesheet_id: number;
bank_code_id: number;
date: string;
start_time: string;
end_time: string;
@IsBoolean()
is_remote: boolean;
@IsBoolean()
is_approved: boolean;
@IsOptional()
@IsString()
@MaxLength(280)
comment?: string;
}

View File

@ -1,10 +0,0 @@
import { Type } from "class-transformer";
import { IsInt, Min, Max } from "class-validator";
export class GetShiftsOverviewDto {
@Type(()=> Number)
@IsInt()
@Min(1)
@Max(26)
period_id: number;
}

View File

@ -1,14 +1,12 @@
//newer version that uses Express session data
export class getShift {
shift_id?: number;
timesheet_id?: number;
bank_code_id?: number;
date?: string;
start_time?: string;
end_time?: string;
is_remote?: boolean;
is_approved?: boolean;
export class GetShiftDto {
timesheet_id: number;
bank_code_id: number;
date: string;
start_time: string;
end_time: string;
is_remote: boolean;
is_approved: boolean;
comment?: string;
}
}

View File

@ -0,0 +1,17 @@
//newer version that uses Express session data
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
export class ShiftDto {
@IsInt() timesheet_id!: number;
@IsInt() bank_code_id!: number;
@IsString() date!: string;
@IsString() start_time!: string;
@IsString() end_time!: string;
@IsBoolean() is_approved!: boolean;
@IsBoolean() is_remote!: boolean;
@IsOptional() @IsString() @MaxLength(280) comment?: string;
}

View File

@ -1,9 +1,9 @@
//newer version that uses Express session data
export class updateShift {
import { PartialType, OmitType } from "@nestjs/swagger";
import { ShiftDto } from "./shift.dto";
date!: string;
start_time!: string;
end_time!: string;
}
export class updateShiftDto extends PartialType (
//allows update using ShiftDto and preventing OmitType variables to be modified
OmitType(ShiftDto, [ 'is_approved', 'timesheet_id'] as const)
){}

View File

@ -1,43 +0,0 @@
import { Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
export const COMMENT_MAX_LENGTH = 280;
export class ShiftPayloadDto {
@Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
date!: string;
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
start_time!: string;
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
end_time!: string;
@IsString()
type!: string;
@IsBoolean()
is_remote!: boolean;
@IsBoolean()
is_approved!: boolean;
@IsOptional()
@IsString()
@MaxLength(COMMENT_MAX_LENGTH)
comment?: string;
};
export class UpsertShiftDto {
@IsOptional()
@ValidateNested()
@Type(()=> ShiftPayloadDto)
old_shift?: ShiftPayloadDto;
@IsOptional()
@ValidateNested()
@Type(()=> ShiftPayloadDto)
new_shift?: ShiftPayloadDto;
};

View File

@ -1,15 +1,3 @@
export function timeFromHHMM(hhmm: string): Date {
const [h, m] = hhmm.split(':').map(Number);
return new Date(1970, 0, 1, h, m, 0, 0);
}
export function toDateOnly(ymd: string): Date {
const y = Number(ymd.slice(0, 4));
const m = Number(ymd.slice(5, 7)) - 1;
const d = Number(ymd.slice(8, 10));
return new Date(y, m, d, 0, 0, 0, 0);
}
export function weekStartSunday(date_local: Date): Date {
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
const dow = start.getDay(); // 0 = dimanche
@ -18,8 +6,26 @@ export function weekStartSunday(date_local: Date): Date {
return start;
}
export function formatHHmm(t: Date): string {
const hh = String(t.getHours()).padStart(2, '0');
const mm = String(t.getMinutes()).padStart(2, '0');
//converts string to HHmm format
export const toStringFromHHmm = (date: Date): string => {
const hh = date.getUTCHours().toString().padStart(2, '0');
const mm = date.getUTCMinutes().toString().padStart(2, '0');
return `${hh}:${mm}`;
}
//converts string to Date format
export const toStringFromDate = (date: Date) =>
date.toISOString().slice(0,10);
//converts HHmm format to string
export const toHHmmFromString = (hhmm: string): Date => {
const [hh, mm] = hhmm.split(':').map(Number);
const date = new Date('1970-01-01T00:00:00.000Z');
date.setUTCHours(hh, mm, 0, 0);
return new Date(date);
}
//converts Date format to string
export const toDateFromString = (ymd: string): Date => {
return new Date(`${ymd}T00:00:00:000Z`);
}

View File

@ -1,138 +0,0 @@
import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
export type Tx = Prisma.TransactionClient;
export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
export class ShiftsHelpersService {
constructor(
private readonly bankTypeResolver: BankCodesResolver,
private readonly overtimeService: OvertimeService,
) { }
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
const start_of_week = weekStartSunday(date_only);
return tx.timesheets.findUnique({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
select: { id: true },
});
}
async normalizeRequired(
raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
label: 'old_shift' | 'new_shift' = 'new_shift',
): Promise<Normalized> {
if (!raw) throw new BadRequestException(`${label} is required`);
const norm = await normalizeShiftPayload(raw);
if (norm.end_time.getTime() <= norm.start_time.getTime()) {
throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
}
return norm;
}
async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
const found = await this.bankTypeResolver.findByType(type, tx);
const id = found?.id;
if (typeof id !== 'number') {
throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
}
return id;
}
async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
return tx.shifts.findMany({
where: { timesheet_id, date: date_only },
include: { bank_code: true },
orderBy: { start_time: 'asc' },
});
}
async assertNoOverlap(
day_shifts: Array<Shifts & { bank_code: { type: string } | null }>,
new_norm: Normalized | undefined,
exclude_id?: number,
) {
if (!new_norm) return;
const conflicts = day_shifts.filter((s) => {
if (exclude_id && s.id === exclude_id) return false;
return overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
s.start_time.getTime(),
s.end_time.getTime(),
);
});
if (conflicts.length) {
const payload = conflicts.map((s) => ({
start_time: formatHHmm(s.start_time),
end_time: formatHHmm(s.end_time),
type: s.bank_code?.type ?? 'UNKNOWN',
}));
throw new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts: payload,
});
}
}
async findExactOldShift(
tx: Tx,
params: {
timesheet_id: number;
date_only: Date;
norm: Normalized;
bank_code_id: number;
comment?: string;
},
) {
const { timesheet_id, date_only, norm, bank_code_id } = params;
return tx.shifts.findFirst({
where: {
timesheet_id,
date: date_only,
start_time: norm.start_time,
end_time: norm.end_time,
is_remote: norm.is_remote,
is_approved: norm.is_approved,
comment: norm.comment ?? null,
bank_code_id,
},
select: { id: true },
});
}
async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
// Switch regular → weekly overtime si > 40h
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
// const [daily, weekly] = await Promise.all([
// this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
// this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
// ]);
return { daily, weekly };
}
async mapDay(
fresh: Array<Shifts & { bank_code: { type: string } | null }>,
): Promise<DayShiftResponse[]> {
return fresh.map((s) => ({
start_time: formatHHmm(s.start_time),
end_time: formatHHmm(s.end_time),
type: s.bank_code?.type ?? 'UNKNOWN',
is_remote: s.is_remote,
comment: s.comment ?? null,
}));
}
}

View File

@ -1,9 +0,0 @@
//newer version that uses Express session data
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ShiftService {
constructor(private readonly prisma: PrismaService){}
}

View File

@ -1,194 +0,0 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { toDateOnly } from "../helpers/shifts-date-time-helpers";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
import { ShiftsHelpersService } from "../helpers/shifts.helpers";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
private readonly logger = new Logger(ShiftsCommandService.name);
constructor(
prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
private readonly helpersService: ShiftsHelpersService,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.shifts;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.shifts;
}
async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, is_approved),
);
}
//TODO: modifier le Master Crud pour recevoir l'ensemble des shifts de la pay-period et trier sur l'action 'create'| 'update' | 'delete'
//_____________________________________________________________________________________________
// MASTER CRUD METHOD
//_____________________________________________________________________________________________
async upsertShifts(
email: string,
action: UpsertAction,
dto: UpsertShiftDto,
): Promise<{
action: UpsertAction;
day: DayShiftResponse[];
}> {
if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
const date = dto.new_shift?.date ?? dto.old_shift?.date;
if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
throw new BadRequestException('old_shift.date and new_shift.date must be identical');
}
const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
if(action === 'create') {
if(!dto.new_shift || dto.old_shift) {
throw new BadRequestException(`Only new_shift must be provided for create`);
}
return this.createShift(employee_id, date, dto);
}
if(action === 'update'){
if(!dto.old_shift || !dto.new_shift) {
throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
}
return this.updateShift(employee_id, date, dto);
}
throw new BadRequestException(`Unknown action: ${action}`);
}
//_________________________________________________________________
// CREATE
//_________________________________________________________________
private async createShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
await tx.shifts.create({
data: {
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
is_approved: new_norm_shift.is_approved,
comment: new_norm_shift.comment ?? null,
bank_code_id: new_bank_code_id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
});
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
private async updateShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id: timesheet.id,
date_only,
norm: old_norm_shift,
bank_code_id: old_bank_code.id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
await tx.shifts.update({
where: { id: existing.id },
data: {
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
comment: new_norm_shift.comment ?? null,
bank_code_id: new_bank_code.id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
});
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
async deleteShift(
email: string,
date_iso: string,
dto: UpsertShiftDto,
){
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso); //converts to Date format
const employee_id = await this.emailResolver.findIdByEmail(email);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id: timesheet.id,
date_only,
norm: norm_shift,
bank_code_id: bank_code_id.id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
await tx.shifts.delete({ where: { id: existing.id } });
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
});
}
}

View File

@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { GetShiftDto } from "../dtos/get-shift.dto";
import { toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers";
import { Shifts } from "@prisma/client";
@Injectable()
export class ShiftsGetService {
constructor(
private readonly prisma: PrismaService,
){}
//fetch a shift using shift_id and return all that shift's info
async getShiftByShiftId(shift_id: number): Promise<GetShiftDto> {
const shift = await this.prisma.shifts.findUnique({
where: { id: shift_id },
select: {
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
is_approved: true,
comment: true,
}
});
if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`);
return {
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
date: toStringFromDate(shift.date),
start_time: toStringFromHHmm(shift.start_time),
end_time: toStringFromHHmm(shift.end_time),
is_remote: shift.is_remote,
is_approved: shift.is_approved,
comment: shift.comment ?? undefined,
};
}
//finds all shifts in a single day to return an array of shifts
async loadShiftsFromSameDay( timesheet_id: number, date_only: Date,
): Promise<Array<Shifts & { bank_code: { type: string } | null }>> {
return this.prisma.shifts.findMany({
where: { timesheet_id, date: date_only },
include: { bank_code: { select: { type: true } } },
});
}
}

View File

@ -1,114 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
import { computeHours } from "src/common/utils/date-utils";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
@Injectable()
export class ShiftsQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly notifs: NotificationsService,
) {}
async getSummary(period_id: number): Promise<OverviewRow[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_id },
});
if(!period) {
throw new NotFoundException(`pay-period ${period_id} not found`);
}
const { period_start, period_end } = period;
//prepare shifts and expenses for display
const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: {
employee: { include: {
user:true,
supervisor: { include: { user: true } },
} },
} },
},
});
const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: { employee: {
include: { user:true,
supervisor: { include: { user:true } },
} },
} },
},
});
const mapRow = new Map<string, OverviewRow>();
for(const shift of shifts) {
const employeeId = shift.timesheet.employee.user_id;
const user = shift.timesheet.employee.user;
const sup = shift.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
full_name: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
total_regular_hrs: 0,
total_evening_hrs: 0,
total_overtime_hrs: 0,
total_expenses: 0,
total_mileage: 0,
is_approved: false,
};
}
const hours = computeHours(shift.start_time, shift.end_time);
switch(shift.bank_code.type) {
case 'regular' : row.total_regular_hrs += hours;
break;
case 'evening' : row.total_evening_hrs += hours;
break;
case 'overtime' : row.total_overtime_hrs += hours;
break;
default: row.total_regular_hrs += hours;
}
mapRow.set(employeeId, row);
}
for(const exp of expenses) {
const employee_id = exp.timesheet.employee.user_id;
const user = exp.timesheet.employee.user;
const sup = exp.timesheet.employee.supervisor?.user;
let row = mapRow.get(employee_id);
if(!row) {
row = {
full_name: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
total_regular_hrs: 0,
total_evening_hrs: 0,
total_overtime_hrs: 0,
total_expenses: 0,
total_mileage: 0,
is_approved: false,
};
}
const amount = Number(exp.amount);
row.total_expenses += amount;
if(exp.bank_code.type === 'mileage') {
row.total_mileage += amount;
}
mapRow.set(employee_id, row);
}
//return by default the list of employee in ascending alphabetical order
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
}
}

View File

@ -0,0 +1,208 @@
import { toDateFromString, toHHmmFromString, toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers";
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { ShiftsGetService } from "./shifts-get.service";
import { updateShiftDto } from "../dtos/update-shift.dto";
import { PrismaService } from "src/prisma/prisma.service";
import { GetShiftDto } from "../dtos/get-shift.dto";
import { ShiftDto } from "../dtos/shift.dto";
import { Shifts } from "@prisma/client";
type Normalized = { date: Date; start_time: Date; end_time: Date; };
@Injectable()
export class ShiftsUpsertService {
constructor(
private readonly prisma: PrismaService,
private readonly getService: ShiftsGetService,
){}
//converts all string hours and date to Date and HHmm formats
private normalizeShiftDto = (dto: ShiftDto): Normalized => {
const date = toDateFromString(dto.date);
const start_time = toHHmmFromString(dto.start_time);
const end_time = toHHmmFromString(dto.end_time);
return { date, start_time, end_time };
}
// used to compare shifts and detect overlaps between them
private overlaps = (
a_start: number,
a_end: number,
b_start: number,
b_end: number,
) => a_start < b_end && b_start < a_end;
//checked if a new shift overlaps already existing shifts
private assertNoOverlap = async (
day_shifts: Array<Shifts & { bank_code: { type: string } | null }>,
new_norm: Normalized | undefined,
exclude_id?: number,
) => {
if (!new_norm) return;
const conflicts = day_shifts.filter((shift) => {
if (exclude_id && shift.id === exclude_id) return false;
return this.overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
shift.start_time.getTime(),
shift.end_time.getTime(),
);
});
if (conflicts.length) {
const payload = conflicts.map((shift) => ({
start_time: toStringFromHHmm(shift.start_time),
end_time: toStringFromHHmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
}));
throw new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts: payload,
});
}
}
//normalized frontend data to match DB
//loads all shift from a selected day to check for overlaping shifts
//checks for overlaping shifts
//create a new shifts
//return an object of type GetShiftDto for the frontend to display
async createShift(timesheet_id: number, dto: ShiftDto): Promise<GetShiftDto> {
const normed_shift = await this.normalizeShiftDto(dto);
if(normed_shift.end_time <= normed_shift.start_time){
throw new BadRequestException('end_time must be greater than start_time')
}
//call to a function to load all shifts contain in single day
const day_shifts = await this.getService.loadShiftsFromSameDay(timesheet_id, normed_shift.date);
//call to a function to detect overlaps between shifts
await this.assertNoOverlap( day_shifts, normed_shift )
//create the shift with normalized date and times
const shift = await this.prisma.shifts.create({
data: {
timesheet_id,
bank_code_id: dto.bank_code_id,
date: normed_shift.date,
start_time: normed_shift.start_time,
end_time: normed_shift.end_time,
is_remote: dto.is_remote,
comment: dto.comment ?? undefined,
},
select: {
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
comment: true,
},
});
if(!shift) throw new BadRequestException(`a shift cannot be created, missing value(s).`);
return {
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
date: toStringFromDate(shift.date),
start_time: toStringFromHHmm(shift.start_time),
end_time: toStringFromHHmm(shift.end_time),
is_remote: shift.is_remote,
is_approved: false,
comment: shift.comment ?? undefined,
};
}
//finds existing shift in DB
//verify if shift is already approved
//normalized Date and Time format to string
//check for valid start and end times
//check for overlaping possibility
//buil a set of data to manipulate modified data only
//update shift in DB and return an updated version to display
async updateShift(shift_id: number, dto: updateShiftDto): Promise<GetShiftDto> {
//search for original shift using shift_id
const existing = await this.prisma.shifts.findUnique({
where: { id: shift_id },
select: {
id: true,
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
is_approved: true,
comment: true,
},
});
if(!existing) throw new NotFoundException(`Shift with id: ${shift_id} not found`);
if(existing.is_approved) throw new BadRequestException('Approved shift cannot be updated');
const date_string = dto.date ?? toStringFromDate(existing.date);
const start_string = dto.start_time ?? toStringFromHHmm(existing.start_time);
const end_string = dto.end_time ?? toStringFromHHmm(existing.end_time);
const norm: Normalized = {
date: toDateFromString(date_string),
start_time: toHHmmFromString(start_string),
end_time: toHHmmFromString(end_string),
};
if(norm.end_time <= norm.start_time) throw new BadRequestException('end time must be greater than start time');
//call to a function to detect overlaps between shifts
const day_shifts = await this.getService.loadShiftsFromSameDay(existing.timesheet_id, norm.date);
//call to a function to detect overlaps between shifts
await this.assertNoOverlap(day_shifts, norm, shift_id);
//partial build, update only modified datas
const data: any = {};
if(dto.date !== undefined) data.date = norm.date;
if(dto.start_time !== undefined) data.start_time = norm.start_time;
if(dto.end_time !== undefined) data.end_time = norm.end_time;
if(dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id;
if(dto.is_remote !== undefined) data.is_remote = dto.is_remote;
if(dto.comment !== undefined) data.comment = dto.comment ?? null;
//sends updated data to DB
const updated_shift = await this.prisma.shifts.update({
where: { id: shift_id },
data,
select: {
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
is_approved: true,
comment: true,
},
});
//returns updated shift to frontend
return {
timesheet_id: updated_shift.timesheet_id,
bank_code_id: updated_shift.bank_code_id,
date: toStringFromDate(updated_shift.date),
start_time: toStringFromHHmm(updated_shift.start_time),
end_time: toStringFromHHmm(updated_shift.end_time),
is_approved: updated_shift.is_approved,
is_remote: updated_shift.is_remote,
comment: updated_shift.comment ?? undefined,
};
}
async deleteShift(shift_id: number) {
const shift = await this.prisma.shifts.findUnique({
where: { id: shift_id },
select: { id: true },
});
if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`);
return this.prisma.shifts.delete({
where: { id: shift.id }
});
}
}

View File

@ -1,30 +1,24 @@
import { Module } from '@nestjs/common';
import { ShiftsController } from './controllers/shifts.controller';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { ShiftsCommandService } from './services/shifts-command.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { ShiftsQueryService } from './services/shifts-query.service';
import { ShiftsArchivalService } from './services/shifts-archival.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { ShiftsUpsertService } from './services/shifts-upsert.service';
import { ShiftsGetService } from './services/shifts-get.service';
import { ShiftController } from './controllers/shift.controller';
import { SharedModule } from '../shared/shared.module';
import { ShiftsHelpersService } from './helpers/shifts.helpers';
import { Module } from '@nestjs/common';
@Module({
imports: [
BusinessLogicsModule,
NotificationsModule,
BusinessLogicsModule,
NotificationsModule,
SharedModule,
],
controllers: [ShiftsController],
providers: [
ShiftsQueryService,
ShiftsCommandService,
controllers: [ShiftController],
providers: [
ShiftsArchivalService,
ShiftsHelpersService,
],
exports: [
ShiftsQueryService,
ShiftsCommandService,
ShiftsArchivalService,
ShiftsGetService,
ShiftsUpsertService,
],
exports: [ ShiftsUpsertService, ShiftsGetService ],
})
export class ShiftsModule {}

View File

@ -1,10 +0,0 @@
export interface OverviewRow {
full_name: string;
supervisor: string;
total_regular_hrs: number;
total_evening_hrs: number;
total_overtime_hrs: number;
total_expenses: number;
total_mileage: number;
is_approved: boolean;
}

View File

@ -1,17 +0,0 @@
export type DayShiftResponse = {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment: string | null;
}
export type ShiftPayload = {
date: string;
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
is_approved: boolean;
comment?: string | null;
}

View File

@ -1,58 +0,0 @@
import { NotFoundException } from "@nestjs/common";
export function overlaps(
a_start_ms: number,
a_end_ms: number,
b_start_ms: number,
b_end_ms: number,
): boolean {
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
}
export function resolveBankCodeByType(type: string): Promise<number> {
const bank = this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true },
});
if (!bank) {
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
}
return bank.id;
}
export function normalizeShiftPayload(payload: {
date: string,
start_time: string,
end_time: string,
type: string,
is_remote: boolean,
is_approved: boolean,
comment?: string | null,
}) {
//normalize shift's infos
const date = payload.date?.trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
const asLocalDateOn = (input: string): Date => {
// HH:mm ?
const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0);
const iso = new Date(input);
if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds());
};
const start_time = asLocalDateOn(payload.start_time);
const end_time = asLocalDateOn(payload.end_time);
const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote;
const is_approved = payload.is_approved;
//normalize comment
const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed : null;
return { date, start_time, end_time, type, is_remote, is_approved, comment };
}

View File

@ -0,0 +1,10 @@
// import { Type } from "class-transformer";
// import { IsInt, Min, Max } from "class-validator";
// export class GetShiftsOverviewDto {
// @Type(()=> Number)
// @IsInt()
// @Min(1)
// @Max(26)
// period_id: number;
// }

View File

@ -0,0 +1,194 @@
// import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
// import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { Prisma, Shifts } from "@prisma/client";
// import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
// import { PrismaService } from "src/prisma/prisma.service";
// import { toDateOnly } from "../helpers/shifts-date-time-helpers";
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
// import { ShiftsHelpersService } from "../helpers/shifts.helpers";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// @Injectable()
// export class ShiftsCommandService extends BaseApprovalService<Shifts> {
// private readonly logger = new Logger(ShiftsCommandService.name);
// constructor(
// prisma: PrismaService,
// private readonly emailResolver: EmailToIdResolver,
// private readonly typeResolver: BankCodesResolver,
// private readonly helpersService: ShiftsHelpersService,
// ) { super(prisma); }
// //_____________________________________________________________________________________________
// // APPROVAL AND DELEGATE METHODS
// //_____________________________________________________________________________________________
// protected get delegate() {
// return this.prisma.shifts;
// }
// protected delegateFor(transaction: Prisma.TransactionClient) {
// return transaction.shifts;
// }
// async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
// return this.prisma.$transaction((transaction) =>
// this.updateApprovalWithTransaction(transaction, id, is_approved),
// );
// }
// //TODO: modifier le Master Crud pour recevoir l'ensemble des shifts de la pay-period et trier sur l'action 'create'| 'update' | 'delete'
// //_____________________________________________________________________________________________
// // MASTER CRUD METHOD
// //_____________________________________________________________________________________________
// async upsertShifts(
// email: string,
// action: UpsertAction,
// dto: UpsertShiftDto,
// ): Promise<{
// action: UpsertAction;
// day: DayShiftResponse[];
// }> {
// if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
// const date = dto.new_shift?.date ?? dto.old_shift?.date;
// if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
// if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
// throw new BadRequestException('old_shift.date and new_shift.date must be identical');
// }
// const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
// if(action === 'create') {
// if(!dto.new_shift || dto.old_shift) {
// throw new BadRequestException(`Only new_shift must be provided for create`);
// }
// return this.createShift(employee_id, date, dto);
// }
// if(action === 'update'){
// if(!dto.old_shift || !dto.new_shift) {
// throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
// }
// return this.updateShift(employee_id, date, dto);
// }
// throw new BadRequestException(`Unknown action: ${action}`);
// }
// //_________________________________________________________________
// // CREATE
// //_________________________________________________________________
// private async createShift(
// employee_id: number,
// date_iso: string,
// dto: UpsertShiftDto,
// ): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
// return this.prisma.$transaction(async (tx) => {
// const date_only = toDateOnly(date_iso);
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
// if(!timesheet) throw new NotFoundException('Timesheet not found')
// const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
// const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
// const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
// await tx.shifts.create({
// data: {
// timesheet_id: timesheet.id,
// date: date_only,
// start_time: new_norm_shift.start_time,
// end_time: new_norm_shift.end_time,
// is_remote: new_norm_shift.is_remote,
// is_approved: new_norm_shift.is_approved,
// comment: new_norm_shift.comment ?? null,
// bank_code_id: new_bank_code_id,
// },
// });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
// const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
// });
// }
// //_________________________________________________________________
// // UPDATE
// //_________________________________________________________________
// private async updateShift(
// employee_id: number,
// date_iso: string,
// dto: UpsertShiftDto,
// ): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
// return this.prisma.$transaction(async (tx) => {
// const date_only = toDateOnly(date_iso);
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
// if(!timesheet) throw new NotFoundException('Timesheet not found')
// const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
// const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
// const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
// const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
// const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// const existing = await this.helpersService.findExactOldShift(tx, {
// timesheet_id: timesheet.id,
// date_only,
// norm: old_norm_shift,
// bank_code_id: old_bank_code.id,
// });
// if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
// await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
// await tx.shifts.update({
// where: { id: existing.id },
// data: {
// start_time: new_norm_shift.start_time,
// end_time: new_norm_shift.end_time,
// is_remote: new_norm_shift.is_remote,
// comment: new_norm_shift.comment ?? null,
// bank_code_id: new_bank_code.id,
// },
// });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
// const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
// });
// }
// //_________________________________________________________________
// // DELETE
// //_________________________________________________________________
// async deleteShift(
// email: string,
// date_iso: string,
// dto: UpsertShiftDto,
// ){
// return this.prisma.$transaction(async (tx) => {
// const date_only = toDateOnly(date_iso); //converts to Date format
// const employee_id = await this.emailResolver.findIdByEmail(email);
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
// if(!timesheet) throw new NotFoundException('Timesheet not found')
// const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
// const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
// const existing = await this.helpersService.findExactOldShift(tx, {
// timesheet_id: timesheet.id,
// date_only,
// norm: norm_shift,
// bank_code_id: bank_code_id.id,
// });
// if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
// await tx.shifts.delete({ where: { id: existing.id } });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
// });
// }
// }

View File

@ -0,0 +1,10 @@
// export interface OverviewRow {
// full_name: string;
// supervisor: string;
// total_regular_hrs: number;
// total_evening_hrs: number;
// total_overtime_hrs: number;
// total_expenses: number;
// total_mileage: number;
// is_approved: boolean;
// }

View File

@ -0,0 +1,114 @@
// import { Injectable, NotFoundException } from "@nestjs/common";
// import { PrismaService } from "src/prisma/prisma.service";
// import { NotificationsService } from "src/modules/notifications/services/notifications.service";
// import { computeHours } from "src/common/utils/date-utils";
// import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// // const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
// @Injectable()
// export class ShiftsQueryService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly notifs: NotificationsService,
// ) {}
// async getSummary(period_id: number): Promise<OverviewRow[]> {
// //fetch pay-period to display
// const period = await this.prisma.payPeriods.findFirst({
// where: { pay_period_no: period_id },
// });
// if(!period) {
// throw new NotFoundException(`pay-period ${period_id} not found`);
// }
// const { period_start, period_end } = period;
// //prepare shifts and expenses for display
// const shifts = await this.prisma.shifts.findMany({
// where: { date: { gte: period_start, lte: period_end } },
// include: {
// bank_code: true,
// timesheet: { include: {
// employee: { include: {
// user:true,
// supervisor: { include: { user: true } },
// } },
// } },
// },
// });
// const expenses = await this.prisma.expenses.findMany({
// where: { date: { gte: period_start, lte: period_end } },
// include: {
// bank_code: true,
// timesheet: { include: { employee: {
// include: { user:true,
// supervisor: { include: { user:true } },
// } },
// } },
// },
// });
// const mapRow = new Map<string, OverviewRow>();
// for(const shift of shifts) {
// const employeeId = shift.timesheet.employee.user_id;
// const user = shift.timesheet.employee.user;
// const sup = shift.timesheet.employee.supervisor?.user;
// let row = mapRow.get(employeeId);
// if(!row) {
// row = {
// full_name: `${user.first_name} ${user.last_name}`,
// supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
// total_regular_hrs: 0,
// total_evening_hrs: 0,
// total_overtime_hrs: 0,
// total_expenses: 0,
// total_mileage: 0,
// is_approved: false,
// };
// }
// const hours = computeHours(shift.start_time, shift.end_time);
// switch(shift.bank_code.type) {
// case 'regular' : row.total_regular_hrs += hours;
// break;
// case 'evening' : row.total_evening_hrs += hours;
// break;
// case 'overtime' : row.total_overtime_hrs += hours;
// break;
// default: row.total_regular_hrs += hours;
// }
// mapRow.set(employeeId, row);
// }
// for(const exp of expenses) {
// const employee_id = exp.timesheet.employee.user_id;
// const user = exp.timesheet.employee.user;
// const sup = exp.timesheet.employee.supervisor?.user;
// let row = mapRow.get(employee_id);
// if(!row) {
// row = {
// full_name: `${user.first_name} ${user.last_name}`,
// supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
// total_regular_hrs: 0,
// total_evening_hrs: 0,
// total_overtime_hrs: 0,
// total_expenses: 0,
// total_mileage: 0,
// is_approved: false,
// };
// }
// const amount = Number(exp.amount);
// row.total_expenses += amount;
// if(exp.bank_code.type === 'mileage') {
// row.total_mileage += amount;
// }
// mapRow.set(employee_id, row);
// }
// //return by default the list of employee in ascending alphabetical order
// return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
// }
// }

View File

@ -0,0 +1,17 @@
// export type DayShiftResponse = {
// start_time: string;
// end_time: string;
// type: string;
// is_remote: boolean;
// comment: string | null;
// }
// export type ShiftPayload = {
// date: string;
// start_time: string;
// end_time: string;
// type: string;
// is_remote: boolean;
// is_approved: boolean;
// comment?: string | null;
// }

View File

@ -0,0 +1,87 @@
// import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
// import { Roles as RoleEnum } from '.prisma/client';
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
// import { ShiftsCommandService } from "../services/shifts-command.service";
// import { ShiftsQueryService } from "../services/shifts-query.service";
// import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
// import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
// import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
// @ApiTags('Shifts')
// @ApiBearerAuth('access-token')
// // @UseGuards()
// @Controller('shifts')
// export class ShiftsController {
// constructor(
// private readonly shiftsService: ShiftsQueryService,
// private readonly shiftsCommandService: ShiftsCommandService,
// ){}
// @Put('upsert/:email')
// async upsert_by_date(
// @Param('email') email_param: string,
// @Query('action') action: UpsertAction,
// @Body() payload: UpsertShiftDto,
// ) {
// return this.shiftsCommandService.upsertShifts(email_param, action, payload);
// }
// @Delete('delete/:email/:date')
// async remove(
// @Param('email') email: string,
// @Param('date') date: string,
// @Body() payload: UpsertShiftDto,
// ) {
// return this.shiftsCommandService.deleteShift(email, date, payload);
// }
// @Patch('approval/:id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
// return this.shiftsCommandService.updateApproval(id, isApproved);
// }
// @Get('summary')
// async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
// return this.shiftsService.getSummary(query.period_id);
// }
// @Get('export.csv')
// @Header('Content-Type', 'text/csv; charset=utf-8')
// @Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
// async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
// const rows = await this.shiftsService.getSummary(query.period_id);
// //CSV Headers
// const header = [
// 'full_name',
// 'supervisor',
// 'total_regular_hrs',
// 'total_evening_hrs',
// 'total_overtime_hrs',
// 'total_expenses',
// 'total_mileage',
// 'is_validated'
// ].join(',') + '\n';
// //CSV rows
// const body = rows.map(r => {
// const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
// return [
// esc(r.full_name),
// esc(r.supervisor),
// r.total_regular_hrs.toFixed(2),
// r.total_evening_hrs.toFixed(2),
// r.total_overtime_hrs.toFixed(2),
// r.total_expenses.toFixed(2),
// r.total_mileage.toFixed(2),
// r.is_approved,
// ].join(',');
// }).join('\n');
// return Buffer.from('\uFEFF' + header + body, 'utf8');
// }
// }

View File

@ -0,0 +1,107 @@
// import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
// import { Prisma, Shifts } from "@prisma/client";
// import { UpsertShiftDto } from "../deprecated-files/upsert-shift.dto";
// import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
// import { normalizeShiftPayload } from "../utils/shifts.utils";
// import { toStringFromHHmm, weekStartSunday } from "./shifts-date-time-helpers";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
// export type Tx = Prisma.TransactionClient;
// export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
// export class ShiftsHelpersService {
// constructor(
// private readonly bankTypeResolver: BankCodesResolver,
// private readonly overtimeService: OvertimeService,
// ) { }
// async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
// const start_of_week = weekStartSunday(date_only);
// return tx.timesheets.findUnique({
// where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
// select: { id: true },
// });
// }
// async normalizeRequired(
// raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
// label: 'old_shift' | 'new_shift' = 'new_shift',
// ): Promise<Normalized> {
// if (!raw) throw new BadRequestException(`${label} is required`);
// const norm = await normalizeShiftPayload(raw);
// if (norm.end_time.getTime() <= norm.start_time.getTime()) {
// throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
// }
// return norm;
// }
// async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
// const found = await this.bankTypeResolver.findByType(type, tx);
// const id = found?.id;
// if (typeof id !== 'number') {
// throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
// }
// return id;
// }
// async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
// return tx.shifts.findMany({
// where: { timesheet_id, date: date_only },
// include: { bank_code: true },
// orderBy: { start_time: 'asc' },
// });
// }
// async findExactOldShift(
// tx: Tx,
// params: {
// timesheet_id: number;
// date_only: Date;
// norm: Normalized;
// bank_code_id: number;
// comment?: string;
// },
// ) {
// const { timesheet_id, date_only, norm, bank_code_id } = params;
// return tx.shifts.findFirst({
// where: {
// timesheet_id,
// date: date_only,
// start_time: norm.start_time,
// end_time: norm.end_time,
// is_remote: norm.is_remote,
// is_approved: norm.is_approved,
// comment: norm.comment ?? null,
// bank_code_id,
// },
// select: { id: true },
// });
// }
// async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
// // Switch regular → weekly overtime si > 40h
// await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
// const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
// const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
// // const [daily, weekly] = await Promise.all([
// // this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
// // this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
// // ]);
// return { daily, weekly };
// }
// async mapDay(
// fresh: Array<Shifts & { bank_code: { type: string } | null }>,
// ): Promise<DayShiftResponse[]> {
// return fresh.map((s) => ({
// start_time: toStringFromHHmm(s.start_time),
// end_time: toStringFromHHmm(s.end_time),
// type: s.bank_code?.type ?? 'UNKNOWN',
// is_remote: s.is_remote,
// comment: s.comment ?? null,
// }));
// }
// }

View File

@ -0,0 +1,58 @@
// import { NotFoundException } from "@nestjs/common";
// export function overlaps(
// a_start_ms: number,
// a_end_ms: number,
// b_start_ms: number,
// b_end_ms: number,
// ): boolean {
// return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
// }
// export function resolveBankCodeByType(type: string): Promise<number> {
// const bank = this.prisma.bankCodes.findFirst({
// where: { type },
// select: { id: true },
// });
// if (!bank) {
// throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
// }
// return bank.id;
// }
// export function normalizeShiftPayload(payload: {
// date: string,
// start_time: string,
// end_time: string,
// type: string,
// is_remote: boolean,
// is_approved: boolean,
// comment?: string | null,
// }) {
// //normalize shift's infos
// const date = payload.date?.trim();
// const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
// if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
// const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
// const asLocalDateOn = (input: string): Date => {
// // HH:mm ?
// const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
// if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0);
// const iso = new Date(input);
// if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
// return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds());
// };
// const start_time = asLocalDateOn(payload.start_time);
// const end_time = asLocalDateOn(payload.end_time);
// const type = (payload.type || '').trim().toUpperCase();
// const is_remote = payload.is_remote;
// const is_approved = payload.is_approved;
// //normalize comment
// const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
// const comment = trimmed && trimmed.length > 0 ? trimmed : null;
// return { date, start_time, end_time, type, is_remote, is_approved, comment };
// }

View File

@ -0,0 +1,43 @@
// import { Type } from "class-transformer";
// import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
// export const COMMENT_MAX_LENGTH = 280;
// export class ShiftPayloadDto {
// @Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
// date!: string;
// @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
// start_time!: string;
// @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
// end_time!: string;
// @IsString()
// type!: string;
// @IsBoolean()
// is_remote!: boolean;
// @IsBoolean()
// is_approved!: boolean;
// @IsOptional()
// @IsString()
// @MaxLength(COMMENT_MAX_LENGTH)
// comment?: string;
// };
// export class UpsertShiftDto {
// @IsOptional()
// @ValidateNested()
// @Type(()=> ShiftPayloadDto)
// old_shift?: ShiftPayloadDto;
// @IsOptional()
// @ValidateNested()
// @Type(()=> ShiftPayloadDto)
// new_shift?: ShiftPayloadDto;
// };

View File

@ -2,13 +2,13 @@ import { TimesheetsController } from './controllers/timesheets.controller';
import { TimesheetsQueryService } from './services/timesheets-query.service';
import { TimesheetArchiveService } from './services/timesheet-archive.service';
import { TimesheetsCommandService } from './services/timesheets-command.service';
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
import { ShiftsCommandService } from '../shifts/_deprecated-files/shifts-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { SharedModule } from '../shared/shared.module';
import { Module } from '@nestjs/common';
import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers';
import { ShiftsHelpersService } from '../shifts/_deprecated-files/shifts.helpers';
@Module({
imports: [