refractoring & add timesheet service

This commit is contained in:
richardtekula
2025-11-25 07:52:31 +01:00
parent 125e30338a
commit 31297ee9a9
13 changed files with 277 additions and 463 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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(
'/',

View File

@@ -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;

View File

@@ -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',

View File

@@ -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',

View File

@@ -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(
'/',

View File

@@ -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
);

View File

@@ -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(
'/',

View File

@@ -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));
};