kimai-sync — API
AppModule
Заголовок раздела «AppModule»@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [appConfig, databaseConfig, kimaiConfig, notionConfig, redisConfig, syncConfig], }), BullModule.forRoot({ redis: process.env.REDIS_URL || 'redis://localhost:6379', }),
DatabaseModule, KimaiModule, NotionModule, SyncModule, JobsModule, ProjectsModule, AppConfigModule, ], controllers: [HealthController],})export class AppModule { }HealthController
Заголовок раздела «HealthController»@Controller('health')export class HealthController { @Get() health(): { status: string; timestamp: string } { return { status: 'ok', timestamp: new Date().toISOString(), }; }}ConfigController
Заголовок раздела «ConfigController»src/modules/config/config.controller.ts
@Controller('config')export class ConfigController { constructor(private configService: ConfigService) {}
@Get() getConfig(): Partial<Config> { return { kimaiUrl: this.configService.get<string>('kimai.url', ''), kimaiApiKey: this.configService.get<string>('kimai.apiKey', '') ? '***hidden***' : '', notionApiKey: this.configService.get<string>('notion.apiKey', '') ? '***hidden***' : '', syncInterval: this.configService.get<string>('sync.interval', '*/5 * * * *'), syncEnabled: this.configService.get<boolean>('sync.enabled', true), lastUpdatedAt: new Date().toISOString(), }; }
@Post() saveConfig(@Body() config: Config) { if (!config.kimaiUrl) { throw new HttpException('Kimai URL is required', HttpStatus.BAD_REQUEST); }
if (!config.syncInterval) { throw new HttpException('Sync interval is required', HttpStatus.BAD_REQUEST); }
const cronParts = config.syncInterval.trim().split(/\s+/); if (cronParts.length < 5) { throw new HttpException('Invalid cron expression format', HttpStatus.BAD_REQUEST); }
console.log('Config saved:', { kimaiUrl: config.kimaiUrl, syncInterval: config.syncInterval, syncEnabled: config.syncEnabled, lastUpdatedAt: config.lastUpdatedAt, });
return { success: true, message: 'Configuration saved successfully', config: { ...config, kimaiApiKey: '***hidden***', notionApiKey: '***hidden***', }, }; }}AppConfigModule
Заголовок раздела «AppConfigModule»src/modules/config/config.module.ts
@Module({ imports: [ConfigModule], controllers: [ConfigController],})export class AppConfigModule {}DatabaseModule
Заголовок раздела «DatabaseModule»src/modules/database/database.module.ts
@Module({ providers: [PrismaService], exports: [PrismaService],})export class DatabaseModule {}PrismaService
Заголовок раздела «PrismaService»src/modules/database/prisma.service.ts
@Injectable()export class PrismaService extends PrismaClient implements OnModuleInit { private readonly logger = new Logger(PrismaService.name);
async onModuleInit(): Promise<void> { try { await this.$connect(); this.logger.log('Database connected successfully'); } catch (error) { this.logger.error('Failed to connect to database', error); throw error; } }
async onModuleDestroy(): Promise<void> { await this.$disconnect(); }}JobsModule
Заголовок раздела «JobsModule»src/modules/jobs/jobs.module.ts
@Module({ imports: [ BullModule.registerQueue({ name: 'sync-jobs', defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 2000, }, }, }), SyncModule, ], providers: [SyncFullJobHandler, SyncWeeklyJobHandler, ScheduledSyncProvider],})export class JobsModule {}SyncFullJobHandler
Заголовок раздела «SyncFullJobHandler»src/modules/jobs/sync-full.job.ts
@Processor('sync-jobs')export class SyncFullJobHandler { private readonly logger = new Logger(SyncFullJobHandler.name);
constructor(private readonly sync: SyncService) {}
@Process('sync-full') async handleFullSync(job: Job<SyncJobData>): Promise<JobResult> { this.logger.log(`🔄 Starting full sync job (Job ID: ${job.id})`);
try { const result = await this.sync.syncFullHistory();
this.logger.log(`✅ Full sync job completed (Job ID: ${job.id})`); return { success: true, synced: result.synced, failed: result.failed, timestamp: result.timestamp, }; } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.error(`❌ Full sync job failed (Job ID: ${job.id})`, err.message); throw err; // BullMQ will retry } }}SyncWeeklyJobHandler
Заголовок раздела «SyncWeeklyJobHandler»src/modules/jobs/sync-weekly.job.ts
@Processor('sync-jobs')export class SyncWeeklyJobHandler { private readonly logger = new Logger(SyncWeeklyJobHandler.name);
constructor(private readonly sync: SyncService) {}
@Process('sync-weekly') async handleWeeklySync(job: Job<SyncJobData>): Promise<JobResult> { this.logger.log(`📅 Starting weekly sync job (Job ID: ${job.id})`);
try { const result = await this.sync.syncCurrentWeek();
this.logger.log(`✅ Weekly sync job completed (Job ID: ${job.id})`); return { success: true, synced: result.synced, failed: result.failed, timestamp: result.timestamp, }; } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.error(`❌ Weekly sync job failed (Job ID: ${job.id})`, err.message); throw err; // BullMQ will retry } }}KimaiModule
Заголовок раздела «KimaiModule»src/modules/kimai/kimai.module.ts
@Module({ imports: [HttpModule], providers: [KimaiService, KimaiClient], exports: [KimaiService],})export class KimaiModule {}KimaiService
Заголовок раздела «KimaiService»src/modules/kimai/kimai.service.ts
@Injectable()export class KimaiService { private readonly logger = new Logger(KimaiService.name);
constructor(private readonly client: KimaiClient) {}
async getTimeEntries(start: Date, end: Date): Promise<KimaiTimeEntry[]> { this.logger.log(`📥 Fetching Kimai entries from ${start.toISOString()} to ${end.toISOString()}`);
const startStr = start.toISOString().split('.')[0]; const endStr = end.toISOString().split('.')[0];
const entries: KimaiTimeEntry[] = []; let page = 1; let hasMore = true;
while (hasMore) { try { const pageEntries = await this.client.getTimeEntries(startStr, endStr, 100, page);
if (pageEntries.length === 0) { hasMore = false; break; }
entries.push(...pageEntries); page++;
await this.delay(500); } catch (error) { this.logger.error(`❌ Error fetching page ${page}`, error); throw error; } }
this.logger.log(`✅ Fetched ${entries.length} total entries`); return entries; }
async getRecentEntries(days: number = 7): Promise<KimaiTimeEntry[]> { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - days);
return this.getTimeEntries(start, end); }
async getProjects(): Promise<any[]> { this.logger.log('🔍 Fetching all Kimai projects...'); try { const projects = await this.client.getProjects(); this.logger.log(`✅ Fetched ${projects.length} projects`); return projects; } catch (error) { this.logger.error('❌ Failed to fetch projects', error); throw error; } }
async getActivities(): Promise<any[]> { this.logger.log('🔍 Fetching all Kimai activities...'); try { const activities = await this.client.getActivities(); this.logger.log(`✅ Fetched ${activities.length} activities`); return activities; } catch (error) { this.logger.error('❌ Failed to fetch activities', error); throw error; } }
private delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }}NotionModule
Заголовок раздела «NotionModule»src/modules/notion/notion.module.ts
@Module({ imports: [HttpModule, DatabaseModule], providers: [NotionService, NotionClient], exports: [NotionService],})export class NotionModule {}ProjectsModule
Заголовок раздела «ProjectsModule»src/modules/projects/projects.module.ts
@Module({ imports: [DatabaseModule], controllers: [ProjectsController, TemplatesController],})export class ProjectsModule {}TemplatesController
Заголовок раздела «TemplatesController»src/modules/projects/templates.controller.ts
@Controller('projects/:projectId/template')export class TemplatesController { private readonly logger = new Logger(TemplatesController.name);
constructor(private readonly prisma: PrismaService) {}
@Get() async getTemplate(@Param('projectId') projectId: string) { try { const project = await this.prisma.project.findUniqueOrThrow({ where: { id: parseInt(projectId) }, });
return { template: project.notionTemplate, }; } catch (error) { this.logger.error(`❌ Failed to get template for project ${projectId}`, error); throw error; } }
@Put() async updateTemplate(@Param('projectId') projectId: string, @Body() dto: NotionTemplateDTO) { try { const updated = await this.prisma.project.update({ where: { id: parseInt(projectId) }, data: { notionTemplate: { templateId: dto.templateId || null, propertyMap: dto.propertyMap, }, }, });
this.logger.log(`✅ Updated Notion template for project ${projectId}`);
return { template: updated.notionTemplate, }; } catch (error) { this.logger.error(`❌ Failed to update template for project ${projectId}`, error); throw error; } }
@Post('validate') async validateTemplate(@Param('projectId') projectId: string, @Body() dto: NotionTemplateDTO) { try { // TODO: Call NotionClient to validate database exists and has required properties this.logger.log(`✅ Template validated for project ${projectId}`);
return { valid: true, message: 'Template is valid', }; } catch (error) { this.logger.error(`❌ Template validation failed for project ${projectId}`, error); throw error; } }}SyncModule
Заголовок раздела «SyncModule»src/modules/sync/sync.module.ts
@Module({ imports: [ BullModule.registerQueue({ name: 'sync-jobs', }), KimaiModule, DatabaseModule, NotionModule, ], controllers: [SyncController], providers: [SyncService], exports: [SyncService],})export class SyncModule {}SyncJobData
Заголовок раздела «SyncJobData»export interface SyncJobData { startDate?: Date; endDate?: Date; userId?: string;}JobResult
Заголовок раздела «JobResult»export interface JobResult { success: boolean; synced: number; failed: number; timestamp: Date; error?: string;}KimaiTimeEntry
Заголовок раздела «KimaiTimeEntry»export interface KimaiTimeEntry { id: number; project: number; activity: number; description?: string; begin: string; // ISO 8601 end: string; // ISO 8601 duration: number; // seconds tags?: string[];}KimaiError
Заголовок раздела «KimaiError»export class KimaiError extends Error { constructor( message: string, public statusCode?: number, public originalError?: Error, ) { super(message); this.name = 'KimaiError'; }}NotionTemplate
Заголовок раздела «NotionTemplate»export interface NotionTemplate { templateId?: string; // Optional identifier for the template projectId: number; propertyMap?: { title: string; date: string; duration: string; activity: string; tags?: string; project?: string; kimaiId: string; };}NotionPagePayload
Заголовок раздела «NotionPagePayload»export interface NotionPagePayload { parent: { database_id: string; }; properties: Record<string, any>; children?: any[]; template?: any;}NotionProperty
Заголовок раздела «NotionProperty»export interface NotionProperty { id: string; name: string; type: string;}NotionError
Заголовок раздела «NotionError»export class NotionError extends Error { constructor( message: string, public statusCode?: number, public originalError?: Error, ) { super(message); this.name = 'NotionError'; }}SyncResult
Заголовок раздела «SyncResult»export interface SyncResult { synced: number; failed: number; timestamp: Date; duration: number; // milliseconds}SyncRequest
Заголовок раздела «SyncRequest»export interface SyncRequest { startDate: Date; endDate: Date; projectId?: number;}TimeEntrySyncPayload
Заголовок раздела «TimeEntrySyncPayload»export interface TimeEntrySyncPayload { kimaiId: number; projectId: number; activity: string; description?: string; begin: Date; end: Date; duration: number;}NotionSyncPayload
Заголовок раздела «NotionSyncPayload»export interface NotionSyncPayload { timeEntryId: number; pageId: string;}SyncError
Заголовок раздела «SyncError»export class SyncError extends Error { constructor( message: string, public phase: 'fetch' | 'process' | 'persist' | 'notify', public recoverable: boolean = true, ) { super(message); this.name = 'SyncError'; }}