Prepojenie na externu DB, projekt-zakazky

This commit is contained in:
2026-02-03 11:20:17 +01:00
parent e4f63a135e
commit cbdd952bc1
37 changed files with 2641 additions and 149 deletions

View File

@@ -17,3 +17,11 @@ FRONTEND_URL="http://localhost:5173"
# File Upload
UPLOAD_DIR="./uploads"
MAX_FILE_SIZE=10485760
# External Database (zakazky) - READ ONLY
# Leave empty to disable external zakazky feature
EXTERNAL_DB_HOST=""
EXTERNAL_DB_PORT=5432
EXTERNAL_DB_NAME=""
EXTERNAL_DB_USER=""
EXTERNAL_DB_PASSWORD=""

View File

@@ -17,6 +17,8 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"pg": "^8.18.0",
"prisma": "^5.22.0",
"uuid": "^13.0.0",
"zod": "^4.3.6"
@@ -27,7 +29,9 @@
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/multer": "^2.0.0",
"@types/node": "^25.2.0",
"@types/pg": "^8.16.0",
"@types/uuid": "^10.0.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
@@ -264,6 +268,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "25.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
@@ -274,6 +288,18 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -383,6 +409,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -495,9 +527,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -543,6 +585,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -1285,7 +1342,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -1353,6 +1409,79 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -1459,6 +1588,95 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1472,6 +1690,45 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
@@ -1543,6 +1800,20 @@
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -1776,6 +2047,15 @@
"source-map": "^0.6.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1785,6 +2065,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -1994,6 +2291,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2024,6 +2327,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
@@ -2063,7 +2372,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"

View File

@@ -34,6 +34,8 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"pg": "^8.18.0",
"prisma": "^5.22.0",
"uuid": "^13.0.0",
"zod": "^4.3.6"
@@ -44,7 +46,9 @@
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/multer": "^2.0.0",
"@types/node": "^25.2.0",
"@types/pg": "^8.16.0",
"@types/uuid": "^10.0.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",

View File

@@ -64,6 +64,7 @@ model User {
rmaStatusChanges RMAStatusHistory[]
rmaComments RMAComment[]
taskComments Comment[]
taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader")
createdCustomers Customer[]
@@ -377,6 +378,7 @@ model Task {
reminders Reminder[]
comments Comment[]
tags TaskTag[]
attachments TaskAttachment[]
@@index([projectId])
@@index([parentId])
@@ -410,6 +412,24 @@ model TaskTag {
@@id([taskId, tagId])
}
model TaskAttachment {
id String @id @default(cuid())
taskId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
filename String
filepath String
mimetype String
size Int
uploadedById String
uploadedBy User @relation("TaskAttachmentUploader", fields: [uploadedById], references: [id])
uploadedAt DateTime @default(now())
@@index([taskId])
}
model Reminder {
id String @id @default(cuid())
taskId String

View File

@@ -23,6 +23,13 @@ export const env = {
UPLOAD_DIR: process.env.UPLOAD_DIR || './uploads',
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10),
// External Database (zakazky)
EXTERNAL_DB_HOST: process.env.EXTERNAL_DB_HOST || '',
EXTERNAL_DB_PORT: parseInt(process.env.EXTERNAL_DB_PORT || '5432', 10),
EXTERNAL_DB_NAME: process.env.EXTERNAL_DB_NAME || '',
EXTERNAL_DB_USER: process.env.EXTERNAL_DB_USER || '',
EXTERNAL_DB_PASSWORD: process.env.EXTERNAL_DB_PASSWORD || '',
// Helpers
isDev: process.env.NODE_ENV === 'development',
isProd: process.env.NODE_ENV === 'production',

View File

@@ -2,6 +2,7 @@ import { Response } from 'express';
import prisma from '../config/database';
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import { movePendingFilesToEntity } from './upload.controller';
export const getEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
try {
@@ -112,6 +113,11 @@ export const createEquipment = async (req: AuthRequest, res: Response): Promise<
},
});
// Move pending files if tempId provided
if (req.body.tempId) {
await movePendingFilesToEntity(req.body.tempId, 'equipment', equipment.id);
}
if (req.logActivity) {
await req.logActivity('CREATE', 'Equipment', equipment.id, { name: equipment.name });
}

View File

@@ -3,6 +3,7 @@ import prisma from '../config/database';
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import { configService } from '../services/config.service';
import { movePendingFilesToEntity } from './upload.controller';
async function generateRMANumber(): Promise<string> {
const today = new Date();
@@ -192,6 +193,11 @@ export const createRMA = async (req: AuthRequest, res: Response): Promise<void>
},
});
// Move pending files if tempId provided
if (req.body.tempId) {
await movePendingFilesToEntity(req.body.tempId, 'rma', rma.id);
}
if (req.logActivity) {
await req.logActivity('CREATE', 'RMA', rma.id, { rmaNumber });
}

