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
51 changes: 51 additions & 0 deletions src/api/workerLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchWorkerLink,
issueWorkerLink,
resolveWorkerPortalUrl,
submitWorkerResponse,
uploadWorkerLinkDocument,
} from './workerLinks'

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

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

describe('worker link APIs', () => {
it('uses the authenticated issue endpoint with an idempotency key', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ worker_url: 'raw-token', expires_at: '2026-08-07T00:00:00Z' }, 201))
await issueWorkerLink('T-1', { expires_in_hours: 72, rotate_existing: true }, 'issue-1')

const [url, init] = vi.mocked(fetch).mock.calls[0]
expect(String(url)).toContain('/tasks/T-1/worker-link')
expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('issue-1')
})

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

await fetchWorkerLink('token value')
await uploadWorkerLinkDocument('token value', new File(['passport'], 'passport.jpg', { type: 'image/jpeg' }), 'upload-1')
await submitWorkerResponse('token value', {
response_type: 'DOCUMENT_SUBMITTED', upload_ids: ['U-1'], idempotency_key: 'response-1',
})

const calls = vi.mocked(fetch).mock.calls
expect(String(calls[0][0])).toContain('/public/worker-links/token%20value')
expect(String(calls[1][0])).toContain('/documents')
expect(calls[1][1]?.body).toBeInstanceOf(FormData)
expect(String(calls[2][0])).toContain('/responses')
})

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',
)
expect(resolveWorkerPortalUrl('https://worker.fowoco.kr/s/token', 'https://fowoco.kr')).toBe(
'https://worker.fowoco.kr/s/token',
)
})
})
103 changes: 103 additions & 0 deletions src/api/workerLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { apiFetch } from './client'

export type WorkerResponseType =
| 'ACKNOWLEDGED'
| 'QUESTION'
| 'NOT_UNDERSTOOD'
| 'DOCUMENT_SUBMITTED'
| 'DIFFICULT'

export interface WorkerLinkIssueBody {
expires_in_hours?: number
rotate_existing: boolean
}

export interface WorkerLinkIssueResponse {
worker_url: string
expires_at: string
}

export interface WorkerLinkViewResponse {
guidance: string
due_date: string | null
allowed_responses: WorkerResponseType[]
}

export interface WorkerLinkDocumentUploadResponse {
upload_id: string
file_name: string
size: number
expires_at: string
}

export interface WorkerResponseSubmitBody {
response_type: WorkerResponseType
message?: string
upload_ids?: string[]
idempotency_key: string
}

export interface WorkerResponseSubmitResponse {
response_id: string
received_at: string
}

export function issueWorkerLink(
taskId: string,
body: WorkerLinkIssueBody,
idempotencyKey: string,
): Promise<WorkerLinkIssueResponse> {
return apiFetch<WorkerLinkIssueResponse>(`/tasks/${encodeURIComponent(taskId)}/worker-link`, {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(body),
})
}

export function fetchWorkerLink(token: string): Promise<WorkerLinkViewResponse> {
return apiFetch<WorkerLinkViewResponse>(`/public/worker-links/${encodeURIComponent(token)}`, {
skipAuthRetry: true,
})
}

export function uploadWorkerLinkDocument(
token: string,
file: File,
clientRequestId: string,
documentType?: string,
): Promise<WorkerLinkDocumentUploadResponse> {
const body = new FormData()
body.set('file', file)
body.set('clientRequestId', clientRequestId)
if (documentType) body.set('documentType', documentType)

return apiFetch<WorkerLinkDocumentUploadResponse>(
`/public/worker-links/${encodeURIComponent(token)}/documents`,
{
method: 'POST',
headers: { 'Idempotency-Key': clientRequestId },
body,
skipAuthRetry: true,
},
)
}

export function submitWorkerResponse(
token: string,
body: WorkerResponseSubmitBody,
): Promise<WorkerResponseSubmitResponse> {
return apiFetch<WorkerResponseSubmitResponse>(
`/public/worker-links/${encodeURIComponent(token)}/responses`,
{
method: 'POST',
body: JSON.stringify(body),
skipAuthRetry: true,
},
)
}

export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string {
if (/^https?:\/\//i.test(workerUrlOrToken)) return workerUrlOrToken
if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString()
return `${origin}/worker-portal/${encodeURIComponent(workerUrlOrToken)}`
}
8 changes: 6 additions & 2 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ function mockTaskAndActivities(
if (url.endsWith('/complete')) {
return Promise.resolve(jsonResponse({ resource_id: 'T-1', task_id: 'T-1', task_status: 'COMPLETED', task_version: 2 }))
}
if (url.endsWith('/worker-link')) {
return Promise.resolve(jsonResponse({ worker_url: 'worker-token-1', expires_at: '2026-08-07T00:00:00Z' }, { status: 201 }))
}
return Promise.resolve(jsonResponse(task(taskOverrides)))
})
}
Expand Down Expand Up @@ -453,7 +456,7 @@ describe('CaseDetailPage', () => {
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/complete'))).toBe(true)
})

