Document Template System
Note: This document describes the OLD template system. The new template system is now part of the PDF Templates feature with a visual block-based editor, component library, and versioning support.
Overview
PDF layout templates were originally planned to be stored in a document_templates table. This has been superseded by the PDF Templates feature which provides:
- Visual block-based editor
- Component library (letterhead, footer, signature)
- Template versioning
- Live preview with chromedp rendering
For current template documentation, see PDF Templates.
Old Template Storage (Superseded)
The original design stored templates in the document_templates table with the following structure:
CREATE TABLE document_templates (
id BIGSERIAL PRIMARY KEY,
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
type document_type NOT NULL, -- 'report' | 'prescription'
name TEXT NOT NULL, -- "Default Report", "Prescription v2"
html_template TEXT NOT NULL, -- HTML with Go template syntax
css_styles TEXT NOT NULL DEFAULT '', -- CSS (separate for maintainability)
header_html TEXT, -- Header HTML (every page)
footer_html TEXT, -- Footer HTML (every page)
page_size TEXT NOT NULL DEFAULT 'A4',
orientation TEXT NOT NULL DEFAULT 'portrait',
margins JSONB NOT NULL DEFAULT '{"top": "20mm", "right": "15mm", "bottom": "20mm", "left": "15mm"}',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, type, is_default) WHERE (is_default = TRUE)
);Template Variables
Templates receive a DocumentData struct with access to:
Organization Context
.Organization.Name- Organization name.Organization.LogoURL- Pre-signed S3 URL (embedded as base64).Organization.Address- Organization address.Organization.Phone- Organization phone.Organization.Email- Organization email
Patient Information
.Patient.Name- Patient full name.Patient.DateOfBirth- Patient date of birth- Additional profile fields from
custom_field_values
Specialist Information
.Specialist.Name- Specialist name.Specialist.Title- Professional title (e.g., "Dr.", "Kinesitherapist").Specialist.SignatureURL- Pre-signed S3 URL (embedded as base64).Specialist.Specialties- Array of specialty names
Appointment Details
.Appointment.StartedAt- Appointment start time.Appointment.EndedAt- Appointment end time.Appointment.Title- Appointment title
Form Data (Core Content)
.Form.Title- Form title.Form.Type- Form type (report, prescription, etc.).Form.Fields- Array of form fields with values.Form.Groups- Fields organized by group
Form Field Structure
type FormFieldRendered struct {
Key string // Field key
Label string // Display label
Type string // "text", "number", "select", "file", "richtext", etc.
Value any // Resolved value from forms.values JSONB
FileURL string // Pre-signed S3 URL if type is "file"
Private bool // true = specialist-only (excluded from patient PDF)
Group string // Group name
Required bool // Is field required
}Document Metadata
.Document.Title- Document title.Document.Type- "report" or "prescription".Document.CreatedAt- Document creation time.Document.SignedAt- Form signing time (nil if not signed)
Generation Context
.GeneratedAt- PDF generation timestamp.Locale- Locale code ("ro-RO", "en-US", etc.)
Template Functions
Registered in Go's template.FuncMap:
| Function | Purpose | Example |
|---|---|---|
formatDate | Locale-aware date formatting | {{formatDate .Appointment.StartedAt "ro-RO"}} → "20 ianuarie 2025" |
formatDateTime | Date + time formatting | {{formatDateTime .GeneratedAt "ro-RO"}} → "20 ianuarie 2025, 14:30" |
safeHTML | Mark richtext as safe (no escaping) | {{safeHTML .Value}} |
upper | Uppercase text | {{upper .Patient.Name}} |
ifEmpty | Default value for empty fields | {{ifEmpty .Value "N/A"}} |
joinComma | Join string slice | {{joinComma .Specialist.Specialties}} |
Example Template
<!DOCTYPE html>
<html lang="{{.Locale}}">
<head>
<meta charset="UTF-8">
<style>{{.CSS}}</style>
</head>
<body>
<header class="doc-header">
<div class="org-logo">
<img src="{{.Organization.LogoURL}}" alt="{{.Organization.Name}}" />
</div>
<div class="org-info">
<h2>{{.Organization.Name}}</h2>
<p>{{.Organization.Address}}</p>
</div>
</header>
<h1>{{.Document.Title}}</h1>
<section class="patient-info">
<h3>Patient</h3>
<p><strong>Name:</strong> {{.Patient.Name}}</p>
<p><strong>Date of Birth:</strong> {{.Patient.DateOfBirth}}</p>
</section>
<section class="appointment-info">
<p><strong>Date:</strong> {{formatDate .Appointment.StartedAt .Locale}}</p>
<p><strong>Specialist:</strong> {{.Specialist.Title}} {{.Specialist.Name}}</p>
</section>
<section class="form-content">
{{range .Form.Fields}}
{{if not .Private}}
<div class="field">
<label>{{.Label}}</label>
{{if eq .Type "richtext"}}
<div class="richtext-value">{{safeHTML .Value}}</div>
{{else if eq .Type "file"}}
<img src="{{.FileURL}}" class="field-image" />
{{else}}
<p>{{.Value}}</p>
{{end}}
</div>
{{end}}
{{end}}
</section>
<footer class="doc-footer">
{{if .Specialist.SignatureURL}}
<div class="signature">
<img src="{{.Specialist.SignatureURL}}" alt="Signature" />
<p>{{.Specialist.Title}} {{.Specialist.Name}}</p>
</div>
{{end}}
<p class="generated-at">Generated: {{formatDate .GeneratedAt .Locale}}</p>
</footer>
</body>
</html>Template Configuration
Page Size
A4(default) - 210mm × 297mmLetter- 8.5in × 11in
Orientation
portrait(default)landscape
Margins
Default margins (JSONB):
{
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}Field Placeholders and system_key Integration
Using system_key for Template Stability
While form field key and label can be customized per organization, system_key provides stable identifiers for templates:
{{range .Form.Fields}}
{{if eq .SystemKey "patient_complaint"}}
<div class="complaint-section">
<h3>{{.Label}}</h3>
<p>{{.Value}}</p>
</div>
{{end}}
{{end}}This allows templates to reference specific fields consistently across organizations, even if they customize the field labels or keys.
Field Type Rendering
Templates should handle different field types appropriately:
{{range .Form.Fields}}
<div class="field">
<label>{{.Label}}</label>
{{if eq .Type "text"}}
<p>{{.Value}}</p>
{{else if eq .Type "richtext"}}
<div class="richtext">{{safeHTML .Value}}</div>
{{else if eq .Type "select"}}
<p>{{.Value}}</p>
{{else if eq .Type "multiselect"}}
<ul>
{{range .Value}}
<li>{{.}}</li>
{{end}}
</ul>
{{else if eq .Type "file"}}
<img src="{{.FileURL}}" alt="{{.Label}}" />
{{else if eq .Type "checkbox"}}
<p>{{if .Value}}Yes{{else}}No{{end}}</p>
{{else}}
<p>{{.Value}}</p>
{{end}}
</div>
{{end}}Grouping Fields
Fields can be organized by group in templates:
{{range $groupName, $fields := .Form.Groups}}
<section class="field-group">
<h3>{{$groupName}}</h3>
{{range $fields}}
<div class="field">
<label>{{.Label}}</label>
<p>{{.Value}}</p>
</div>
{{end}}
</section>
{{end}}Private Field Filtering
Private fields are automatically filtered based on the audience before the template receives data. The template doesn't need to check - it simply renders what it receives.
Filtering happens in the DocumentDataBuilder:
// If audience is "patient", exclude private fields
if audience == "patient" {
fields = filterPrivateFields(fields)
}Templates can still conditionally check if needed:
{{range .Form.Fields}}
{{if not .Private}}
<!-- Render field -->
{{end}}
{{end}}Image Embedding
All images (logos, signatures, file uploads) are embedded as base64 data URIs to ensure the PDF is self-contained:
- Template references S3 URL:
<img src="{{.Organization.LogoURL}}" />
- Builder fetches S3 file and converts to base64
- Final HTML has:
<img src="data:image/png;base64,iVBORw0KG..." />
This ensures PDFs have no external dependencies and render consistently offline.
Custom Field Values in Templates
Patient profile data from custom_field_values is available via:
{{range $key, $value := .CustomFields}}
<p><strong>{{$key}}:</strong> {{$value}}</p>
{{end}}Or access specific fields:
<p>Blood Type: {{index .CustomFields "blood_type"}}</p>
<p>City: {{index .CustomFields "city"}}</p>Headers and Footers
Templates can define headers and footers that appear on every page:
<!-- header_html -->
<div class="page-header">
<img src="{{.Organization.LogoURL}}" class="header-logo" />
<span class="header-text">{{.Organization.Name}}</span>
</div>
<!-- footer_html -->
<div class="page-footer">
<p>Page <span class="pageNumber"></span> of <span class="totalPages"></span></p>
<p>{{.Organization.Address}} | {{.Organization.Phone}}</p>
</div>Template Preview
Admins can preview templates with sample data using:
POST /v1/document-templates/{id}/previewThis generates a PDF with either:
- Placeholder data (if no appointment_id provided)
- Real appointment data (if appointment_id provided)
Template Versioning
Unlike form templates, document templates do NOT have versioning. Changes to templates are applied immediately and affect all future PDF generations.
Cache invalidation ensures previously-cached PDFs are regenerated with the updated template when accessed again.
Best Practices
- Keep templates simple - Complex layouts can fail to render or have performance issues
- Test with real data - Use preview endpoint with actual appointments
- Handle missing data - Use
ifEmptyfunction or conditional blocks - Embed images - Never use external URLs in templates (they won't work in PDFs)
- Use CSS classes - Keep styling in
css_stylesfield, not inline - Responsive page breaks - Use CSS
page-break-after,page-break-insidefor multi-page documents - Font selection - Use web-safe fonts or embed custom fonts as base64
- Test printing - Preview PDFs before deploying to production