Skip to content

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:

sql
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

go
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:

FunctionPurposeExample
formatDateLocale-aware date formatting{{formatDate .Appointment.StartedAt "ro-RO"}} → "20 ianuarie 2025"
formatDateTimeDate + time formatting{{formatDateTime .GeneratedAt "ro-RO"}} → "20 ianuarie 2025, 14:30"
safeHTMLMark richtext as safe (no escaping){{safeHTML .Value}}
upperUppercase text{{upper .Patient.Name}}
ifEmptyDefault value for empty fields{{ifEmpty .Value "N/A"}}
joinCommaJoin string slice{{joinComma .Specialist.Specialties}}

Example Template

html
<!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 × 297mm
  • Letter - 8.5in × 11in

Orientation

  • portrait (default)
  • landscape

Margins

Default margins (JSONB):

json
{
  "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:

html
{{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:

html
{{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:

html
{{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:

go
// If audience is "patient", exclude private fields
if audience == "patient" {
    fields = filterPrivateFields(fields)
}

Templates can still conditionally check if needed:

html
{{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:

  1. Template references S3 URL: <img src="{{.Organization.LogoURL}}" />
  1. Builder fetches S3 file and converts to base64
  2. 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:

html
{{range $key, $value := .CustomFields}}
    <p><strong>{{$key}}:</strong> {{$value}}</p>
{{end}}

Or access specific fields:

html
<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:

html
<!-- 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}/preview

This 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

  1. Keep templates simple - Complex layouts can fail to render or have performance issues
  2. Test with real data - Use preview endpoint with actual appointments
  3. Handle missing data - Use ifEmpty function or conditional blocks
  4. Embed images - Never use external URLs in templates (they won't work in PDFs)
  5. Use CSS classes - Keep styling in css_styles field, not inline
  6. Responsive page breaks - Use CSS page-break-after, page-break-inside for multi-page documents
  7. Font selection - Use web-safe fonts or embed custom fonts as base64
  8. Test printing - Preview PDFs before deploying to production