FHIR, API, datamodel

This commit is contained in:
colinislit
2025-11-21 22:32:29 +01:00
parent 5f7ef0801d
commit d5c6cf208b
39 changed files with 17974 additions and 1004 deletions

View File

@@ -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}
/> />

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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">

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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');
}
}

View 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>
);
}

View 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>
);
}

View 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
View 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} />;
}

View File

@@ -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;
} }

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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
View File

@@ -0,0 +1,8 @@
/**
* FHIR Library
* Main entry point for FHIR functionality
*/
export * from './types';
export * from './transforms';
export * from './utils';

View File

@@ -0,0 +1,7 @@
/**
* FHIR Transforms
* Export all transform functions
*/
export * from './patient';
export * from './practitioner';

View 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,
};
}

View 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
View 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
View 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();
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View 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
-- ============================================================================

View 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
-- ============================================================================

View 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)
-- ============================================================================

View File

@@ -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,
}, },
}, },
}, },

View File

@@ -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"

View 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

File diff suppressed because it is too large Load Diff

6759
pnpm-lock.yaml.backup-next16 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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"
]
} }