+
+
+ {{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_TITLE') }}
+
+
+ {{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_SUBTITLE') }}
+
+
+
+
{
v-if="showCreateDialog"
ref="createDocumentDialog"
:assistant-id="selectedAssistantId"
+ @create-success="onCreateSuccess"
@close="handleCreateDialogClose"
/>
{
type="Documents"
@delete-success="onDeleteSuccess"
/>
-
diff --git a/app/javascript/dashboard/store/captain/bulkActions.js b/app/javascript/dashboard/store/captain/bulkActions.js
index 436801092..1fab006f6 100644
--- a/app/javascript/dashboard/store/captain/bulkActions.js
+++ b/app/javascript/dashboard/store/captain/bulkActions.js
@@ -61,5 +61,18 @@ export default createStore({
});
return response;
},
+
+ handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
+ const response = await dispatch('processBulkAction', {
+ type: 'AssistantDocument',
+ actionType: 'sync',
+ ids,
+ });
+
+ await dispatch('captainDocuments/markSyncing', response.ids || [], {
+ root: true,
+ });
+ return response;
+ },
}),
});
diff --git a/app/javascript/dashboard/store/captain/document.js b/app/javascript/dashboard/store/captain/document.js
index 76d0c9124..fbe017e26 100644
--- a/app/javascript/dashboard/store/captain/document.js
+++ b/app/javascript/dashboard/store/captain/document.js
@@ -1,15 +1,55 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
import { createStore } from '../storeFactory';
+const SYNCING_STATE = 'syncing';
+
+const markRecordsSyncing = (records, ids) => {
+ const idSet = new Set(ids);
+ return records.map(record =>
+ idSet.has(record.id)
+ ? {
+ ...record,
+ sync_status: SYNCING_STATE,
+ sync_in_progress: true,
+ last_sync_attempted_at: Math.floor(Date.now() / 1000),
+ last_sync_error_code: null,
+ }
+ : record
+ );
+};
+
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
+ getters: {
+ getRecords: state => state.records,
+ },
actions: mutations => ({
+ setFetchingList({ commit }, isFetching) {
+ commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
+ },
+ setRecords({ commit }, { records, meta }) {
+ commit(mutations.SET, records);
+ commit(mutations.SET_META, meta);
+ },
removeBulkRecords({ commit, getters }, ids) {
const records = getters.getRecords.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, records);
},
+ markSyncing({ commit, getters }, ids) {
+ commit(mutations.SET, markRecordsSyncing(getters.getRecords, ids));
+ },
+ async sync({ dispatch }, id) {
+ try {
+ await CaptainDocumentAPI.sync(id);
+ dispatch('markSyncing', [id]);
+ return id;
+ } catch (error) {
+ return throwErrorMessage(error);
+ }
+ },
}),
});
diff --git a/app/javascript/shared/helpers/documentHelper.js b/app/javascript/shared/helpers/documentHelper.js
index f050ad54d..ca7610f53 100644
--- a/app/javascript/shared/helpers/documentHelper.js
+++ b/app/javascript/shared/helpers/documentHelper.js
@@ -5,6 +5,7 @@
// Constants for document processing
const PDF_PREFIX = 'PDF:';
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
+const URL_DISPLAY_PREFIX_PATTERN = /^https?:\/\/(www\.)?/i;
/**
* Checks if a document is a PDF based on its external link
@@ -16,10 +17,26 @@ export const isPdfDocument = externalLink => {
return externalLink.startsWith(PDF_PREFIX);
};
+/**
+ * Checks if a link is safe to bind to an href attribute (http/https only).
+ * Guards against schemes like `javascript:` that would execute on click.
+ * @param {string} externalLink - The external link string
+ * @returns {boolean} True if the link uses http or https
+ */
+export const isSafeHttpLink = externalLink => {
+ if (!externalLink) return false;
+ try {
+ const { protocol } = new URL(externalLink);
+ return protocol === 'http:' || protocol === 'https:';
+ } catch (e) {
+ return false;
+ }
+};
+
/**
* Formats the display link for documents
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
- * For regular URLs: returns as-is
+ * For regular URLs: strips http(s):// and www. for a denser list view
*
* @param {string} externalLink - The external link string
* @returns {string} Formatted display link
@@ -34,5 +51,28 @@ export const formatDocumentLink = externalLink => {
return fullName.replace(TIMESTAMP_PATTERN, '');
}
- return externalLink;
+ return externalLink.replace(URL_DISPLAY_PREFIX_PATTERN, '');
+};
+
+/**
+ * Returns the path of a URL for compact display in document lists. This avoids
+ * repeating the domain while preserving enough context to distinguish pages.
+ * Falls back to the bare hostname for root URLs and formatDocumentLink for
+ * malformed URLs and PDFs.
+ */
+export const getDocumentDisplayPath = externalLink => {
+ if (!externalLink) return '';
+ if (isPdfDocument(externalLink)) return formatDocumentLink(externalLink);
+ try {
+ const { pathname, hostname } = new URL(externalLink);
+ const path = pathname.replace(/^\/+/, '');
+ if (!path) return hostname.replace(/^www\./i, '');
+ try {
+ return decodeURIComponent(path);
+ } catch (e) {
+ return path;
+ }
+ } catch (e) {
+ return formatDocumentLink(externalLink);
+ }
};
diff --git a/app/javascript/shared/helpers/specs/documentHelper.spec.js b/app/javascript/shared/helpers/specs/documentHelper.spec.js
index 64baf7069..dc01e078d 100644
--- a/app/javascript/shared/helpers/specs/documentHelper.spec.js
+++ b/app/javascript/shared/helpers/specs/documentHelper.spec.js
@@ -1,5 +1,6 @@
import {
isPdfDocument,
+ isSafeHttpLink,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
@@ -31,6 +32,35 @@ describe('documentHelper', () => {
});
});
+ describe('#isSafeHttpLink', () => {
+ it('returns true for http and https URLs', () => {
+ expect(isSafeHttpLink('http://example.com')).toBe(true);
+ expect(isSafeHttpLink('https://example.com/path?q=1#x')).toBe(true);
+ expect(isSafeHttpLink('HTTPS://EXAMPLE.COM')).toBe(true);
+ });
+
+ /* eslint-disable no-script-url */
+ it('returns false for javascript: and other dangerous schemes', () => {
+ expect(isSafeHttpLink('javascript:alert(1)')).toBe(false);
+ expect(isSafeHttpLink('JavaScript:alert(1)')).toBe(false);
+ expect(isSafeHttpLink('data:text/html,')).toBe(
+ false
+ );
+ expect(isSafeHttpLink('vbscript:msgbox(1)')).toBe(false);
+ expect(isSafeHttpLink('file:///etc/passwd')).toBe(false);
+ expect(isSafeHttpLink('ftp://files.example.com/doc.pdf')).toBe(false);
+ });
+ /* eslint-enable no-script-url */
+
+ it('returns false for invalid or empty values', () => {
+ expect(isSafeHttpLink('')).toBe(false);
+ expect(isSafeHttpLink(null)).toBe(false);
+ expect(isSafeHttpLink(undefined)).toBe(false);
+ expect(isSafeHttpLink('not a url')).toBe(false);
+ expect(isSafeHttpLink('//example.com')).toBe(false);
+ });
+ });
+
describe('#formatDocumentLink', () => {
describe('PDF documents', () => {
it('removes PDF: prefix from PDF documents', () => {
@@ -78,32 +108,30 @@ describe('documentHelper', () => {
});
describe('Regular URLs', () => {
- it('returns regular URLs unchanged', () => {
- expect(formatDocumentLink('https://example.com')).toBe(
- 'https://example.com'
- );
+ it('removes http(s) and www prefixes for compact display', () => {
+ expect(formatDocumentLink('https://example.com')).toBe('example.com');
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
- 'http://docs.example.com/api'
+ 'docs.example.com/api'
);
- expect(formatDocumentLink('https://github.com/user/repo')).toBe(
- 'https://github.com/user/repo'
+ expect(formatDocumentLink('https://www.github.com/user/repo')).toBe(
+ 'github.com/user/repo'
);
});
it('handles URLs with query parameters', () => {
expect(formatDocumentLink('https://example.com?param=value')).toBe(
- 'https://example.com?param=value'
+ 'example.com?param=value'
);
expect(
formatDocumentLink(
'https://api.example.com/docs?version=v1&format=json'
)
- ).toBe('https://api.example.com/docs?version=v1&format=json');
+ ).toBe('api.example.com/docs?version=v1&format=json');
});
it('handles URLs with fragments', () => {
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
- 'https://example.com/docs#section1'
+ 'example.com/docs#section1'
);
});
});
diff --git a/db/migrate/20260429043000_add_sync_stats_index_to_captain_documents.rb b/db/migrate/20260429043000_add_sync_stats_index_to_captain_documents.rb
new file mode 100644
index 000000000..9c2cdb66c
--- /dev/null
+++ b/db/migrate/20260429043000_add_sync_stats_index_to_captain_documents.rb
@@ -0,0 +1,12 @@
+class AddSyncStatsIndexToCaptainDocuments < ActiveRecord::Migration[7.0]
+ def up
+ add_index :captain_documents,
+ [:account_id, :assistant_id, :sync_status, :last_synced_at],
+ name: 'idx_captain_documents_on_account_assistant_sync_stats',
+ if_not_exists: true
+ end
+
+ def down
+ remove_index :captain_documents, name: 'idx_captain_documents_on_account_assistant_sync_stats', if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9f8d76b78..1948330e6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -381,11 +381,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
+ t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats"
+ t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
- t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
end
create_table "captain_inboxes", force: :cascade do |t|
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
index bc1ebaf9e..b9a6bbcc5 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
@@ -77,7 +77,12 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
next unless document.available?
next if document.sync_in_progress?
- document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
+ document.update!(
+ sync_status: :syncing,
+ sync_step: nil,
+ last_sync_error_code: nil,
+ last_sync_attempted_at: Time.current
+ )
Captain::Documents::PerformSyncJob.perform_later(document)
synced_document_ids << document.id
end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
index 973559743..23f410499 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
@@ -11,8 +11,13 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def index
base_query = @documents
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
+ base_query = apply_source_filter(base_query, permitted_params[:source])
+ base_query = apply_filter(base_query, permitted_params[:filter])
+ base_query = apply_search(base_query, permitted_params[:search_key])
+ base_query = apply_sort(base_query, permitted_params[:sort])
@documents_count = base_query.count
+ @sync_interval_hours = current_sync_interval&.in_hours&.to_i
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
end
@@ -34,7 +39,12 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
- @document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
+ @document.update!(
+ sync_status: :syncing,
+ sync_step: nil,
+ last_sync_error_code: nil,
+ last_sync_attempted_at: Time.current
+ )
Captain::Documents::PerformSyncJob.perform_later(@document)
head :accepted
end
@@ -47,7 +57,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
private
def set_documents
- @documents = Current.account.captain_documents.includes(:assistant).ordered
+ @documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
end
def set_document
@@ -63,7 +73,58 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
end
def permitted_params
- params.permit(:assistant_id, :page, :id, :account_id)
+ params.permit(:assistant_id, :page, :id, :account_id, :filter, :source, :sort, :search_key)
+ end
+
+ def apply_source_filter(scope, source)
+ case source
+ when 'web' then scope.syncable
+ when 'pdf' then scope.pdf_documents
+ else scope
+ end
+ end
+
+ def apply_filter(scope, filter)
+ case filter
+ when 'stale' then stale_documents(scope.syncable)
+ when 'synced' then up_to_date_documents(scope.syncable)
+ when 'syncing' then scope.syncable.sync_in_progress
+ when 'failed' then scope.syncable.sync_failed
+ else scope
+ end
+ end
+
+ def apply_search(scope, search_key)
+ return scope if search_key.blank?
+
+ query = "%#{ActiveRecord::Base.sanitize_sql_like(search_key)}%"
+ scope.where('captain_documents.name ILIKE :query OR captain_documents.external_link ILIKE :query', query: query)
+ end
+
+ def apply_sort(scope, sort)
+ case sort
+ when 'recently_created' then scope.order(created_at: :desc)
+ else scope.order(updated_at: :desc)
+ end
+ end
+
+ def stale_documents(scope)
+ return scope.none unless current_sync_interval
+
+ scope.sync_synced.where(Captain::Document.arel_table[:last_synced_at].lt(current_sync_interval.ago))
+ end
+
+ def up_to_date_documents(scope)
+ documents = scope.sync_synced
+ return documents unless current_sync_interval
+
+ documents.where(Captain::Document.arel_table[:last_synced_at].gteq(current_sync_interval.ago))
+ end
+
+ def current_sync_interval
+ return @current_sync_interval if defined?(@current_sync_interval)
+
+ @current_sync_interval = Current.account.captain_document_sync_interval
end
def document_params
diff --git a/enterprise/app/jobs/captain/documents/perform_sync_job.rb b/enterprise/app/jobs/captain/documents/perform_sync_job.rb
index 100d72eeb..eaef7d64d 100644
--- a/enterprise/app/jobs/captain/documents/perform_sync_job.rb
+++ b/enterprise/app/jobs/captain/documents/perform_sync_job.rb
@@ -20,13 +20,13 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
exception_class: error.class.name)
end
- # Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
+ # Permanent errors (404, 403, empty content) - no point retrying, discard immediately.
# Document is already marked failed by SyncService before the exception reaches here.
discard_on(Captain::Documents::SyncService::PermanentSyncError)
- # TransientSyncError is raised by SyncService when the customer's site is unreachable —
+ # TransientSyncError is raised by SyncService when the customer's site is unreachable -
# timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
- # a chance to recover before we give up.
+ # a chance to recover before we mark the document failed.
#
# The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
# site flakiness isn't an application bug.
@@ -36,6 +36,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
attempts: 4
) do |job, error|
document = job.arguments.first
+ job.send(:mark_sync_failed, document, error.message)
job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
end
@@ -47,7 +48,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do
- document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
+ mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
@@ -78,13 +79,26 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
raise error
end
- def handle_unexpected_failure(document, error, start_time)
+ def mark_sync_failed(document, error_code)
document.update!(
sync_status: :failed,
sync_step: nil,
- last_sync_error_code: 'sync_error',
+ last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
+ end
+
+ def mark_sync_started(document)
+ document.update!(
+ sync_status: :syncing,
+ sync_step: nil,
+ last_sync_error_code: nil,
+ last_sync_attempted_at: Time.current
+ )
+ end
+
+ def handle_unexpected_failure(document, error, start_time)
+ mark_sync_failed(document, 'sync_error')
log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
exception_class: error.class.name,
duration_ms: duration_ms_since(start_time))
diff --git a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
index 4103fd271..393a307db 100644
--- a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
+++ b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
@@ -82,7 +82,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
end
def reserve_sync_slot(document)
- document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
+ mark_sync_started(document)
true
rescue ActiveRecord::RecordInvalid => e
log_document_skip(document, e)
@@ -112,4 +112,13 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
+
+ def mark_sync_started(document)
+ document.update!(
+ sync_status: :syncing,
+ sync_step: nil,
+ last_sync_error_code: nil,
+ last_sync_attempted_at: Time.current
+ )
+ end
end
diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb
index 7509286a1..0fe14813b 100644
--- a/enterprise/app/models/captain/document.rb
+++ b/enterprise/app/models/captain/document.rb
@@ -4,8 +4,10 @@
#
# id :bigint not null, primary key
# content :text
+# content_fingerprint :string
# external_link :string not null
# last_sync_attempted_at :datetime
+# last_sync_error_code :string
# last_synced_at :datetime
# metadata :jsonb
# name :string
@@ -18,6 +20,7 @@
#
# Indexes
#
+# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
# index_captain_documents_on_assistant_id (assistant_id)
@@ -62,6 +65,14 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
+ scope :pdf_documents, -> { where("external_link LIKE 'PDF:%' OR external_link LIKE '%.pdf'") }
+ scope :sync_in_progress, -> { sync_syncing.where(arel_table[:last_sync_attempted_at].gteq(SYNC_STALE_TIMEOUT.ago)) }
+ scope :stale, lambda { |stale_before|
+ sync_failed.or(sync_synced.where(arel_table[:last_synced_at].lt(stale_before)))
+ }
+ scope :synced_since, lambda { |time|
+ sync_synced.where(arel_table[:last_synced_at].gteq(time))
+ }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb
index a3cc19b16..bbde3ffb0 100644
--- a/enterprise/app/policies/captain/assistant_policy.rb
+++ b/enterprise/app/policies/captain/assistant_policy.rb
@@ -7,6 +7,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
+ def stats?
+ true
+ end
+
def tools?
@account_user.administrator?
end
diff --git a/enterprise/app/services/captain/documents/sync_service.rb b/enterprise/app/services/captain/documents/sync_service.rb
index f1a67c781..eca28dbab 100644
--- a/enterprise/app/services/captain/documents/sync_service.rb
+++ b/enterprise/app/services/captain/documents/sync_service.rb
@@ -15,10 +15,7 @@ class Captain::Documents::SyncService
@document.update!(sync_step: 'fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
- unless result.success
- mark_failed(result.error_code)
- raise_for_error_code(result.error_code)
- end
+ handle_fetch_error(result.error_code) unless result.success
@document.update!(sync_step: 'comparing')
new_fingerprint = compute_fingerprint(result.content)
@@ -75,8 +72,11 @@ class Captain::Documents::SyncService
)
end
- def raise_for_error_code(error_code)
- raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
+ def handle_fetch_error(error_code)
+ if PERMANENT_ERROR_CODES.include?(error_code)
+ mark_failed(error_code)
+ raise PermanentSyncError, error_code
+ end
raise TransientSyncError, error_code
end
diff --git a/enterprise/app/views/api/v1/accounts/captain/documents/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/documents/index.json.jbuilder
index 5b8c726ea..e6fb5fd0b 100644
--- a/enterprise/app/views/api/v1/accounts/captain/documents/index.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/documents/index.json.jbuilder
@@ -7,4 +7,5 @@ end
json.meta do
json.total_count @documents_count
json.page @current_page
+ json.sync_interval_hours @sync_interval_hours if @sync_interval_hours.present?
end
diff --git a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
index 62710bc64..56260f675 100644
--- a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
@@ -8,10 +8,12 @@ json.created_at resource.created_at.to_i
json.external_link resource.external_link
json.display_url resource.display_url
json.file_size resource.file_size
+json.pdf_document resource.pdf_document?
json.id resource.id
json.name resource.name
json.status resource.status
json.sync_status resource.sync_status
+json.sync_in_progress resource.sync_in_progress?
json.last_synced_at resource.last_synced_at&.to_i
json.last_sync_attempted_at resource.last_sync_attempted_at&.to_i
json.last_sync_error_code resource.last_sync_error_code