Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/api/approvals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
approveTask,
buildTaskApprovalSnapshot,
completeTask,
recordExternalSubmission,
recordTaskEvidence,
rejectTask,
requestTaskApproval,
Expand All @@ -18,7 +19,7 @@ function jsonResponse(body: unknown, status = 200) {

function task(): TaskDetailResponse {
return {
task_id: 'T-1', worker_id: 'W-1', case_id: null, task_type: 'STAY_PERIOD_EXTENSION',
task_id: 'T-1', target_type: 'WORKER', worker_id: 'W-1', case_id: null, task_type: 'STAY_PERIOD_EXTENSION',
workflow_id: 'wf-stay', workflow_catalog_version: '3', title: '체류기간 연장', description: '안내',
business_data: { office: '수원' }, source: 'MANUAL', status: 'DRAFT', due_date: '2026-08-10',
content_revision: 2, version: 7, missing_required_slots: [], checklist_items: [], created_by: 'U-1',
Expand All @@ -35,29 +36,40 @@ describe('approval APIs', () => {
expected_version: 7,
ai_snapshot: null,
hr_snapshot: {
worker_id: 'W-1', task_type: 'STAY_PERIOD_EXTENSION', workflow_id: 'wf-stay',
target_type: 'WORKER', worker_id: 'W-1', task_type: 'STAY_PERIOD_EXTENSION', workflow_id: 'wf-stay',
title: '체류기간 연장', description: '안내', due_date: '2026-08-10', business_data: { office: '수원' },
},
changed_fields: ['task_content'],
source_versions: { workflow_catalog_version: '3', content_revision: 2 },
})
})

it('uses the approval, decision, evidence and completion endpoints', async () => {
it('uses the approval, decision, external submission, evidence and completion endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ task_id: 'T-1' }, 201)))

await requestTaskApproval('T-1', buildTaskApprovalSnapshot(task()))
await approveTask('T-1', { expected_version: 8 })
await rejectTask('T-1', { expected_version: 8, reason: '마감일 확인 필요' })
await recordExternalSubmission('T-1', {
expected_version: 8,
destination: '수원출입국·외국인청',
safe_reference: '접수번호 1234',
})
await recordTaskEvidence('T-1', { evidence_type: 'RECEIPT', note: '접수번호 1234' })
await completeTask('T-1', 9)

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/tasks/T-1/approval-requests')
expect(String(calls[1][0])).toContain('/tasks/T-1/approve')
expect(String(calls[2][0])).toContain('/tasks/T-1/reject')
expect(String(calls[3][0])).toContain('/tasks/T-1/evidence')
expect(String(calls[4][0])).toContain('/tasks/T-1/complete')
expect(JSON.parse(calls[4][1]?.body as string)).toEqual({ expected_version: 9 })
expect(String(calls[3][0])).toContain('/tasks/T-1/external-submissions')
expect(JSON.parse(calls[3][1]?.body as string)).toEqual({
expected_version: 8,
destination: '수원출입국·외국인청',
safe_reference: '접수번호 1234',
})
expect(String(calls[4][0])).toContain('/tasks/T-1/evidence')
expect(String(calls[5][0])).toContain('/tasks/T-1/complete')
expect(JSON.parse(calls[5][1]?.body as string)).toEqual({ expected_version: 9 })
})
})
18 changes: 18 additions & 0 deletions src/api/approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface RecordTaskEvidenceBody {
recorded_at?: string
}

export interface RecordExternalSubmissionBody {
expected_version: number
destination: string
safe_reference: string
submitted_at?: string
}

export interface TaskActionResponse {
resource_id: string
task_id: string
Expand All @@ -48,6 +55,7 @@ export function buildTaskApprovalSnapshot(task: TaskDetailResponse): RequestTask
expected_version: task.version,
ai_snapshot: null,
hr_snapshot: {
target_type: task.target_type,
worker_id: task.worker_id,
task_type: task.task_type,
workflow_id: task.workflow_id,
Expand Down Expand Up @@ -104,6 +112,16 @@ export function recordTaskEvidence(
})
}

export function recordExternalSubmission(
taskId: string,
body: RecordExternalSubmissionBody,
): Promise<TaskActionResponse> {
return apiFetch<TaskActionResponse>(
`/tasks/${encodeURIComponent(taskId)}/external-submissions`,
{ method: 'POST', body: JSON.stringify(body) },
)
}

