@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.
Files changed (64) hide show
  1. package/dist/generated/entities/progress_job/index.js +57 -0
  2. package/dist/generated/entities/progress_job/index.js.map +7 -0
  3. package/dist/generated/entities.ids.generated.js +4 -0
  4. package/dist/generated/entities.ids.generated.js.map +2 -2
  5. package/dist/generated/entity-fields-registry.js +2 -0
  6. package/dist/generated/entity-fields-registry.js.map +2 -2
  7. package/dist/modules/progress/__integration__/TC-PROG-001.spec.js +51 -0
  8. package/dist/modules/progress/__integration__/TC-PROG-001.spec.js.map +7 -0
  9. package/dist/modules/progress/acl.js +33 -0
  10. package/dist/modules/progress/acl.js.map +7 -0
  11. package/dist/modules/progress/api/active/route.js +57 -0
  12. package/dist/modules/progress/api/active/route.js.map +7 -0
  13. package/dist/modules/progress/api/jobs/[id]/route.js +126 -0
  14. package/dist/modules/progress/api/jobs/[id]/route.js.map +7 -0
  15. package/dist/modules/progress/api/jobs/route.js +156 -0
  16. package/dist/modules/progress/api/jobs/route.js.map +7 -0
  17. package/dist/modules/progress/api/openapi.js +27 -0
  18. package/dist/modules/progress/api/openapi.js.map +7 -0
  19. package/dist/modules/progress/data/entities.js +113 -0
  20. package/dist/modules/progress/data/entities.js.map +7 -0
  21. package/dist/modules/progress/data/validators.js +48 -0
  22. package/dist/modules/progress/data/validators.js.map +7 -0
  23. package/dist/modules/progress/di.js +16 -0
  24. package/dist/modules/progress/di.js.map +7 -0
  25. package/dist/modules/progress/events.js +22 -0
  26. package/dist/modules/progress/events.js.map +7 -0
  27. package/dist/modules/progress/index.js +13 -0
  28. package/dist/modules/progress/index.js.map +7 -0
  29. package/dist/modules/progress/lib/events.js +14 -0
  30. package/dist/modules/progress/lib/events.js.map +7 -0
  31. package/dist/modules/progress/lib/progressService.js +21 -0
  32. package/dist/modules/progress/lib/progressService.js.map +7 -0
  33. package/dist/modules/progress/lib/progressServiceImpl.js +215 -0
  34. package/dist/modules/progress/lib/progressServiceImpl.js.map +7 -0
  35. package/dist/modules/progress/migrations/Migration20260220214819.js +16 -0
  36. package/dist/modules/progress/migrations/Migration20260220214819.js.map +7 -0
  37. package/dist/modules/progress/setup.js +12 -0
  38. package/dist/modules/progress/setup.js.map +7 -0
  39. package/generated/entities/progress_job/index.ts +27 -0
  40. package/generated/entities.ids.generated.ts +4 -0
  41. package/generated/entity-fields-registry.ts +2 -0
  42. package/package.json +2 -2
  43. package/src/modules/progress/__integration__/TC-PROG-001.spec.ts +67 -0
  44. package/src/modules/progress/__tests__/progressService.test.ts +377 -0
  45. package/src/modules/progress/acl.ts +29 -0
  46. package/src/modules/progress/api/active/route.ts +61 -0
  47. package/src/modules/progress/api/jobs/[id]/route.ts +136 -0
  48. package/src/modules/progress/api/jobs/route.ts +192 -0
  49. package/src/modules/progress/api/openapi.ts +28 -0
  50. package/src/modules/progress/data/entities.ts +94 -0
  51. package/src/modules/progress/data/validators.ts +51 -0
  52. package/src/modules/progress/di.ts +15 -0
  53. package/src/modules/progress/events.ts +21 -0
  54. package/src/modules/progress/i18n/de.json +20 -0
  55. package/src/modules/progress/i18n/en.json +20 -0
  56. package/src/modules/progress/i18n/es.json +20 -0
  57. package/src/modules/progress/i18n/pl.json +20 -0
  58. package/src/modules/progress/index.ts +11 -0
  59. package/src/modules/progress/lib/events.ts +60 -0
  60. package/src/modules/progress/lib/progressService.ts +47 -0
  61. package/src/modules/progress/lib/progressServiceImpl.ts +261 -0
  62. package/src/modules/progress/migrations/.snapshot-open-mercato.json +316 -0
  63. package/src/modules/progress/migrations/Migration20260220214819.ts +15 -0
  64. package/src/modules/progress/setup.ts +10 -0
