Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_document_creator.ex
defmodule PhoenixKitDocumentCreator do
@moduledoc """
Document Creator module for PhoenixKit.
Document template design and PDF generation via Google Docs API.
Templates, documents, and headers/footers are created and edited as
Google Docs, embedded in the admin UI via iframe. Variables use
`{{ placeholder }}` syntax and are substituted via the Google Docs
`replaceAllText` API. PDF export uses the Google Drive export endpoint.
## Installation
Add to your parent app's `mix.exs`:
{:phoenix_kit_document_creator, path: "../phoenix_kit_document_creator"}
Then `mix deps.get`. The module auto-discovers via beam scanning.
Enable it in Admin > Modules.
## Google Docs Setup
Configure the Google Docs integration in Admin > Settings > Document Creator.
You need a Google Cloud project with Docs API and Drive API enabled,
and an OAuth 2.0 Client ID (Web application type).
"""
use PhoenixKit.Module
require Logger
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Integrations
alias PhoenixKit.Settings
alias PhoenixKitDocumentCreator.GoogleDocsClient
# ===========================================================================
# Required callbacks
# ===========================================================================
@impl PhoenixKit.Module
def module_key, do: "document_creator"
@impl PhoenixKit.Module
def module_name, do: "Document Creator"
@impl PhoenixKit.Module
def enabled? do
Settings.get_boolean_setting("document_creator_enabled", false)
rescue
_ -> false
catch
# During test sandbox shutdown the pool checkout exits with
# `"owner #PID<...> exited"` — `rescue` doesn't catch :exit signals.
# Without this clause, a 1-in-N suite run flakes on the next test
# that calls `enabled?/0` from a process the sandbox no longer owns.
:exit, _ -> false
end
@impl PhoenixKit.Module
def enable_system do
Settings.update_boolean_setting_with_module("document_creator_enabled", true, module_key())
end
@impl PhoenixKit.Module
def disable_system do
Settings.update_boolean_setting_with_module("document_creator_enabled", false, module_key())
end
# ===========================================================================
# Optional callbacks
# ===========================================================================
# Derive at compile time from mix.exs so the runtime function can't
# drift from the declared package version. The `version_test` callback
# check pins this in `test/integration/module_callbacks_test.exs`.
@version Mix.Project.config()[:version]
@impl PhoenixKit.Module
def version, do: @version
# No `migration_module/0` override — migrations are handled by
# PhoenixKit core (V86 + V94 create the doc tables; this module owns
# no migrations of its own). The `PhoenixKit.Module` behaviour treats
# the callback as optional, so omitting it is the canonical pattern.
@impl PhoenixKit.Module
def permission_metadata do
%{
key: module_key(),
label: "Document Creator",
icon: "hero-document-text",
description: "Visual template design and PDF generation"
}
end
@impl PhoenixKit.Module
def css_sources, do: [:phoenix_kit_document_creator]
@impl PhoenixKit.Module
def route_module, do: PhoenixKitDocumentCreator.Web.Routes
@impl PhoenixKit.Module
def required_integrations, do: ["google"]
@impl PhoenixKit.Module
def children, do: []
@impl PhoenixKit.Module
def settings_tabs do
[
%Tab{
id: :admin_settings_document_creator,
label: "Document Creator",
icon: "hero-document-text",
path: "document-creator",
priority: 930,
level: :admin,
parent: :admin_settings,
permission: module_key(),
match: :exact,
live_view: {PhoenixKitDocumentCreator.Web.GoogleOAuthSettingsLive, :index},
gettext_backend: PhoenixKitDocumentCreator.Gettext,
gettext_domain: "default"
}
]
end
@impl PhoenixKit.Module
def admin_tabs do
base_tabs()
end
defp base_tabs do
[
%Tab{
id: :admin_document_creator,
label: "Document Creator",
icon: "hero-document-text",
path: "document-creator",
priority: 650,
level: :admin,
permission: module_key(),
match: :prefix,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
redirect_to_first_subtab: true,
live_view: {PhoenixKitDocumentCreator.Web.DocumentsLive, :documents},
gettext_backend: PhoenixKitDocumentCreator.Gettext,
gettext_domain: "default"
},
%Tab{
id: :admin_document_creator_documents,
label: "Documents",
icon: "hero-document-duplicate",
path: "document-creator/documents",
priority: 648,
level: :admin,
permission: module_key(),
parent: :admin_document_creator,
match: :prefix,
live_view: {PhoenixKitDocumentCreator.Web.DocumentsLive, :documents},
gettext_backend: PhoenixKitDocumentCreator.Gettext,
gettext_domain: "default"
},
%Tab{
id: :admin_document_creator_templates,
label: "Templates",
icon: "hero-document-text",
path: "document-creator/templates",
priority: 649,
level: :admin,
permission: module_key(),
parent: :admin_document_creator,
match: :prefix,
live_view: {PhoenixKitDocumentCreator.Web.DocumentsLive, :templates},
gettext_backend: PhoenixKitDocumentCreator.Gettext,
gettext_domain: "default"
},
%Tab{
id: :admin_document_creator_categories,
label: "Categories",
icon: "hero-folder",
path: "document-creator/categories",
priority: 651,
level: :admin,
permission: module_key(),
parent: :admin_document_creator,
match: :prefix,
live_view: {PhoenixKitDocumentCreator.Web.CategoriesLive, :index},
gettext_backend: PhoenixKitDocumentCreator.Gettext,
gettext_domain: "default"
}
]
end
# ===========================================================================
# Legacy migration
# ===========================================================================
@legacy_oauth_settings_key "document_creator_google_oauth"
@new_integration_provider "google"
@new_integration_name "default"
# Implements the optional `migrate_legacy/0` callback added in core
# V107+. Hex `~> 1.7` doesn't declare it on the `PhoenixKit.Module`
# behaviour, so `@impl PhoenixKit.Module` would warn — keep this as
# a plain function until the floor version is bumped. The orchestrator
# in `PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0`
# dispatches by `function_exported?/3` regardless of the annotation.
def migrate_legacy do
creds_result = migrate_legacy_oauth_credentials()
refs_result = migrate_legacy_connection_references()
{:ok,
%{
credentials_migration: creds_result,
reference_migration: refs_result
}}
rescue
e ->
Logger.warning("[DocumentCreator] migrate_legacy/0 raised: #{Exception.message(e)}")
{:error, e}
end
# Migration (1): pre-Integrations OAuth tokens stored under
# `document_creator_google_oauth` → new `integration:google:default`
# row managed by PhoenixKit.Integrations. Was previously in core's
# `Integrations.run_legacy_migrations/0` (with hardcoded `@legacy_keys`);
# moved here because the data shape is doc_creator-specific.
defp migrate_legacy_oauth_credentials do
legacy_data = Settings.get_json_setting(@legacy_oauth_settings_key, nil)
cond do
not is_map(legacy_data) or map_size(legacy_data) == 0 ->
:no_legacy_data
already_migrated?() ->
:already_migrated
true ->
do_migrate_oauth_credentials(legacy_data)
end
rescue
e ->
Logger.warning(
"[DocumentCreator] OAuth credentials migration raised: #{Exception.message(e)}"
)
{:error, e}
end
# Prefer the uuid-strict `find_uuid_by_provider_name/1` (core 1.7.105+)
# so the check matches the same lookup shape we use elsewhere
# (`migrate_legacy_connection_references/0`, the consumer pattern in
# AGENTS.md). On older cores that pre-date the helper, fall back to
# the legacy `provider:name` string lookup against `get_integration/1`'s
# dual-input read shim. The fallback can be deleted once
# `phoenix_kit ~> 1.7.105` is the floor in `mix.exs` (also tracked in
# dev_docs/migration_cleanup.md).
defp already_migrated? do
storage_key = "#{@new_integration_provider}:#{@new_integration_name}"
if function_exported?(Integrations, :find_uuid_by_provider_name, 1) do
# `apply/3` is deliberate: cores that pre-date the helper would
# otherwise emit an "undefined function" warning at compile time
# even though the `function_exported?/3` guard makes the call
# safe at runtime. Drop the `apply/3` once `~> 1.7.105` is the
# floor in `mix.exs`.
# credo:disable-for-next-line Credo.Check.Refactor.Apply
apply(Integrations, :find_uuid_by_provider_name, [storage_key]) |> is_binary()
else
case Integrations.get_integration(storage_key) do
{:ok, _} -> true
_ -> false
end
end
end
defp do_migrate_oauth_credentials(legacy_data) do
integration_data = build_integration_data(legacy_data)
integration_key = "#{@new_integration_provider}:#{@new_integration_name}"
# Two-step write under core's strict-UUID Integrations API: create
# the row via `add_connection/3` (the only legitimate place to
# construct a new `integration:{provider}:{name}` storage key),
# then save the migrated credentials against the returned uuid.
with {:ok, %{uuid: uuid}} <-
ensure_connection(@new_integration_provider, @new_integration_name),
{:ok, _saved} <- Integrations.save_setup(uuid, integration_data) do
migrate_legacy_folders(legacy_data)
clear_legacy_oauth_key()
log_migration_activity(:credentials_migrated, %{
legacy_key: @legacy_oauth_settings_key,
new_key: "integration:#{integration_key}",
integration_uuid: uuid
})
Logger.info(
"[DocumentCreator] Migrated legacy '#{@legacy_oauth_settings_key}' → 'integration:#{integration_key}'"
)
:migrated
else
{:error, reason} ->
Logger.warning(
"[DocumentCreator] OAuth credentials migration save failed: #{inspect(reason)}"
)
{:error, reason}
end
end
defp ensure_connection(provider, name) do
case Integrations.add_connection(provider, name) do
{:ok, %{uuid: _} = result} ->
{:ok, result}
{:error, :already_exists} ->
resolve_existing_connection_uuid(provider, name)
error ->
error
end
end
# Cross-version compat for the `:already_exists` resolve step.
#
# `find_uuid_by_provider_name/1` is the cleaner V107 primitive but
# doesn't exist in Hex `~> 1.7`. Without the `function_exported?/3`
# gate, dialyzer / `mix precommit` flags it as a missing call.
# When the floor version is bumped past V107 the gate can be
# removed and the call inlined.
defp resolve_existing_connection_uuid(provider, name) do
if function_exported?(Integrations, :find_uuid_by_provider_name, 1) do
# credo:disable-for-next-line Credo.Check.Refactor.Apply
case apply(Integrations, :find_uuid_by_provider_name, [{provider, name}]) do
{:ok, uuid} -> {:ok, %{uuid: uuid}}
error -> error
end
else
# Pre-V107 fallback — scan provider's connections by name.
case Integrations.list_connections(provider) |> Enum.find(&(&1.name == name)) do
%{uuid: uuid} -> {:ok, %{uuid: uuid}}
_ -> {:error, :not_found}
end
end
end
defp build_integration_data(legacy_data) do
base = %{
"provider" => @new_integration_provider,
"auth_type" => "oauth2",
"client_id" => legacy_data["client_id"],
"client_secret" => legacy_data["client_secret"],
"access_token" => legacy_data["access_token"],
"refresh_token" => legacy_data["refresh_token"],
"token_type" => legacy_data["token_type"] || "Bearer",
"token_obtained_at" => legacy_data["token_obtained_at"],
"status" => derive_status(legacy_data),
"external_account_id" => legacy_data["connected_email"],
"metadata" => %{
"connected_email" => legacy_data["connected_email"]
}
}
base
|> maybe_put_expires_at(legacy_data)
|> maybe_put_connected_at(legacy_data)
end
defp derive_status(%{"access_token" => token}) when is_binary(token) and token != "",
do: "connected"
defp derive_status(_), do: "disconnected"
defp maybe_put_expires_at(data, legacy_data) do
with expires_in when is_integer(expires_in) <- legacy_data["expires_in"],
obtained_at when is_binary(obtained_at) <- legacy_data["token_obtained_at"],
{:ok, dt, _} <- DateTime.from_iso8601(obtained_at) do
Map.put(
data,
"expires_at",
dt |> DateTime.add(expires_in, :second) |> DateTime.to_iso8601()
)
else
_ -> data
end
end
defp maybe_put_connected_at(%{"status" => "connected"} = data, legacy_data) do
Map.put(
data,
"connected_at",
legacy_data["token_obtained_at"] || DateTime.utc_now() |> DateTime.to_iso8601()
)
end
defp maybe_put_connected_at(data, _legacy_data), do: data
# After the credentials are migrated into a `PhoenixKit.Integrations`
# row (which encrypts secrets at rest), the original
# `document_creator_google_oauth` settings row still holds plaintext
# `client_secret` / `access_token` / `refresh_token`. `already_migrated?/0`
# stops re-migration on subsequent boots, so without this cleanup the
# plaintext copies would persist in `phoenix_kit_settings` indefinitely.
# Overwrite with `%{}` to keep the row but drop the secrets. Failure
# here doesn't roll back the migration — it's a best-effort secrets
# wipe; ops can always remove the row by hand.
defp clear_legacy_oauth_key do
Settings.update_json_setting_with_module(
@legacy_oauth_settings_key,
%{},
module_key()
)
:ok
rescue
e ->
Logger.warning(
"[DocumentCreator] Failed to clear legacy OAuth key after migration — " <>
"plaintext secrets may remain in '#{@legacy_oauth_settings_key}'. " <>
"exception=#{inspect(e.__struct__)}"
)
:ok
end
defp migrate_legacy_folders(legacy_data) do
folder_fields = ~w(
folder_path_templates folder_name_templates
folder_path_documents folder_name_documents
folder_path_deleted folder_name_deleted
templates_folder_id documents_folder_id
deleted_templates_folder_id deleted_documents_folder_id
)
folder_data = Map.take(legacy_data, folder_fields)
if map_size(folder_data) > 0 do
Settings.update_json_setting_with_module(
"document_creator_folders",
folder_data,
module_key()
)
end
rescue
e ->
Logger.warning(
"[DocumentCreator] Failed to move legacy folder config: #{Exception.message(e)}"
)
end
# Migration (2): boot-time sweep that resolves any name-string
# `document_creator_settings.google_connection` value to its
# matching integration row's uuid. The same logic runs lazily on
# first read via `GoogleDocsClient.active_integration_uuid/0` —
# this boot pass just rewrites it eagerly so admins don't see a
# delay on first page load.
defp migrate_legacy_connection_references do
case Settings.get_json_setting("document_creator_settings", %{}) do
%{"google_connection" => value} when is_binary(value) and value != "" ->
if GoogleDocsClient.uuid?(value) do
:already_uuid
else
resolve_and_persist(value)
end
_ ->
:no_reference_set
end
rescue
e ->
Logger.warning("[DocumentCreator] Reference migration raised: #{Exception.message(e)}")
{:error, e}
end
defp resolve_and_persist(name_string) do
case resolve_via_list_connections(name_string) do
{:ok, uuid} ->
rewrite_google_connection(uuid)
log_migration_activity(:reference_migrated, %{
old_value: name_string,
new_uuid: uuid
})
:migrated
{:error, reason} ->
Logger.warning(
"[DocumentCreator] Reference migration: cannot resolve '#{name_string}': #{inspect(reason)}"
)
# Audit the failure so the lazy and boot paths are symmetric:
# both surface a `legacy_migrated` row with `migration_kind=
# reference_migration_failed` when the resolver can't pin a
# uuid. Without this row, ops have no audit trail of "we tried
# to migrate at boot, it didn't resolve" — they only see the
# resulting "not configured" state and the warning log line.
log_migration_activity(:reference_migration_failed, %{
old_value: name_string,
reason: inspect(reason)
})
{:error, reason}
end
end
# `find_uuid_by_provider_name/1` is the cleaner core API but only
# exists in newer phoenix_kit versions. Use `list_connections/1`
# (long-stable) so this works against any phoenix_kit dep this
# module's mix.exs allows.
defp resolve_via_list_connections(name_string) do
{provider, name} =
case String.split(name_string, ":", parts: 2) do
[p, n] when n != "" -> {p, n}
[p] -> {p, "default"}
end
Integrations.list_connections(provider)
|> Enum.find(fn conn -> conn.name == name end)
|> case do
%{uuid: uuid} -> {:ok, uuid}
_ -> {:error, :not_found}
end
rescue
e ->
# The lookup is the fallback path of the legacy reference sweep —
# it runs at boot during `migrate_legacy/0` against whatever
# state Settings is in. A raise here doesn't crash the boot
# (the orchestrator catches it) but losing the exception type
# makes ops debugging guesswork. Log with grep-able context
# before swallowing. `Exception.message/1` is deliberately
# excluded — some Ecto exception structs embed query bindings
# that could leak provider strings into logs.
Logger.warning(fn ->
"[DocumentCreator] resolve_via_list_connections failed: " <>
"exception=#{inspect(e.__struct__)}"
end)
{:error, :resolver_failed}
end
defp rewrite_google_connection(uuid) do
current = Settings.get_json_setting("document_creator_settings", %{})
updated = Map.put(current, "google_connection", uuid)
Settings.update_json_setting_with_module(
"document_creator_settings",
updated,
module_key()
)
end
defp log_migration_activity(action_atom, metadata) do
if Code.ensure_loaded?(PhoenixKit.Activity) do
PhoenixKit.Activity.log(%{
action: "integration.legacy_migrated",
module: module_key(),
mode: "auto",
resource_type: "integration",
metadata:
Map.merge(metadata, %{
"migration_kind" => Atom.to_string(action_atom),
"actor_role" => "system"
})
})
end
:ok
rescue
e ->
# Activity logging failures must NEVER crash the migration
# path — the orchestrator already caught any earlier exception
# by the time we get here, and we don't want a missing
# activities table (host hasn't run core's migration yet) to
# turn a successful credentials migration into a boot failure.
# But silently returning :ok means an ops team can't tell why
# their audit feed is empty. Log the exception type before
# swallowing.
Logger.warning(fn ->
"[DocumentCreator] activity log failed during legacy migration: " <>
"kind=#{action_atom}, exception=#{inspect(e.__struct__)}"
end)
:ok
end
end