@open-mercato/core 0.4.5-develop-995ce486fa → 0.4.5-develop-17cfa89764

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.
Files changed (65) hide show
  1. package/AGENTS.md +2 -1
  2. package/dist/generated/entities/progress_job/index.js +57 -0
  3. package/dist/generated/entities/progress_job/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/progress/__integration__/TC-PROG-001.spec.js +51 -0
  9. package/dist/modules/progress/__integration__/TC-PROG-001.spec.js.map +7 -0
  10. package/dist/modules/progress/acl.js +33 -0
  11. package/dist/modules/progress/acl.js.map +7 -0
  12. package/dist/modules/progress/api/active/route.js +57 -0
  13. package/dist/modules/progress/api/active/route.js.map +7 -0
  14. package/dist/modules/progress/api/jobs/[id]/route.js +126 -0
  15. package/dist/modules/progress/api/jobs/[id]/route.js.map +7 -0
  16. package/dist/modules/progress/api/jobs/route.js +156 -0
  17. package/dist/modules/progress/api/jobs/route.js.map +7 -0
  18. package/dist/modules/progress/api/openapi.js +27 -0
  19. package/dist/modules/progress/api/openapi.js.map +7 -0
  20. package/dist/modules/progress/data/entities.js +113 -0
  21. package/dist/modules/progress/data/entities.js.map +7 -0
  22. package/dist/modules/progress/data/validators.js +48 -0
  23. package/dist/modules/progress/data/validators.js.map +7 -0
  24. package/dist/modules/progress/di.js +16 -0
  25. package/dist/modules/progress/di.js.map +7 -0
  26. package/dist/modules/progress/events.js +22 -0
  27. package/dist/modules/progress/events.js.map +7 -0
  28. package/dist/modules/progress/index.js +13 -0
  29. package/dist/modules/progress/index.js.map +7 -0
  30. package/dist/modules/progress/lib/events.js +14 -0
  31. package/dist/modules/progress/lib/events.js.map +7 -0
  32. package/dist/modules/progress/lib/progressService.js +21 -0
  33. package/dist/modules/progress/lib/progressService.js.map +7 -0
  34. package/dist/modules/progress/lib/progressServiceImpl.js +215 -0
  35. package/dist/modules/progress/lib/progressServiceImpl.js.map +7 -0
  36. package/dist/modules/progress/migrations/Migration20260220214819.js +16 -0
  37. package/dist/modules/progress/migrations/Migration20260220214819.js.map +7 -0
  38. package/dist/modules/progress/setup.js +12 -0
  39. package/dist/modules/progress/setup.js.map +7 -0
  40. package/generated/entities/progress_job/index.ts +27 -0
  41. package/generated/entities.ids.generated.ts +4 -0
  42. package/generated/entity-fields-registry.ts +2 -0
  43. package/package.json +2 -2
  44. package/src/modules/progress/__integration__/TC-PROG-001.spec.ts +67 -0
  45. package/src/modules/progress/__tests__/progressService.test.ts +377 -0
  46. package/src/modules/progress/acl.ts +29 -0
  47. package/src/modules/progress/api/active/route.ts +61 -0
  48. package/src/modules/progress/api/jobs/[id]/route.ts +136 -0
  49. package/src/modules/progress/api/jobs/route.ts +192 -0
  50. package/src/modules/progress/api/openapi.ts +28 -0
  51. package/src/modules/progress/data/entities.ts +94 -0
  52. package/src/modules/progress/data/validators.ts +51 -0
  53. package/src/modules/progress/di.ts +15 -0
  54. package/src/modules/progress/events.ts +21 -0
  55. package/src/modules/progress/i18n/de.json +20 -0
  56. package/src/modules/progress/i18n/en.json +20 -0
  57. package/src/modules/progress/i18n/es.json +20 -0
  58. package/src/modules/progress/i18n/pl.json +20 -0
  59. package/src/modules/progress/index.ts +11 -0
  60. package/src/modules/progress/lib/events.ts +60 -0
  61. package/src/modules/progress/lib/progressService.ts +47 -0
  62. package/src/modules/progress/lib/progressServiceImpl.ts +261 -0
  63. package/src/modules/progress/migrations/.snapshot-open-mercato.json +316 -0
  64. package/src/modules/progress/migrations/Migration20260220214819.ts +15 -0
  65. 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
+ }