it('reissues the security link and shows the new-link overlay', async () => {
it('issues the security link through the API and shows the real URL', async () => {
const user = userEvent.setup()
mockTaskAndActivities({ status: 'APPROVED' })
renderPage()
Expand All @@ -466,6 +469,7 @@ describe('CaseDetailPage', () => {
await user.click(screen.getByRole('button', { name: '새 링크 생성' }))

expect(screen.getByRole('dialog', { name: '새 링크가 준비되었습니다' })).toBeInTheDocument()
expect(screen.getByText('fowoco.kr/s/7K9P-****-Q2M4')).toBeInTheDocument()
expect(screen.getByText('http://localhost:3000/worker-portal/worker-token-1')).toBeInTheDocument()
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/worker-link'))).toBe(true)
})
})
29 changes: 25 additions & 4 deletions src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { fetchTaskActivities } from '../../api/audit'
import { fetchDocumentReadiness, fetchDocuments, upsertDocumentRequestDraft } from '../../api/documents'
import { ApiError, getErrorMessage } from '../../api/errors'
import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks'
import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks'
import { AgentSourceLabel } from '../../components/ui/AgentSourceLabel/AgentSourceLabel'
import { AgentSummary } from '../../components/ui/AgentSummary/AgentSummary'
import { Button } from '../../components/ui/Button/Button'
Expand Down Expand Up @@ -77,6 +78,8 @@ export function CaseDetailPage() {
const [togglingItemId, setTogglingItemId] = useState<string | null>(null)
const [linkOverlay, setLinkOverlay] = useState<LinkOverlay>('none')
const [lastReissue, setLastReissue] = useState<ReissueSubmission | null>(null)
const [issuedWorkerUrl, setIssuedWorkerUrl] = useState<string | null>(null)
const [issuedExpiresAt, setIssuedExpiresAt] = useState<string | null>(null)
const moreMenuRef = useRef<HTMLDivElement>(null)
const showToast = useToastStore((state) => state.showToast)

Expand Down Expand Up @@ -265,10 +268,26 @@ export function CaseDetailPage() {
setLinkOverlay('reissue')
}

function handleSubmitLinkReissue(submission: ReissueSubmission) {
// TODO(backend): POST /api/v1/tasks/:taskId/worker-link { rotateExisting: true } (feat/7-worker-link, 미병합)
setLastReissue(submission)
setLinkOverlay('reissued')
async function handleSubmitLinkReissue(submission: ReissueSubmission) {
if (!task || actionPending) return
const expiryHours = submission.expiry === '24시간' ? 24 : submission.expiry === '7일' ? 168 : 72
setActionPending(true)
try {
const issued = await issueWorkerLink(
task.task_id,
{ expires_in_hours: expiryHours, rotate_existing: true },
crypto.randomUUID(),
)
setLastReissue(submission)
setIssuedWorkerUrl(resolveWorkerPortalUrl(issued.worker_url, window.location.origin))
setIssuedExpiresAt(issued.expires_at)
setLinkOverlay('reissued')
showToast('보안 링크를 발급했습니다. 아직 자동 전송되지는 않았습니다.')
} catch (error) {
showToast(error instanceof ApiError ? getErrorMessage(error) : '보안 링크를 발급하지 못했습니다.')
} finally {
setActionPending(false)
}
}

if (taskStatus === 'loading') {
Expand Down Expand Up @@ -656,6 +675,8 @@ export function CaseDetailPage() {
<LinkReissuedModal
open={linkOverlay === 'reissued'}
submission={lastReissue}
workerUrl={issuedWorkerUrl}
expiresAt={issuedExpiresAt}
onClose={() => setLinkOverlay('none')}
/>

Expand Down
5 changes: 2 additions & 3 deletions src/pages/CaseDetailPage/overlays/LinkReissueModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react'
import { Modal } from '../../../components/ui/Modal/Modal'
import styles from './overlays.module.css'

const EXPIRIES = ['24시간', '72시간', '7일', '업무 마감일까지']
const EXPIRIES = ['24시간', '72시간', '7일']

export interface ReissueSubmission {
reason: string
Expand All @@ -17,8 +17,7 @@ export interface LinkReissueModalProps {
}

// Figma 08_Prototype Flow · Flow E(보안 링크 만료·재발급) 기준.
// 서버 워커 링크 재발급 API는 아직 main에 병합되지 않아 (feat/7-worker-link 브랜치,
// POST /api/v1/tasks/{taskId}/worker-link { rotateExisting: true }) 데모로만 동작한다.
// POST /api/v1/tasks/{taskId}/worker-link의 rotate_existing 계약을 사용한다.
export function LinkReissueModal({ open, taskTitle, onClose, onSubmit }: LinkReissueModalProps) {
const [reason, setReason] = useState('기존 링크 만료')
const [expiry, setExpiry] = useState(EXPIRIES[1])
Expand Down
23 changes: 16 additions & 7 deletions src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import { Modal } from '../../../components/ui/Modal/Modal'
import { useToastStore } from '../../../store/toastStore'
import { getWorkerRequestStateViewModel } from '../../../view-models/workerRequestStateViewModel'
import type { ReissueSubmission } from './LinkReissueModal'
import styles from './overlays.module.css'

const DEMO_NEW_LINK = 'fowoco.kr/s/7K9P-****-Q2M4'

export interface LinkReissuedModalProps {
open: boolean
submission: ReissueSubmission | null
workerUrl: string | null
expiresAt: string | null
onClose: () => void
}

export function LinkReissuedModal({ open, submission, onClose }: LinkReissuedModalProps) {
export function LinkReissuedModal({
open,
submission,
workerUrl,
expiresAt,
onClose,
}: LinkReissuedModalProps) {
const showToast = useToastStore((state) => state.showToast)
const requestState = getWorkerRequestStateViewModel({})

async function handleCopyLink() {
if (!workerUrl) return
try {
await navigator.clipboard.writeText(DEMO_NEW_LINK)
await navigator.clipboard.writeText(workerUrl)
showToast('링크를 복사했습니다.')
} catch {
showToast('복사에 실패했습니다. 직접 선택해 복사해 주세요.')
Expand All @@ -30,11 +39,11 @@ export function LinkReissuedModal({ open, submission, onClose }: LinkReissuedMod
</p>

<p className={styles.readyBanner}>
기존 링크 폐기 완료 · 새 링크만 유효 · {submission?.expiry}
{requestState.label} · {submission?.expiry} · {expiresAt ? new Date(expiresAt).toLocaleString('ko-KR') : '만료시각 확인 필요'}까지
</p>

<div className={styles.plainRow}>
<span className={styles.plainValue}>{DEMO_NEW_LINK}</span>
<span className={styles.plainValue}>{workerUrl ?? '링크 확인 필요'}</span>
<button type="button" className={styles.textLink} onClick={handleCopyLink}>
복사
</button>
Expand All @@ -44,7 +53,7 @@ export function LinkReissuedModal({ open, submission, onClose }: LinkReissuedMod
<p className={styles.plainValue}>{submission?.reason}</p>

<div className={styles.policyBanner}>
<p className={styles.policyBannerText}>SMS·메신저가 자동으로 발송된 것이 아닙니다.</p>
<p className={styles.policyBannerText}>{requestState.description} SMS·메신저로 직접 전달해 주세요.</p>
</div>

<div className={styles.actionRow}>
Expand Down
21 changes: 15 additions & 6 deletions src/pages/LinkExpiredPage/LinkExpiredPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { MobileShell } from '../../components/mobile/MobileShell'
import styles from './LinkExpiredPage.module.css'

export function LinkExpiredPage() {
function handleCopyRequest() {
// TODO(backend): POST /api/links/:token/reissue-request -> 재발급 요청 문구 생성 후 클립보드 복사
const { token } = useParams()
const [copied, setCopied] = useState(false)

async function handleCopyRequest() {
try {
await navigator.clipboard.writeText('기존 FOWOCO 제출 링크를 사용할 수 없습니다. 새 링크를 보내 주세요.')
setCopied(true)
} catch {
setCopied(false)
}
}

return (
Expand All @@ -18,9 +28,8 @@ export function LinkExpiredPage() {
<div className={styles.reasonCard}>
<p className={styles.reasonTitle}>링크 상태 · 만료</p>
<p className={styles.reasonBody}>
만료시각 2026.07.20 14:30
<br />
기존 링크로는 제출할 수 없습니다.
이 링크는 만료됐거나 새 링크 발급으로 폐기되었습니다.
{token ? <><br />기존 링크로는 제출할 수 없습니다.</> : null}
</p>
</div>

Expand All @@ -31,7 +40,7 @@ export function LinkExpiredPage() {
</div>

<button type="button" className={styles.copyButton} onClick={handleCopyRequest}>
재발급 요청 문구 복사
{copied ? '요청 문구 복사됨' : '재발급 요청 문구 복사'}
</button>

<p className={styles.footnote}>
Expand Down
20 changes: 20 additions & 0 deletions src/pages/LinkRequestPage/LinkRequestPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@
cursor: pointer;
}

.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.responseNotice {
margin: 20px 0 0;
padding: 12px 16px;
border-radius: var(--fowoco-radius-8);
background: var(--fowoco-green-50);
color: var(--fowoco-green-600);
font-size: 13px;
}

.responseError {
margin: 20px 0 0;
color: var(--status-critical);
font-size: 13px;
}

.footnote {
margin: 12px 0 0;
font-size: 12px;
Expand Down
Loading