View File

@@ -3,6 +3,7 @@ import prisma from '../config/database';
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import { configService } from '../services/config.service';
import { movePendingFilesToEntity } from './upload.controller';
export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => {
try {
@@ -168,6 +169,11 @@ export const createTask = async (req: AuthRequest, res: Response): Promise<void>
});
}
// Move pending files if tempId provided
if (req.body.tempId) {
await movePendingFilesToEntity(req.body.tempId, 'task', task.id);
}
if (req.logActivity) {
await req.logActivity('CREATE', 'Task', task.id, { title: task.title });
}

View File

@@ -0,0 +1,586 @@
import { Response } from 'express';
import fs from 'fs';
import path from 'path';
import prisma from '../config/database';
import { successResponse, errorResponse, getParam } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import { getFileUrl, getFilePath } from '../middleware/upload.middleware';
import { env } from '../config/env';
// Pending file info stored in memory (for simplicity - in production use Redis)
interface PendingFile {
tempFilename: string;
originalFilename: string;
mimetype: string;
size: number;
uploadedById: string;
uploadedAt: Date;
}
const pendingFiles = new Map<string, PendingFile[]>();
// Helper to get pending directory
const getPendingDir = () => path.join(env.UPLOAD_DIR || 'uploads', 'pending');
// Pending uploads - for new entities that don't have ID yet
export const uploadPendingFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const tempId = getParam(req, 'tempId');
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
return;
}
// Get existing pending files for this tempId
const existing = pendingFiles.get(tempId) || [];
// Add new files to pending
const newPendingFiles: PendingFile[] = files.map((file) => ({
tempFilename: file.filename,
originalFilename: file.originalname,
mimetype: file.mimetype,
size: file.size,
uploadedById: req.user!.userId,
uploadedAt: new Date(),
}));
pendingFiles.set(tempId, [...existing, ...newPendingFiles]);
// Return file info (without DB id since they're not saved yet)
const response = newPendingFiles.map((f, index) => ({
id: `pending-${tempId}-${Date.now()}-${index}`,
filename: f.originalFilename,
filepath: `/uploads/pending/${f.tempFilename}`,
mimetype: f.mimetype,
size: f.size,
uploadedAt: f.uploadedAt.toISOString(),
isPending: true,
}));
successResponse(res, response, 'Súbory boli nahrané.', 201);
} catch (error) {
console.error('Error uploading pending files:', error);
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
}
};
export const getPendingFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const tempId = getParam(req, 'tempId');
const files = pendingFiles.get(tempId) || [];
const response = files.map((f, index) => ({
id: `pending-${tempId}-${index}`,
filename: f.originalFilename,
filepath: `/uploads/pending/${f.tempFilename}`,
mimetype: f.mimetype,
size: f.size,
uploadedAt: f.uploadedAt.toISOString(),
isPending: true,
}));
successResponse(res, response);
} catch (error) {
console.error('Error getting pending files:', error);
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
}
};
export const deletePendingFile = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const tempId = getParam(req, 'tempId');
const filename = getParam(req, 'filename');
const files = pendingFiles.get(tempId) || [];
const fileIndex = files.findIndex((f) => f.tempFilename === filename || f.originalFilename === filename);
if (fileIndex === -1) {
errorResponse(res, 'Súbor nebol nájdený.', 404);
return;
}
const file = files[fileIndex];
// Delete physical file
const filePath = path.join(getPendingDir(), file.tempFilename);
try {
fs.unlinkSync(filePath);
} catch {
console.warn(`Pending file not found: ${filePath}`);
}
// Remove from pending list
files.splice(fileIndex, 1);
if (files.length === 0) {
pendingFiles.delete(tempId);
} else {
pendingFiles.set(tempId, files);
}
successResponse(res, null, 'Súbor bol vymazaný.');
} catch (error) {
console.error('Error deleting pending file:', error);
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
}
};
// Helper function to move pending files to entity
export const movePendingFilesToEntity = async (
tempId: string,
entityType: 'equipment' | 'rma' | 'task',
entityId: string
): Promise<void> => {
const files = pendingFiles.get(tempId);
if (!files || files.length === 0) return;
const targetDir = path.join(env.UPLOAD_DIR || 'uploads', entityType);
// Ensure target directory exists
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
for (const file of files) {
const sourcePath = path.join(getPendingDir(), file.tempFilename);
const targetPath = path.join(targetDir, file.tempFilename);
// Move file
if (fs.existsSync(sourcePath)) {
fs.renameSync(sourcePath, targetPath);
// Create DB record
if (entityType === 'equipment') {
await prisma.equipmentAttachment.create({
data: {
equipmentId: entityId,
filename: file.originalFilename,
filepath: getFileUrl('equipment', file.tempFilename),
mimetype: file.mimetype,
size: file.size,
uploadedById: file.uploadedById,
},
});
} else if (entityType === 'rma') {
await prisma.rMAAttachment.create({
data: {
rmaId: entityId,
filename: file.originalFilename,
filepath: getFileUrl('rma', file.tempFilename),
mimetype: file.mimetype,
size: file.size,
uploadedById: file.uploadedById,
},
});
} else if (entityType === 'task') {
await prisma.taskAttachment.create({
data: {
taskId: entityId,
filename: file.originalFilename,
filepath: getFileUrl('task', file.tempFilename),
mimetype: file.mimetype,
size: file.size,
uploadedById: file.uploadedById,
},
});
}
}
}
// Clear pending files
pendingFiles.delete(tempId);
};
// Equipment Attachments
export const uploadEquipmentFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const equipmentId = getParam(req, 'id');
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
return;
}
// Skontrolovať, či equipment existuje
const equipment = await prisma.equipment.findUnique({
where: { id: equipmentId },
});
if (!equipment) {
// Vymazať nahrané súbory
files.forEach((file) => {
fs.unlink(file.path, () => {});
});
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
return;
}
// Uložiť záznamy do databázy
const attachments = await Promise.all(
files.map((file) =>
prisma.equipmentAttachment.create({
data: {
equipmentId,
filename: file.originalname,
filepath: getFileUrl('equipment', file.filename),
mimetype: file.mimetype,
size: file.size,
uploadedById: req.user!.userId,
},
})
)
);
if (req.logActivity) {
await req.logActivity('UPLOAD', 'Equipment', equipmentId, {
files: attachments.map((a) => a.filename),
});
}
successResponse(res, attachments, 'Súbory boli nahrané.', 201);
} catch (error) {
console.error('Error uploading equipment files:', error);
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
}
};
export const getEquipmentFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const equipmentId = getParam(req, 'id');
const attachments = await prisma.equipmentAttachment.findMany({
where: { equipmentId },
orderBy: { uploadedAt: 'desc' },
include: {
uploadedBy: { select: { id: true, name: true } },
},
});
successResponse(res, attachments);
} catch (error) {
console.error('Error fetching equipment files:', error);
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
}
};
export const deleteEquipmentFile = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const equipmentId = getParam(req, 'id');
const fileId = getParam(req, 'fileId');
const attachment = await prisma.equipmentAttachment.findFirst({
where: { id: fileId, equipmentId },
});
if (!attachment) {
errorResponse(res, 'Súbor nebol nájdený.', 404);
return;
}
// Vymazať fyzický súbor
const filename = path.basename(attachment.filepath);
const filePath = getFilePath('equipment', filename);
try {
fs.unlinkSync(filePath);
} catch {
// Súbor možno už neexistuje
console.warn(`File not found: ${filePath}`);
}
// Vymazať záznam z databázy
await prisma.equipmentAttachment.delete({
where: { id: fileId },
});
if (req.logActivity) {
await req.logActivity('DELETE_FILE', 'Equipment', equipmentId, {
filename: attachment.filename,
});
}
successResponse(res, null, 'Súbor bol vymazaný.');
} catch (error) {
console.error('Error deleting equipment file:', error);
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
}
};
// RMA Attachments
export const uploadRMAFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const rmaId = getParam(req, 'id');
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
return;
}
// Skontrolovať, či RMA existuje
const rma = await prisma.rMA.findUnique({
where: { id: rmaId },
});
if (!rma) {
// Vymazať nahrané súbory
files.forEach((file) => {
fs.unlink(file.path, () => {});
});
errorResponse(res, 'RMA nebola nájdená.', 404);
return;
}
// Uložiť záznamy do databázy
const attachments = await Promise.all(
files.map((file) =>
prisma.rMAAttachment.create({
data: {
rmaId,
filename: file.originalname,
filepath: getFileUrl('rma', file.filename),
mimetype: file.mimetype,
size: file.size,
uploadedById: req.user!.userId,
},
})
)
);
if (req.logActivity) {
await req.logActivity('UPLOAD', 'RMA', rmaId, {
files: attachments.map((a) => a.filename),
});
}
successResponse(res, attachments, 'Súbory boli nahrané.', 201);
} catch (error) {
console.error('Error uploading RMA files:', error);
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
}
};
export const getRMAFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const rmaId = getParam(req, 'id');
const attachments = await prisma.rMAAttachment.findMany({
where: { rmaId },
orderBy: { uploadedAt: 'desc' },
include: {
uploadedBy: { select: { id: true, name: true } },
},
});
successResponse(res, attachments);
} catch (error) {
console.error('Error fetching RMA files:', error);
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
}
};
export const deleteRMAFile = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const rmaId = getParam(req, 'id');
const fileId = getParam(req, 'fileId');
const attachment = await prisma.rMAAttachment.findFirst({
where: { id: fileId, rmaId },
});
if (!attachment) {
errorResponse(res, 'Súbor nebol nájdený.', 404);
return;
}
// Vymazať fyzický súbor
const filename = path.basename(attachment.filepath);
const filePath = getFilePath('rma', filename);
try {
fs.unlinkSync(filePath);
} catch {
// Súbor možno už neexistuje
console.warn(`File not found: ${filePath}`);
}
// Vymazať záznam z databázy
await prisma.rMAAttachment.delete({
where: { id: fileId },
});
if (req.logActivity) {
await req.logActivity('DELETE_FILE', 'RMA', rmaId, {
filename: attachment.filename,
});
}
successResponse(res, null, 'Súbor bol vymazaný.');
} catch (error) {
console.error('Error deleting RMA file:', error);
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
}
};
// Task Attachments
export const uploadTaskFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const taskId = getParam(req, 'id');
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
return;
}
// Skontrolovať, či task existuje
const task = await prisma.task.findUnique({
where: { id: taskId },
});
if (!task) {
// Vymazať nahrané súbory
files.forEach((file) => {
fs.unlink(file.path, () => {});
});
errorResponse(res, 'Úloha nebola nájdená.', 404);
return;
}
// Uložiť záznamy do databázy
const attachments = await Promise.all(
files.map((file) =>
prisma.taskAttachment.create({
data: {
taskId,
filename: file.originalname,
filepath: getFileUrl('task', file.filename),
mimetype: file.mimetype,
size: file.size,
uploadedById: req.user!.userId,
},
})
)
);
if (req.logActivity) {
await req.logActivity('UPLOAD', 'Task', taskId, {
files: attachments.map((a) => a.filename),
});
}
successResponse(res, attachments, 'Súbory boli nahrané.', 201);
} catch (error) {
console.error('Error uploading task files:', error);
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
}
};
export const getTaskFiles = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const taskId = getParam(req, 'id');
const attachments = await prisma.taskAttachment.findMany({
where: { taskId },
orderBy: { uploadedAt: 'desc' },
include: {
uploadedBy: { select: { id: true, name: true } },
},
});
successResponse(res, attachments);
} catch (error) {
console.error('Error fetching task files:', error);
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
}
};
export const deleteTaskFile = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const taskId = getParam(req, 'id');
const fileId = getParam(req, 'fileId');
const attachment = await prisma.taskAttachment.findFirst({
where: { id: fileId, taskId },
});
if (!attachment) {
errorResponse(res, 'Súbor nebol nájdený.', 404);
return;
}
// Vymazať fyzický súbor
const filename = path.basename(attachment.filepath);
const filePath = getFilePath('task', filename);
try {
fs.unlinkSync(filePath);
} catch {
// Súbor možno už neexistuje
console.warn(`File not found: ${filePath}`);
}
// Vymazať záznam z databázy
await prisma.taskAttachment.delete({
where: { id: fileId },
});
if (req.logActivity) {
await req.logActivity('DELETE_FILE', 'Task', taskId, {
filename: attachment.filename,
});
}
successResponse(res, null, 'Súbor bol vymazaný.');
} catch (error) {
console.error('Error deleting task file:', error);
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
}
};
// Download file (universal)
export const downloadFile = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const fileId = getParam(req, 'fileId');
const entityType = getParam(req, 'entityType'); // 'equipment', 'rma', or 'task'
let attachment: { filepath: string; filename: string } | null = null;
if (entityType === 'equipment') {
attachment = await prisma.equipmentAttachment.findUnique({
where: { id: fileId },
select: { filepath: true, filename: true },
});
} else if (entityType === 'rma') {
attachment = await prisma.rMAAttachment.findUnique({
where: { id: fileId },
select: { filepath: true, filename: true },
});
} else if (entityType === 'task') {
attachment = await prisma.taskAttachment.findUnique({
where: { id: fileId },
select: { filepath: true, filename: true },
});
}
if (!attachment) {
errorResponse(res, 'Súbor nebol nájdený.', 404);
return;
}
const filename = path.basename(attachment.filepath);
const filePath = getFilePath(entityType, filename);
if (!fs.existsSync(filePath)) {
errorResponse(res, 'Súbor nebol nájdený na serveri.', 404);
return;
}
res.download(filePath, attachment.filename);
} catch (error) {
console.error('Error downloading file:', error);
errorResponse(res, 'Chyba pri sťahovaní súboru.', 500);
}
};

