// Copyright (c) 2026 PowerOn AG // All rights reserved. /** * Redmine API * * Frontend client for the Redmine feature backend. * URL pattern: /api/redmine/{instanceId}/... */ import { ApiRequestOptions } from '../hooks/useApi'; // ============================================================================ // Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py // ============================================================================ export interface RedmineConfigDto { id?: string; featureInstanceId: string; mandateId?: string | null; baseUrl: string; projectId: string; hasApiKey: boolean; rootTrackerName: string; defaultPeriodValue?: Record | null; schemaCacheTtlSeconds: number; schemaCachedAt?: number | null; isActive: boolean; lastConnectedAt?: number | null; lastSyncAt?: number | null; lastFullSyncAt?: number | null; lastSyncTicketCount?: number | null; lastSyncErrorMessage?: string | null; } export interface RedmineConfigUpdateRequest { baseUrl?: string; projectId?: string; apiKey?: string; rootTrackerName?: string; defaultPeriodValue?: Record | null; schemaCacheTtlSeconds?: number; isActive?: boolean; } export interface RedmineFieldChoice { id: number; name: string; isClosed?: boolean | null; } export interface RedmineCustomFieldSchema { id: number; name: string; fieldFormat: string; isRequired: boolean; possibleValues: string[]; multiple: boolean; defaultValue?: string | null; } export interface RedmineFieldSchema { projectId: string; projectName: string; trackers: RedmineFieldChoice[]; statuses: RedmineFieldChoice[]; priorities: RedmineFieldChoice[]; users: RedmineFieldChoice[]; categories: RedmineFieldChoice[]; customFields: RedmineCustomFieldSchema[]; rootTrackerName: string; rootTrackerId: number | null; } export interface RedmineRelation { id: number; issueId: number; issueToId: number; relationType: string; delay?: number | null; } export interface RedmineCustomFieldValue { id: number; name: string; value: any; } export interface RedmineTicket { id: number; subject: string; description: string; trackerId?: number | null; trackerName?: string | null; statusId?: number | null; statusName?: string | null; isClosed: boolean; priorityId?: number | null; priorityName?: string | null; assignedToId?: number | null; assignedToName?: string | null; authorId?: number | null; authorName?: string | null; parentId?: number | null; fixedVersionId?: number | null; fixedVersionName?: string | null; categoryId?: number | null; categoryName?: string | null; createdOn?: string | null; updatedOn?: string | null; customFields: RedmineCustomFieldValue[]; relations: RedmineRelation[]; } export interface RedmineSyncResult { instanceId: string; full: boolean; ticketsUpserted: number; relationsUpserted: number; durationMs: number; lastSyncAt: number; error?: string | null; } export interface RedmineSyncStatus { instanceId: string; lastSyncAt?: number | null; lastFullSyncAt?: number | null; lastSyncDurationMs?: number | null; lastSyncTicketCount?: number | null; lastSyncErrorAt?: number | null; lastSyncErrorMessage?: string | null; mirroredTicketCount: number; mirroredRelationCount: number; } export interface RedmineConnectionTestResult { ok: boolean; reason?: string; message?: string; status?: number; user?: { id: number; name: string }; project?: { id: number; name: string }; } export interface RedmineStats { instanceId: string; dateFrom?: string | null; dateTo?: string | null; bucket: string; trackerIds: number[]; categoryIds: number[]; statusFilter: string; kpis: { total: number; open: number; closed: number; closedInPeriod: number; createdInPeriod: number; orphans: number; }; statusByTracker: Array<{ trackerId?: number | null; trackerName: string; countsByStatus: Record; total: number; }>; throughput: Array<{ bucketKey: string; label: string; created: number; closed: number; cumTotal: number; cumOpen: number; }>; topAssignees: Array<{ assignedToId?: number | null; name: string; open: number; }>; relationDistribution: Array<{ relationType: string; count: number }>; backlogAging: Array<{ bucketKey: string; label: string; minDays: number; maxDays?: number | null; count: number; }>; } export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`; // ============================================================================ // Config // ============================================================================ export async function getRedmineConfigApi( request: ApiRequestFunction, instanceId: string, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' }); } export async function updateRedmineConfigApi( request: ApiRequestFunction, instanceId: string, body: RedmineConfigUpdateRequest, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body }); } export async function deleteRedmineConfigApi( request: ApiRequestFunction, instanceId: string, ): Promise<{ deleted: boolean }> { return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' }); } export async function testRedmineConnectionApi( request: ApiRequestFunction, instanceId: string, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' }); } // ============================================================================ // Schema // ============================================================================ export async function getRedmineSchemaApi( request: ApiRequestFunction, instanceId: string, forceRefresh = false, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/schema`, method: 'get', params: forceRefresh ? { forceRefresh: true } : undefined, }); } // ============================================================================ // Sync // ============================================================================ export async function runRedmineSyncApi( request: ApiRequestFunction, instanceId: string, force = false, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/sync`, method: 'post', params: force ? { force: true } : undefined, }); } export async function getRedmineSyncStatusApi( request: ApiRequestFunction, instanceId: string, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' }); } // ============================================================================ // Tickets // ============================================================================ export interface ListTicketsParams { trackerIds?: number[]; status?: 'open' | 'closed' | '*'; dateFrom?: string; dateTo?: string; assignedToId?: number; } export async function listRedmineTicketsApi( request: ApiRequestFunction, instanceId: string, params: ListTicketsParams = {}, ): Promise { const queryParams: Record = {}; if (params.status) queryParams.status = params.status; if (params.dateFrom) queryParams.dateFrom = params.dateFrom; if (params.dateTo) queryParams.dateTo = params.dateTo; if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId; if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds; return await request({ url: `${_baseUrl(instanceId)}/tickets`, method: 'get', params: queryParams, }); } export async function getRedmineTicketApi( request: ApiRequestFunction, instanceId: string, issueId: number, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/tickets/${issueId}`, method: 'get', }); } export interface RedmineTicketUpdateBody { subject?: string; description?: string; trackerId?: number; statusId?: number; priorityId?: number; assignedToId?: number; parentIssueId?: number; fixedVersionId?: number; notes?: string; customFields?: Record; } export async function updateRedmineTicketApi( request: ApiRequestFunction, instanceId: string, issueId: number, body: RedmineTicketUpdateBody, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/tickets/${issueId}`, method: 'put', data: body, }); } export interface RedmineTicketCreateBody { subject: string; trackerId: number; description?: string; statusId?: number; priorityId?: number; assignedToId?: number; parentIssueId?: number; fixedVersionId?: number; customFields?: Record; } export async function createRedmineTicketApi( request: ApiRequestFunction, instanceId: string, body: RedmineTicketCreateBody, ): Promise { return await request({ url: `${_baseUrl(instanceId)}/tickets`, method: 'post', data: body, }); } export async function deleteRedmineTicketApi( request: ApiRequestFunction, instanceId: string, issueId: number, fallbackStatusId?: number, ): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> { return await request({ url: `${_baseUrl(instanceId)}/tickets/${issueId}`, method: 'delete', params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined, }); } // ============================================================================ // Stats // ============================================================================ export interface RedmineStatsParams { dateFrom?: string; dateTo?: string; bucket?: 'day' | 'week' | 'month'; trackerIds?: number[]; categoryIds?: number[]; statusFilter?: '*' | 'open' | 'closed'; } export async function getRedmineStatsApi( request: ApiRequestFunction, instanceId: string, params: RedmineStatsParams = {}, ): Promise { const queryParams: Record = {}; if (params.dateFrom) queryParams.dateFrom = params.dateFrom; if (params.dateTo) queryParams.dateTo = params.dateTo; if (params.bucket) queryParams.bucket = params.bucket; if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds; if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds; if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter; return await request({ url: `${_baseUrl(instanceId)}/stats`, method: 'get', params: queryParams, }); }