From 125e30338a595861bf404549e3902592b9c696f1 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Mon, 24 Nov 2025 11:30:25 +0100 Subject: [PATCH] add many to one in todo, fix bugs, notification about todos --- DOKUMENTACIA.md | 375 +++++++++++++++++- src/controllers/admin.controller.js | 6 +- src/controllers/time-tracking.controller.js | 12 +- src/routes/admin.routes.js | 12 +- .../11/timesheet-2025-11-1763979698080.xlsx} | Bin 7152 -> 7150 bytes 5 files changed, 381 insertions(+), 24 deletions(-) rename uploads/timesheets/{ae65f40f-e22a-45c6-9647-36005c6d31e8/2025/11/timesheet-2025-11-1763975803413.xlsx => 68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1763979698080.xlsx} (52%) diff --git a/DOKUMENTACIA.md b/DOKUMENTACIA.md index 46adf08..6109806 100644 --- a/DOKUMENTACIA.md +++ b/DOKUMENTACIA.md @@ -561,10 +561,26 @@ getMonthlyStats(userId, year, month) - averagePerDay - byProject (čas per projekt) - byCompany (čas per firma) + +generateMonthlyTimesheet(userId, year, month) + → Generate Excel (XLSX) report z time entries + → Fetch user info: username, firstName, lastName + → Fetch completed entries for month (LEFT JOIN projects, todos, companies) + → Filter: iba entries s endTime a duration + → Počíta: totalMinutes, dailyTotals (per day) + → Vytvára Excel workbook cez ExcelJS: + - Header: Timesheet, Name, Period, Generated date + - Table: Date, Project, Todo, Company, Description, Start, End, Duration + - Summary: Daily totals + Overall total + → Save to: uploads/timesheets/{userId}/{year}/{month}/timesheet-{period}-{timestamp}.xlsx + → INSERT INTO timesheets (isGenerated = true) + → Vráti: { timesheet, filePath, entriesCount, totalMinutes, totalHours } ``` **Volá:** -- Databázu (timeEntries, projects, todos, companies) +- Databázu (timeEntries, projects, todos, companies, users, timesheets) +- ExcelJS library (workbook generation) +- File system (fs/promises) - save XLSX file - `utils/errors.NotFoundError` - `utils/errors.BadRequestError` @@ -609,10 +625,233 @@ logUserCreation(adminId, newUserId, username, role, ip, userAgent) --- +### 13. timesheet.controller.js +**Účel:** File upload a správa timesheetov (PDF/Excel) + +**Databáza:** `timesheets`, `users` + +**Metódy:** +```javascript +uploadTimesheet(req, res) + → Validácia file type (PDF, Excel) + → Max 10MB limit + → Save to: uploads/timesheets/{userId}/{year}/{month}/ + → INSERT INTO timesheets + → File stored on disk (not in DB) + +getMyTimesheets(userId, filters) + → Filter: year, month (optional) + → SELECT * FROM timesheets WHERE userId + → ORDER BY uploadedAt DESC + +getAllTimesheets(filters) + → Admin only! + → Filter: userId, year, month (all optional) + → LEFT JOIN users (get username, name) + → Vráti timesheets všetkých userov + +downloadTimesheet(timesheetId, userId, userRole) + → Check permissions: owner alebo admin + → Validate file exists on disk + → res.download(filePath, fileName) + +deleteTimesheet(timesheetId, userId, userRole) + → Check permissions: owner alebo admin + → Delete file from filesystem (fs.unlink) + → DELETE FROM timesheets + → Continue even if file deletion fails +``` + +**Volá:** +- Databázu (timesheets, users) +- File system operations (fs/promises) +- `utils/errors.NotFoundError, ForbiddenError, BadRequestError` + +**File Storage Pattern:** +``` +uploads/timesheets/ + └── {userId}/ + └── {year}/ + └── {month}/ + └── filename-timestamp-random.pdf +``` + +**POZNÁMKA:** Timesheet service NEEXISTUJE - všetka logika je priamo v controlleri! + +--- + +## VALIDATORS + +### 1. auth.validators.js +**Účel:** Zod schemas pre autentifikáciu a user management + +**Schemas:** +```javascript +loginSchema + → username: 3-50 chars, required + → password: min 1 char, required + +setPasswordSchema + → newPassword: min 8 chars, obsahuje a-z, A-Z, 0-9, špeciálny znak + → confirmPassword: musí sa zhodovať + → .refine() custom validation pre password match + +linkEmailSchema + → email: valid email format, max 255 chars + → emailPassword: min 1 char + +createUserSchema (admin only) + → username: 3-50 chars, iba [a-zA-Z0-9_-] + → email: optional (ak sa zadá, môže sa linknúť JMAP) + → emailPassword: optional (pre automatické linkovanie) + → firstName, lastName: optional, max 100 chars + +updateUserSchema + → firstName, lastName, email: all optional + +changeRoleSchema + → userId: UUID + → role: enum ['admin', 'member'] +``` + +**Použitie:** +- Všetky `/api/auth/*` routes +- Admin user management routes + +--- + +### 2. crm.validators.js +**Účel:** Zod schemas pre Company, Project, Todo, Note, Time Tracking + +**Company Schemas:** +```javascript +createCompanySchema + → name: required, max 255 chars + → description: optional, max 1000 chars + → address, city, country: optional + → phone: optional, max 50 chars + → email: optional, valid email OR empty string + → website: optional, valid URL OR empty string + +updateCompanySchema + → Všetky fields optional +``` + +**Project Schemas:** +```javascript +createProjectSchema + → name: required, max 255 chars + → companyId: optional UUID OR empty string + → status: enum ['active', 'completed', 'on_hold', 'cancelled'] + → startDate, endDate: optional strings OR empty + +updateProjectSchema + → Všetky fields optional + → NULL support: .or(z.null()) +``` + +**Todo Schemas:** +```javascript +createTodoSchema + → title: required, max 255 chars + → projectId, companyId, assignedTo: optional UUID OR empty + → status: enum ['pending', 'in_progress', 'completed', 'cancelled'] + → priority: enum ['low', 'medium', 'high', 'urgent'] + → dueDate: optional string OR empty + +updateTodoSchema + → Všetky fields optional + NULL support +``` + +**Note Schemas:** +```javascript +createNoteSchema + → content: required, max 5000 chars + → title: optional, max 255 chars + → companyId, projectId, todoId, contactId: optional UUID OR empty + → reminderDate: optional string OR empty + +updateNoteSchema + → Všetky fields optional + NULL support +``` + +**Time Tracking Schemas:** +```javascript +startTimeEntrySchema + → projectId, todoId, companyId: optional UUID (preprocessed to null if empty) + → description: optional, max 1000 chars, trimmed, null if empty + +stopTimeEntrySchema + → Same as start (používa sa pre update pri stop) + +updateTimeEntrySchema + → startTime, endTime: optional ISO strings + → projectId, todoId, companyId, description: optional with preprocessing +``` + +**Helper Functions:** +```javascript +optionalUuid(message) + → Preprocess: undefined, null, '' → null + → Validate: UUID format + → Used for optional foreign keys + +optionalDescription + → Preprocess: trim whitespace, '' → null + → Validate: max 1000 chars + → Nullable +``` + +**Pattern - Empty String Handling:** +```javascript +// Frontend môže poslať empty string namiesto null +.or(z.literal('')) // Accept empty string +.or(z.literal('').or(z.null())) // Update: accept empty OR null +``` + +--- + +### 3. email-account.validators.js +**Účel:** Zod schemas pre email account management + +**Schemas:** +```javascript +createEmailAccountSchema + → email: required, valid format, max 255 chars + → emailPassword: required, min 1 char + +updateEmailAccountSchema + → emailPassword: optional, min 1 char + → isActive: optional boolean + +setPrimaryAccountSchema + → accountId: UUID + → POZNÁMKA: NEVYUŽÍVA SA (endpoint má accountId v path params) +``` + +**Použitie:** +- `/api/email-accounts/*` routes +- JMAP credential validation flow + +--- + ## CONTROLLERS **Účel:** Spracovanie HTTP requestov, volanie services, vracanie responses +### Zoznam Controllerov: +1. **admin.controller.js** - User management (admin only) +2. **auth.controller.js** - Autentifikácia a onboarding +3. **company.controller.js** - Firmy CRUD + nested notes +4. **contact.controller.js** - Email kontakty +5. **crm-email.controller.js** - Email management (read status, search) +6. **email-account.controller.js** - JMAP účty +7. **note.controller.js** - Standalone poznámky (nevyužité) +8. **project.controller.js** - Projekty CRUD + nested notes + team management +9. **todo.controller.js** - Úlohy CRUD +10. **time-tracking.controller.js** - Sledovanie času +11. **timesheet.controller.js** - Upload a download timesheetov (bez service!) + ### Štruktúra každého controllera: ```javascript export const methodName = async (req, res) => { @@ -655,6 +894,19 @@ export const methodName = async (req, res) => { **Účel:** Definícia endpointov, middleware, validácia +### Zoznam Route Files: +1. **admin.routes.js** - User management (Auth + Admin role) +2. **auth.routes.js** - Login, set password, link email (Mixed public/protected) +3. **company.routes.js** - Firmy + nested notes (Auth only) +4. **contact.routes.js** - Kontakty (Auth only) +5. **crm-email.routes.js** - Emaily (Auth only) +6. **email-account.routes.js** - JMAP účty (Auth only) +7. **note.routes.js** - Standalone poznámky (Auth only, nevyužité) +8. **project.routes.js** - Projekty + notes + team (Auth only) +9. **todo.routes.js** - Úlohy (Auth only) +10. **time-tracking.routes.js** - Time tracking (Auth only) +11. **timesheet.routes.js** - Timesheets upload/download (Auth, admin for /all) + ### Štruktúra route file: ```javascript import express from 'express' @@ -697,6 +949,12 @@ export default router ## UTILS +### Zoznam Utility Files: +1. **errors.js** - Custom error classes + formatting +2. **jwt.js** - JWT token generation and validation +3. **logger.js** - Colored console logging +4. **password.js** - Password hashing, encryption, generation + ### 1. errors.js **Účel:** Custom error classy a formatting @@ -1500,14 +1758,40 @@ Order: DESC by startTime ``` Účel: Mesačný prehľad time entries Params: year (YYYY), month (1-12) +Query: userId (optional, admin only – ak je zadaný, načítava sa daný používateľ) Auth: Áno Response: Array of entries pre daný mesiac ``` +#### POST /api/time-tracking/month/:year/:month/generate +``` +Účel: Vygenerovať mesačný timesheet (Excel XLSX) +Params: year (YYYY), month (1-12) +Query: userId (optional, admin only - generate pre iného usera) +Auth: Áno +Body: {} (bez payloadu; posiela sa prázdny objekt) +Response: { + timesheet: { id, fileName, filePath, ... }, + filePath, + entriesCount, + totalMinutes, + totalHours +} +Efekt: + - Vytvorí Excel súbor s time entries pre daný mesiac + - Obsahuje: denné záznamy, projekty, todos, descrip, duration + - Summary: daily totals + overall total + - Uloží do: uploads/timesheets/{userId}/{year}/{month}/ + - INSERT INTO timesheets (isGenerated = true) +Volá: time-tracking.service.generateMonthlyTimesheet() +Admin feature: Admin môže generovať timesheet pre iného usera (query param userId) +``` + #### GET /api/time-tracking/stats/monthly/:year/:month ``` Účel: Mesačné štatistiky Params: year (YYYY), month (1-12) +Query: userId (optional, admin only – ak je zadaný, načítava sa daný používateľ) Auth: Áno Response: { totalMinutes, @@ -1566,39 +1850,70 @@ Validation: #### POST /api/timesheets/upload ``` -Účel: Upload timesheet file +Účel: Upload timesheet file (manuálne nahraný PDF/Excel) Content-Type: multipart/form-data Form: file (PDF/Excel), year (YYYY), month (1-12) Auth: Áno -Validation: Max 10MB +Validation: + - Max 10MB + - Allowed types: PDF, Excel (xlsx, xls) +Efekt: + - Uloží file do: uploads/timesheets/{userId}/{year}/{month}/ + - Generate unique filename: {name}-{timestamp}-{random}.ext + - INSERT INTO timesheets (isGenerated = false) +Response: { timesheet object } ``` -#### GET /api/timesheets/my +#### GET /api/timesheets/my?year=YYYY&month=M ``` -Účel: Moje timesheets +Účel: Moje timesheets (uploaded + generated) +Query: year, month (both optional) Auth: Áno +Response: { timesheets: [...], count } +Order: DESC by uploadedAt ``` -#### GET /api/timesheets/all +#### GET /api/timesheets/all?userId=uuid&year=YYYY&month=M ``` -Účel: Všetky timesheets (admin) +Účel: Všetky timesheets všetkých userov (admin) +Query: userId, year, month (all optional) Auth: Áno (admin only) +Response: { timesheets: [...with user info...], count } +Includes: userId, username, firstName, lastName (LEFT JOIN users) +Order: DESC by uploadedAt ``` #### GET /api/timesheets/:timesheetId/download ``` Účel: Stiahnuť timesheet file Auth: Áno -Response: File download +Permissions: Owner OR admin +Response: File download (res.download) +Errors: + - 404: Timesheet nenájdený alebo súbor neexistuje + - 403: Nemáte oprávnenie (nie vlastník ani admin) ``` #### DELETE /api/timesheets/:timesheetId ``` Účel: Zmazať timesheet Auth: Áno -Efekt: Delete file + DB record +Permissions: Owner OR admin +Efekt: + - Delete file from filesystem (fs.unlink) + - DELETE FROM timesheets + - Continue even if file deletion fails (log error) +Errors: + - 404: Timesheet nenájdený + - 403: Nemáte oprávnenie ``` +**POZNÁMKA:** +- Timesheets môžu byť **uploaded** (manuálne PDF/Excel) alebo **generated** (auto Excel z time entries) +- Field `isGenerated` rozlišuje typ: `true` = auto-generated, `false` = manually uploaded +- Obe typy sa ukladajú do rovnakej tabuľky `timesheets` a rovnakého adresára +- Generated timesheets sa vytvárajú cez `POST /api/time-tracking/month/:year/:month/generate` + --- ## VZŤAHY MEDZI SLUŽBAMI @@ -1978,7 +2293,41 @@ console.log('[DEBUG] JMAP validation:', valid); --- -**Vytvorené:** 2025-11-21 -**Posledná aktualizácia:** 2025-11-24 -**Autor:** CRM Server Team -**Kontakt:** crm-server documentation +**Vytvorené:** 2025-11-21 +**Posledná aktualizácia:** 2025-11-24 +**Autor:** CRM Server Team + +--- + +## CHANGELOG + +### 2025-11-24 - Additions +**Pridané sekcie:** +1. **VALIDATORS** - Kompletná dokumentácia všetkých Zod schemas + - auth.validators.js (login, password, user creation) + - crm.validators.js (company, project, todo, note, time tracking) + - email-account.validators.js (JMAP accounts) + +2. **SERVICES** - Doplnené chýbajúce metódy + - time-tracking.service.generateMonthlyTimesheet() - Excel XLSX generation + +3. **CONTROLLERS** - Pridaný chýbajúci controller + - timesheet.controller.js - File upload/download (bez service layer) + +4. **ROUTES** - Kompletný zoznam všetkých route files + - 11 route files s uvedením middleware requirements + +5. **API ROUTES** - Doplnené chýbajúce endpointy + - POST /api/time-tracking/month/:year/:month/generate - Generate Excel timesheet + - GET /api/timesheets/my - Detail s filters (year, month) + - GET /api/timesheets/all - Admin endpoint s filters + - DELETE /api/timesheets/:timesheetId - Permission checks + +6. **UTILS** - Zoznam všetkých utility files (boli už zdokumentované) + +**Upresnenia:** +- Timesheet service NEEXISTUJE - logika priamo v controlleri +- isGenerated flag rozlišuje uploaded vs generated timesheets +- Admin môže generovať timesheet pre iného usera (query param userId) +- Empty string handling vo validátoroch: `.or(z.literal(''))` pattern +- Optional UUID preprocessing v time tracking schemas diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js index 1c2a2a3..9464e8c 100644 --- a/src/controllers/admin.controller.js +++ b/src/controllers/admin.controller.js @@ -123,10 +123,8 @@ export const getAllUsers = async (req, res) => { res.status(200).json({ success: true, - data: { - users: allUsers, - count: allUsers.length, - }, + count: allUsers.length, + data: allUsers, }); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); diff --git a/src/controllers/time-tracking.controller.js b/src/controllers/time-tracking.controller.js index a41689c..84750a6 100644 --- a/src/controllers/time-tracking.controller.js +++ b/src/controllers/time-tracking.controller.js @@ -113,10 +113,12 @@ export const getAllTimeEntries = async (req, res) => { export const getMonthlyTimeEntries = async (req, res) => { try { const userId = req.userId; + const userRole = req.user.role; + const targetUserId = userRole === 'admin' && req.query.userId ? req.query.userId : userId; const { year, month } = req.params; const entries = await timeTrackingService.getMonthlyTimeEntries( - userId, + targetUserId, parseInt(year), parseInt(month) ); @@ -139,10 +141,12 @@ export const getMonthlyTimeEntries = async (req, res) => { export const generateMonthlyTimesheet = async (req, res) => { try { const userId = req.userId; + const userRole = req.user.role; + const targetUserId = userRole === 'admin' && req.query.userId ? req.query.userId : userId; const { year, month } = req.params; const result = await timeTrackingService.generateMonthlyTimesheet( - userId, + targetUserId, parseInt(year), parseInt(month) ); @@ -253,10 +257,12 @@ export const deleteTimeEntry = async (req, res) => { export const getMonthlyStats = async (req, res) => { try { const userId = req.userId; + const userRole = req.user.role; + const targetUserId = userRole === 'admin' && req.query.userId ? req.query.userId : userId; const { year, month } = req.params; const stats = await timeTrackingService.getMonthlyStats( - userId, + targetUserId, parseInt(year), parseInt(month) ); diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index 80ba5c4..9181b09 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -9,9 +9,16 @@ import { z } from 'zod'; const router = express.Router(); /** - * Všetky admin routes vyžadujú autentifikáciu a admin rolu + * Routes accessible to all authenticated users */ router.use(authenticate); + +// Zoznam všetkých userov (dostupné pre všetkých autentifikovaných používateľov) +router.get('/users', adminController.getAllUsers); + +/** + * Admin-only routes + */ router.use(requireAdmin); /** @@ -21,9 +28,6 @@ router.use(requireAdmin); // Vytvorenie nového usera router.post('/users', validateBody(createUserSchema), adminController.createUser); -// Zoznam všetkých userov -router.get('/users', adminController.getAllUsers); - // Získanie konkrétneho usera router.get( '/users/:userId', diff --git a/uploads/timesheets/ae65f40f-e22a-45c6-9647-36005c6d31e8/2025/11/timesheet-2025-11-1763975803413.xlsx b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1763979698080.xlsx similarity index 52% rename from uploads/timesheets/ae65f40f-e22a-45c6-9647-36005c6d31e8/2025/11/timesheet-2025-11-1763975803413.xlsx rename to uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1763979698080.xlsx index 79154cb97460dc0ecd1f49fb000c5aac37032e33..b5b9c8ff0cf192db32c18a8215c2bb633b42f0f7 100644 GIT binary patch delta 2000 zcmY*ac{~*A8lGV+Q_hXCjTtfwXU3i_WM5_|go&eYB*rp`$|!4-<=B_WAjToGA8}o6R4B4#38Cy;(-b)~y8X_*-#^dyzTf*Tzu)^k&$Feqp~Yuw$q70E004LaG~5%u z1Rw_uN67|Cvw+qNm0J|tPrQt27Wkx%eeyFREh2twv1%4pnMFX?DM*?jq#_IC151B^(;mXnjC ze=RFw%xmh&p(!zNNo0X~lQ2`c_vl;u*V;}CI5m(UA9Q*_v}XISeZwk>y-nC)lQDw4 z3f)YV%PuINDDbwQ?TcQn&9ES4*>PTK*d-QePoKkOhxIVLU9p%n%il`F(&MZk^v~Ej z)83#2w%F2t&LNCcaM--K0*PA_iyUBh}^*0Yg9x2a; zr+Ivf>h#PJHB~Kyqs}?Kphe7SbhV>15LIhuo`h2UCZxE@8Qr$uA4NJ6JScJYz%Lx4 z-&67m9JB+l9=7 z?x1HV8y%Xe8Ag(?%5?!7?$UB+d7fD~l#SXWQy=aS2mcH>Wx#bQWszPYy*$gjUtWvX z-y9F{{OGS-F|a-mNAlyYOx-5>GQ8AAH$*+VM9?yE1}#UFajJd_FLtevK7FKMictWU zNL#@;uDHm=3QOq;b|uFWFga&qhdL~B9AcT_gS8dB$?Dh6Vz>I;njZUC6s=}uph{fo zHDp8r2(^6XChN0gxe-CyxQ)mfy0(xW=JHix(`QtnXBkF%=MXp5e(kkd$eif{FF^;= z`kt52#IK=D-1~WK#o8$h@1oL*Rq_8|-Mf#l{&bpLhM?vRPx`!z9?digeJH-T^NmIw z3laDP(KNGaNtlvye}nOlO9&O+_Dr};6u4t*HiC(ekdAg8olZA$txRGf8YxvVy8jMc zp0-eFMv_VARayg0&mzgLu#Oa?u+Ua`)E@&uV2Y!~y;HN8(oTgs078Y%k*DAwb4JCYAJ&%9?m zn{1bK+u&#(s}NFzIFxnrzSUXaUv>6~&2Rxa!Tq zYme^Nk^@cvvn(E+p}sf|Y95k+)3G8N(57lTbb(pUY#kH&;PtLYVEP2{`D({^4vTyz zaftKtA{|vLAOJAK0RRXACvFnUbJ424@N{}WhAn+4d-U`PG@Mpk_+xV=8 z{Po^` zU!Y)X0&Q*jML;7&G6X|aGw?)BSChQ;Ui^HrZZa+{)zKXDI5b}Vs9kBuZBf5i>S5c~+HqbX1h6!dk_f9>*xloDJE=d zvCcrQNvZMX)?5l#JS0LWrCz16x{v3P5_yM9C|kA% zs?ka0VfvY;$;ezKuB(dc5B$tI>2mdjM#be&y_i`Rx))b4=c<4OXq@?JHI8Y8WQd6kpnmU2$5x@}xOLVkY9oUGIt| z%WvzAnmNn5pQ-yQKf1zpGFq-k{IN?(Lk-#8@PMkZ;Q>^O4MR{BHUPym*`O_kWy3wO zbN}OK-D3I(w!8g5msmde&){V-i|v!FbHssnDc4|7b`TB|2bNNDzOS^h0tRIQCjQ?t zxRf9OfDiPry%l&Pi1lC~hY;Y0`BIeO;y??EC0tUHeev;sVUc)=MPh+}E@jO=Qxf3_ T_JLKb4at-~xHLBe%C7hun>=}g delta 2014 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