Project: workspace-doctolib · Branch: HEAD · Mode: ` · **Model:**claude-sonnet-4-6`
Stats: 1 user messages, 12 tool calls, tools used: Bash.
Conversation
Turn 1 — User
Analyze type design for PR https://github.com/doctolib/doctolib/pull/241055 — "feat(FHIR-2195): add archive/unarchive actions to document list".
New and modified types in this PR:
packages/@doctolib/national-health-records/src/types/nhrDocumentActions.ts (NEW)
export type NhrActionModalConfig = {
title: string
body: string
confirmLabel: string
}
packages/@doctolib/national-health-records/src/variants/types.ts (MODIFIED)
interface SearchDocumentsResult {
data: NhrDocument[]
total?: number // was required, now optional — breaking change?
status: NhrResultStatus
message?: string
code?: string
}
NhrResultStatus comes from @doctolib/tpf-national-health-records.
packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.ts
type ModalState = {
doc: NhrDocument
config: NhrActionModalConfig
}
type UseNhrDocumentStatusActionsResult = {
options: DropdownActionOption[]
modalState: ModalState | null
isLoading: boolean
error: string | null
handleConfirm: () => Promise<void>
handleDismiss: () => void
}
packages/@doctolib/national-health-records/src/index.ts (re-exports)
export type { DropdownActionOption } from '@doctolib/tpf-national-health-records-ui'
export type { NhrDocument } from '@doctolib/tpf-national-health-records'
These re-export third-party types through the package's public API.
packages/@doctolib/dmp/src/types/DmpApi.ts (MODIFIED)
// Before:
export type DocumentStatusUpdateProps = {
ins: string
documentUniqueId: string
uuid: string
healthCareSettings: string // removed
getTransactionFrames?: boolean
}
// After:
export type DocumentStatusUpdateProps = {
ins: string
documentUniqueId: string
uuid: string
getTransactionFrames?: boolean
}
Analyze:
1. NhrActionModalConfig — is it a thin anemic type or well-designed? Should body be string | ReactNode?
2. SearchDocumentsResult — making total optional: is this a sound invariant change or does it introduce ambiguity?
3. ModalState — is it a good local type? Should it be exported?
4. Re-exporting DropdownActionOption and NhrDocument via index.ts — pros/cons of this barrel-export pattern for third-party types
5. UseNhrDocumentStatusActionsResult — coupling of concerns (UI state + action handlers in one type)
6. Breaking change in DocumentStatusUpdateProps — removing healthCareSettings and reading from frDmpWorkflow instead
Rate each type on: encapsulation (1-5), invariant expression (1-5), usefulness (1-5). Provide findings in Conventional Comments format.
Turn 1 — Assistant
I'll analyze each type in this PR systematically. Let me first pull the PR details to understand the full context before evaluating the type designs.
Tools used:
Bash:gh pr view 241055 --repo doctolib/doctolib --json title,body,files,additions,deletions 2>/dev/null | head -200
Tool results:
→ result (ok): {"additions":1106,"body":"## Why\n\nFHIR-2195 — Wires the Archive/Unarchive actions into the Document List UI by providing the action configuration the dropdown consumes.\n\n⚠️ Depends on #241005 (DMP API change) — do not merge until that PR lands first. GitHub will automatically retarget this PR to master once #241005 merges.\n\n## How\n\nNew hook — useNhrDocumentStatusActions\nManages the full state machine: which options are visib
…(truncated, 5881 more chars)
Now let me read the actual source files to ground the analysis in real code rather than just the PR description.
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 200 'nhrDocumentActions.ts'
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 80 'useNhrDocumentStatusActions.ts' | head -120
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/types/nhrDocumentActions.ts b/packages/@doctolib/national-health-records/src/types/nhrDocumentActions.ts new file mode 100644 index 0000000000000..abc1997c3fefa --- /dev/null +++ b/packages/@doctolib/national-health-records/src/types/nhrDocumentActions.ts @@ -0,0 +1,5 @@ +export type NhrActionModalConfig = { + title: string + body: string + confirmLabel: string +} diff --git a/packages/@doctolib/national-health-records/src/variants/d …(truncated, 8206 more chars)
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.ts b/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.ts new file mode 100644 index 0000000000000..318806a23a573 --- /dev/null +++ b/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.ts @@ -0,0 +1,101 @@ +import { useCallback, useMemo, useState } from 'react' +import type { NhrDocument } from '@doctolib/tpf-national-health-records' +import t …(truncated, 3048 more chars)
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 200 'useNhrDocumentStatusActions.ts' | tail -120
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 60 'DmpApi.ts'
Tool results:
→ result (ok): + try { + await nhrOperations.updateDocumentMetadata({ + patient: toNhrPatientInformation(patient.ins), + params: { payloads: [{ id: modalState.doc.id, propertyName: 'status' }] }, + }) + setModalState(null) + await invalidateNhrDocuments() + } catch { + setError(i18n.t('national_health_records.document_metadata_actions.dmp.errors.action_failed')) + } finally { + setIsLoading(false) + } + } + + const handleDismiss = () => { + if (isLoa …(truncated, 4607 more chars)
Tool results:
→ result (ok): diff --git a/packages/@doctolib/dmp/src/business/DmpApi.ts b/packages/@doctolib/dmp/src/business/DmpApi.ts index b84e8653cb2b0..c272c11b501ca 100644 --- a/packages/@doctolib/dmp/src/business/DmpApi.ts +++ b/packages/@doctolib/dmp/src/business/DmpApi.ts @@ -342,7 +342,9 @@ export class DmpApiImpl implements DmpApi { }
async updateDocumentStatus(props: DocumentStatusUpdateProps): Promise<void> { - const { ins, documentUniqueId: uniqueId, uuid, healthCareSettings, getTransactionFrames } …(truncated, 3173 more chars)
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 100 'index.ts'
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 80 'DmpNhrAdapter.ts'
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/index.ts b/packages/@doctolib/national-health-records/src/index.ts index c1dd5fa542c9f..8ba4daba0c917 100644 --- a/packages/@doctolib/national-health-records/src/index.ts +++ b/packages/@doctolib/national-health-records/src/index.ts @@ -4,3 +4,10 @@ export { useNhrDocumentListVariant } from './variants/useNhrDocumentListVariant' export type { DocumentSearchOptions, NhrDocumentListVariant } from './variants/types' export { actionsBuild …(truncated, 5001 more chars)
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/business/adapters/DmpNhrAdapter.ts b/packages/@doctolib/national-health-records/src/business/adapters/DmpNhrAdapter.ts index 211a101163052..edada9756ee93 100644 --- a/packages/@doctolib/national-health-records/src/business/adapters/DmpNhrAdapter.ts +++ b/packages/@doctolib/national-health-records/src/business/adapters/DmpNhrAdapter.ts @@ -3,11 +3,10 @@ import type { DmpAuthor, DmpDocument, DmpDocumentContent, - DocumentStatusU …(truncated, 2812 more chars)
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 50 'NhrDocumentActionDialog.tsx'
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 40 'useNhrDocumentStatusActions.test.ts' | head -80
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/components/NhrDocumentActionDialog/NhrDocumentActionDialog.tsx b/packages/@doctolib/national-health-records/src/components/NhrDocumentActionDialog/NhrDocumentActionDialog.tsx new file mode 100644 index 0000000000000..55e06847bc397 --- /dev/null +++ b/packages/@doctolib/national-health-records/src/components/NhrDocumentActionDialog/NhrDocumentActionDialog.tsx @@ -0,0 +1,32 @@ +import { Alert } from '@doctolib/design-system/Alert' +import …(truncated, 1702 more chars)
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.test.ts b/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.test.ts new file mode 100644 index 0000000000000..ffc2a3a3b3ce4 --- /dev/null +++ b/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.test.ts @@ -0,0 +1,191 @@ +import { act, renderHook } from '@testing-library/react' +import type { Patient } from 'monolith_DEPRECATED/types/doctor/ap …(truncated, 1498 more chars)
Now let me look at how handleConfirm uses the result and whether there's silent failure handling:
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 60 'useNhrDocumentStatusActions.test.ts' | tail -120
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -B5 -A 30 'result.status'
Tool results:
→ result (ok): diff --git a/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.test.ts b/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.test.ts new file mode 100644 index 0000000000000..ffc2a3a3b3ce4 --- /dev/null +++ b/packages/@doctolib/national-health-records/src/hooks/useNhrDocumentStatusActions.test.ts @@ -0,0 +1,191 @@ +import { act, renderHook } from '@testing-library/react' +import type { Patient } from 'monolith_DEPRECATED/types/doctor/ap …(truncated, 2539 more chars)
Tool results:
→ result (ok): (Bash completed with no output)
Tools used:
Bash:gh pr diff 241055 --repo doctolib/doctolib 2>/dev/null | grep -A 200 'describe.*handleConfirm'
Tool results:
→ result (ok): + describe('handleConfirm', () => { + it('calls updateDocumentMetadata with propertyName status for archive', async () => { + mockUpdateDocumentMetadata.mockResolvedValue({ status: 'success', result: { payloads: [] } }) + const { result } = renderHook(() => useNhrDocumentStatusActions({ patient })) + const archiveOption = findOption(result.current.options, 'archive') + + act(() => archiveOption.onClick(activeDoc)) + await act(async () => { + await result.curre …(truncated, 7592 more chars)
I now have a complete picture of every type involved. Here is the full analysis in Conventional Comments format.
Type: NhrActionModalConfig
Invariants Identified
- All three string fields (
title,body,confirmLabel) must be non-empty for the dialog to be presentable to the user. - The type is used exclusively for rendering a confirmation dialog, so it implicitly encodes the concept "everything needed to show a single dialog".
Ratings
- Encapsulation: 3/5 — It is a plain data bag exported via
index.tswith no enforcement of any kind. Any caller can construct an instance with empty strings and the type will accept it. - Invariant Expression: 2/5 — Nothing in the type communicates that empty strings are invalid, nor that the fields have a text-content constraint.
- Usefulness: 4/5 — Exactly the right size for its role: decouples the business logic of "what the dialog says" from the component that renders it. Keeping it simple is appropriate here.
Strengths
The separation between NhrActionModalConfig (what to say) and NhrDocumentActionDialog (how to render it) is clean and testable. archiveConfig() and unarchiveConfig() as factory functions that return this type are idiomatic.
Concerns
suggestion(invariants): Empty string is structurally valid but semantically broken. A dialog with title: '' would render invisibly. TypeScript's branded type or a newtype wrapper type NonEmptyString = string & { readonly _brand: unique symbol } costs very little and prevents this class of bug at construction. If that feels heavy, at minimum the factory functions (archiveConfig, unarchiveConfig) should be the only construction sites and the type itself should remain unexported, relying on the factories as the enforced entry point.
question(design): The body field is string, but the component renders {config.body} as a React child alongside an optional <Alert>. If a future action needs richer dialog body content (e.g. a warning with a list of affected documents), body: string cannot express it. Was string | React.ReactNode considered and rejected? If the intent is strictly i18n-translated plain text forever, a comment documenting that constraint would prevent a future PR from widening this silently.
Type: SearchDocumentsResult
Invariants Identified
- Before this PR:
{ data: NhrDocument[], total: number }—totalwas always present and presumably equal todata.lengthin the implementations. - After this PR:
totalis optional andstatus: NhrResultStatusis required. The implicit invariant is thatstatus === 'success'impliesdataandtotalare meaningful, whilestatus === 'skipped'impliesdata === []andtotal === 0. messageandcodecarry supplemental diagnostic information and are meaningful only whenstatus !== 'success'.
Ratings
- Encapsulation: 2/5 — This is an internal interface (not exported from
index.ts), which is good. However, the fieldsmessageandcodeare not guarded behind the status discriminant at the type level. - Invariant Expression: 2/5 — The relationship between
statusand the other fields is entirely implicit. A reader cannot tell from the type alone thatmessageis only meaningful for non-success paths, or thattotalandstatushave a correlation. - Usefulness: 3/5 — Adding
statusis justified and prevents silent data loss. Makingtotaloptional, however, weakens a previously strong guarantee without recovering that strength elsewhere.
Concerns
issue(type-safety): The total?: number change is the most significant invariant regression in this PR. The previous type guaranteed callers would always have a count. Now every consumer of SearchDocumentsResult must handle total === undefined. Looking at the actual implementations in useSearchDocuments.ts, total is always set alongside data — there is no code path that produces a SearchDocumentsResult where total is genuinely absent. The optionality was added to satisfy the adapter layer (DmpNhrAdapter returns NhrDocumentSearchResult which may not include total), but this is an internal mapping concern that should be resolved at the adapter boundary, not by loosening the contract of SearchDocumentsResult.
suggestion(discriminated-union): The status / message / code combination is a classic discriminated union waiting to be expressed properly:
type SearchDocumentsResult =
| { status: 'success'; data: NhrDocument[]; total: number }
| { status: 'skipped'; data: []; total: 0; message: string }
| { status: 'error'; data: []; message: string; code?: string }
This makes the invariants structurally enforced: a status: 'success' result cannot have an absent total, and message only exists on non-success branches. The PR description explicitly calls out checking result.status to surface silent failures — a discriminated union makes those checks exhaustive and eliminates the ambiguous optional fields.
nitpick(optionality): Even without a full discriminated union, total should remain required (total: number) since every call site in this PR provides it. The undefined case is unused and the optionality adds cognitive overhead for zero benefit.
Type: ModalState
Invariants Identified
docis the document targeted by the pending action.configfully describes what the confirmation dialog will render.- Together they encode "an action is pending on this specific document with this UI configuration".
nullencodes "no action is pending" (viauseState<ModalState | null>(null)).
Ratings
- Encapsulation: 5/5 — Correctly file-private. The hook is the only place that reads or writes it, and only the resolved
configand loading/error state are exposed to the component. - Invariant Expression: 4/5 — The
doc + configpairing is semantically coherent. The null / non-null distinction for "pending vs idle" is idiomatic React. - Usefulness: 5/5 — Collocating the document and dialog config prevents the doc from being lost between "open modal" and "confirm". This is a genuine safety guarantee.
Strengths
Keeping ModalState unexported is the right call. The component (NhrDocumentActionDialog) receives only config — it deliberately does not receive doc, which prevents the dialog layer from accidentally acting on the document directly. The doc stays inside the hook until handleConfirm is invoked. This is good encapsulation.
Concerns
suggestion(invariant-gap): handleConfirm ignores the result status from updateDocumentMetadata. Looking at the implementation: it resolves the promise unconditionally and closes the modal on any non-thrown return. But updateDocumentMetadata can return { status: 'error', ... } without throwing — the DmpNhrAdapter explicitly constructs { status: 'error', message: ... } as a successful (non-thrown) resolution. The catch block in handleConfirm will never fire for those cases. The dialog will close, invalidateNhrDocuments will be called, and the user will see no error even though the archive/unarchive failed. This is the silent failure the PR description says it addresses, but the hook implementation does not actually handle it.
// current — silent failure on adapter-level errors
try {
await nhrOperations.updateDocumentMetadata(...)
setModalState(null)
await invalidateNhrDocuments()
} catch { ... }
// required
const result = await nhrOperations.updateDocumentMetadata(...)
const failed = result.result?.payloads.some(p => p.status === 'error')
if (failed) {
setError(i18n.t('...errors.action_failed'))
return
}
setModalState(null)
await invalidateNhrDocuments()
Re-exports in index.ts
Concern: re-exporting third-party types as package surface area
issue(coupling): `export type { DropdownActionOption } from '@doctolib/tpf-national-health-r …(truncated, 6592 more chars)