export function completeTask(taskId: string, expectedVersion: number): Promise<TaskActionResponse> {
return apiFetch<TaskActionResponse>(`/tasks/${encodeURIComponent(taskId)}/complete`, {
method: 'POST',
Expand Down
28 changes: 27 additions & 1 deletion src/api/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,34 @@ export type AuditAction =
| 'EXTERNAL_SUBMISSION_RECORDED'
| 'EVIDENCE_RECORDED'
| 'TASK_COMPLETED'
| 'FILE_UPLOADED'
| 'FILE_DOWNLOADED'
| 'WORKER_DOCUMENT_FILE_LINKED'
| 'DOCUMENT_REQUEST_DRAFT_SAVED'
| 'AI_RUN_CREATED'
| 'AI_RUN_ANSWERS_SUBMITTED'
| 'AI_RUN_CANDIDATES_DECIDED'
| 'OUTBOX_MANUAL_RETRY_REQUESTED'
| 'WORKER_LINK_RESPONSE_SUBMITTED'
| 'WORKER_LINK_RESPONSES_REVIEWED'
| 'WORKER_LINK_SENT'
| 'WORKER_LINK_ACCESSED'
| 'USER_AGREEMENTS_RECORDED'
| 'PASSWORD_RESET_REQUESTED'
| 'PASSWORD_RESET_COMPLETED'

export type AuditTargetType = 'TASK' | 'APPROVAL_REQUEST' | 'EXTERNAL_SUBMISSION' | 'EVIDENCE'
export type AuditTargetType =
| 'TASK'
| 'APPROVAL_REQUEST'
| 'EXTERNAL_SUBMISSION'
| 'EVIDENCE'
| 'FILE'
| 'WORKER_DOCUMENT'
| 'DOCUMENT_REQUEST_DRAFT'
| 'AI_RUN'
| 'OUTBOX_EVENT'
| 'WORKER_LINK'
| 'USER_ACCOUNT'

export interface AuditEventResponse {
audit_event_id: string
Expand Down
27 changes: 27 additions & 0 deletions src/api/cases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchCaseProjection, fetchCases } from './cases'

function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}

beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
afterEach(() => vi.unstubAllGlobals())

describe('case APIs', () => {
it('lists Cases with query parameters and fetches a projection by encoded ID', async () => {
vi.mocked(fetch).mockImplementation(() =>
Promise.resolve(jsonResponse({ items: [], page: 0, size: 20, total_elements: 0 })),
)

await fetchCases({ keyword: '응웬 반', page: 1, size: 20 })
await fetchCaseProjection('C/1')

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/cases?keyword=%EC%9D%91%EC%9B%AC+%EB%B0%98&page=1&size=20')
expect(String(calls[1][0])).toContain('/cases/C%2F1/projection')
})
})
26 changes: 25 additions & 1 deletion src/api/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ describe('fetchTasks', () => {

await fetchTasks({
status: 'READY_FOR_REVIEW',
targetType: 'COMPANY',
source: 'SYSTEM_DDAY',
caseId: 'CASE-17',
keyword: '체류연장',
page: 1,
Expand All @@ -40,7 +42,10 @@ describe('fetchTasks', () => {

const [url] = vi.mocked(fetch).mock.calls[0]
expect(url).toContain('status=READY_FOR_REVIEW')
expect(url).toContain('caseId=CASE-17')
expect(url).toContain('target_type=COMPANY')
expect(url).toContain('source=SYSTEM_DDAY')
expect(url).toContain('case_id=CASE-17')
expect(url).not.toContain('caseId=')
expect(url).toContain('keyword=%EC%B2%B4%EB%A5%98%EC%97%B0%EC%9E%A5')
expect(url).toContain('page=1&size=20')
})
Expand Down Expand Up @@ -77,6 +82,25 @@ describe('createTask', () => {
title: '체류연장 준비',
})
})

it('supports a company task without worker_id', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ task_id: 'T-company' }, 201))

await createTask({
target_type: 'COMPANY',
task_type: 'PAYROLL_EXPLANATION',
workflow_id: 'wf-payroll-explanation',
title: '급여명세서 설명 준비',
})

const [, init] = vi.mocked(fetch).mock.calls[0]
expect(JSON.parse(init?.body as string)).toEqual({
target_type: 'COMPANY',
task_type: 'PAYROLL_EXPLANATION',
workflow_id: 'wf-payroll-explanation',
title: '급여명세서 설명 준비',
})
})
})