@@ -0,0 +1,192 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import type { EntityManager } from '@mikro-orm/postgresql'
6
+ import type { FilterQuery } from '@mikro-orm/core'
7
+ import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
8
+ import { ProgressJob } from '../../data/entities'
9
+ import { createProgressJobSchema, listProgressJobsSchema } from '../../data/validators'
10
+ import {
11
+ createProgressCrudOpenApi,
12
+ createPagedListResponseSchema,
13
+ } from '../openapi'
14
+
15
+ const routeMetadata = {
16
+ GET: { requireAuth: true, requireFeatures: ['progress.view'] },
17
+ POST: { requireAuth: true, requireFeatures: ['progress.create'] },
18
+ }
19
+
20
+ export const metadata = routeMetadata
21
+
22
+ const listQuerySchema = listProgressJobsSchema
23
+
24
+ type JobRow = {
25
+ id: string
26
+ jobType: string
27
+ name: string
28
+ description: string | null
29
+ status: string
30
+ progressPercent: number
31
+ processedCount: number
32
+ totalCount: number | null
33
+ etaSeconds: number | null
34
+ cancellable: boolean
35
+ startedAt: string | null
36
+ finishedAt: string | null
37
+ errorMessage: string | null
38
+ createdAt: string | null
39
+ tenantId: string
40
+ organizationId: string | null
41
+ }
42
+
43
+ const toRow = (job: ProgressJob): JobRow => ({
44
+ id: String(job.id),
45
+ jobType: String(job.jobType),
46
+ name: String(job.name),
47
+ description: job.description ?? null,
48
+ status: job.status,
49
+ progressPercent: job.progressPercent,
50
+ processedCount: job.processedCount,
51
+ totalCount: job.totalCount ?? null,
52
+ etaSeconds: job.etaSeconds ?? null,
53
+ cancellable: !!job.cancellable,
54
+ startedAt: job.startedAt ? job.startedAt.toISOString() : null,
55
+ finishedAt: job.finishedAt ? job.finishedAt.toISOString() : null,
56
+ errorMessage: job.errorMessage ?? null,
57
+ createdAt: job.createdAt ? job.createdAt.toISOString() : null,
58
+ tenantId: String(job.tenantId),
59
+ organizationId: job.organizationId ? String(job.organizationId) : null,
60
+ })
61
+
62
+ export async function GET(req: Request) {
63
+ const auth = await getAuthFromRequest(req)
64
+ if (!auth || !auth.tenantId) {
65
+ return NextResponse.json({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 1 }, { status: 401 })
66
+ }
67
+
68
+ const url = new URL(req.url)
69
+ const parsed = listQuerySchema.safeParse({
70
+ status: url.searchParams.get('status') ?? undefined,
71
+ jobType: url.searchParams.get('jobType') ?? undefined,
72
+ parentJobId: url.searchParams.get('parentJobId') ?? undefined,
73
+ includeCompleted: url.searchParams.get('includeCompleted') ?? undefined,
74
+ completedSince: url.searchParams.get('completedSince') ?? undefined,
75
+ page: url.searchParams.get('page') ?? undefined,
76
+ pageSize: url.searchParams.get('pageSize') ?? undefined,
77
+ search: url.searchParams.get('search') ?? undefined,
78
+ sortField: url.searchParams.get('sortField') ?? undefined,
79
+ sortDir: url.searchParams.get('sortDir') ?? undefined,
80
+ })
81
+ if (!parsed.success) {
82
+ return NextResponse.json({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 1 }, { status: 400 })
83
+ }
84
+
85
+ const container = await createRequestContainer()
86
+ const em = container.resolve('em') as EntityManager
87
+
88
+ const { status, jobType, parentJobId, includeCompleted, completedSince, page, pageSize, search, sortField, sortDir } = parsed.data
89
+ const filter: FilterQuery<ProgressJob> = {
90
+ tenantId: auth.tenantId,
91
+ }
92
+
93
+ if (auth.orgId) {
94
+ filter.organizationId = auth.orgId
95
+ }
96
+
97
+ if (status) {
98
+ const statusValues = status.split(',')
99
+ filter.status = statusValues.length > 1 ? { $in: statusValues as never } : status as never
100
+ } else if (includeCompleted !== 'true') {
101
+ filter.status = { $in: ['pending', 'running'] }
102
+ }
103
+
104
+ if (jobType) filter.jobType = jobType
105
+ if (parentJobId) filter.parentJobId = parentJobId
106
+ if (completedSince) filter.finishedAt = { $gte: new Date(completedSince) }
107
+
108
+ if (search) {
109
+ const escaped = escapeLikePattern(search)
110
+ filter.$or = [
111
+ { name: { $ilike: `%${escaped}%` } },
112
+ { jobType: { $ilike: `%${escaped}%` } },
113
+ ]
114
+ }
115
+
116
+ const fieldMap: Record<string, string> = {
117
+ createdAt: 'createdAt',
118
+ startedAt: 'startedAt',
119
+ finishedAt: 'finishedAt',
120
+ }
121
+ const orderBy: Record<string, 'ASC' | 'DESC'> = {}
122
+ if (sortField) {
123
+ const mapped = fieldMap[sortField] || 'createdAt'
124
+ orderBy[mapped] = sortDir === 'asc' ? 'ASC' : 'DESC'
125
+ } else {
126
+ orderBy.createdAt = 'DESC'
127
+ }
128
+
129
+ const [rows, total] = await em.findAndCount(ProgressJob, filter, {
130
+ orderBy,
131
+ limit: pageSize,
132
+ offset: (page - 1) * pageSize,
133
+ })
134
+ const items = rows.map(toRow)
135
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
136
+
137
+ return NextResponse.json({ items, total, page, pageSize, totalPages })
138
+ }
139
+
140
+ export async function POST(req: Request) {
141
+ const auth = await getAuthFromRequest(req)
142
+ if (!auth || !auth.tenantId) {
143
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
144
+ }
145
+
146
+ const body = await req.json()
147
+ const parsed = createProgressJobSchema.safeParse(body)
148
+ if (!parsed.success) {
149
+ return NextResponse.json({ error: 'Invalid input', details: parsed.error.flatten() }, { status: 400 })
150
+ }
151
+
152
+ const container = await createRequestContainer()
153
+ const progressService = container.resolve('progressService') as import('../../lib/progressService').ProgressService
154
+
155
+ const job = await progressService.createJob(parsed.data, {
156
+ tenantId: auth.tenantId,
157
+ organizationId: auth.orgId,
158
+ userId: auth.sub,
159
+ })
160
+
161
+ return NextResponse.json({ id: job.id }, { status: 201 })
162
+ }
163
+
164
+ const jobListItemSchema = z.object({
165
+ id: z.string().uuid(),
166
+ jobType: z.string(),
167
+ name: z.string(),
168
+ description: z.string().nullable(),
169
+ status: z.string(),
170
+ progressPercent: z.number(),
171
+ processedCount: z.number(),
172
+ totalCount: z.number().nullable(),
173
+ etaSeconds: z.number().nullable(),
174
+ cancellable: z.boolean(),
175
+ startedAt: z.string().nullable(),
176
+ finishedAt: z.string().nullable(),
177
+ errorMessage: z.string().nullable(),
178
+ createdAt: z.string().nullable(),
179
+ tenantId: z.string().uuid(),
180
+ organizationId: z.string().uuid().nullable(),
181
+ })
182
+
183
+ export const openApi = createProgressCrudOpenApi({
184
+ resourceName: 'ProgressJob',
185
+ pluralName: 'ProgressJobs',
186
+ querySchema: listQuerySchema,
187
+ listResponseSchema: createPagedListResponseSchema(jobListItemSchema),
188
+ create: {
189
+ schema: createProgressJobSchema,
190
+ description: 'Creates a new progress job for tracking a long-running operation.',
191
+ },
192
+ })
@@ -0,0 +1,28 @@
1
+ import { type ZodTypeAny } from 'zod'
2
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import {
4
+ createCrudOpenApiFactory,
5
+ createPagedListResponseSchema as createSharedPagedListResponseSchema,
6
+ defaultCreateResponseSchema as sharedDefaultCreateResponseSchema,
7
+ defaultOkResponseSchema as sharedDefaultOkResponseSchema,
8
+ type CrudOpenApiOptions,
9
+ } from '@open-mercato/shared/lib/openapi/crud'
10
+
11
+ export const defaultCreateResponseSchema = sharedDefaultCreateResponseSchema
12
+ export const defaultOkResponseSchema = sharedDefaultOkResponseSchema
13
+
14
+ export function createPagedListResponseSchema(itemSchema: ZodTypeAny) {
15
+ return createSharedPagedListResponseSchema(itemSchema, { paginationMetaOptional: true })
16
+ }
17
+
18
+ const buildProgressCrudOpenApi = createCrudOpenApiFactory({
19
+ defaultTag: 'Progress',
20
+ defaultCreateResponseSchema,
21
+ defaultOkResponseSchema,
22
+ makeListDescription: ({ pluralLower }) =>
23
+ `Returns a paginated collection of ${pluralLower} scoped to the authenticated tenant.`,
24
+ })
25
+
26
+ export function createProgressCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {
27
+ return buildProgressCrudOpenApi(options)
28
+ }
@@ -0,0 +1,94 @@
1
+ import { Entity, PrimaryKey, Property, Index, OptionalProps } from '@mikro-orm/core'
2
+
3
+ export type ProgressJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
4
+
5
+ // No deleted_at column: terminal statuses (completed, failed, cancelled) serve as logical soft-delete.
6
+ // Old jobs should be purged via scheduled cleanup rather than soft-deleted individually.
7
+ @Entity({ tableName: 'progress_jobs' })
8
+ @Index({ name: 'progress_jobs_status_tenant_idx', properties: ['status', 'tenantId'] })
9
+ @Index({ name: 'progress_jobs_type_tenant_idx', properties: ['jobType', 'tenantId'] })
10
+ @Index({ name: 'progress_jobs_parent_idx', properties: ['parentJobId'] })
11
+ export class ProgressJob {
12
+ [OptionalProps]?: 'status' | 'progressPercent' | 'processedCount' | 'cancellable' | 'createdAt' | 'updatedAt'
13
+
14
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
15
+ id!: string
16
+
17
+ @Property({ name: 'job_type', type: 'text' })
18
+ jobType!: string
19
+
20
+ @Property({ name: 'name', type: 'text' })
21
+ name!: string
22
+
23
+ @Property({ name: 'description', type: 'text', nullable: true })
24
+ description?: string | null
25
+
26
+ @Property({ name: 'status', type: 'text' })
27
+ status: ProgressJobStatus = 'pending'
28
+
29
+ @Property({ name: 'progress_percent', type: 'smallint' })
30
+ progressPercent: number = 0
31
+
32
+ @Property({ name: 'processed_count', type: 'int' })
33
+ processedCount: number = 0
34
+
35
+ @Property({ name: 'total_count', type: 'int', nullable: true })
36
+ totalCount?: number | null
37
+
38
+ @Property({ name: 'eta_seconds', type: 'int', nullable: true })
39
+ etaSeconds?: number | null
40
+
41
+ @Property({ name: 'started_by_user_id', type: 'uuid', nullable: true })
42
+ startedByUserId?: string | null
43
+
44
+ @Property({ name: 'started_at', type: Date, nullable: true })
45
+ startedAt?: Date | null
46
+
47
+ @Property({ name: 'heartbeat_at', type: Date, nullable: true })
48
+ heartbeatAt?: Date | null
49
+
50
+ @Property({ name: 'finished_at', type: Date, nullable: true })
51
+ finishedAt?: Date | null
52
+
53
+ @Property({ name: 'result_summary', type: 'json', nullable: true })
54
+ resultSummary?: Record<string, unknown> | null
55
+
56
+ @Property({ name: 'error_message', type: 'text', nullable: true })
57
+ errorMessage?: string | null
58
+
59
+ @Property({ name: 'error_stack', type: 'text', nullable: true })
60
+ errorStack?: string | null
61
+
62
+ @Property({ name: 'meta', type: 'json', nullable: true })
63
+ meta?: Record<string, unknown> | null
64
+
65
+ @Property({ name: 'cancellable', type: 'boolean' })
66
+ cancellable: boolean = false
67
+
68
+ @Property({ name: 'cancelled_by_user_id', type: 'uuid', nullable: true })
69
+ cancelledByUserId?: string | null
70
+
71
+ @Property({ name: 'cancel_requested_at', type: Date, nullable: true })
72
+ cancelRequestedAt?: Date | null
73
+
74
+ @Property({ name: 'parent_job_id', type: 'uuid', nullable: true })
75
+ parentJobId?: string | null
76
+
77
+ @Property({ name: 'partition_index', type: 'int', nullable: true })
78
+ partitionIndex?: number | null
79
+
80
+ @Property({ name: 'partition_count', type: 'int', nullable: true })
81
+ partitionCount?: number | null
82
+
83
+ @Property({ name: 'tenant_id', type: 'uuid' })
84
+ tenantId!: string
85
+
86
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
87
+ organizationId?: string | null
88
+
89
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
90
+ createdAt: Date = new Date()
91
+
92
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
93
+ updatedAt: Date = new Date()
94
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod'
2
+
3
+ export const progressJobStatusSchema = z.enum(['pending', 'running', 'completed', 'failed', 'cancelled'])
4
+
5
+ export const createProgressJobSchema = z.object({
6
+ jobType: z.string().min(1).max(100),
7
+ name: z.string().min(1).max(255),
8
+ description: z.string().max(1000).optional(),
9
+ totalCount: z.number().int().positive().optional(),
10
+ cancellable: z.boolean().optional().default(false),
11
+ meta: z.record(z.string(), z.unknown()).optional(),
12
+ parentJobId: z.string().uuid().optional(),
13
+ partitionIndex: z.number().int().min(0).optional(),
14
+ partitionCount: z.number().int().positive().optional(),
15
+ })
16
+
17
+ export const updateProgressSchema = z.object({
18
+ processedCount: z.number().int().min(0).optional(),
19
+ progressPercent: z.number().int().min(0).max(100).optional(),
20
+ totalCount: z.number().int().positive().optional(),
21
+ etaSeconds: z.number().int().min(0).optional(),
22
+ meta: z.record(z.string(), z.unknown()).optional(),
23
+ })
24
+
25
+ export const completeJobSchema = z.object({
26
+ resultSummary: z.record(z.string(), z.unknown()).optional(),
27
+ })
28
+
29
+ export const failJobSchema = z.object({
30
+ errorMessage: z.string().max(2000),
31
+ errorStack: z.string().max(10000).optional(),
32
+ })
33
+
34
+ export const listProgressJobsSchema = z.object({
35
+ status: z.string().optional(),
36
+ jobType: z.string().optional(),
37
+ parentJobId: z.string().uuid().optional(),
38
+ includeCompleted: z.enum(['true', 'false']).optional(),
39
+ completedSince: z.string().optional(),
40
+ page: z.coerce.number().min(1).default(1),
41
+ pageSize: z.coerce.number().min(1).max(100).default(20),
42
+ search: z.string().optional(),
43
+ sortField: z.enum(['createdAt', 'startedAt', 'finishedAt']).optional(),
44
+ sortDir: z.enum(['asc', 'desc']).optional(),
45
+ })
46
+
47
+ export type CreateProgressJobInput = z.infer<typeof createProgressJobSchema>
48
+ export type UpdateProgressInput = z.infer<typeof updateProgressSchema>
49
+ export type CompleteJobInput = z.infer<typeof completeJobSchema>
50
+ export type FailJobInput = z.infer<typeof failJobSchema>
51
+ export type ListProgressJobsInput = z.infer<typeof listProgressJobsSchema>
@@ -0,0 +1,15 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+ import type { EntityManager } from '@mikro-orm/core'
3
+ import { createProgressService } from './lib/progressServiceImpl'
4
+
5
+ export function register(container: AppContainer) {
6
+ container.register({
7
+ progressService: {
8
+ resolve: (c) => {
9
+ const em = c.resolve<EntityManager>('em')
10
+ const eventBus = c.resolve('eventBus') as { emit: (event: string, payload: Record<string, unknown>) => Promise<void> }
11
+ return createProgressService(em, eventBus)
12
+ },
13
+ },
14
+ })
15
+ }
@@ -0,0 +1,21 @@
1
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
2
+
3
+ export const events = [
4
+ { id: 'progress.job.created', label: 'Job Created', entity: 'job', category: 'crud' },
5
+ { id: 'progress.job.started', label: 'Job Started', entity: 'job', category: 'lifecycle' },
6
+ { id: 'progress.job.updated', label: 'Job Updated', entity: 'job', category: 'lifecycle' },
7
+ { id: 'progress.job.completed', label: 'Job Completed', entity: 'job', category: 'lifecycle' },
8
+ { id: 'progress.job.failed', label: 'Job Failed', entity: 'job', category: 'lifecycle' },
9
+ { id: 'progress.job.cancelled', label: 'Job Cancelled', entity: 'job', category: 'lifecycle' },
10
+ ] as const
11
+
12
+ export const eventsConfig = createModuleEvents({
13
+ moduleId: 'progress',
14
+ events,
15
+ })
16
+
17
+ export const emitProgressEvent = eventsConfig.emit
18
+
19
+ export type ProgressEventId = typeof events[number]['id']
20
+
21
+ export default eventsConfig
@@ -0,0 +1,20 @@
1
+ {
2
+ "progress.actions.cancel": "Abbrechen",
3
+ "progress.actions.dismiss": "Verwerfen",
4
+ "progress.actions.retry": "Wiederholen",
5
+ "progress.activeCount": "{count} Vorgänge laufen",
6
+ "progress.errors.alreadyFinished": "Vorgang ist bereits abgeschlossen",
7
+ "progress.errors.cannotCancel": "Dieser Vorgang kann nicht abgebrochen werden",
8
+ "progress.errors.notFound": "Vorgang nicht gefunden",
9
+ "progress.eta.hoursMinutes": "noch {hours}h {minutes}m",
10
+ "progress.eta.minutes": "noch {count}m",
11
+ "progress.eta.seconds": "noch {count}s",
12
+ "progress.processed": "verarbeitet",
13
+ "progress.recentlyCompleted": "{count} Vorgänge abgeschlossen",
14
+ "progress.status.cancelled": "Abgebrochen",
15
+ "progress.status.completed": "Abgeschlossen",
16
+ "progress.status.failed": "Fehlgeschlagen",
17
+ "progress.status.pending": "Ausstehend",
18
+ "progress.status.running": "Läuft",
19
+ "progress.title": "Fortschritt"
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "progress.actions.cancel": "Cancel",
3
+ "progress.actions.dismiss": "Dismiss",
4
+ "progress.actions.retry": "Retry",
5
+ "progress.activeCount": "{count} operations running",
6
+ "progress.errors.alreadyFinished": "Job has already finished",
7
+ "progress.errors.cannotCancel": "This job cannot be cancelled",
8
+ "progress.errors.notFound": "Job not found",
9
+ "progress.eta.hoursMinutes": "{hours}h {minutes}m remaining",
10
+ "progress.eta.minutes": "{count}m remaining",
11
+ "progress.eta.seconds": "{count}s remaining",
12
+ "progress.processed": "processed",
13
+ "progress.recentlyCompleted": "{count} operations completed",
14
+ "progress.status.cancelled": "Cancelled",
15
+ "progress.status.completed": "Completed",
16
+ "progress.status.failed": "Failed",
17
+ "progress.status.pending": "Pending",
18
+ "progress.status.running": "Running",
19
+ "progress.title": "Progress"
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "progress.actions.cancel": "Cancelar",
3
+ "progress.actions.dismiss": "Descartar",
4
+ "progress.actions.retry": "Reintentar",
5
+ "progress.activeCount": "{count} operaciones en curso",
6
+ "progress.errors.alreadyFinished": "La operación ya ha finalizado",
7
+ "progress.errors.cannotCancel": "Esta operación no se puede cancelar",
8
+ "progress.errors.notFound": "Operación no encontrada",
9
+ "progress.eta.hoursMinutes": "{hours}h {minutes}m restantes",
10
+ "progress.eta.minutes": "{count}m restantes",
11
+ "progress.eta.seconds": "{count}s restantes",
12
+ "progress.processed": "procesados",
13
+ "progress.recentlyCompleted": "{count} operaciones completadas",
14
+ "progress.status.cancelled": "Cancelado",
15
+ "progress.status.completed": "Completado",
16
+ "progress.status.failed": "Fallido",
17
+ "progress.status.pending": "Pendiente",
18
+ "progress.status.running": "En curso",
19
+ "progress.title": "Progreso"
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "progress.actions.cancel": "Anuluj",
3
+ "progress.actions.dismiss": "Odrzuć",
4
+ "progress.actions.retry": "Ponów",
5
+ "progress.activeCount": "{count} operacji w toku",
6
+ "progress.errors.alreadyFinished": "Zadanie zostało już zakończone",
7
+ "progress.errors.cannotCancel": "Tego zadania nie można anulować",
8
+ "progress.errors.notFound": "Nie znaleziono zadania",
9
+ "progress.eta.hoursMinutes": "pozostało {hours}h {minutes}m",
10
+ "progress.eta.minutes": "pozostało {count}m",
11
+ "progress.eta.seconds": "pozostało {count}s",
12
+ "progress.processed": "przetworzonych",
13
+ "progress.recentlyCompleted": "{count} operacji zakończonych",
14
+ "progress.status.cancelled": "Anulowane",
15
+ "progress.status.completed": "Zakończone",
16
+ "progress.status.failed": "Niepowodzenie",
17
+ "progress.status.pending": "Oczekuje",
18
+ "progress.status.running": "W toku",
19
+ "progress.title": "Postęp"
20
+ }
@@ -0,0 +1,11 @@
1
+ import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
2
+
3
+ export const metadata: ModuleInfo = {
4
+ name: 'progress',
5
+ title: 'Progress',
6
+ version: '0.1.0',
7
+ description: 'Generic server-side progress tracking for long-running operations',
8
+ author: 'Open Mercato Team',
9
+ license: 'Proprietary',
10
+ ejectable: true,
11
+ }
@@ -0,0 +1,60 @@
1
+ import { events } from '../events'
2
+
3
+ type EventEntry = typeof events[number]
4
+ type EventMap = { [K in EventEntry['id'] as Uppercase<K extends `progress.job.${infer A}` ? `JOB_${A}` : never>]: K }
5
+
6
+ function buildEventMap<T extends readonly { id: string }[]>(defs: T) {
7
+ const map: Record<string, string> = {}
8
+ for (const def of defs) {
9
+ const suffix = def.id.replace('progress.job.', '')
10
+ map[`JOB_${suffix.toUpperCase()}`] = def.id
11
+ }
12
+ return map as EventMap
13
+ }
14
+
15
+ export const PROGRESS_EVENTS = buildEventMap(events)
16
+
17
+ export type ProgressJobCreatedPayload = {
18
+ jobId: string
19
+ jobType: string
20
+ name: string
21
+ tenantId: string
22
+ organizationId?: string | null
23
+ }
24
+
25
+ export type ProgressJobStartedPayload = {
26
+ jobId: string
27
+ jobType: string
28
+ tenantId: string
29
+ }
30
+
31
+ export type ProgressJobUpdatedPayload = {
32
+ jobId: string
33
+ jobType?: string
34
+ progressPercent: number
35
+ processedCount: number
36
+ totalCount?: number | null
37
+ etaSeconds?: number | null
38
+ tenantId: string
39
+ }
40
+
41
+ export type ProgressJobCompletedPayload = {
42
+ jobId: string
43
+ jobType: string
44
+ resultSummary?: Record<string, unknown> | null
45
+ tenantId: string
46
+ }
47
+
48
+ export type ProgressJobFailedPayload = {
49
+ jobId: string
50
+ jobType: string
51
+ errorMessage: string
52
+ tenantId: string
53
+ stale?: boolean
54
+ }
55
+
56
+ export type ProgressJobCancelledPayload = {
57
+ jobId: string
58
+ jobType: string
59
+ tenantId: string
60
+ }
@@ -0,0 +1,47 @@
1
+ import type { ProgressJob } from '../data/entities'
2
+ import type { CreateProgressJobInput, UpdateProgressInput, CompleteJobInput, FailJobInput } from '../data/validators'
3
+
4
+ export interface ProgressServiceContext {
5
+ tenantId: string
6
+ organizationId?: string | null
7
+ userId?: string | null
8
+ }
9
+
10
+ export interface ProgressService {
11
+ createJob(input: CreateProgressJobInput, ctx: ProgressServiceContext): Promise<ProgressJob>
12
+ startJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob>
13
+ updateProgress(jobId: string, input: UpdateProgressInput, ctx: ProgressServiceContext): Promise<ProgressJob>
14
+ incrementProgress(jobId: string, delta: number, ctx: ProgressServiceContext): Promise<ProgressJob>
15
+ completeJob(jobId: string, input: CompleteJobInput | undefined, ctx: ProgressServiceContext): Promise<ProgressJob>
16
+ failJob(jobId: string, input: FailJobInput, ctx: ProgressServiceContext): Promise<ProgressJob>
17
+ cancelJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob>
18
+ isCancellationRequested(jobId: string): Promise<boolean>
19
+ getActiveJobs(ctx: ProgressServiceContext): Promise<ProgressJob[]>
20
+ getRecentlyCompletedJobs(ctx: ProgressServiceContext, sinceSeconds?: number): Promise<ProgressJob[]>
21
+ getJob(jobId: string, ctx: ProgressServiceContext): Promise<ProgressJob | null>
22
+ markStaleJobsFailed(tenantId: string, timeoutSeconds?: number): Promise<number>
23
+ }
24
+
25
+ export const HEARTBEAT_INTERVAL_MS = 5000
26
+ export const STALE_JOB_TIMEOUT_SECONDS = 60
27
+
28
+ export function calculateEta(
29
+ processedCount: number,
30
+ totalCount: number,
31
+ startedAt: Date,
32
+ ): number | null {
33
+ if (processedCount === 0 || totalCount === 0) return null
34
+
35
+ const elapsedMs = Date.now() - startedAt.getTime()
36
+ const rate = processedCount / elapsedMs
37
+ const remaining = totalCount - processedCount
38
+
39
+ if (rate <= 0) return null
40
+
41
+ return Math.ceil(remaining / rate / 1000)
42
+ }
43
+
44
+ export function calculateProgressPercent(processedCount: number, totalCount: number | null): number {
45
+ if (!totalCount || totalCount <= 0) return 0
46
+ return Math.min(100, Math.round((processedCount / totalCount) * 100))
47
+ }