feat: Add services, company documents, company timesheet export
- Add services table and CRUD endpoints (/api/services) - Add company documents upload/download functionality - Add company timesheet XLSX export endpoint - Remove admin requirement from event routes (all authenticated users can manage events) - Add service validators Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import auditRoutes from './routes/audit.routes.js';
|
||||
import eventRoutes from './routes/event.routes.js';
|
||||
import messageRoutes from './routes/message.routes.js';
|
||||
import userRoutes from './routes/user.routes.js';
|
||||
import serviceRoutes from './routes/service.routes.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -124,6 +125,7 @@ app.use('/api/audit-logs', auditRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/messages', messageRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/services', serviceRoutes);
|
||||
|
||||
// Basic route
|
||||
app.get('/', (req, res) => {
|
||||
|
||||
88
src/controllers/company-document.controller.js
Normal file
88
src/controllers/company-document.controller.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as companyDocumentService from '../services/company-document.service.js';
|
||||
|
||||
/**
|
||||
* Get all documents for a company
|
||||
* GET /api/companies/:companyId/documents
|
||||
*/
|
||||
export const getDocuments = async (req, res, next) => {
|
||||
try {
|
||||
const { companyId } = req.params;
|
||||
|
||||
const documents = await companyDocumentService.getDocumentsByCompanyId(companyId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: documents.length,
|
||||
data: documents,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a document for a company
|
||||
* POST /api/companies/:companyId/documents
|
||||
*/
|
||||
export const uploadDocument = async (req, res, next) => {
|
||||
try {
|
||||
const { companyId } = req.params;
|
||||
const userId = req.userId;
|
||||
const { description } = req.body;
|
||||
|
||||
const document = await companyDocumentService.uploadDocument({
|
||||
companyId,
|
||||
userId,
|
||||
file: req.file,
|
||||
description,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: document,
|
||||
message: 'Dokument bol nahraný',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a document
|
||||
* GET /api/companies/:companyId/documents/:docId/download
|
||||
*/
|
||||
export const downloadDocument = async (req, res, next) => {
|
||||
try {
|
||||
const { companyId, docId } = req.params;
|
||||
|
||||
const { filePath, fileName, fileType } = await companyDocumentService.getDocumentForDownload(
|
||||
companyId,
|
||||
docId
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', fileType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
* DELETE /api/companies/:companyId/documents/:docId
|
||||
*/
|
||||
export const deleteDocument = async (req, res, next) => {
|
||||
try {
|
||||
const { companyId, docId } = req.params;
|
||||
|
||||
const result = await companyDocumentService.deleteDocument(companyId, docId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
101
src/controllers/service.controller.js
Normal file
101
src/controllers/service.controller.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as serviceService from '../services/service.service.js';
|
||||
|
||||
/**
|
||||
* Get all services
|
||||
* GET /api/services
|
||||
*/
|
||||
export const getAllServices = async (req, res, next) => {
|
||||
try {
|
||||
const services = await serviceService.getAllServices();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: services.length,
|
||||
data: services,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service by ID
|
||||
* GET /api/services/:serviceId
|
||||
*/
|
||||
export const getServiceById = async (req, res, next) => {
|
||||
try {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
const service = await serviceService.getServiceById(serviceId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: service,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new service
|
||||
* POST /api/services
|
||||
* Body: { name, price, description }
|
||||
*/
|
||||
export const createService = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const data = req.body;
|
||||
|
||||
const service = await serviceService.createService(userId, data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: service,
|
||||
message: 'Služba bola vytvorená',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update service
|
||||
* PUT /api/services/:serviceId
|
||||
* Body: { name, price, description }
|
||||
*/
|
||||
export const updateService = async (req, res, next) => {
|
||||
try {
|
||||
const { serviceId } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
const service = await serviceService.updateService(serviceId, data);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: service,
|
||||
message: 'Služba bola aktualizovaná',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete service
|
||||
* DELETE /api/services/:serviceId
|
||||
*/
|
||||
export const deleteService = async (req, res, next) => {
|
||||
try {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
const result = await serviceService.deleteService(serviceId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
@@ -325,3 +325,37 @@ export const getMonthlyStats = async (req, res, next) => {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate timesheet for a specific company
|
||||
* POST /api/time-tracking/generate-company-timesheet
|
||||
* Body: { year, month, companyId }
|
||||
*/
|
||||
export const generateCompanyTimesheet = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const { year, month, companyId } = req.body;
|
||||
|
||||
if (!year || !month || !companyId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Year, month and companyId are required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await timeTrackingService.generateCompanyTimesheet(
|
||||
userId,
|
||||
parseInt(year),
|
||||
parseInt(month),
|
||||
companyId
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Timesheet pre firmu ${result.companyName} bol vygenerovaný`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,6 +284,32 @@ export const timeEntries = pgTable('time_entries', {
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Company Documents table - dokumenty nahrané k firme
|
||||
export const companyDocuments = pgTable('company_documents', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }).notNull(),
|
||||
fileName: text('file_name').notNull(), // unikátny názov súboru na disku
|
||||
originalName: text('original_name').notNull(), // pôvodný názov súboru
|
||||
filePath: text('file_path').notNull(), // cesta k súboru
|
||||
fileType: text('file_type').notNull(), // MIME typ
|
||||
fileSize: integer('file_size').notNull(), // veľkosť v bytoch
|
||||
description: text('description'),
|
||||
uploadedBy: uuid('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Services table - služby ponúkané firmou
|
||||
export const services = pgTable('services', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
price: text('price').notNull(), // stored as text for flexibility with decimal
|
||||
description: text('description'),
|
||||
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Messages table - interná komunikácia medzi používateľmi
|
||||
export const messages = pgTable('messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import * as companyController from '../controllers/company.controller.js';
|
||||
import * as personalContactController from '../controllers/personal-contact.controller.js';
|
||||
import * as companyDocumentController from '../controllers/company-document.controller.js';
|
||||
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||
import { checkCompanyAccess } from '../middlewares/auth/resourceAccessMiddleware.js';
|
||||
@@ -8,6 +10,14 @@ import { validateBody, validateParams } from '../middlewares/security/validateIn
|
||||
import { createCompanySchema, updateCompanySchema, createCompanyReminderSchema, updateCompanyReminderSchema } from '../validators/crm.validators.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Configure multer for file uploads (memory storage)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB max
|
||||
},
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// All company routes require authentication
|
||||
@@ -198,4 +208,40 @@ router.get(
|
||||
personalContactController.getContactsByCompany
|
||||
);
|
||||
|
||||
// Company Documents
|
||||
router.get(
|
||||
'/:companyId/documents',
|
||||
validateParams(z.object({ companyId: z.string().uuid() })),
|
||||
checkCompanyAccess,
|
||||
companyDocumentController.getDocuments
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:companyId/documents',
|
||||
validateParams(z.object({ companyId: z.string().uuid() })),
|
||||
checkCompanyAccess,
|
||||
upload.single('file'),
|
||||
companyDocumentController.uploadDocument
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:companyId/documents/:docId/download',
|
||||
validateParams(z.object({
|
||||
companyId: z.string().uuid(),
|
||||
docId: z.string().uuid()
|
||||
})),
|
||||
checkCompanyAccess,
|
||||
companyDocumentController.downloadDocument
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:companyId/documents/:docId',
|
||||
requireAdmin,
|
||||
validateParams(z.object({
|
||||
companyId: z.string().uuid(),
|
||||
docId: z.string().uuid()
|
||||
})),
|
||||
companyDocumentController.deleteDocument
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from 'express';
|
||||
import * as eventController from '../controllers/event.controller.js';
|
||||
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||
import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js';
|
||||
import { createEventSchema, updateEventSchema } from '../validators/crm.validators.js';
|
||||
import { z } from 'zod';
|
||||
@@ -39,42 +38,38 @@ router.get(
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/events - Vytvoriť event (iba admin)
|
||||
* POST /api/events - Vytvoriť event (všetci autentifikovaní používatelia)
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requireAdmin,
|
||||
validateBody(createEventSchema),
|
||||
eventController.createEvent
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/events/:eventId - Upraviť event (iba admin)
|
||||
* PUT /api/events/:eventId - Upraviť event (všetci autentifikovaní používatelia)
|
||||
*/
|
||||
router.put(
|
||||
'/:eventId',
|
||||
requireAdmin,
|
||||
validateParams(eventIdSchema),
|
||||
validateBody(updateEventSchema),
|
||||
eventController.updateEvent
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/events/:eventId - Zmazať event (iba admin)
|
||||
* DELETE /api/events/:eventId - Zmazať event (všetci autentifikovaní používatelia)
|
||||
*/
|
||||
router.delete(
|
||||
'/:eventId',
|
||||
requireAdmin,
|
||||
validateParams(eventIdSchema),
|
||||
eventController.deleteEvent
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/events/:eventId/notify - Odoslať notifikácie priradeným používateľom (iba admin)
|
||||
* POST /api/events/:eventId/notify - Odoslať notifikácie priradeným používateľom (všetci autentifikovaní používatelia)
|
||||
*/
|
||||
router.post(
|
||||
'/:eventId/notify',
|
||||
requireAdmin,
|
||||
validateParams(eventIdSchema),
|
||||
eventController.sendEventNotification
|
||||
);
|
||||
|
||||
63
src/routes/service.routes.js
Normal file
63
src/routes/service.routes.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import express from 'express';
|
||||
import * as serviceController from '../controllers/service.controller.js';
|
||||
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
||||
import { createServiceSchema, updateServiceSchema } from '../validators/crm.validators.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const serviceIdSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
});
|
||||
|
||||
// All service routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
/**
|
||||
* GET /api/services - Get all services (all authenticated users)
|
||||
*/
|
||||
router.get('/', serviceController.getAllServices);
|
||||
|
||||
/**
|
||||
* GET /api/services/:serviceId - Get service by ID (all authenticated users)
|
||||
*/
|
||||
router.get(
|
||||
'/:serviceId',
|
||||
validateParams(serviceIdSchema),
|
||||
serviceController.getServiceById
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/services - Create new service (admin only)
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requireAdmin,
|
||||
validateBody(createServiceSchema),
|
||||
serviceController.createService
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/services/:serviceId - Update service (admin only)
|
||||
*/
|
||||
router.put(
|
||||
'/:serviceId',
|
||||
requireAdmin,
|
||||
validateParams(serviceIdSchema),
|
||||
validateBody(updateServiceSchema),
|
||||
serviceController.updateService
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/services/:serviceId - Delete service (admin only)
|
||||
*/
|
||||
router.delete(
|
||||
'/:serviceId',
|
||||
requireAdmin,
|
||||
validateParams(serviceIdSchema),
|
||||
serviceController.deleteService
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -63,6 +63,19 @@ router.post(
|
||||
timeTrackingController.generateMonthlyTimesheet
|
||||
);
|
||||
|
||||
// Generate company timesheet (XLSX)
|
||||
router.post(
|
||||
'/generate-company-timesheet',
|
||||
validateBody(
|
||||
z.object({
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
month: z.number().int().min(1).max(12),
|
||||
companyId: z.string().uuid('Neplatný formát company ID'),
|
||||
})
|
||||
),
|
||||
timeTrackingController.generateCompanyTimesheet
|
||||
);
|
||||
|
||||
// Get monthly statistics
|
||||
router.get(
|
||||
'/stats/monthly/:year/:month',
|
||||
|
||||
166
src/services/company-document.service.js
Normal file
166
src/services/company-document.service.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { db } from '../config/database.js';
|
||||
import { companyDocuments, companies, users } from '../db/schema.js';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { BadRequestError, NotFoundError } from '../utils/errors.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const BASE_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'documents');
|
||||
|
||||
const buildDestinationPath = (companyId, 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, companyId);
|
||||
const filePath = path.join(folder, filename);
|
||||
|
||||
return { folder, filename, filePath };
|
||||
};
|
||||
|
||||
const safeUnlink = async (filePath) => {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete file', error);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCompanyExists = async (companyId) => {
|
||||
const [company] = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.limit(1);
|
||||
|
||||
if (!company) {
|
||||
throw new NotFoundError('Firma nenájdená');
|
||||
}
|
||||
|
||||
return company;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all documents for a company
|
||||
*/
|
||||
export const getDocumentsByCompanyId = async (companyId) => {
|
||||
await ensureCompanyExists(companyId);
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: companyDocuments.id,
|
||||
companyId: companyDocuments.companyId,
|
||||
fileName: companyDocuments.fileName,
|
||||
originalName: companyDocuments.originalName,
|
||||
fileType: companyDocuments.fileType,
|
||||
fileSize: companyDocuments.fileSize,
|
||||
description: companyDocuments.description,
|
||||
uploadedAt: companyDocuments.uploadedAt,
|
||||
uploadedBy: companyDocuments.uploadedBy,
|
||||
uploaderUsername: users.username,
|
||||
uploaderFirstName: users.firstName,
|
||||
uploaderLastName: users.lastName,
|
||||
})
|
||||
.from(companyDocuments)
|
||||
.leftJoin(users, eq(companyDocuments.uploadedBy, users.id))
|
||||
.where(eq(companyDocuments.companyId, companyId))
|
||||
.orderBy(desc(companyDocuments.uploadedAt));
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a document for a company
|
||||
*/
|
||||
export const uploadDocument = async ({ companyId, userId, file, description }) => {
|
||||
if (!file) {
|
||||
throw new BadRequestError('Súbor nebol nahraný');
|
||||
}
|
||||
|
||||
await ensureCompanyExists(companyId);
|
||||
|
||||
const { folder, filename, filePath } = buildDestinationPath(companyId, file.originalname);
|
||||
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, file.buffer);
|
||||
|
||||
const [newDoc] = await db
|
||||
.insert(companyDocuments)
|
||||
.values({
|
||||
companyId,
|
||||
fileName: filename,
|
||||
originalName: file.originalname,
|
||||
filePath,
|
||||
fileType: file.mimetype,
|
||||
fileSize: file.size,
|
||||
description: description || null,
|
||||
uploadedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newDoc;
|
||||
} catch (error) {
|
||||
await safeUnlink(filePath);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get document by ID for download
|
||||
*/
|
||||
export const getDocumentForDownload = async (companyId, documentId) => {
|
||||
await ensureCompanyExists(companyId);
|
||||
|
||||
const [doc] = await db
|
||||
.select()
|
||||
.from(companyDocuments)
|
||||
.where(and(
|
||||
eq(companyDocuments.id, documentId),
|
||||
eq(companyDocuments.companyId, companyId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!doc) {
|
||||
throw new NotFoundError('Dokument nenájdený');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(doc.filePath);
|
||||
} catch {
|
||||
throw new NotFoundError('Súbor nebol nájdený na serveri');
|
||||
}
|
||||
|
||||
return {
|
||||
document: doc,
|
||||
filePath: doc.filePath,
|
||||
fileName: doc.originalName,
|
||||
fileType: doc.fileType,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
export const deleteDocument = async (companyId, documentId) => {
|
||||
await ensureCompanyExists(companyId);
|
||||
|
||||
const [doc] = await db
|
||||
.select()
|
||||
.from(companyDocuments)
|
||||
.where(and(
|
||||
eq(companyDocuments.id, documentId),
|
||||
eq(companyDocuments.companyId, companyId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!doc) {
|
||||
throw new NotFoundError('Dokument nenájdený');
|
||||
}
|
||||
|
||||
await safeUnlink(doc.filePath);
|
||||
await db.delete(companyDocuments).where(eq(companyDocuments.id, documentId));
|
||||
|
||||
return { success: true, message: 'Dokument bol odstránený' };
|
||||
};
|
||||
87
src/services/service.service.js
Normal file
87
src/services/service.service.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { services } from '../db/schema.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Get all services
|
||||
*/
|
||||
export const getAllServices = async () => {
|
||||
return await db
|
||||
.select()
|
||||
.from(services)
|
||||
.orderBy(desc(services.createdAt));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service by ID
|
||||
*/
|
||||
export const getServiceById = async (serviceId) => {
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.id, serviceId))
|
||||
.limit(1);
|
||||
|
||||
if (!service) {
|
||||
throw new NotFoundError('Služba nenájdená');
|
||||
}
|
||||
|
||||
return service;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new service
|
||||
* @param {string} userId - ID of user creating the service
|
||||
* @param {object} data - Service data
|
||||
*/
|
||||
export const createService = async (userId, data) => {
|
||||
const { name, price, description } = data;
|
||||
|
||||
const [newService] = await db
|
||||
.insert(services)
|
||||
.values({
|
||||
name,
|
||||
price,
|
||||
description: description || null,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update service
|
||||
* @param {string} serviceId - ID of service to update
|
||||
* @param {object} data - Updated data
|
||||
*/
|
||||
export const updateService = async (serviceId, data) => {
|
||||
const service = await getServiceById(serviceId);
|
||||
|
||||
const { name, price, description } = data;
|
||||
|
||||
const [updated] = await db
|
||||
.update(services)
|
||||
.set({
|
||||
name: name !== undefined ? name : service.name,
|
||||
price: price !== undefined ? price : service.price,
|
||||
description: description !== undefined ? description : service.description,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(services.id, serviceId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete service
|
||||
*/
|
||||
export const deleteService = async (serviceId) => {
|
||||
await getServiceById(serviceId); // Check if exists
|
||||
|
||||
await db.delete(services).where(eq(services.id, serviceId));
|
||||
|
||||
return { success: true, message: 'Služba bola odstránená' };
|
||||
};
|
||||
@@ -677,6 +677,218 @@ export const getTimeEntryWithRelations = async (entryId) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an XLSX timesheet for a specific company for a given month and user.
|
||||
*/
|
||||
export const generateCompanyTimesheet = async (userId, year, month, companyId) => {
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0, 23, 59, 59, 999);
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
username: users.username,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
const [company] = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.limit(1);
|
||||
|
||||
if (!company) {
|
||||
throw new NotFoundError('Firma nenájdená');
|
||||
}
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
id: timeEntries.id,
|
||||
startTime: timeEntries.startTime,
|
||||
endTime: timeEntries.endTime,
|
||||
duration: timeEntries.duration,
|
||||
description: timeEntries.description,
|
||||
projectName: projects.name,
|
||||
todoTitle: todos.title,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(projects, eq(timeEntries.projectId, projects.id))
|
||||
.leftJoin(todos, eq(timeEntries.todoId, todos.id))
|
||||
.where(
|
||||
and(
|
||||
eq(timeEntries.userId, userId),
|
||||
eq(timeEntries.companyId, companyId),
|
||||
gte(timeEntries.startTime, startDate),
|
||||
lte(timeEntries.startTime, endDate)
|
||||
)
|
||||
)
|
||||
.orderBy(timeEntries.startTime);
|
||||
|
||||
const completedEntries = entries.filter((entry) => entry.endTime && entry.duration !== null);
|
||||
if (completedEntries.length === 0) {
|
||||
throw new NotFoundError('Žiadne dokončené záznamy pre danú firmu a mesiac');
|
||||
}
|
||||
|
||||
let totalMinutes = 0;
|
||||
const dailyTotals = {};
|
||||
|
||||
completedEntries.forEach((entry) => {
|
||||
const minutes = entry.duration || 0;
|
||||
totalMinutes += minutes;
|
||||
const day = formatDate(entry.startTime);
|
||||
dailyTotals[day] = (dailyTotals[day] || 0) + minutes;
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'crm-server';
|
||||
const worksheet = workbook.addWorksheet('Company Timesheet', {
|
||||
views: [{ state: 'frozen', ySplit: 7 }],
|
||||
});
|
||||
|
||||
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
|
||||
const periodLabel = `${year}-${String(month).padStart(2, '0')}`;
|
||||
|
||||
worksheet.getCell('A1').value = 'Company Timesheet';
|
||||
worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true };
|
||||
worksheet.mergeCells('A1:D1');
|
||||
|
||||
worksheet.getCell('A2').value = `Company: ${company.name}`;
|
||||
worksheet.getCell('A3').value = `Employee: ${fullName}`;
|
||||
worksheet.getCell('A4').value = `Period: ${periodLabel}`;
|
||||
worksheet.getCell('A5').value = `Generated: ${new Date().toLocaleString()}`;
|
||||
|
||||
worksheet.columns = [
|
||||
{ key: 'date', width: 12 },
|
||||
{ key: 'project', width: 28 },
|
||||
{ key: 'todo', width: 28 },
|
||||
{ key: 'description', width: 40 },
|
||||
{ key: 'start', width: 12 },
|
||||
{ key: 'end', width: 12 },
|
||||
{ key: 'duration', width: 16 },
|
||||
];
|
||||
|
||||
const headerRowNumber = 7;
|
||||
const headerRow = worksheet.getRow(headerRowNumber);
|
||||
headerRow.values = [
|
||||
'Date',
|
||||
'Project',
|
||||
'Todo',
|
||||
'Description',
|
||||
'Start',
|
||||
'End',
|
||||
'Duration (h:mm)',
|
||||
];
|
||||
headerRow.font = { name: 'Calibri', size: 11, bold: true };
|
||||
headerRow.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8EEF5' } };
|
||||
headerRow.height = 20;
|
||||
|
||||
completedEntries.forEach((entry) => {
|
||||
worksheet.addRow({
|
||||
date: formatDate(entry.startTime),
|
||||
project: entry.projectName || '',
|
||||
todo: entry.todoTitle || '',
|
||||
description: entry.description || '',
|
||||
start: formatTime(entry.startTime),
|
||||
end: formatTime(entry.endTime),
|
||||
duration: formatDuration(entry.duration),
|
||||
});
|
||||
});
|
||||
|
||||
// Style body rows
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber >= headerRowNumber) {
|
||||
row.alignment = { vertical: 'middle', wrapText: true };
|
||||
}
|
||||
});
|
||||
|
||||
let summaryStart = worksheet.lastRow.number + 2;
|
||||
worksheet.getCell(`A${summaryStart}`).value = 'Daily totals';
|
||||
worksheet.getCell(`A${summaryStart}`).font = { name: 'Calibri', size: 12, bold: true };
|
||||
worksheet.mergeCells(`A${summaryStart}:B${summaryStart}`);
|
||||
|
||||
const dailyHeaderRowNumber = summaryStart + 1;
|
||||
const dailyHeaderRow = worksheet.getRow(dailyHeaderRowNumber);
|
||||
dailyHeaderRow.values = ['Date', 'Total (h:mm)'];
|
||||
dailyHeaderRow.font = { name: 'Calibri', size: 11, bold: true };
|
||||
dailyHeaderRow.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
dailyHeaderRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8EEF5' } };
|
||||
|
||||
Object.keys(dailyTotals)
|
||||
.sort()
|
||||
.forEach((day) => {
|
||||
worksheet.addRow([day, formatDuration(dailyTotals[day])]);
|
||||
});
|
||||
|
||||
const overallRow = worksheet.addRow(['Overall total', formatDuration(totalMinutes)]);
|
||||
overallRow.font = { name: 'Calibri', size: 11, bold: true };
|
||||
|
||||
const uploadsDir = path.join(
|
||||
process.cwd(),
|
||||
'uploads',
|
||||
'timesheets',
|
||||
userId.toString(),
|
||||
year.toString(),
|
||||
month.toString()
|
||||
);
|
||||
await fs.mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const filename = `company-timesheet-${company.name.replace(/[^a-zA-Z0-9]/g, '_')}-${periodLabel}-${Date.now()}.xlsx`;
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
let savedFilePath = null;
|
||||
|
||||
try {
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
savedFilePath = filePath;
|
||||
const { size: fileSize } = await fs.stat(filePath);
|
||||
|
||||
const [newTimesheet] = await db
|
||||
.insert(timesheets)
|
||||
.values({
|
||||
userId,
|
||||
fileName: filename,
|
||||
filePath,
|
||||
fileType: 'xlsx',
|
||||
fileSize,
|
||||
year,
|
||||
month,
|
||||
isGenerated: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
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,
|
||||
},
|
||||
companyName: company.name,
|
||||
totals: {
|
||||
totalMinutes,
|
||||
totalFormatted: formatDuration(totalMinutes),
|
||||
daily: dailyTotals,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (savedFilePath) {
|
||||
await fs.unlink(savedFilePath).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get monthly statistics for a user
|
||||
*/
|
||||
|
||||
@@ -177,7 +177,7 @@ export const updateTimeEntrySchema = z.object({
|
||||
export const createEventSchema = z.object({
|
||||
title: z.string().min(1, 'Názov je povinný'),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['meeting', 'event', 'important']).default('meeting'),
|
||||
type: z.enum(['meeting', 'event', 'important', 'dostupnost', 'ine']).default('meeting'),
|
||||
start: z.string().min(1, 'Začiatok je povinný'),
|
||||
end: z.string().min(1, 'Koniec je povinný'),
|
||||
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
|
||||
@@ -186,8 +186,30 @@ export const createEventSchema = z.object({
|
||||
export const updateEventSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['meeting', 'event', 'important']).optional(),
|
||||
type: z.enum(['meeting', 'event', 'important', 'dostupnost', 'ine']).optional(),
|
||||
start: z.string().optional(),
|
||||
end: z.string().optional(),
|
||||
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
|
||||
});
|
||||
|
||||
// Service validators
|
||||
export const createServiceSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Názov služby je povinný',
|
||||
})
|
||||
.min(1, 'Názov služby nemôže byť prázdny')
|
||||
.max(255, 'Názov služby môže mať maximálne 255 znakov'),
|
||||
price: z
|
||||
.string({
|
||||
required_error: 'Cena je povinná',
|
||||
})
|
||||
.min(1, 'Cena nemôže byť prázdna'),
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const updateServiceSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
price: z.string().min(1).optional(),
|
||||
description: z.string().max(1000).optional().or(z.literal('').or(z.null())),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user