From 78ff10263184f5be822847cea9eb54d09c34ba05 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:30:01 +0100 Subject: [PATCH] feat(calendar): add production launch features - Email Service: Add email.service.ts with Brevo SMTP for reminders and calendar share invitations (German templates) - Push Notifications: Add notification module with Expo Push API support, device token management, and notification.controller.ts endpoints - Reminder Service: Integrate email and push notifications in reminder processing, add userEmail field to reminders schema - Share Service: Send invitation emails when sharing calendars - Unit Tests: Add jest.config.js and 63 tests for CalendarService, EventService, ReminderService, and ShareService with mock utilities - Database Migrations: Add migrate.ts with advisory locks for safe production deployments - Type-Checking: Enable type-check script for web app, fix all TypeScript errors including CalendarViewType completeness and optional field access Co-Authored-By: Claude Opus 4.5 --- apps/calendar/apps/backend/jest.config.js | 25 ++ apps/calendar/apps/backend/package.json | 14 +- .../backend/src/__tests__/utils/mock-db.ts | 67 ++++ .../src/__tests__/utils/mock-factories.ts | 140 ++++++++ apps/calendar/apps/backend/src/app.module.ts | 4 + .../src/calendar/calendar.service.spec.ts | 218 ++++++++++++ apps/calendar/apps/backend/src/db/migrate.ts | 222 ++++++++++++ .../src/db/schema/device-tokens.schema.ts | 26 ++ .../apps/backend/src/db/schema/index.ts | 1 + .../backend/src/db/schema/reminders.schema.ts | 3 + .../apps/backend/src/email/email.module.ts | 9 + .../apps/backend/src/email/email.service.ts | 219 ++++++++++++ .../backend/src/event/event.service.spec.ts | 275 +++++++++++++++ .../backend/src/notification/dto/index.ts | 1 + .../notification/dto/register-token.dto.ts | 18 + .../notification/notification.controller.ts | 45 +++ .../src/notification/notification.module.ts | 11 + .../src/notification/notification.service.ts | 125 +++++++ .../backend/src/notification/push.service.ts | 149 ++++++++ .../src/reminder/reminder.controller.ts | 2 +- .../backend/src/reminder/reminder.module.ts | 3 +- .../src/reminder/reminder.service.spec.ts | 300 ++++++++++++++++ .../backend/src/reminder/reminder.service.ts | 93 ++++- .../backend/src/share/share.controller.ts | 2 +- .../backend/src/share/share.service.spec.ts | 326 ++++++++++++++++++ .../apps/backend/src/share/share.service.ts | 34 +- apps/calendar/apps/web/package.json | 2 +- .../calendar/CalendarToolbarContent.svelte | 6 + .../calendar/StatsSidebarSection.svelte | 2 +- .../components/calendar/ViewModePill.svelte | 12 + .../apps/web/src/lib/stores/network.svelte.ts | 3 +- .../apps/web/src/lib/stores/search.svelte.ts | 6 +- .../src/routes/(app)/settings/+page.svelte | 6 + 33 files changed, 2338 insertions(+), 31 deletions(-) create mode 100644 apps/calendar/apps/backend/jest.config.js create mode 100644 apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts create mode 100644 apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts create mode 100644 apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts create mode 100644 apps/calendar/apps/backend/src/db/migrate.ts create mode 100644 apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts create mode 100644 apps/calendar/apps/backend/src/email/email.module.ts create mode 100644 apps/calendar/apps/backend/src/email/email.service.ts create mode 100644 apps/calendar/apps/backend/src/event/event.service.spec.ts create mode 100644 apps/calendar/apps/backend/src/notification/dto/index.ts create mode 100644 apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts create mode 100644 apps/calendar/apps/backend/src/notification/notification.controller.ts create mode 100644 apps/calendar/apps/backend/src/notification/notification.module.ts create mode 100644 apps/calendar/apps/backend/src/notification/notification.service.ts create mode 100644 apps/calendar/apps/backend/src/notification/push.service.ts create mode 100644 apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts create mode 100644 apps/calendar/apps/backend/src/share/share.service.spec.ts diff --git a/apps/calendar/apps/backend/jest.config.js b/apps/calendar/apps/backend/jest.config.js new file mode 100644 index 000000000..fdb8eec5b --- /dev/null +++ b/apps/calendar/apps/backend/jest.config.js @@ -0,0 +1,25 @@ +/** @type {import('jest').Config} */ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s', '!**/*.spec.ts', '!**/index.ts', '!main.ts'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@calendar/shared$': '/../../packages/shared/src', + }, + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + // Ignore node_modules except for workspace packages + transformIgnorePatterns: ['node_modules/(?!(@calendar|@manacore)/)'], +}; diff --git a/apps/calendar/apps/backend/package.json b/apps/calendar/apps/backend/package.json index 909efa017..ed42839f0 100644 --- a/apps/calendar/apps/backend/package.json +++ b/apps/calendar/apps/backend/package.json @@ -11,6 +11,9 @@ "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "type-check": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", "migration:generate": "drizzle-kit generate", "migration:run": "tsx src/db/migrate.ts", "db:push": "drizzle-kit push", @@ -30,25 +33,34 @@ "dotenv": "^16.4.7", "drizzle-kit": "^0.30.2", "drizzle-orm": "^0.38.3", + "expo-server-sdk": "^3.10.0", "ical.js": "^2.1.0", + "nodemailer": "^6.9.16", "postgres": "^3.4.5", "prom-client": "^15.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "tsdav": "^2.1.1" + "tsdav": "^2.1.1", + "uuid": "^11.0.4" }, "devDependencies": { "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.15", "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/uuid": "^10.0.0", "@types/node": "^22.10.2", + "@types/nodemailer": "^6.4.17", "@typescript-eslint/eslint-plugin": "^8.18.1", "@typescript-eslint/parser": "^8.18.1", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "jest": "^29.7.0", "prettier": "^3.4.2", "source-map-support": "^0.5.21", + "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts b/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts new file mode 100644 index 000000000..f388eca88 --- /dev/null +++ b/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts @@ -0,0 +1,67 @@ +/** + * Mock database utilities for testing + */ + +import type { Database } from '../../db/connection'; + +/** + * Create a mock database with chainable query methods + */ +export function createMockDb(): jest.Mocked { + const mockChain = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockResolvedValue([]), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue([]), + }; + + // Make all methods return the chain + Object.keys(mockChain).forEach((key) => { + if (key !== 'returning' && key !== 'execute') { + (mockChain as any)[key].mockReturnValue(mockChain); + } + }); + + return mockChain as unknown as jest.Mocked; +} + +/** + * Setup mock database to return specific data + */ +export function setupMockDbQuery(mockDb: jest.Mocked, data: T[]): void { + // For SELECT queries - the final method in the chain resolves to data + mockDb.where.mockResolvedValueOnce(data); +} + +/** + * Setup mock database for INSERT operations + */ +export function setupMockDbInsert(mockDb: jest.Mocked, data: T[]): void { + mockDb.returning.mockResolvedValueOnce(data); +} + +/** + * Setup mock database for UPDATE operations + */ +export function setupMockDbUpdate(mockDb: jest.Mocked, data: T[]): void { + mockDb.returning.mockResolvedValueOnce(data); +} + +/** + * Reset all mock calls on the database + */ +export function resetMockDb(mockDb: jest.Mocked): void { + Object.values(mockDb).forEach((fn) => { + if (typeof fn === 'function' && fn.mockClear) { + fn.mockClear(); + } + }); +} diff --git a/apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts b/apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts new file mode 100644 index 000000000..be4951adf --- /dev/null +++ b/apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts @@ -0,0 +1,140 @@ +/** + * Mock factories for test data generation + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { Calendar } from '../../db/schema/calendars.schema'; +import type { Event } from '../../db/schema/events.schema'; +import type { Reminder } from '../../db/schema/reminders.schema'; +import type { CalendarShare } from '../../db/schema/calendar-shares.schema'; +import type { DeviceToken } from '../../db/schema/device-tokens.schema'; + +// Default test user +export const TEST_USER_ID = 'test-user-123'; +export const TEST_USER_EMAIL = 'test@example.com'; + +/** + * Create a mock calendar + */ +export function createMockCalendar(overrides: Partial = {}): Calendar { + return { + id: uuidv4(), + userId: TEST_USER_ID, + name: 'Test Calendar', + description: 'A test calendar', + color: '#3B82F6', + isDefault: false, + isVisible: true, + timezone: 'Europe/Berlin', + settings: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock event + */ +export function createMockEvent(overrides: Partial = {}): Event { + const startTime = new Date(); + startTime.setHours(startTime.getHours() + 1); + const endTime = new Date(startTime); + endTime.setHours(endTime.getHours() + 1); + + return { + id: uuidv4(), + calendarId: uuidv4(), + userId: TEST_USER_ID, + title: 'Test Event', + description: 'A test event', + location: null, + startTime, + endTime, + isAllDay: false, + timezone: 'Europe/Berlin', + recurrenceRule: null, + recurrenceEndDate: null, + recurrenceExceptions: null, + parentEventId: null, + color: null, + status: 'confirmed', + externalId: null, + externalCalendarId: null, + lastSyncedAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock reminder + */ +export function createMockReminder(overrides: Partial = {}): Reminder { + const reminderTime = new Date(); + reminderTime.setMinutes(reminderTime.getMinutes() + 15); + + return { + id: uuidv4(), + eventId: uuidv4(), + userId: TEST_USER_ID, + userEmail: TEST_USER_EMAIL, + minutesBefore: 15, + reminderTime, + notifyPush: true, + notifyEmail: false, + status: 'pending', + sentAt: null, + eventInstanceDate: null, + createdAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock calendar share + */ +export function createMockCalendarShare(overrides: Partial = {}): CalendarShare { + return { + id: uuidv4(), + calendarId: uuidv4(), + sharedWithUserId: null, + sharedWithEmail: 'shared@example.com', + permission: 'read', + shareToken: null, + shareUrl: null, + status: 'pending', + invitedBy: TEST_USER_ID, + acceptedAt: null, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +/** + * Create a mock device token + */ +export function createMockDeviceToken(overrides: Partial = {}): DeviceToken { + return { + id: uuidv4(), + userId: TEST_USER_ID, + pushToken: `ExponentPushToken[${uuidv4()}]`, + platform: 'ios', + deviceName: 'Test iPhone', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +/** + * Create mock database query result + */ +export function createQueryResult(data: T[]): T[] { + return data; +} diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index bc7d48a9f..159c4cd91 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -11,6 +11,8 @@ import { ReminderModule } from './reminder/reminder.module'; import { ShareModule } from './share/share.module'; import { NetworkModule } from './network/network.module'; import { MetricsModule } from './metrics'; +import { EmailModule } from './email/email.module'; +import { NotificationModule } from './notification/notification.module'; @Module({ imports: [ @@ -22,6 +24,8 @@ import { MetricsModule } from './metrics'; MetricsModule, DatabaseModule, HealthModule, + EmailModule, + NotificationModule, CalendarModule, EventModule, EventTagModule, diff --git a/apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts b/apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts new file mode 100644 index 000000000..8d51529cb --- /dev/null +++ b/apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts @@ -0,0 +1,218 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { CalendarService } from './calendar.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { createMockCalendar, TEST_USER_ID } from '../__tests__/utils/mock-factories'; + +describe('CalendarService', () => { + let service: CalendarService; + let mockDb: any; + + beforeEach(async () => { + // Create mock database with chainable methods + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CalendarService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(CalendarService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all calendars for a user', async () => { + const calendars = [ + createMockCalendar({ name: 'Calendar 1' }), + createMockCalendar({ name: 'Calendar 2' }), + ]; + mockDb.where.mockResolvedValueOnce(calendars); + + const result = await service.findAll(TEST_USER_ID); + + expect(result).toEqual(calendars); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should return empty array when user has no calendars', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findAll(TEST_USER_ID); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return calendar when found', async () => { + const calendar = createMockCalendar(); + mockDb.where.mockResolvedValueOnce([calendar]); + + const result = await service.findById(calendar.id, TEST_USER_ID); + + expect(result).toEqual(calendar); + }); + + it('should return null when calendar not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id', TEST_USER_ID); + + expect(result).toBeNull(); + }); + }); + + describe('findByIdOrThrow', () => { + it('should return calendar when found', async () => { + const calendar = createMockCalendar(); + mockDb.where.mockResolvedValueOnce([calendar]); + + const result = await service.findByIdOrThrow(calendar.id, TEST_USER_ID); + + expect(result).toEqual(calendar); + }); + + it('should throw NotFoundException when calendar not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findByIdOrThrow('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('create', () => { + it('should create a new calendar', async () => { + const newCalendar = createMockCalendar({ name: 'New Calendar' }); + mockDb.returning.mockResolvedValueOnce([newCalendar]); + + const result = await service.create(TEST_USER_ID, { + name: 'New Calendar', + color: '#3B82F6', + }); + + expect(result).toEqual(newCalendar); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + + it('should clear other defaults when creating default calendar', async () => { + const newCalendar = createMockCalendar({ name: 'Default', isDefault: true }); + mockDb.returning.mockResolvedValueOnce([newCalendar]); + + await service.create(TEST_USER_ID, { + name: 'Default', + isDefault: true, + }); + + // Should have called update to clear other defaults + expect(mockDb.update).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update calendar', async () => { + const calendar = createMockCalendar(); + const updatedCalendar = { ...calendar, name: 'Updated Name' }; + + // Mock findByIdOrThrow + mockDb.where.mockResolvedValueOnce([calendar]); + // Mock update returning + mockDb.returning.mockResolvedValueOnce([updatedCalendar]); + + const result = await service.update(calendar.id, TEST_USER_ID, { + name: 'Updated Name', + }); + + expect(result.name).toBe('Updated Name'); + }); + + it('should throw NotFoundException when updating non-existent calendar', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should delete calendar', async () => { + const calendar = createMockCalendar({ isDefault: false }); + mockDb.where.mockResolvedValueOnce([calendar]); + + await service.delete(calendar.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when deleting non-existent calendar', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + + it('should throw error when deleting only calendar that is default', async () => { + const calendar = createMockCalendar({ isDefault: true }); + // First call returns the calendar + mockDb.where.mockResolvedValueOnce([calendar]); + // Second call (findAll) returns only this calendar + mockDb.where.mockResolvedValueOnce([calendar]); + + await expect(service.delete(calendar.id, TEST_USER_ID)).rejects.toThrow( + 'Cannot delete the only calendar' + ); + }); + }); + + describe('getOrCreateDefaultCalendar', () => { + it('should return existing default calendar', async () => { + const defaultCalendar = createMockCalendar({ isDefault: true }); + mockDb.where.mockResolvedValueOnce([defaultCalendar]); + + const result = await service.getOrCreateDefaultCalendar(TEST_USER_ID); + + expect(result).toEqual(defaultCalendar); + }); + + it('should make first calendar default if no default exists', async () => { + const calendar = createMockCalendar({ isDefault: false }); + const updatedCalendar = { ...calendar, isDefault: true }; + + // No default calendar + mockDb.where.mockResolvedValueOnce([]); + // Limit returns one calendar + mockDb.limit.mockResolvedValueOnce([calendar]); + // Update returns updated calendar + mockDb.returning.mockResolvedValueOnce([updatedCalendar]); + + const result = await service.getOrCreateDefaultCalendar(TEST_USER_ID); + + expect(result.isDefault).toBe(true); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/db/migrate.ts b/apps/calendar/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..d938d8351 --- /dev/null +++ b/apps/calendar/apps/backend/src/db/migrate.ts @@ -0,0 +1,222 @@ +/** + * Database Migration Script with Advisory Locks + * + * This script safely runs database migrations with the following features: + * - Advisory locks to prevent concurrent migrations + * - Retry logic for transient network failures + * - Timeout protection + * - Proper cleanup on exit + * - Graceful handling when no migrations exist + * + * Usage: + * pnpm migration:run # Run migrations + * MIGRATION_TIMEOUT=600 pnpm migration:run # With custom timeout (seconds) + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { sql } from 'drizzle-orm'; +import postgres from 'postgres'; +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Load environment variables +dotenv.config(); + +// Configuration +const MIGRATION_LOCK_ID = 314159265; // Unique lock ID for calendar migrations (pi digits) +const MAX_LOCK_WAIT_MS = parseInt(process.env.MIGRATION_TIMEOUT || '300', 10) * 1000; // Default 5 minutes +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 2000; + +/** + * Retry wrapper for transient errors + */ +async function withRetry( + operation: () => Promise, + operationName: string, + maxRetries = MAX_RETRIES +): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + + // Check if error is transient (network-related) + const isTransient = + lastError.message?.includes('ECONNREFUSED') || + lastError.message?.includes('ETIMEDOUT') || + lastError.message?.includes('ENOTFOUND') || + lastError.message?.includes('connection') || + (lastError as any).code === '57P03'; // PostgreSQL: cannot connect now + + if (!isTransient || attempt === maxRetries) { + throw error; + } + + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff + console.log( + `\u26a0\ufe0f [${operationName}] Transient error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})` + ); + console.log(` Error: ${lastError.message}`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError!; +} + +/** + * Acquire PostgreSQL advisory lock + */ +async function acquireLock(db: ReturnType): Promise { + const result = await db.execute( + sql`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID}) as acquired` + ); + return (result as any)[0]?.acquired === true; +} + +/** + * Release PostgreSQL advisory lock + */ +async function releaseLock(db: ReturnType): Promise { + await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`); +} + +/** + * Wait for migration lock with timeout + */ +async function waitForLock(db: ReturnType): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < MAX_LOCK_WAIT_MS) { + const acquired = await acquireLock(db); + if (acquired) { + return true; + } + + const elapsed = Math.round((Date.now() - startTime) / 1000); + console.log(`\u23f3 Waiting for migration lock... (${elapsed}s / ${MAX_LOCK_WAIT_MS / 1000}s)`); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + + return false; +} + +/** + * Main migration function + */ +async function runMigrations(): Promise { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + console.log('\n\ud83d\udd04 Starting Calendar database migration process...'); + console.log(` Lock ID: ${MIGRATION_LOCK_ID}`); + console.log(` Timeout: ${MAX_LOCK_WAIT_MS / 1000}s`); + console.log(''); + + // Create connection with single connection for migrations + const connection = postgres(databaseUrl, { + max: 1, + idle_timeout: 20, + connect_timeout: 30, + }); + + const db = drizzle(connection); + let lockAcquired = false; + + try { + // Test database connection + console.log('\ud83d\udd0c Testing database connection...'); + await withRetry(async () => { + await db.execute(sql`SELECT 1`); + }, 'Database connection'); + console.log('\u2705 Database connection successful\n'); + + // Attempt to acquire advisory lock + console.log('\ud83d\udd12 Attempting to acquire migration lock...'); + + lockAcquired = await withRetry(() => acquireLock(db), 'Acquire lock'); + + if (!lockAcquired) { + console.log('\u23f3 Another instance is running migrations. Waiting for lock...'); + + lockAcquired = await waitForLock(db); + + if (!lockAcquired) { + throw new Error( + `Migration lock timeout after ${MAX_LOCK_WAIT_MS / 1000}s - another migration may be stuck` + ); + } + } + + console.log('\u2705 Migration lock acquired\n'); + + // Check if migration files exist + const migrationsFolder = './src/db/migrations'; + const journalPath = path.join(migrationsFolder, 'meta', '_journal.json'); + + if (!fs.existsSync(journalPath)) { + console.log('\u26a0\ufe0f No migration files found (meta/_journal.json missing)'); + console.log(' This is normal if you have not generated any migrations yet.'); + console.log(' To generate migrations, run: pnpm migration:generate'); + console.log(' For development, you can use: pnpm db:push'); + console.log('\n\u2705 No migrations to run\n'); + return; + } + + // Run migrations + console.log('\ud83d\udce6 Running database migrations...'); + + await withRetry( + async () => { + await migrate(db, { + migrationsFolder, + }); + }, + 'Run migrations', + 1 // Only 1 attempt for actual migrations (they should be idempotent) + ); + + console.log('\u2705 Migrations completed successfully\n'); + } catch (error) { + console.error('\n\u274c Migration failed:', error); + throw error; + } finally { + // Always attempt to release lock + if (lockAcquired) { + try { + await releaseLock(db); + console.log('\ud83d\udd13 Migration lock released'); + } catch (unlockError) { + console.error('\u26a0\ufe0f Failed to release lock:', unlockError); + } + } + + // Close connection + try { + await connection.end(); + console.log('\ud83d\udd0c Database connection closed\n'); + } catch (closeError) { + console.error('\u26a0\ufe0f Failed to close connection:', closeError); + } + } +} + +// Run migrations +runMigrations() + .then(() => { + console.log('\ud83c\udf89 Calendar migration process completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n\ud83d\udca5 Migration process failed:', error.message); + process.exit(1); + }); diff --git a/apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts b/apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts new file mode 100644 index 000000000..31d733ff8 --- /dev/null +++ b/apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts @@ -0,0 +1,26 @@ +import { pgTable, uuid, text, timestamp, varchar, boolean, index } from 'drizzle-orm/pg-core'; + +/** + * Device tokens table - stores Expo push tokens for mobile notifications + */ +export const deviceTokens = pgTable( + 'device_tokens', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + pushToken: text('push_token').notNull().unique(), + platform: varchar('platform', { length: 20 }).notNull(), // 'ios' | 'android' + deviceName: varchar('device_name', { length: 255 }), + isActive: boolean('is_active').default(true), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('device_tokens_user_idx').on(table.userId), + tokenIdx: index('device_tokens_token_idx').on(table.pushToken), + activeIdx: index('device_tokens_active_idx').on(table.userId, table.isActive), + }) +); + +export type DeviceToken = typeof deviceTokens.$inferSelect; +export type NewDeviceToken = typeof deviceTokens.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/index.ts b/apps/calendar/apps/backend/src/db/schema/index.ts index 430959615..f9e77b1fa 100644 --- a/apps/calendar/apps/backend/src/db/schema/index.ts +++ b/apps/calendar/apps/backend/src/db/schema/index.ts @@ -6,3 +6,4 @@ export * from './event-tag-groups.schema'; export * from './calendar-shares.schema'; export * from './reminders.schema'; export * from './external-calendars.schema'; +export * from './device-tokens.schema'; diff --git a/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts b/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts index a41b486ff..41130967a 100644 --- a/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts @@ -22,6 +22,9 @@ export const reminders = pgTable( .references(() => events.id, { onDelete: 'cascade' }), userId: text('user_id').notNull(), + // User email for email notifications (stored at creation time) + userEmail: varchar('user_email', { length: 255 }), + // Timing minutesBefore: integer('minutes_before').notNull(), reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(), diff --git a/apps/calendar/apps/backend/src/email/email.module.ts b/apps/calendar/apps/backend/src/email/email.module.ts new file mode 100644 index 000000000..43cc0d651 --- /dev/null +++ b/apps/calendar/apps/backend/src/email/email.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Global() +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/apps/calendar/apps/backend/src/email/email.service.ts b/apps/calendar/apps/backend/src/email/email.service.ts new file mode 100644 index 000000000..e4053c22a --- /dev/null +++ b/apps/calendar/apps/backend/src/email/email.service.ts @@ -0,0 +1,219 @@ +/** + * Calendar Email Service + * + * Sends transactional emails via Brevo SMTP for: + * - Event reminders + * - Calendar share invitations + */ + +import { Injectable, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; + +interface EmailOptions { + to: string; + subject: string; + html: string; + text?: string; +} + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private transporter: nodemailer.Transporter | null = null; + + private getTransporter(): nodemailer.Transporter | null { + if (this.transporter) { + return this.transporter; + } + + const host = process.env.SMTP_HOST || 'smtp-relay.brevo.com'; + const port = parseInt(process.env.SMTP_PORT || '587', 10); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASSWORD; + + if (!user || !pass) { + this.logger.warn('SMTP credentials not configured, emails will be logged only'); + return null; + } + + this.transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { + user, + pass, + }, + }); + + return this.transporter; + } + + /** + * Send an email via Brevo SMTP + */ + async sendEmail(options: EmailOptions): Promise { + const { to, subject, html, text } = options; + const from = process.env.SMTP_FROM || 'ManaCore Calendar '; + + this.logger.log(`Sending email to: ${to}, subject: ${subject}`); + + const transport = this.getTransporter(); + + if (!transport) { + this.logger.log('No SMTP configured, logging email content:'); + this.logger.log(` To: ${to}`); + this.logger.log(` Subject: ${subject}`); + this.logger.debug(` HTML: ${html.substring(0, 200)}...`); + return false; + } + + try { + const result = await transport.sendMail({ + from, + to, + subject, + html, + text: text || html.replace(/<[^>]*>/g, ''), + }); + + this.logger.log(`Sent successfully, messageId: ${result.messageId}`); + return true; + } catch (error) { + this.logger.error('Failed to send email:', error); + return false; + } + } + + /** + * Send event reminder email + */ + async sendReminderEmail( + email: string, + eventTitle: string, + eventTime: Date, + minutesBefore: number + ): Promise { + const formattedTime = eventTime.toLocaleString('de-DE', { + dateStyle: 'full', + timeStyle: 'short', + timeZone: 'Europe/Berlin', + }); + + const timeLabel = this.formatMinutesBefore(minutesBefore); + + return this.sendEmail({ + to: email, + subject: `Erinnerung: ${eventTitle} - ${timeLabel}`, + html: ` + + + + + + + +
+

