FHIR, API, datamodel
This commit is contained in:
@@ -8,28 +8,69 @@ import Image from 'next/image'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { MDXComponents } from 'mdx/types'
|
import type { MDXComponents } from 'mdx/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate slug from heading text for anchor links
|
||||||
|
*/
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^\w\-]+/g, '')
|
||||||
|
.replace(/\-\-+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from React children
|
||||||
|
*/
|
||||||
|
function getTextContent(children: React.ReactNode): string {
|
||||||
|
if (typeof children === 'string') return children
|
||||||
|
if (Array.isArray(children)) return children.map(getTextContent).join('')
|
||||||
|
if (children && typeof children === 'object' && 'props' in children) {
|
||||||
|
return getTextContent(children.props.children)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
export const mdxComponents: MDXComponents = {
|
export const mdxComponents: MDXComponents = {
|
||||||
// Headings with anchor links
|
// Headings with anchor links
|
||||||
h1: ({ children, ...props }) => (
|
h1: ({ children, ...props }) => {
|
||||||
<h1 className="text-4xl font-bold text-slate-900 mt-8 mb-4" {...props}>
|
const text = getTextContent(children)
|
||||||
|
const id = slugify(text)
|
||||||
|
return (
|
||||||
|
<h1 id={id} className="text-4xl font-bold text-slate-900 mt-8 mb-4 scroll-mt-20" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</h1>
|
</h1>
|
||||||
),
|
)
|
||||||
h2: ({ children, ...props }) => (
|
},
|
||||||
<h2 className="text-3xl font-bold text-slate-900 mt-8 mb-4 border-b border-slate-200 pb-2" {...props}>
|
h2: ({ children, ...props }) => {
|
||||||
|
const text = getTextContent(children)
|
||||||
|
const id = slugify(text)
|
||||||
|
return (
|
||||||
|
<h2 id={id} className="text-3xl font-bold text-slate-900 mt-8 mb-4 border-b border-slate-200 pb-2 scroll-mt-20" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
),
|
)
|
||||||
h3: ({ children, ...props }) => (
|
},
|
||||||
<h3 className="text-2xl font-semibold text-slate-900 mt-6 mb-3" {...props}>
|
h3: ({ children, ...props }) => {
|
||||||
|
const text = getTextContent(children)
|
||||||
|
const id = slugify(text)
|
||||||
|
return (
|
||||||
|
<h3 id={id} className="text-2xl font-semibold text-slate-900 mt-6 mb-3 scroll-mt-20" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
),
|
)
|
||||||
h4: ({ children, ...props }) => (
|
},
|
||||||
<h4 className="text-xl font-semibold text-slate-900 mt-4 mb-2" {...props}>
|
h4: ({ children, ...props }) => {
|
||||||
|
const text = getTextContent(children)
|
||||||
|
const id = slugify(text)
|
||||||
|
return (
|
||||||
|
<h4 id={id} className="text-xl font-semibold text-slate-900 mt-4 mb-2 scroll-mt-20" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</h4>
|
</h4>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
|
|
||||||
// Paragraphs
|
// Paragraphs
|
||||||
p: ({ children, ...props }) => (
|
p: ({ children, ...props }) => (
|
||||||
@@ -67,7 +108,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
img: ({ src, alt, ...props }) => {
|
img: ({ src, alt, width, height, ...props }) => {
|
||||||
if (!src) return null
|
if (!src) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,8 +117,8 @@ export const mdxComponents: MDXComponents = {
|
|||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt || ''}
|
alt={alt || ''}
|
||||||
width={1200}
|
width={Number(width) || 1200}
|
||||||
height={675}
|
height={Number(height) || 675}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReleaseSidebar } from './release-sidebar'
|
import { ReleaseSidebar } from './release-sidebar'
|
||||||
import type { ReleaseNote, GroupMetadata, CategoryMetadata } from '@/lib/mdx/documentatie'
|
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
|
||||||
|
|
||||||
interface ReleaseSidebarWrapperProps {
|
interface ReleaseSidebarWrapperProps {
|
||||||
releases: ReleaseNote[]
|
releases: ReleaseNote[]
|
||||||
@@ -15,6 +15,7 @@ interface ReleaseSidebarWrapperProps {
|
|||||||
groups: GroupMetadata[]
|
groups: GroupMetadata[]
|
||||||
categories: CategoryMetadata[]
|
categories: CategoryMetadata[]
|
||||||
}
|
}
|
||||||
|
tocMap: Record<string, TocItem[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReleaseSidebarWrapper(props: ReleaseSidebarWrapperProps) {
|
export default function ReleaseSidebarWrapper(props: ReleaseSidebarWrapperProps) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Link from 'next/link'
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { ChevronRight, ChevronDown, ChevronLeft, X } from 'lucide-react'
|
import { ChevronRight, ChevronDown, ChevronLeft, X } from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { ReleaseNote, GroupMetadata, CategoryMetadata } from '@/lib/mdx/documentatie'
|
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
|
||||||
|
|
||||||
interface ReleaseSidebarProps {
|
interface ReleaseSidebarProps {
|
||||||
releases: ReleaseNote[]
|
releases: ReleaseNote[]
|
||||||
@@ -19,13 +19,15 @@ interface ReleaseSidebarProps {
|
|||||||
groups: GroupMetadata[]
|
groups: GroupMetadata[]
|
||||||
categories: CategoryMetadata[]
|
categories: CategoryMetadata[]
|
||||||
}
|
}
|
||||||
|
tocMap: Record<string, TocItem[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
|
export function ReleaseSidebar({ releases, metadata, tocMap }: ReleaseSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
|
||||||
foundation: true,
|
foundation: true,
|
||||||
|
architecture: true,
|
||||||
features: true,
|
features: true,
|
||||||
infrastructure: true,
|
infrastructure: true,
|
||||||
bugs: true,
|
bugs: true,
|
||||||
@@ -143,10 +145,11 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
|
|||||||
<div className="space-y-1 ml-2">
|
<div className="space-y-1 ml-2">
|
||||||
{groupReleases.map((release) => {
|
{groupReleases.map((release) => {
|
||||||
const isActive = pathname === `/documentatie/${release.slug}`
|
const isActive = pathname === `/documentatie/${release.slug}`
|
||||||
|
const toc = tocMap[release.slug] || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={release.slug}>
|
||||||
<Link
|
<Link
|
||||||
key={release.slug}
|
|
||||||
href={`/documentatie/${release.slug}`}
|
href={`/documentatie/${release.slug}`}
|
||||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
|
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
|
||||||
isActive
|
isActive
|
||||||
@@ -162,6 +165,24 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
|
|||||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
|
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
|
||||||
}`} />
|
}`} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Table of Contents for active page */}
|
||||||
|
{isActive && toc.length > 0 && (
|
||||||
|
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
|
||||||
|
{toc.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={`#${item.id}`}
|
||||||
|
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
|
||||||
|
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,10 +241,11 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
|
|||||||
<div className="space-y-1 ml-2">
|
<div className="space-y-1 ml-2">
|
||||||
{groupReleases.map((release) => {
|
{groupReleases.map((release) => {
|
||||||
const isActive = pathname === `/documentatie/${release.slug}`
|
const isActive = pathname === `/documentatie/${release.slug}`
|
||||||
|
const toc = tocMap[release.slug] || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={release.slug}>
|
||||||
<Link
|
<Link
|
||||||
key={release.slug}
|
|
||||||
href={`/documentatie/${release.slug}`}
|
href={`/documentatie/${release.slug}`}
|
||||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${isActive
|
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${isActive
|
||||||
? 'bg-teal-50 text-teal-700'
|
? 'bg-teal-50 text-teal-700'
|
||||||
@@ -237,6 +259,24 @@ export function ReleaseSidebar({ releases, metadata }: ReleaseSidebarProps) {
|
|||||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
|
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
|
||||||
}`} />
|
}`} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Table of Contents for active page */}
|
||||||
|
{isActive && toc.length > 0 && (
|
||||||
|
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
|
||||||
|
{toc.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={`#${item.id}`}
|
||||||
|
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
|
||||||
|
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { getAllReleases, getCategoryMetadata, type ReleaseNote } from '@/lib/mdx/documentatie'
|
import { getAllReleases, getCategoryMetadata, extractHeadings, type ReleaseNote, type TocItem } from '@/lib/mdx/documentatie'
|
||||||
import ReleaseSidebarWrapper from './components/release-sidebar-wrapper'
|
import ReleaseSidebarWrapper from './components/release-sidebar-wrapper'
|
||||||
|
|
||||||
interface ReleasesLayoutProps {
|
interface ReleasesLayoutProps {
|
||||||
@@ -15,10 +15,17 @@ interface ReleasesLayoutProps {
|
|||||||
export default async function ReleasesLayout({ children }: ReleasesLayoutProps) {
|
export default async function ReleasesLayout({ children }: ReleasesLayoutProps) {
|
||||||
let releases: ReleaseNote[] = []
|
let releases: ReleaseNote[] = []
|
||||||
let metadata = { groups: [], categories: [] } as Awaited<ReturnType<typeof getCategoryMetadata>>
|
let metadata = { groups: [], categories: [] } as Awaited<ReturnType<typeof getCategoryMetadata>>
|
||||||
|
let tocMap: Record<string, TocItem[]> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
releases = await getAllReleases()
|
releases = await getAllReleases()
|
||||||
metadata = await getCategoryMetadata()
|
metadata = await getCategoryMetadata()
|
||||||
|
|
||||||
|
// Extract headings for each release
|
||||||
|
tocMap = releases.reduce((acc, release) => {
|
||||||
|
acc[release.slug] = extractHeadings(release.content)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, TocItem[]>)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading releases or metadata:', error)
|
console.error('Error loading releases or metadata:', error)
|
||||||
}
|
}
|
||||||
@@ -30,7 +37,7 @@ export default async function ReleasesLayout({ children }: ReleasesLayoutProps)
|
|||||||
<div className="max-w-[1600px] mx-auto">
|
<div className="max-w-[1600px] mx-auto">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Sidebar - collapsible on mobile (48px collapsed), fixed on desktop (320px) */}
|
{/* Sidebar - collapsible on mobile (48px collapsed), fixed on desktop (320px) */}
|
||||||
<ReleaseSidebarWrapper releases={releases} metadata={metadata} />
|
<ReleaseSidebarWrapper releases={releases} metadata={metadata} tocMap={tocMap} />
|
||||||
|
|
||||||
{/* Main Content - margin for collapsed sidebar on mobile, fixed sidebar on desktop */}
|
{/* Main Content - margin for collapsed sidebar on mobile, fixed sidebar on desktop */}
|
||||||
<div className="flex-1 ml-12 lg:ml-80">
|
<div className="flex-1 ml-12 lg:ml-80">
|
||||||
|
|||||||
163
app/api/fhir/Patient/[id]/route.ts
Normal file
163
app/api/fhir/Patient/[id]/route.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Patient API - Instance Endpoints
|
||||||
|
* GET /api/fhir/Patient/[id] - Read patient
|
||||||
|
* PUT /api/fhir/Patient/[id] - Update patient
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
dbPatientToFHIR,
|
||||||
|
fhirPatientToDB,
|
||||||
|
createOperationOutcome,
|
||||||
|
validateFHIRResource,
|
||||||
|
} from '@/lib/fhir';
|
||||||
|
import type { FHIRPatient } from '@/lib/fhir';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fhir/Patient/[id]
|
||||||
|
* Returns a single FHIR Patient resource
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
// Query patient by ID
|
||||||
|
const { data: patient, error } = await supabaseAdmin
|
||||||
|
.from('patients')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !patient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'not-found',
|
||||||
|
`Patient with id ${id} not found`
|
||||||
|
),
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to FHIR resource
|
||||||
|
const fhirPatient = dbPatientToFHIR(patient);
|
||||||
|
|
||||||
|
return NextResponse.json(fhirPatient);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/fhir/Patient/[id]
|
||||||
|
* Updates a patient from FHIR Patient resource
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
const fhirPatient: FHIRPatient = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const validation = validateFHIRResource(fhirPatient, [
|
||||||
|
'resourceType',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthDate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'invalid', validation.errors.join(', ')),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify resourceType
|
||||||
|
if (fhirPatient.resourceType !== 'Patient') {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'invalid',
|
||||||
|
`Expected resourceType "Patient", got "${fhirPatient.resourceType}"`
|
||||||
|
),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ID matches
|
||||||
|
if (fhirPatient.id && fhirPatient.id !== id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'invalid',
|
||||||
|
`ID in URL (${id}) does not match ID in resource (${fhirPatient.id})`
|
||||||
|
),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if patient exists
|
||||||
|
const { data: existingPatient, error: fetchError } = await supabaseAdmin
|
||||||
|
.from('patients')
|
||||||
|
.select('id')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError || !existingPatient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'not-found',
|
||||||
|
`Patient with id ${id} not found`
|
||||||
|
),
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to database format
|
||||||
|
const patientUpdate = fhirPatientToDB(fhirPatient);
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
const { data: updatedPatient, error: updateError } = await supabaseAdmin
|
||||||
|
.from('patients')
|
||||||
|
.update(patientUpdate)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'processing', updateError.message),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated patient as FHIR resource
|
||||||
|
const fhirResponse = dbPatientToFHIR(updatedPatient);
|
||||||
|
|
||||||
|
return NextResponse.json(fhirResponse);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/api/fhir/Patient/route.ts
Normal file
148
app/api/fhir/Patient/route.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Patient API - Collection Endpoints
|
||||||
|
* GET /api/fhir/Patient - List patients
|
||||||
|
* POST /api/fhir/Patient - Create patient
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
dbPatientToFHIR,
|
||||||
|
fhirPatientToDB,
|
||||||
|
createOperationOutcome,
|
||||||
|
validateFHIRResource,
|
||||||
|
} from '@/lib/fhir';
|
||||||
|
import type { FHIRBundle, FHIRPatient } from '@/lib/fhir';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fhir/Patient
|
||||||
|
* Returns a FHIR Bundle with searchset of patients
|
||||||
|
* Supports query parameters: name, identifier, birthdate
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
let query = supabaseAdmin.from('patients').select('*');
|
||||||
|
|
||||||
|
// Search by name (family or given)
|
||||||
|
const name = searchParams.get('name');
|
||||||
|
if (name) {
|
||||||
|
query = query.or(
|
||||||
|
`name_family.ilike.%${name}%,name_given.cs.{${name}}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by identifier (BSN)
|
||||||
|
const identifier = searchParams.get('identifier');
|
||||||
|
if (identifier) {
|
||||||
|
query = query.eq('identifier_bsn', identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by birth date
|
||||||
|
const birthdate = searchParams.get('birthdate');
|
||||||
|
if (birthdate) {
|
||||||
|
query = query.eq('birth_date', birthdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const { data: patients, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'processing', error.message),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to FHIR Bundle
|
||||||
|
const bundle: FHIRBundle<FHIRPatient> = {
|
||||||
|
resourceType: 'Bundle',
|
||||||
|
type: 'searchset',
|
||||||
|
total: patients?.length || 0,
|
||||||
|
entry: patients?.map((patient) => ({
|
||||||
|
resource: dbPatientToFHIR(patient),
|
||||||
|
})) || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(bundle);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/fhir/Patient
|
||||||
|
* Creates a new patient from FHIR Patient resource
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const fhirPatient: FHIRPatient = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const validation = validateFHIRResource(fhirPatient, [
|
||||||
|
'resourceType',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthDate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'invalid', validation.errors.join(', ')),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify resourceType
|
||||||
|
if (fhirPatient.resourceType !== 'Patient') {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'invalid',
|
||||||
|
`Expected resourceType "Patient", got "${fhirPatient.resourceType}"`
|
||||||
|
),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to database format
|
||||||
|
const patientInsert = fhirPatientToDB(fhirPatient);
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
const { data: newPatient, error } = await supabaseAdmin
|
||||||
|
.from('patients')
|
||||||
|
.insert(patientInsert)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'processing', error.message),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return created patient as FHIR resource
|
||||||
|
const fhirResponse = dbPatientToFHIR(newPatient);
|
||||||
|
|
||||||
|
return NextResponse.json(fhirResponse, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/fhir/Practitioner/[id]/route.ts
Normal file
56
app/api/fhir/Practitioner/[id]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Practitioner API - Instance Endpoints
|
||||||
|
* GET /api/fhir/Practitioner/[id] - Read practitioner
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
dbPractitionerToFHIR,
|
||||||
|
createOperationOutcome,
|
||||||
|
} from '@/lib/fhir';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fhir/Practitioner/[id]
|
||||||
|
* Returns a single FHIR Practitioner resource
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
// Query practitioner by ID
|
||||||
|
const { data: practitioner, error } = await supabaseAdmin
|
||||||
|
.from('practitioners')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !practitioner) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'not-found',
|
||||||
|
`Practitioner with id ${id} not found`
|
||||||
|
),
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to FHIR resource
|
||||||
|
const fhirPractitioner = dbPractitionerToFHIR(practitioner);
|
||||||
|
|
||||||
|
return NextResponse.json(fhirPractitioner);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/fhir/Practitioner/route.ts
Normal file
142
app/api/fhir/Practitioner/route.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Practitioner API - Collection Endpoints
|
||||||
|
* GET /api/fhir/Practitioner - List practitioners
|
||||||
|
* POST /api/fhir/Practitioner - Create practitioner
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
dbPractitionerToFHIR,
|
||||||
|
fhirPractitionerToDB,
|
||||||
|
createOperationOutcome,
|
||||||
|
validateFHIRResource,
|
||||||
|
} from '@/lib/fhir';
|
||||||
|
import type { FHIRBundle, FHIRPractitioner } from '@/lib/fhir';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fhir/Practitioner
|
||||||
|
* Returns a FHIR Bundle with searchset of practitioners
|
||||||
|
* Supports query parameters: name, identifier
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
let query = supabaseAdmin.from('practitioners').select('*');
|
||||||
|
|
||||||
|
// Search by name (family or given)
|
||||||
|
const name = searchParams.get('name');
|
||||||
|
if (name) {
|
||||||
|
query = query.or(
|
||||||
|
`name_family.ilike.%${name}%,name_given.cs.{${name}}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by identifier (BIG or AGB)
|
||||||
|
const identifier = searchParams.get('identifier');
|
||||||
|
if (identifier) {
|
||||||
|
query = query.or(
|
||||||
|
`identifier_big.eq.${identifier},identifier_agb.eq.${identifier}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const { data: practitioners, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'processing', error.message),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to FHIR Bundle
|
||||||
|
const bundle: FHIRBundle<FHIRPractitioner> = {
|
||||||
|
resourceType: 'Bundle',
|
||||||
|
type: 'searchset',
|
||||||
|
total: practitioners?.length || 0,
|
||||||
|
entry: practitioners?.map((practitioner) => ({
|
||||||
|
resource: dbPractitionerToFHIR(practitioner),
|
||||||
|
})) || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(bundle);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/fhir/Practitioner
|
||||||
|
* Creates a new practitioner from FHIR Practitioner resource
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const fhirPractitioner: FHIRPractitioner = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const validation = validateFHIRResource(fhirPractitioner, [
|
||||||
|
'resourceType',
|
||||||
|
'name',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'invalid', validation.errors.join(', ')),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify resourceType
|
||||||
|
if (fhirPractitioner.resourceType !== 'Practitioner') {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'invalid',
|
||||||
|
`Expected resourceType "Practitioner", got "${fhirPractitioner.resourceType}"`
|
||||||
|
),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to database format
|
||||||
|
const practitionerInsert = fhirPractitionerToDB(fhirPractitioner);
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
const { data: newPractitioner, error } = await supabaseAdmin
|
||||||
|
.from('practitioners')
|
||||||
|
.insert(practitionerInsert)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome('error', 'processing', error.message),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return created practitioner as FHIR resource
|
||||||
|
const fhirResponse = dbPractitionerToFHIR(newPractitioner);
|
||||||
|
|
||||||
|
return NextResponse.json(fhirResponse, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
createOperationOutcome(
|
||||||
|
'error',
|
||||||
|
'exception',
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/epd/patients/[id]/page.tsx
Normal file
46
app/epd/patients/[id]/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { getPatient } from '../actions';
|
||||||
|
import { PatientForm } from '../components/patient-form';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default async function PatientDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const patient = await getPatient(id);
|
||||||
|
|
||||||
|
const name = patient.name?.[0];
|
||||||
|
const fullName = [
|
||||||
|
...(name?.prefix || []),
|
||||||
|
...(name?.given || []),
|
||||||
|
name?.family,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Header with back button */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href="/epd/patients"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span>Terug naar patiënten</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Patiënt bewerken</h1>
|
||||||
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
|
{fullName} - ID: {patient.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patient Form */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||||
|
<PatientForm patient={patient} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
app/epd/patients/actions.ts
Normal file
117
app/epd/patients/actions.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient CRUD Server Actions (FHIR-based)
|
||||||
|
*
|
||||||
|
* Server-side actions that interact with FHIR Patient API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import type { FHIRPatient, FHIRBundle } from '@/lib/fhir';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all patients via FHIR API
|
||||||
|
*/
|
||||||
|
export async function getPatients(filters?: {
|
||||||
|
search?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const url = new URL(`${API_BASE_URL}/api/fhir/Patient`);
|
||||||
|
|
||||||
|
if (filters?.search) {
|
||||||
|
url.searchParams.set('name', filters.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch patients: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle: FHIRBundle<FHIRPatient> = await response.json();
|
||||||
|
return bundle.entry?.map((entry) => entry.resource).filter(Boolean) as FHIRPatient[] || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching patients:', error);
|
||||||
|
throw new Error('Failed to fetch patients');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single patient by ID via FHIR API
|
||||||
|
*/
|
||||||
|
export async function getPatient(id: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient/${id}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch patient: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient: FHIRPatient = await response.json();
|
||||||
|
return patient;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching patient:', error);
|
||||||
|
throw new Error('Failed to fetch patient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new patient via FHIR API
|
||||||
|
*/
|
||||||
|
export async function createPatient(fhirPatient: FHIRPatient) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(fhirPatient),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.issue?.[0]?.diagnostics || 'Failed to create patient');
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient: FHIRPatient = await response.json();
|
||||||
|
revalidatePath('/epd/patients');
|
||||||
|
return patient;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating patient:', error);
|
||||||
|
throw error instanceof Error ? error : new Error('Failed to create patient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing patient via FHIR API
|
||||||
|
*/
|
||||||
|
export async function updatePatient(id: string, fhirPatient: FHIRPatient) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/fhir/Patient/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...fhirPatient, id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.issue?.[0]?.diagnostics || 'Failed to update patient');
|
||||||
|
}
|
||||||
|
|
||||||
|
const patient: FHIRPatient = await response.json();
|
||||||
|
revalidatePath('/epd/patients');
|
||||||
|
revalidatePath(`/epd/patients/${id}`);
|
||||||
|
return patient;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating patient:', error);
|
||||||
|
throw error instanceof Error ? error : new Error('Failed to update patient');
|
||||||
|
}
|
||||||
|
}
|
||||||
254
app/epd/patients/components/patient-form.tsx
Normal file
254
app/epd/patients/components/patient-form.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient Form Component (FHIR-based)
|
||||||
|
* Form for creating/editing patients using FHIR format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Save, Loader2 } from 'lucide-react';
|
||||||
|
import { createPatient, updatePatient } from '../actions';
|
||||||
|
import type { FHIRPatient } from '@/lib/fhir';
|
||||||
|
|
||||||
|
interface PatientFormProps {
|
||||||
|
patient?: FHIRPatient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientForm({ patient }: PatientFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const existingName = patient?.name?.[0];
|
||||||
|
const existingBsn = patient?.identifier?.find(
|
||||||
|
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||||
|
)?.value;
|
||||||
|
const existingPhone = patient?.telecom?.find((t) => t.system === 'phone')?.value;
|
||||||
|
const existingEmail = patient?.telecom?.find((t) => t.system === 'email')?.value;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
// Build FHIR Patient resource
|
||||||
|
const fhirPatient: FHIRPatient = {
|
||||||
|
resourceType: 'Patient',
|
||||||
|
identifier: [
|
||||||
|
{
|
||||||
|
system: 'http://fhir.nl/fhir/NamingSystem/bsn',
|
||||||
|
value: formData.get('bsn') as string,
|
||||||
|
use: 'official' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: [
|
||||||
|
{
|
||||||
|
use: 'official' as const,
|
||||||
|
family: formData.get('family') as string,
|
||||||
|
given: [formData.get('given') as string].filter(Boolean),
|
||||||
|
prefix: formData.get('prefix')
|
||||||
|
? [(formData.get('prefix') as string)]
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gender: formData.get('gender') as 'male' | 'female' | 'other' | 'unknown',
|
||||||
|
birthDate: formData.get('birthDate') as string,
|
||||||
|
telecom: [
|
||||||
|
formData.get('phone')
|
||||||
|
? {
|
||||||
|
system: 'phone' as const,
|
||||||
|
value: formData.get('phone') as string,
|
||||||
|
use: 'mobile' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
formData.get('email')
|
||||||
|
? {
|
||||||
|
system: 'email' as const,
|
||||||
|
value: formData.get('email') as string,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
].filter((t): t is NonNullable<typeof t> => t !== undefined),
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (patient?.id) {
|
||||||
|
// Update existing patient
|
||||||
|
await updatePatient(patient.id, fhirPatient);
|
||||||
|
} else {
|
||||||
|
// Create new patient
|
||||||
|
await createPatient(fhirPatient);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/epd/patients');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Er is een fout opgetreden');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name Fields */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="prefix" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Voorvoegsel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="prefix"
|
||||||
|
name="prefix"
|
||||||
|
defaultValue={existingName?.prefix?.[0] || ''}
|
||||||
|
placeholder="Dhr./Mevr."
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="given" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Voornaam *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="given"
|
||||||
|
name="given"
|
||||||
|
defaultValue={existingName?.given?.[0] || ''}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="family" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Achternaam *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="family"
|
||||||
|
name="family"
|
||||||
|
defaultValue={existingName?.family || ''}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BSN and Birth Date */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bsn" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
BSN *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="bsn"
|
||||||
|
name="bsn"
|
||||||
|
defaultValue={existingBsn || ''}
|
||||||
|
required
|
||||||
|
placeholder="123456789"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="birthDate" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Geboortedatum *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="birthDate"
|
||||||
|
name="birthDate"
|
||||||
|
defaultValue={patient?.birthDate || ''}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="gender" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Geslacht *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="gender"
|
||||||
|
name="gender"
|
||||||
|
defaultValue={patient?.gender || 'unknown'}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="male">Man</option>
|
||||||
|
<option value="female">Vrouw</option>
|
||||||
|
<option value="other">Anders</option>
|
||||||
|
<option value="unknown">Onbekend</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Telefoonnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
defaultValue={existingPhone || ''}
|
||||||
|
placeholder="+31612345678"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
E-mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
defaultValue={existingEmail || ''}
|
||||||
|
placeholder="patient@example.com"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-4 pt-4 border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Opslaan...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
<span>{patient ? 'Bijwerken' : 'Aanmaken'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-4 py-2 text-slate-700 hover:text-slate-900 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
app/epd/patients/components/patient-list.tsx
Normal file
142
app/epd/patients/components/patient-list.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient List Component (FHIR-based)
|
||||||
|
* Displays patients from FHIR API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { User, Calendar, Phone, Mail } from 'lucide-react';
|
||||||
|
import type { FHIRPatient } from '@/lib/fhir';
|
||||||
|
|
||||||
|
interface PatientListProps {
|
||||||
|
initialPatients: FHIRPatient[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientList({ initialPatients }: PatientListProps) {
|
||||||
|
const [patients] = useState<FHIRPatient[]>(initialPatients);
|
||||||
|
|
||||||
|
if (patients.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg border border-slate-200">
|
||||||
|
<User className="mx-auto h-12 w-12 text-slate-400" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-slate-900">Geen patiënten gevonden</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Begin met het toevoegen van een nieuwe patiënt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
Patiënt
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
BSN
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
Geboortedatum
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
Contact
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||||
|
Geslacht
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-slate-200">
|
||||||
|
{patients.map((patient) => {
|
||||||
|
const name = patient.name?.[0];
|
||||||
|
const fullName = [
|
||||||
|
...(name?.prefix || []),
|
||||||
|
...(name?.given || []),
|
||||||
|
name?.family,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const bsn = patient.identifier?.find(
|
||||||
|
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
const phone = patient.telecom?.find((t) => t.system === 'phone')?.value;
|
||||||
|
const email = patient.telecom?.find((t) => t.system === 'email')?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={patient.id}
|
||||||
|
className="hover:bg-slate-50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
href={`/epd/patients/${patient.id}`}
|
||||||
|
className="flex items-center group"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-teal-400 to-teal-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-white font-semibold text-sm">
|
||||||
|
{name?.given?.[0]?.[0]}
|
||||||
|
{name?.family?.[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="text-sm font-medium text-slate-900 group-hover:text-teal-600 transition-colors">
|
||||||
|
{fullName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500">ID: {patient.id}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-slate-900">{bsn || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-slate-900">
|
||||||
|
<Calendar className="h-4 w-4 text-slate-400 mr-2" />
|
||||||
|
{patient.birthDate || '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{phone && (
|
||||||
|
<div className="flex items-center text-sm text-slate-900">
|
||||||
|
<Phone className="h-4 w-4 text-slate-400 mr-2" />
|
||||||
|
{phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<div className="flex items-center text-sm text-slate-900">
|
||||||
|
<Mail className="h-4 w-4 text-slate-400 mr-2" />
|
||||||
|
{email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!phone && !email && (
|
||||||
|
<span className="text-sm text-slate-400">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className="text-sm text-slate-900 capitalize">
|
||||||
|
{patient.gender === 'male' && 'Man'}
|
||||||
|
{patient.gender === 'female' && 'Vrouw'}
|
||||||
|
{patient.gender === 'other' && 'Anders'}
|
||||||
|
{patient.gender === 'unknown' && 'Onbekend'}
|
||||||
|
{!patient.gender && '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/epd/patients/new/page.tsx
Normal file
29
app/epd/patients/new/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { PatientForm } from '../components/patient-form';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function NewPatientPage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Header with back button */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href="/epd/patients"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span>Terug naar patiënten</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Nieuwe patiënt</h1>
|
||||||
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
|
Voeg een nieuwe patiënt toe aan het systeem (FHIR)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patient Form */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||||
|
<PatientForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
app/epd/patients/page.tsx
Normal file
52
app/epd/patients/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { getPatients } from './actions';
|
||||||
|
import { PatientList } from './components/patient-list';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface SearchParams {
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PatientsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
return (
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Patiënten (FHIR)</h1>
|
||||||
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
|
FHIR-compliant patiëntenbeheer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/epd/patients/new"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Nieuwe patiënt</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patient List */}
|
||||||
|
<Suspense fallback={<div>Loading patients...</div>}>
|
||||||
|
<PatientListWrapper searchParams={params} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function PatientListWrapper({ searchParams }: { searchParams: SearchParams }) {
|
||||||
|
const patients = await getPatients({
|
||||||
|
search: searchParams.search,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <PatientList initialPatients={patients} />;
|
||||||
|
}
|
||||||
@@ -167,6 +167,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Global styles */
|
/* Global styles */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
|
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,29 @@
|
|||||||
"description": "Basis setup en infrastructuur",
|
"description": "Basis setup en infrastructuur",
|
||||||
"order": 1
|
"order": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "architecture",
|
||||||
|
"title": "Architecture",
|
||||||
|
"description": "Datamodel en FHIR standaarden",
|
||||||
|
"order": 2
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "features",
|
"id": "features",
|
||||||
"title": "Core Features",
|
"title": "Core Features",
|
||||||
"description": "EPD functionaliteit",
|
"description": "EPD functionaliteit",
|
||||||
"order": 2
|
"order": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "infrastructure",
|
"id": "infrastructure",
|
||||||
"title": "Infrastructure",
|
"title": "Infrastructure",
|
||||||
"description": "Ondersteunende systemen",
|
"description": "Ondersteunende systemen",
|
||||||
"order": 3
|
"order": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bugs",
|
"id": "bugs",
|
||||||
"title": "Bugs & Fixes",
|
"title": "Bugs & Fixes",
|
||||||
"description": "Opgeloste bugs en troubleshooting",
|
"description": "Opgeloste bugs en troubleshooting",
|
||||||
"order": 4
|
"order": 5
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": [
|
"categories": [
|
||||||
@@ -47,6 +53,22 @@
|
|||||||
"description": "Development en deployment configuratie",
|
"description": "Development en deployment configuratie",
|
||||||
"order": 3
|
"order": 3
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "fhir-datamodel",
|
||||||
|
"title": "FHIR Datamodel",
|
||||||
|
"group": "architecture",
|
||||||
|
"description": "FHIR-compliant datamodel voor GGZ-gegevens volgens MedMIJ standaarden",
|
||||||
|
"order": 1,
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "fhir-api",
|
||||||
|
"title": "FHIR REST API",
|
||||||
|
"group": "architecture",
|
||||||
|
"description": "RESTful FHIR API endpoints voor data-uitwisseling",
|
||||||
|
"order": 2,
|
||||||
|
"status": "in-progress"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "dashboard",
|
"slug": "dashboard",
|
||||||
"title": "Dashboard & Navigation",
|
"title": "Dashboard & Navigation",
|
||||||
|
|||||||
1508
content/nl/documentatie/fhir-api.mdx
Normal file
1508
content/nl/documentatie/fhir-api.mdx
Normal file
File diff suppressed because it is too large
Load Diff
476
content/nl/documentatie/fhir-datamodel.mdx
Normal file
476
content/nl/documentatie/fhir-datamodel.mdx
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
---
|
||||||
|
title: "FHIR Datamodel"
|
||||||
|
category: "fhir-datamodel"
|
||||||
|
group: "architecture"
|
||||||
|
version: "2.0.0"
|
||||||
|
releaseDate: "2024-11-21"
|
||||||
|
status: "active"
|
||||||
|
description: "FHIR-compliant datamodel voor GGZ-gegevens volgens MedMIJ standaarden - patiënten, behandelaren, contactmomenten en behandelplannen"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Het Mini-EPD gebruikt een **FHIR-compliant datamodel** voor alle GGZ-gegevens. FHIR (Fast Healthcare Interoperability Resources) is de internationale standaard voor uitwisseling van zorggegevens en maakt toekomstige integratie mogelijk met:
|
||||||
|
|
||||||
|
- **MedMIJ** - Nederlandse patiëntportalen
|
||||||
|
- **Koppeltaal** - eHealth apps en interventies
|
||||||
|
- **Landelijk Schakelpunt (LSP)** - Medicatie-uitwisseling
|
||||||
|
- **Andere zorginstellingen** - Via standaard FHIR API's
|
||||||
|
|
||||||
|
**Pragmatische aanpak:**
|
||||||
|
We implementeren **6 kern FHIR resources** die essentieel zijn voor een werkend GGZ-dossier. Dit zorgt voor data-uitwisselbaarheid zonder onnodige complexiteit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standaard Compliance
|
||||||
|
|
||||||
|
### MedMIJ Basisgegevens GGZ 2.0
|
||||||
|
|
||||||
|
Ons datamodel volgt de **MedMIJ Basisgegevens GGZ 2.0** specificatie. Dit betekent dat alle velden en structuren compatibel zijn met het Nederlandse afsprakenstelsel voor patiëntportalen.
|
||||||
|
|
||||||
|
**Voordelen:**
|
||||||
|
- ✅ Cliënten kunnen later hun eigen dossier inzien via een PGO-app
|
||||||
|
- ✅ Gegevens kunnen veilig gedeeld worden met andere zorgaanbieders
|
||||||
|
- ✅ Automatische uitwisseling met huisartsen en andere verwijzers
|
||||||
|
- ✅ Geen vendor lock-in - data is altijd exporteerbaar
|
||||||
|
|
||||||
|
### FHIR R4 Resources
|
||||||
|
|
||||||
|
Alle tabellen zijn gebaseerd op **FHIR R4** resource definities:
|
||||||
|
- Veldnamen volgen FHIR naming conventions
|
||||||
|
- Data types komen overeen met FHIR specificaties
|
||||||
|
- Relaties tussen resources volgen FHIR reference structuur
|
||||||
|
- API endpoints kunnen direct FHIR JSON genereren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geïmplementeerde Resources
|
||||||
|
|
||||||
|
### 1. **Practitioners** - Behandelaren
|
||||||
|
|
||||||
|
**Doel:** Registratie van alle zorgprofessionals die in het systeem werken.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- BIG-nummer (indien van toepassing)
|
||||||
|
- AGB-code voor facturatie
|
||||||
|
- Naam en kwalificaties
|
||||||
|
- Contactgegevens
|
||||||
|
- Actieve status
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `Practitioner` resource uit FHIR R4. Ondersteunt Nederlandse BIG en AGB identificatie systemen.
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Toewijzen van behandelverantwoordelijkheid
|
||||||
|
- Autorisatie en toegangscontrole
|
||||||
|
- Facturatie en declaratie
|
||||||
|
- Handtekeningen op documenten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Organizations** - Instellingen
|
||||||
|
|
||||||
|
**Doel:** Registratie van de GGZ-instelling en externe samenwerkingspartners.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- AGB-code instelling
|
||||||
|
- KVK-nummer
|
||||||
|
- Naam en nevenvestigingen
|
||||||
|
- Adres en contactgegevens
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `Organization` resource uit FHIR R4.
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Juridische verantwoordelijkheid
|
||||||
|
- Facturatie en contracten
|
||||||
|
- Verwijzingen naar andere instellingen
|
||||||
|
- Netwerkzorg registratie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Patients** - Patiënten/Cliënten
|
||||||
|
|
||||||
|
**Doel:** Centrale registratie van alle patiënten die behandeling ontvangen.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- BSN (versleuteld opgeslagen)
|
||||||
|
- Naam, geboortedatum, geslacht
|
||||||
|
- Adres en contactgegevens
|
||||||
|
- Verzekeringsgegevens
|
||||||
|
- Huisarts (naam + AGB)
|
||||||
|
- Noodcontactpersoon
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `Patient` resource uit FHIR R4. Komt overeen met de Nederlandse **ZIB Patient** (ZorgInformatieBouwsteen).
|
||||||
|
|
||||||
|
**Privacy:**
|
||||||
|
- BSN wordt versleuteld opgeslagen
|
||||||
|
- Toegang via Row Level Security (RLS)
|
||||||
|
- AVG-compliant logging van alle toegang
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Basis voor alle andere dossiergegevens
|
||||||
|
- Identificatie bij contact
|
||||||
|
- Facturatie naar verzekering
|
||||||
|
- Communicatie met cliënt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Encounters** - Contactmomenten
|
||||||
|
|
||||||
|
**Doel:** Registratie van elk contact tussen cliënt en behandelaar.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Type contact (intake, diagnostiek, behandeling, crisis)
|
||||||
|
- Status (gepland, lopend, afgerond)
|
||||||
|
- Datum en tijd
|
||||||
|
- Behandelaar en cliënt
|
||||||
|
- Locatie (polikliniek, online, kliniek)
|
||||||
|
- Aanmeldingsreden
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `Encounter` resource uit FHIR R4. Komt overeen met de Nederlandse **ZIB Contact**.
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Timeline van alle contacten per cliënt
|
||||||
|
- Facturatie per contactmoment
|
||||||
|
- Koppeling van diagnoses aan intake
|
||||||
|
- Planning en agenda-beheer
|
||||||
|
|
||||||
|
**Waarom belangrijk:**
|
||||||
|
Alle andere gegevens (diagnoses, observaties, behandelplannen) worden gekoppeld aan een specifiek contactmoment. Dit maakt het later mogelijk om te zien: *"Deze diagnose is gesteld tijdens de intake van 15 maart 2024"*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Conditions** - Diagnoses
|
||||||
|
|
||||||
|
**Doel:** Registratie van diagnoses volgens DSM-5 of ICD-10.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- DSM-5/ICD-10 code (bijv. "F32.2")
|
||||||
|
- Omschrijving (bijv. "Depressieve episode, ernstig")
|
||||||
|
- Klinische status (actief, in remissie, opgelost)
|
||||||
|
- Ernst (mild, matig, ernstig)
|
||||||
|
- Verificatie status (voorlopig, bevestigd)
|
||||||
|
- Vastgesteld door welke behandelaar
|
||||||
|
- Bij welk contactmoment
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `Condition` resource uit FHIR R4. Komt overeen met de Nederlandse **ZIB Problem**.
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Problemlijst per cliënt
|
||||||
|
- Basis voor behandelplan
|
||||||
|
- DBC registratie en facturatie
|
||||||
|
- Rapportage en statistiek
|
||||||
|
|
||||||
|
**Voorbeeld flow:**
|
||||||
|
1. Intake: voorlopige diagnose "F32.2 - Depressieve episode, ernstig"
|
||||||
|
2. Na diagnostiek: bevestiging van diagnose
|
||||||
|
3. Na behandeling: status wijzigt naar "in remissie"
|
||||||
|
4. Bij herstel: status wordt "opgelost"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Observations** - Metingen en Observaties
|
||||||
|
|
||||||
|
**Doel:** Registratie van alle metingen, scores en observaties tijdens behandeling.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Type observatie (ROM-score, risico-inschatting, vitaliteit)
|
||||||
|
- Uitkomst/waarde
|
||||||
|
- Interpretatie (normaal, afwijkend)
|
||||||
|
- Datum en tijd
|
||||||
|
- Uitgevoerd door welke behandelaar
|
||||||
|
- Gekoppeld aan welk contactmoment
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `Observation` resource uit FHIR R4. Komt overeen met de Nederlandse **ZIB LaboratoryTestResult** en **ZIB Alert**.
|
||||||
|
|
||||||
|
**Categorieën:**
|
||||||
|
- **ROM-metingen:** PHQ-9, GAD-7, OQ-45 (routine outcome monitoring)
|
||||||
|
- **Risico-inschattingen:** Suïcidaliteit, agressie, verwaarlozing
|
||||||
|
- **Middelengebruik:** Alcohol, drugs, medicatie-compliance
|
||||||
|
- **Vitale functies:** Bloeddruk, hartslag (indien relevant)
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Voortgang monitoren met ROM-scores
|
||||||
|
- Veiligheidsbewaking (risico's)
|
||||||
|
- Wetenschappelijk onderzoek
|
||||||
|
- Kwaliteitsindicatoren
|
||||||
|
|
||||||
|
**Voorbeeld:**
|
||||||
|
- PHQ-9 vragenlijst ingevuld: score 18 → interpretatie: "Matig-ernstige depressie"
|
||||||
|
- Risico-inschatting: "Suïcidale gedachten, geen plannen" → interpretatie: "Matig risico"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. **CarePlans** - Behandelplannen
|
||||||
|
|
||||||
|
**Doel:** Overzicht van de geplande behandeling met doelen en interventies.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Titel en beschrijving
|
||||||
|
- Status (concept, actief, afgerond, gestopt)
|
||||||
|
- Looptijd (start- en einddatum)
|
||||||
|
- Behandeldoelen (SMART geformuleerd)
|
||||||
|
- Behandelactiviteiten (interventies, frequentie)
|
||||||
|
- Welke diagnoses worden behandeld
|
||||||
|
- Regiebehandelaar
|
||||||
|
|
||||||
|
**FHIR Mapping:**
|
||||||
|
Volgt de `CarePlan` resource uit FHIR R4. Komt overeen met de Nederlandse **ZIB TreatmentDirective**.
|
||||||
|
|
||||||
|
**Pragmatische keuze:**
|
||||||
|
Doelen (Goals) en Activiteiten zijn embedded in de CarePlan als JSONB fields. Dit is eenvoudiger dan aparte tabellen en voldoet nog steeds aan FHIR structuur.
|
||||||
|
|
||||||
|
**Praktisch gebruik:**
|
||||||
|
- Multidisciplinair behandelplan opstellen
|
||||||
|
- Voortgang monitoren
|
||||||
|
- Communicatie met cliënt
|
||||||
|
- Evaluatie en bijstelling
|
||||||
|
|
||||||
|
**Koppeltaal-integratie:**
|
||||||
|
Via de CarePlan kunnen later eHealth apps gekoppeld worden. Bijvoorbeeld: *"Opdracht: 3x per week mindfulness oefening via app MindDistrict"* → automatisch gesynchroniseerd tussen EPD en app.
|
||||||
|
|
||||||
|
**Voorbeeld structuur:**
|
||||||
|
```
|
||||||
|
Behandelplan: "Behandeling depressie"
|
||||||
|
├─ Diagnose: F32.2 (Depressieve episode, ernstig)
|
||||||
|
├─ Doel 1: "PHQ-9 score < 10 binnen 12 weken"
|
||||||
|
├─ Doel 2: "Herstel dagelijks functioneren (werk/sociaal)"
|
||||||
|
├─ Activiteit 1: Individuele CGT - 1x/week, 12 sessies
|
||||||
|
├─ Activiteit 2: ROM-meting PHQ-9 - elke 4 weken
|
||||||
|
└─ Activiteit 3: Medicatie - Sertraline 50mg dagelijks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relaties tussen Resources
|
||||||
|
|
||||||
|
```
|
||||||
|
Patient (Patiënt)
|
||||||
|
│
|
||||||
|
└─── Encounters (Contactmomenten)
|
||||||
|
│
|
||||||
|
├─── Conditions (Diagnoses)
|
||||||
|
│ └─── ondersteund door Observations (ROM-scores)
|
||||||
|
│
|
||||||
|
├─── Observations (Metingen/Risico's)
|
||||||
|
│
|
||||||
|
└─── CarePlans (Behandelplannen)
|
||||||
|
├─── Doelen (Goals)
|
||||||
|
└─── Activiteiten (Interventies)
|
||||||
|
|
||||||
|
Uitgevoerd door: Practitioner (Behandelaar)
|
||||||
|
Binnen: Organization (Instelling)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Werkwijze:**
|
||||||
|
1. **Aanmelding** → Encounter (intake) wordt aangemaakt
|
||||||
|
2. **Diagnostiek** → Conditions (diagnoses) gekoppeld aan Encounter
|
||||||
|
3. **ROM-meting** → Observations gekoppeld aan Encounter
|
||||||
|
4. **Behandelplan** → CarePlan gekoppeld aan Patient + Conditions
|
||||||
|
5. **Behandeling** → Nieuwe Encounters voor sessies
|
||||||
|
6. **Evaluatie** → Nieuwe Observations (ROM) om voortgang te meten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data-uitwisselbaarheid
|
||||||
|
|
||||||
|
### FHIR API Endpoints
|
||||||
|
|
||||||
|
Alle resources zijn beschikbaar via RESTful FHIR API endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/fhir/Patient → Lijst van patiënten
|
||||||
|
GET /api/fhir/Patient/{id} → Specifieke patiënt
|
||||||
|
POST /api/fhir/Patient → Nieuwe patiënt aanmaken
|
||||||
|
PUT /api/fhir/Patient/{id} → Patiënt bijwerken
|
||||||
|
|
||||||
|
GET /api/fhir/Practitioner → Lijst van behandelaren
|
||||||
|
GET /api/fhir/CarePlan/{id} → Behandelplan ophalen
|
||||||
|
POST /api/fhir/CarePlan → Behandelplan importeren
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response format:**
|
||||||
|
- Content-Type: `application/fhir+json`
|
||||||
|
- Volledig FHIR R4 compliant JSON
|
||||||
|
- Ondersteunt FHIR search parameters
|
||||||
|
- Error responses via FHIR OperationOutcome
|
||||||
|
|
||||||
|
### Export/Import Scenario
|
||||||
|
|
||||||
|
**Voorbeeld - Behandelplan delen:**
|
||||||
|
1. Behandelaar maakt behandelplan in Mini-EPD
|
||||||
|
2. Export via API: `GET /api/fhir/CarePlan/abc-123`
|
||||||
|
3. FHIR JSON response bevat volledig behandelplan
|
||||||
|
4. Andere instelling importeert: `POST /api/fhir/CarePlan`
|
||||||
|
5. ✅ Behandelplan is succesvol gedeeld tussen systemen
|
||||||
|
|
||||||
|
**MedMIJ scenario (toekomst):**
|
||||||
|
1. Cliënt opent PGO-app (Persoonlijke Gezondheidsomgeving)
|
||||||
|
2. App vraagt toestemming voor toegang tot dossier
|
||||||
|
3. Mini-EPD API deelt FHIR resources met app
|
||||||
|
4. Cliënt ziet eigen behandelplan, diagnoses en ROM-scores
|
||||||
|
5. ✅ Data-uitwisselbaarheid volgens MedMIJ standaard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & Beveiliging
|
||||||
|
|
||||||
|
### Toegangscontrole (Row Level Security)
|
||||||
|
|
||||||
|
Elke tabel heeft RLS policies:
|
||||||
|
- **Behandelaren** zien alleen hun eigen patiënten
|
||||||
|
- **Patiënten** kunnen later hun eigen data inzien (via patiëntenportaal)
|
||||||
|
- **Administrators** hebben beperkte toegang (geen medische data)
|
||||||
|
|
||||||
|
### Encryptie
|
||||||
|
|
||||||
|
- **BSN** wordt versleuteld opgeslagen (pgcrypto)
|
||||||
|
- **Communicatie** via HTTPS/TLS
|
||||||
|
- **Database** backups zijn versleuteld
|
||||||
|
|
||||||
|
### AVG-compliance
|
||||||
|
|
||||||
|
- **Recht op inzage** - Cliënt kan eigen data opvragen via API
|
||||||
|
- **Recht op correctie** - Data kan bijgewerkt worden
|
||||||
|
- **Recht op vergetelheid** - Data kan verwijderd worden (na wettelijke bewaartermijn)
|
||||||
|
- **Logging** - Alle toegang wordt gelogd in audit trail
|
||||||
|
- **Toestemming** - Consent management via Consents resource (toekomstig)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### ✅ Fase 1: Foundation (Voltooid)
|
||||||
|
|
||||||
|
- Database schema met 6 FHIR resources
|
||||||
|
- TypeScript type definitions
|
||||||
|
- Data migratie van legacy tabellen
|
||||||
|
- Seed data voor demo
|
||||||
|
|
||||||
|
### ✅ Fase 2: API & UI (Voltooid)
|
||||||
|
|
||||||
|
- FHIR transform library (DB ↔ FHIR JSON)
|
||||||
|
- Patient API endpoints (GET/POST/PUT)
|
||||||
|
- Practitioner API endpoints (GET/POST)
|
||||||
|
- Patient UI (lijst, detail, formulieren)
|
||||||
|
|
||||||
|
### ⏳ Fase 3: Encounters & Conditions (In planning)
|
||||||
|
|
||||||
|
- Encounter API endpoints
|
||||||
|
- Condition API endpoints
|
||||||
|
- Observation API endpoints
|
||||||
|
- UI voor contactmomenten en diagnoses
|
||||||
|
|
||||||
|
### 🎯 Fase 4: CarePlans (Hoofddoel)
|
||||||
|
|
||||||
|
- CarePlan API endpoints
|
||||||
|
- Wizard UI voor behandelplan opstellen
|
||||||
|
- Doelen en activiteiten beheer
|
||||||
|
- Voortgang monitoring
|
||||||
|
|
||||||
|
### 🔮 Fase 5: Integraties (Toekomst)
|
||||||
|
|
||||||
|
- MedMIJ aansluiting (patiëntportaal)
|
||||||
|
- Koppeltaal aansluiting (eHealth apps)
|
||||||
|
- Landelijk Schakelpunt (medicatie)
|
||||||
|
- Swagger/OpenAPI documentatie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
### Database: PostgreSQL (Supabase)
|
||||||
|
|
||||||
|
- **Type-safe ENUMs** voor statussen (draft, active, completed, etc.)
|
||||||
|
- **Automatische timestamps** (created_at, updated_at)
|
||||||
|
- **Foreign keys** voor relaties tussen resources
|
||||||
|
- **Indexes** op veel-gebruikte velden voor performance
|
||||||
|
- **JSONB fields** voor flexibele embedded data (goals, activities)
|
||||||
|
|
||||||
|
### Veldnamen volgen FHIR
|
||||||
|
|
||||||
|
Voorbeelden:
|
||||||
|
- `name_family` → `Patient.name.family`
|
||||||
|
- `identifier_bsn` → `Patient.identifier[system=bsn].value`
|
||||||
|
- `status` → `CarePlan.status`
|
||||||
|
|
||||||
|
Dit maakt transformatie naar FHIR JSON eenvoudig en voorspelbaar.
|
||||||
|
|
||||||
|
### Transform Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Database → FHIR
|
||||||
|
const fhirPatient = dbPatientToFHIR(dbRow);
|
||||||
|
|
||||||
|
// FHIR → Database
|
||||||
|
const dbInsert = fhirPatientToDB(fhirResource);
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle transforms zijn in `lib/fhir/transforms/` met 100% type safety.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voor Functioneel Beheerders
|
||||||
|
|
||||||
|
### Wat betekent dit voor dagelijks gebruik?
|
||||||
|
|
||||||
|
**Voor behandelaren:**
|
||||||
|
- Intuïtieve UI, geen FHIR kennis nodig
|
||||||
|
- Alle data logisch gestructureerd
|
||||||
|
- Diagnoses gekoppeld aan intake-moment
|
||||||
|
- Behandelplan volgt automatisch uit diagnose
|
||||||
|
|
||||||
|
**Voor beheerders:**
|
||||||
|
- Export naar andere systemen is mogelijk
|
||||||
|
- Backups bevatten FHIR-compliant data
|
||||||
|
- Audits en rapportages zijn eenvoudig
|
||||||
|
- Geen vendor lock-in
|
||||||
|
|
||||||
|
**Voor ICT:**
|
||||||
|
- Database schema volgt internationale standaard
|
||||||
|
- API endpoints zijn FHIR-compliant
|
||||||
|
- Integraties zijn goed gedocumenteerd
|
||||||
|
- Migratie naar andere systemen is eenvoudig
|
||||||
|
|
||||||
|
### Veelgestelde Vragen
|
||||||
|
|
||||||
|
**Q: Moet ik FHIR kennen om het systeem te gebruiken?**
|
||||||
|
A: Nee, de UI abstracteert alle FHIR complexiteit. Je werkt gewoon met patiënten, contacten en behandelplannen.
|
||||||
|
|
||||||
|
**Q: Kunnen we later gemakkelijk overstappen naar een ander EPD?**
|
||||||
|
A: Ja, alle data is in FHIR format te exporteren. Geen vendor lock-in.
|
||||||
|
|
||||||
|
**Q: Is dit compatible met MedMIJ?**
|
||||||
|
A: Ja, het datamodel volgt MedMIJ Basisgegevens GGZ 2.0 specificaties.
|
||||||
|
|
||||||
|
**Q: Hoe zit het met privacy?**
|
||||||
|
A: BSN versleuteld, RLS policies, audit logging, AVG-compliant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenties
|
||||||
|
|
||||||
|
**FHIR Specificaties:**
|
||||||
|
- FHIR R4: https://hl7.org/fhir/R4/
|
||||||
|
- FHIR Patient: https://hl7.org/fhir/R4/patient.html
|
||||||
|
- FHIR CarePlan: https://hl7.org/fhir/R4/careplan.html
|
||||||
|
|
||||||
|
**Nederlandse Standaarden:**
|
||||||
|
- MedMIJ GGZ: https://informatiestandaarden.nictiz.nl/wiki/MedMij:V2020.01/OntwerpGGZ
|
||||||
|
- ZIBs: https://zibs.nl/
|
||||||
|
|
||||||
|
**Project Documentatie:**
|
||||||
|
- Technisch schema: `lib/supabase/20241121_fhir_ggz_schema.sql`
|
||||||
|
- Bouwplan: `docs/bouwplan-pragmatisch-fhir.md`
|
||||||
|
- Datamodel details: `docs/datamodel-documentatie.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Laatst bijgewerkt:** 21 november 2024
|
||||||
|
**Versie:** 2.0.0 (Pragmatisch FHIR)
|
||||||
|
**Status:** Actief - Epic 1 & 2 Voltooid
|
||||||
1188
docs/bouwplan-mini-epd.md
Normal file
1188
docs/bouwplan-mini-epd.md
Normal file
File diff suppressed because it is too large
Load Diff
1798
docs/bouwplan-pragmatisch-fhir.md
Normal file
1798
docs/bouwplan-pragmatisch-fhir.md
Normal file
File diff suppressed because it is too large
Load Diff
494
docs/datamodel-documentatie.md
Normal file
494
docs/datamodel-documentatie.md
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
# Datamodel Mini-ECD: FHIR-compliant GGZ Dossier
|
||||||
|
|
||||||
|
**Versie:** 1.1
|
||||||
|
**Datum:** 21 november 2024
|
||||||
|
**Status:** In ontwikkeling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overzicht
|
||||||
|
|
||||||
|
Het Mini-ECD gebruikt een datamodel gebaseerd op **FHIR (Fast Healthcare Interoperability Resources)**, de internationale standaard voor uitwisseling van zorggegevens. Dit maakt toekomstige integratie met MedMIJ (patiëntportalen) en Koppeltaal (eHealth apps) mogelijk zonder grote aanpassingen.
|
||||||
|
|
||||||
|
Het datamodel bestaat uit **13 kernonderdelen** die samen het complete GGZ-traject ondersteunen: van aanmelding tot behandelplan, inclusief doelen, toestemmingen en belangrijke waarschuwingen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## De 13 bouwstenen van het dossier
|
||||||
|
|
||||||
|
### 1. **Behandelaren** (`practitioners`)
|
||||||
|
**Wat is het?**
|
||||||
|
Alle zorgprofessionals die in het systeem werken: psychologen, psychiaters, gz-psychologen, verpleegkundigen, etc.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- BIG-nummer (indien geregistreerd)
|
||||||
|
- AGB-code
|
||||||
|
- Naam en voorletters
|
||||||
|
- Kwalificaties (bijv. "GZ-psycholoog", "Psychotherapeut")
|
||||||
|
- Contactgegevens
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Practitioner** resource. Dit maakt het mogelijk om behandelaren later uit te wisselen met andere systemen (bijvoorbeeld voor verwijzingen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Instellingen** (`organizations`)
|
||||||
|
**Wat is het?**
|
||||||
|
De GGZ-organisaties zelf: jouw instelling, maar ook externe organisaties waarmee je samenwerkt.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- AGB-code instelling
|
||||||
|
- KVK-nummer
|
||||||
|
- Naam en eventuele nevenvestigingen
|
||||||
|
- Contactgegevens en adres
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Organization** resource. Nodig voor facturatie, verwijzingen en juridische verantwoordelijkheid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Cliënten** (`patients`)
|
||||||
|
**Wat is het?**
|
||||||
|
De patiënten/cliënten die behandeling krijgen.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- BSN (verplicht)
|
||||||
|
- Naam, geboortedatum, geslacht
|
||||||
|
- Adres en contactgegevens
|
||||||
|
- Verzekeringsgegevens
|
||||||
|
- Huisarts (naam + AGB-code)
|
||||||
|
- Noodcontactpersoon
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Patient** resource. Dit is de basis voor alle andere gegevens in het dossier. Het correspondeert met de Nederlandse **ZIB Patient** (ZorgInformatieBouwsteen).
|
||||||
|
|
||||||
|
**Privacy:**
|
||||||
|
BSN wordt versleuteld opgeslagen en is alleen toegankelijk voor geautoriseerde behandelaren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Contactmomenten** (`encounters`)
|
||||||
|
**Wat is het?**
|
||||||
|
Elk contact tussen cliënt en behandelaar: intakegesprek, behandelsessie, telefonisch consult, etc.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Type contact (intake, diagnostiek, behandeling, crisis)
|
||||||
|
- Status (gepland, bezig, afgerond)
|
||||||
|
- Wanneer (start- en eindtijd)
|
||||||
|
- Wie (behandelaar + cliënt)
|
||||||
|
- Waar (polikliniek, online, kliniek)
|
||||||
|
- Waarom (aanmeldingsreden, klachten)
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Encounter** resource. Dit is cruciaal omdat alle andere gegevens (diagnoses, observaties, behandelplannen) gekoppeld worden aan een specifiek contactmoment. Hierdoor kun je later zien: "Deze diagnose is gesteld tijdens de intake van 15 maart 2024".
|
||||||
|
|
||||||
|
Dit correspondeert met de Nederlandse **ZIB Contact**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Diagnoses** (`conditions`)
|
||||||
|
**Wat is het?**
|
||||||
|
De vastgestelde diagnoses volgens DSM-5 of ICD-10. Dit kunnen zowel definitieve diagnoses zijn als voorlopige diagnoses.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- DSM-5 code (bijv. "F32.2")
|
||||||
|
- Omschrijving (bijv. "Depressieve episode, ernstig")
|
||||||
|
- Status (actief, in remissie, opgelost)
|
||||||
|
- Ernst (mild, matig, ernstig)
|
||||||
|
- Zekerheid (voorlopig, bevestigd, uitgesloten)
|
||||||
|
- Wanneer ontstaan / wanneer opgelost
|
||||||
|
- Wie stelde de diagnose vast
|
||||||
|
- Bij welk contactmoment
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Condition** resource. Dit onderscheidt tussen "encounter diagnosis" (gesteld tijdens een specifiek contact) en "problem list item" (langlopend probleem op de problemlijst).
|
||||||
|
|
||||||
|
Dit correspondeert met de Nederlandse **ZIB Problem**.
|
||||||
|
|
||||||
|
**Voorbeeld:**
|
||||||
|
Een cliënt meldt zich aan met depressieve klachten. Na de intake wordt voorlopig "F32.2 - Depressieve episode, ernstig" vastgesteld. Na behandeling verandert de status naar "in remissie".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Observaties & Metingen** (`observations`)
|
||||||
|
**Wat is het?**
|
||||||
|
Alle metingen, scores, risico-inschattingen en observaties tijdens de behandeling.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Wat werd geobserveerd (bijv. "Suïcidaliteit", "PHQ-9 score", "Bloeddruk")
|
||||||
|
- Uitkomst (bijv. "Hoog risico", "Score: 18 punten", "120/80")
|
||||||
|
- Interpretatie (normaal, afwijkend hoog, afwijkend laag)
|
||||||
|
- Wanneer gemeten
|
||||||
|
- Door wie
|
||||||
|
- Bij welk contactmoment
|
||||||
|
|
||||||
|
**Categorieën:**
|
||||||
|
- **ROM-metingen**: PHQ-9, GAD-7, OQ-45, etc.
|
||||||
|
- **Risico-inschattingen**: Suïcidaliteit, agressie, verwaarlozing
|
||||||
|
- **Middelengebruik**: Alcohol, drugs, medicatie
|
||||||
|
- **Vitale functies**: Bloeddruk, hartslag (indien relevant)
|
||||||
|
- **Sociale anamnese**: Werk, relatie, financiën
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Observation** resource. Dit is een zeer flexibele resource die allerlei soorten metingen kan bevatten. Door standaard codes te gebruiken (SNOMED, LOINC) kunnen deze later gedeeld worden met andere systemen.
|
||||||
|
|
||||||
|
Dit correspondeert met de Nederlandse **ZIB Alert** en **ZIB LaboratoryTestResult**.
|
||||||
|
|
||||||
|
**Voorbeeld:**
|
||||||
|
- ROM-vragenlijst PHQ-9 ingevuld: score 18 (matig-ernstige depressie)
|
||||||
|
- Risico-inschatting: "Suïcidale gedachten aanwezig, geen concrete plannen" → interpretatie: matig risico
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. **Medicatie** (`medication_statements`)
|
||||||
|
**Wat is het?**
|
||||||
|
De medicatie die de cliënt gebruikt of heeft gebruikt. Dit kan voorgeschreven zijn door de psychiater, maar ook medicatie van de huisarts.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Medicijnnaam (bijv. "Sertraline 50mg tablet")
|
||||||
|
- ATC-code (internationale medicijncode)
|
||||||
|
- Status (actief, gestopt, gepland)
|
||||||
|
- Dosering (bijv. "1 tablet 's ochtends")
|
||||||
|
- Toedieningsweg (oraal, intraveneus, etc.)
|
||||||
|
- Startdatum / stopdatum
|
||||||
|
- Reden van gebruik (bijv. "Depressie")
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **MedicationStatement** resource. Dit registreert wat de patiënt daadwerkelijk gebruikt (niet wat voorgeschreven is - dat zou een MedicationRequest zijn).
|
||||||
|
|
||||||
|
Dit correspondeert met de Nederlandse **ZIB MedicationUse** en is onderdeel van het **MedicatieProces 9.0**.
|
||||||
|
|
||||||
|
**Let op:**
|
||||||
|
Voor volledige medicatiegeschiedenis moet later gekoppeld worden met het Landelijk Schakelpunt (LSP) of andere medicatieservices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. **Behandelplannen** (`care_plans`)
|
||||||
|
**Wat is het?**
|
||||||
|
Het overzicht van de geplande behandeling: wat gaan we doen, waarom, en met welk doel?
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Titel (bijv. "Behandelplan depressie")
|
||||||
|
- Beschrijving van de aanpak
|
||||||
|
- Status (concept, actief, afgerond, gestopt)
|
||||||
|
- Looptijd (startdatum - einddatum)
|
||||||
|
- Behandeldoelen (bijv. "PHQ-9 score < 10", "Herstel dagelijks functioneren")
|
||||||
|
- Welke diagnoses worden behandeld
|
||||||
|
- Wie is de regiebehandelaar
|
||||||
|
- Welk zorgteam is betrokken
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **CarePlan** resource. Dit is de container voor alle behandelactiviteiten en koppelt diagnoses aan interventies.
|
||||||
|
|
||||||
|
Dit correspondeert met de Nederlandse **ZIB TreatmentDirective**.
|
||||||
|
|
||||||
|
**Koppeltaal-integratie:**
|
||||||
|
Dit is ook de resource die Koppeltaal gebruikt om eHealth-apps te koppelen aan de behandeling. Bijvoorbeeld: "Opdracht: 3x per week mindfulness oefening via app X".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. **Behandelactiviteiten** (`care_plan_activities`)
|
||||||
|
**Wat is het?**
|
||||||
|
De concrete activiteiten binnen een behandelplan: gesprekken, medicatie, huiswerkopdrachten, ROM-metingen, etc.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Omschrijving (bijv. "Individuele CGT sessies", "ROM-meting PHQ-9")
|
||||||
|
- Status (nog niet gestart, gepland, bezig, afgerond)
|
||||||
|
- Planning (bijv. "1x per week, 12 sessies")
|
||||||
|
- Uitvoerende behandelaar
|
||||||
|
- Locatie (polikliniek, online, kliniek)
|
||||||
|
- Voortgang (vrije tekst updates)
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit **CarePlan.activity**. Dit is onderdeel van de CarePlan resource en beschrijft de "wat en wanneer" van de behandeling.
|
||||||
|
|
||||||
|
**Voorbeeld activiteiten:**
|
||||||
|
- Individuele CGT: 1x/week, 12 sessies
|
||||||
|
- Medicatie: Sertraline 50mg dagelijks
|
||||||
|
- ROM-meting: Elke 4 weken PHQ-9 invullen
|
||||||
|
- Huiswerk: Dagboek bijhouden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. **Toestemmingen & Wilsverklaringen** (`consents`)
|
||||||
|
**Wat is het?**
|
||||||
|
Alle toestemmingen van de cliënt: voor behandeling, voor gegevensuitwisseling (AVG), wilsverklaringen (niet-reanimeren, euthanasie-verklaring, etc.).
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Type toestemming (behandeling, privacy/AVG, wilsverklaring, onderzoek)
|
||||||
|
- Status (actief, ingetrokken, afgewezen)
|
||||||
|
- Categorie (niet-reanimeren, advance directive, noodgevallen-only)
|
||||||
|
- Datum en wie gaf toestemming
|
||||||
|
- Geldigheid (startdatum - einddatum)
|
||||||
|
- Wat mag wel/niet (toegang, delen, correctie)
|
||||||
|
- Met wie mag gedeeld worden (specifieke behandelaren, organisaties)
|
||||||
|
- Documenten (ondertekende verklaring als PDF)
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Consent** resource. Dit correspondeert met de Nederlandse **ZIB AdvanceDirective**.
|
||||||
|
|
||||||
|
**AVG-compliance:**
|
||||||
|
Dit is cruciaal voor AVG-naleving. Hiermee registreer je:
|
||||||
|
- Toestemming voor behandeling (informed consent)
|
||||||
|
- Toestemming voor delen met huisarts/andere zorgverleners
|
||||||
|
- Intrekking van toestemming
|
||||||
|
- Wilsverklaringen die juridisch bindend zijn
|
||||||
|
|
||||||
|
**Voorbeelden:**
|
||||||
|
- "Toestemming behandeling depressie" (informed consent)
|
||||||
|
- "Geen toestemming delen met huisarts" (privacy)
|
||||||
|
- "Niet-reanimeren verklaring" (wilsverklaring)
|
||||||
|
- "Toestemming opname behandelgegevens in landelijke uitwisseling" (MedMIJ)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. **Waarschuwingen & Alerts** (`flags`)
|
||||||
|
**Wat is het?**
|
||||||
|
Belangrijke waarschuwingen die behandelaren **direct** moeten zien bij het openen van een dossier. Denk aan veiligheidsrisico's, allergieën, of gedragswaarschuwingen.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Type waarschuwing (veiligheid, klinisch, gedrag, infectie, allergie)
|
||||||
|
- Alert inhoud (bijv. "Suïciderisico", "Agressie naar hulpverleners")
|
||||||
|
- Prioriteit (hoog, middel, laag)
|
||||||
|
- Status (actief, inactief)
|
||||||
|
- Geldigheid (startdatum - einddatum)
|
||||||
|
- Wie maakte de alert
|
||||||
|
- Gerelateerde diagnoses of observaties
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **Flag** resource. Dit correspondeert met de Nederlandse **ZIB Alert**.
|
||||||
|
|
||||||
|
**Verschil met Observations:**
|
||||||
|
Observations zijn metingen/bevindingen. Flags zijn **actieve waarschuwingen** die aandacht vragen.
|
||||||
|
|
||||||
|
**Categorieën:**
|
||||||
|
- **Safety (veiligheid)**: Suïciderisico, zelfverwaarlozing, valrisico
|
||||||
|
- **Clinical (klinisch)**: Ernstige allergie voor medicatie, infectiegevaar
|
||||||
|
- **Behavioral (gedrag)**: Agressie naar hulpverleners, grensoverschrijdend gedrag
|
||||||
|
- **Administrative**: Geen-toon status (privacy), wanbetaler
|
||||||
|
|
||||||
|
**Voorbeeld flags:**
|
||||||
|
- 🔴 "HOOG SUÏCIDERISICO - Concrete plannen, middelen aanwezig"
|
||||||
|
- 🟠 "Agressie naar vrouwelijke hulpverleners - Alleen mannelijke behandelaar"
|
||||||
|
- 🟡 "Allergie: Penicilline - anafylactische shock"
|
||||||
|
- ⚪ "Geen toestemming contact familie - Privacy verzoek"
|
||||||
|
|
||||||
|
**In de UI:**
|
||||||
|
Flags worden prominent weergegeven (rood banner bovenaan dossier) zodat ze niet gemist kunnen worden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. **Documenten** (`document_references`)
|
||||||
|
**Wat is het?**
|
||||||
|
Alle documenten in het dossier: intakeverslagen, behandelplannen, brieven aan huisarts, ROM-rapporten, etc.
|
||||||
|
|
||||||
|
**Belangrijkste gegevens:**
|
||||||
|
- Type document (intakeverslag, behandelplan, brief, rapport)
|
||||||
|
- Status (concept, definitief, vervangen)
|
||||||
|
- Datum
|
||||||
|
- Auteur (behandelaar)
|
||||||
|
- Gekoppeld aan welk contactmoment
|
||||||
|
- Content (Markdown tekst, PDF, of link naar bestand)
|
||||||
|
|
||||||
|
**Waarom FHIR?**
|
||||||
|
In FHIR heet dit een **DocumentReference** resource. Dit zorgt ervoor dat documenten doorzoekbaar zijn en gekoppeld kunnen worden aan specifieke momenten in de behandeling.
|
||||||
|
|
||||||
|
**MedMIJ-integratie:**
|
||||||
|
Via MedMIJ kunnen cliënten later hun eigen documenten ophalen in een persoonlijke gezondheidsomgeving (PGO-app).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoe hangen deze onderdelen samen?
|
||||||
|
|
||||||
|
```
|
||||||
|
Cliënt (Patient)
|
||||||
|
│
|
||||||
|
├─── heeft Toestemmingen (Consents) ⚠️ AVG-compliant
|
||||||
|
│
|
||||||
|
├─── heeft Waarschuwingen (Flags) 🚨 Altijd zichtbaar
|
||||||
|
│
|
||||||
|
└─── heeft Contactmomenten (Encounters)
|
||||||
|
│
|
||||||
|
├─── leidt tot Diagnoses (Conditions)
|
||||||
|
│ └─── ondersteund door Observaties (Observations)
|
||||||
|
│
|
||||||
|
├─── gebruikt Medicatie (MedicationStatements)
|
||||||
|
│
|
||||||
|
├─── krijgt Behandelplan (CarePlan)
|
||||||
|
│ ├─── met Doelen (Goals) 🎯 Meetbaar
|
||||||
|
│ └─── met Activiteiten (CarePlanActivities)
|
||||||
|
│
|
||||||
|
└─── resulteert in Documenten (DocumentReferences)
|
||||||
|
|
||||||
|
Uitgevoerd door Behandelaar (Practitioner)
|
||||||
|
Binnen Instelling (Organization)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nieuwe verbindingen:**
|
||||||
|
- **Goals** zijn gekoppeld aan **CarePlan** en **Conditions**
|
||||||
|
- **Goals** worden gemeten via **Observations** (ROM-scores)
|
||||||
|
- **Flags** zijn gekoppeld aan **Conditions** en **Observations** (wat veroorzaakt de alert)
|
||||||
|
- **Consents** bepalen wie **DocumentReferences** mag inzien
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Waarom FHIR gebruiken?
|
||||||
|
|
||||||
|
### **1. Toekomstbestendig**
|
||||||
|
FHIR is de internationale standaard voor zorggegevens. Alle moderne zorgsystemen ondersteunen dit. Door vanaf dag 1 FHIR-compliant te bouwen, kunnen we later makkelijk integreren met:
|
||||||
|
- MedMIJ (patiëntportalen)
|
||||||
|
- Koppeltaal (eHealth apps)
|
||||||
|
- Landelijk Schakelpunt (LSP)
|
||||||
|
- Andere GGZ-instellingen
|
||||||
|
- Huisartseninformatiesystemen
|
||||||
|
|
||||||
|
### **2. Herbruikbaarheid**
|
||||||
|
Elk onderdeel ("resource") kan apart uitgewisseld worden. Bijvoorbeeld:
|
||||||
|
- Huisarts vraagt diagnoses op via FHIR API
|
||||||
|
- Cliënt haalt eigen medicatielijst op via MedMIJ
|
||||||
|
- eHealth app ontvangt behandelplan via Koppeltaal
|
||||||
|
|
||||||
|
### **3. Geen vendor lock-in**
|
||||||
|
Omdat we een open standaard gebruiken, zijn we niet afhankelijk van één leverancier. Data kan altijd geëxporteerd en geïmporteerd worden in FHIR-formaat.
|
||||||
|
|
||||||
|
### **4. Bewezen technologie**
|
||||||
|
FHIR wordt wereldwijd gebruikt door duizenden ziekenhuizen, klinieken en zorginstellingen. Alle grote EPD-leveranciers ondersteunen het.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MedMIJ & Koppeltaal: Wat betekent dit?
|
||||||
|
|
||||||
|
### **MedMIJ - Patiëntportalen**
|
||||||
|
MedMIJ is het Nederlandse afsprakenstelsel waarmee patiënten hun medische gegevens kunnen ophalen in een PGO-app (Persoonlijke Gezondheidsomgeving).
|
||||||
|
|
||||||
|
**Voor GGZ is de "Basisgegevens GGZ 2.0" specificatie relevant:**
|
||||||
|
- 24 zorginformatiebouwstenen (ZIBs)
|
||||||
|
- Inclusief: diagnoses, medicatie, behandelplan, contactmomenten
|
||||||
|
|
||||||
|
**Ons datamodel ondersteunt dit omdat:**
|
||||||
|
- Alle velden volgen de MedMIJ FHIR profielen
|
||||||
|
- DSM-5 codes zijn opgenomen
|
||||||
|
- Juridische status kan vastgelegd worden
|
||||||
|
- Medicatie volgens MedicatieProces 9.0
|
||||||
|
|
||||||
|
**In de toekomst kunnen we:**
|
||||||
|
- Een FHIR API bouwen die MedMIJ-compliant is
|
||||||
|
- Cliënten toegang geven tot hun eigen dossier via een PGO-app
|
||||||
|
- Automatisch gegevens uitwisselen met andere zorgaanbieders
|
||||||
|
|
||||||
|
### **Koppeltaal - eHealth Apps**
|
||||||
|
Koppeltaal is de standaard waarmee GGZ-instellingen eHealth apps kunnen koppelen aan hun EPD.
|
||||||
|
|
||||||
|
**Voorbeeld:**
|
||||||
|
Behandelaar schrijft voor: "Doe dagelijks de mindfulness oefening in app MindDistrict"
|
||||||
|
→ Koppeltaal zorgt dat dit automatisch in het EPD en in de app komt te staan
|
||||||
|
→ Voortgang komt automatisch terug in het EPD
|
||||||
|
|
||||||
|
**Ons datamodel ondersteunt dit omdat:**
|
||||||
|
- CarePlan resource volgt Koppeltaal specificaties
|
||||||
|
- Activities kunnen gekoppeld worden aan externe apps
|
||||||
|
- Status updates worden automatisch verwerkt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & Beveiliging
|
||||||
|
|
||||||
|
### **Encryptie**
|
||||||
|
- BSN wordt versleuteld opgeslagen
|
||||||
|
- Communicatie via HTTPS/TLS
|
||||||
|
|
||||||
|
### **Toegangscontrole (RLS)**
|
||||||
|
- Behandelaren zien alleen hun eigen cliënten
|
||||||
|
- Cliënten kunnen later hun eigen data inzien (via patiëntenportaal)
|
||||||
|
- Auditlog houdt bij wie wat wanneer heeft bekeken
|
||||||
|
|
||||||
|
### **AVG-compliance**
|
||||||
|
- Recht op inzage: cliënt kan eigen data opvragen
|
||||||
|
- Recht op vergetelheid: data kan verwijderd worden
|
||||||
|
- Logging: alle acties worden gelogd
|
||||||
|
- Bewaartermijnen: automatische archivering na X jaar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische implementatie
|
||||||
|
|
||||||
|
### **Database: PostgreSQL (Supabase)**
|
||||||
|
- Type-safe met ENUMs voor statussen
|
||||||
|
- Automatische timestamps (created_at, updated_at)
|
||||||
|
- Foreign keys voor relaties
|
||||||
|
- Indexes voor performance
|
||||||
|
|
||||||
|
### **Veldnamen volgen FHIR**
|
||||||
|
Bijvoorbeeld:
|
||||||
|
- `name_family` → Patient.name.family
|
||||||
|
- `code_code` → Condition.code.coding.code
|
||||||
|
- `clinical_status` → Condition.clinicalStatus
|
||||||
|
|
||||||
|
Dit maakt het later makkelijk om FHIR JSON te genereren.
|
||||||
|
|
||||||
|
### **Later: FHIR API endpoints**
|
||||||
|
```
|
||||||
|
GET /fhir/Patient/{id}
|
||||||
|
GET /fhir/Encounter?patient={id}
|
||||||
|
GET /fhir/Condition?patient={id}
|
||||||
|
GET /fhir/CarePlan?patient={id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wat betekent dit voor gebruikers?
|
||||||
|
|
||||||
|
### **Voor behandelaren:**
|
||||||
|
- Alle data is logisch gestructureerd
|
||||||
|
- Diagnoses zijn gekoppeld aan intake-moment
|
||||||
|
- Behandelplan volgt automatisch uit diagnose
|
||||||
|
- ROM-scores zijn zichtbaar in tijdlijn
|
||||||
|
|
||||||
|
### **Voor cliënten (in toekomst):**
|
||||||
|
- Eigen dossier inzien via app
|
||||||
|
- Behandelplan en afspraken zien
|
||||||
|
- ROM-vragenlijsten invullen via app
|
||||||
|
- Resultaten direct naar behandelaar
|
||||||
|
|
||||||
|
### **Voor beheerders:**
|
||||||
|
- Export naar andere systemen is mogelijk
|
||||||
|
- Backups bevatten FHIR-compliant data
|
||||||
|
- Audits en rapportages zijn eenvoudig
|
||||||
|
- Geen vendor lock-in
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### **Fase 1: MVP (nu)**
|
||||||
|
✅ Database schema met alle FHIR resources
|
||||||
|
✅ Intake → Diagnose → Behandelplan workflow
|
||||||
|
✅ Basis toegangscontrole
|
||||||
|
|
||||||
|
### **Fase 2: Basis functionaliteit**
|
||||||
|
🔲 UI voor alle resources
|
||||||
|
🔲 AI-assistentie voor intake
|
||||||
|
🔲 ROM-metingen integratie
|
||||||
|
|
||||||
|
### **Fase 3: Integraties**
|
||||||
|
🔲 FHIR API endpoints
|
||||||
|
🔲 MedMIJ aansluiting (patiëntportaal)
|
||||||
|
🔲 Koppeltaal aansluiting (eHealth apps)
|
||||||
|
🔲 LSP medicatie-uitwisseling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenties
|
||||||
|
|
||||||
|
- **FHIR Specificatie:** https://hl7.org/fhir/
|
||||||
|
- **MedMIJ GGZ:** https://informatiestandaarden.nictiz.nl/wiki/MedMij:V2020.01/OntwerpGGZ
|
||||||
|
- **Koppeltaal:** https://www.koppeltaal.nl/
|
||||||
|
- **ZIBs (ZorgInformatieBouwstenen):** https://zibs.nl/
|
||||||
|
- **DSM-5 Codes:** American Psychiatric Association
|
||||||
|
- **MedicatieProces 9.0:** https://informatiestandaarden.nictiz.nl/wiki/mp:V9
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Laatst bijgewerkt:** 21 november 2024
|
||||||
|
**Auteur:** Colin Lit (ikbenlit.nl)
|
||||||
|
**Project:** AI Speedrun - Mini-ECD
|
||||||
@@ -62,6 +62,103 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
care_plans: {
|
||||||
|
Row: {
|
||||||
|
activities: Json | null
|
||||||
|
addresses_condition_ids: string[] | null
|
||||||
|
author_id: string | null
|
||||||
|
care_team_ids: string[] | null
|
||||||
|
category_code: string | null
|
||||||
|
category_display: string | null
|
||||||
|
contributor_ids: string[] | null
|
||||||
|
created_at: string | null
|
||||||
|
created_date: string | null
|
||||||
|
description: string | null
|
||||||
|
encounter_id: string | null
|
||||||
|
goals: Json | null
|
||||||
|
id: string
|
||||||
|
identifier: string | null
|
||||||
|
intent: string
|
||||||
|
note: string | null
|
||||||
|
patient_id: string
|
||||||
|
period_end: string | null
|
||||||
|
period_start: string | null
|
||||||
|
status: Database["public"]["Enums"]["careplan_status"]
|
||||||
|
title: string
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
activities?: Json | null
|
||||||
|
addresses_condition_ids?: string[] | null
|
||||||
|
author_id?: string | null
|
||||||
|
care_team_ids?: string[] | null
|
||||||
|
category_code?: string | null
|
||||||
|
category_display?: string | null
|
||||||
|
contributor_ids?: string[] | null
|
||||||
|
created_at?: string | null
|
||||||
|
created_date?: string | null
|
||||||
|
description?: string | null
|
||||||
|
encounter_id?: string | null
|
||||||
|
goals?: Json | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
intent?: string
|
||||||
|
note?: string | null
|
||||||
|
patient_id: string
|
||||||
|
period_end?: string | null
|
||||||
|
period_start?: string | null
|
||||||
|
status?: Database["public"]["Enums"]["careplan_status"]
|
||||||
|
title: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
activities?: Json | null
|
||||||
|
addresses_condition_ids?: string[] | null
|
||||||
|
author_id?: string | null
|
||||||
|
care_team_ids?: string[] | null
|
||||||
|
category_code?: string | null
|
||||||
|
category_display?: string | null
|
||||||
|
contributor_ids?: string[] | null
|
||||||
|
created_at?: string | null
|
||||||
|
created_date?: string | null
|
||||||
|
description?: string | null
|
||||||
|
encounter_id?: string | null
|
||||||
|
goals?: Json | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
intent?: string
|
||||||
|
note?: string | null
|
||||||
|
patient_id?: string
|
||||||
|
period_end?: string | null
|
||||||
|
period_start?: string | null
|
||||||
|
status?: Database["public"]["Enums"]["careplan_status"]
|
||||||
|
title?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "care_plans_author_id_fkey"
|
||||||
|
columns: ["author_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "practitioners"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "care_plans_encounter_id_fkey"
|
||||||
|
columns: ["encounter_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "encounters"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "care_plans_patient_id_fkey"
|
||||||
|
columns: ["patient_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "patients"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
clients: {
|
clients: {
|
||||||
Row: {
|
Row: {
|
||||||
birth_date: string
|
birth_date: string
|
||||||
@@ -89,6 +186,256 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
conditions: {
|
||||||
|
Row: {
|
||||||
|
abatement_age: number | null
|
||||||
|
abatement_datetime: string | null
|
||||||
|
asserter_id: string | null
|
||||||
|
body_site_code: string | null
|
||||||
|
body_site_display: string | null
|
||||||
|
category: string
|
||||||
|
clinical_status: Database["public"]["Enums"]["condition_clinical_status"]
|
||||||
|
code_code: string
|
||||||
|
code_display: string
|
||||||
|
code_system: string
|
||||||
|
created_at: string | null
|
||||||
|
encounter_id: string | null
|
||||||
|
id: string
|
||||||
|
identifier: string | null
|
||||||
|
note: string | null
|
||||||
|
onset_age: number | null
|
||||||
|
onset_datetime: string | null
|
||||||
|
patient_id: string
|
||||||
|
recorded_date: string
|
||||||
|
recorder_id: string | null
|
||||||
|
severity_code: string | null
|
||||||
|
severity_display: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
verification_status: Database["public"]["Enums"]["condition_verification_status"]
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
abatement_age?: number | null
|
||||||
|
abatement_datetime?: string | null
|
||||||
|
asserter_id?: string | null
|
||||||
|
body_site_code?: string | null
|
||||||
|
body_site_display?: string | null
|
||||||
|
category?: string
|
||||||
|
clinical_status?: Database["public"]["Enums"]["condition_clinical_status"]
|
||||||
|
code_code: string
|
||||||
|
code_display: string
|
||||||
|
code_system?: string
|
||||||
|
created_at?: string | null
|
||||||
|
encounter_id?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
note?: string | null
|
||||||
|
onset_age?: number | null
|
||||||
|
onset_datetime?: string | null
|
||||||
|
patient_id: string
|
||||||
|
recorded_date?: string
|
||||||
|
recorder_id?: string | null
|
||||||
|
severity_code?: string | null
|
||||||
|
severity_display?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
verification_status?: Database["public"]["Enums"]["condition_verification_status"]
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
abatement_age?: number | null
|
||||||
|
abatement_datetime?: string | null
|
||||||
|
asserter_id?: string | null
|
||||||
|
body_site_code?: string | null
|
||||||
|
body_site_display?: string | null
|
||||||
|
category?: string
|
||||||
|
clinical_status?: Database["public"]["Enums"]["condition_clinical_status"]
|
||||||
|
code_code?: string
|
||||||
|
code_display?: string
|
||||||
|
code_system?: string
|
||||||
|
created_at?: string | null
|
||||||
|
encounter_id?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
note?: string | null
|
||||||
|
onset_age?: number | null
|
||||||
|
onset_datetime?: string | null
|
||||||
|
patient_id?: string
|
||||||
|
recorded_date?: string
|
||||||
|
recorder_id?: string | null
|
||||||
|
severity_code?: string | null
|
||||||
|
severity_display?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
verification_status?: Database["public"]["Enums"]["condition_verification_status"]
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "conditions_asserter_id_fkey"
|
||||||
|
columns: ["asserter_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "practitioners"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "conditions_encounter_id_fkey"
|
||||||
|
columns: ["encounter_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "encounters"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "conditions_patient_id_fkey"
|
||||||
|
columns: ["patient_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "patients"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "conditions_recorder_id_fkey"
|
||||||
|
columns: ["recorder_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "practitioners"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
demo_users: {
|
||||||
|
Row: {
|
||||||
|
access_level: string
|
||||||
|
created_at: string
|
||||||
|
expires_at: string | null
|
||||||
|
id: string
|
||||||
|
last_login_at: string | null
|
||||||
|
notes: string | null
|
||||||
|
updated_at: string
|
||||||
|
usage_count: number | null
|
||||||
|
user_id: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
access_level?: string
|
||||||
|
created_at?: string
|
||||||
|
expires_at?: string | null
|
||||||
|
id?: string
|
||||||
|
last_login_at?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
usage_count?: number | null
|
||||||
|
user_id?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
access_level?: string
|
||||||
|
created_at?: string
|
||||||
|
expires_at?: string | null
|
||||||
|
id?: string
|
||||||
|
last_login_at?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
usage_count?: number | null
|
||||||
|
user_id?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
encounters: {
|
||||||
|
Row: {
|
||||||
|
admission_source: string | null
|
||||||
|
class_code: string
|
||||||
|
class_display: string
|
||||||
|
created_at: string | null
|
||||||
|
discharge_disposition: string | null
|
||||||
|
id: string
|
||||||
|
identifier: string | null
|
||||||
|
intake_note_id: string | null
|
||||||
|
notes: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
patient_id: string
|
||||||
|
period_end: string | null
|
||||||
|
period_start: string
|
||||||
|
practitioner_id: string | null
|
||||||
|
priority_code: string | null
|
||||||
|
priority_display: string | null
|
||||||
|
reason_code: string[] | null
|
||||||
|
reason_display: string[] | null
|
||||||
|
status: Database["public"]["Enums"]["encounter_status"]
|
||||||
|
type_code: string
|
||||||
|
type_display: string
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
admission_source?: string | null
|
||||||
|
class_code: string
|
||||||
|
class_display: string
|
||||||
|
created_at?: string | null
|
||||||
|
discharge_disposition?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
intake_note_id?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
organization_id?: string | null
|
||||||
|
patient_id: string
|
||||||
|
period_end?: string | null
|
||||||
|
period_start: string
|
||||||
|
practitioner_id?: string | null
|
||||||
|
priority_code?: string | null
|
||||||
|
priority_display?: string | null
|
||||||
|
reason_code?: string[] | null
|
||||||
|
reason_display?: string[] | null
|
||||||
|
status?: Database["public"]["Enums"]["encounter_status"]
|
||||||
|
type_code: string
|
||||||
|
type_display: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
admission_source?: string | null
|
||||||
|
class_code?: string
|
||||||
|
class_display?: string
|
||||||
|
created_at?: string | null
|
||||||
|
discharge_disposition?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
intake_note_id?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
organization_id?: string | null
|
||||||
|
patient_id?: string
|
||||||
|
period_end?: string | null
|
||||||
|
period_start?: string
|
||||||
|
practitioner_id?: string | null
|
||||||
|
priority_code?: string | null
|
||||||
|
priority_display?: string | null
|
||||||
|
reason_code?: string[] | null
|
||||||
|
reason_display?: string[] | null
|
||||||
|
status?: Database["public"]["Enums"]["encounter_status"]
|
||||||
|
type_code?: string
|
||||||
|
type_display?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "encounters_intake_note_id_fkey"
|
||||||
|
columns: ["intake_note_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "intake_notes"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "encounters_organization_id_fkey"
|
||||||
|
columns: ["organization_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "organizations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "encounters_patient_id_fkey"
|
||||||
|
columns: ["patient_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "patients"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "encounters_practitioner_id_fkey"
|
||||||
|
columns: ["practitioner_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "practitioners"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
intake_notes: {
|
intake_notes: {
|
||||||
Row: {
|
Row: {
|
||||||
author: string | null
|
author: string | null
|
||||||
@@ -133,6 +480,319 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
observations: {
|
||||||
|
Row: {
|
||||||
|
body_site: string | null
|
||||||
|
category: string
|
||||||
|
code_code: string
|
||||||
|
code_display: string
|
||||||
|
code_system: string
|
||||||
|
created_at: string | null
|
||||||
|
effective_datetime: string
|
||||||
|
encounter_id: string | null
|
||||||
|
id: string
|
||||||
|
identifier: string | null
|
||||||
|
interpretation_code: string | null
|
||||||
|
interpretation_display: string | null
|
||||||
|
issued: string | null
|
||||||
|
method_code: string | null
|
||||||
|
method_display: string | null
|
||||||
|
note: string | null
|
||||||
|
patient_id: string
|
||||||
|
performer_id: string | null
|
||||||
|
reference_range_high: number | null
|
||||||
|
reference_range_low: number | null
|
||||||
|
reference_range_text: string | null
|
||||||
|
status: Database["public"]["Enums"]["observation_status"]
|
||||||
|
value_boolean: boolean | null
|
||||||
|
value_codeable_concept: Json | null
|
||||||
|
value_quantity_comparator: string | null
|
||||||
|
value_quantity_unit: string | null
|
||||||
|
value_quantity_value: number | null
|
||||||
|
value_string: string | null
|
||||||
|
value_type: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
body_site?: string | null
|
||||||
|
category: string
|
||||||
|
code_code: string
|
||||||
|
code_display: string
|
||||||
|
code_system: string
|
||||||
|
created_at?: string | null
|
||||||
|
effective_datetime: string
|
||||||
|
encounter_id?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
interpretation_code?: string | null
|
||||||
|
interpretation_display?: string | null
|
||||||
|
issued?: string | null
|
||||||
|
method_code?: string | null
|
||||||
|
method_display?: string | null
|
||||||
|
note?: string | null
|
||||||
|
patient_id: string
|
||||||
|
performer_id?: string | null
|
||||||
|
reference_range_high?: number | null
|
||||||
|
reference_range_low?: number | null
|
||||||
|
reference_range_text?: string | null
|
||||||
|
status?: Database["public"]["Enums"]["observation_status"]
|
||||||
|
value_boolean?: boolean | null
|
||||||
|
value_codeable_concept?: Json | null
|
||||||
|
value_quantity_comparator?: string | null
|
||||||
|
value_quantity_unit?: string | null
|
||||||
|
value_quantity_value?: number | null
|
||||||
|
value_string?: string | null
|
||||||
|
value_type: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
body_site?: string | null
|
||||||
|
category?: string
|
||||||
|
code_code?: string
|
||||||
|
code_display?: string
|
||||||
|
code_system?: string
|
||||||
|
created_at?: string | null
|
||||||
|
effective_datetime?: string
|
||||||
|
encounter_id?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier?: string | null
|
||||||
|
interpretation_code?: string | null
|
||||||
|
interpretation_display?: string | null
|
||||||
|
issued?: string | null
|
||||||
|
method_code?: string | null
|
||||||
|
method_display?: string | null
|
||||||
|
note?: string | null
|
||||||
|
patient_id?: string
|
||||||
|
performer_id?: string | null
|
||||||
|
reference_range_high?: number | null
|
||||||
|
reference_range_low?: number | null
|
||||||
|
reference_range_text?: string | null
|
||||||
|
status?: Database["public"]["Enums"]["observation_status"]
|
||||||
|
value_boolean?: boolean | null
|
||||||
|
value_codeable_concept?: Json | null
|
||||||
|
value_quantity_comparator?: string | null
|
||||||
|
value_quantity_unit?: string | null
|
||||||
|
value_quantity_value?: number | null
|
||||||
|
value_string?: string | null
|
||||||
|
value_type?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "observations_encounter_id_fkey"
|
||||||
|
columns: ["encounter_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "encounters"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "observations_patient_id_fkey"
|
||||||
|
columns: ["patient_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "patients"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "observations_performer_id_fkey"
|
||||||
|
columns: ["performer_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "practitioners"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
organizations: {
|
||||||
|
Row: {
|
||||||
|
active: boolean | null
|
||||||
|
address_city: string | null
|
||||||
|
address_country: string | null
|
||||||
|
address_line: string[] | null
|
||||||
|
address_postal_code: string | null
|
||||||
|
alias: string[] | null
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
identifier_agb: string | null
|
||||||
|
identifier_kvk: string | null
|
||||||
|
name: string
|
||||||
|
telecom_email: string | null
|
||||||
|
telecom_phone: string | null
|
||||||
|
telecom_website: string | null
|
||||||
|
type_code: string | null
|
||||||
|
type_display: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
active?: boolean | null
|
||||||
|
address_city?: string | null
|
||||||
|
address_country?: string | null
|
||||||
|
address_line?: string[] | null
|
||||||
|
address_postal_code?: string | null
|
||||||
|
alias?: string[] | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier_agb?: string | null
|
||||||
|
identifier_kvk?: string | null
|
||||||
|
name: string
|
||||||
|
telecom_email?: string | null
|
||||||
|
telecom_phone?: string | null
|
||||||
|
telecom_website?: string | null
|
||||||
|
type_code?: string | null
|
||||||
|
type_display?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
active?: boolean | null
|
||||||
|
address_city?: string | null
|
||||||
|
address_country?: string | null
|
||||||
|
address_line?: string[] | null
|
||||||
|
address_postal_code?: string | null
|
||||||
|
alias?: string[] | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier_agb?: string | null
|
||||||
|
identifier_kvk?: string | null
|
||||||
|
name?: string
|
||||||
|
telecom_email?: string | null
|
||||||
|
telecom_phone?: string | null
|
||||||
|
telecom_website?: string | null
|
||||||
|
type_code?: string | null
|
||||||
|
type_display?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
patients: {
|
||||||
|
Row: {
|
||||||
|
active: boolean | null
|
||||||
|
address_city: string | null
|
||||||
|
address_country: string | null
|
||||||
|
address_line: string[] | null
|
||||||
|
address_postal_code: string | null
|
||||||
|
birth_date: string
|
||||||
|
created_at: string | null
|
||||||
|
emergency_contact_name: string | null
|
||||||
|
emergency_contact_phone: string | null
|
||||||
|
emergency_contact_relationship: string | null
|
||||||
|
gender: Database["public"]["Enums"]["gender_type"]
|
||||||
|
general_practitioner_agb: string | null
|
||||||
|
general_practitioner_name: string | null
|
||||||
|
id: string
|
||||||
|
identifier_bsn: string | null
|
||||||
|
identifier_client_number: string | null
|
||||||
|
insurance_company: string | null
|
||||||
|
insurance_number: string | null
|
||||||
|
name_family: string
|
||||||
|
name_given: string[]
|
||||||
|
name_prefix: string | null
|
||||||
|
name_use: string | null
|
||||||
|
telecom_email: string | null
|
||||||
|
telecom_phone: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
active?: boolean | null
|
||||||
|
address_city?: string | null
|
||||||
|
address_country?: string | null
|
||||||
|
address_line?: string[] | null
|
||||||
|
address_postal_code?: string | null
|
||||||
|
birth_date: string
|
||||||
|
created_at?: string | null
|
||||||
|
emergency_contact_name?: string | null
|
||||||
|
emergency_contact_phone?: string | null
|
||||||
|
emergency_contact_relationship?: string | null
|
||||||
|
gender: Database["public"]["Enums"]["gender_type"]
|
||||||
|
general_practitioner_agb?: string | null
|
||||||
|
general_practitioner_name?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier_bsn?: string | null
|
||||||
|
identifier_client_number?: string | null
|
||||||
|
insurance_company?: string | null
|
||||||
|
insurance_number?: string | null
|
||||||
|
name_family: string
|
||||||
|
name_given: string[]
|
||||||
|
name_prefix?: string | null
|
||||||
|
name_use?: string | null
|
||||||
|
telecom_email?: string | null
|
||||||
|
telecom_phone?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
active?: boolean | null
|
||||||
|
address_city?: string | null
|
||||||
|
address_country?: string | null
|
||||||
|
address_line?: string[] | null
|
||||||
|
address_postal_code?: string | null
|
||||||
|
birth_date?: string
|
||||||
|
created_at?: string | null
|
||||||
|
emergency_contact_name?: string | null
|
||||||
|
emergency_contact_phone?: string | null
|
||||||
|
emergency_contact_relationship?: string | null
|
||||||
|
gender?: Database["public"]["Enums"]["gender_type"]
|
||||||
|
general_practitioner_agb?: string | null
|
||||||
|
general_practitioner_name?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier_bsn?: string | null
|
||||||
|
identifier_client_number?: string | null
|
||||||
|
insurance_company?: string | null
|
||||||
|
insurance_number?: string | null
|
||||||
|
name_family?: string
|
||||||
|
name_given?: string[]
|
||||||
|
name_prefix?: string | null
|
||||||
|
name_use?: string | null
|
||||||
|
telecom_email?: string | null
|
||||||
|
telecom_phone?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
practitioners: {
|
||||||
|
Row: {
|
||||||
|
active: boolean | null
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
identifier_agb: string | null
|
||||||
|
identifier_big: string | null
|
||||||
|
name_family: string
|
||||||
|
name_given: string[]
|
||||||
|
name_prefix: string | null
|
||||||
|
name_suffix: string | null
|
||||||
|
qualification: string[] | null
|
||||||
|
telecom_email: string | null
|
||||||
|
telecom_phone: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
user_id: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
active?: boolean | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier_agb?: string | null
|
||||||
|
identifier_big?: string | null
|
||||||
|
name_family: string
|
||||||
|
name_given: string[]
|
||||||
|
name_prefix?: string | null
|
||||||
|
name_suffix?: string | null
|
||||||
|
qualification?: string[] | null
|
||||||
|
telecom_email?: string | null
|
||||||
|
telecom_phone?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
user_id?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
active?: boolean | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
identifier_agb?: string | null
|
||||||
|
identifier_big?: string | null
|
||||||
|
name_family?: string
|
||||||
|
name_given?: string[]
|
||||||
|
name_prefix?: string | null
|
||||||
|
name_suffix?: string | null
|
||||||
|
qualification?: string[] | null
|
||||||
|
telecom_email?: string | null
|
||||||
|
telecom_phone?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
user_id?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
problem_profiles: {
|
problem_profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
category: string
|
category: string
|
||||||
@@ -230,10 +890,55 @@ export type Database = {
|
|||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
[_ in never]: never
|
get_demo_access_level: {
|
||||||
|
Args: { check_user_id: string }
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
|
hook_check_duplicate_email: { Args: { event: Json }; Returns: Json }
|
||||||
|
is_demo_user: { Args: { check_user_id: string }; Returns: boolean }
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
[_ in never]: never
|
careplan_status:
|
||||||
|
| "draft"
|
||||||
|
| "active"
|
||||||
|
| "on-hold"
|
||||||
|
| "revoked"
|
||||||
|
| "completed"
|
||||||
|
| "entered-in-error"
|
||||||
|
| "unknown"
|
||||||
|
condition_clinical_status:
|
||||||
|
| "active"
|
||||||
|
| "recurrence"
|
||||||
|
| "relapse"
|
||||||
|
| "inactive"
|
||||||
|
| "remission"
|
||||||
|
| "resolved"
|
||||||
|
| "unknown"
|
||||||
|
condition_verification_status:
|
||||||
|
| "unconfirmed"
|
||||||
|
| "provisional"
|
||||||
|
| "differential"
|
||||||
|
| "confirmed"
|
||||||
|
| "refuted"
|
||||||
|
| "entered-in-error"
|
||||||
|
encounter_status:
|
||||||
|
| "planned"
|
||||||
|
| "in-progress"
|
||||||
|
| "on-hold"
|
||||||
|
| "completed"
|
||||||
|
| "cancelled"
|
||||||
|
| "entered-in-error"
|
||||||
|
| "unknown"
|
||||||
|
gender_type: "male" | "female" | "other" | "unknown"
|
||||||
|
observation_status:
|
||||||
|
| "registered"
|
||||||
|
| "preliminary"
|
||||||
|
| "final"
|
||||||
|
| "amended"
|
||||||
|
| "corrected"
|
||||||
|
| "cancelled"
|
||||||
|
| "entered-in-error"
|
||||||
|
| "unknown"
|
||||||
}
|
}
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -360,6 +1065,53 @@ export type CompositeTypes<
|
|||||||
|
|
||||||
export const Constants = {
|
export const Constants = {
|
||||||
public: {
|
public: {
|
||||||
Enums: {},
|
Enums: {
|
||||||
|
careplan_status: [
|
||||||
|
"draft",
|
||||||
|
"active",
|
||||||
|
"on-hold",
|
||||||
|
"revoked",
|
||||||
|
"completed",
|
||||||
|
"entered-in-error",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
condition_clinical_status: [
|
||||||
|
"active",
|
||||||
|
"recurrence",
|
||||||
|
"relapse",
|
||||||
|
"inactive",
|
||||||
|
"remission",
|
||||||
|
"resolved",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
condition_verification_status: [
|
||||||
|
"unconfirmed",
|
||||||
|
"provisional",
|
||||||
|
"differential",
|
||||||
|
"confirmed",
|
||||||
|
"refuted",
|
||||||
|
"entered-in-error",
|
||||||
|
],
|
||||||
|
encounter_status: [
|
||||||
|
"planned",
|
||||||
|
"in-progress",
|
||||||
|
"on-hold",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
"entered-in-error",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
gender_type: ["male", "female", "other", "unknown"],
|
||||||
|
observation_status: [
|
||||||
|
"registered",
|
||||||
|
"preliminary",
|
||||||
|
"final",
|
||||||
|
"amended",
|
||||||
|
"corrected",
|
||||||
|
"cancelled",
|
||||||
|
"entered-in-error",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
8
lib/fhir/index.ts
Normal file
8
lib/fhir/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Library
|
||||||
|
* Main entry point for FHIR functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './transforms';
|
||||||
|
export * from './utils';
|
||||||
7
lib/fhir/transforms/index.ts
Normal file
7
lib/fhir/transforms/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Transforms
|
||||||
|
* Export all transform functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './patient';
|
||||||
|
export * from './practitioner';
|
||||||
198
lib/fhir/transforms/patient.ts
Normal file
198
lib/fhir/transforms/patient.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Patient Transforms
|
||||||
|
* Convert between Database rows and FHIR Patient resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tables, TablesInsert } from '../../database.types';
|
||||||
|
import type { FHIRPatient } from '../types';
|
||||||
|
|
||||||
|
type PatientRow = Tables<'patients'>;
|
||||||
|
type PatientInsert = TablesInsert<'patients'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform database Patient row to FHIR Patient resource
|
||||||
|
*/
|
||||||
|
export function dbPatientToFHIR(row: PatientRow): FHIRPatient {
|
||||||
|
return {
|
||||||
|
resourceType: 'Patient',
|
||||||
|
id: row.id,
|
||||||
|
|
||||||
|
// Identifiers (BSN, client number)
|
||||||
|
identifier: [
|
||||||
|
{
|
||||||
|
system: 'http://fhir.nl/fhir/NamingSystem/bsn',
|
||||||
|
value: row.identifier_bsn || undefined,
|
||||||
|
use: 'official' as const,
|
||||||
|
},
|
||||||
|
row.identifier_client_number
|
||||||
|
? {
|
||||||
|
system: 'urn:oid:2.16.840.1.113883.2.4.3.11.999.7.6',
|
||||||
|
value: row.identifier_client_number,
|
||||||
|
use: 'usual' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
].filter((x): x is NonNullable<typeof x> => x !== undefined),
|
||||||
|
|
||||||
|
// Active status
|
||||||
|
active: row.active ?? true,
|
||||||
|
|
||||||
|
// Name
|
||||||
|
name: [
|
||||||
|
{
|
||||||
|
use: (row.name_use as any) || 'official',
|
||||||
|
family: row.name_family,
|
||||||
|
given: row.name_given,
|
||||||
|
prefix: row.name_prefix ? [row.name_prefix] : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Telecom (contact)
|
||||||
|
telecom: [
|
||||||
|
row.telecom_phone
|
||||||
|
? {
|
||||||
|
system: 'phone' as const,
|
||||||
|
value: row.telecom_phone,
|
||||||
|
use: 'mobile' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
row.telecom_email
|
||||||
|
? {
|
||||||
|
system: 'email' as const,
|
||||||
|
value: row.telecom_email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
].filter((x): x is NonNullable<typeof x> => x !== undefined),
|
||||||
|
|
||||||
|
// Gender
|
||||||
|
gender: row.gender,
|
||||||
|
|
||||||
|
// Birth date
|
||||||
|
birthDate: row.birth_date,
|
||||||
|
|
||||||
|
// Address
|
||||||
|
address: row.address_line
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
use: 'home',
|
||||||
|
line: row.address_line,
|
||||||
|
city: row.address_city || undefined,
|
||||||
|
postalCode: row.address_postal_code || undefined,
|
||||||
|
country: row.address_country || 'NL',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Contact person (emergency contact)
|
||||||
|
contact: row.emergency_contact_name
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
relationship: [
|
||||||
|
{
|
||||||
|
coding: [
|
||||||
|
{
|
||||||
|
system: 'http://terminology.hl7.org/CodeSystem/v2-0131',
|
||||||
|
code: 'C',
|
||||||
|
display: row.emergency_contact_relationship || 'Emergency Contact',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: {
|
||||||
|
text: row.emergency_contact_name,
|
||||||
|
},
|
||||||
|
telecom: row.emergency_contact_phone
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
system: 'phone',
|
||||||
|
value: row.emergency_contact_phone,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// General Practitioner
|
||||||
|
generalPractitioner: row.general_practitioner_name
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
display: row.general_practitioner_name,
|
||||||
|
identifier: row.general_practitioner_agb
|
||||||
|
? {
|
||||||
|
system: 'http://fhir.nl/fhir/NamingSystem/agb-z',
|
||||||
|
value: row.general_practitioner_agb,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Meta (timestamps)
|
||||||
|
meta: {
|
||||||
|
lastUpdated: row.updated_at || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform FHIR Patient resource to database insert
|
||||||
|
*/
|
||||||
|
export function fhirPatientToDB(fhir: FHIRPatient): PatientInsert {
|
||||||
|
// Extract BSN from identifiers
|
||||||
|
const bsn = fhir.identifier?.find(
|
||||||
|
(i) => i.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
// Extract client number from identifiers
|
||||||
|
const clientNumber = fhir.identifier?.find(
|
||||||
|
(i) => i.system === 'urn:oid:2.16.840.1.113883.2.4.3.11.999.7.6'
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
// Extract name (use first name)
|
||||||
|
const name = fhir.name?.[0];
|
||||||
|
|
||||||
|
// Extract address (use first address)
|
||||||
|
const address = fhir.address?.[0];
|
||||||
|
|
||||||
|
// Extract phone and email from telecom
|
||||||
|
const phone = fhir.telecom?.find((t) => t.system === 'phone')?.value;
|
||||||
|
const email = fhir.telecom?.find((t) => t.system === 'email')?.value;
|
||||||
|
|
||||||
|
// Extract emergency contact
|
||||||
|
const emergencyContact = fhir.contact?.[0];
|
||||||
|
const emergencyContactName = emergencyContact?.name?.text;
|
||||||
|
const emergencyContactPhone = emergencyContact?.telecom?.find(
|
||||||
|
(t) => t.system === 'phone'
|
||||||
|
)?.value;
|
||||||
|
const emergencyContactRelationship =
|
||||||
|
emergencyContact?.relationship?.[0]?.coding?.[0]?.display;
|
||||||
|
|
||||||
|
// Extract general practitioner
|
||||||
|
const gp = fhir.generalPractitioner?.[0];
|
||||||
|
const gpName = gp?.display;
|
||||||
|
const gpAgb = gp?.identifier?.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fhir.id,
|
||||||
|
identifier_bsn: bsn || '999999990', // Default placeholder
|
||||||
|
identifier_client_number: clientNumber || undefined,
|
||||||
|
name_family: name?.family || '',
|
||||||
|
name_given: name?.given || [],
|
||||||
|
name_prefix: name?.prefix?.[0] || undefined,
|
||||||
|
name_use: name?.use || 'official',
|
||||||
|
birth_date: fhir.birthDate || '',
|
||||||
|
gender: fhir.gender || 'unknown',
|
||||||
|
telecom_phone: phone || undefined,
|
||||||
|
telecom_email: email || undefined,
|
||||||
|
address_line: address?.line || undefined,
|
||||||
|
address_city: address?.city || undefined,
|
||||||
|
address_postal_code: address?.postalCode || undefined,
|
||||||
|
address_country: address?.country || 'NL',
|
||||||
|
emergency_contact_name: emergencyContactName || undefined,
|
||||||
|
emergency_contact_phone: emergencyContactPhone || undefined,
|
||||||
|
emergency_contact_relationship: emergencyContactRelationship || undefined,
|
||||||
|
general_practitioner_name: gpName || undefined,
|
||||||
|
general_practitioner_agb: gpAgb || undefined,
|
||||||
|
active: fhir.active ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
133
lib/fhir/transforms/practitioner.ts
Normal file
133
lib/fhir/transforms/practitioner.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Practitioner Transforms
|
||||||
|
* Convert between Database rows and FHIR Practitioner resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tables, TablesInsert } from '../../database.types';
|
||||||
|
import type { FHIRPractitioner } from '../types';
|
||||||
|
|
||||||
|
type PractitionerRow = Tables<'practitioners'>;
|
||||||
|
type PractitionerInsert = TablesInsert<'practitioners'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform database Practitioner row to FHIR Practitioner resource
|
||||||
|
*/
|
||||||
|
export function dbPractitionerToFHIR(row: PractitionerRow): FHIRPractitioner {
|
||||||
|
return {
|
||||||
|
resourceType: 'Practitioner',
|
||||||
|
id: row.id,
|
||||||
|
|
||||||
|
// Identifiers (BIG, AGB)
|
||||||
|
identifier: [
|
||||||
|
row.identifier_big
|
||||||
|
? {
|
||||||
|
system: 'http://fhir.nl/fhir/NamingSystem/big',
|
||||||
|
value: row.identifier_big,
|
||||||
|
use: 'official' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
row.identifier_agb
|
||||||
|
? {
|
||||||
|
system: 'http://fhir.nl/fhir/NamingSystem/agb-z',
|
||||||
|
value: row.identifier_agb,
|
||||||
|
use: 'official' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
].filter((x): x is NonNullable<typeof x> => x !== undefined),
|
||||||
|
|
||||||
|
// Active status
|
||||||
|
active: row.active ?? true,
|
||||||
|
|
||||||
|
// Name
|
||||||
|
name: [
|
||||||
|
{
|
||||||
|
use: 'official',
|
||||||
|
family: row.name_family,
|
||||||
|
given: row.name_given,
|
||||||
|
prefix: row.name_prefix ? [row.name_prefix] : undefined,
|
||||||
|
suffix: row.name_suffix ? [row.name_suffix] : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Telecom (contact)
|
||||||
|
telecom: [
|
||||||
|
row.telecom_phone
|
||||||
|
? {
|
||||||
|
system: 'phone' as const,
|
||||||
|
value: row.telecom_phone,
|
||||||
|
use: 'work' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
row.telecom_email
|
||||||
|
? {
|
||||||
|
system: 'email' as const,
|
||||||
|
value: row.telecom_email,
|
||||||
|
use: 'work' as const,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
].filter((x): x is NonNullable<typeof x> => x !== undefined),
|
||||||
|
|
||||||
|
// Qualifications (professional titles and specializations)
|
||||||
|
qualification: row.qualification
|
||||||
|
? row.qualification.map((qual) => ({
|
||||||
|
code: {
|
||||||
|
coding: [
|
||||||
|
{
|
||||||
|
system: 'http://terminology.hl7.org/CodeSystem/v2-0360',
|
||||||
|
display: qual,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: qual,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Meta (timestamps)
|
||||||
|
meta: {
|
||||||
|
lastUpdated: row.updated_at || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform FHIR Practitioner resource to database insert
|
||||||
|
*/
|
||||||
|
export function fhirPractitionerToDB(
|
||||||
|
fhir: FHIRPractitioner
|
||||||
|
): PractitionerInsert {
|
||||||
|
// Extract BIG from identifiers
|
||||||
|
const big = fhir.identifier?.find(
|
||||||
|
(i) => i.system === 'http://fhir.nl/fhir/NamingSystem/big'
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
// Extract AGB from identifiers
|
||||||
|
const agb = fhir.identifier?.find(
|
||||||
|
(i) => i.system === 'http://fhir.nl/fhir/NamingSystem/agb-z'
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
// Extract name (use first name)
|
||||||
|
const name = fhir.name?.[0];
|
||||||
|
|
||||||
|
// Extract phone and email from telecom
|
||||||
|
const phone = fhir.telecom?.find((t) => t.system === 'phone')?.value;
|
||||||
|
const email = fhir.telecom?.find((t) => t.system === 'email')?.value;
|
||||||
|
|
||||||
|
// Extract qualifications
|
||||||
|
const qualifications = fhir.qualification?.map(
|
||||||
|
(q) => q.code.text || q.code.coding?.[0]?.display || ''
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fhir.id,
|
||||||
|
identifier_big: big || undefined,
|
||||||
|
identifier_agb: agb || undefined,
|
||||||
|
name_prefix: name?.prefix?.[0] || undefined,
|
||||||
|
name_given: name?.given || [],
|
||||||
|
name_family: name?.family || '',
|
||||||
|
name_suffix: name?.suffix?.[0] || undefined,
|
||||||
|
qualification: qualifications || undefined,
|
||||||
|
telecom_phone: phone || undefined,
|
||||||
|
telecom_email: email || undefined,
|
||||||
|
active: fhir.active ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
220
lib/fhir/types/index.ts
Normal file
220
lib/fhir/types/index.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* FHIR R4 Type Definitions
|
||||||
|
* Simplified types for pragmatic implementation
|
||||||
|
* Based on: http://hl7.org/fhir/R4/
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common FHIR Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FHIRIdentifier {
|
||||||
|
system?: string;
|
||||||
|
value?: string;
|
||||||
|
use?: 'usual' | 'official' | 'temp' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FHIRHumanName {
|
||||||
|
use?: 'usual' | 'official' | 'temp' | 'nickname' | 'anonymous' | 'old' | 'maiden';
|
||||||
|
text?: string;
|
||||||
|
family?: string;
|
||||||
|
given?: string[];
|
||||||
|
prefix?: string[];
|
||||||
|
suffix?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FHIRContactPoint {
|
||||||
|
system?: 'phone' | 'fax' | 'email' | 'pager' | 'url' | 'sms' | 'other';
|
||||||
|
value?: string;
|
||||||
|
use?: 'home' | 'work' | 'temp' | 'old' | 'mobile';
|
||||||
|
rank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FHIRAddress {
|
||||||
|
use?: 'home' | 'work' | 'temp' | 'old' | 'billing';
|
||||||
|
type?: 'postal' | 'physical' | 'both';
|
||||||
|
text?: string;
|
||||||
|
line?: string[];
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FHIRCodeableConcept {
|
||||||
|
coding?: Array<{
|
||||||
|
system?: string;
|
||||||
|
version?: string;
|
||||||
|
code?: string;
|
||||||
|
display?: string;
|
||||||
|
}>;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FHIRReference {
|
||||||
|
reference?: string;
|
||||||
|
type?: string;
|
||||||
|
identifier?: FHIRIdentifier;
|
||||||
|
display?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FHIRMeta {
|
||||||
|
versionId?: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
source?: string;
|
||||||
|
profile?: string[];
|
||||||
|
security?: FHIRCodeableConcept[];
|
||||||
|
tag?: FHIRCodeableConcept[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FHIR Patient Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FHIRPatient {
|
||||||
|
resourceType: 'Patient';
|
||||||
|
id?: string;
|
||||||
|
meta?: FHIRMeta;
|
||||||
|
implicitRules?: string;
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
// Patient fields
|
||||||
|
identifier?: FHIRIdentifier[];
|
||||||
|
active?: boolean;
|
||||||
|
name?: FHIRHumanName[];
|
||||||
|
telecom?: FHIRContactPoint[];
|
||||||
|
gender?: 'male' | 'female' | 'other' | 'unknown';
|
||||||
|
birthDate?: string;
|
||||||
|
deceasedBoolean?: boolean;
|
||||||
|
deceasedDateTime?: string;
|
||||||
|
address?: FHIRAddress[];
|
||||||
|
maritalStatus?: FHIRCodeableConcept;
|
||||||
|
multipleBirthBoolean?: boolean;
|
||||||
|
multipleBirthInteger?: number;
|
||||||
|
photo?: Array<{
|
||||||
|
contentType?: string;
|
||||||
|
data?: string;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
contact?: Array<{
|
||||||
|
relationship?: FHIRCodeableConcept[];
|
||||||
|
name?: FHIRHumanName;
|
||||||
|
telecom?: FHIRContactPoint[];
|
||||||
|
address?: FHIRAddress;
|
||||||
|
gender?: 'male' | 'female' | 'other' | 'unknown';
|
||||||
|
organization?: FHIRReference;
|
||||||
|
}>;
|
||||||
|
communication?: Array<{
|
||||||
|
language: FHIRCodeableConcept;
|
||||||
|
preferred?: boolean;
|
||||||
|
}>;
|
||||||
|
generalPractitioner?: FHIRReference[];
|
||||||
|
managingOrganization?: FHIRReference;
|
||||||
|
link?: Array<{
|
||||||
|
other: FHIRReference;
|
||||||
|
type: 'replaced-by' | 'replaces' | 'refer' | 'seealso';
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FHIR Practitioner Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FHIRPractitioner {
|
||||||
|
resourceType: 'Practitioner';
|
||||||
|
id?: string;
|
||||||
|
meta?: FHIRMeta;
|
||||||
|
implicitRules?: string;
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
// Practitioner fields
|
||||||
|
identifier?: FHIRIdentifier[];
|
||||||
|
active?: boolean;
|
||||||
|
name?: FHIRHumanName[];
|
||||||
|
telecom?: FHIRContactPoint[];
|
||||||
|
address?: FHIRAddress[];
|
||||||
|
gender?: 'male' | 'female' | 'other' | 'unknown';
|
||||||
|
birthDate?: string;
|
||||||
|
photo?: Array<{
|
||||||
|
contentType?: string;
|
||||||
|
data?: string;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
qualification?: Array<{
|
||||||
|
identifier?: FHIRIdentifier[];
|
||||||
|
code: FHIRCodeableConcept;
|
||||||
|
period?: {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
};
|
||||||
|
issuer?: FHIRReference;
|
||||||
|
}>;
|
||||||
|
communication?: FHIRCodeableConcept[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FHIR Organization Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FHIROrganization {
|
||||||
|
resourceType: 'Organization';
|
||||||
|
id?: string;
|
||||||
|
meta?: FHIRMeta;
|
||||||
|
|
||||||
|
identifier?: FHIRIdentifier[];
|
||||||
|
active?: boolean;
|
||||||
|
type?: FHIRCodeableConcept[];
|
||||||
|
name?: string;
|
||||||
|
alias?: string[];
|
||||||
|
telecom?: FHIRContactPoint[];
|
||||||
|
address?: FHIRAddress[];
|
||||||
|
partOf?: FHIRReference;
|
||||||
|
contact?: Array<{
|
||||||
|
purpose?: FHIRCodeableConcept;
|
||||||
|
name?: FHIRHumanName;
|
||||||
|
telecom?: FHIRContactPoint[];
|
||||||
|
address?: FHIRAddress;
|
||||||
|
}>;
|
||||||
|
endpoint?: FHIRReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FHIR Bundle for search results
|
||||||
|
*/
|
||||||
|
export interface FHIRBundle<T = any> {
|
||||||
|
resourceType: 'Bundle';
|
||||||
|
type: 'searchset' | 'collection' | 'transaction' | 'transaction-response' | 'batch' | 'batch-response' | 'history' | 'document' | 'message';
|
||||||
|
total?: number;
|
||||||
|
link?: Array<{
|
||||||
|
relation: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
entry?: Array<{
|
||||||
|
fullUrl?: string;
|
||||||
|
resource?: T;
|
||||||
|
search?: {
|
||||||
|
mode?: 'match' | 'include' | 'outcome';
|
||||||
|
score?: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FHIR OperationOutcome for errors
|
||||||
|
*/
|
||||||
|
export interface FHIROperationOutcome {
|
||||||
|
resourceType: 'OperationOutcome';
|
||||||
|
issue: Array<{
|
||||||
|
severity: 'fatal' | 'error' | 'warning' | 'information';
|
||||||
|
code: string;
|
||||||
|
details?: FHIRCodeableConcept;
|
||||||
|
diagnostics?: string;
|
||||||
|
location?: string[];
|
||||||
|
expression?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
93
lib/fhir/utils.ts
Normal file
93
lib/fhir/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Utilities
|
||||||
|
* Helper functions for working with FHIR resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FHIRReference, FHIROperationOutcome } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract resource ID from FHIR reference string
|
||||||
|
* Example: "Patient/123" -> "123"
|
||||||
|
*/
|
||||||
|
export function extractIdFromReference(reference?: string): string | null {
|
||||||
|
if (!reference) return null;
|
||||||
|
const parts = reference.split('/');
|
||||||
|
return parts.length === 2 ? parts[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create FHIR reference from resource type and ID
|
||||||
|
* Example: ("Patient", "123") -> "Patient/123"
|
||||||
|
*/
|
||||||
|
export function createReference(
|
||||||
|
resourceType: string,
|
||||||
|
id: string,
|
||||||
|
display?: string
|
||||||
|
): FHIRReference {
|
||||||
|
return {
|
||||||
|
reference: `${resourceType}/${id}`,
|
||||||
|
type: resourceType,
|
||||||
|
display,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create FHIR OperationOutcome for errors
|
||||||
|
*/
|
||||||
|
export function createOperationOutcome(
|
||||||
|
severity: 'fatal' | 'error' | 'warning' | 'information',
|
||||||
|
code: string,
|
||||||
|
diagnostics: string
|
||||||
|
): FHIROperationOutcome {
|
||||||
|
return {
|
||||||
|
resourceType: 'OperationOutcome',
|
||||||
|
issue: [
|
||||||
|
{
|
||||||
|
severity,
|
||||||
|
code,
|
||||||
|
diagnostics,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate FHIR resource has required fields
|
||||||
|
*/
|
||||||
|
export function validateFHIRResource(
|
||||||
|
resource: any,
|
||||||
|
requiredFields: string[]
|
||||||
|
): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!resource[field]) {
|
||||||
|
errors.push(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to FHIR date format (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
export function toFHIRDate(date: Date | string): string {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date.split('T')[0];
|
||||||
|
}
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format datetime to FHIR datetime format (ISO 8601)
|
||||||
|
*/
|
||||||
|
export function toFHIRDateTime(date: Date | string): string {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
@@ -137,3 +137,95 @@ export async function getCategoryMetadata(): Promise<IndexData> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate slug from heading text (must match the slugify function in mdx-components.tsx)
|
||||||
|
*/
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^\w\-]+/g, '')
|
||||||
|
.replace(/\-\-+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table of Contents item
|
||||||
|
*/
|
||||||
|
export interface TocItem {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract headings from MDX content for Table of Contents
|
||||||
|
* Extracts h1, h2, and h3 headings, filtered for main sections
|
||||||
|
*/
|
||||||
|
export function extractHeadings(content: string): TocItem[] {
|
||||||
|
const headingRegex = /^(#{1,3})\s+(.+)$/gm
|
||||||
|
const headings: TocItem[] = []
|
||||||
|
let match
|
||||||
|
|
||||||
|
// Main sections to include in TOC (h2 level)
|
||||||
|
const includedH2Sections = [
|
||||||
|
'overview',
|
||||||
|
'standaard-compliance',
|
||||||
|
'geimplementeerde-resources',
|
||||||
|
'relaties-tussen-resources',
|
||||||
|
'privacy-beveiliging',
|
||||||
|
'roadmap',
|
||||||
|
'patient-api',
|
||||||
|
'practitioner-api',
|
||||||
|
'encounter-api',
|
||||||
|
'condition-api',
|
||||||
|
'observation-api',
|
||||||
|
'careplan-api',
|
||||||
|
'authenticatie-autorisatie',
|
||||||
|
'error-handling',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Resource subsections to include (h3 level)
|
||||||
|
const includedH3Sections = [
|
||||||
|
'1-practitioners-behandelaren',
|
||||||
|
'2-organizations-instellingen',
|
||||||
|
'3-patients-patientenclienten',
|
||||||
|
'4-encounters-contactmomenten',
|
||||||
|
'5-conditions-diagnoses',
|
||||||
|
'6-observations-metingen-en-observaties',
|
||||||
|
'7-careplans-behandelplannen',
|
||||||
|
]
|
||||||
|
|
||||||
|
while ((match = headingRegex.exec(content)) !== null) {
|
||||||
|
const level = match[1].length
|
||||||
|
const text = match[2]
|
||||||
|
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Remove markdown links
|
||||||
|
.replace(/`([^`]+)`/g, '$1') // Remove inline code
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
|
||||||
|
.replace(/\*([^*]+)\*/g, '$1') // Remove italic
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const id = slugify(text)
|
||||||
|
|
||||||
|
// Include h2 headings from the main sections list
|
||||||
|
if (level === 2 && includedH2Sections.includes(id)) {
|
||||||
|
headings.push({
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Include h3 headings from the resource sections list
|
||||||
|
else if (level === 3 && includedH3Sections.includes(id)) {
|
||||||
|
headings.push({
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headings
|
||||||
|
}
|
||||||
|
|||||||
1058
lib/supabase/20241121_fhir_ggz_schema.sql
Normal file
1058
lib/supabase/20241121_fhir_ggz_schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
704
lib/supabase/20241121_pragmatic_fhir_schema.sql
Normal file
704
lib/supabase/20241121_pragmatic_fhir_schema.sql
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PRAGMATIC FHIR GGZ EPD SCHEMA
|
||||||
|
-- ============================================================================
|
||||||
|
-- Created: 2024-11-21
|
||||||
|
-- Version: Pragmatic v2.0
|
||||||
|
-- Description: Simplified FHIR schema for prototype focused on data interoperability
|
||||||
|
--
|
||||||
|
-- Differences from full schema (20241121_fhir_ggz_schema.sql):
|
||||||
|
-- - Only 7 tables (6 FHIR resources + organizations)
|
||||||
|
-- - Goals embedded in care_plans.goals JSONB (not separate table)
|
||||||
|
-- - Activities embedded in care_plans.activities JSONB (not separate table)
|
||||||
|
-- - No medications, consents, flags, documents tables
|
||||||
|
-- - BSN placeholders (no encryption for demo)
|
||||||
|
-- - Keeps existing tables: clients, intake_notes, treatment_plans, ai_events
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ENABLE EXTENSIONS
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Note: pgcrypto not needed for pragmatic version (no BSN encryption)
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ENUM TYPES (for type safety)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- FHIR Gender
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE gender_type AS ENUM ('male', 'female', 'other', 'unknown');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- FHIR Encounter Status
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE encounter_status AS ENUM (
|
||||||
|
'planned', 'in-progress', 'on-hold', 'completed',
|
||||||
|
'cancelled', 'entered-in-error', 'unknown'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- FHIR Condition Clinical Status
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE condition_clinical_status AS ENUM (
|
||||||
|
'active', 'recurrence', 'relapse', 'inactive',
|
||||||
|
'remission', 'resolved', 'unknown'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- FHIR Condition Verification Status
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE condition_verification_status AS ENUM (
|
||||||
|
'unconfirmed', 'provisional', 'differential',
|
||||||
|
'confirmed', 'refuted', 'entered-in-error'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- FHIR Observation Status
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE observation_status AS ENUM (
|
||||||
|
'registered', 'preliminary', 'final', 'amended',
|
||||||
|
'corrected', 'cancelled', 'entered-in-error', 'unknown'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- FHIR CarePlan Status
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE careplan_status AS ENUM (
|
||||||
|
'draft', 'active', 'on-hold', 'revoked',
|
||||||
|
'completed', 'entered-in-error', 'unknown'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: practitioners (FHIR: Practitioner)
|
||||||
|
-- Behandelaren/professionals
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS practitioners (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR Practitioner fields
|
||||||
|
identifier_big TEXT UNIQUE, -- BIG-nummer (optioneel)
|
||||||
|
identifier_agb TEXT, -- AGB-code
|
||||||
|
|
||||||
|
-- Name (HumanName)
|
||||||
|
name_prefix TEXT, -- "Drs.", "Dr."
|
||||||
|
name_given TEXT[] NOT NULL, -- Voornamen
|
||||||
|
name_family TEXT NOT NULL, -- Achternaam
|
||||||
|
name_suffix TEXT, -- "PhD", "MSc"
|
||||||
|
|
||||||
|
-- Qualification
|
||||||
|
qualification TEXT[], -- ["GZ-psycholoog", "Psychotherapeut"]
|
||||||
|
|
||||||
|
-- Contact
|
||||||
|
telecom_phone TEXT,
|
||||||
|
telecom_email TEXT,
|
||||||
|
|
||||||
|
-- Active
|
||||||
|
active BOOLEAN DEFAULT true,
|
||||||
|
|
||||||
|
-- Link to auth user
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE practitioners IS 'FHIR: Practitioner - Behandelaren en zorgprofessionals';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: organizations (FHIR: Organization)
|
||||||
|
-- GGZ-instellingen
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR Organization fields
|
||||||
|
identifier_agb TEXT UNIQUE, -- AGB-code instelling
|
||||||
|
identifier_kvk TEXT, -- KVK-nummer
|
||||||
|
|
||||||
|
-- Name
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
alias TEXT[], -- Alternative names
|
||||||
|
|
||||||
|
-- Type
|
||||||
|
type_code TEXT DEFAULT 'prov', -- healthcare provider
|
||||||
|
type_display TEXT DEFAULT 'Healthcare Provider',
|
||||||
|
|
||||||
|
-- Contact
|
||||||
|
telecom_phone TEXT,
|
||||||
|
telecom_email TEXT,
|
||||||
|
telecom_website TEXT,
|
||||||
|
|
||||||
|
-- Address
|
||||||
|
address_line TEXT[],
|
||||||
|
address_city TEXT,
|
||||||
|
address_postal_code TEXT,
|
||||||
|
address_country TEXT DEFAULT 'NL',
|
||||||
|
|
||||||
|
-- Active
|
||||||
|
active BOOLEAN DEFAULT true,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE organizations IS 'FHIR: Organization - GGZ-instellingen';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: patients (FHIR: Patient / ZIB: Patient)
|
||||||
|
-- Cliënten/patiënten
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS patients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR Patient.identifier
|
||||||
|
-- PRAGMATIC: No encryption, use placeholder BSN for demo
|
||||||
|
identifier_bsn TEXT DEFAULT '999999990', -- Placeholder BSN
|
||||||
|
identifier_client_number TEXT, -- Interne cliëntnummer
|
||||||
|
|
||||||
|
-- FHIR Patient.name (HumanName)
|
||||||
|
name_family TEXT NOT NULL, -- Achternaam
|
||||||
|
name_given TEXT[] NOT NULL, -- Voornamen array
|
||||||
|
name_prefix TEXT, -- Voorvoegsel (van, de, etc)
|
||||||
|
name_use TEXT DEFAULT 'official', -- official, maiden, nickname
|
||||||
|
|
||||||
|
-- FHIR Patient.birthDate
|
||||||
|
birth_date DATE NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Patient.gender
|
||||||
|
gender gender_type NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Patient.telecom (ContactPoint)
|
||||||
|
telecom_phone TEXT,
|
||||||
|
telecom_email TEXT,
|
||||||
|
|
||||||
|
-- FHIR Patient.address (Address)
|
||||||
|
address_line TEXT[], -- Straat + huisnummer
|
||||||
|
address_city TEXT,
|
||||||
|
address_postal_code TEXT,
|
||||||
|
address_country TEXT DEFAULT 'NL',
|
||||||
|
|
||||||
|
-- Insurance (ZIB: Payer)
|
||||||
|
insurance_company TEXT, -- Zorgverzekeraar
|
||||||
|
insurance_number TEXT, -- Polisnummer
|
||||||
|
|
||||||
|
-- FHIR Patient.contact (naasten)
|
||||||
|
emergency_contact_name TEXT,
|
||||||
|
emergency_contact_relationship TEXT,
|
||||||
|
emergency_contact_phone TEXT,
|
||||||
|
|
||||||
|
-- FHIR Patient.active
|
||||||
|
active BOOLEAN DEFAULT true,
|
||||||
|
|
||||||
|
-- FHIR Patient.generalPractitioner (huisarts)
|
||||||
|
general_practitioner_name TEXT,
|
||||||
|
general_practitioner_agb TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE patients IS 'FHIR: Patient / ZIB: Patient - Cliënten/patiënten (pragmatic version with placeholder BSN)';
|
||||||
|
COMMENT ON COLUMN patients.identifier_bsn IS 'Placeholder BSN for demo (not encrypted in pragmatic version)';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: encounters (FHIR: Encounter / ZIB: Contact)
|
||||||
|
-- Contactmomenten (intake, behandelsessie, etc)
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS encounters (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR Encounter.identifier
|
||||||
|
identifier TEXT UNIQUE DEFAULT gen_random_uuid()::TEXT,
|
||||||
|
|
||||||
|
-- FHIR Encounter.status
|
||||||
|
status encounter_status NOT NULL DEFAULT 'planned',
|
||||||
|
|
||||||
|
-- FHIR Encounter.class
|
||||||
|
class_code TEXT NOT NULL, -- AMB (ambulatory), IMP (inpatient), EMER (emergency), VR (virtual)
|
||||||
|
class_display TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Encounter.type
|
||||||
|
type_code TEXT NOT NULL, -- intake, diagnostiek, behandeling, follow-up, crisis
|
||||||
|
type_display TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Encounter.priority
|
||||||
|
priority_code TEXT, -- routine, urgent, emergency
|
||||||
|
priority_display TEXT,
|
||||||
|
|
||||||
|
-- FHIR Encounter.subject (patient)
|
||||||
|
patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Encounter.participant (behandelaar)
|
||||||
|
practitioner_id UUID REFERENCES practitioners(id),
|
||||||
|
|
||||||
|
-- FHIR Encounter.serviceProvider (instelling)
|
||||||
|
organization_id UUID REFERENCES organizations(id),
|
||||||
|
|
||||||
|
-- FHIR Encounter.period
|
||||||
|
period_start TIMESTAMPTZ NOT NULL,
|
||||||
|
period_end TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- FHIR Encounter.reasonCode
|
||||||
|
reason_code TEXT[], -- DSM-5 codes, SNOMED codes
|
||||||
|
reason_display TEXT[], -- Human-readable reason
|
||||||
|
|
||||||
|
-- FHIR Encounter.hospitalization (indien opname)
|
||||||
|
admission_source TEXT,
|
||||||
|
discharge_disposition TEXT,
|
||||||
|
|
||||||
|
-- Free text notes
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Link to intake_notes (existing table)
|
||||||
|
intake_note_id UUID REFERENCES intake_notes(id),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE encounters IS 'FHIR: Encounter / ZIB: Contact - Contactmomenten (intake, behandeling, etc)';
|
||||||
|
COMMENT ON COLUMN encounters.intake_note_id IS 'Link to existing intake_notes table for backwards compatibility';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: conditions (FHIR: Condition / ZIB: Problem)
|
||||||
|
-- DSM-5 diagnoses en problemlijst
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS conditions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR Condition.identifier
|
||||||
|
identifier TEXT UNIQUE DEFAULT gen_random_uuid()::TEXT,
|
||||||
|
|
||||||
|
-- FHIR Condition.clinicalStatus
|
||||||
|
clinical_status condition_clinical_status NOT NULL DEFAULT 'active',
|
||||||
|
|
||||||
|
-- FHIR Condition.verificationStatus
|
||||||
|
verification_status condition_verification_status NOT NULL DEFAULT 'provisional',
|
||||||
|
|
||||||
|
-- FHIR Condition.category
|
||||||
|
category TEXT NOT NULL DEFAULT 'encounter-diagnosis', -- of 'problem-list-item'
|
||||||
|
|
||||||
|
-- FHIR Condition.severity
|
||||||
|
severity_code TEXT, -- mild, moderate, severe
|
||||||
|
severity_display TEXT,
|
||||||
|
|
||||||
|
-- FHIR Condition.code (DSM-5 / ICD-10)
|
||||||
|
code_system TEXT NOT NULL DEFAULT 'http://hl7.org/fhir/sid/icd-10',
|
||||||
|
code_code TEXT NOT NULL, -- "F32.2", "F41.1"
|
||||||
|
code_display TEXT NOT NULL, -- "Depressieve episode, ernstig"
|
||||||
|
|
||||||
|
-- FHIR Condition.bodySite (indien relevant)
|
||||||
|
body_site_code TEXT,
|
||||||
|
body_site_display TEXT,
|
||||||
|
|
||||||
|
-- FHIR Condition.subject (patient)
|
||||||
|
patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Condition.encounter (wanneer gesteld)
|
||||||
|
encounter_id UUID REFERENCES encounters(id),
|
||||||
|
|
||||||
|
-- FHIR Condition.onsetDateTime / abatementDateTime
|
||||||
|
onset_datetime TIMESTAMPTZ,
|
||||||
|
onset_age INTEGER, -- Leeftijd bij ontstaan (optioneel)
|
||||||
|
abatement_datetime TIMESTAMPTZ,
|
||||||
|
abatement_age INTEGER,
|
||||||
|
|
||||||
|
-- FHIR Condition.recordedDate
|
||||||
|
recorded_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- FHIR Condition.recorder (wie legde vast)
|
||||||
|
recorder_id UUID REFERENCES practitioners(id),
|
||||||
|
|
||||||
|
-- FHIR Condition.asserter (wie stelde diagnose)
|
||||||
|
asserter_id UUID REFERENCES practitioners(id),
|
||||||
|
|
||||||
|
-- FHIR Condition.note
|
||||||
|
note TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE conditions IS 'FHIR: Condition / ZIB: Problem - DSM-5 diagnoses en problemlijst';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: observations (FHIR: Observation)
|
||||||
|
-- ROM-scores, risico's, klachten, metingen
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS observations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR Observation.identifier
|
||||||
|
identifier TEXT UNIQUE DEFAULT gen_random_uuid()::TEXT,
|
||||||
|
|
||||||
|
-- FHIR Observation.status
|
||||||
|
status observation_status NOT NULL DEFAULT 'final',
|
||||||
|
|
||||||
|
-- FHIR Observation.category
|
||||||
|
category TEXT NOT NULL, -- vital-signs, social-history, exam, survey, therapy
|
||||||
|
|
||||||
|
-- FHIR Observation.code (wat werd geobserveerd)
|
||||||
|
code_system TEXT NOT NULL, -- SNOMED, LOINC, custom
|
||||||
|
code_code TEXT NOT NULL,
|
||||||
|
code_display TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Observation.subject (patient)
|
||||||
|
patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Observation.encounter
|
||||||
|
encounter_id UUID REFERENCES encounters(id),
|
||||||
|
|
||||||
|
-- FHIR Observation.effectiveDateTime
|
||||||
|
effective_datetime TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR Observation.issued
|
||||||
|
issued TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- FHIR Observation.performer (wie deed observatie)
|
||||||
|
performer_id UUID REFERENCES practitioners(id),
|
||||||
|
|
||||||
|
-- FHIR Observation.value[x] (polymorf!)
|
||||||
|
value_type TEXT NOT NULL, -- quantity, string, boolean, codeableConcept
|
||||||
|
value_quantity_value NUMERIC,
|
||||||
|
value_quantity_unit TEXT,
|
||||||
|
value_quantity_comparator TEXT, -- <, <=, >=, >
|
||||||
|
value_string TEXT,
|
||||||
|
value_boolean BOOLEAN,
|
||||||
|
value_codeable_concept JSONB, -- {system, code, display}
|
||||||
|
|
||||||
|
-- FHIR Observation.interpretation
|
||||||
|
interpretation_code TEXT, -- H (high), L (low), N (normal)
|
||||||
|
interpretation_display TEXT,
|
||||||
|
|
||||||
|
-- FHIR Observation.note
|
||||||
|
note TEXT,
|
||||||
|
|
||||||
|
-- FHIR Observation.bodySite
|
||||||
|
body_site TEXT,
|
||||||
|
|
||||||
|
-- FHIR Observation.method
|
||||||
|
method_code TEXT,
|
||||||
|
method_display TEXT,
|
||||||
|
|
||||||
|
-- Reference range (normaalwaarden)
|
||||||
|
reference_range_low NUMERIC,
|
||||||
|
reference_range_high NUMERIC,
|
||||||
|
reference_range_text TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE observations IS 'FHIR: Observation - ROM-scores, risico-inschattingen, metingen';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLE: care_plans (FHIR: CarePlan)
|
||||||
|
-- Behandelplannen met embedded goals en activities
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS care_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- FHIR CarePlan.identifier
|
||||||
|
identifier TEXT UNIQUE DEFAULT gen_random_uuid()::TEXT,
|
||||||
|
|
||||||
|
-- FHIR CarePlan.status
|
||||||
|
status careplan_status NOT NULL DEFAULT 'draft',
|
||||||
|
|
||||||
|
-- FHIR CarePlan.intent
|
||||||
|
intent TEXT NOT NULL DEFAULT 'plan', -- proposal, plan, order, option
|
||||||
|
|
||||||
|
-- FHIR CarePlan.category
|
||||||
|
category_code TEXT DEFAULT 'ggz-behandelplan',
|
||||||
|
category_display TEXT DEFAULT 'GGZ Behandelplan',
|
||||||
|
|
||||||
|
-- FHIR CarePlan.title
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR CarePlan.description
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- FHIR CarePlan.subject (patient)
|
||||||
|
patient_id UUID REFERENCES patients(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
|
||||||
|
-- FHIR CarePlan.encounter (intake waar uit voortkomt)
|
||||||
|
encounter_id UUID REFERENCES encounters(id),
|
||||||
|
|
||||||
|
-- FHIR CarePlan.period
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
|
||||||
|
-- FHIR CarePlan.created
|
||||||
|
created_date TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- FHIR CarePlan.author (regiebehandelaar)
|
||||||
|
author_id UUID REFERENCES practitioners(id),
|
||||||
|
|
||||||
|
-- FHIR CarePlan.contributor
|
||||||
|
contributor_ids UUID[], -- Array van practitioner IDs
|
||||||
|
|
||||||
|
-- FHIR CarePlan.careTeam
|
||||||
|
care_team_ids UUID[], -- Array van practitioner IDs
|
||||||
|
|
||||||
|
-- FHIR CarePlan.addresses (welke diagnoses)
|
||||||
|
addresses_condition_ids UUID[], -- Array van condition IDs
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- PRAGMATIC APPROACH: EMBEDDED GOALS AND ACTIVITIES
|
||||||
|
-- ========================================================================
|
||||||
|
-- FHIR CarePlan.goal (behandeldoelen als JSONB array)
|
||||||
|
goals JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Structure: [
|
||||||
|
-- {
|
||||||
|
-- "description": {"text": "PHQ-9 score < 10"},
|
||||||
|
-- "target": [{
|
||||||
|
-- "measure": {"coding": [{"system": "...", "code": "44249-1"}]},
|
||||||
|
-- "detailQuantity": {"value": 10, "comparator": "<"},
|
||||||
|
-- "dueDate": "2024-06-30"
|
||||||
|
-- }]
|
||||||
|
-- }
|
||||||
|
-- ]
|
||||||
|
|
||||||
|
-- FHIR CarePlan.activity (behandelactiviteiten als JSONB array)
|
||||||
|
activities JSONB DEFAULT '[]'::jsonb,
|
||||||
|
-- Structure: [
|
||||||
|
-- {
|
||||||
|
-- "detail": {
|
||||||
|
-- "code": {"text": "Cognitieve gedragstherapie"},
|
||||||
|
-- "status": "in-progress",
|
||||||
|
-- "scheduledTiming": {"repeat": {"frequency": 1, "period": 1, "periodUnit": "wk"}},
|
||||||
|
-- "performer": ["practitioner-id"],
|
||||||
|
-- "description": "Individuele CGT sessies, 12 weken",
|
||||||
|
-- "location": "Polikliniek"
|
||||||
|
-- }
|
||||||
|
-- }
|
||||||
|
-- ]
|
||||||
|
-- ========================================================================
|
||||||
|
|
||||||
|
-- FHIR CarePlan.note
|
||||||
|
note TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE care_plans IS 'FHIR: CarePlan - Behandelplannen met embedded goals en activities (pragmatic approach)';
|
||||||
|
COMMENT ON COLUMN care_plans.goals IS 'FHIR Goal structures embedded as JSONB array (pragmatic: no separate table)';
|
||||||
|
COMMENT ON COLUMN care_plans.activities IS 'FHIR CarePlan.activity structures embedded as JSONB array (pragmatic: no separate table)';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INDEXES (voor performance)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Practitioners
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_practitioners_user_id ON practitioners(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_practitioners_active ON practitioners(active);
|
||||||
|
|
||||||
|
-- Patients
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patients_bsn ON patients(identifier_bsn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patients_active ON patients(active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patients_client_number ON patients(identifier_client_number);
|
||||||
|
|
||||||
|
-- Encounters
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_encounters_patient_id ON encounters(patient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_encounters_practitioner_id ON encounters(practitioner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_encounters_status ON encounters(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_encounters_period_start ON encounters(period_start DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_encounters_intake_note_id ON encounters(intake_note_id);
|
||||||
|
|
||||||
|
-- Conditions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conditions_patient_id ON conditions(patient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conditions_encounter_id ON conditions(encounter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conditions_clinical_status ON conditions(clinical_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conditions_code ON conditions(code_code);
|
||||||
|
|
||||||
|
-- Observations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_observations_patient_id ON observations(patient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_observations_encounter_id ON observations(encounter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_observations_category ON observations(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_observations_effective_datetime ON observations(effective_datetime DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_observations_code ON observations(code_code);
|
||||||
|
|
||||||
|
-- Care Plans
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_care_plans_patient_id ON care_plans(patient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_care_plans_status ON care_plans(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_care_plans_author_id ON care_plans(author_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_care_plans_encounter_id ON care_plans(encounter_id);
|
||||||
|
|
||||||
|
-- GIN indexes for JSONB columns (for efficient querying)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_care_plans_goals_gin ON care_plans USING GIN (goals);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_care_plans_activities_gin ON care_plans USING GIN (activities);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ROW LEVEL SECURITY (RLS) - basis setup
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enable RLS on all tables
|
||||||
|
ALTER TABLE practitioners ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE encounters ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE conditions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE observations ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE care_plans ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Voor MVP: authenticated users kunnen alles zien (later verfijnen)
|
||||||
|
|
||||||
|
-- Practitioners: can read/update their own record
|
||||||
|
DROP POLICY IF EXISTS "Practitioners can view own record" ON practitioners;
|
||||||
|
CREATE POLICY "Practitioners can view own record" ON practitioners
|
||||||
|
FOR SELECT USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Practitioners can update own record" ON practitioners;
|
||||||
|
CREATE POLICY "Practitioners can update own record" ON practitioners
|
||||||
|
FOR UPDATE USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Patients: authenticated users can view/create/update
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can view patients" ON patients;
|
||||||
|
CREATE POLICY "Authenticated users can view patients" ON patients
|
||||||
|
FOR SELECT USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can insert patients" ON patients;
|
||||||
|
CREATE POLICY "Authenticated users can insert patients" ON patients
|
||||||
|
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can update patients" ON patients;
|
||||||
|
CREATE POLICY "Authenticated users can update patients" ON patients
|
||||||
|
FOR UPDATE USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- Encounters: authenticated users can view/create/update
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can view encounters" ON encounters;
|
||||||
|
CREATE POLICY "Authenticated users can view encounters" ON encounters
|
||||||
|
FOR SELECT USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can insert encounters" ON encounters;
|
||||||
|
CREATE POLICY "Authenticated users can insert encounters" ON encounters
|
||||||
|
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can update encounters" ON encounters;
|
||||||
|
CREATE POLICY "Authenticated users can update encounters" ON encounters
|
||||||
|
FOR UPDATE USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- Conditions: authenticated users can view/create/update
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can view conditions" ON conditions;
|
||||||
|
CREATE POLICY "Authenticated users can view conditions" ON conditions
|
||||||
|
FOR SELECT USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can insert conditions" ON conditions;
|
||||||
|
CREATE POLICY "Authenticated users can insert conditions" ON conditions
|
||||||
|
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can update conditions" ON conditions;
|
||||||
|
CREATE POLICY "Authenticated users can update conditions" ON conditions
|
||||||
|
FOR UPDATE USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- Observations: authenticated users can view/create
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can view observations" ON observations;
|
||||||
|
CREATE POLICY "Authenticated users can view observations" ON observations
|
||||||
|
FOR SELECT USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can insert observations" ON observations;
|
||||||
|
CREATE POLICY "Authenticated users can insert observations" ON observations
|
||||||
|
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- Care Plans: authenticated users can view/create/update
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can view care plans" ON care_plans;
|
||||||
|
CREATE POLICY "Authenticated users can view care plans" ON care_plans
|
||||||
|
FOR SELECT USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can insert care plans" ON care_plans;
|
||||||
|
CREATE POLICY "Authenticated users can insert care plans" ON care_plans
|
||||||
|
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can update care plans" ON care_plans;
|
||||||
|
CREATE POLICY "Authenticated users can update care plans" ON care_plans
|
||||||
|
FOR UPDATE USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCTIONS - updated_at trigger
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply trigger to all tables with updated_at
|
||||||
|
DROP TRIGGER IF EXISTS set_updated_at ON practitioners;
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON practitioners
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS set_updated_at ON organizations;
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON organizations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS set_updated_at ON patients;
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON patients
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS set_updated_at ON encounters;
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON encounters
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS set_updated_at ON conditions;
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON conditions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS set_updated_at ON care_plans;
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON care_plans
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SEED DATA (demo/development)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Default organization
|
||||||
|
INSERT INTO organizations (id, name, identifier_agb, active)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'Demo GGZ Instelling',
|
||||||
|
'AGB-DEMO-001',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUMMARY
|
||||||
|
-- ============================================================================
|
||||||
|
-- ✅ Created 7 tables: practitioners, organizations, patients, encounters, conditions, observations, care_plans
|
||||||
|
-- ✅ Goals and activities embedded in care_plans JSONB (pragmatic approach)
|
||||||
|
-- ✅ No medications, consents, flags, documents tables (out of scope)
|
||||||
|
-- ✅ Simplified BSN (placeholder for demo)
|
||||||
|
-- ✅ RLS enabled on all tables
|
||||||
|
-- ✅ Indexes for performance
|
||||||
|
-- ✅ Triggers for updated_at
|
||||||
|
-- ✅ 1 default organization seeded
|
||||||
|
--
|
||||||
|
-- NEXT STEPS:
|
||||||
|
-- 1. Run this migration in Supabase
|
||||||
|
-- 2. Generate TypeScript types: supabase gen types
|
||||||
|
-- 3. Create data migration script: clients → patients, treatment_plans → care_plans
|
||||||
|
-- 4. Seed demo data: practitioners, patients, encounters
|
||||||
|
-- ============================================================================
|
||||||
235
lib/supabase/migrations/20241121_migrate_legacy_to_fhir.sql
Normal file
235
lib/supabase/migrations/20241121_migrate_legacy_to_fhir.sql
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- DATA MIGRATION: Legacy tables → FHIR tables
|
||||||
|
-- ============================================================================
|
||||||
|
-- Created: 2024-11-21
|
||||||
|
-- Purpose: Migrate existing data from clients/treatment_plans to patients/care_plans
|
||||||
|
--
|
||||||
|
-- IMPORTANT: This migration preserves UUIDs for referential integrity
|
||||||
|
-- Run this AFTER applying the pragmatic FHIR schema
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 1: Migrate clients → patients
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Check for existing data
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
client_count INTEGER;
|
||||||
|
patient_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO client_count FROM clients;
|
||||||
|
SELECT COUNT(*) INTO patient_count FROM patients;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Found % clients, % patients (before migration)', client_count, patient_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Migrate clients to patients
|
||||||
|
INSERT INTO patients (
|
||||||
|
id, -- Preserve UUID for referential integrity
|
||||||
|
name_family, -- clients.last_name
|
||||||
|
name_given, -- clients.first_name (as array)
|
||||||
|
birth_date, -- clients.birth_date
|
||||||
|
gender, -- Default 'unknown' (required field)
|
||||||
|
identifier_bsn, -- Placeholder
|
||||||
|
identifier_client_number, -- Use original client ID as client number
|
||||||
|
active, -- Default true
|
||||||
|
created_at, -- Preserve timestamp
|
||||||
|
updated_at -- Preserve timestamp
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.last_name,
|
||||||
|
ARRAY[c.first_name], -- Convert string to array
|
||||||
|
c.birth_date,
|
||||||
|
'unknown'::gender_type, -- Required field, default to unknown
|
||||||
|
'999999990', -- Placeholder BSN (demo)
|
||||||
|
c.id::text, -- Use UUID as client number for traceability
|
||||||
|
true,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clients c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
-- Don't re-migrate if already exists
|
||||||
|
SELECT 1 FROM patients p WHERE p.id = c.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Report migration results
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
migrated_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO migrated_count
|
||||||
|
FROM patients p
|
||||||
|
INNER JOIN clients c ON p.id = c.id;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Successfully migrated % clients to patients', migrated_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 2: Migrate treatment_plans → care_plans
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Check for existing data
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
plan_count INTEGER;
|
||||||
|
careplan_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO plan_count FROM treatment_plans;
|
||||||
|
SELECT COUNT(*) INTO careplan_count FROM care_plans;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Found % treatment_plans, % care_plans (before migration)', plan_count, careplan_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Migrate treatment_plans to care_plans
|
||||||
|
INSERT INTO care_plans (
|
||||||
|
id, -- Preserve UUID
|
||||||
|
identifier, -- Generate from UUID
|
||||||
|
status, -- Map from treatment_plans.status
|
||||||
|
title, -- Generate default title
|
||||||
|
description, -- Extract from plan JSONB if available
|
||||||
|
patient_id, -- treatment_plans.client_id → patient_id
|
||||||
|
goals, -- Extract from plan.doelen
|
||||||
|
activities, -- Extract from plan.interventies
|
||||||
|
period_start, -- Derive from created_at
|
||||||
|
period_end, -- Derive from created_at + 3 months (default)
|
||||||
|
created_date, -- Preserve created_at
|
||||||
|
created_at, -- Preserve created_at
|
||||||
|
updated_at -- Preserve updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
tp.id,
|
||||||
|
tp.id::text, -- Use UUID as identifier
|
||||||
|
CASE
|
||||||
|
WHEN tp.status = 'concept' THEN 'draft'::careplan_status
|
||||||
|
WHEN tp.status = 'gepubliceerd' THEN 'active'::careplan_status
|
||||||
|
ELSE 'draft'::careplan_status
|
||||||
|
END,
|
||||||
|
'Behandelplan v' || tp.version::text, -- Default title
|
||||||
|
NULL, -- No description in legacy schema
|
||||||
|
tp.client_id, -- Maps to patient_id (already migrated)
|
||||||
|
-- Transform goals from legacy structure to FHIR Goal structure
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'description', jsonb_build_object('text', goal),
|
||||||
|
'lifecycleStatus', 'active'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(tp.plan->'doelen') goal
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
-- Transform activities from legacy structure to FHIR CarePlan.activity structure
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'detail', jsonb_build_object(
|
||||||
|
'code', jsonb_build_object('text', interventie),
|
||||||
|
'status', 'in-progress',
|
||||||
|
'scheduledString', COALESCE(tp.plan->>'frequentie', 'Niet gespecificeerd')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(tp.plan->'interventies') interventie
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
tp.created_at::date, -- Start date from creation
|
||||||
|
(tp.created_at + interval '3 months')::date, -- Default 3-month treatment
|
||||||
|
tp.created_at,
|
||||||
|
tp.created_at,
|
||||||
|
tp.updated_at
|
||||||
|
FROM treatment_plans tp
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
-- Don't re-migrate if already exists
|
||||||
|
SELECT 1 FROM care_plans cp WHERE cp.id = tp.id
|
||||||
|
)
|
||||||
|
-- Ensure the client was migrated to patients first
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM patients p WHERE p.id = tp.client_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Report migration results
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
migrated_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO migrated_count
|
||||||
|
FROM care_plans cp
|
||||||
|
INNER JOIN treatment_plans tp ON cp.id = tp.id;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Successfully migrated % treatment_plans to care_plans', migrated_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 3: Verification queries
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Verify patient migration
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
client_count INTEGER;
|
||||||
|
patient_count INTEGER;
|
||||||
|
match_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO client_count FROM clients;
|
||||||
|
SELECT COUNT(*) INTO patient_count FROM patients;
|
||||||
|
SELECT COUNT(*) INTO match_count
|
||||||
|
FROM patients p
|
||||||
|
INNER JOIN clients c ON p.id = c.id;
|
||||||
|
|
||||||
|
RAISE NOTICE '=== MIGRATION VERIFICATION ===';
|
||||||
|
RAISE NOTICE 'Clients: %, Patients: %, Matched: %', client_count, patient_count, match_count;
|
||||||
|
|
||||||
|
IF match_count = client_count THEN
|
||||||
|
RAISE NOTICE '✓ All clients successfully migrated to patients';
|
||||||
|
ELSE
|
||||||
|
RAISE WARNING '✗ Migration incomplete: % clients not migrated', (client_count - match_count);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Verify care plan migration
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
plan_count INTEGER;
|
||||||
|
careplan_count INTEGER;
|
||||||
|
match_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO plan_count FROM treatment_plans;
|
||||||
|
SELECT COUNT(*) INTO careplan_count FROM care_plans;
|
||||||
|
SELECT COUNT(*) INTO match_count
|
||||||
|
FROM care_plans cp
|
||||||
|
INNER JOIN treatment_plans tp ON cp.id = tp.id;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Treatment Plans: %, Care Plans: %, Matched: %', plan_count, careplan_count, match_count;
|
||||||
|
|
||||||
|
IF plan_count = 0 THEN
|
||||||
|
RAISE NOTICE '- No treatment plans to migrate (table empty)';
|
||||||
|
ELSIF match_count = plan_count THEN
|
||||||
|
RAISE NOTICE '✓ All treatment plans successfully migrated to care_plans';
|
||||||
|
ELSE
|
||||||
|
RAISE WARNING '✗ Migration incomplete: % treatment plans not migrated', (plan_count - match_count);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUMMARY
|
||||||
|
-- ============================================================================
|
||||||
|
-- This migration script:
|
||||||
|
-- ✓ Migrates clients → patients (preserving UUIDs)
|
||||||
|
-- ✓ Migrates treatment_plans → care_plans (preserving UUIDs)
|
||||||
|
-- ✓ Transforms legacy JSONB structure to FHIR-compliant structure
|
||||||
|
-- ✓ Sets sensible defaults for required FHIR fields
|
||||||
|
-- ✓ Idempotent: safe to re-run (uses NOT EXISTS checks)
|
||||||
|
-- ✓ Preserves referential integrity
|
||||||
|
--
|
||||||
|
-- NEXT STEPS:
|
||||||
|
-- 1. Run this migration: supabase db push or apply_migration
|
||||||
|
-- 2. Verify data in patients and care_plans tables
|
||||||
|
-- 3. Update application code to use new FHIR tables
|
||||||
|
-- 4. Optionally: keep legacy tables for rollback, or drop after verification
|
||||||
|
-- ============================================================================
|
||||||
418
lib/supabase/migrations/20241121_seed_demo_data.sql
Normal file
418
lib/supabase/migrations/20241121_seed_demo_data.sql
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SEED DEMO DATA: Practitioners, Patients, Encounters
|
||||||
|
-- ============================================================================
|
||||||
|
-- Created: 2024-11-21
|
||||||
|
-- Purpose: Populate FHIR tables with realistic demo data for testing
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 1: Seed Practitioners (Behandelaren)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Get the demo organization ID
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
demo_org_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO demo_org_id FROM organizations WHERE identifier_agb = 'AGB-DEMO-001';
|
||||||
|
RAISE NOTICE 'Demo organization ID: %', demo_org_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Practitioner 1: Dr. Sarah de Vries (GZ-psycholoog)
|
||||||
|
INSERT INTO practitioners (
|
||||||
|
id,
|
||||||
|
identifier_big,
|
||||||
|
identifier_agb,
|
||||||
|
name_prefix,
|
||||||
|
name_given,
|
||||||
|
name_family,
|
||||||
|
qualification,
|
||||||
|
telecom_phone,
|
||||||
|
telecom_email,
|
||||||
|
active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'10000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'79012345601',
|
||||||
|
'03012345',
|
||||||
|
'Dr.',
|
||||||
|
ARRAY['Sarah'],
|
||||||
|
'de Vries',
|
||||||
|
ARRAY['GZ-psycholoog', 'Cognitieve gedragstherapie', 'EMDR'],
|
||||||
|
'06-12345678',
|
||||||
|
's.devries@demo-ggz.nl',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Practitioner 2: Drs. Mark Jansen (Psychotherapeut)
|
||||||
|
INSERT INTO practitioners (
|
||||||
|
id,
|
||||||
|
identifier_big,
|
||||||
|
identifier_agb,
|
||||||
|
name_prefix,
|
||||||
|
name_given,
|
||||||
|
name_family,
|
||||||
|
qualification,
|
||||||
|
telecom_phone,
|
||||||
|
telecom_email,
|
||||||
|
active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'10000000-0000-0000-0000-000000000002'::uuid,
|
||||||
|
'79023456702',
|
||||||
|
'03023456',
|
||||||
|
'Drs.',
|
||||||
|
ARRAY['Mark'],
|
||||||
|
'Jansen',
|
||||||
|
ARRAY['Psychotherapeut', 'Schematherapie', 'Acceptance and Commitment Therapy'],
|
||||||
|
'06-23456789',
|
||||||
|
'm.jansen@demo-ggz.nl',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Practitioner 3: Lisa van den Berg (Klinisch psycholoog)
|
||||||
|
INSERT INTO practitioners (
|
||||||
|
id,
|
||||||
|
identifier_big,
|
||||||
|
identifier_agb,
|
||||||
|
name_prefix,
|
||||||
|
name_given,
|
||||||
|
name_family,
|
||||||
|
qualification,
|
||||||
|
telecom_phone,
|
||||||
|
telecom_email,
|
||||||
|
active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'10000000-0000-0000-0000-000000000003'::uuid,
|
||||||
|
'79034567803',
|
||||||
|
'03034567',
|
||||||
|
NULL,
|
||||||
|
ARRAY['Lisa'],
|
||||||
|
'van den Berg',
|
||||||
|
ARRAY['Klinisch psycholoog', 'Diagnostiek', 'ROM-coördinator'],
|
||||||
|
'06-34567890',
|
||||||
|
'l.vandenberg@demo-ggz.nl',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
RAISE NOTICE '✓ Seeded 3 practitioners';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 2: Update existing patients with complete data
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Get patient IDs from migrated clients
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
patient_colin_id UUID;
|
||||||
|
patient_jan_id UUID;
|
||||||
|
patient_optimus_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Get Colin's patient ID
|
||||||
|
SELECT id INTO patient_colin_id FROM patients WHERE name_family = 'Lit' AND 'Colin' = ANY(name_given);
|
||||||
|
|
||||||
|
-- Get Jan's patient ID
|
||||||
|
SELECT id INTO patient_jan_id FROM patients WHERE name_family = 'de Vriesh' AND 'Jan' = ANY(name_given);
|
||||||
|
|
||||||
|
-- Get Optimus's patient ID
|
||||||
|
SELECT id INTO patient_optimus_id FROM patients WHERE name_family = 'Prime' AND 'Optimus' = ANY(name_given);
|
||||||
|
|
||||||
|
-- Update Colin with complete data
|
||||||
|
UPDATE patients
|
||||||
|
SET
|
||||||
|
gender = 'male',
|
||||||
|
telecom_phone = '06-11111111',
|
||||||
|
telecom_email = 'colin.lit@example.com',
|
||||||
|
address_line = ARRAY['Kerkstraat 12'],
|
||||||
|
address_city = 'Amsterdam',
|
||||||
|
address_postal_code = '1012 AB',
|
||||||
|
insurance_company = 'VGZ',
|
||||||
|
insurance_number = 'VGZ-123456',
|
||||||
|
general_practitioner_name = 'Dr. A. Huisarts',
|
||||||
|
general_practitioner_agb = '12345678'
|
||||||
|
WHERE id = patient_colin_id;
|
||||||
|
|
||||||
|
-- Update Jan with complete data
|
||||||
|
UPDATE patients
|
||||||
|
SET
|
||||||
|
gender = 'male',
|
||||||
|
telecom_phone = '06-22222222',
|
||||||
|
telecom_email = 'jan.devriesh@example.com',
|
||||||
|
address_line = ARRAY['Hoofdstraat 45'],
|
||||||
|
address_city = 'Utrecht',
|
||||||
|
address_postal_code = '3511 AB',
|
||||||
|
insurance_company = 'CZ',
|
||||||
|
insurance_number = 'CZ-789012',
|
||||||
|
general_practitioner_name = 'Dr. B. Dokter',
|
||||||
|
general_practitioner_agb = '23456789'
|
||||||
|
WHERE id = patient_jan_id;
|
||||||
|
|
||||||
|
-- Update Optimus with complete data (easter egg patient)
|
||||||
|
UPDATE patients
|
||||||
|
SET
|
||||||
|
gender = 'other',
|
||||||
|
telecom_phone = '06-99999999',
|
||||||
|
address_line = ARRAY['Cybertron Base 1'],
|
||||||
|
address_city = 'Eindhoven',
|
||||||
|
address_postal_code = '5600 AA',
|
||||||
|
insurance_company = 'Menzis',
|
||||||
|
insurance_number = 'MEN-PRIME-01'
|
||||||
|
WHERE id = patient_optimus_id;
|
||||||
|
|
||||||
|
RAISE NOTICE '✓ Updated 3 existing patients with complete data';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 3: Seed Encounters (Contactmomenten)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Get organization ID
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
demo_org_id UUID;
|
||||||
|
patient_colin_id UUID;
|
||||||
|
patient_jan_id UUID;
|
||||||
|
patient_optimus_id UUID;
|
||||||
|
practitioner_sarah_id UUID := '10000000-0000-0000-0000-000000000001'::uuid;
|
||||||
|
practitioner_mark_id UUID := '10000000-0000-0000-0000-000000000002'::uuid;
|
||||||
|
practitioner_lisa_id UUID := '10000000-0000-0000-0000-000000000003'::uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Get IDs
|
||||||
|
SELECT id INTO demo_org_id FROM organizations WHERE identifier_agb = 'AGB-DEMO-001';
|
||||||
|
SELECT id INTO patient_colin_id FROM patients WHERE name_family = 'Lit' AND 'Colin' = ANY(name_given);
|
||||||
|
SELECT id INTO patient_jan_id FROM patients WHERE name_family = 'de Vriesh' AND 'Jan' = ANY(name_given);
|
||||||
|
SELECT id INTO patient_optimus_id FROM patients WHERE name_family = 'Prime' AND 'Optimus' = ANY(name_given);
|
||||||
|
|
||||||
|
-- Encounter 1: Colin's intake with Dr. Sarah de Vries (completed)
|
||||||
|
INSERT INTO encounters (
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
class_code,
|
||||||
|
class_display,
|
||||||
|
type_code,
|
||||||
|
type_display,
|
||||||
|
priority_code,
|
||||||
|
priority_display,
|
||||||
|
patient_id,
|
||||||
|
practitioner_id,
|
||||||
|
organization_id,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
reason_code,
|
||||||
|
reason_display,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'20000000-0000-0000-0000-000000000001'::uuid,
|
||||||
|
'completed',
|
||||||
|
'AMB',
|
||||||
|
'Ambulatory',
|
||||||
|
'intake',
|
||||||
|
'Intake gesprek',
|
||||||
|
'routine',
|
||||||
|
'Routine',
|
||||||
|
patient_colin_id,
|
||||||
|
practitioner_sarah_id,
|
||||||
|
demo_org_id,
|
||||||
|
'2024-10-15 10:00:00+00',
|
||||||
|
'2024-10-15 11:00:00+00',
|
||||||
|
ARRAY['F32.1', 'F51.0'],
|
||||||
|
ARRAY['Matige depressieve episode', 'Insomnia'],
|
||||||
|
'Eerste intake gesprek. Cliënt presenteert zich met depressieve klachten en slaapproblemen sinds 3 maanden. PHQ-9 score: 14.'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Encounter 2: Jan's intake with Drs. Mark Jansen (completed)
|
||||||
|
INSERT INTO encounters (
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
class_code,
|
||||||
|
class_display,
|
||||||
|
type_code,
|
||||||
|
type_display,
|
||||||
|
priority_code,
|
||||||
|
priority_display,
|
||||||
|
patient_id,
|
||||||
|
practitioner_id,
|
||||||
|
organization_id,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
reason_code,
|
||||||
|
reason_display,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'20000000-0000-0000-0000-000000000002'::uuid,
|
||||||
|
'completed',
|
||||||
|
'AMB',
|
||||||
|
'Ambulatory',
|
||||||
|
'intake',
|
||||||
|
'Intake gesprek',
|
||||||
|
'routine',
|
||||||
|
'Routine',
|
||||||
|
patient_jan_id,
|
||||||
|
practitioner_mark_id,
|
||||||
|
demo_org_id,
|
||||||
|
'2024-11-01 14:00:00+00',
|
||||||
|
'2024-11-01 15:30:00+00',
|
||||||
|
ARRAY['F41.1', 'Z63.0'],
|
||||||
|
ARRAY['Gegeneraliseerde angststoornis', 'Relatieproblemen'],
|
||||||
|
'Intake gesprek. Cliënt geeft aan last te hebben van voortdurende piekeren en angstklachten. GAD-7 score: 16. Ook relationele problematiek.'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Encounter 3: Colin's 2nd session (planned)
|
||||||
|
INSERT INTO encounters (
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
class_code,
|
||||||
|
class_display,
|
||||||
|
type_code,
|
||||||
|
type_display,
|
||||||
|
priority_code,
|
||||||
|
priority_display,
|
||||||
|
patient_id,
|
||||||
|
practitioner_id,
|
||||||
|
organization_id,
|
||||||
|
period_start,
|
||||||
|
reason_code,
|
||||||
|
reason_display,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'20000000-0000-0000-0000-000000000003'::uuid,
|
||||||
|
'planned',
|
||||||
|
'AMB',
|
||||||
|
'Ambulatory',
|
||||||
|
'behandeling',
|
||||||
|
'Behandelsessie',
|
||||||
|
'routine',
|
||||||
|
'Routine',
|
||||||
|
patient_colin_id,
|
||||||
|
practitioner_sarah_id,
|
||||||
|
demo_org_id,
|
||||||
|
'2024-11-25 10:00:00+00',
|
||||||
|
ARRAY['F32.1'],
|
||||||
|
ARRAY['Matige depressieve episode'],
|
||||||
|
'Tweede sessie CGT gepland. Focus op gedragsactivatie en cognitieve herstructurering.'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Encounter 4: Optimus diagnostiek with Lisa (completed - easter egg)
|
||||||
|
INSERT INTO encounters (
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
class_code,
|
||||||
|
class_display,
|
||||||
|
type_code,
|
||||||
|
type_display,
|
||||||
|
patient_id,
|
||||||
|
practitioner_id,
|
||||||
|
organization_id,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
reason_code,
|
||||||
|
reason_display,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'20000000-0000-0000-0000-000000000004'::uuid,
|
||||||
|
'completed',
|
||||||
|
'AMB',
|
||||||
|
'Ambulatory',
|
||||||
|
'diagnostiek',
|
||||||
|
'Diagnostisch onderzoek',
|
||||||
|
patient_optimus_id,
|
||||||
|
practitioner_lisa_id,
|
||||||
|
demo_org_id,
|
||||||
|
'2024-11-10 09:00:00+00',
|
||||||
|
'2024-11-10 11:00:00+00',
|
||||||
|
ARRAY['Z03.2'],
|
||||||
|
ARRAY['Observatie voor vermoede psychische aandoening'],
|
||||||
|
'Diagnostisch onderzoek. Cliënt vertoont opvallende communicatiepatronen en metallic spraakpatroon. Nader onderzoek geïndiceerd.'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Encounter 5: Jan's follow-up (in-progress)
|
||||||
|
INSERT INTO encounters (
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
class_code,
|
||||||
|
class_display,
|
||||||
|
type_code,
|
||||||
|
type_display,
|
||||||
|
patient_id,
|
||||||
|
practitioner_id,
|
||||||
|
organization_id,
|
||||||
|
period_start,
|
||||||
|
reason_code,
|
||||||
|
reason_display,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'20000000-0000-0000-0000-000000000005'::uuid,
|
||||||
|
'in-progress',
|
||||||
|
'AMB',
|
||||||
|
'Ambulatory',
|
||||||
|
'behandeling',
|
||||||
|
'Behandelsessie',
|
||||||
|
patient_jan_id,
|
||||||
|
practitioner_mark_id,
|
||||||
|
demo_org_id,
|
||||||
|
NOW(),
|
||||||
|
ARRAY['F41.1'],
|
||||||
|
ARRAY['Gegeneraliseerde angststoornis'],
|
||||||
|
'Sessie 3: ACT technieken. Werk aan psychologische flexibiliteit en acceptatie.'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
RAISE NOTICE '✓ Seeded 5 encounters';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VERIFICATION
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
practitioner_count INTEGER;
|
||||||
|
patient_count INTEGER;
|
||||||
|
encounter_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO practitioner_count FROM practitioners;
|
||||||
|
SELECT COUNT(*) INTO patient_count FROM patients;
|
||||||
|
SELECT COUNT(*) INTO encounter_count FROM encounters;
|
||||||
|
|
||||||
|
RAISE NOTICE '=== SEED DATA SUMMARY ===';
|
||||||
|
RAISE NOTICE 'Practitioners: %', practitioner_count;
|
||||||
|
RAISE NOTICE 'Patients: %', patient_count;
|
||||||
|
RAISE NOTICE 'Encounters: %', encounter_count;
|
||||||
|
|
||||||
|
IF practitioner_count >= 3 AND patient_count >= 3 AND encounter_count >= 5 THEN
|
||||||
|
RAISE NOTICE '✓ All demo data seeded successfully!';
|
||||||
|
ELSE
|
||||||
|
RAISE WARNING '⚠ Some demo data may be missing';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUMMARY
|
||||||
|
-- ============================================================================
|
||||||
|
-- ✓ 3 Practitioners created (Sarah, Mark, Lisa)
|
||||||
|
-- ✓ 3 Patients updated with complete data (Colin, Jan, Optimus)
|
||||||
|
-- ✓ 5 Encounters created:
|
||||||
|
-- - 2 completed intakes
|
||||||
|
-- - 1 planned session
|
||||||
|
-- - 1 completed diagnostiek
|
||||||
|
-- - 1 in-progress behandeling
|
||||||
|
--
|
||||||
|
-- NEXT STEPS:
|
||||||
|
-- 1. Test frontend with demo data
|
||||||
|
-- 2. Create conditions (diagnoses) for patients
|
||||||
|
-- 3. Create care_plans (behandelplannen)
|
||||||
|
-- 4. Add observations (ROM scores)
|
||||||
|
-- ============================================================================
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { NextConfig } from "next";
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
compress: true, // Enable gzip compression
|
compress: true, // Enable gzip compression
|
||||||
|
|
||||||
@@ -16,31 +15,23 @@ const nextConfig: NextConfig = {
|
|||||||
optimizePackageImports: ['lucide-react', '@react-three/fiber', '@react-three/drei'],
|
optimizePackageImports: ['lucide-react', '@react-three/fiber', '@react-three/drei'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Webpack optimizations
|
// Webpack optimizations - only in production
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config, { isServer, dev }) => {
|
||||||
// Optimize bundle size
|
// Only apply optimizations in production build
|
||||||
if (!isServer) {
|
if (!isServer && !dev) {
|
||||||
config.optimization = {
|
config.optimization = {
|
||||||
...config.optimization,
|
...config.optimization,
|
||||||
moduleIds: 'deterministic',
|
|
||||||
splitChunks: {
|
splitChunks: {
|
||||||
chunks: 'all',
|
chunks: 'async',
|
||||||
cacheGroups: {
|
cacheGroups: {
|
||||||
default: false,
|
// Keep default Next.js optimizations
|
||||||
vendors: false,
|
...config.optimization?.splitChunks?.cacheGroups,
|
||||||
// Vendor chunk for heavy libraries
|
// Add specific chunk for three.js (heavy library)
|
||||||
vendor: {
|
|
||||||
name: 'vendor',
|
|
||||||
chunks: 'all',
|
|
||||||
test: /node_modules/,
|
|
||||||
priority: 20,
|
|
||||||
},
|
|
||||||
// Separate chunk for three.js (heavy)
|
|
||||||
three: {
|
three: {
|
||||||
name: 'three',
|
name: 'three',
|
||||||
chunks: 'all',
|
|
||||||
test: /[\\/]node_modules[\\/](three|@react-three)[\\/]/,
|
test: /[\\/]node_modules[\\/](three|@react-three)[\\/]/,
|
||||||
priority: 30,
|
priority: 30,
|
||||||
|
reuseExistingChunk: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
16
package.json
16
package.json
@@ -3,8 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack",
|
"dev": "next dev",
|
||||||
"build": "next build --webpack",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
|
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "16.0.1",
|
"next": "14.2.18",
|
||||||
"next-mdx-remote": "^5.0.0",
|
"next-mdx-remote": "^5.0.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "18.3.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "18.3.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.181.1",
|
"three": "^0.181.1",
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^18",
|
||||||
"@types/three": "^0.181.0",
|
"@types/three": "^0.181.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.1",
|
"eslint-config-next": "14.2.18",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
51
package.json.backup-next16
Normal file
51
package.json.backup-next16
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "15-mini-epd-prototype",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --webpack",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
|
||||||
|
"setup:auth-hook": "tsx scripts/setup-auth-hook.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.4.0",
|
||||||
|
"@supabase/ssr": "^0.7.0",
|
||||||
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
|
"next": "16.0.1",
|
||||||
|
"next-mdx-remote": "^5.0.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"three": "^0.181.1",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/three": "^0.181.0",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1327
pnpm-lock.yaml
generated
1327
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
6759
pnpm-lock.yaml.backup-next16
Normal file
6759
pnpm-lock.yaml.backup-next16
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -30,5 +36,7 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user