Add meetings feature with admin-only CRUD
- Add meetings table with timezone support - Add meeting.service.js with timezone parsing (Europe/Bratislava) - Add meeting.controller.js for CRUD operations - Add meeting.routes.js with admin middleware for create/update/delete - GET endpoints available for all authenticated users 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import todoRoutes from './routes/todo.routes.js';
|
|||||||
import timeTrackingRoutes from './routes/time-tracking.routes.js';
|
import timeTrackingRoutes from './routes/time-tracking.routes.js';
|
||||||
import noteRoutes from './routes/note.routes.js';
|
import noteRoutes from './routes/note.routes.js';
|
||||||
import auditRoutes from './routes/audit.routes.js';
|
import auditRoutes from './routes/audit.routes.js';
|
||||||
|
import meetingRoutes from './routes/meeting.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ app.use('/api/todos', todoRoutes);
|
|||||||
app.use('/api/time-tracking', timeTrackingRoutes);
|
app.use('/api/time-tracking', timeTrackingRoutes);
|
||||||
app.use('/api/notes', noteRoutes);
|
app.use('/api/notes', noteRoutes);
|
||||||
app.use('/api/audit-logs', auditRoutes);
|
app.use('/api/audit-logs', auditRoutes);
|
||||||
|
app.use('/api/meetings', meetingRoutes);
|
||||||
|
|
||||||
// Basic route
|
// Basic route
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
|
|||||||
105
src/controllers/meeting.controller.js
Normal file
105
src/controllers/meeting.controller.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import * as meetingService from '../services/meeting.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meetings by month
|
||||||
|
* GET /api/meetings?year=2024&month=1
|
||||||
|
*/
|
||||||
|
export const getMeetingsByMonth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { year, month } = req.query;
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
const queryYear = year ? parseInt(year) : currentDate.getFullYear();
|
||||||
|
const queryMonth = month ? parseInt(month) : currentDate.getMonth() + 1;
|
||||||
|
|
||||||
|
const meetings = await meetingService.getMeetingsByMonth(queryYear, queryMonth);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: meetings.length,
|
||||||
|
data: meetings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meeting by ID
|
||||||
|
* GET /api/meetings/:meetingId
|
||||||
|
*/
|
||||||
|
export const getMeetingById = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { meetingId } = req.params;
|
||||||
|
|
||||||
|
const meeting = await meetingService.getMeetingById(meetingId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: meeting,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new meeting (admin only)
|
||||||
|
* POST /api/meetings
|
||||||
|
*/
|
||||||
|
export const createMeeting = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
const meeting = await meetingService.createMeeting(userId, data);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: meeting,
|
||||||
|
message: 'Meeting bol vytvorený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a meeting (admin only)
|
||||||
|
* PUT /api/meetings/:meetingId
|
||||||
|
*/
|
||||||
|
export const updateMeeting = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { meetingId } = req.params;
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
const meeting = await meetingService.updateMeeting(meetingId, data);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: meeting,
|
||||||
|
message: 'Meeting bol upravený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a meeting (admin only)
|
||||||
|
* DELETE /api/meetings/:meetingId
|
||||||
|
*/
|
||||||
|
export const deleteMeeting = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { meetingId } = req.params;
|
||||||
|
|
||||||
|
const result = await meetingService.deleteMeeting(meetingId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -213,6 +213,18 @@ export const timesheets = pgTable('timesheets', {
|
|||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Meetings table - meetingy/stretnutia (iba admin môže CRUD)
|
||||||
|
export const meetings = pgTable('meetings', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
start: timestamp('start', { withTimezone: true }).notNull(),
|
||||||
|
end: timestamp('end', { withTimezone: true }).notNull(),
|
||||||
|
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Time Entries table - sledovanie odpracovaného času používateľov
|
// Time Entries table - sledovanie odpracovaného času používateľov
|
||||||
export const timeEntries = pgTable('time_entries', {
|
export const timeEntries = pgTable('time_entries', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|||||||
90
src/routes/meeting.routes.js
Normal file
90
src/routes/meeting.routes.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as meetingController from '../controllers/meeting.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 { z } from 'zod';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Schema pre meeting
|
||||||
|
const meetingSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
start: z.string().min(1, 'Začiatok je povinný'),
|
||||||
|
end: z.string().min(1, 'Koniec je povinný'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const meetingUpdateSchema = z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
start: z.string().optional(),
|
||||||
|
end: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthQuerySchema = z.object({
|
||||||
|
year: z.string().regex(/^\d{4}$/).optional(),
|
||||||
|
month: z.string().regex(/^(1[0-2]|[1-9])$/).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const meetingIdSchema = z.object({
|
||||||
|
meetingId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Všetky routes vyžadujú autentifikáciu
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/meetings - Získať meetingy podľa mesiaca (všetci autentifikovaní používatelia)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
validateQuery(monthQuerySchema),
|
||||||
|
meetingController.getMeetingsByMonth
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/meetings/:meetingId - Získať konkrétny meeting (všetci autentifikovaní používatelia)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:meetingId',
|
||||||
|
validateParams(meetingIdSchema),
|
||||||
|
meetingController.getMeetingById
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only routes (CREATE, UPDATE, DELETE)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/meetings - Vytvoriť meeting (iba admin)
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireAdmin,
|
||||||
|
validateBody(meetingSchema),
|
||||||
|
meetingController.createMeeting
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/meetings/:meetingId - Upraviť meeting (iba admin)
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:meetingId',
|
||||||
|
requireAdmin,
|
||||||
|
validateParams(meetingIdSchema),
|
||||||
|
validateBody(meetingUpdateSchema),
|
||||||
|
meetingController.updateMeeting
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/meetings/:meetingId - Zmazať meeting (iba admin)
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:meetingId',
|
||||||
|
requireAdmin,
|
||||||
|
validateParams(meetingIdSchema),
|
||||||
|
meetingController.deleteMeeting
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
163
src/services/meeting.service.js
Normal file
163
src/services/meeting.service.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { meetings } from '../db/schema.js';
|
||||||
|
import { eq, and, gte, lt, desc } from 'drizzle-orm';
|
||||||
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse datetime-local string to Date object
|
||||||
|
* datetime-local format: "2024-01-15T12:00" (no timezone)
|
||||||
|
* Interprets as Europe/Bratislava timezone
|
||||||
|
*/
|
||||||
|
const parseLocalDateTime = (dateTimeString) => {
|
||||||
|
if (!dateTimeString) return null;
|
||||||
|
|
||||||
|
// Ak už má timezone info, parsuj priamo
|
||||||
|
if (dateTimeString.includes('Z') || /[+-]\d{2}:\d{2}$/.test(dateTimeString)) {
|
||||||
|
return new Date(dateTimeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre datetime-local input (bez timezone) - interpretuj ako Europe/Bratislava
|
||||||
|
// Normalizuj string - pridaj sekundy ak chýbajú
|
||||||
|
let normalized = dateTimeString;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(dateTimeString)) {
|
||||||
|
normalized = `${dateTimeString}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zisti či je letný alebo zimný čas pre daný dátum
|
||||||
|
// Parsuj dátum a mesiac zo stringu
|
||||||
|
const [datePart] = normalized.split('T');
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number);
|
||||||
|
|
||||||
|
// Jednoduchá detekcia letného času pre Európu (posledná nedeľa marca - posledná nedeľa októbra)
|
||||||
|
const isDST = month > 3 && month < 10 ||
|
||||||
|
(month === 3 && day >= 25) ||
|
||||||
|
(month === 10 && day < 25);
|
||||||
|
|
||||||
|
const offset = isDST ? '+02:00' : '+01:00';
|
||||||
|
|
||||||
|
// Vytvor ISO string s timezone
|
||||||
|
const isoString = `${normalized}${offset}`;
|
||||||
|
|
||||||
|
return new Date(isoString);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format meeting timestamps to ISO string with timezone
|
||||||
|
* This ensures frontend receives properly formatted dates
|
||||||
|
*/
|
||||||
|
const formatMeetingOutput = (meeting) => {
|
||||||
|
if (!meeting) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meeting,
|
||||||
|
start: meeting.start instanceof Date ? meeting.start.toISOString() : meeting.start,
|
||||||
|
end: meeting.end instanceof Date ? meeting.end.toISOString() : meeting.end,
|
||||||
|
createdAt: meeting.createdAt instanceof Date ? meeting.createdAt.toISOString() : meeting.createdAt,
|
||||||
|
updatedAt: meeting.updatedAt instanceof Date ? meeting.updatedAt.toISOString() : meeting.updatedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meetings by month
|
||||||
|
* @param {number} year - rok
|
||||||
|
* @param {number} month - mesiac (1-12)
|
||||||
|
*/
|
||||||
|
export const getMeetingsByMonth = async (year, month) => {
|
||||||
|
// Začiatok a koniec mesiaca v Europe/Bratislava timezone
|
||||||
|
const startOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01T00:00`;
|
||||||
|
const endMonth = month === 12 ? 1 : month + 1;
|
||||||
|
const endYear = month === 12 ? year + 1 : year;
|
||||||
|
const endOfMonthStr = `${endYear}-${String(endMonth).padStart(2, '0')}-01T00:00`;
|
||||||
|
|
||||||
|
const startOfMonth = parseLocalDateTime(startOfMonthStr);
|
||||||
|
const endOfMonth = parseLocalDateTime(endOfMonthStr);
|
||||||
|
|
||||||
|
const monthMeetings = await db
|
||||||
|
.select()
|
||||||
|
.from(meetings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(meetings.start, startOfMonth),
|
||||||
|
lt(meetings.start, endOfMonth)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(meetings.start));
|
||||||
|
|
||||||
|
return monthMeetings.map(formatMeetingOutput);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meeting by ID
|
||||||
|
*/
|
||||||
|
export const getMeetingById = async (meetingId) => {
|
||||||
|
const [meeting] = await db
|
||||||
|
.select()
|
||||||
|
.from(meetings)
|
||||||
|
.where(eq(meetings.id, meetingId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!meeting) {
|
||||||
|
throw new NotFoundError('Meeting nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatMeetingOutput(meeting);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new meeting
|
||||||
|
*/
|
||||||
|
export const createMeeting = async (userId, data) => {
|
||||||
|
const { title, description, start, end } = data;
|
||||||
|
|
||||||
|
const [newMeeting] = await db
|
||||||
|
.insert(meetings)
|
||||||
|
.values({
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
start: parseLocalDateTime(start),
|
||||||
|
end: parseLocalDateTime(end),
|
||||||
|
createdBy: userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return formatMeetingOutput(newMeeting);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a meeting
|
||||||
|
*/
|
||||||
|
export const updateMeeting = async (meetingId, data) => {
|
||||||
|
// Check if meeting exists
|
||||||
|
await getMeetingById(meetingId);
|
||||||
|
|
||||||
|
const { title, description, start, end } = data;
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (title !== undefined) updateData.title = title;
|
||||||
|
if (description !== undefined) updateData.description = description;
|
||||||
|
if (start !== undefined) updateData.start = parseLocalDateTime(start);
|
||||||
|
if (end !== undefined) updateData.end = parseLocalDateTime(end);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(meetings)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(meetings.id, meetingId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return formatMeetingOutput(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a meeting
|
||||||
|
*/
|
||||||
|
export const deleteMeeting = async (meetingId) => {
|
||||||
|
// Check if meeting exists
|
||||||
|
await getMeetingById(meetingId);
|
||||||
|
|
||||||
|
await db.delete(meetings).where(eq(meetings.id, meetingId));
|
||||||
|
|
||||||
|
return { success: true, message: 'Meeting bol zmazaný' };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user