View File

@@ -0,0 +1,48 @@
import { Response } from 'express';
import { successResponse, errorResponse, parseQueryInt, getQueryString } from '../utils/helpers';
import { AuthRequest } from '../middleware/auth.middleware';
import { externalDbService } from '../services/externalDb.service';
// Check if external DB is configured
export const checkConfiguration = async (_req: AuthRequest, res: Response): Promise<void> => {
const isConfigured = externalDbService.isConfigured();
successResponse(res, { configured: isConfigured });
};
// Get zakazky by year
export const getZakazky = async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!externalDbService.isConfigured()) {
errorResponse(res, 'Externá databáza nie je nakonfigurovaná.', 503);
return;
}
const rok = parseQueryInt(req.query.rok, new Date().getFullYear());
const search = getQueryString(req, 'search');
let zakazky;
if (search) {
zakazky = await externalDbService.searchZakazky(rok, search);
} else {
zakazky = await externalDbService.getZakazkyByYear(rok);
}
successResponse(res, zakazky);
} catch (error) {
console.error('Error fetching zakazky:', error);
errorResponse(res, 'Chyba pri načítaní zákaziek z externej databázy.', 500);
}
};
// Get available years (current year and 5 years back)
export const getAvailableYears = async (_req: AuthRequest, res: Response): Promise<void> => {
const currentYear = new Date().getFullYear();
const years = [];
for (let i = 0; i <= 5; i++) {
years.push(currentYear - i);
}
successResponse(res, years);
};

