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

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