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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-28 12:30:01 +01:00
parent cb130191ab
commit 78ff102631
33 changed files with 2338 additions and 31 deletions

View file

@ -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$': '<rootDir>/../../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)/)'],
};

View file

@ -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",

View file

@ -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<Database> {
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<Database>;
}
/**
* Setup mock database to return specific data
*/
export function setupMockDbQuery<T>(mockDb: jest.Mocked<Database>, 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<T>(mockDb: jest.Mocked<Database>, data: T[]): void {
mockDb.returning.mockResolvedValueOnce(data);
}
/**
* Setup mock database for UPDATE operations
*/
export function setupMockDbUpdate<T>(mockDb: jest.Mocked<Database>, data: T[]): void {
mockDb.returning.mockResolvedValueOnce(data);
}
/**
* Reset all mock calls on the database
*/
export function resetMockDb(mockDb: jest.Mocked<Database>): void {
Object.values(mockDb).forEach((fn) => {
if (typeof fn === 'function' && fn.mockClear) {
fn.mockClear();
}
});
}

View file

@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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<T>(data: T[]): T[] {
return data;
}

View file

@ -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,

View file

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

View file

@ -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<T>(
operation: () => Promise<T>,
operationName: string,
maxRetries = MAX_RETRIES
): Promise<T> {
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<typeof drizzle>): Promise<boolean> {
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<typeof drizzle>): Promise<void> {
await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`);
}
/**
* Wait for migration lock with timeout
*/
async function waitForLock(db: ReturnType<typeof drizzle>): Promise<boolean> {
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<void> {
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);
});

View file

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

View file

@ -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';

View file

@ -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(),

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View file

@ -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<boolean> {
const { to, subject, html, text } = options;
const from = process.env.SMTP_FROM || 'ManaCore Calendar <calendar@mana.how>';
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<boolean> {
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: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #3B82F6; margin: 0;">ManaCore Kalender</h1>
</div>
<div style="background-color: #EFF6FF; border-left: 4px solid #3B82F6; padding: 20px; border-radius: 4px; margin-bottom: 20px;">
<h2 style="margin: 0 0 10px 0; color: #1E40AF;">${eventTitle}</h2>
<p style="margin: 0; color: #1E40AF; font-size: 18px;">${formattedTime}</p>
</div>
<p style="color: #666;">Dein Termin beginnt ${timeLabel}.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese Erinnerung wurde automatisch von ManaCore Kalender gesendet.
</p>
</body>
</html>
`,
});
}
/**
* Send calendar share invitation email
*/
async sendCalendarInvitationEmail(
email: string,
calendarName: string,
inviterName: string,
permission: string,
acceptUrl: string
): Promise<boolean> {
const permissionLabel = this.formatPermission(permission);
return this.sendEmail({
to: email,
subject: `Kalender-Einladung: ${calendarName}`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #3B82F6; margin: 0;">ManaCore Kalender</h1>
</div>
<p>Hallo,</p>
<p><strong>${inviterName}</strong> hat den Kalender <strong>${calendarName}</strong> mit dir geteilt.</p>
<div style="background-color: #F3F4F6; padding: 15px; border-radius: 6px; margin: 20px 0;">
<p style="margin: 0; color: #666;">Berechtigung: <strong>${permissionLabel}</strong></p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="${acceptUrl}" style="background-color: #3B82F6; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Einladung annehmen</a>
</div>
<p style="color: #666; font-size: 14px;">Diese Einladung ist 7 Tage gültig.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore Kalender gesendet.<br>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="${acceptUrl}" style="color: #3B82F6; word-break: break-all;">${acceptUrl}</a>
</p>
</body>
</html>
`,
});
}
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;
}
}
}

View file

@ -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<CalendarService>;
let mockEventTagService: jest.Mocked<EventTagService>;
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<CalendarService>;
mockEventTagService = {
setEventTags: jest.fn(),
getTagsForEvent: jest.fn().mockResolvedValue([]),
} as unknown as jest.Mocked<EventTagService>;
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>(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);
});
});
});

View file

@ -0,0 +1 @@
export * from './register-token.dto';

View file

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

View file

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

View file

@ -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 {}

View file

@ -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<DeviceToken> {
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<void> {
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<void> {
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<DeviceToken[]> {
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<boolean> {
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<number> {
const tokens = await this.getActiveTokensForUser(userId);
return tokens.length;
}
}

View file

@ -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<string, unknown>;
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<boolean> {
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<Map<string, boolean>> {
const results = new Map<string, boolean>();
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<Map<string, ExpoPushReceipt>> {
const results = new Map<string, ExpoPushReceipt>();
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;
}
}

View file

@ -20,7 +20,7 @@ export class ReminderController {
@Param('eventId') eventId: string,
@Body() dto: Omit<CreateReminderDto, 'eventId'>
) {
const reminder = await this.reminderService.create(user.userId, {
const reminder = await this.reminderService.create(user.userId, user.email, {
...dto,
eventId,
});

View file

@ -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],

View file

@ -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<EventService>;
let mockEmailService: jest.Mocked<EmailService>;
let mockNotificationService: jest.Mocked<NotificationService>;
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<EventService>;
mockEmailService = {
sendReminderEmail: jest.fn().mockResolvedValue(true),
} as unknown as jest.Mocked<EmailService>;
mockNotificationService = {
sendToUser: jest.fn().mockResolvedValue(true),
} as unknown as jest.Mocked<NotificationService>;
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>(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);
});
});
});

View file

@ -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<Reminder[]> {
@ -34,7 +40,7 @@ export class ReminderService {
return result[0] || null;
}
async create(userId: string, dto: CreateReminderDto): Promise<Reminder> {
async create(userId: string, userEmail: string, dto: CreateReminderDto): Promise<Reminder> {
// 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<void> {
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'}`;
}
}

View file

@ -23,7 +23,7 @@ export class ShareController {
@Param('calendarId') calendarId: string,
@Body() dto: Omit<CreateShareDto, 'calendarId'>
) {
const share = await this.shareService.create(user.userId, {
const share = await this.shareService.create(user.userId, user.email, {
...dto,
calendarId,
});

View file

@ -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<CalendarService>;
let mockEmailService: jest.Mocked<EmailService>;
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<CalendarService>;
mockEmailService = {
sendCalendarInvitationEmail: jest.fn().mockResolvedValue(true),
} as unknown as jest.Mocked<EmailService>;
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>(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);
});
});
});

View file

@ -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<CalendarShare[]> {
@ -42,9 +46,9 @@ export class ShareService {
);
}
async create(userId: string, dto: CreateShareDto): Promise<CalendarShare> {
async create(userId: string, inviterEmail: string, dto: CreateShareDto): Promise<CalendarShare> {
// 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;
}

View file

@ -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",

View file

@ -21,13 +21,19 @@
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
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

View file

@ -132,7 +132,7 @@
style="height: {(day.count / maxTrendValue) * 100}%"
class:has-events={day.count > 0}
></div>
<span class="trend-label">{day.label.charAt(0)}</span>
<span class="trend-label">{day.label?.charAt(0) ?? ''}</span>
</div>
{/each}
</div>

View file

@ -26,25 +26,37 @@
// View labels (short versions for pill)
const viewLabels: Record<CalendarViewType, string> = {
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<CalendarViewType, string> = {
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

View file

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

View file

@ -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<T extends SearchableItem>(newQuery: string, matchingItems: T[]) {
query = newQuery;
matchingEventIds = new Set(matchingItems.map((item) => item.id));
isSearching = newQuery.trim().length > 0;

View file

@ -128,13 +128,19 @@
// View labels
const viewLabels: Record<CalendarViewType, string> = {
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