View File

@@ -2,15 +2,40 @@ import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import path from 'path';
import fs from 'fs';
import { env } from './config/env';
import routes from './routes';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
import prisma from './config/database';
// Ensure upload directories exist
const ensureUploadDirectories = () => {
const uploadDir = env.UPLOAD_DIR || 'uploads';
const directories = [
uploadDir,
path.join(uploadDir, 'equipment'),
path.join(uploadDir, 'rma'),
path.join(uploadDir, 'task'),
path.join(uploadDir, 'pending'),
];
directories.forEach((dir) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory: ${dir}`);
}
});
};
ensureUploadDirectories();
const app = express();
// Security middleware
app.use(helmet());
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Pre static files z iného originu
}));
app.use(cors({
origin: env.FRONTEND_URL,
credentials: true,
@@ -20,6 +45,10 @@ app.use(cors({
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Static files - uploads
const uploadsPath = path.resolve(env.UPLOAD_DIR);
app.use('/uploads', express.static(uploadsPath));
// Logging
if (env.isDev) {
app.use(morgan('dev'));

View File

@@ -3,7 +3,7 @@ import { AuthRequest } from './auth.middleware';
import prisma from '../config/database';
import { Prisma } from '@prisma/client';
type ActionType = 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE' | 'LOGIN' | 'LOGOUT';
type ActionType = 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE' | 'LOGIN' | 'LOGOUT' | 'UPLOAD' | 'DELETE_FILE';
type EntityType = 'User' | 'Project' | 'Task' | 'Customer' | 'Equipment' | 'Revision' | 'RMA';
export const logActivity = async (

View File

@@ -0,0 +1,164 @@
import multer from 'multer';
import path from 'path';
import { Request } from 'express';
import { env } from '../config/env';
// Povolené MIME typy
const ALLOWED_MIME_TYPES = [
// Obrázky
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
// Dokumenty
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
];
// Povolené prípony
const ALLOWED_EXTENSIONS = [
'.jpg', '.jpeg', '.png', '.gif', '.webp',
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
'.txt', '.csv',
];
// Maximálna veľkosť súboru (default 10MB)
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE || '10485760', 10);
// Storage konfigurácia
const storage = multer.diskStorage({
destination: (req: Request, _file, cb) => {
// Určenie cieľového priečinka podľa entity type
const entityType = req.params.entityType || req.body.entityType || 'general';
const uploadPath = path.join(env.UPLOAD_DIR || 'uploads', entityType);
cb(null, uploadPath);
},
filename: (_req, file, cb) => {
// Generovanie unikátneho názvu: timestamp-random.extension
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueSuffix}${ext}`);
},
});
// File filter
const fileFilter = (
_req: Request,
file: Express.Multer.File,
cb: multer.FileFilterCallback
) => {
const ext = path.extname(file.originalname).toLowerCase();
// Kontrola MIME typu
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(new Error(`Nepodporovaný typ súboru: ${file.mimetype}`));
return;
}
// Kontrola prípony
if (!ALLOWED_EXTENSIONS.includes(ext)) {
cb(new Error(`Nepodporovaná prípona súboru: ${ext}`));
return;
}
cb(null, true);
};
// Základná multer inštancia
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
files: 10, // Max počet súborov naraz
},
});
// Pre-configured uploaders pre rôzne entity
export const equipmentUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'equipment'));
},
filename: (_req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueSuffix}${ext}`);
},
}),
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
files: 10,
},
});
export const rmaUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'rma'));
},
filename: (_req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueSuffix}${ext}`);
},
}),
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
files: 10,
},
});
// Pending upload - for files before entity is created
export const pendingUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'pending'));
},
filename: (_req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueSuffix}${ext}`);
},
}),
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
files: 10,
},
});
export const taskUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'task'));
},
filename: (_req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uniqueSuffix}${ext}`);
},
}),
fileFilter,
limits: {
fileSize: MAX_FILE_SIZE,
files: 10,
},
});
// Helper pre získanie URL súboru
export const getFileUrl = (entityType: string, filename: string): string => {
return `/uploads/${entityType}/${filename}`;
};
// Helper pre získanie cesty súboru
export const getFilePath = (entityType: string, filename: string): string => {
return path.join(env.UPLOAD_DIR || 'uploads', entityType, filename);
};