ManaCore Kalender

+
+ +
+

${eventTitle}

+

${formattedTime}

+
+ +

Dein Termin beginnt ${timeLabel}.

+ +
+ +

+ Diese Erinnerung wurde automatisch von ManaCore Kalender gesendet. +

+ + +`, + }); + } + + /** + * Send calendar share invitation email + */ + async sendCalendarInvitationEmail( + email: string, + calendarName: string, + inviterName: string, + permission: string, + acceptUrl: string + ): Promise { + const permissionLabel = this.formatPermission(permission); + + return this.sendEmail({ + to: email, + subject: `Kalender-Einladung: ${calendarName}`, + html: ` + + + + + + + +
+

ManaCore Kalender

+
+ +

Hallo,

+ +

${inviterName} hat den Kalender ${calendarName} mit dir geteilt.

+ +
+

Berechtigung: ${permissionLabel}

+
+ + + +

Diese Einladung ist 7 Tage gültig.

+ +
+ +

+ Diese E-Mail wurde automatisch von ManaCore Kalender gesendet.
+ Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+ ${acceptUrl} +

+ + +`, + }); + } + + private formatMinutesBefore(minutes: number): string { + if (minutes === 0) { + return 'jetzt'; + } + if (minutes < 60) { + return `in ${minutes} Minuten`; + } + if (minutes < 1440) { + const hours = Math.round(minutes / 60); + return `in ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`; + } + const days = Math.round(minutes / 1440); + return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; + } + + private formatPermission(permission: string): string { + switch (permission) { + case 'read': + return 'Nur Lesen'; + case 'write': + return 'Bearbeiten'; + case 'admin': + return 'Vollzugriff'; + default: + return permission; + } + } +} diff --git a/apps/calendar/apps/backend/src/event/event.service.spec.ts b/apps/calendar/apps/backend/src/event/event.service.spec.ts new file mode 100644 index 000000000..bc6a62285 --- /dev/null +++ b/apps/calendar/apps/backend/src/event/event.service.spec.ts @@ -0,0 +1,275 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { EventService } from './event.service'; +import { CalendarService } from '../calendar/calendar.service'; +import { EventTagService } from '../event-tag/event-tag.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { + createMockEvent, + createMockCalendar, + TEST_USER_ID, +} from '../__tests__/utils/mock-factories'; + +describe('EventService', () => { + let service: EventService; + let mockDb: any; + let mockCalendarService: jest.Mocked; + let mockEventTagService: jest.Mocked; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + mockCalendarService = { + findByIdOrThrow: jest.fn(), + getOrCreateDefaultCalendar: jest.fn(), + } as unknown as jest.Mocked; + + mockEventTagService = { + setEventTags: jest.fn(), + getTagsForEvent: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: CalendarService, + useValue: mockCalendarService, + }, + { + provide: EventTagService, + useValue: mockEventTagService, + }, + ], + }).compile(); + + service = module.get(EventService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('queryEvents', () => { + it('should return events within date range', async () => { + const events = [createMockEvent({ title: 'Event 1' }), createMockEvent({ title: 'Event 2' })]; + mockDb.orderBy.mockResolvedValueOnce(events); + + const result = await service.queryEvents(TEST_USER_ID, { + startDate: '2024-01-01', + endDate: '2024-12-31', + }); + + expect(result).toEqual(events); + expect(mockDb.select).toHaveBeenCalled(); + }); + + it('should filter by calendar IDs', async () => { + const calendarId = 'calendar-123'; + const events = [createMockEvent({ calendarId })]; + mockDb.orderBy.mockResolvedValueOnce(events); + + const result = await service.queryEvents(TEST_USER_ID, { + calendarIds: [calendarId], + }); + + expect(result).toEqual(events); + }); + + it('should return empty array when no events match', async () => { + mockDb.orderBy.mockResolvedValueOnce([]); + + const result = await service.queryEvents(TEST_USER_ID, {}); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return event when found', async () => { + const event = createMockEvent(); + mockDb.where.mockResolvedValueOnce([event]); + + const result = await service.findById(event.id, TEST_USER_ID); + + expect(result).toEqual(event); + }); + + it('should return null when event not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id', TEST_USER_ID); + + expect(result).toBeNull(); + }); + }); + + describe('findByIdOrThrow', () => { + it('should return event when found', async () => { + const event = createMockEvent(); + mockDb.where.mockResolvedValueOnce([event]); + + const result = await service.findByIdOrThrow(event.id, TEST_USER_ID); + + expect(result).toEqual(event); + }); + + it('should throw NotFoundException when event not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findByIdOrThrow('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('create', () => { + it('should create event with provided calendarId', async () => { + const calendar = createMockCalendar(); + const newEvent = createMockEvent({ calendarId: calendar.id }); + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.returning.mockResolvedValueOnce([newEvent]); + + const result = await service.create(TEST_USER_ID, { + calendarId: calendar.id, + title: 'New Event', + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + }); + + expect(result).toEqual(newEvent); + expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith(calendar.id, TEST_USER_ID); + }); + + it('should create event using default calendar when no calendarId provided', async () => { + const defaultCalendar = createMockCalendar({ isDefault: true }); + const newEvent = createMockEvent({ calendarId: defaultCalendar.id }); + + mockCalendarService.getOrCreateDefaultCalendar.mockResolvedValueOnce(defaultCalendar); + mockDb.returning.mockResolvedValueOnce([newEvent]); + + const result = await service.create(TEST_USER_ID, { + title: 'New Event', + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + }); + + expect(result).toEqual(newEvent); + expect(mockCalendarService.getOrCreateDefaultCalendar).toHaveBeenCalledWith(TEST_USER_ID); + }); + + it('should set tags when tagIds provided', async () => { + const calendar = createMockCalendar(); + const newEvent = createMockEvent({ calendarId: calendar.id }); + const tagIds = ['tag-1', 'tag-2']; + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.returning.mockResolvedValueOnce([newEvent]); + + await service.create(TEST_USER_ID, { + calendarId: calendar.id, + title: 'New Event', + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + tagIds, + }); + + expect(mockEventTagService.setEventTags).toHaveBeenCalledWith(newEvent.id, tagIds); + }); + }); + + describe('update', () => { + it('should update event', async () => { + const event = createMockEvent(); + const updatedEvent = { ...event, title: 'Updated Title' }; + + mockDb.where.mockResolvedValueOnce([event]); + mockDb.returning.mockResolvedValueOnce([updatedEvent]); + + const result = await service.update(event.id, TEST_USER_ID, { + title: 'Updated Title', + }); + + expect(result.title).toBe('Updated Title'); + }); + + it('should throw NotFoundException when updating non-existent event', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { title: 'New Title' }) + ).rejects.toThrow(NotFoundException); + }); + + it('should verify calendar ownership when changing calendar', async () => { + const event = createMockEvent(); + const newCalendar = createMockCalendar({ id: 'new-calendar-id' }); + const updatedEvent = { ...event, calendarId: newCalendar.id }; + + mockDb.where.mockResolvedValueOnce([event]); + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(newCalendar); + mockDb.returning.mockResolvedValueOnce([updatedEvent]); + + await service.update(event.id, TEST_USER_ID, { + calendarId: newCalendar.id, + }); + + expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith( + newCalendar.id, + TEST_USER_ID + ); + }); + }); + + describe('delete', () => { + it('should delete event', async () => { + const event = createMockEvent(); + mockDb.where.mockResolvedValueOnce([event]); + + await service.delete(event.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when deleting non-existent event', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('findByCalendar', () => { + it('should return events for specific calendar', async () => { + const calendar = createMockCalendar(); + const events = [createMockEvent({ calendarId: calendar.id })]; + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.orderBy.mockResolvedValueOnce(events); + + const result = await service.findByCalendar(calendar.id, TEST_USER_ID, {}); + + expect(result).toEqual(events); + expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith(calendar.id, TEST_USER_ID); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/notification/dto/index.ts b/apps/calendar/apps/backend/src/notification/dto/index.ts new file mode 100644 index 000000000..56b4c0554 --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/dto/index.ts @@ -0,0 +1 @@ +export * from './register-token.dto'; diff --git a/apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts b/apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts new file mode 100644 index 000000000..4de7cdfbe --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsNotEmpty, IsIn, IsOptional, Matches } from 'class-validator'; + +export class RegisterTokenDto { + @IsString() + @IsNotEmpty() + @Matches(/^ExponentPushToken\[.+\]$/, { + message: 'pushToken must be a valid Expo push token', + }) + pushToken: string; + + @IsString() + @IsIn(['ios', 'android']) + platform: 'ios' | 'android'; + + @IsOptional() + @IsString() + deviceName?: string; +} diff --git a/apps/calendar/apps/backend/src/notification/notification.controller.ts b/apps/calendar/apps/backend/src/notification/notification.controller.ts new file mode 100644 index 000000000..900a4189e --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/notification.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Post, Delete, Body, Param, UseGuards, Get } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { NotificationService } from './notification.service'; +import { RegisterTokenDto } from './dto'; + +@Controller('api/v1/notifications') +@UseGuards(JwtAuthGuard) +export class NotificationController { + constructor(private notificationService: NotificationService) {} + + /** + * Register a push token for the current user + */ + @Post('register-token') + async registerToken(@CurrentUser() user: CurrentUserData, @Body() dto: RegisterTokenDto) { + const token = await this.notificationService.registerToken(user.userId, dto); + return { + success: true, + token: { + id: token.id, + platform: token.platform, + deviceName: token.deviceName, + createdAt: token.createdAt, + }, + }; + } + + /** + * Remove a push token + */ + @Delete('token/:token') + async removeToken(@CurrentUser() user: CurrentUserData, @Param('token') token: string) { + await this.notificationService.removeToken(decodeURIComponent(token)); + return { success: true }; + } + + /** + * Get the number of registered devices for the current user + */ + @Get('devices/count') + async getDeviceCount(@CurrentUser() user: CurrentUserData) { + const count = await this.notificationService.getTokenCount(user.userId); + return { count }; + } +} diff --git a/apps/calendar/apps/backend/src/notification/notification.module.ts b/apps/calendar/apps/backend/src/notification/notification.module.ts new file mode 100644 index 000000000..b73839a2b --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/notification.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { NotificationController } from './notification.controller'; +import { NotificationService } from './notification.service'; +import { PushService } from './push.service'; + +@Module({ + controllers: [NotificationController], + providers: [NotificationService, PushService], + exports: [NotificationService, PushService], +}) +export class NotificationModule {} diff --git a/apps/calendar/apps/backend/src/notification/notification.service.ts b/apps/calendar/apps/backend/src/notification/notification.service.ts new file mode 100644 index 000000000..9a02f1127 --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/notification.service.ts @@ -0,0 +1,125 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { + deviceTokens, + type DeviceToken, + type NewDeviceToken, +} from '../db/schema/device-tokens.schema'; +import { PushService, PushNotification } from './push.service'; +import { RegisterTokenDto } from './dto'; + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private pushService: PushService + ) {} + + /** + * Register or update a device token for a user + */ + async registerToken(userId: string, dto: RegisterTokenDto): Promise { + const { pushToken, platform, deviceName } = dto; + + // Check if token already exists + const existing = await this.db + .select() + .from(deviceTokens) + .where(eq(deviceTokens.pushToken, pushToken)); + + if (existing.length > 0) { + // Update existing token (might be a different user now) + const [updated] = await this.db + .update(deviceTokens) + .set({ + userId, + platform, + deviceName, + isActive: true, + updatedAt: new Date(), + }) + .where(eq(deviceTokens.pushToken, pushToken)) + .returning(); + + this.logger.log(`Updated device token for user ${userId}`); + return updated; + } + + // Create new token + const newToken: NewDeviceToken = { + userId, + pushToken, + platform, + deviceName, + isActive: true, + }; + + const [created] = await this.db.insert(deviceTokens).values(newToken).returning(); + this.logger.log(`Registered new device token for user ${userId}`); + return created; + } + + /** + * Remove a device token + */ + async removeToken(pushToken: string): Promise { + await this.db.delete(deviceTokens).where(eq(deviceTokens.pushToken, pushToken)); + this.logger.log(`Removed device token: ${pushToken.substring(0, 30)}...`); + } + + /** + * Deactivate a token (soft delete) + */ + async deactivateToken(pushToken: string): Promise { + await this.db + .update(deviceTokens) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(deviceTokens.pushToken, pushToken)); + } + + /** + * Get all active tokens for a user + */ + async getActiveTokensForUser(userId: string): Promise { + return this.db + .select() + .from(deviceTokens) + .where(and(eq(deviceTokens.userId, userId), eq(deviceTokens.isActive, true))); + } + + /** + * Send push notification to a user's devices + */ + async sendToUser(userId: string, notification: PushNotification): Promise { + const tokens = await this.getActiveTokensForUser(userId); + + if (tokens.length === 0) { + this.logger.debug(`No active push tokens for user ${userId}`); + return false; + } + + const pushTokens = tokens.map((t) => t.pushToken); + const results = await this.pushService.sendToTokens(pushTokens, notification); + + // Deactivate tokens that failed (might be invalid/unregistered) + for (const [token, success] of results.entries()) { + if (!success) { + await this.deactivateToken(token); + } + } + + return Array.from(results.values()).some((v) => v); + } + + /** + * Get count of active tokens for a user + */ + async getTokenCount(userId: string): Promise { + const tokens = await this.getActiveTokensForUser(userId); + return tokens.length; + } +} diff --git a/apps/calendar/apps/backend/src/notification/push.service.ts b/apps/calendar/apps/backend/src/notification/push.service.ts new file mode 100644 index 000000000..9a8474a4f --- /dev/null +++ b/apps/calendar/apps/backend/src/notification/push.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Expo, { ExpoPushMessage, ExpoPushTicket, ExpoPushReceipt } from 'expo-server-sdk'; + +export interface PushNotification { + title: string; + body: string; + data?: Record; + sound?: 'default' | null; + badge?: number; +} + +@Injectable() +export class PushService { + private readonly logger = new Logger(PushService.name); + private expo: Expo; + + constructor() { + this.expo = new Expo(); + } + + /** + * Validate if a token is a valid Expo push token + */ + isValidToken(token: string): boolean { + return Expo.isExpoPushToken(token); + } + + /** + * Send push notification to a single token + */ + async sendToToken(token: string, notification: PushNotification): Promise { + if (!this.isValidToken(token)) { + this.logger.warn(`Invalid Expo push token: ${token}`); + return false; + } + + const message: ExpoPushMessage = { + to: token, + title: notification.title, + body: notification.body, + data: notification.data, + sound: notification.sound ?? 'default', + badge: notification.badge, + }; + + try { + const tickets = await this.expo.sendPushNotificationsAsync([message]); + const ticket = tickets[0]; + + if (ticket.status === 'error') { + this.logger.error(`Push notification error: ${ticket.message}`, ticket.details); + return false; + } + + this.logger.log(`Push notification sent successfully to token: ${token.substring(0, 30)}...`); + return true; + } catch (error) { + this.logger.error('Failed to send push notification:', error); + return false; + } + } + + /** + * Send push notification to multiple tokens + */ + async sendToTokens( + tokens: string[], + notification: PushNotification + ): Promise> { + const results = new Map(); + const validTokens = tokens.filter((token) => { + const isValid = this.isValidToken(token); + if (!isValid) { + this.logger.warn(`Skipping invalid token: ${token}`); + results.set(token, false); + } + return isValid; + }); + + if (validTokens.length === 0) { + return results; + } + + const messages: ExpoPushMessage[] = validTokens.map((token) => ({ + to: token, + title: notification.title, + body: notification.body, + data: notification.data, + sound: notification.sound ?? 'default', + badge: notification.badge, + })); + + // Chunk messages (Expo has a limit of 100 per batch) + const chunks = this.expo.chunkPushNotifications(messages); + + for (const chunk of chunks) { + try { + const tickets = await this.expo.sendPushNotificationsAsync(chunk); + + tickets.forEach((ticket, index) => { + const token = (chunk[index] as ExpoPushMessage).to as string; + if (ticket.status === 'ok') { + results.set(token, true); + } else { + this.logger.error(`Push error for ${token}: ${ticket.message}`); + results.set(token, false); + } + }); + } catch (error) { + this.logger.error('Failed to send push notification batch:', error); + chunk.forEach((msg) => { + results.set(msg.to as string, false); + }); + } + } + + const successCount = Array.from(results.values()).filter((v) => v).length; + this.logger.log(`Push notifications sent: ${successCount}/${tokens.length} successful`); + + return results; + } + + /** + * Check receipts for sent notifications + * Call this after some time to verify delivery + */ + async checkReceipts(ticketIds: string[]): Promise> { + const results = new Map(); + const chunks = this.expo.chunkPushNotificationReceiptIds(ticketIds); + + for (const chunk of chunks) { + try { + const receipts = await this.expo.getPushNotificationReceiptsAsync(chunk); + + for (const [id, receipt] of Object.entries(receipts)) { + results.set(id, receipt); + + if (receipt.status === 'error') { + this.logger.error(`Receipt error for ${id}: ${receipt.message}`, receipt.details); + } + } + } catch (error) { + this.logger.error('Failed to get push notification receipts:', error); + } + } + + return results; + } +} diff --git a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts index 77d18b7bd..72ae5561a 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts @@ -20,7 +20,7 @@ export class ReminderController { @Param('eventId') eventId: string, @Body() dto: Omit ) { - const reminder = await this.reminderService.create(user.userId, { + const reminder = await this.reminderService.create(user.userId, user.email, { ...dto, eventId, }); diff --git a/apps/calendar/apps/backend/src/reminder/reminder.module.ts b/apps/calendar/apps/backend/src/reminder/reminder.module.ts index 6edb2966c..1a82e5df5 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.module.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { ReminderController } from './reminder.controller'; import { ReminderService } from './reminder.service'; import { EventModule } from '../event/event.module'; +import { NotificationModule } from '../notification/notification.module'; @Module({ - imports: [EventModule], + imports: [EventModule, NotificationModule], controllers: [ReminderController], providers: [ReminderService], exports: [ReminderService], diff --git a/apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts b/apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts new file mode 100644 index 000000000..aef42b8b4 --- /dev/null +++ b/apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts @@ -0,0 +1,300 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { ReminderService } from './reminder.service'; +import { EventService } from '../event/event.service'; +import { EmailService } from '../email/email.service'; +import { NotificationService } from '../notification/notification.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { + createMockReminder, + createMockEvent, + TEST_USER_ID, + TEST_USER_EMAIL, +} from '../__tests__/utils/mock-factories'; + +describe('ReminderService', () => { + let service: ReminderService; + let mockDb: any; + let mockEventService: jest.Mocked; + let mockEmailService: jest.Mocked; + let mockNotificationService: jest.Mocked; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + mockEventService = { + findByIdOrThrow: jest.fn(), + } as unknown as jest.Mocked; + + mockEmailService = { + sendReminderEmail: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; + + mockNotificationService = { + sendToUser: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReminderService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: EventService, + useValue: mockEventService, + }, + { + provide: EmailService, + useValue: mockEmailService, + }, + { + provide: NotificationService, + useValue: mockNotificationService, + }, + ], + }).compile(); + + service = module.get(ReminderService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByEvent', () => { + it('should return reminders for an event', async () => { + const event = createMockEvent(); + const reminders = [ + createMockReminder({ eventId: event.id }), + createMockReminder({ eventId: event.id }), + ]; + + mockEventService.findByIdOrThrow.mockResolvedValueOnce(event); + mockDb.where.mockResolvedValueOnce(reminders); + + const result = await service.findByEvent(event.id, TEST_USER_ID); + + expect(result).toEqual(reminders); + expect(mockEventService.findByIdOrThrow).toHaveBeenCalledWith(event.id, TEST_USER_ID); + }); + }); + + describe('findById', () => { + it('should return reminder when found', async () => { + const reminder = createMockReminder(); + mockDb.where.mockResolvedValueOnce([reminder]); + + const result = await service.findById(reminder.id, TEST_USER_ID); + + expect(result).toEqual(reminder); + }); + + it('should return null when reminder not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id', TEST_USER_ID); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create a new reminder', async () => { + const event = createMockEvent(); + const newReminder = createMockReminder({ eventId: event.id }); + + mockEventService.findByIdOrThrow.mockResolvedValueOnce(event); + mockDb.returning.mockResolvedValueOnce([newReminder]); + + const result = await service.create(TEST_USER_ID, TEST_USER_EMAIL, { + eventId: event.id, + minutesBefore: 15, + notifyPush: true, + notifyEmail: false, + }); + + expect(result).toEqual(newReminder); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should calculate reminder time based on event start time', async () => { + const startTime = new Date('2024-12-15T10:00:00Z'); + const event = createMockEvent({ startTime }); + const newReminder = createMockReminder({ + eventId: event.id, + minutesBefore: 30, + }); + + mockEventService.findByIdOrThrow.mockResolvedValueOnce(event); + mockDb.returning.mockResolvedValueOnce([newReminder]); + + await service.create(TEST_USER_ID, TEST_USER_EMAIL, { + eventId: event.id, + minutesBefore: 30, + }); + + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ + minutesBefore: 30, + userEmail: TEST_USER_EMAIL, + }) + ); + }); + }); + + describe('delete', () => { + it('should delete reminder', async () => { + const reminder = createMockReminder(); + mockDb.where.mockResolvedValueOnce([reminder]); + + await service.delete(reminder.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when deleting non-existent reminder', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('getPendingReminders', () => { + it('should return pending reminders due within a minute', async () => { + const reminders = [ + createMockReminder({ status: 'pending' }), + createMockReminder({ status: 'pending' }), + ]; + mockDb.where.mockResolvedValueOnce(reminders); + + const result = await service.getPendingReminders(); + + expect(result).toEqual(reminders); + }); + }); + + describe('markAsSent', () => { + it('should mark reminder as sent with timestamp', async () => { + const reminder = createMockReminder(); + + await service.markAsSent(reminder.id); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'sent', + }) + ); + }); + }); + + describe('markAsFailed', () => { + it('should mark reminder as failed', async () => { + const reminder = createMockReminder(); + + await service.markAsFailed(reminder.id, 'Error message'); + + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + }) + ); + }); + }); + + describe('processReminders', () => { + it('should process pending reminders and send notifications', async () => { + const event = createMockEvent(); + const reminder = createMockReminder({ + eventId: event.id, + notifyPush: true, + notifyEmail: true, + userEmail: TEST_USER_EMAIL, + }); + + // Mock getPendingReminders + mockDb.where.mockResolvedValueOnce([reminder]); + // Mock event lookup + mockDb.where.mockResolvedValueOnce([event]); + + await service.processReminders(); + + expect(mockNotificationService.sendToUser).toHaveBeenCalledWith( + reminder.userId, + expect.objectContaining({ + title: expect.stringContaining('Erinnerung'), + body: expect.any(String), + }) + ); + expect(mockEmailService.sendReminderEmail).toHaveBeenCalledWith( + TEST_USER_EMAIL, + event.title, + expect.any(Date), + reminder.minutesBefore + ); + }); + + it('should mark reminder as failed when event not found', async () => { + const reminder = createMockReminder(); + + // Mock getPendingReminders + mockDb.where.mockResolvedValueOnce([reminder]); + // Mock event lookup - not found + mockDb.where.mockResolvedValueOnce([]); + + await service.processReminders(); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it('should skip email if userEmail not set', async () => { + const event = createMockEvent(); + const reminder = createMockReminder({ + eventId: event.id, + notifyPush: false, + notifyEmail: true, + userEmail: null, + }); + + mockDb.where.mockResolvedValueOnce([reminder]); + mockDb.where.mockResolvedValueOnce([event]); + + await service.processReminders(); + + expect(mockEmailService.sendReminderEmail).not.toHaveBeenCalled(); + }); + }); + + describe('updateRemindersForEvent', () => { + it('should update reminder times when event time changes', async () => { + const eventId = 'event-123'; + const newStartTime = new Date('2024-12-20T14:00:00Z'); + const reminders = [ + createMockReminder({ eventId, minutesBefore: 15 }), + createMockReminder({ eventId, minutesBefore: 60 }), + ]; + + mockDb.where.mockResolvedValueOnce(reminders); + + await service.updateRemindersForEvent(eventId, newStartTime); + + // Should have updated each reminder + expect(mockDb.update).toHaveBeenCalledTimes(reminders.length); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/reminder/reminder.service.ts b/apps/calendar/apps/backend/src/reminder/reminder.service.ts index c356bb62f..3459b95c3 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.service.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { eq, and, lte } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; @@ -7,13 +7,19 @@ import { reminders } from '../db/schema/reminders.schema'; import type { Reminder, NewReminder } from '../db/schema/reminders.schema'; import { events } from '../db/schema/events.schema'; import { EventService } from '../event/event.service'; +import { EmailService } from '../email/email.service'; +import { NotificationService } from '../notification/notification.service'; import { CreateReminderDto } from './dto'; @Injectable() export class ReminderService { + private readonly logger = new Logger(ReminderService.name); + constructor( @Inject(DATABASE_CONNECTION) private db: Database, - private eventService: EventService + private eventService: EventService, + private emailService: EmailService, + private notificationService: NotificationService ) {} async findByEvent(eventId: string, userId: string): Promise { @@ -34,7 +40,7 @@ export class ReminderService { return result[0] || null; } - async create(userId: string, dto: CreateReminderDto): Promise { + async create(userId: string, userEmail: string, dto: CreateReminderDto): Promise { // Verify user owns the event and get event details const event = await this.eventService.findByIdOrThrow(dto.eventId, userId); @@ -45,6 +51,7 @@ export class ReminderService { const newReminder: NewReminder = { eventId: dto.eventId, userId, + userEmail, minutesBefore: dto.minutesBefore, reminderTime, notifyPush: dto.notifyPush ?? true, @@ -85,17 +92,23 @@ export class ReminderService { async markAsFailed(id: string, error: string): Promise { await this.db.update(reminders).set({ status: 'failed' }).where(eq(reminders.id, id)); - console.error(`Reminder ${id} failed:`, error); + this.logger.error(`Reminder ${id} failed: ${error}`); } /** * Process pending reminders every minute - * In production, this would send push notifications and emails + * Sends push notifications and emails based on reminder settings */ @Cron(CronExpression.EVERY_MINUTE) async processReminders() { const pendingReminders = await this.getPendingReminders(); + if (pendingReminders.length === 0) { + return; + } + + this.logger.log(`Processing ${pendingReminders.length} pending reminders`); + for (const reminder of pendingReminders) { try { // Get event details for the notification @@ -110,24 +123,55 @@ export class ReminderService { } const event = eventResult[0]; + let pushSent = false; + let emailSent = false; - // TODO: Implement actual notification sending - // For now, just log and mark as sent - console.log( - `[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes` - ); - + // Send push notification if (reminder.notifyPush) { - // TODO: Send push notification via Expo Push API - console.log(` - Would send push notification to user ${reminder.userId}`); + try { + pushSent = await this.notificationService.sendToUser(reminder.userId, { + title: `Erinnerung: ${event.title}`, + body: this.formatReminderBody(event.title, reminder.minutesBefore), + data: { + type: 'reminder', + eventId: event.id, + reminderId: reminder.id, + }, + }); + + if (pushSent) { + this.logger.log(`Push notification sent for reminder ${reminder.id}`); + } + } catch (error) { + this.logger.error(`Push notification failed for reminder ${reminder.id}:`, error); + } } - if (reminder.notifyEmail) { - // TODO: Send email notification - console.log(` - Would send email to user ${reminder.userId}`); + // Send email notification + if (reminder.notifyEmail && reminder.userEmail) { + try { + emailSent = await this.emailService.sendReminderEmail( + reminder.userEmail, + event.title, + new Date(event.startTime), + reminder.minutesBefore + ); + + if (emailSent) { + this.logger.log(`Email sent for reminder ${reminder.id}`); + } + } catch (error) { + this.logger.error(`Email notification failed for reminder ${reminder.id}:`, error); + } } - await this.markAsSent(reminder.id); + // Mark as sent if at least one notification was attempted + // (even if user has no push tokens, we consider it "sent" for push-only reminders) + if (!reminder.notifyPush || pushSent || !reminder.notifyEmail || emailSent) { + await this.markAsSent(reminder.id); + } else { + await this.markAsFailed(reminder.id, 'All notification channels failed'); + } } catch (error) { await this.markAsFailed(reminder.id, (error as Error).message); } @@ -152,4 +196,19 @@ export class ReminderService { .where(eq(reminders.id, reminder.id)); } } + + private formatReminderBody(eventTitle: string, minutesBefore: number): string { + if (minutesBefore === 0) { + return `"${eventTitle}" beginnt jetzt`; + } + if (minutesBefore < 60) { + return `"${eventTitle}" beginnt in ${minutesBefore} Minuten`; + } + if (minutesBefore < 1440) { + const hours = Math.round(minutesBefore / 60); + return `"${eventTitle}" beginnt in ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`; + } + const days = Math.round(minutesBefore / 1440); + return `"${eventTitle}" beginnt in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; + } } diff --git a/apps/calendar/apps/backend/src/share/share.controller.ts b/apps/calendar/apps/backend/src/share/share.controller.ts index 0180c281c..3ef418bb0 100644 --- a/apps/calendar/apps/backend/src/share/share.controller.ts +++ b/apps/calendar/apps/backend/src/share/share.controller.ts @@ -23,7 +23,7 @@ export class ShareController { @Param('calendarId') calendarId: string, @Body() dto: Omit ) { - const share = await this.shareService.create(user.userId, { + const share = await this.shareService.create(user.userId, user.email, { ...dto, calendarId, }); diff --git a/apps/calendar/apps/backend/src/share/share.service.spec.ts b/apps/calendar/apps/backend/src/share/share.service.spec.ts new file mode 100644 index 000000000..9d745cade --- /dev/null +++ b/apps/calendar/apps/backend/src/share/share.service.spec.ts @@ -0,0 +1,326 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { ShareService } from './share.service'; +import { CalendarService } from '../calendar/calendar.service'; +import { EmailService } from '../email/email.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { + createMockCalendarShare, + createMockCalendar, + TEST_USER_ID, + TEST_USER_EMAIL, +} from '../__tests__/utils/mock-factories'; + +describe('ShareService', () => { + let service: ShareService; + let mockDb: any; + let mockCalendarService: jest.Mocked; + let mockEmailService: jest.Mocked; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + mockCalendarService = { + findByIdOrThrow: jest.fn(), + } as unknown as jest.Mocked; + + mockEmailService = { + sendCalendarInvitationEmail: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ShareService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: CalendarService, + useValue: mockCalendarService, + }, + { + provide: EmailService, + useValue: mockEmailService, + }, + ], + }).compile(); + + service = module.get(ShareService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByCalendar', () => { + it('should return shares for a calendar', async () => { + const calendar = createMockCalendar(); + const shares = [ + createMockCalendarShare({ calendarId: calendar.id }), + createMockCalendarShare({ calendarId: calendar.id }), + ]; + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.where.mockResolvedValueOnce(shares); + + const result = await service.findByCalendar(calendar.id, TEST_USER_ID); + + expect(result).toEqual(shares); + expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith(calendar.id, TEST_USER_ID); + }); + }); + + describe('findById', () => { + it('should return share when found', async () => { + const share = createMockCalendarShare(); + mockDb.where.mockResolvedValueOnce([share]); + + const result = await service.findById(share.id); + + expect(result).toEqual(share); + }); + + it('should return null when share not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id'); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create share with email and send invitation', async () => { + const calendar = createMockCalendar(); + const newShare = createMockCalendarShare({ + calendarId: calendar.id, + sharedWithEmail: 'invited@example.com', + }); + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.returning.mockResolvedValueOnce([newShare]); + + const result = await service.create(TEST_USER_ID, TEST_USER_EMAIL, { + calendarId: calendar.id, + email: 'invited@example.com', + permission: 'read', + }); + + expect(result).toEqual(newShare); + expect(mockEmailService.sendCalendarInvitationEmail).toHaveBeenCalledWith( + 'invited@example.com', + calendar.name, + expect.any(String), + 'read', + expect.stringContaining('/shares/') + ); + }); + + it('should create shareable link when createLink is true', async () => { + const calendar = createMockCalendar(); + const newShare = createMockCalendarShare({ + calendarId: calendar.id, + shareToken: expect.any(String), + shareUrl: expect.stringContaining('/share/'), + }); + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.returning.mockResolvedValueOnce([newShare]); + + const result = await service.create(TEST_USER_ID, TEST_USER_EMAIL, { + calendarId: calendar.id, + createLink: true, + permission: 'read', + }); + + expect(result).toEqual(newShare); + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ + shareToken: expect.any(String), + }) + ); + // Should not send email for link sharing + expect(mockEmailService.sendCalendarInvitationEmail).not.toHaveBeenCalled(); + }); + + it('should set expiration date when provided', async () => { + const calendar = createMockCalendar(); + const expiresAt = '2024-12-31T23:59:59Z'; + const newShare = createMockCalendarShare({ + calendarId: calendar.id, + expiresAt: new Date(expiresAt), + }); + + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.returning.mockResolvedValueOnce([newShare]); + + await service.create(TEST_USER_ID, TEST_USER_EMAIL, { + calendarId: calendar.id, + email: 'invited@example.com', + permission: 'read', + expiresAt, + }); + + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ + expiresAt: expect.any(Date), + }) + ); + }); + }); + + describe('update', () => { + it('should update share permissions', async () => { + const share = createMockCalendarShare({ permission: 'read' }); + const calendar = createMockCalendar({ id: share.calendarId }); + const updatedShare = { ...share, permission: 'write' }; + + mockDb.where.mockResolvedValueOnce([share]); + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + mockDb.returning.mockResolvedValueOnce([updatedShare]); + + const result = await service.update(share.id, TEST_USER_ID, { + permission: 'write', + }); + + expect(result.permission).toBe('write'); + }); + + it('should throw NotFoundException when share not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { permission: 'write' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should delete share', async () => { + const share = createMockCalendarShare(); + const calendar = createMockCalendar({ id: share.calendarId }); + + mockDb.where.mockResolvedValueOnce([share]); + mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); + + await service.delete(share.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when deleting non-existent share', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('acceptInvitation', () => { + it('should accept pending invitation', async () => { + const share = createMockCalendarShare({ status: 'pending' }); + const acceptedShare = { + ...share, + status: 'accepted', + sharedWithUserId: TEST_USER_ID, + acceptedAt: new Date(), + }; + + mockDb.where.mockResolvedValueOnce([share]); + mockDb.returning.mockResolvedValueOnce([acceptedShare]); + + const result = await service.acceptInvitation(share.id, TEST_USER_ID); + + expect(result.status).toBe('accepted'); + expect(result.sharedWithUserId).toBe(TEST_USER_ID); + }); + + it('should throw NotFoundException when invitation not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.acceptInvitation('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + + it('should throw ForbiddenException when invitation already processed', async () => { + const share = createMockCalendarShare({ status: 'accepted' }); + mockDb.where.mockResolvedValueOnce([share]); + + await expect(service.acceptInvitation(share.id, TEST_USER_ID)).rejects.toThrow( + ForbiddenException + ); + }); + }); + + describe('declineInvitation', () => { + it('should decline pending invitation', async () => { + const share = createMockCalendarShare({ status: 'pending' }); + const declinedShare = { ...share, status: 'declined' }; + + mockDb.where.mockResolvedValueOnce([share]); + mockDb.returning.mockResolvedValueOnce([declinedShare]); + + const result = await service.declineInvitation(share.id, TEST_USER_ID); + + expect(result.status).toBe('declined'); + }); + + it('should throw ForbiddenException when invitation already processed', async () => { + const share = createMockCalendarShare({ status: 'declined' }); + mockDb.where.mockResolvedValueOnce([share]); + + await expect(service.declineInvitation(share.id, TEST_USER_ID)).rejects.toThrow( + ForbiddenException + ); + }); + }); + + describe('findByShareToken', () => { + it('should return share for valid token', async () => { + const share = createMockCalendarShare({ + shareToken: 'valid-token-123', + }); + mockDb.where.mockResolvedValueOnce([share]); + + const result = await service.findByShareToken('valid-token-123'); + + expect(result).toEqual(share); + }); + + it('should return null for invalid token', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findByShareToken('invalid-token'); + + expect(result).toBeNull(); + }); + }); + + describe('getSharedCalendarsForUser', () => { + it('should return accepted shares for user', async () => { + const shares = [ + createMockCalendarShare({ sharedWithUserId: TEST_USER_ID, status: 'accepted' }), + createMockCalendarShare({ sharedWithUserId: TEST_USER_ID, status: 'accepted' }), + ]; + mockDb.where.mockResolvedValueOnce(shares); + + const result = await service.getSharedCalendarsForUser(TEST_USER_ID); + + expect(result).toEqual(shares); + expect(result.every((s) => s.status === 'accepted')).toBe(true); + }); + }); +}); diff --git a/apps/calendar/apps/backend/src/share/share.service.ts b/apps/calendar/apps/backend/src/share/share.service.ts index c990fa405..31b5eed99 100644 --- a/apps/calendar/apps/backend/src/share/share.service.ts +++ b/apps/calendar/apps/backend/src/share/share.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { eq, and, or } from 'drizzle-orm'; import { randomBytes } from 'crypto'; import { DATABASE_CONNECTION } from '../db/database.module'; @@ -9,13 +9,17 @@ import { type NewCalendarShare, } from '../db/schema/calendar-shares.schema'; import { CalendarService } from '../calendar/calendar.service'; +import { EmailService } from '../email/email.service'; import { CreateShareDto, UpdateShareDto } from './dto'; @Injectable() export class ShareService { + private readonly logger = new Logger(ShareService.name); + constructor( @Inject(DATABASE_CONNECTION) private db: Database, - private calendarService: CalendarService + private calendarService: CalendarService, + private emailService: EmailService ) {} async findByCalendar(calendarId: string, userId: string): Promise { @@ -42,9 +46,9 @@ export class ShareService { ); } - async create(userId: string, dto: CreateShareDto): Promise { + async create(userId: string, inviterEmail: string, dto: CreateShareDto): Promise { // Verify user owns the calendar - await this.calendarService.findByIdOrThrow(dto.calendarId, userId); + const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId); const newShare: NewCalendarShare = { calendarId: dto.calendarId, @@ -68,6 +72,28 @@ export class ShareService { } const [created] = await this.db.insert(calendarShares).values(newShare).returning(); + + // Send invitation email if sharing with specific email + if (dto.email && !dto.createLink) { + const inviterName = inviterEmail.split('@')[0]; + const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5179'; + const acceptUrl = `${baseUrl}/shares/${created.id}/accept`; + + try { + await this.emailService.sendCalendarInvitationEmail( + dto.email, + calendar.name, + inviterName, + dto.permission, + acceptUrl + ); + this.logger.log(`Invitation email sent to ${dto.email} for calendar ${calendar.name}`); + } catch (error) { + this.logger.error(`Failed to send invitation email to ${dto.email}:`, error); + // Don't fail the share creation if email fails + } + } + return created; } diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index f45487663..a72ff16d0 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -11,7 +11,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", "format": "prettier --write .", - "type-check": "echo 'Skipping type-check for now'" + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { "@sveltejs/adapter-node": "^5.0.0", diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte index 84df580eb..2d8a0eb65 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte @@ -21,13 +21,19 @@ // View type labels const viewLabels: Record = { day: 'Tag', + '3day': '3 Tage', '5day': '5 Tage', week: 'Woche', '10day': '10 Tage', '14day': '14 Tage', + '30day': '30 Tage', + '60day': '60 Tage', + '90day': '90 Tage', + '365day': '365 Tage', month: 'Monat', year: 'Jahr', agenda: 'Agenda', + custom: 'Benutzerdefiniert', }; // Views to show in selector diff --git a/apps/calendar/apps/web/src/lib/components/calendar/StatsSidebarSection.svelte b/apps/calendar/apps/web/src/lib/components/calendar/StatsSidebarSection.svelte index a739ad0a4..86edc1838 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/StatsSidebarSection.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/StatsSidebarSection.svelte @@ -132,7 +132,7 @@ style="height: {(day.count / maxTrendValue) * 100}%" class:has-events={day.count > 0} > - {day.label.charAt(0)} + {day.label?.charAt(0) ?? ''} {/each} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte index e5f272d25..1a63700b8 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte @@ -26,25 +26,37 @@ // View labels (short versions for pill) const viewLabels: Record = { day: '1', + '3day': '3', '5day': '5', week: '7', '10day': '10', '14day': '14', + '30day': '30', + '60day': '60', + '90day': '90', + '365day': '365', month: 'M', year: 'Y', agenda: 'A', + custom: 'C', }; // View titles for tooltip const viewTitles: Record = { day: 'Tagesansicht', + '3day': '3-Tage-Ansicht', '5day': '5-Tage-Ansicht', week: 'Wochenansicht', '10day': '10-Tage-Ansicht', '14day': '14-Tage-Ansicht', + '30day': '30-Tage-Ansicht', + '60day': '60-Tage-Ansicht', + '90day': '90-Tage-Ansicht', + '365day': '365-Tage-Ansicht', month: 'Monatsansicht', year: 'Jahresansicht', agenda: 'Agenda', + custom: 'Benutzerdefiniert', }; // Get enabled views from settings diff --git a/apps/calendar/apps/web/src/lib/stores/network.svelte.ts b/apps/calendar/apps/web/src/lib/stores/network.svelte.ts index 917081c36..793d48c95 100644 --- a/apps/calendar/apps/web/src/lib/stores/network.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/network.svelte.ts @@ -196,10 +196,11 @@ export const networkStore = { })); // Convert to simulation links + // Cast type to be compatible with SimulationLink (calendar API has extended types) links = response.links.map((link) => ({ source: link.source, target: link.target, - type: link.type, + type: link.type as SimulationLink['type'], strength: link.strength, sharedTags: link.sharedTags, })); diff --git a/apps/calendar/apps/web/src/lib/stores/search.svelte.ts b/apps/calendar/apps/web/src/lib/stores/search.svelte.ts index 64c93eddb..05bfa2ebd 100644 --- a/apps/calendar/apps/web/src/lib/stores/search.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/search.svelte.ts @@ -2,9 +2,9 @@ * Search Store - manages search state for highlighting events in calendar views */ -interface SearchItem { +// Accept any object with at least an id property +interface SearchableItem { id: string; - [key: string]: unknown; } // State @@ -15,7 +15,7 @@ let isSearching = $state(false); /** * Set search query and matching items (events or any items with an id) */ -function setSearch(newQuery: string, matchingItems: SearchItem[]) { +function setSearch(newQuery: string, matchingItems: T[]) { query = newQuery; matchingEventIds = new Set(matchingItems.map((item) => item.id)); isSearching = newQuery.trim().length > 0; diff --git a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte index d270cea1c..f8aab5bec 100644 --- a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte @@ -128,13 +128,19 @@ // View labels const viewLabels: Record = { day: 'Tag', + '3day': '3 Tage', '5day': '5 Tage', week: 'Woche', '10day': '10 Tage', '14day': '14 Tage', + '30day': '30 Tage', + '60day': '60 Tage', + '90day': '90 Tage', + '365day': '365 Tage', month: 'Monat', year: 'Jahr', agenda: 'Agenda', + custom: 'Benutzerdefiniert', }; // Duration options in minutes