@open-mercato/core 0.4.5-develop-995ce486fa → 0.4.5-develop-8a56591995
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generated/entities/progress_job/index.js +57 -0
- package/dist/generated/entities/progress_job/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +4 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/progress/__integration__/TC-PROG-001.spec.js +51 -0
- package/dist/modules/progress/__integration__/TC-PROG-001.spec.js.map +7 -0
- package/dist/modules/progress/acl.js +33 -0
- package/dist/modules/progress/acl.js.map +7 -0
- package/dist/modules/progress/api/active/route.js +57 -0
- package/dist/modules/progress/api/active/route.js.map +7 -0
- package/dist/modules/progress/api/jobs/[id]/route.js +126 -0
- package/dist/modules/progress/api/jobs/[id]/route.js.map +7 -0
- package/dist/modules/progress/api/jobs/route.js +156 -0
- package/dist/modules/progress/api/jobs/route.js.map +7 -0
- package/dist/modules/progress/api/openapi.js +27 -0
- package/dist/modules/progress/api/openapi.js.map +7 -0
- package/dist/modules/progress/data/entities.js +113 -0
- package/dist/modules/progress/data/entities.js.map +7 -0
- package/dist/modules/progress/data/validators.js +48 -0
- package/dist/modules/progress/data/validators.js.map +7 -0
- package/dist/modules/progress/di.js +16 -0
- package/dist/modules/progress/di.js.map +7 -0
- package/dist/modules/progress/events.js +22 -0
- package/dist/modules/progress/events.js.map +7 -0
- package/dist/modules/progress/index.js +13 -0
- package/dist/modules/progress/index.js.map +7 -0
- package/dist/modules/progress/lib/events.js +14 -0
- package/dist/modules/progress/lib/events.js.map +7 -0
- package/dist/modules/progress/lib/progressService.js +21 -0
- package/dist/modules/progress/lib/progressService.js.map +7 -0
- package/dist/modules/progress/lib/progressServiceImpl.js +215 -0
- package/dist/modules/progress/lib/progressServiceImpl.js.map +7 -0
- package/dist/modules/progress/migrations/Migration20260220214819.js +16 -0
- package/dist/modules/progress/migrations/Migration20260220214819.js.map +7 -0
- package/dist/modules/progress/setup.js +12 -0
- package/dist/modules/progress/setup.js.map +7 -0
- package/generated/entities/progress_job/index.ts +27 -0
- package/generated/entities.ids.generated.ts +4 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +2 -2
- package/src/modules/progress/__integration__/TC-PROG-001.spec.ts +67 -0
- package/src/modules/progress/__tests__/progressService.test.ts +377 -0
- package/src/modules/progress/acl.ts +29 -0
- package/src/modules/progress/api/active/route.ts +61 -0
- package/src/modules/progress/api/jobs/[id]/route.ts +136 -0
- package/src/modules/progress/api/jobs/route.ts +192 -0
- package/src/modules/progress/api/openapi.ts +28 -0
- package/src/modules/progress/data/entities.ts +94 -0
- package/src/modules/progress/data/validators.ts +51 -0
- package/src/modules/progress/di.ts +15 -0
- package/src/modules/progress/events.ts +21 -0
- package/src/modules/progress/i18n/de.json +20 -0
- package/src/modules/progress/i18n/en.json +20 -0
- package/src/modules/progress/i18n/es.json +20 -0
- package/src/modules/progress/i18n/pl.json +20 -0
- package/src/modules/progress/index.ts +11 -0
- package/src/modules/progress/lib/events.ts +60 -0
- package/src/modules/progress/lib/progressService.ts +47 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +261 -0
- package/src/modules/progress/migrations/.snapshot-open-mercato.json +316 -0
- package/src/modules/progress/migrations/Migration20260220214819.ts +15 -0
- package/src/modules/progress/setup.ts +10 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { createProgressService } from '../lib/progressServiceImpl'
|
|
2
|
+
import { PROGRESS_EVENTS } from '../lib/events'
|
|
3
|
+
import { calculateEta, calculateProgressPercent } from '../lib/progressService'
|
|
4
|
+
import type { ProgressJob } from '../data/entities'
|
|
5
|
+
|
|
6
|
+
const baseCtx = {
|
|
7
|
+
tenantId: '7f4c85ef-f8f7-4e53-9df1-42e95bd8d48e',
|
|
8
|
+
organizationId: null,
|
|
9
|
+
userId: '2d4a4c33-9c4b-4e39-8e15-0a3cd9a7f432',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const buildEm = () => {
|
|
13
|
+
const em = {
|
|
14
|
+
create: jest.fn(),
|
|
15
|
+
persistAndFlush: jest.fn().mockResolvedValue(undefined),
|
|
16
|
+
flush: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
findOne: jest.fn(),
|
|
18
|
+
findOneOrFail: jest.fn(),
|
|
19
|
+
find: jest.fn(),
|
|
20
|
+
}
|
|
21
|
+
return em
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('progress service', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.clearAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('createJob — creates entity, persists, emits JOB_CREATED', async () => {
|
|
30
|
+
const em = buildEm()
|
|
31
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
32
|
+
|
|
33
|
+
em.create.mockImplementation((_entity, data) => ({ id: 'job-1', ...data }))
|
|
34
|
+
|
|
35
|
+
const service = createProgressService(em as never, eventBus)
|
|
36
|
+
|
|
37
|
+
const input = { jobType: 'import', name: 'Import contacts', totalCount: 100, cancellable: true }
|
|
38
|
+
const job = await service.createJob(input, baseCtx)
|
|
39
|
+
|
|
40
|
+
expect(job.id).toBe('job-1')
|
|
41
|
+
expect(job.status).toBe('pending')
|
|
42
|
+
expect(job.jobType).toBe('import')
|
|
43
|
+
expect(job.cancellable).toBe(true)
|
|
44
|
+
expect(em.persistAndFlush).toHaveBeenCalledWith(job)
|
|
45
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
46
|
+
PROGRESS_EVENTS.JOB_CREATED,
|
|
47
|
+
expect.objectContaining({
|
|
48
|
+
jobId: 'job-1',
|
|
49
|
+
jobType: 'import',
|
|
50
|
+
name: 'Import contacts',
|
|
51
|
+
tenantId: baseCtx.tenantId,
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('startJob — sets running status, timestamps, emits JOB_STARTED', async () => {
|
|
57
|
+
const em = buildEm()
|
|
58
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
59
|
+
|
|
60
|
+
const job = { id: 'job-1', status: 'pending', jobType: 'import' } as ProgressJob
|
|
61
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
62
|
+
|
|
63
|
+
const service = createProgressService(em as never, eventBus)
|
|
64
|
+
const result = await service.startJob('job-1', baseCtx)
|
|
65
|
+
|
|
66
|
+
expect(result.status).toBe('running')
|
|
67
|
+
expect(result.startedAt).toBeInstanceOf(Date)
|
|
68
|
+
expect(result.heartbeatAt).toBeInstanceOf(Date)
|
|
69
|
+
expect(em.flush).toHaveBeenCalled()
|
|
70
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
71
|
+
PROGRESS_EVENTS.JOB_STARTED,
|
|
72
|
+
expect.objectContaining({ jobId: 'job-1', jobType: 'import', tenantId: baseCtx.tenantId })
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('updateProgress — auto-calculates progressPercent and ETA', async () => {
|
|
77
|
+
const em = buildEm()
|
|
78
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
79
|
+
|
|
80
|
+
const job = {
|
|
81
|
+
id: 'job-1',
|
|
82
|
+
jobType: 'import',
|
|
83
|
+
processedCount: 0,
|
|
84
|
+
totalCount: 100,
|
|
85
|
+
progressPercent: 0,
|
|
86
|
+
startedAt: new Date(Date.now() - 10_000),
|
|
87
|
+
meta: null,
|
|
88
|
+
} as unknown as ProgressJob
|
|
89
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
90
|
+
|
|
91
|
+
const service = createProgressService(em as never, eventBus)
|
|
92
|
+
const result = await service.updateProgress('job-1', { processedCount: 50 }, baseCtx)
|
|
93
|
+
|
|
94
|
+
expect(result.processedCount).toBe(50)
|
|
95
|
+
expect(result.progressPercent).toBe(50)
|
|
96
|
+
expect(result.etaSeconds).toBeGreaterThan(0)
|
|
97
|
+
expect(result.heartbeatAt).toBeInstanceOf(Date)
|
|
98
|
+
expect(em.flush).toHaveBeenCalled()
|
|
99
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
100
|
+
PROGRESS_EVENTS.JOB_UPDATED,
|
|
101
|
+
expect.objectContaining({ jobId: 'job-1', processedCount: 50, progressPercent: 50 })
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('updateProgress — uses explicit progressPercent when provided', async () => {
|
|
106
|
+
const em = buildEm()
|
|
107
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
108
|
+
|
|
109
|
+
const job = {
|
|
110
|
+
id: 'job-1',
|
|
111
|
+
jobType: 'import',
|
|
112
|
+
processedCount: 0,
|
|
113
|
+
totalCount: 100,
|
|
114
|
+
progressPercent: 0,
|
|
115
|
+
startedAt: new Date(),
|
|
116
|
+
meta: null,
|
|
117
|
+
} as unknown as ProgressJob
|
|
118
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
119
|
+
|
|
120
|
+
const service = createProgressService(em as never, eventBus)
|
|
121
|
+
const result = await service.updateProgress('job-1', { processedCount: 50, progressPercent: 75 }, baseCtx)
|
|
122
|
+
|
|
123
|
+
expect(result.progressPercent).toBe(75)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('updateProgress — merges meta instead of replacing', async () => {
|
|
127
|
+
const em = buildEm()
|
|
128
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
129
|
+
|
|
130
|
+
const job = {
|
|
131
|
+
id: 'job-1',
|
|
132
|
+
jobType: 'import',
|
|
133
|
+
processedCount: 0,
|
|
134
|
+
totalCount: null,
|
|
135
|
+
progressPercent: 0,
|
|
136
|
+
startedAt: null,
|
|
137
|
+
meta: { existing: 'value' },
|
|
138
|
+
} as unknown as ProgressJob
|
|
139
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
140
|
+
|
|
141
|
+
const service = createProgressService(em as never, eventBus)
|
|
142
|
+
await service.updateProgress('job-1', { meta: { added: 'new' } }, baseCtx)
|
|
143
|
+
|
|
144
|
+
expect(job.meta).toEqual({ existing: 'value', added: 'new' })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('incrementProgress — adds delta, recalculates percent, emits JOB_UPDATED', async () => {
|
|
148
|
+
const em = buildEm()
|
|
149
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
150
|
+
|
|
151
|
+
const job = {
|
|
152
|
+
id: 'job-1',
|
|
153
|
+
jobType: 'import',
|
|
154
|
+
processedCount: 40,
|
|
155
|
+
totalCount: 100,
|
|
156
|
+
progressPercent: 40,
|
|
157
|
+
startedAt: new Date(Date.now() - 10_000),
|
|
158
|
+
} as unknown as ProgressJob
|
|
159
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
160
|
+
|
|
161
|
+
const service = createProgressService(em as never, eventBus)
|
|
162
|
+
const result = await service.incrementProgress('job-1', 10, baseCtx)
|
|
163
|
+
|
|
164
|
+
expect(result.processedCount).toBe(50)
|
|
165
|
+
expect(result.progressPercent).toBe(50)
|
|
166
|
+
expect(result.heartbeatAt).toBeInstanceOf(Date)
|
|
167
|
+
expect(em.flush).toHaveBeenCalled()
|
|
168
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
169
|
+
PROGRESS_EVENTS.JOB_UPDATED,
|
|
170
|
+
expect.objectContaining({ jobId: 'job-1', processedCount: 50, progressPercent: 50 })
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('completeJob — sets completed status, progress 100%, emits JOB_COMPLETED', async () => {
|
|
175
|
+
const em = buildEm()
|
|
176
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
177
|
+
|
|
178
|
+
const job = {
|
|
179
|
+
id: 'job-1',
|
|
180
|
+
jobType: 'import',
|
|
181
|
+
status: 'running',
|
|
182
|
+
progressPercent: 80,
|
|
183
|
+
etaSeconds: 5,
|
|
184
|
+
tenantId: baseCtx.tenantId,
|
|
185
|
+
} as unknown as ProgressJob
|
|
186
|
+
em.findOne.mockResolvedValue(job)
|
|
187
|
+
|
|
188
|
+
const service = createProgressService(em as never, eventBus)
|
|
189
|
+
const result = await service.completeJob('job-1', { resultSummary: { imported: 100 } }, baseCtx)
|
|
190
|
+
|
|
191
|
+
expect(result.status).toBe('completed')
|
|
192
|
+
expect(result.progressPercent).toBe(100)
|
|
193
|
+
expect(result.etaSeconds).toBe(0)
|
|
194
|
+
expect(result.finishedAt).toBeInstanceOf(Date)
|
|
195
|
+
expect(result.resultSummary).toEqual({ imported: 100 })
|
|
196
|
+
expect(em.flush).toHaveBeenCalled()
|
|
197
|
+
expect(em.findOne).toHaveBeenCalledWith(
|
|
198
|
+
expect.anything(),
|
|
199
|
+
expect.objectContaining({ id: 'job-1', tenantId: baseCtx.tenantId })
|
|
200
|
+
)
|
|
201
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
202
|
+
PROGRESS_EVENTS.JOB_COMPLETED,
|
|
203
|
+
expect.objectContaining({ jobId: 'job-1', jobType: 'import', tenantId: baseCtx.tenantId })
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('failJob — sets failed status, records error, emits JOB_FAILED', async () => {
|
|
208
|
+
const em = buildEm()
|
|
209
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
210
|
+
|
|
211
|
+
const job = {
|
|
212
|
+
id: 'job-1',
|
|
213
|
+
jobType: 'import',
|
|
214
|
+
status: 'running',
|
|
215
|
+
tenantId: baseCtx.tenantId,
|
|
216
|
+
} as unknown as ProgressJob
|
|
217
|
+
em.findOne.mockResolvedValue(job)
|
|
218
|
+
|
|
219
|
+
const service = createProgressService(em as never, eventBus)
|
|
220
|
+
const result = await service.failJob('job-1', { errorMessage: 'Network error', errorStack: 'stack...' }, baseCtx)
|
|
221
|
+
|
|
222
|
+
expect(result.status).toBe('failed')
|
|
223
|
+
expect(result.finishedAt).toBeInstanceOf(Date)
|
|
224
|
+
expect(result.errorMessage).toBe('Network error')
|
|
225
|
+
expect(result.errorStack).toBe('stack...')
|
|
226
|
+
expect(em.flush).toHaveBeenCalled()
|
|
227
|
+
expect(em.findOne).toHaveBeenCalledWith(
|
|
228
|
+
expect.anything(),
|
|
229
|
+
expect.objectContaining({ id: 'job-1', tenantId: baseCtx.tenantId })
|
|
230
|
+
)
|
|
231
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
232
|
+
PROGRESS_EVENTS.JOB_FAILED,
|
|
233
|
+
expect.objectContaining({ jobId: 'job-1', errorMessage: 'Network error', tenantId: baseCtx.tenantId })
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('cancelJob (pending) — sets cancelled status immediately', async () => {
|
|
238
|
+
const em = buildEm()
|
|
239
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
240
|
+
|
|
241
|
+
const job = {
|
|
242
|
+
id: 'job-1',
|
|
243
|
+
jobType: 'import',
|
|
244
|
+
status: 'pending',
|
|
245
|
+
cancellable: true,
|
|
246
|
+
} as unknown as ProgressJob
|
|
247
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
248
|
+
|
|
249
|
+
const service = createProgressService(em as never, eventBus)
|
|
250
|
+
const result = await service.cancelJob('job-1', baseCtx)
|
|
251
|
+
|
|
252
|
+
expect(result.status).toBe('cancelled')
|
|
253
|
+
expect(result.finishedAt).toBeInstanceOf(Date)
|
|
254
|
+
expect(result.cancelRequestedAt).toBeInstanceOf(Date)
|
|
255
|
+
expect(result.cancelledByUserId).toBe(baseCtx.userId)
|
|
256
|
+
expect(em.flush).toHaveBeenCalled()
|
|
257
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
258
|
+
PROGRESS_EVENTS.JOB_CANCELLED,
|
|
259
|
+
expect.objectContaining({ jobId: 'job-1', tenantId: baseCtx.tenantId })
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('cancelJob (running) — sets cancelRequestedAt but keeps running status', async () => {
|
|
264
|
+
const em = buildEm()
|
|
265
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
266
|
+
|
|
267
|
+
const job = {
|
|
268
|
+
id: 'job-1',
|
|
269
|
+
jobType: 'import',
|
|
270
|
+
status: 'running',
|
|
271
|
+
cancellable: true,
|
|
272
|
+
} as unknown as ProgressJob
|
|
273
|
+
em.findOneOrFail.mockResolvedValue(job)
|
|
274
|
+
|
|
275
|
+
const service = createProgressService(em as never, eventBus)
|
|
276
|
+
const result = await service.cancelJob('job-1', baseCtx)
|
|
277
|
+
|
|
278
|
+
expect(result.status).toBe('running')
|
|
279
|
+
expect(result.cancelRequestedAt).toBeInstanceOf(Date)
|
|
280
|
+
expect(result.cancelledByUserId).toBe(baseCtx.userId)
|
|
281
|
+
expect(result.finishedAt).toBeUndefined()
|
|
282
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
283
|
+
PROGRESS_EVENTS.JOB_CANCELLED,
|
|
284
|
+
expect.objectContaining({ jobId: 'job-1' })
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('markStaleJobsFailed — marks stale jobs as failed, emits per job', async () => {
|
|
289
|
+
const em = buildEm()
|
|
290
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
291
|
+
|
|
292
|
+
const staleJob1 = { id: 'stale-1', jobType: 'export', status: 'running', tenantId: baseCtx.tenantId } as unknown as ProgressJob
|
|
293
|
+
const staleJob2 = { id: 'stale-2', jobType: 'import', status: 'running', tenantId: baseCtx.tenantId } as unknown as ProgressJob
|
|
294
|
+
em.find.mockResolvedValue([staleJob1, staleJob2])
|
|
295
|
+
|
|
296
|
+
const service = createProgressService(em as never, eventBus)
|
|
297
|
+
const count = await service.markStaleJobsFailed(baseCtx.tenantId, 60)
|
|
298
|
+
|
|
299
|
+
expect(count).toBe(2)
|
|
300
|
+
expect(staleJob1.status).toBe('failed')
|
|
301
|
+
expect(staleJob1.finishedAt).toBeInstanceOf(Date)
|
|
302
|
+
expect(staleJob1.errorMessage).toContain('no heartbeat for 60 seconds')
|
|
303
|
+
expect(staleJob2.status).toBe('failed')
|
|
304
|
+
expect(em.flush).toHaveBeenCalled()
|
|
305
|
+
expect(em.find).toHaveBeenCalledWith(
|
|
306
|
+
expect.anything(),
|
|
307
|
+
expect.objectContaining({ tenantId: baseCtx.tenantId })
|
|
308
|
+
)
|
|
309
|
+
expect(eventBus.emit).toHaveBeenCalledTimes(2)
|
|
310
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
311
|
+
PROGRESS_EVENTS.JOB_FAILED,
|
|
312
|
+
expect.objectContaining({ jobId: 'stale-1', stale: true })
|
|
313
|
+
)
|
|
314
|
+
expect(eventBus.emit).toHaveBeenCalledWith(
|
|
315
|
+
PROGRESS_EVENTS.JOB_FAILED,
|
|
316
|
+
expect.objectContaining({ jobId: 'stale-2', stale: true })
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('getRecentlyCompletedJobs — queries completed/failed jobs with tenant scope', async () => {
|
|
321
|
+
const em = buildEm()
|
|
322
|
+
const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
|
|
323
|
+
|
|
324
|
+
const completedJob = { id: 'done-1', status: 'completed', tenantId: baseCtx.tenantId } as unknown as ProgressJob
|
|
325
|
+
em.find.mockResolvedValue([completedJob])
|
|
326
|
+
|
|
327
|
+
const service = createProgressService(em as never, eventBus)
|
|
328
|
+
const result = await service.getRecentlyCompletedJobs(baseCtx)
|
|
329
|
+
|
|
330
|
+
expect(result).toEqual([completedJob])
|
|
331
|
+
expect(em.find).toHaveBeenCalledWith(
|
|
332
|
+
expect.anything(),
|
|
333
|
+
expect.objectContaining({
|
|
334
|
+
tenantId: baseCtx.tenantId,
|
|
335
|
+
status: { $in: ['completed', 'failed'] },
|
|
336
|
+
parentJobId: null,
|
|
337
|
+
}),
|
|
338
|
+
expect.objectContaining({ orderBy: { finishedAt: 'DESC' }, limit: 10 })
|
|
339
|
+
)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('calculateProgressPercent', () => {
|
|
344
|
+
it('returns correct percentage', () => {
|
|
345
|
+
expect(calculateProgressPercent(50, 100)).toBe(50)
|
|
346
|
+
expect(calculateProgressPercent(1, 3)).toBe(33)
|
|
347
|
+
expect(calculateProgressPercent(2, 3)).toBe(67)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('clamps at 100', () => {
|
|
351
|
+
expect(calculateProgressPercent(150, 100)).toBe(100)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('returns 0 for null or zero totalCount', () => {
|
|
355
|
+
expect(calculateProgressPercent(50, null)).toBe(0)
|
|
356
|
+
expect(calculateProgressPercent(50, 0)).toBe(0)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe('calculateEta', () => {
|
|
361
|
+
it('returns null when processedCount is zero', () => {
|
|
362
|
+
expect(calculateEta(0, 100, new Date())).toBeNull()
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('returns null when totalCount is zero', () => {
|
|
366
|
+
expect(calculateEta(50, 0, new Date())).toBeNull()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('calculates remaining seconds correctly', () => {
|
|
370
|
+
const startedAt = new Date(Date.now() - 10_000)
|
|
371
|
+
const eta = calculateEta(50, 100, startedAt)
|
|
372
|
+
|
|
373
|
+
expect(eta).not.toBeNull()
|
|
374
|
+
expect(eta!).toBeGreaterThan(0)
|
|
375
|
+
expect(eta!).toBeLessThanOrEqual(11)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const features = [
|
|
2
|
+
{
|
|
3
|
+
id: 'progress.view',
|
|
4
|
+
title: 'View progress jobs',
|
|
5
|
+
module: 'progress',
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
id: 'progress.create',
|
|
9
|
+
title: 'Create progress jobs',
|
|
10
|
+
module: 'progress',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'progress.update',
|
|
14
|
+
title: 'Update progress jobs',
|
|
15
|
+
module: 'progress',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'progress.cancel',
|
|
19
|
+
title: 'Cancel progress jobs',
|
|
20
|
+
module: 'progress',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'progress.manage',
|
|
24
|
+
title: 'Manage all progress jobs',
|
|
25
|
+
module: 'progress',
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export default features
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import type { ProgressJob } from '../../data/entities'
|
|
5
|
+
|
|
6
|
+
const routeMetadata = {
|
|
7
|
+
GET: { requireAuth: true, requireFeatures: ['progress.view'] },
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const metadata = routeMetadata
|
|
11
|
+
|
|
12
|
+
export async function GET(req: Request) {
|
|
13
|
+
const auth = await getAuthFromRequest(req)
|
|
14
|
+
if (!auth || !auth.tenantId) {
|
|
15
|
+
return NextResponse.json({ active: [], recentlyCompleted: [] }, { status: 401 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const container = await createRequestContainer()
|
|
19
|
+
const progressService = container.resolve('progressService') as import('../../lib/progressService').ProgressService
|
|
20
|
+
|
|
21
|
+
const ctx = { tenantId: auth.tenantId, organizationId: auth.orgId }
|
|
22
|
+
|
|
23
|
+
const [jobs, recentlyCompleted] = await Promise.all([
|
|
24
|
+
progressService.getActiveJobs(ctx),
|
|
25
|
+
progressService.getRecentlyCompletedJobs(ctx),
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
return NextResponse.json({
|
|
29
|
+
active: jobs.map(formatJob),
|
|
30
|
+
recentlyCompleted: recentlyCompleted.map(formatJob),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatJob(job: ProgressJob) {
|
|
35
|
+
return {
|
|
36
|
+
id: job.id,
|
|
37
|
+
jobType: job.jobType,
|
|
38
|
+
name: job.name,
|
|
39
|
+
description: job.description,
|
|
40
|
+
status: job.status,
|
|
41
|
+
progressPercent: job.progressPercent,
|
|
42
|
+
processedCount: job.processedCount,
|
|
43
|
+
totalCount: job.totalCount,
|
|
44
|
+
etaSeconds: job.etaSeconds,
|
|
45
|
+
cancellable: job.cancellable,
|
|
46
|
+
startedAt: job.startedAt?.toISOString() ?? null,
|
|
47
|
+
finishedAt: job.finishedAt?.toISOString() ?? null,
|
|
48
|
+
errorMessage: job.errorMessage,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const openApi = {
|
|
53
|
+
GET: {
|
|
54
|
+
summary: 'Get active progress jobs',
|
|
55
|
+
description: 'Returns currently running jobs and recently completed jobs for the progress top bar.',
|
|
56
|
+
tags: ['Progress'],
|
|
57
|
+
responses: {
|
|
58
|
+
200: { description: 'Active and recently completed jobs' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
|
+
import { ProgressJob } from '../../../data/entities'
|
|
6
|
+
import { updateProgressSchema } from '../../../data/validators'
|
|
7
|
+
import type { ProgressService } from '../../../lib/progressService'
|
|
8
|
+
|
|
9
|
+
const routeMetadata = {
|
|
10
|
+
GET: { requireAuth: true, requireFeatures: ['progress.view'] },
|
|
11
|
+
PUT: { requireAuth: true, requireFeatures: ['progress.update'] },
|
|
12
|
+
DELETE: { requireAuth: true, requireFeatures: ['progress.cancel'] },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const metadata = routeMetadata
|
|
16
|
+
|
|
17
|
+
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
|
18
|
+
const auth = await getAuthFromRequest(req)
|
|
19
|
+
if (!auth || !auth.tenantId) {
|
|
20
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const container = await createRequestContainer()
|
|
24
|
+
const em = container.resolve('em') as EntityManager
|
|
25
|
+
|
|
26
|
+
const job = await em.findOne(ProgressJob, {
|
|
27
|
+
id: params.id,
|
|
28
|
+
tenantId: auth.tenantId,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (!job) {
|
|
32
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return NextResponse.json({
|
|
36
|
+
id: job.id,
|
|
37
|
+
jobType: job.jobType,
|
|
38
|
+
name: job.name,
|
|
39
|
+
description: job.description,
|
|
40
|
+
status: job.status,
|
|
41
|
+
progressPercent: job.progressPercent,
|
|
42
|
+
processedCount: job.processedCount,
|
|
43
|
+
totalCount: job.totalCount,
|
|
44
|
+
etaSeconds: job.etaSeconds,
|
|
45
|
+
cancellable: job.cancellable,
|
|
46
|
+
meta: job.meta,
|
|
47
|
+
resultSummary: job.resultSummary,
|
|
48
|
+
errorMessage: job.errorMessage,
|
|
49
|
+
startedByUserId: job.startedByUserId,
|
|
50
|
+
startedAt: job.startedAt?.toISOString() ?? null,
|
|
51
|
+
heartbeatAt: job.heartbeatAt?.toISOString() ?? null,
|
|
52
|
+
finishedAt: job.finishedAt?.toISOString() ?? null,
|
|
53
|
+
parentJobId: job.parentJobId,
|
|
54
|
+
partitionIndex: job.partitionIndex,
|
|
55
|
+
partitionCount: job.partitionCount,
|
|
56
|
+
createdAt: job.createdAt.toISOString(),
|
|
57
|
+
updatedAt: job.updatedAt.toISOString(),
|
|
58
|
+
tenantId: job.tenantId,
|
|
59
|
+
organizationId: job.organizationId,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function PUT(req: Request, { params }: { params: { id: string } }) {
|
|
64
|
+
const auth = await getAuthFromRequest(req)
|
|
65
|
+
if (!auth || !auth.tenantId) {
|
|
66
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const body = await req.json()
|
|
70
|
+
const parsed = updateProgressSchema.safeParse(body)
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
return NextResponse.json({ error: 'Invalid input', details: parsed.error.flatten() }, { status: 400 })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const container = await createRequestContainer()
|
|
76
|
+
const progressService = container.resolve('progressService') as ProgressService
|
|
77
|
+
|
|
78
|
+
const job = await progressService.updateProgress(params.id, parsed.data, {
|
|
79
|
+
tenantId: auth.tenantId,
|
|
80
|
+
organizationId: auth.orgId,
|
|
81
|
+
userId: auth.sub,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return NextResponse.json({ ok: true, progressPercent: job.progressPercent })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
|
88
|
+
const auth = await getAuthFromRequest(req)
|
|
89
|
+
if (!auth || !auth.tenantId) {
|
|
90
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const container = await createRequestContainer()
|
|
94
|
+
const progressService = container.resolve('progressService') as ProgressService
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await progressService.cancelJob(params.id, {
|
|
98
|
+
tenantId: auth.tenantId,
|
|
99
|
+
organizationId: auth.orgId,
|
|
100
|
+
userId: auth.sub,
|
|
101
|
+
})
|
|
102
|
+
return NextResponse.json({ ok: true })
|
|
103
|
+
} catch {
|
|
104
|
+
return NextResponse.json({ error: 'Cannot cancel this job' }, { status: 400 })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const openApi = {
|
|
109
|
+
GET: {
|
|
110
|
+
summary: 'Get progress job details',
|
|
111
|
+
description: 'Returns full details of a specific progress job by ID.',
|
|
112
|
+
tags: ['Progress'],
|
|
113
|
+
responses: {
|
|
114
|
+
200: { description: 'Progress job details' },
|
|
115
|
+
404: { description: 'Job not found' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
PUT: {
|
|
119
|
+
summary: 'Update progress job',
|
|
120
|
+
description: 'Updates progress metrics and heartbeat for a running job.',
|
|
121
|
+
tags: ['Progress'],
|
|
122
|
+
responses: {
|
|
123
|
+
200: { description: 'Progress updated' },
|
|
124
|
+
400: { description: 'Invalid input' },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
DELETE: {
|
|
128
|
+
summary: 'Cancel progress job',
|
|
129
|
+
description: 'Requests cancellation of a running or pending job.',
|
|
130
|
+
tags: ['Progress'],
|
|
131
|
+
responses: {
|
|
132
|
+
200: { description: 'Cancellation requested' },
|
|
133
|
+
400: { description: 'Cannot cancel this job' },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}
|