Skip to content
docs.
WORKSPACE

Your documentation

Explore the project catalog
Localzet ServerАсинхронный PHP-серверTriangleФреймворк и компонентыMIX IDИдентификация и интеграцииHack APIПубличные API хакатонаACNПроект и компонентыUserlay / UserNetПроект и компонентыAxiomПроект и компонентыМесседжинг через почтуПроект и компонентыLWT / JWT / JOSEПроект и компонентыФинансовая аналитика KimaiПроект и компоненты✓Мультипарадигмальная БДПроект и компонентыZorin PlatformПроект и компонентыZorin TrustПроект и компонентыFloraScope / VegwatchПроект и компонентыaegis-qДокументация проектаaevumdbДокументация проектаauditДокументация проектаauto-invest-managerАвтотрейдерcaine-companionДокументация проектаcertbot-cloudflareДокументация проектаClusterLocalzet Server / ClustercmdveilДокументация проектаConsolePHP CLICronБиблиотеки Localzet Serverdata-connectorДокументация проектаdevice-controlDevice Controlenn-projectДокументация проектаEventsБиблиотеки Localzet ServerflowsintДокументация проектаforgeДокументация проектаHttpClientБиблиотеки Localzet ServerinterpreterСобственный языкinvariaДокументация проектаkeynotesДокументация проектаknotrouteKnotroutelaravel-personal-analyticsДокументация проектаlocalzet-osДокументация проектаmeridianДокументация проектаMQTTБиблиотеки Localzet Servernano-kernelСобственная ОСNoterДокументация проектаpersonal-analyticsДокументация проектаProxyБиблиотеки Localzet ServerPSRБиблиотеки Localzet ServerPSR-7Библиотеки Localzet Serverrschs-monitorДокументация проектаsemsealДокументация проектаshardsealДокументация проектаSocketIOБиблиотеки Localzet ServerSOCKS5Библиотеки Localzet Serverstandalone-analyticsДокументация проектаstatic-php-cliДокументация проектаStorageБиблиотеки Localzet ServersutureДокументация проектаtempoweaveДокументация проектаttsСинтез речиTunnelБиблиотеки Localzet Serverui-kitUI KitvesperДокументация проектаWebAnalyzerWebAnalyzerzorin-access-brokerДокументация проектаzorin-authority-brokerДокументация проектаzorin-authority-sdk-phpДокументация проектаzorin-authority-sdk-pythonДокументация проектаzorin-authority-sdk-tsДокументация проектаzorin-client-intelДокументация проектаzorin-device-core-aospДокументация проектаzorin-host-agentДокументация проектаzorin-native-labДокументация проектаzorin-nodeДокументация проектаzorin-opsДокументация проектаdonstu-python-sdkДокументация проектаfloortraceFloortraceserver-rsLocalzet Server / Cluster
Catalog
Documentation/Финансовая аналитика Kimai

kimai-sync — API

src/app.module.ts

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

src/health.controller.ts

@Controller('health')
export class HealthController {
@Get()
health(): { status: string; timestamp: string } {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
}

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***',
},
};
}
}

src/modules/config/config.module.ts

@Module({
imports: [ConfigModule],
controllers: [ConfigController],
})
export class AppConfigModule {}

src/modules/database/database.module.ts

@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}

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

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

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

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

src/modules/kimai/kimai.module.ts

@Module({
imports: [HttpModule],
providers: [KimaiService, KimaiClient],
exports: [KimaiService],
})
export class KimaiModule {}

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

src/modules/notion/notion.module.ts

@Module({
imports: [HttpModule, DatabaseModule],
providers: [NotionService, NotionClient],
exports: [NotionService],
})
export class NotionModule {}

src/modules/projects/projects.module.ts

@Module({
imports: [DatabaseModule],
controllers: [ProjectsController, TemplatesController],
})
export class ProjectsModule {}

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

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

src/types/jobs.types.ts

export interface SyncJobData {
startDate?: Date;
endDate?: Date;
userId?: string;
}

src/types/jobs.types.ts

export interface JobResult {
success: boolean;
synced: number;
failed: number;
timestamp: Date;
error?: string;
}

src/types/kimai.types.ts

export interface KimaiTimeEntry {
id: number;
project: number;
activity: number;
description?: string;
begin: string; // ISO 8601
end: string; // ISO 8601
duration: number; // seconds
tags?: string[];
}

src/types/kimai.types.ts

export class KimaiError extends Error {
constructor(
message: string,
public statusCode?: number,
public originalError?: Error,
) {
super(message);
this.name = 'KimaiError';
}
}

src/types/notion.types.ts

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

src/types/notion.types.ts

export interface NotionPagePayload {
parent: {
database_id: string;
};
properties: Record<string, any>;
children?: any[];
template?: any;
}

src/types/notion.types.ts

export interface NotionProperty {
id: string;
name: string;
type: string;
}

src/types/notion.types.ts

export class NotionError extends Error {
constructor(
message: string,
public statusCode?: number,
public originalError?: Error,
) {
super(message);
this.name = 'NotionError';
}
}

src/types/sync.types.ts

export interface SyncResult {
synced: number;
failed: number;
timestamp: Date;
duration: number; // milliseconds
}

src/types/sync.types.ts

export interface SyncRequest {
startDate: Date;
endDate: Date;
projectId?: number;
}

src/types/sync.types.ts

export interface TimeEntrySyncPayload {
kimaiId: number;
projectId: number;
activity: string;
description?: string;
begin: Date;
end: Date;
duration: number;
}

src/types/sync.types.ts

export interface NotionSyncPayload {
timeEntryId: number;
pageId: string;
}

src/types/sync.types.ts

export class SyncError extends Error {
constructor(
message: string,
public phase: 'fetch' | 'process' | 'persist' | 'notify',
public recoverable: boolean = true,
) {
super(message);
this.name = 'SyncError';
}
}