[verified] fix(ordrestyring): mirror hours through GraphQL

This commit is contained in:
alexpolo1
2026-10-04 09:03:34 +02:00
parent 47a3e8a9b6
commit 2b03ea47b1
5 changed files with 583 additions and 71 deletions
+24
View File
@@ -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
};
@@ -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 => {
@@ -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);
});
});
+207 -62
View File
@@ -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');
+2 -1
View File
@@ -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);