mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
cb130191ab
commit
78ff102631
33 changed files with 2338 additions and 31 deletions
25
apps/calendar/apps/backend/jest.config.js
Normal file
25
apps/calendar/apps/backend/jest.config.js
Normal 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)/)'],
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
67
apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts
Normal file
67
apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
140
apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts
Normal file
140
apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
218
apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts
Normal file
218
apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
222
apps/calendar/apps/backend/src/db/migrate.ts
Normal file
222
apps/calendar/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
9
apps/calendar/apps/backend/src/email/email.module.ts
Normal file
9
apps/calendar/apps/backend/src/email/email.module.ts
Normal 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 {}
|
||||
219
apps/calendar/apps/backend/src/email/email.service.ts
Normal file
219
apps/calendar/apps/backend/src/email/email.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
275
apps/calendar/apps/backend/src/event/event.service.spec.ts
Normal file
275
apps/calendar/apps/backend/src/event/event.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
apps/calendar/apps/backend/src/notification/dto/index.ts
Normal file
1
apps/calendar/apps/backend/src/notification/dto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './register-token.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
149
apps/calendar/apps/backend/src/notification/push.service.ts
Normal file
149
apps/calendar/apps/backend/src/notification/push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
300
apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts
Normal file
300
apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
326
apps/calendar/apps/backend/src/share/share.service.spec.ts
Normal file
326
apps/calendar/apps/backend/src/share/share.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue