Document Creator

A PhoenixKit module for document template management and PDF generation via Google Docs. Templates and documents live in Google Drive as Google Docs. Variables use {{ placeholder }} syntax and are substituted via the Google Docs API. PDF export uses the Google Drive export endpoint.

Features

Setup

1. Add the dependency

# In parent app's mix.exs
{:phoenix_kit_document_creator, "~> 0.2"}

2. Enable the module

Start your app, go to Admin > Modules, enable Document Creator.

3. Connect Google Docs

  1. Create a Google Cloud project with Docs API and Drive API enabled
  2. Create an OAuth 2.0 Client ID (Web application type)
  3. Go to Admin > Settings > Document Creator
  4. Enter the Client ID and Client Secret
  5. Click Connect and authorize the Google account
  6. Create /templates and /documents folders in the connected Google Drive

Prerequisites

Architecture

How it works

Template (Google Doc with {{ variables }})
| copy via Drive API
v
Document (Google Doc copy)
| replaceAllText via Docs API
v
Document (variables filled in)
| export via Drive API
v
PDF
  1. Templates are Google Docs stored in a /templates folder in Drive. Variables like {{ client_name }} are auto-detected from the document text.
  2. Documents are created by copying a template via the Drive API, then substituting variable values using the Docs API replaceAllText endpoint.
  3. PDF export uses the Drive API file export endpoint — no local Chrome or binary dependencies needed.

Google Drive as source of truth

All document content lives in Google Drive. The Phoenix app serves as a coordinator:

Dependencies

PackagePurpose
phoenix_kitModule behaviour, Settings API, admin layout, Routes
phoenix_live_viewAdmin pages
reqHTTP client for Google Docs/Drive API

Admin Pages

The module registers 3 admin tabs plus a settings tab:

PagePathDescription
Document Creator/admin/document-creatorLanding page (redirects to first subtab)
Documents/admin/document-creator/documentsList documents from Drive with thumbnails
Templates/admin/document-creator/templatesList templates from Drive with thumbnails

Settings:

PagePathDescription
Google OAuth/admin/settings/document-creatorConnect/disconnect Google account, manage OAuth credentials

Database Schema

Tables are created by PhoenixKit core's migration system (V86 for the initial tables, V94 for the google_doc_id / status / path / folder_id columns):

phoenix_kit_doc_templates

ColumnTypeDescription
uuidUUID (PK)Auto-generated (UUIDv7)
namestringTemplate name
slugstringURL-safe identifier (unique, auto-generated from name)
descriptiontextTemplate description
statusstring"published", "trashed", "lost", or "unfiled"
google_doc_idstringGoogle Doc ID (partial unique index)
pathstringHuman-readable Drive path (e.g. "documents/order-1/sub-4")
folder_idstringDrive folder ID of the file's current parent
variablesjsonbArray of variable definitions detected in the template
configjsonbConfiguration (e.g., paper_size)
thumbnailtextBase64 data URI for preview
content_html / content_css / content_nativemixedLegacy columns — no longer populated

phoenix_kit_doc_documents

ColumnTypeDescription
uuidUUID (PK)Auto-generated (UUIDv7)
namestringDocument name
statusstring"published", "trashed", "lost", or "unfiled"
google_doc_idstringGoogle Doc ID (partial unique index)
pathstringHuman-readable Drive path
folder_idstringDrive folder ID of the file's current parent
template_uuidUUID (FK)Template this was created from (optional)
variable_valuesjsonbMap of variable values used during creation
configjsonbConfiguration
thumbnailtextBase64 data URI for preview
created_by_uuidUUIDOptional FK to users
content_html / content_css / header/footer colsmixedLegacy columns — no longer populated

phoenix_kit_doc_headers_footers

Legacy table — headers and footers are now handled natively by Google Docs. Retained for migration compatibility; a future migration will drop it.

Context API

The module exposes three layers. See AGENTS.md for the full breakdown; the quick reference below covers the most common calls.

PhoenixKitDocumentCreator.Documents — combined Drive + DB

Reads go to the local DB (fast); writes go to Drive first then DB. Mutating functions accept opts with :actor_uuid for activity logging.

# Listing (DB-only, no Drive round-trip)
Documents.list_templates_from_db()
Documents.list_documents_from_db()
Documents.list_trashed_templates_from_db()
Documents.list_trashed_documents_from_db()
# Sync (recursive walker — picks up files nested in subfolders too)
Documents.sync_from_drive()
# Create
Documents.create_template("Invoice Template", actor_uuid: uid)
Documents.create_document("Blank Doc", actor_uuid: uid)
# Create a document from a template, optionally into a subfolder you manage
Documents.create_document_from_template(template_file_id, %{"client" => "Acme"},
name: "Acme Contract",
parent_folder_id: sub_folder_id, # optional — defaults to managed documents root
path: "documents/order-1/sub-4", # optional — human-readable path
actor_uuid: uid
)
# Register a Drive file your own code created (no Drive calls — DB-only upsert)
Documents.register_existing_document(%{
google_doc_id: doc_id,
name: "Invoice",
template_uuid: tpl_uuid,
variable_values: vars,
folder_id: sub_folder_id,
path: "documents/order-1/sub-4"
}, actor_uuid: uid)
Documents.register_existing_template(%{google_doc_id: gid, name: "Tpl"})
# Delete (soft — moves to the deleted folder)
Documents.delete_document(file_id, actor_uuid: uid)
Documents.delete_template(file_id, actor_uuid: uid)
Documents.restore_document(file_id, actor_uuid: uid)
Documents.restore_template(file_id, actor_uuid: uid)
# Unfiled resolution (file found outside the managed tree)
Documents.move_to_templates(file_id, actor_uuid: uid)
Documents.move_to_documents(file_id, actor_uuid: uid)
Documents.set_correct_location(file_id, actor_uuid: uid)
# Variable detection on a template
Documents.detect_variables(file_id) # {:ok, ["client_name", "date"]}
# PDF export
Documents.export_pdf(file_id, name: "Acme Contract", actor_uuid: uid)
# Folder helpers (cached via Settings, lazy-discovered on first access)
Documents.get_folder_ids()
Documents.refresh_folders()
Documents.templates_folder_url()
Documents.documents_folder_url()
# PubSub — broadcast {:files_changed, self()} on "document_creator:files"
# topic. Bulk callers passing `emit_pubsub: false` to the register functions
# should call this once at the end to resync connected admin LiveViews.
Documents.broadcast_files_changed()

PhoenixKitDocumentCreator.Errors — error atom dispatcher

Context and client functions return {:error, :atom} tuples — never raw strings. Translate at the UI / API boundary via Errors.message/1, which returns gettext-wrapped strings (translations live in core phoenix_kit):

case Documents.create_template("Invoice", actor_uuid: uid) do
{:ok, template} -> ...
{:error, reason} ->
flash = PhoenixKitDocumentCreator.Errors.message(reason)
put_flash(socket, :error, flash)
end

Atoms that flow out of the public API: :templates_folder_not_found, :documents_folder_not_found, :invalid_parent_folder_id, :invalid_google_doc_id, :missing_google_doc_id, :missing_name, :not_found, :file_trashed, :invalid_file_id, :no_doc_id, :no_thumbnail, :create_document_failed, :create_folder_failed, :folder_search_failed, :move_failed, :copy_failed, :pdf_export_failed, :thumbnail_link_failed, :thumbnail_fetch_failed, :list_files_failed, :get_file_parents_failed, :sync_failed, :max_depth_exceeded. Every atom has a literal gettext("…") clause in Errors.message/1; unknown atoms fall through to inspect/1.

PhoenixKitDocumentCreator.GoogleDocsClient — direct Drive + Docs API

OAuth credentials and token refresh live in PhoenixKit.Integrations under the "google" provider; this module delegates authentication there.

The active connection is stored as the integration row's uuid in document_creator_settings.google_connection. active_integration_uuid/0 is the read accessor; migrate_legacy/0 (the PhoenixKit.Module boot callback) handles two kinds of pre-uuid data on upgrade:

  1. The legacy document_creator_google_oauth settings key with locally- stored OAuth tokens, migrated into a real PhoenixKit.Integrations row under "google:default". After a successful migration the plaintext secrets in the legacy key are wiped (the row is reset to %{}) so they don't survive the move to encrypted Integrations storage.
  2. Name-string google_connection references ("google" / "google:my-name") from before the uuid switch, rewritten in place to the matching row's uuid.

Lazy on-read promotion in active_integration_uuid/0 covers any record the boot pass missed — first request after upgrade rewrites the setting transparently. Host apps trigger boot migration via PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0 from Application.start/2; if you never call it, the lazy path keeps things working at the cost of one extra Settings round-trip per affected request.

GoogleDocsClient.connection_status() # {:ok, %{email: ...}} | {:error, reason}
GoogleDocsClient.get_credentials() # {:ok, creds} | {:error, :not_configured}
# Folders
GoogleDocsClient.find_folder_by_name(name, opts)
GoogleDocsClient.create_folder(name, opts)
GoogleDocsClient.find_or_create_folder(name, opts)
GoogleDocsClient.ensure_folder_path("a/b/c", opts)
GoogleDocsClient.discover_folders() # resolves all four managed folders
GoogleDocsClient.list_subfolders(parent_id) # paginated, alphabetical
GoogleDocsClient.list_folder_files(folder_id) # paginated; direct children only
GoogleDocsClient.get_folder_url(folder_id)
# Files
GoogleDocsClient.create_document(title, parent: folder_id)
GoogleDocsClient.copy_file(src_id, new_name, parent: folder_id)
GoogleDocsClient.move_file(file_id, to_folder_id)
GoogleDocsClient.export_pdf(file_id) # {:ok, pdf_binary}
GoogleDocsClient.fetch_thumbnail(file_id) # {:ok, "data:image/png;base64,..."}
GoogleDocsClient.file_status(file_id) # {:ok, %{trashed: bool, parents: [id]}}
GoogleDocsClient.file_location(file_id) # {:ok, %{folder_id, path, trashed}}
GoogleDocsClient.get_edit_url(file_id)
# Docs
GoogleDocsClient.get_document(doc_id)
GoogleDocsClient.get_document_text(doc_id)
GoogleDocsClient.batch_update(doc_id, requests)
GoogleDocsClient.replace_all_text(doc_id, %{"var" => "value"})

PhoenixKitDocumentCreator.GoogleDocsClient.DriveWalker — paginated + recursive traversal

Canonical paginated listing primitive. list_folder_files/1 and list_subfolders/1 on the parent client delegate here.

alias PhoenixKitDocumentCreator.GoogleDocsClient.DriveWalker
DriveWalker.list_files(folder_id) # {:ok, [file_map]} — paginated
DriveWalker.list_folders(folder_id) # {:ok, [folder_map]} — paginated, alphabetical
# BFS the whole tree — returns every descendant folder and every Google Doc
# in any of them, each file annotated with its owning `folder_id` and the
# resolved human-readable `path`.
DriveWalker.walk_tree(root_folder_id,
root_path: "documents", # caller-supplied path to anchor descendants at
max_depth: 20 # defensive cap; root is depth 0
)

Variable System

Templates support {{ variable_name }} placeholders.

Auto-detection

Variables are extracted from Google Doc text content via regex:

PhoenixKitDocumentCreator.Variable.extract_variables("Dear {{ client_name }},")
# => ["client_name"]

Type guessing

Variable types are guessed from names:

Name containsGuessed type
date:date
amount, price:currency
description, notes:multiline
anything else:text

Navigation (Paths Module)

All paths go through PhoenixKit.Utils.Routes.path/1 via the centralized Paths module:

alias PhoenixKitDocumentCreator.Paths
Paths.index() # /admin/document-creator
Paths.templates() # /admin/document-creator/templates
Paths.documents() # /admin/document-creator/documents
Paths.settings() # /admin/settings/document-creator

PhoenixKit Module Integration

Behaviour callbacks

CallbackValue
module_key"document_creator"
module_name"Document Creator"
version"0.1.2"
permission_metadataKey: "document_creator", icon: "hero-document-text"
children[]
admin_tabs3 tabs (parent + documents + templates)
settings_tabs1 tab (Google OAuth settings)

Permission

The module registers "document_creator" as a permission key. Owner and Admin roles get access automatically. Custom roles must be granted access via Admin > Roles.

Project Structure

lib/
phoenix_kit_document_creator.ex # Main module (behaviour callbacks, tab registration)
phoenix_kit_document_creator/
documents.ex # Context: combined Drive + DB operations
google_docs_client.ex # Google Docs + Drive API client (OAuth via Integrations)
google_docs_client/
drive_walker.ex # Paginated + recursive Drive traversal
variable.ex # Extract {{ variables }} and guess types
paths.ex # Centralized URL path helpers
schemas/
document.ex # Document schema
header_footer.ex # Header/footer schema — legacy, deprecated
template.ex # Template schema (with slug auto-gen)
web/
documents_live.ex # Landing page (templates + documents tabs)
google_oauth_settings_live.ex # OAuth settings page
components/
create_document_modal.ex # Multi-step creation modal

Development

Code quality

mix format
mix credo --strict
mix dialyzer

Running tests

# Unit tests only (no database needed)
mix test --exclude integration
# All tests (requires PostgreSQL)
createdb phoenix_kit_document_creator_test
mix test

License

MIT