describe('updateTask', () => {
Expand Down
32 changes: 26 additions & 6 deletions src/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ export type TaskStatus =
| 'COMPLETED'
| 'CANCELLED'

export type TaskType = 'RECONTRACT' | 'EMPLOYMENT_PERIOD_EXTENSION' | 'STAY_PERIOD_EXTENSION'
export type TaskType =
| 'RECONTRACT'
| 'EMPLOYMENT_PERIOD_EXTENSION'
| 'STAY_PERIOD_EXTENSION'
| 'DOCUMENT_REQUEST'
| 'WORKER_ONBOARDING'
| 'PAYROLL_EXPLANATION'
| 'EMPLOYMENT_CHANGE'
| 'WORK_INSTRUCTION'
export type TaskTargetType = 'WORKER' | 'COMPANY'
export type TaskSource = 'MANUAL' | 'SYSTEM_DDAY' | 'AI_CANDIDATE'

export interface TaskChecklistItemResponse {
Expand All @@ -27,7 +36,8 @@ export interface TaskChecklistItemResponse {

export interface TaskDetailResponse {
task_id: string
worker_id: string
target_type: TaskTargetType
worker_id: string | null
case_id: string | null
task_type: TaskType
workflow_id: string
Expand All @@ -50,7 +60,8 @@ export interface TaskDetailResponse {

export interface TaskSummaryResponse {
task_id: string
worker_id: string
target_type: TaskTargetType
worker_id: string | null
case_id: string | null
task_type: TaskType
workflow_id: string
Expand All @@ -76,6 +87,8 @@ export interface TaskPageResponse {
export interface FetchTasksParams {
status?: TaskStatus
taskType?: TaskType
targetType?: TaskTargetType
source?: TaskSource
workerId?: string
caseId?: string
dueFrom?: string
Expand All @@ -89,8 +102,10 @@ export function fetchTasks(params: FetchTasksParams = {}): Promise<TaskPageRespo
const query = new URLSearchParams()
if (params.status) query.set('status', params.status)
if (params.taskType) query.set('taskType', params.taskType)
if (params.targetType) query.set('target_type', params.targetType)
if (params.source) query.set('source', params.source)
if (params.workerId) query.set('workerId', params.workerId)
if (params.caseId) query.set('caseId', params.caseId)
if (params.caseId) query.set('case_id', params.caseId)
if (params.dueFrom) query.set('dueFrom', params.dueFrom)
if (params.dueTo) query.set('dueTo', params.dueTo)
if (params.keyword) query.set('keyword', params.keyword)
Expand All @@ -103,8 +118,7 @@ export function fetchTaskById(taskId: string): Promise<TaskDetailResponse> {
return apiFetch<TaskDetailResponse>(`/tasks/${encodeURIComponent(taskId)}`)
}

export interface CreateTaskBody {
worker_id: string
interface CreateTaskFields {
case_id?: string
task_type: TaskType
workflow_id: string
Expand All @@ -114,6 +128,12 @@ export interface CreateTaskBody {
business_data?: Record<string, unknown>
}

export type CreateTaskBody = CreateTaskFields &
(
| { target_type?: 'WORKER'; worker_id: string }
| { target_type: 'COMPANY'; worker_id?: never }
)

export function createTask(body: CreateTaskBody): Promise<TaskDetailResponse> {
return apiFetch<TaskDetailResponse>('/tasks', { method: 'POST', body: JSON.stringify(body) })
}
Expand Down
52 changes: 52 additions & 0 deletions src/api/workerLinks.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchTaskWorkerLinkDelivery,
fetchTaskWorkerResponses,
fetchWorkerLink,
issueWorkerLink,
markWorkerLinkSent,
markTaskWorkerResponsesRead,
resolveWorkerPortalUrl,
submitWorkerResponse,
uploadWorkerLinkDocument,
Expand All @@ -24,6 +28,37 @@ describe('worker link APIs', () => {
expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('issue-1')
})

it('gets the current delivery status and records manual delivery', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
jsonResponse({
worker_link_id: 'L-1',
link_status: 'ACTIVE',
delivery_status: 'NOT_SENT',
sent_at: null,
expires_at: '2026-08-07T00:00:00Z',
}),
)
.mockResolvedValueOnce(
jsonResponse({
worker_link_id: 'L-1',
link_status: 'ACTIVE',
delivery_status: 'SENT',
sent_at: '2026-08-05T00:00:00Z',
expires_at: '2026-08-07T00:00:00Z',
}),
)

await fetchTaskWorkerLinkDelivery('T/1')
await markWorkerLinkSent('L/1')

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-link')
expect(calls[0][1]?.method).toBeUndefined()
expect(String(calls[1][0])).toContain('/worker-links/L%2F1/sent')
expect(calls[1][1]?.method).toBe('POST')
})

it('views, uploads and submits through the public token endpoints', async () => {
vi.mocked(fetch).mockImplementation(() => Promise.resolve(jsonResponse({ upload_id: 'U-1' }, 201)))

Expand All @@ -40,6 +75,23 @@ describe('worker link APIs', () => {
expect(String(calls[2][0])).toContain('/responses')
})

it('lists and marks HR worker responses as reviewed through authenticated endpoints', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
jsonResponse({ items: [], page: 1, size: 10, total_elements: 0, total_pages: 0 }),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }))

await fetchTaskWorkerResponses('T/1', 1, 10)
await markTaskWorkerResponsesRead('T/1')

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/tasks/T%2F1/worker-responses?page=1&size=10')
expect(calls[0][1]?.method).toBeUndefined()
expect(String(calls[1][0])).toContain('/tasks/T%2F1/worker-responses/read')
expect(calls[1][1]?.method).toBe('POST')
})

it('turns the current backend raw-token response into a frontend route', () => {
expect(resolveWorkerPortalUrl('raw/token', 'https://fowoco.kr')).toBe(
'https://fowoco.kr/worker-portal/raw%2Ftoken',
Expand Down
Loading