@open-mercato/core 0.4.5-develop-3093b8bee8 → 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,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
|
+
}
|