View File

@@ -1,10 +1,12 @@
import { Router } from 'express';
import * as equipmentController from '../controllers/equipment.controller';
import * as uploadController from '../controllers/upload.controller';
import { authenticate } from '../middleware/auth.middleware';
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
import { activityLogger } from '../middleware/activityLog.middleware';
import { validate } from '../middleware/validate.middleware';
import { equipmentSchema } from '../utils/validators';
import { equipmentUpload } from '../middleware/upload.middleware';
const router = Router();
@@ -22,4 +24,9 @@ router.delete('/:id', canDelete('equipment'), equipmentController.deleteEquipmen
router.get('/:id/revisions', canRead('equipment'), equipmentController.getEquipmentRevisions);
router.post('/:id/revisions', canCreate('equipment'), equipmentController.createEquipmentRevision);
// File attachments
router.get('/:id/files', canRead('equipment'), uploadController.getEquipmentFiles);
router.post('/:id/files', canCreate('equipment'), equipmentUpload.array('files', 10), uploadController.uploadEquipmentFiles);
router.delete('/:id/files/:fileId', canDelete('equipment'), uploadController.deleteEquipmentFile);
export default router;

View File

@@ -8,6 +8,8 @@ import equipmentRoutes from './equipment.routes';
import rmaRoutes from './rma.routes';
import settingsRoutes from './settings.routes';
import dashboardRoutes from './dashboard.routes';
import uploadRoutes from './upload.routes';
import zakazkyRoutes from './zakazky.routes';
const router = Router();
@@ -20,5 +22,7 @@ router.use('/equipment', equipmentRoutes);
router.use('/rma', rmaRoutes);
router.use('/settings', settingsRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/files', uploadRoutes);
router.use('/zakazky', zakazkyRoutes);
export default router;

View File

@@ -1,10 +1,12 @@
import { Router } from 'express';
import * as rmaController from '../controllers/rma.controller';
import * as uploadController from '../controllers/upload.controller';
import { authenticate } from '../middleware/auth.middleware';
import { canRead, canCreate, canUpdate, canDelete, isAdmin } from '../middleware/rbac.middleware';
import { activityLogger } from '../middleware/activityLog.middleware';
import { validate } from '../middleware/validate.middleware';
import { rmaSchema } from '../utils/validators';
import { rmaUpload } from '../middleware/upload.middleware';
const router = Router();
@@ -27,4 +29,9 @@ router.patch('/:id/approve', isAdmin, rmaController.approveRMA);
// Comments
router.post('/:id/comments', canRead('rma'), rmaController.addRMAComment);
// File attachments
router.get('/:id/files', canRead('rma'), uploadController.getRMAFiles);
router.post('/:id/files', canCreate('rma'), rmaUpload.array('files', 10), uploadController.uploadRMAFiles);
router.delete('/:id/files/:fileId', canDelete('rma'), uploadController.deleteRMAFile);
export default router;

View File

@@ -1,10 +1,12 @@
import { Router } from 'express';
import * as tasksController from '../controllers/tasks.controller';
import * as uploadController from '../controllers/upload.controller';
import { authenticate } from '../middleware/auth.middleware';
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
import { activityLogger } from '../middleware/activityLog.middleware';
import { validate } from '../middleware/validate.middleware';
import { taskSchema } from '../utils/validators';
import { taskUpload } from '../middleware/upload.middleware';
const router = Router();
@@ -28,4 +30,9 @@ router.delete('/:id/assignees/:userId', canUpdate('tasks'), tasksController.remo
router.get('/:id/comments', canRead('tasks'), tasksController.getTaskComments);
router.post('/:id/comments', canRead('tasks'), tasksController.addTaskComment);
// Files
router.post('/:id/files', canUpdate('tasks'), taskUpload.array('files', 10), uploadController.uploadTaskFiles);
router.get('/:id/files', canRead('tasks'), uploadController.getTaskFiles);
router.delete('/:id/files/:fileId', canUpdate('tasks'), uploadController.deleteTaskFile);
export default router;

View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
import * as uploadController from '../controllers/upload.controller';
import { authenticate } from '../middleware/auth.middleware';
import { activityLogger } from '../middleware/activityLog.middleware';
import { pendingUpload } from '../middleware/upload.middleware';
const router = Router();
router.use(authenticate);
router.use(activityLogger);
// Pending uploads - for new entities before they are saved
router.post('/pending/:tempId', pendingUpload.array('files', 10), uploadController.uploadPendingFiles);
router.get('/pending/:tempId', uploadController.getPendingFiles);
router.delete('/pending/:tempId/:filename', uploadController.deletePendingFile);
// Download file by entity type and file ID
router.get('/:entityType/:fileId/download', uploadController.downloadFile);
export default router;

View File

@@ -0,0 +1,21 @@
import { Router } from 'express';
import * as zakazkyController from '../controllers/zakazky.controller';
import { authenticate } from '../middleware/auth.middleware';
import { canRead } from '../middleware/rbac.middleware';
const router = Router();
// All routes require authentication
router.use(authenticate);
// Check if external DB is configured
router.get('/status', zakazkyController.checkConfiguration);
// Get available years
router.get('/years', zakazkyController.getAvailableYears);
// Get zakazky by year (with optional search)
// Query params: rok (year), search (optional)
router.get('/', canRead('projects'), zakazkyController.getZakazky);
export default router;

View File

@@ -0,0 +1,173 @@
import { Pool, PoolClient } from 'pg';
import { env } from '../config/env';
// External database connection pool - READ ONLY
let pool: Pool | null = null;
// Forbidden SQL keywords for safety
const FORBIDDEN_KEYWORDS = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE',
'GRANT', 'REVOKE', 'EXECUTE', 'CALL', 'MERGE', 'REPLACE'
];
// Validate query is read-only
const validateReadOnlyQuery = (query: string): void => {
const upperQuery = query.toUpperCase().trim();
for (const keyword of FORBIDDEN_KEYWORDS) {
// Check if query starts with forbidden keyword or contains it as a statement
if (upperQuery.startsWith(keyword) || upperQuery.includes(`;${keyword}`) || upperQuery.includes(`; ${keyword}`)) {
throw new Error(`Forbidden operation: ${keyword} is not allowed on external database. Read-only access only.`);
}
}
// Must start with SELECT or be a function call that returns data
if (!upperQuery.startsWith('SELECT')) {
throw new Error('Only SELECT queries are allowed on external database.');
}
};
// Initialize connection pool
const getPool = (): Pool => {
if (!pool) {
if (!env.EXTERNAL_DB_HOST || !env.EXTERNAL_DB_NAME) {
throw new Error('External database not configured. Check EXTERNAL_DB_* environment variables.');
}
pool = new Pool({
host: env.EXTERNAL_DB_HOST,
port: env.EXTERNAL_DB_PORT,
database: env.EXTERNAL_DB_NAME,
user: env.EXTERNAL_DB_USER,
password: env.EXTERNAL_DB_PASSWORD,
max: 5, // Max connections in pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// Handle pool errors
pool.on('error', (err) => {
console.error('External DB pool error:', err);
});
}
return pool;
};
// Safe read-only query executor
const executeReadOnlyQuery = async <T>(query: string, params?: unknown[]): Promise<T[]> => {
// Validate query before execution
validateReadOnlyQuery(query);
const dbPool = getPool();
let client: PoolClient | null = null;
try {
client = await dbPool.connect();
// Set transaction to read-only for extra safety
await client.query('SET TRANSACTION READ ONLY');
const result = await client.query(query, params);
return result.rows as T[];
} finally {
if (client) {
client.release();
}
}
};
// Check if external DB is configured
export const isExternalDbConfigured = (): boolean => {
return !!(env.EXTERNAL_DB_HOST && env.EXTERNAL_DB_NAME);
};
// Zakazka interface based on function output
export interface Zakazka {
id: number;
id_stav_zakazky: number;
cislo: string;
datum_vystavenia: Date | null;
datum_ukoncenia: Date | null;
customer: string;
nazov: string;
poznamka: string | null;
vystavil: string;
uzavreta: boolean;
}
// Raw row interface from DB function (id without underscore, rest with underscore)
interface ZakazkaRow {
id: number;
_id_stav_zakazky: number;
_cislo: string;
_datum_vystavenia: Date | null;
_datum_ukoncenia: Date | null;
_customer: string;
_nazov: string;
_poznamka: string | null;
_vystavil: string;
_uzavreta: boolean;
}
// Get zakazky by year - READ ONLY
export const getZakazkyByYear = async (rok: number): Promise<Zakazka[]> => {
try {
// Call the stored function using safe read-only executor
const rows = await executeReadOnlyQuery<ZakazkaRow>(
'SELECT * FROM da.zakazky_select_all($1)',
[rok]
);
// Map the result to our interface
return rows.map((row) => ({
id: row.id,
id_stav_zakazky: row._id_stav_zakazky,
cislo: row._cislo,
datum_vystavenia: row._datum_vystavenia,
datum_ukoncenia: row._datum_ukoncenia,
customer: row._customer,
nazov: row._nazov,
poznamka: row._poznamka,
vystavil: row._vystavil,
uzavreta: row._uzavreta,
}));
} catch (error) {
console.error('Error fetching zakazky:', error);
throw error;
}
};
// Get single zakazka by ID
export const getZakazkaById = async (rok: number, id: number): Promise<Zakazka | null> => {
const zakazky = await getZakazkyByYear(rok);
return zakazky.find((z) => z.id === id) || null;
};
// Search zakazky
export const searchZakazky = async (rok: number, search: string): Promise<Zakazka[]> => {
const zakazky = await getZakazkyByYear(rok);
const searchLower = search.toLowerCase();
return zakazky.filter((z) =>
z.cislo.toLowerCase().includes(searchLower) ||
z.nazov.toLowerCase().includes(searchLower) ||
z.customer.toLowerCase().includes(searchLower)
);
};
// Close pool (for graceful shutdown)
export const closeExternalDb = async (): Promise<void> => {
if (pool) {
await pool.end();
pool = null;
}
};
export const externalDbService = {
isConfigured: isExternalDbConfigured,
getZakazkyByYear,
getZakazkaById,
searchZakazky,
close: closeExternalDb,
};