diff --git a/DOKUMENTACIA.md b/DOKUMENTACIA.md index 6109806..f5b0981 100644 --- a/DOKUMENTACIA.md +++ b/DOKUMENTACIA.md @@ -626,57 +626,33 @@ logUserCreation(adminId, newUserId, username, role, ip, userAgent) --- ### 13. timesheet.controller.js -**Účel:** File upload a správa timesheetov (PDF/Excel) +**Účel:** HTTP vrstva pre timesheet upload/list/download/delete (PDF/Excel) -**Databáza:** `timesheets`, `users` +**Deleguje na:** `services/timesheet.service.js` -**Metódy:** +**Toky handlerov:** ```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) + → timesheetService.uploadTimesheet({ userId, year, month, file }) + → Vráti sanitized meta (bez filePath) -getMyTimesheets(userId, filters) - → Filter: year, month (optional) - → SELECT * FROM timesheets WHERE userId - → ORDER BY uploadedAt DESC +getMyTimesheets(req, res) + → timesheetService.getTimesheetsForUser(userId, { year?, month? }) -getAllTimesheets(filters) - → Admin only! - → Filter: userId, year, month (all optional) - → LEFT JOIN users (get username, name) - → Vráti timesheets všetkých userov +getAllTimesheets(req, res) + → timesheetService.getAllTimesheets({ userId?, year?, month? }) -downloadTimesheet(timesheetId, userId, userRole) - → Check permissions: owner alebo admin - → Validate file exists on disk +downloadTimesheet(req, res) + → timesheetService.getDownloadInfo(timesheetId, { userId, role }) → 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 +deleteTimesheet(req, res) + → timesheetService.deleteTimesheet(timesheetId, { userId, role }) ``` -**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! +**Poznámky:** +- Service vrstva rieši validáciu MIME typu (PDF/XLSX), tvorbu adresárovej štruktúry `uploads/timesheets/{userId}/{year}/{month}`, permission check (owner/admin) a bezpečné mazanie súboru. +- Response payloady obsahujú len meta údaje: `id, fileName, fileType, fileSize, year, month, isGenerated, uploadedAt`. --- @@ -696,7 +672,7 @@ setPasswordSchema → confirmPassword: musí sa zhodovať → .refine() custom validation pre password match -linkEmailSchema +linkEmailSchema (momentálne neexponované; route je vypnutá) → email: valid email format, max 255 chars → emailPassword: min 1 char @@ -715,7 +691,8 @@ changeRoleSchema ``` **Použitie:** -- Všetky `/api/auth/*` routes +- Aktívne: `/api/auth/login`, `/api/auth/set-password`, `/api/auth/logout`, `/api/auth/session` +- Neaktivované: `/api/auth/link-email`, `/api/auth/skip-email` (ponechané schema pre prípadné obnovenie) - Admin user management routes --- @@ -896,16 +873,16 @@ export const methodName = async (req, res) => { ### Zoznam Route Files: 1. **admin.routes.js** - User management (Auth + Admin role) -2. **auth.routes.js** - Login, set password, link email (Mixed public/protected) +2. **auth.routes.js** - Login, set password (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) +7. **project.routes.js** - Projekty + notes + team (Auth only) +8. **todo.routes.js** - Úlohy (Auth only) +9. **time-tracking.routes.js** - Time tracking (Auth only) +10. **timesheet.routes.js** - Timesheets upload/download (Auth, admin for /all) +11. **note.routes.js** - Standalone poznámky (odpojené z app.js, ponechané len ako archív) ### Štruktúra route file: ```javascript @@ -1100,20 +1077,6 @@ Auth: Áno Rate Limit: Áno ``` -#### POST /api/auth/link-email -``` -Účel: Pripojenie email účtu -Body: { email, emailPassword } -Auth: Áno -Volá: email.service, emailAccountService -``` - -#### POST /api/auth/skip-email -``` -Účel: Preskočiť email setup -Auth: Áno -``` - #### POST /api/auth/logout ``` Účel: Odhlásenie (clear cookies) @@ -1128,12 +1091,7 @@ Auth: Áno Response: { user, authenticated: true } ``` -#### GET /api/auth/me -``` -Účel: Profil aktuálneho usera -Auth: Áno -Response: { user with emailAccounts } -``` +**Removed/disabled:** `/api/auth/link-email`, `/api/auth/skip-email`, `/api/auth/me` (nepoužíva ich FE). --- @@ -1195,13 +1153,7 @@ Auth: Áno Response: Company object ``` -#### GET /api/companies/:companyId/details -``` -Účel: Firma s všetkými reláciami -Auth: Áno -Response: { ...company, projects: [], todos: [], notes: [] } -Poznámka: NEVYUŽÍVA SA vo frontende (robí sa 3 samostatné cally) -``` +> Poznámka: Endpoint `/api/companies/:companyId/details` bol odstránený (frontend používa samostatné volania). #### POST /api/companies ``` @@ -1270,13 +1222,7 @@ Auth: Áno Auth: Áno ``` -#### GET /api/projects/:projectId/details -``` -Účel: Projekt s reláciami -Auth: Áno -Response: { ...project, company, todos, notes, timesheets, assignedUsers } -Poznámka: NEVYUŽÍVA SA vo frontende -``` +> Poznámka: Endpoint `/api/projects/:projectId/details` bol odstránený (nepoužíva ho FE). #### POST /api/projects ``` @@ -1389,26 +1335,13 @@ Query: všetky parametre optional Auth: Áno ``` -#### GET /api/todos/my?status= -``` -Účel: Moje úlohy (assigned to current user) -Auth: Áno -Poznámka: NEVYUŽÍVA SA vo frontende -``` - #### GET /api/todos/:todoId ``` Účel: Detail todo Auth: Áno ``` -#### GET /api/todos/:todoId/details -``` -Účel: Todo s reláciami -Auth: Áno -Response: { ...todo, project, company, assignedUser, notes } -Poznámka: NEVYUŽÍVA SA vo frontende -``` +> Poznámka: Endpoints `/api/todos/my` a `/api/todos/:todoId/details` boli odstránené (nepoužíva ich FE). #### POST /api/todos ``` @@ -1445,8 +1378,8 @@ Response: Updated todo ### 📝 NOTES (Standalone) -**POZNÁMKA:** Všetky standalone note routes sú **NEVYUŽITÉ** vo frontende. -Notes sa používajú iba cez nested routes (companies/:id/notes, projects/:id/notes). +**POZNÁMKA:** Standalone note routes sú odpojené z app.js a frontend ich nepoužíva. +Poznámky sa riešia iba cez nested routes (companies/:id/notes, projects/:id/notes). #### GET /api/notes?search=&companyId=&projectId=&todoId=&contactId= ``` @@ -1534,26 +1467,7 @@ Auth: Áno Efekt: CASCADE delete emails ``` -#### POST /api/contacts/:contactId/link-company?accountId=uuid -``` -Účel: Linknúť firmu k kontaktu -Body: { companyId* } -Poznámka: NEVYUŽÍVA SA vo frontende -``` - -#### POST /api/contacts/:contactId/unlink-company?accountId=uuid -``` -Účel: Odlinkovať firmu od kontaktu -Poznámka: NEVYUŽÍVA SA vo frontende -``` - -#### POST /api/contacts/:contactId/create-company?accountId=uuid -``` -Účel: Vytvoriť firmu z kontaktu -Body: (optional) { name, email, phone, ... } -Poznámka: NEVYUŽÍVA SA vo frontende -Efekt: Vytvorí company, nastaví contact.companyId -``` +> Poznámka: Link/unlink company a create-company routes boli odstránené (FE ich nevolá). --- @@ -1566,12 +1480,7 @@ Auth: Áno Response: Array of accounts (bez passwords!) ``` -#### GET /api/email-accounts/:id -``` -Účel: Detail email accountu -Auth: Áno -Poznámka: NEVYUŽÍVA SA vo frontende -``` +> Poznámka: Endpoints `/api/email-accounts/:id`, `/:id/password`, `/:id/status` boli odstránené (nepoužíva ich FE). #### POST /api/email-accounts ``` @@ -1588,22 +1497,6 @@ Efekt: Volá: email.service, password.encryptPassword() ``` -#### PATCH /api/email-accounts/:id/password -``` -Účel: Zmeniť heslo k emailu -Body: { emailPassword* } -Auth: Áno -Poznámka: NEVYUŽÍVA SA vo frontende -``` - -#### PATCH /api/email-accounts/:id/status -``` -Účel: Aktivovať/deaktivovať email account -Body: { isActive* } -Auth: Áno -Poznámka: NEVYUŽÍVA SA vo frontende -``` - #### POST /api/email-accounts/:id/set-primary ``` Účel: Nastaviť ako primárny email @@ -1681,25 +1574,13 @@ Auth: Áno Efekt: UPDATE emails SET isRead = true + sync JMAP ``` -#### GET /api/emails/contact/:contactId?accountId=uuid -``` -Účel: Emaily od konkrétneho kontaktu -Poznámka: NEVYUŽÍVA SA vo frontende -``` - #### POST /api/emails/contact/:contactId/read?accountId=uuid ``` Účel: Označiť všetky emaily kontaktu ako prečítané Auth: Áno ``` -#### PATCH /api/emails/:jmapId/read?accountId=uuid -``` -Účel: Označiť jeden email ako read/unread -Body: { isRead* } -Auth: Áno -Poznámka: NEVYUŽÍVA SA vo frontende -``` +> Poznámka: Endpoints `/api/emails/contact/:contactId` a `/api/emails/:jmapId/read` boli odstránené (FE ich nevolá). #### POST /api/emails/reply ``` @@ -2294,13 +2175,18 @@ console.log('[DEBUG] JMAP validation:', valid); --- **Vytvorené:** 2025-11-21 -**Posledná aktualizácia:** 2025-11-24 +**Posledná aktualizácia:** 2025-11-25 **Autor:** CRM Server Team --- ## CHANGELOG +### 2025-11-25 - Cleanup + Timesheet Service +- Presunutá biznis logika timesheetov do `services/timesheet.service.js`, controller ostáva tenký. +- Odstránené nevyužité routes (FE): auth link-email/skip-email/me, company/project/todo details, contacts link/unlink/create-company, email-account detail/password/status, emails contact listing + PATCH read, standalone notes odpojené z app.js. +- Dokumentácia zosúladená s aktuálnymi endpointmi. + ### 2025-11-24 - Additions **Pridané sekcie:** 1. **VALIDATORS** - Kompletná dokumentácia všetkých Zod schemas diff --git a/src/app.js b/src/app.js index 7555e26..4c2e8f5 100644 --- a/src/app.js +++ b/src/app.js @@ -21,7 +21,6 @@ import timesheetRoutes from './routes/timesheet.routes.js'; import companyRoutes from './routes/company.routes.js'; import projectRoutes from './routes/project.routes.js'; import todoRoutes from './routes/todo.routes.js'; -import noteRoutes from './routes/note.routes.js'; import timeTrackingRoutes from './routes/time-tracking.routes.js'; const app = express(); @@ -82,7 +81,6 @@ app.use('/api/timesheets', timesheetRoutes); app.use('/api/companies', companyRoutes); app.use('/api/projects', projectRoutes); app.use('/api/todos', todoRoutes); -app.use('/api/notes', noteRoutes); app.use('/api/time-tracking', timeTrackingRoutes); // Basic route diff --git a/src/controllers/timesheet.controller.js b/src/controllers/timesheet.controller.js index 6946d4d..c3023d1 100644 --- a/src/controllers/timesheet.controller.js +++ b/src/controllers/timesheet.controller.js @@ -1,97 +1,42 @@ -import { db } from '../config/database.js'; -import { timesheets, users } from '../db/schema.js'; -import { eq, and, desc } from 'drizzle-orm'; -import { formatErrorResponse, NotFoundError, BadRequestError, ForbiddenError } from '../utils/errors.js'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import * as timesheetService from '../services/timesheet.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; /** * Upload timesheet * POST /api/timesheets/upload */ export const uploadTimesheet = async (req, res) => { - const { year, month } = req.body; - const userId = req.userId; - const file = req.file; - - let savedFilePath = null; - try { - if (!file) { - throw new BadRequestError('Súbor nebol nahraný'); + const { year, month, userId: requestUserId } = req.body; + + // Determine target userId: + // - If requestUserId is provided and user is admin, use requestUserId + // - Otherwise, use req.userId (upload for themselves) + let targetUserId = req.userId; + if (requestUserId) { + if (req.user.role !== 'admin') { + const errorResponse = formatErrorResponse( + new Error('Iba admin môže nahrávať timesheets za iných používateľov'), + process.env.NODE_ENV === 'development' + ); + return res.status(403).json(errorResponse); + } + targetUserId = requestUserId; } - // Validate file type - const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel']; - if (!allowedTypes.includes(file.mimetype)) { - throw new BadRequestError('Neplatný typ súboru. Povolené sú iba PDF a Excel súbory.'); - } - - // Determine file type - let fileType = 'pdf'; - if (file.mimetype.includes('sheet') || file.mimetype.includes('excel')) { - fileType = 'xlsx'; - } - - // Create directory structure: uploads/timesheets/{userId}/{year}/{month} - const uploadsDir = path.join(process.cwd(), 'uploads', 'timesheets'); - const userDir = path.join(uploadsDir, userId, year.toString(), month.toString()); - await fs.mkdir(userDir, { recursive: true }); - - // Generate unique filename - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - const ext = path.extname(file.originalname); - const name = path.basename(file.originalname, ext); - const filename = `${name}-${uniqueSuffix}${ext}`; - savedFilePath = path.join(userDir, filename); - - // Save file from memory buffer to disk - await fs.writeFile(savedFilePath, file.buffer); - - // Create timesheet record - const [newTimesheet] = await db - .insert(timesheets) - .values({ - userId, - fileName: file.originalname, - filePath: savedFilePath, - fileType, - fileSize: file.size, - year: parseInt(year), - month: parseInt(month), - isGenerated: false, - }) - .returning(); + const timesheet = await timesheetService.uploadTimesheet({ + userId: targetUserId, + year, + month, + file: req.file, + }); res.status(201).json({ success: true, - data: { - timesheet: { - id: newTimesheet.id, - fileName: newTimesheet.fileName, - fileType: newTimesheet.fileType, - fileSize: newTimesheet.fileSize, - year: newTimesheet.year, - month: newTimesheet.month, - isGenerated: newTimesheet.isGenerated, - uploadedAt: newTimesheet.uploadedAt, - }, - }, + data: { timesheet }, message: 'Timesheet bol úspešne nahraný', }); } catch (error) { - // If error occurs and file was saved, delete it - if (savedFilePath) { - try { - await fs.unlink(savedFilePath); - } catch (unlinkError) { - console.error('Failed to delete file:', unlinkError); - } - } const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); res.status(error.statusCode || 500).json(errorResponse); } @@ -102,34 +47,10 @@ export const uploadTimesheet = async (req, res) => { * GET /api/timesheets/my */ export const getMyTimesheets = async (req, res) => { - const userId = req.userId; - const { year, month } = req.query; - try { - let conditions = [eq(timesheets.userId, userId)]; + const { year, month } = req.query; - if (year) { - conditions.push(eq(timesheets.year, parseInt(year))); - } - - if (month) { - conditions.push(eq(timesheets.month, parseInt(month))); - } - - const userTimesheets = await db - .select({ - id: timesheets.id, - fileName: timesheets.fileName, - fileType: timesheets.fileType, - fileSize: timesheets.fileSize, - year: timesheets.year, - month: timesheets.month, - isGenerated: timesheets.isGenerated, - uploadedAt: timesheets.uploadedAt, - }) - .from(timesheets) - .where(and(...conditions)) - .orderBy(desc(timesheets.uploadedAt)); + const userTimesheets = await timesheetService.getTimesheetsForUser(req.userId, { year, month }); res.status(200).json({ success: true, @@ -149,42 +70,10 @@ export const getMyTimesheets = async (req, res) => { * GET /api/timesheets/all */ export const getAllTimesheets = async (req, res) => { - const { userId: filterUserId, year, month } = req.query; - try { - let conditions = []; + const { userId, year, month } = req.query; - if (filterUserId) { - conditions.push(eq(timesheets.userId, filterUserId)); - } - - if (year) { - conditions.push(eq(timesheets.year, parseInt(year))); - } - - if (month) { - conditions.push(eq(timesheets.month, parseInt(month))); - } - - const allTimesheets = await db - .select({ - id: timesheets.id, - fileName: timesheets.fileName, - fileType: timesheets.fileType, - fileSize: timesheets.fileSize, - year: timesheets.year, - month: timesheets.month, - isGenerated: timesheets.isGenerated, - uploadedAt: timesheets.uploadedAt, - userId: timesheets.userId, - username: users.username, - firstName: users.firstName, - lastName: users.lastName, - }) - .from(timesheets) - .leftJoin(users, eq(timesheets.userId, users.id)) - .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy(desc(timesheets.uploadedAt)); + const allTimesheets = await timesheetService.getAllTimesheets({ userId, year, month }); res.status(200).json({ success: true, @@ -204,35 +93,14 @@ export const getAllTimesheets = async (req, res) => { * GET /api/timesheets/:timesheetId/download */ export const downloadTimesheet = async (req, res) => { - const { timesheetId } = req.params; - const userId = req.userId; - const userRole = req.user.role; // Fix: use req.user.role instead of req.userRole - try { - const [timesheet] = await db - .select() - .from(timesheets) - .where(eq(timesheets.id, timesheetId)) - .limit(1); + const { timesheetId } = req.params; + const { filePath, fileName } = await timesheetService.getDownloadInfo(timesheetId, { + userId: req.userId, + role: req.user.role, + }); - if (!timesheet) { - throw new NotFoundError('Timesheet nenájdený'); - } - - // Check permissions: user can only download their own timesheets, admin can download all - if (userRole !== 'admin' && timesheet.userId !== userId) { - throw new ForbiddenError('Nemáte oprávnenie stiahnuť tento timesheet'); - } - - // Check if file exists - try { - await fs.access(timesheet.filePath); - } catch { - throw new NotFoundError('Súbor nebol nájdený na serveri'); - } - - // Send file - res.download(timesheet.filePath, timesheet.fileName); + res.download(filePath, fileName); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); res.status(error.statusCode || 500).json(errorResponse); @@ -244,36 +112,13 @@ export const downloadTimesheet = async (req, res) => { * DELETE /api/timesheets/:timesheetId */ export const deleteTimesheet = async (req, res) => { - const { timesheetId } = req.params; - const userId = req.userId; - const userRole = req.user.role; // Fix: use req.user.role instead of req.userRole - try { - const [timesheet] = await db - .select() - .from(timesheets) - .where(eq(timesheets.id, timesheetId)) - .limit(1); + const { timesheetId } = req.params; - if (!timesheet) { - throw new NotFoundError('Timesheet nenájdený'); - } - - // Check permissions: user can only delete their own timesheets, admin can delete all - if (userRole !== 'admin' && timesheet.userId !== userId) { - throw new ForbiddenError('Nemáte oprávnenie zmazať tento timesheet'); - } - - // Delete file from filesystem - try { - await fs.unlink(timesheet.filePath); - } catch (unlinkError) { - console.error('Failed to delete file from filesystem:', unlinkError); - // Continue with database deletion even if file deletion fails - } - - // Delete from database - await db.delete(timesheets).where(eq(timesheets.id, timesheetId)); + await timesheetService.deleteTimesheet(timesheetId, { + userId: req.userId, + role: req.user.role, + }); res.status(200).json({ success: true, diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index c5188f9..54e3b00 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -5,7 +5,6 @@ import { validateBody } from '../middlewares/security/validateInput.js'; import { loginSchema, setPasswordSchema, - linkEmailSchema, } from '../validators/auth.validators.js'; import { loginRateLimiter, @@ -39,25 +38,10 @@ router.post( authController.setPassword ); -// KROK 3: Link email -router.post( - '/link-email', - authenticate, - sensitiveOperationLimiter, - validateBody(linkEmailSchema), - authController.linkEmail -); - -// KROK 3 (alternatíva): Skip email -router.post('/skip-email', authenticate, authController.skipEmail); - // Logout router.post('/logout', authenticate, authController.logout); // Get current session router.get('/session', authenticate, authController.getSession); -// Get current user profile -router.get('/me', authenticate, authController.getMe); - export default router; diff --git a/src/routes/company.routes.js b/src/routes/company.routes.js index 480b5b5..ff44ce8 100644 --- a/src/routes/company.routes.js +++ b/src/routes/company.routes.js @@ -24,13 +24,6 @@ router.get( companyController.getCompanyById ); -// Get company with relations (projects, todos, notes) -router.get( - '/:companyId/details', - validateParams(z.object({ companyId: z.string().uuid() })), - companyController.getCompanyWithRelations -); - // Create new company router.post( '/', diff --git a/src/routes/contact.routes.js b/src/routes/contact.routes.js index f6b3d4e..2f04422 100644 --- a/src/routes/contact.routes.js +++ b/src/routes/contact.routes.js @@ -53,38 +53,4 @@ router.delete( contactController.removeContact ); -// Link company to contact -router.post( - '/:contactId/link-company', - validateParams(z.object({ contactId: z.string().uuid() })), - validateBody(z.object({ companyId: z.string().uuid() })), - contactController.linkCompanyToContact -); - -// Unlink company from contact -router.post( - '/:contactId/unlink-company', - validateParams(z.object({ contactId: z.string().uuid() })), - contactController.unlinkCompanyFromContact -); - -// Create company from contact -router.post( - '/:contactId/create-company', - validateParams(z.object({ contactId: z.string().uuid() })), - validateBody( - z.object({ - name: z.string().optional(), - email: z.string().email().optional(), - phone: z.string().optional(), - address: z.string().optional(), - city: z.string().optional(), - country: z.string().optional(), - website: z.string().url().optional(), - description: z.string().optional(), - }) - ), - contactController.createCompanyFromContact -); - export default router; diff --git a/src/routes/crm-email.routes.js b/src/routes/crm-email.routes.js index 876c3bc..3712910 100644 --- a/src/routes/crm-email.routes.js +++ b/src/routes/crm-email.routes.js @@ -42,13 +42,6 @@ router.post( crmEmailController.markThreadRead ); -// Get emails for a specific contact -router.get( - '/contact/:contactId', - validateParams(z.object({ contactId: z.string().uuid() })), - crmEmailController.getContactEmails -); - // Mark all emails from contact as read router.post( '/contact/:contactId/read', @@ -56,14 +49,6 @@ router.post( crmEmailController.markContactEmailsRead ); -// Mark email as read/unread -router.patch( - '/:jmapId/read', - validateParams(z.object({ jmapId: z.string() })), - validateBody(z.object({ isRead: z.boolean() })), - crmEmailController.markAsRead -); - // Send email reply router.post( '/reply', diff --git a/src/routes/email-account.routes.js b/src/routes/email-account.routes.js index 471ab47..dfbf6a7 100644 --- a/src/routes/email-account.routes.js +++ b/src/routes/email-account.routes.js @@ -21,13 +21,6 @@ router.use(authenticate); // Get all email accounts for logged-in user router.get('/', emailAccountController.getEmailAccounts); -// Get specific email account -router.get( - '/:id', - validateParams(z.object({ id: z.string().uuid() })), - emailAccountController.getEmailAccount -); - // Create new email account router.post( '/', @@ -36,23 +29,6 @@ router.post( emailAccountController.createEmailAccount ); -// Update email account password -router.patch( - '/:id/password', - validateParams(z.object({ id: z.string().uuid() })), - validateBody(z.object({ emailPassword: z.string().min(1) })), - sensitiveOperationLimiter, - emailAccountController.updateEmailAccountPassword -); - -// Toggle email account status -router.patch( - '/:id/status', - validateParams(z.object({ id: z.string().uuid() })), - validateBody(z.object({ isActive: z.boolean() })), - emailAccountController.toggleEmailAccountStatus -); - // Set email account as primary router.post( '/:id/set-primary', diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 829d726..4188ee3 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -24,13 +24,6 @@ router.get( projectController.getProjectById ); -// Get project with relations (company, todos, notes, timesheets) -router.get( - '/:projectId/details', - validateParams(z.object({ projectId: z.string().uuid() })), - projectController.getProjectWithRelations -); - // Create new project router.post( '/', diff --git a/src/routes/timesheet.routes.js b/src/routes/timesheet.routes.js index 0da4de4..e41f6bb 100644 --- a/src/routes/timesheet.routes.js +++ b/src/routes/timesheet.routes.js @@ -49,6 +49,7 @@ router.post( validateBody(z.object({ year: z.string().regex(/^\d{4}$/, 'Rok musí byť 4-miestne číslo'), month: z.string().regex(/^([1-9]|1[0-2])$/, 'Mesiac musí byť číslo od 1 do 12'), + userId: z.string().uuid().optional(), // Optional: admin can upload for other users })), timesheetController.uploadTimesheet ); diff --git a/src/routes/todo.routes.js b/src/routes/todo.routes.js index 6321dac..b4af5d7 100644 --- a/src/routes/todo.routes.js +++ b/src/routes/todo.routes.js @@ -17,9 +17,6 @@ router.use(authenticate); // Get all todos router.get('/', todoController.getAllTodos); -// Get my todos (assigned to current user) -router.get('/my', todoController.getMyTodos); - // Get todo by ID router.get( '/:todoId', @@ -27,13 +24,6 @@ router.get( todoController.getTodoById ); -// Get todo with relations (project, company, assigned user, notes) -router.get( - '/:todoId/details', - validateParams(z.object({ todoId: z.string().uuid() })), - todoController.getTodoWithRelations -); - // Create new todo router.post( '/', diff --git a/src/services/timesheet.service.js b/src/services/timesheet.service.js new file mode 100644 index 0000000..ce5a70f --- /dev/null +++ b/src/services/timesheet.service.js @@ -0,0 +1,197 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { db } from '../config/database.js'; +import { timesheets, users } from '../db/schema.js'; +import { and, desc, eq } from 'drizzle-orm'; +import { BadRequestError, ForbiddenError, NotFoundError } from '../utils/errors.js'; + +const ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', +]; + +const BASE_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'timesheets'); + +const sanitizeTimesheet = (record) => ({ + id: record.id, + fileName: record.fileName, + fileType: record.fileType, + fileSize: record.fileSize, + year: record.year, + month: record.month, + isGenerated: record.isGenerated, + uploadedAt: record.uploadedAt, +}); + +const detectFileType = (mimeType) => { + if (!ALLOWED_MIME_TYPES.includes(mimeType)) { + throw new BadRequestError('Neplatný typ súboru. Povolené sú iba PDF a Excel súbory.'); + } + return mimeType.includes('sheet') || mimeType.includes('excel') ? 'xlsx' : 'pdf'; +}; + +const buildDestinationPath = (userId, year, month, originalName) => { + const ext = path.extname(originalName); + const name = path.basename(originalName, ext); + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const filename = `${name}-${uniqueSuffix}${ext}`; + const folder = path.join(BASE_UPLOAD_DIR, userId, year.toString(), month.toString()); + const filePath = path.join(folder, filename); + + return { folder, filename, filePath }; +}; + +const ensureTimesheetExists = async (timesheetId) => { + const [timesheet] = await db + .select() + .from(timesheets) + .where(eq(timesheets.id, timesheetId)) + .limit(1); + + if (!timesheet) { + throw new NotFoundError('Timesheet nenájdený'); + } + + return timesheet; +}; + +const assertAccess = (timesheet, { userId, role }) => { + if (role !== 'admin' && timesheet.userId !== userId) { + throw new ForbiddenError('Nemáte oprávnenie k tomuto timesheetu'); + } +}; + +const safeUnlink = async (filePath) => { + if (!filePath) return; + try { + await fs.unlink(filePath); + } catch (error) { + // Keep server responsive even if cleanup fails + console.error('Failed to delete file:', error); + } +}; + +export const uploadTimesheet = async ({ userId, year, month, file }) => { + if (!file) { + throw new BadRequestError('Súbor nebol nahraný'); + } + + const parsedYear = parseInt(year); + const parsedMonth = parseInt(month); + const fileType = detectFileType(file.mimetype); + const { folder, filename, filePath } = buildDestinationPath(userId, parsedYear, parsedMonth, file.originalname); + + await fs.mkdir(folder, { recursive: true }); + + try { + await fs.writeFile(filePath, file.buffer); + + const [newTimesheet] = await db + .insert(timesheets) + .values({ + userId, + fileName: file.originalname, + filePath, + fileType, + fileSize: file.size, + year: parsedYear, + month: parsedMonth, + isGenerated: false, + }) + .returning(); + + return sanitizeTimesheet(newTimesheet); + } catch (error) { + await safeUnlink(filePath); + throw error; + } +}; + +export const getTimesheetsForUser = async (userId, { year, month } = {}) => { + const conditions = [eq(timesheets.userId, userId)]; + + if (year) { + conditions.push(eq(timesheets.year, parseInt(year))); + } + + if (month) { + conditions.push(eq(timesheets.month, parseInt(month))); + } + + return db + .select({ + id: timesheets.id, + fileName: timesheets.fileName, + fileType: timesheets.fileType, + fileSize: timesheets.fileSize, + year: timesheets.year, + month: timesheets.month, + isGenerated: timesheets.isGenerated, + uploadedAt: timesheets.uploadedAt, + }) + .from(timesheets) + .where(and(...conditions)) + .orderBy(desc(timesheets.uploadedAt)); +}; + +export const getAllTimesheets = async ({ userId, year, month } = {}) => { + const conditions = []; + + if (userId) { + conditions.push(eq(timesheets.userId, userId)); + } + + if (year) { + conditions.push(eq(timesheets.year, parseInt(year))); + } + + if (month) { + conditions.push(eq(timesheets.month, parseInt(month))); + } + + return db + .select({ + id: timesheets.id, + fileName: timesheets.fileName, + fileType: timesheets.fileType, + fileSize: timesheets.fileSize, + year: timesheets.year, + month: timesheets.month, + isGenerated: timesheets.isGenerated, + uploadedAt: timesheets.uploadedAt, + userId: timesheets.userId, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(timesheets) + .leftJoin(users, eq(timesheets.userId, users.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(timesheets.uploadedAt)); +}; + +export const getDownloadInfo = async (timesheetId, { userId, role }) => { + const timesheet = await ensureTimesheetExists(timesheetId); + assertAccess(timesheet, { userId, role }); + + try { + await fs.access(timesheet.filePath); + } catch { + throw new NotFoundError('Súbor nebol nájdený na serveri'); + } + + return { + timesheet, + filePath: timesheet.filePath, + fileName: timesheet.fileName, + }; +}; + +export const deleteTimesheet = async (timesheetId, { userId, role }) => { + const timesheet = await ensureTimesheetExists(timesheetId); + assertAccess(timesheet, { userId, role }); + + await safeUnlink(timesheet.filePath); + await db.delete(timesheets).where(eq(timesheets.id, timesheetId)); +}; diff --git a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1763979698080.xlsx b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1764053316289.xlsx similarity index 52% rename from uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1763979698080.xlsx rename to uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1764053316289.xlsx index b5b9c8f..8b024ee 100644 Binary files a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1763979698080.xlsx and b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2025/11/timesheet-2025-11-1764053316289.xlsx differ