diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index f6c1f6077..6668f5985 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; @@ -18,6 +18,7 @@ import { NetworkModule } from './network/network.module'; import { EmailModule } from './email/email.module'; import { NotificationModule } from './notification/notification.module'; import { AdminModule } from './admin/admin.module'; +import { HttpExceptionFilter } from './common/http-exception.filter'; @Module({ imports: [ @@ -60,6 +61,10 @@ import { AdminModule } from './admin/admin.module'; AdminModule, ], providers: [ + { + provide: APP_FILTER, + useClass: HttpExceptionFilter, + }, { provide: APP_GUARD, useClass: ThrottlerGuard, diff --git a/apps/calendar/apps/backend/src/common/http-exception.filter.ts b/apps/calendar/apps/backend/src/common/http-exception.filter.ts new file mode 100644 index 000000000..e73f40d46 --- /dev/null +++ b/apps/calendar/apps/backend/src/common/http-exception.filter.ts @@ -0,0 +1,60 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Response, Request } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let error = 'Internal Server Error'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + error = exceptionResponse; + } else if (typeof exceptionResponse === 'object') { + const res = exceptionResponse as Record; + message = res.message || message; + error = res.error || error; + } + } else if (exception instanceof Error) { + message = exception.message; + this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); + } else { + this.logger.error('Unknown exception', exception); + } + + if (status >= 500) { + this.logger.error( + `[${request.method}] ${request.url} - ${status}: ${message}`, + exception instanceof Error ? exception.stack : undefined + ); + } else { + this.logger.warn(`[${request.method}] ${request.url} - ${status}: ${message}`); + } + + response.status(status).json({ + statusCode: status, + message, + error, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 5588c8bcc..5773d94f9 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -16,6 +16,10 @@ export class EventService { private eventTagService: EventTagService ) {} + private escapeLikePattern(input: string): string { + return input.replace(/[%_\\]/g, '\\$&'); + } + async queryEvents(userId: string, query: QueryEventsDto): Promise { const conditions = [eq(events.userId, userId)]; @@ -37,13 +41,11 @@ export class EventService { conditions.push(or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any); } - // Search filter + // Search filter (escaped to prevent LIKE pattern injection) if (query.search) { + const escaped = this.escapeLikePattern(query.search); conditions.push( - or( - ilike(events.title, `%${query.search}%`), - ilike(events.description, `%${query.search}%`) - ) as any + or(ilike(events.title, `%${escaped}%`), ilike(events.description, `%${escaped}%`)) as any ); } @@ -193,13 +195,11 @@ export class EventService { conditions.push(inArray(events.calendarId, query.calendarIds)); } - // Search filter - search in title and description + // Search filter (escaped to prevent LIKE pattern injection) if (query.search) { + const escaped = this.escapeLikePattern(query.search); conditions.push( - or( - ilike(events.title, `%${query.search}%`), - ilike(events.description, `%${query.search}%`) - ) as any + or(ilike(events.title, `%${escaped}%`), ilike(events.description, `%${escaped}%`)) as any ); } diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index fed0f0356..11362509a 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -11,7 +11,8 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", "format": "prettier --write .", - "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" }, "devDependencies": { "@manacore/shared-pwa": "workspace:*", @@ -20,10 +21,12 @@ "@sveltejs/kit": "^2.47.1", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.1.7", + "@testing-library/jest-dom": "^6.9.1", "@types/d3-force": "^3.0.0", "@types/node": "^20.0.0", "@types/suncalc": "^1.9.2", "@vite-pwa/sveltekit": "^1.1.0", + "jsdom": "^25.0.1", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", "svelte": "^5.41.0", @@ -31,14 +34,14 @@ "tailwindcss": "^4.1.7", "tslib": "^2.4.1", "typescript": "^5.9.3", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.0" }, "dependencies": { "@calendar/shared": "workspace:*", "@manacore/shared-api-client": "workspace:*", + "@manacore/shared-app-onboarding": "workspace:*", "@manacore/shared-auth": "workspace:*", - "@manacore/shared-splitscreen": "workspace:*", - "@manacore/shared-stores": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -46,6 +49,8 @@ "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", + "@manacore/shared-splitscreen": "workspace:*", + "@manacore/shared-stores": "workspace:*", "@manacore/shared-subscription-ui": "workspace:*", "@manacore/shared-tags": "workspace:*", "@manacore/shared-tailwind": "workspace:*", @@ -54,9 +59,8 @@ "@manacore/shared-types": "workspace:*", "@manacore/shared-ui": "workspace:*", "@manacore/shared-utils": "workspace:*", - "@manacore/shared-app-onboarding": "workspace:*", - "@sqlite.org/sqlite-wasm": "^3.49.1-build1", "@neodrag/svelte": "^2.3.3", + "@sqlite.org/sqlite-wasm": "^3.49.1-build1", "d3-force": "^3.0.0", "date-fns": "^4.1.0", "suncalc": "^1.9.0", diff --git a/apps/calendar/apps/web/src/lib/api/client.ts b/apps/calendar/apps/web/src/lib/api/client.ts index 5b12c0f6a..8d3bb12b8 100644 --- a/apps/calendar/apps/web/src/lib/api/client.ts +++ b/apps/calendar/apps/web/src/lib/api/client.ts @@ -41,6 +41,12 @@ function getApi(): ApiClient { return _api; } +/** + * Request deduplication for GET requests + * Prevents identical concurrent requests from being sent multiple times + */ +const pendingRequests = new Map>>(); + /** * Legacy fetchApi interface for backwards compatibility */ @@ -55,6 +61,7 @@ export interface FetchOptions { /** * Fetch API wrapper using shared client * Maintains backward compatibility with existing code + * GET requests are deduplicated — identical concurrent GETs share one in-flight request */ export async function fetchApi( endpoint: string, @@ -67,6 +74,19 @@ export async function fetchApi( return api.upload(endpoint, body); } + // Deduplicate GET requests + if (method === 'GET') { + const existing = pendingRequests.get(endpoint); + if (existing) { + return existing as Promise>; + } + const promise = api.get(endpoint).finally(() => { + pendingRequests.delete(endpoint); + }); + pendingRequests.set(endpoint, promise as Promise>); + return promise; + } + switch (method) { case 'POST': return api.post(endpoint, body); diff --git a/apps/calendar/apps/web/src/lib/api/events.test.ts b/apps/calendar/apps/web/src/lib/api/events.test.ts new file mode 100644 index 000000000..393871859 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/events.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { CalendarEvent } from '@calendar/shared'; + +// Mock the client module +vi.mock('./client', () => ({ + fetchApi: vi.fn(), +})); + +import { fetchApi } from './client'; +import { getEvents, getEvent, createEvent, updateEvent, deleteEvent } from './events'; + +const mockFetchApi = vi.mocked(fetchApi); + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'evt-1', + calendarId: 'cal-1', + userId: 'user-1', + title: 'Test Event', + description: null, + location: null, + startTime: '2026-03-15T10:00:00Z', + endTime: '2026-03-15T11:00:00Z', + isAllDay: false, + timezone: 'Europe/Berlin', + recurrenceRule: null, + recurrenceEndDate: null, + recurrenceExceptions: null, + parentEventId: null, + color: null, + status: 'confirmed', + externalId: null, + metadata: null, + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-01T00:00:00Z', + ...overrides, + }; +} + +describe('events API client', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getEvents', () => { + it('should build query params with startDate and endDate', async () => { + mockFetchApi.mockResolvedValue({ + data: { events: [], pagination: { offset: 0, count: 0 } }, + error: null, + }); + + await getEvents({ + startDate: '2026-03-01T00:00:00', + endDate: '2026-03-31T23:59:59', + }); + + expect(mockFetchApi).toHaveBeenCalledOnce(); + const url = mockFetchApi.mock.calls[0][0]; + expect(url).toContain('startDate=2026-03-01T00%3A00%3A00'); + expect(url).toContain('endDate=2026-03-31T23%3A59%3A59'); + }); + + it('should include calendarIds when provided', async () => { + mockFetchApi.mockResolvedValue({ + data: { events: [], pagination: { offset: 0, count: 0 } }, + error: null, + }); + + await getEvents({ + startDate: '2026-03-01T00:00:00', + endDate: '2026-03-31T23:59:59', + calendarIds: ['cal-1', 'cal-2'], + }); + + const url = mockFetchApi.mock.calls[0][0]; + expect(url).toContain('calendarIds=cal-1%2Ccal-2'); + }); + + it('should include search param when provided', async () => { + mockFetchApi.mockResolvedValue({ + data: { events: [], pagination: { offset: 0, count: 0 } }, + error: null, + }); + + await getEvents({ + startDate: '2026-03-01T00:00:00', + endDate: '2026-03-31T23:59:59', + search: 'meeting', + }); + + const url = mockFetchApi.mock.calls[0][0]; + expect(url).toContain('search=meeting'); + }); + + it('should include limit and offset when provided', async () => { + mockFetchApi.mockResolvedValue({ + data: { events: [], pagination: { offset: 0, count: 0 } }, + error: null, + }); + + await getEvents({ + startDate: '2026-03-01T00:00:00', + endDate: '2026-03-31T23:59:59', + limit: 10, + offset: 20, + }); + + const url = mockFetchApi.mock.calls[0][0]; + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + }); + + it('should extract events array from response', async () => { + const events = [makeEvent(), makeEvent({ id: 'evt-2', title: 'Second' })]; + mockFetchApi.mockResolvedValue({ + data: { events, pagination: { offset: 0, count: 2 } }, + error: null, + }); + + const result = await getEvents({ + startDate: '2026-03-01T00:00:00', + endDate: '2026-03-31T23:59:59', + }); + + expect(result.data).toHaveLength(2); + expect(result.error).toBeNull(); + expect(result.pagination).toEqual({ offset: 0, count: 2 }); + }); + + it('should return error when API fails', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: { message: 'Server error', code: 'SERVER_ERROR', status: 500 }, + }); + + const result = await getEvents({ + startDate: '2026-03-01T00:00:00', + endDate: '2026-03-31T23:59:59', + }); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + message: 'Server error', + code: 'SERVER_ERROR', + status: 500, + }); + }); + }); + + describe('getEvent', () => { + it('should fetch a single event by ID', async () => { + const event = makeEvent(); + mockFetchApi.mockResolvedValue({ + data: { event }, + error: null, + }); + + const result = await getEvent('evt-1'); + + expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1'); + expect(result.data).toEqual(event); + expect(result.error).toBeNull(); + }); + + it('should return error when event not found', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: { message: 'Not found', code: 'NOT_FOUND', status: 404 }, + }); + + const result = await getEvent('nonexistent'); + + expect(result.data).toBeNull(); + expect(result.error?.code).toBe('NOT_FOUND'); + }); + }); + + describe('createEvent', () => { + it('should send POST request with event data', async () => { + const event = makeEvent(); + mockFetchApi.mockResolvedValue({ + data: { event }, + error: null, + }); + + const input = { + calendarId: 'cal-1', + title: 'Test Event', + startTime: '2026-03-15T10:00:00Z', + endTime: '2026-03-15T11:00:00Z', + }; + + const result = await createEvent(input); + + expect(mockFetchApi).toHaveBeenCalledWith('/events', { + method: 'POST', + body: input, + }); + expect(result.data).toEqual(event); + expect(result.error).toBeNull(); + }); + + it('should return error on creation failure', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: { message: 'Validation failed', code: 'VALIDATION_ERROR', status: 400 }, + }); + + const result = await createEvent({ + title: '', + startTime: '2026-03-15T10:00:00Z', + endTime: '2026-03-15T11:00:00Z', + }); + + expect(result.data).toBeNull(); + expect(result.error?.code).toBe('VALIDATION_ERROR'); + }); + }); + + describe('updateEvent', () => { + it('should send PUT request with update data', async () => { + const event = makeEvent({ title: 'Updated Title' }); + mockFetchApi.mockResolvedValue({ + data: { event }, + error: null, + }); + + const updateData = { title: 'Updated Title' }; + const result = await updateEvent('evt-1', updateData); + + expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1', { + method: 'PUT', + body: updateData, + }); + expect(result.data).toEqual(event); + expect(result.error).toBeNull(); + }); + + it('should return error on update failure', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: { message: 'Forbidden', code: 'FORBIDDEN', status: 403 }, + }); + + const result = await updateEvent('evt-1', { title: 'Updated' }); + + expect(result.data).toBeNull(); + expect(result.error?.code).toBe('FORBIDDEN'); + }); + }); + + describe('deleteEvent', () => { + it('should send DELETE request', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: null, + }); + + const result = await deleteEvent('evt-1'); + + expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1', { + method: 'DELETE', + }); + expect(result.error).toBeNull(); + }); + + it('should return error on delete failure', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: { message: 'Not found', code: 'NOT_FOUND', status: 404 }, + }); + + const result = await deleteEvent('nonexistent'); + + expect(result.error?.code).toBe('NOT_FOUND'); + }); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte index a480ca04e..fd04c2662 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -3,9 +3,7 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; import { filterByTags } from '$lib/utils/eventFiltering'; - import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns'; import { de } from 'date-fns/locale'; import { toDate } from '$lib/utils/eventDateHelpers'; @@ -87,18 +85,6 @@ onEventClick(event); } } - - function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - eventContextMenuStore.show(event, e.clientX, e.clientY); - } - - function handleContextMenuEdit(event: CalendarEvent) { - if (onEventClick) { - onEventClick(event); - } - }
@@ -124,11 +110,7 @@
{#each group.events as event} - - {/each} -
- {/each} -
- {/if} - - - -
- -
- {#each hours as hour} -
- {settingsStore.formatHour(hour)} -
- {/each} -
- - -
- {#each days as day} - -
handleSidebarDragOver(e, day)} - ondragleave={handleSidebarDragLeave} - ondrop={(e) => handleSidebarDrop(e, day)} - > - {#each hours as hour} -
- {/each} - - - {#each getBlockAllDayEventsForDay(day) as event (event.id)} - - {/each} - - - {#each getEventsForDay(day) as event (event.id)} - {@const isBeingDragged = isDragging && draggedEvent?.id === event.id} - {@const isBeingResized = isResizing && resizeEvent?.id === event.id} - {@const isDraft = eventsStore.isDraftEvent(event.id)} - {@const isCrossDayDrag = - isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)} - {@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)} - {@const isSearchDimmed = searchStore.isEventDimmed(event.id)} -
startDrag(event, e)} - onclick={(e) => !isDraft && handleEventClick(event, e)} - oncontextmenu={(e) => handleEventContextMenu(event, e)} - onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)} - title={`${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}: ${event.title || (isDraft ? '(Neuer Termin)' : '')}`} - > - -
startResize(event, 'top', e)} - role="separator" - aria-orientation="horizontal" - aria-label="Startzeit ändern" - aria-valuenow={0} - tabindex="-1" - >
- - {#if columnClass !== 'very-compact'} - - {isBeingResized - ? getResizePreviewTime(event) - : `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`} - - {/if} - {event.title || (isDraft ? '(Neuer Termin)' : '')} - - -
startResize(event, 'bottom', e)} - role="separator" - aria-orientation="horizontal" - aria-label="Endzeit ändern" - aria-valuenow={0} - tabindex="-1" - >
-
- {/each} - - - {#if settingsStore.showTasksInCalendar} - {#each getScheduledTasksForDay(day) as task (task.id)} - {@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id} - {@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id} - {@const isTaskCrossDayDrag = - isTaskBeingDragged && - taskDragTargetDay !== null && - !isSameDay(day, taskDragTargetDay)} - - {/each} - - - {#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)} - - {/if} - {/if} - - - {#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)} -
- {#if columnClass !== 'very-compact'} - {formatEventTime(draggedEvent.startTime)} - {/if} - {draggedEvent.title} -
- {/if} - - - {#if isCreating && createTargetDay && isSameDay(day, createTargetDay)} -
- {#if columnClass !== 'very-compact'} - {getCreatePreviewTime()} - {/if} - (Neuer Termin) -
- {/if} - - - {#if true} - {@const overflow = getOverflowEventsForDay(day)} - {#if overflow.before.length > 0} -
- {#each overflow.before as event} -
- {/each} -
- {/if} - {#if overflow.after.length > 0} -
- {#each overflow.after as event} -
- {/each} -
- {/if} - {/if} - - - {#if isToday(day)} -
- {/if} -
- {/each} -
-
- - - diff --git a/apps/calendar/apps/web/src/lib/components/calendar/UnifiedBar.svelte b/apps/calendar/apps/web/src/lib/components/calendar/UnifiedBar.svelte index aa8fa98f7..f1eb864c0 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/UnifiedBar.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/UnifiedBar.svelte @@ -1,635 +1,633 @@
- - {#if unifiedBarStore.showCalendarToolbar} - - {/if} + + {#if unifiedBarStore.showCalendarToolbar} + + {/if} - - {#if unifiedBarStore.showTagStrip} - - {/if} + + {#if unifiedBarStore.showTagStrip} + + {/if} - - {#if unifiedBarStore.showDateStrip} - - {/if} + + {#if unifiedBarStore.showDateStrip} + + {/if} - - {#if unifiedBarStore.showDateStrip && !unifiedBarStore.legacyDateStripCollapsed} - - {/if} + + {#if unifiedBarStore.showDateStrip && !unifiedBarStore.legacyDateStripCollapsed} + + {/if} - - {#if unifiedBarStore.showTagStrip} - - {/if} + + {#if unifiedBarStore.showTagStrip} + + {/if} - - {#if unifiedBarStore.showDateStrip} - - {/if} + + {#if unifiedBarStore.showDateStrip} + + {/if} - - {#if unifiedBarStore.showDateStrip} - - {/if} + + {#if unifiedBarStore.showDateStrip} + + {/if} - - {#if unifiedBarStore.showQuickInput} - - {/if} + + {#if unifiedBarStore.showQuickInput} + + {/if} - - {#if unifiedBarStore.isOverlayOpen} -