From 2b03ea47b1c5396b3d2ff722e207a42bb655f3d0 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sun, 4 Oct 2026 08:44:37 +0200 Subject: [PATCH] [verified] fix(ordrestyring): mirror hours through GraphQL --- backend/src/graphql/queries/hours.js | 24 ++ .../__tests__/ordrestyringSyncService.test.js | 331 +++++++++++++++++- .../__tests__/planningService.test.js | 27 ++ .../src/services/ordrestyringSyncService.js | 269 ++++++++++---- backend/src/services/planningService.js | 3 +- 5 files changed, 583 insertions(+), 71 deletions(-) create mode 100644 backend/src/services/__tests__/planningService.test.js diff --git a/backend/src/graphql/queries/hours.js b/backend/src/graphql/queries/hours.js index 83849d1..ac743a9 100644 --- a/backend/src/graphql/queries/hours.js +++ b/backend/src/graphql/queries/hours.js @@ -72,6 +72,29 @@ const GET_HOURS_BY_DATE_RANGE_QUERY = ` } `; +// Minimal PII-safe shape used by the durable local hours mirror. +const GET_HOURS_SYNC_QUERY = ` + query GetHoursForSync($pagination: Pagination) { + hours(pagination: $pagination) { + items { + id + startTime + stopTime + description + costPrice + createdAt + updatedAt + user { id } + case { caseNumber } + type { id } + } + count + hasMorePages + nextCursor + } + } +`; + const GET_HOUR_TYPES_QUERY = ` query GetHourTypes($pagination: Pagination) { hourTypes(pagination: $pagination) { @@ -105,6 +128,7 @@ const GET_BUSINESS_HOURS_QUERY = ` module.exports = { GET_HOURS_QUERY, GET_HOURS_BY_DATE_RANGE_QUERY, + GET_HOURS_SYNC_QUERY, GET_HOUR_TYPES_QUERY, GET_BUSINESS_HOURS_QUERY }; diff --git a/backend/src/services/__tests__/ordrestyringSyncService.test.js b/backend/src/services/__tests__/ordrestyringSyncService.test.js index e280624..e570d5a 100644 --- a/backend/src/services/__tests__/ordrestyringSyncService.test.js +++ b/backend/src/services/__tests__/ordrestyringSyncService.test.js @@ -3,6 +3,7 @@ process.env.DB_PASSWORD = 'test-password'; jest.mock('mysql2/promise', () => ({ createConnection: jest.fn() })); jest.mock('axios', () => ({ get: jest.fn() })); +jest.mock('../graphqlClient', () => ({ request: jest.fn() })); jest.mock('../ordrestyringService', () => ({ calculateDashboardMetrics: jest.fn() })); jest.mock('../../utils/logger', () => ({ info: jest.fn(), @@ -12,6 +13,7 @@ jest.mock('../../utils/logger', () => ({ const mysql = require('mysql2/promise'); const axios = require('axios'); +const graphqlClient = require('../graphqlClient'); const ordrestyringService = require('../ordrestyringService'); const OrdrestyringSyncService = require('../ordrestyringSyncService'); @@ -126,7 +128,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { } }); - test.each(['syncCases', 'syncUsers', 'syncHours', 'syncDebtors'])( + test.each(['syncCases', 'syncUsers', 'syncDebtors'])( '%s contains connection-close failures after successful core work', async method => { axios.get.mockResolvedValueOnce({ data: [] }); @@ -142,6 +144,277 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { } ); + test('syncHours mirrors every GraphQL page into the local hours schema', async () => { + graphqlClient.request + .mockResolvedValueOnce({ hours: { items: [{ + id: 101, + startTime: 1758268800, + stopTime: 1758272400, + description: 'Arbejde', + costPrice: 5000.5, + createdAt: 1758268700, + updatedAt: 1758272500, + user: { id: 7 }, + case: { caseNumber: '5001' }, + type: { id: 2 } + }], count: 1, hasMorePages: true, nextCursor: 'page-2' } }) + .mockResolvedValueOnce({ hours: { items: [{ + id: 102, + startTime: 1758276000, + stopTime: 1758279600, + description: 'Kontrol', + costPrice: 6000, + createdAt: 1758275900, + updatedAt: 1758279700, + user: { id: 8 }, + case: { caseNumber: '5002' }, + type: { id: 3 } + }], count: 1, hasMorePages: false, nextCursor: null } }); + + const connection = { + beginTransaction: jest.fn().mockResolvedValue(), + execute: jest.fn(async sql => { + if (sql.includes('SELECT id, emp_id')) return [[]]; + return [{ affectedRows: 1 }]; + }), + commit: jest.fn().mockResolvedValue(), + rollback: jest.fn().mockResolvedValue(), + end: jest.fn().mockRejectedValue(new Error('private close failure')) + }; + mysql.createConnection.mockResolvedValueOnce(connection); + const service = new OrdrestyringSyncService(); + + await expect(service.syncHours()).resolves.toEqual({ hasChanges: true, changes: 2 }); + expect(graphqlClient.request).toHaveBeenNthCalledWith(1, expect.any(String), { + pagination: { cursor: null, limit: 200 } + }); + expect(graphqlClient.request).toHaveBeenNthCalledWith(2, expect.any(String), { + pagination: { cursor: 'page-2', limit: 200 } + }); + const syncQuery = graphqlClient.request.mock.calls[0][0]; + expect(syncQuery).not.toMatch(/email|fullName|customer\s*\{/); + const insertCalls = connection.execute.mock.calls.filter(([sql]) => sql.includes('INSERT INTO hours')); + expect(insertCalls).toHaveLength(2); + expect(insertCalls[0][1]).toEqual([ + 101, 7, 1758268800, 1758272400, 'Arbejde', 2, 1758272500, 1758268700, 5001, '5001' + ]); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(connection.end).toHaveBeenCalledTimes(1); + }); + + test('syncHours reconciles deleted rows without rewriting unchanged rows', async () => { + const remoteRows = [101, 102, 103, 104].map((id, index) => ({ + id, + startTime: 1758268800 + (index * 7200), + stopTime: 1758272400 + (index * 7200), + description: `Arbejde ${index}`, + costPrice: 5000 + index, + createdAt: 1758268700 + (index * 7200), + updatedAt: 1758272500 + (index * 7200), + user: { id: 7 + index }, + case: { caseNumber: `500${index + 1}` }, + type: { id: 2 } + })); + graphqlClient.request.mockResolvedValueOnce({ + hours: { items: remoteRows, count: 4, hasMorePages: false, nextCursor: null } + }); + const service = new OrdrestyringSyncService(); + const localRows = remoteRows.map(row => service.normalizeGraphqlHour(row)); + localRows.push({ + id: 999, emp_id: 7, start_time: 1, stop_time: 2, remark: null, + hour_type: 2, updated_at: 1, created_at: 1, costprice: 1, new_case_number: 'old' + }); + const connection = { + beginTransaction: jest.fn().mockResolvedValue(), + execute: jest.fn(async (sql, params) => { + if (sql.includes('SELECT id, emp_id')) return [localRows]; + if (sql.includes('DELETE FROM hours')) return [{ affectedRows: 1 }]; + throw new Error(`Unexpected write: ${sql} ${params}`); + }), + commit: jest.fn().mockResolvedValue(), + rollback: jest.fn().mockResolvedValue(), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValueOnce(connection); + + await expect(service.syncHours()).resolves.toEqual({ hasChanges: true, changes: 1 }); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('INSERT INTO hours'))).toBe(false); + expect(connection.execute).toHaveBeenCalledWith('DELETE FROM hours WHERE id = ?', [999]); + expect(connection.commit).toHaveBeenCalledTimes(1); + }); + + test('syncHours preserves locally created planning allocations', async () => { + const remoteRows = [101, 102, 103, 104].map((id, index) => ({ + id, startTime: 1758268800 + index, stopTime: 1758272400 + index, + description: `Arbejde ${index}`, costPrice: 5000, createdAt: 1758268700, + updatedAt: 1758272500, user: { id: 7 }, case: { caseNumber: '5001' }, type: { id: 2 } + })); + graphqlClient.request.mockResolvedValueOnce({ + hours: { items: remoteRows, count: 4, hasMorePages: false, nextCursor: null } + }); + const service = new OrdrestyringSyncService(); + const localRows = remoteRows.map(row => ({ ...service.normalizeGraphqlHour(row), exported: null })); + localRows.push({ + id: 999, emp_id: 7, start_time: 1, stop_time: 2, remark: 'Planlagt', + hour_type: 1, updated_at: 1, created_at: 1, costprice: null, + new_case_number: '5001', exported: -1 + }); + const connection = { + beginTransaction: jest.fn().mockResolvedValue(), + execute: jest.fn(async sql => { + if (sql.includes('SELECT id, emp_id')) return [localRows]; + throw new Error(`Unexpected write: ${sql}`); + }), + commit: jest.fn().mockResolvedValue(), rollback: jest.fn().mockResolvedValue(), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValueOnce(connection); + + await expect(service.syncHours()).resolves.toEqual({ hasChanges: false, changes: 0 }); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM hours'))).toBe(false); + expect(connection.commit).toHaveBeenCalledTimes(1); + }); + + test('syncHours fails closed if an upstream id collides with a local planning allocation', async () => { + const remote = { + id: 101, startTime: 1758268800, stopTime: 1758272400, description: 'Arbejde', + costPrice: 5000, createdAt: 1758268700, updatedAt: 1758272500, + user: { id: 7 }, case: { caseNumber: '5001' }, type: { id: 2 } + }; + graphqlClient.request.mockResolvedValueOnce({ + hours: { items: [remote], count: 1, hasMorePages: false, nextCursor: null } + }); + const service = new OrdrestyringSyncService(); + const connection = { + beginTransaction: jest.fn().mockResolvedValue(), + execute: jest.fn().mockResolvedValueOnce([[{ + ...service.normalizeGraphqlHour(remote), exported: -1 + }]]), + commit: jest.fn().mockResolvedValue(), rollback: jest.fn().mockResolvedValue(), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValueOnce(connection); + + await expect(service.syncHours()).rejects.toThrow('collides with a local planning allocation'); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + }); + + test('syncHours refuses an unexpectedly empty source before deleting a populated mirror', async () => { + graphqlClient.request.mockResolvedValueOnce({ + hours: { items: [], count: 0, hasMorePages: false, nextCursor: null } + }); + const connection = { + beginTransaction: jest.fn().mockResolvedValue(), + execute: jest.fn().mockResolvedValueOnce([[{ id: 101 }]]), + commit: jest.fn().mockResolvedValue(), + rollback: jest.fn().mockResolvedValue(), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValueOnce(connection); + const service = new OrdrestyringSyncService(); + + await expect(service.syncHours()).rejects.toThrow('unexpectedly empty'); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM hours'))).toBe(false); + }); + + test.each([ + ['id', { id: '101' }], + ['startTime', { startTime: true }], + ['stopTime', { stopTime: 'bad' }], + ['empty stopTime', { stopTime: '' }], + ['user.id', { user: { id: 0 } }], + ['costPrice', { costPrice: -1 }], + ['description', { description: 17 }], + ['case.caseNumber', { case: { caseNumber: 'x'.repeat(256) } }] + ])('syncHours rejects malformed non-null %s before database writes', (_field, override) => { + const service = new OrdrestyringSyncService(); + const valid = { + id: 101, + startTime: 1758268800, + stopTime: 1758272400, + description: 'Arbejde', + costPrice: 5000, + createdAt: 1758268700, + updatedAt: 1758272500, + user: { id: 7 }, + case: { caseNumber: '5001' }, + type: { id: 2 } + }; + expect(() => service.normalizeGraphqlHour({ ...valid, ...override })).toThrow('Invalid GraphQL hour'); + }); + + test.each([ + ['stopTime', hour => { delete hour.stopTime; }], + ['description', hour => { delete hour.description; }], + ['costPrice', hour => { delete hour.costPrice; }], + ['user.id', hour => { hour.user = {}; }], + ['case relation', hour => { delete hour.case; }] + ])('syncHours rejects a missing %s field before database writes', (_field, removeField) => { + const service = new OrdrestyringSyncService(); + const hour = { + id: 101, startTime: 1758268800, stopTime: null, description: null, + costPrice: null, createdAt: null, updatedAt: null, + user: null, case: null, type: null + }; + removeField(hour); + expect(() => service.normalizeGraphqlHour(hour)).toThrow('Missing GraphQL hour'); + }); + + test('syncHours accepts explicit nulls for nullable GraphQL fields', () => { + const service = new OrdrestyringSyncService(); + expect(service.normalizeGraphqlHour({ + id: 101, startTime: 1758268800, stopTime: null, description: null, + costPrice: null, createdAt: null, updatedAt: null, + user: null, case: null, type: null + })).toEqual({ + id: 101, emp_id: null, start_time: 1758268800, stop_time: null, + remark: null, hour_type: null, updated_at: null, created_at: null, + costprice: null, new_case_number: null + }); + }); + + test('syncHours fails closed on an invalid source-ratio configuration', async () => { + const remote = { + id: 101, startTime: 1758268800, stopTime: 1758272400, + description: 'Arbejde', costPrice: 5000, createdAt: 1758268700, + updatedAt: 1758272500, user: { id: 7 }, case: { caseNumber: '5001' }, type: { id: 2 } + }; + graphqlClient.request.mockResolvedValueOnce({ + hours: { items: [remote], count: 1, hasMorePages: false, nextCursor: null } + }); + const service = new OrdrestyringSyncService(); + const connection = { + beginTransaction: jest.fn().mockResolvedValue(), + execute: jest.fn().mockResolvedValueOnce([[service.normalizeGraphqlHour(remote)]]), + commit: jest.fn().mockResolvedValue(), + rollback: jest.fn().mockResolvedValue(), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValueOnce(connection); + const previousRatio = process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO; + process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO = 'not-a-number'; + try { + await expect(service.syncHours()).rejects.toThrow('Invalid hours mirror safety threshold'); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + } finally { + if (previousRatio === undefined) delete process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO; + else process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO = previousRatio; + } + }); + + test('syncHours fails before local writes when GraphQL pagination fails', async () => { + graphqlClient.request.mockRejectedValueOnce(new Error('private upstream failure')); + const service = new OrdrestyringSyncService(); + + await expect(service.syncHours()).rejects.toThrow('private upstream failure'); + expect(mysql.createConnection).not.toHaveBeenCalled(); + }); + test('offer snapshot sync contains connection-close failures after successful core work', async () => { const connection = { end: jest.fn().mockRejectedValue(new Error('private close failure')) @@ -223,11 +496,54 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { expect(appConnection.end).toHaveBeenCalledTimes(1); }); + test('waits for the hours mirror before rebuilding dependent case features', async () => { + const service = new OrdrestyringSyncService(); + let releaseHours; + service.syncCases = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.syncUsers = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.syncHours = jest.fn().mockImplementation(() => new Promise(resolve => { releaseHours = resolve; })); + service.syncDebtors = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.syncOfferSnapshots = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.syncCaseFeatures = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.syncDashboardMetrics = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.logSyncActivity = jest.fn().mockResolvedValue(); + + const run = service.performSync(); + await new Promise(resolve => setImmediate(resolve)); + expect(service.syncCaseFeatures).not.toHaveBeenCalled(); + releaseHours({ hasChanges: true, changes: 1 }); + await run; + + expect(service.syncCaseFeatures).toHaveBeenCalledTimes(1); + expect(service.logSyncActivity).toHaveBeenCalledWith( + 'success', 'Data updated', expect.any(Number), 1 + ); + }); + + test('does not rebuild case features when the hours mirror fails', async () => { + const service = new OrdrestyringSyncService(); + for (const name of ['syncCases', 'syncUsers', 'syncDebtors', 'syncOfferSnapshots']) { + service[name] = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + } + service.syncHours = jest.fn().mockRejectedValue(new Error('private upstream failure')); + service.syncCaseFeatures = jest.fn().mockResolvedValue({ hasChanges: true, changes: 99 }); + service.syncDashboardMetrics = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 }); + service.logSyncActivity = jest.fn().mockResolvedValue(); + + await service.performSync(); + + expect(service.syncCaseFeatures).not.toHaveBeenCalled(); + expect(service.logSyncActivity).toHaveBeenCalledWith( + 'error', 'Synchronization incomplete', expect.any(Number), 0 + ); + }); + test('records the total changed rows from all successful sync stages', async () => { const service = new OrdrestyringSyncService(); const stages = [ ['syncCases', 1], ['syncUsers', 2], + ['syncHours', 100], ['syncDebtors', 5], ['syncOfferSnapshots', 6], ['syncCaseFeatures', 8], @@ -236,7 +552,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { stages.forEach(([name, changes]) => { service[name] = jest.fn().mockResolvedValue({ hasChanges: changes > 0, changes }); }); - service.syncHours = jest.fn().mockResolvedValue({ hasChanges: true, changes: 100 }); + service.syncMaterials = jest.fn().mockResolvedValue({ hasChanges: true, changes: 100 }); service.syncCaseMaterialSnapshots = jest.fn().mockResolvedValue({ hasChanges: true, changes: 100 }); service.logSyncActivity = jest.fn().mockResolvedValue(); @@ -247,9 +563,8 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { 'success', 'Data updated', expect.any(Number), - 22 + 122 ); - expect(service.syncHours).not.toHaveBeenCalled(); expect(service.syncMaterials).not.toHaveBeenCalled(); expect(service.syncCaseMaterialSnapshots).not.toHaveBeenCalled(); }); @@ -257,7 +572,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { test('records a failed stage as an incomplete run instead of a false success', async () => { const service = new OrdrestyringSyncService(); const stages = [ - 'syncCases', 'syncUsers', 'syncDebtors', + 'syncCases', 'syncUsers', 'syncHours', 'syncDebtors', 'syncOfferSnapshots', 'syncCaseFeatures', 'syncDashboardMetrics' ]; stages.forEach(name => { @@ -272,7 +587,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { 'error', 'Synchronization incomplete', expect.any(Number), - 4 + 5 ); }); @@ -282,7 +597,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { ])('records a %s stage as incomplete instead of no_changes', async (_label, stageResult) => { const service = new OrdrestyringSyncService(); const stages = [ - 'syncCases', 'syncUsers', 'syncDebtors', + 'syncCases', 'syncUsers', 'syncHours', 'syncDebtors', 'syncOfferSnapshots', 'syncCaseFeatures', 'syncDashboardMetrics' ]; stages.forEach(name => { @@ -308,7 +623,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => { ])('keeps the authoritative run healthy when optional dashboard metrics %s', async (_label, dashboardResult, rejects) => { const service = new OrdrestyringSyncService(); const coreStages = [ - 'syncCases', 'syncUsers', 'syncDebtors', + 'syncCases', 'syncUsers', 'syncHours', 'syncDebtors', 'syncOfferSnapshots', 'syncCaseFeatures' ]; coreStages.forEach(name => { diff --git a/backend/src/services/__tests__/planningService.test.js b/backend/src/services/__tests__/planningService.test.js new file mode 100644 index 0000000..f4feb36 --- /dev/null +++ b/backend/src/services/__tests__/planningService.test.js @@ -0,0 +1,27 @@ +const PlanningService = require('../planningService'); + +describe('PlanningService local allocation provenance', () => { + test('marks locally created hours so the GraphQL mirror preserves them', async () => { + const db = { + query: jest.fn() + .mockResolvedValueOnce([{ id: 7 }]) + .mockResolvedValueOnce([{ case_number: '5001' }]) + .mockResolvedValueOnce({ insertId: 9001 }) + }; + const service = new PlanningService(db); + + await expect(service.createAllocation({ + employee_id: 7, + case_number: '5001', + start_time: '2026-10-04T08:00:00Z', + end_time: '2026-10-04T09:00:00Z', + remark: 'Planlagt arbejde', + hour_type: 1 + })).resolves.toMatchObject({ success: true, allocation_id: 9001 }); + + const [insertSql, params] = db.query.mock.calls[2]; + expect(insertSql).toContain('exported'); + expect(insertSql).toContain('VALUES (?, ?, ?, ?, ?, ?, -1, ?, ?)'); + expect(params).toHaveLength(8); + }); +}); diff --git a/backend/src/services/ordrestyringSyncService.js b/backend/src/services/ordrestyringSyncService.js index 17edb7e..270fd22 100644 --- a/backend/src/services/ordrestyringSyncService.js +++ b/backend/src/services/ordrestyringSyncService.js @@ -1,6 +1,8 @@ const logger = require('../utils/logger'); const axios = require('axios'); const mysql = require('mysql2/promise'); +const graphqlClient = require('./graphqlClient'); +const { GET_HOURS_SYNC_QUERY } = require('../graphql/queries/hours'); /** * OrdrestyringSyncService - Håndterer automatisk synkronisering af data fra Ordrestyring API @@ -126,20 +128,29 @@ class OrdrestyringSyncService { let recordsProcessed = 0; let syncIncomplete = false; - // The legacy REST /hours endpoint is documented as unavailable and is not an active writer. - // Historical hours remain read-only until a dedicated GraphQL mirror is implemented. - const syncResults = await Promise.allSettled([ + // Refresh authoritative mirrors first. Case features consume both cases and hours, + // so they must never rebuild concurrently from an old or partially replaced mirror. + const coreResults = await Promise.allSettled([ this.syncCases(), this.syncUsers(), + this.syncHours(), this.syncDebtors(), - this.syncOfferSnapshots(), - this.syncCaseFeatures(), - this.syncDashboardMetrics() // Cache dashboard metrics hourly + this.syncOfferSnapshots() ]); + const stageIsHealthy = result => result.status === 'fulfilled' + && !result.value?.degraded && !result.value?.skipped; + const caseFeaturesResult = stageIsHealthy(coreResults[0]) && stageIsHealthy(coreResults[2]) + ? (await Promise.allSettled([this.syncCaseFeatures()]))[0] + : { + status: 'fulfilled', + value: { hasChanges: false, changes: 0, skipped: true, reason: 'dependency_unavailable' } + }; + const dashboardResult = (await Promise.allSettled([this.syncDashboardMetrics()]))[0]; + const syncResults = [...coreResults, caseFeaturesResult, dashboardResult]; // Process results syncResults.forEach((result, index) => { - const dataType = ['cases', 'users', 'debtors', 'offer_snapshots', 'case_features', 'dashboard_metrics'][index]; + const dataType = ['cases', 'users', 'hours', 'debtors', 'offer_snapshots', 'case_features', 'dashboard_metrics'][index]; const isAuthoritativeStage = dataType !== 'dashboard_metrics'; if (result.status === 'fulfilled') { const stageChanges = Number(result.value?.changes); @@ -301,73 +312,207 @@ class OrdrestyringSyncService { } /** - * Sync hours (timeregistreringer) from API + * Fetch the complete Ordrestyring hours dataset through bounded cursor pagination. + */ + async fetchAllGraphqlHours() { + const pageSize = Math.min(200, Math.max(1, + parseInt(process.env.ORDRESTYRING_HOURS_SYNC_PAGE_SIZE || '200', 10) || 200)); + const maxRows = Math.min(50000, Math.max(1, + parseInt(process.env.ORDRESTYRING_HOURS_SYNC_MAX_ROWS || '50000', 10) || 50000)); + const rows = []; + const seenCursors = new Set(); + let cursor = null; + let pages = 0; + + do { + pages += 1; + if (pages > 500) throw new Error('GraphQL hours page ceiling exceeded'); + const result = await graphqlClient.request(GET_HOURS_SYNC_QUERY, { + pagination: { cursor, limit: pageSize } + }); + const page = result?.hours; + if (!page || !Array.isArray(page.items)) throw new Error('Invalid GraphQL hours response'); + if (!Number.isInteger(page.count) || page.count !== page.items.length) { + throw new Error('Invalid GraphQL hours page count'); + } + if (typeof page.hasMorePages !== 'boolean') throw new Error('Invalid GraphQL hours pagination state'); + if (rows.length + page.items.length > maxRows) throw new Error('GraphQL hours row ceiling exceeded'); + rows.push(...page.items); + + const nextCursor = page.nextCursor || null; + if (page.hasMorePages && (page.items.length === 0 || !nextCursor)) { + throw new Error('Invalid GraphQL hours next cursor'); + } + if (nextCursor && seenCursors.has(nextCursor)) throw new Error('GraphQL hours cursor repeated'); + if (nextCursor) seenCursors.add(nextCursor); + cursor = page.hasMorePages ? nextCursor : null; + } while (cursor); + + return rows; + } + + normalizeGraphqlHour(hour) { + if (!hour || typeof hour !== 'object' || Array.isArray(hour)) { + throw new Error('Invalid GraphQL hour item'); + } + const own = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const sourceField = field => { + if (!own(hour, field)) throw new Error(`Missing GraphQL hour ${field}`); + return hour[field]; + }; + const relationField = (relation, field) => { + const value = sourceField(relation); + if (value === null) return null; + if (!value || typeof value !== 'object' || Array.isArray(value) || !own(value, field)) { + throw new Error(`Missing GraphQL hour ${relation}.${field}`); + } + return value[field]; + }; + const requiredInteger = (value, field) => { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid GraphQL hour ${field}`); + } + return value; + }; + const optionalInteger = (value, field, { positive = false } = {}) => { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || (positive && value <= 0)) { + throw new Error(`Invalid GraphQL hour ${field}`); + } + return value; + }; + const optionalString = (value, field, maxLength = Infinity) => { + if (value === null) return null; + if (typeof value !== 'string' || value.length > maxLength) { + throw new Error(`Invalid GraphQL hour ${field}`); + } + return value; + }; + const optionalMoneyMinor = (value, field) => { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 + || value > Number.MAX_SAFE_INTEGER) { + throw new Error(`Invalid GraphQL hour ${field}`); + } + return Math.round(value); + }; + + const startTime = requiredInteger(sourceField('startTime'), 'startTime'); + const stopTime = optionalInteger(sourceField('stopTime'), 'stopTime', { positive: true }); + if (stopTime !== null && stopTime < startTime) throw new Error('Invalid GraphQL hour stopTime'); + + return { + id: requiredInteger(sourceField('id'), 'id'), + emp_id: optionalInteger(relationField('user', 'id'), 'user.id', { positive: true }), + start_time: startTime, + stop_time: stopTime, + remark: optionalString(sourceField('description'), 'description'), + hour_type: optionalInteger(relationField('type', 'id'), 'type.id', { positive: true }), + updated_at: optionalInteger(sourceField('updatedAt'), 'updatedAt', { positive: true }), + created_at: optionalInteger(sourceField('createdAt'), 'createdAt', { positive: true }), + costprice: optionalMoneyMinor(sourceField('costPrice'), 'costPrice'), + new_case_number: optionalString(relationField('case', 'caseNumber'), 'case.caseNumber', 255) + }; + } + + hourRowsEqual(local, incoming) { + const nullableNumber = value => value === null || value === undefined ? null : Number(value); + const nullableString = value => value === null || value === undefined ? null : String(value); + return Number(local.id) === incoming.id + && nullableNumber(local.emp_id) === incoming.emp_id + && nullableNumber(local.start_time) === incoming.start_time + && nullableNumber(local.stop_time) === incoming.stop_time + && nullableString(local.remark) === incoming.remark + && nullableNumber(local.hour_type) === incoming.hour_type + && nullableNumber(local.updated_at) === incoming.updated_at + && nullableNumber(local.created_at) === incoming.created_at + && nullableNumber(local.costprice) === incoming.costprice + && nullableString(local.new_case_number) === incoming.new_case_number; + } + + /** + * Sync the durable local hours mirror from the working Ordrestyring GraphQL API. */ async syncHours() { - let connection; - try { - // Get hours from last 7 days to catch recent updates - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const response = await axios.get(`${this.apiBaseUrl}/hours`, { - auth: { - username: this.apiToken, - password: 'x' - }, - headers: { - 'Accept': 'application/json' - }, - params: { - from_date: sevenDaysAgo.toISOString().split('T')[0] - }, - timeout: 30000 - }); + const rawHours = await this.fetchAllGraphqlHours(); + const incomingById = new Map(); + for (const rawHour of rawHours) { + const hour = this.normalizeGraphqlHour(rawHour); + if (incomingById.has(hour.id)) throw new Error('Duplicate GraphQL hour id'); + incomingById.set(hour.id, hour); + } - const apiHours = response.data; - if (!Array.isArray(apiHours)) { - throw new Error('Invalid API response for hours'); + let connection; + let transactionStarted = false; + try { + connection = await mysql.createConnection(this.dbConfig); + await connection.beginTransaction(); + transactionStarted = true; + + const [localHours] = await connection.execute(` + SELECT id, emp_id, start_time, stop_time, remark, hour_type, + updated_at, created_at, costprice, new_case_number, exported + FROM hours + FOR UPDATE + `); + if (incomingById.size === 0 && localHours.length > 0) { + throw new Error('GraphQL hours response unexpectedly empty'); + } + const minimumSourceRatio = Number(process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO || 0.8); + if (!Number.isFinite(minimumSourceRatio) || minimumSourceRatio < 0.8 || minimumSourceRatio > 1) { + throw new Error('Invalid hours mirror safety threshold'); + } + if (localHours.length > 0 && incomingById.size / localHours.length < minimumSourceRatio) { + throw new Error('GraphQL hours response below mirror safety threshold'); } - connection = await mysql.createConnection(this.dbConfig); - - // Get recent hours from local database - const [localHours] = await connection.execute( - 'SELECT id, case_id, user_id, date, hours FROM hours WHERE date >= DATE_SUB(NOW(), INTERVAL 7 DAY)' - ); - - const localHoursMap = new Map(localHours.map(h => [h.id, h])); + const localById = new Map(localHours.map(hour => [Number(hour.id), hour])); let changes = 0; + const upsertSql = ` + INSERT INTO hours ( + id, emp_id, start_time, stop_time, remark, hour_type, + updated_at, created_at, costprice, new_case_number + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + emp_id = VALUES(emp_id), + start_time = VALUES(start_time), + stop_time = VALUES(stop_time), + remark = VALUES(remark), + hour_type = VALUES(hour_type), + updated_at = VALUES(updated_at), + created_at = VALUES(created_at), + costprice = VALUES(costprice), + new_case_number = VALUES(new_case_number) + `; - for (const apiHour of apiHours) { - const localHour = localHoursMap.get(apiHour.id); - - if (!localHour) { - await this.insertHour(connection, apiHour); - changes++; - } else if (this.hourNeedsUpdate(localHour, apiHour)) { - await this.updateHour(connection, apiHour); - changes++; + for (const hour of incomingById.values()) { + const local = localById.get(hour.id); + if (Number(local?.exported) === -1) { + throw new Error('GraphQL hour id collides with a local planning allocation'); + } + if (!local || !this.hourRowsEqual(local, hour)) { + await connection.execute(upsertSql, [ + hour.id, hour.emp_id, hour.start_time, hour.stop_time, hour.remark, + hour.hour_type, hour.updated_at, hour.created_at, hour.costprice, hour.new_case_number + ]); + changes += 1; } } - return { hasChanges: changes > 0, changes }; + for (const local of localHours) { + const id = Number(local.id); + if (Number(local.exported) !== -1 && !incomingById.has(id)) { + await connection.execute('DELETE FROM hours WHERE id = ?', [id]); + changes += 1; + } + } + await connection.commit(); + transactionStarted = false; + return { hasChanges: changes > 0, changes }; } catch (error) { - // Handle API errors gracefully - don't crash sync - if (error.response?.status === 500) { - logger.info('hours sync completed: no changes', { - note: 'Ordrestyring API /hours endpoint temporarily unavailable (500) - skipped' - }); - return { hasChanges: false, changes: 0, skipped: true }; - } - if (error.response?.status === 404) { - logger.info('hours sync completed: no changes', { - note: 'Ordrestyring API /hours endpoint not found (404) - skipped' - }); - return { hasChanges: false, changes: 0, skipped: true }; - } - logger.error('Error syncing hours:', error.message); + if (connection && transactionStarted) await connection.rollback().catch(() => {}); + logger.error('GraphQL hours sync failed'); throw error; } finally { await this.closeConnectionSafely(connection, 'Hours sync'); diff --git a/backend/src/services/planningService.js b/backend/src/services/planningService.js index cbd19ad..3f9da43 100644 --- a/backend/src/services/planningService.js +++ b/backend/src/services/planningService.js @@ -333,9 +333,10 @@ class PlanningService { remark, hour_type, new_case_number, + exported, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, -1, ?, ?) `; const now = Math.floor(Date.now() / 1000);