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