d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage, toError } from '@sim/utils/errors'
|
|
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
|
|
import { incidentioConnectorMeta } from '@/connectors/incidentio/meta'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { htmlToPlainText, parseTagDate } from '@/connectors/utils'
|
|
|
|
const logger = createLogger('IncidentioConnector')
|
|
|
|
const INCIDENTIO_API_BASE = 'https://api.incident.io'
|
|
const PAGE_SIZE = 100
|
|
/** Cap incident updates fetched per document so a noisy incident can't blow the time budget. */
|
|
const MAX_UPDATES_PER_INCIDENT = 100
|
|
|
|
interface IncidentioNamedRef {
|
|
id?: string
|
|
name?: string
|
|
/** Present on incident_status: the stable status category enum (triage/live/closed/...). */
|
|
category?: string
|
|
rank?: number
|
|
}
|
|
|
|
interface IncidentioCustomFieldValue {
|
|
value_text?: string
|
|
value_link?: string
|
|
value_numeric?: string
|
|
value_option?: { value?: string }
|
|
value_catalog_entry?: { name?: string }
|
|
}
|
|
|
|
interface IncidentioCustomFieldEntry {
|
|
custom_field?: { id?: string; name?: string; field_type?: string }
|
|
values?: IncidentioCustomFieldValue[]
|
|
}
|
|
|
|
interface IncidentioRoleAssignment {
|
|
role?: { id?: string; name?: string; role_type?: string }
|
|
assignee?: { id?: string; name?: string; email?: string }
|
|
}
|
|
|
|
interface IncidentioTimestampValue {
|
|
incident_timestamp?: { id?: string; name?: string; rank?: number }
|
|
/**
|
|
* The v2 API nests the timestamp string under `value.value` (an object), not a
|
|
* flat string. A flat string is tolerated defensively for older shapes.
|
|
*/
|
|
value?: { value?: string } | string
|
|
}
|
|
|
|
interface IncidentioIncident {
|
|
id: string
|
|
reference?: string
|
|
name?: string
|
|
summary?: string
|
|
mode?: string
|
|
visibility?: string
|
|
permalink?: string
|
|
call_url?: string
|
|
created_at?: string
|
|
updated_at?: string
|
|
slack_channel_id?: string
|
|
slack_channel_name?: string
|
|
severity?: IncidentioNamedRef
|
|
incident_status?: IncidentioNamedRef
|
|
incident_type?: IncidentioNamedRef
|
|
custom_field_entries?: IncidentioCustomFieldEntry[]
|
|
incident_role_assignments?: IncidentioRoleAssignment[]
|
|
incident_timestamp_values?: IncidentioTimestampValue[]
|
|
}
|
|
|
|
interface IncidentioPaginationMeta {
|
|
after?: string
|
|
page_size?: number
|
|
total_record_count?: number
|
|
}
|
|
|
|
interface IncidentioIncidentsListResponse {
|
|
incidents?: IncidentioIncident[]
|
|
pagination_meta?: IncidentioPaginationMeta
|
|
}
|
|
|
|
interface IncidentioIncidentShowResponse {
|
|
incident?: IncidentioIncident
|
|
}
|
|
|
|
/**
|
|
* incident.io ActorV2: the entity behind an action, used for incident update
|
|
* authors. The v2 API documents this as an ActorV2 with exactly one of the
|
|
* nested `user`/`api_key`/`workflow`/`alert` variants populated. A flat
|
|
* `{ name, email }` shape is also tolerated defensively; we read whichever is
|
|
* present so the author resolves regardless of which form the API returns.
|
|
*/
|
|
interface IncidentioActor {
|
|
user?: { id?: string; name?: string; email?: string }
|
|
api_key?: { id?: string; name?: string }
|
|
alert?: { id?: string; title?: string }
|
|
workflow?: { id?: string; name?: string }
|
|
name?: string
|
|
email?: string
|
|
}
|
|
|
|
interface IncidentioUpdate {
|
|
id?: string
|
|
message?: string
|
|
new_severity?: IncidentioNamedRef
|
|
/**
|
|
* The status change on an update. The v2 API documents `new_incident_status`,
|
|
* but a flat `new_status` shape also appears in the wild; both are read.
|
|
*/
|
|
new_incident_status?: IncidentioNamedRef
|
|
new_status?: IncidentioNamedRef
|
|
updater?: IncidentioActor
|
|
/** Some responses use `author` instead of `updater`; both are read. */
|
|
author?: IncidentioActor
|
|
created_at?: string
|
|
}
|
|
|
|
/**
|
|
* Resolves a human-readable display name for an update author, covering the
|
|
* ActorV2 variants and the flat `{ name, email }` shape.
|
|
*/
|
|
function actorName(actor: IncidentioActor | undefined): string | undefined {
|
|
if (!actor) return undefined
|
|
return (
|
|
actor.user?.name ||
|
|
actor.user?.email ||
|
|
actor.api_key?.name ||
|
|
actor.workflow?.name ||
|
|
actor.alert?.title ||
|
|
actor.name ||
|
|
actor.email ||
|
|
undefined
|
|
)
|
|
}
|
|
|
|
interface IncidentioUpdatesListResponse {
|
|
incident_updates?: IncidentioUpdate[]
|
|
pagination_meta?: IncidentioPaginationMeta
|
|
}
|
|
|
|
/**
|
|
* Builds the metadata-based content hash for an incident.
|
|
*
|
|
* Uses only the incident `updated_at` timestamp, which incident.io bumps whenever any
|
|
* field, role assignment, custom field, or status changes. This guarantees the hash is
|
|
* identical whether produced from the list stub or the fully-hydrated getDocument result,
|
|
* so the sync engine can detect changes without downloading content.
|
|
*/
|
|
function buildContentHash(incident: IncidentioIncident): string {
|
|
return `incidentio:${incident.id}:${incident.updated_at ?? ''}`
|
|
}
|
|
|
|
/**
|
|
* Builds the public URL for an incident, preferring the API-provided permalink.
|
|
*/
|
|
function buildSourceUrl(incident: IncidentioIncident): string | undefined {
|
|
return incident.permalink || undefined
|
|
}
|
|
|
|
/**
|
|
* Derives the document title from the incident reference and name.
|
|
*/
|
|
function buildTitle(incident: IncidentioIncident): string {
|
|
const name = incident.name?.trim()
|
|
const reference = incident.reference?.trim()
|
|
if (reference && name) return `${reference}: ${name}`
|
|
return name || reference || 'Untitled Incident'
|
|
}
|
|
|
|
/**
|
|
* Collects the source-specific metadata fed to mapTags. Shared between the list stub
|
|
* and getDocument so tag values stay consistent regardless of which path produced the doc.
|
|
*/
|
|
function buildMetadata(incident: IncidentioIncident): Record<string, unknown> {
|
|
const lead = incident.incident_role_assignments?.find(
|
|
(assignment) => assignment.role?.role_type === 'lead'
|
|
)
|
|
const reporter = incident.incident_role_assignments?.find(
|
|
(assignment) => assignment.role?.role_type === 'reporter'
|
|
)
|
|
const reportedBy = (reporter ?? lead)?.assignee?.name
|
|
|
|
return {
|
|
reference: incident.reference,
|
|
status: incident.incident_status?.name,
|
|
statusCategory: incident.incident_status?.category,
|
|
severity: incident.severity?.name,
|
|
incidentType: incident.incident_type?.name,
|
|
mode: incident.mode,
|
|
visibility: incident.visibility,
|
|
incidentDate: incident.created_at,
|
|
reportedBy,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders a single custom field value to a human-readable string, covering each of
|
|
* incident.io's value variants (text, link, numeric, select option, catalog entry).
|
|
*/
|
|
function renderCustomFieldValue(value: IncidentioCustomFieldValue): string | undefined {
|
|
if (value.value_text) return htmlToPlainText(value.value_text)
|
|
if (value.value_link) return value.value_link
|
|
if (value.value_numeric) return value.value_numeric
|
|
if (value.value_option?.value) return value.value_option.value
|
|
if (value.value_catalog_entry?.name) return value.value_catalog_entry.name
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Formats the incident, its custom fields, role assignments, timeline, and status
|
|
* updates into a single plain-text document. HTML in summary / custom field text is
|
|
* stripped via htmlToPlainText.
|
|
*/
|
|
function formatIncidentContent(incident: IncidentioIncident, updates: IncidentioUpdate[]): string {
|
|
const parts: string[] = []
|
|
|
|
parts.push(`Incident: ${buildTitle(incident)}`)
|
|
if (incident.incident_status?.name) parts.push(`Status: ${incident.incident_status.name}`)
|
|
if (incident.severity?.name) parts.push(`Severity: ${incident.severity.name}`)
|
|
if (incident.incident_type?.name) parts.push(`Type: ${incident.incident_type.name}`)
|
|
if (incident.created_at) parts.push(`Reported: ${incident.created_at}`)
|
|
|
|
if (incident.summary?.trim()) {
|
|
parts.push('')
|
|
parts.push('--- Summary ---')
|
|
parts.push(htmlToPlainText(incident.summary))
|
|
}
|
|
|
|
const roleLines = (incident.incident_role_assignments ?? [])
|
|
.map((assignment) => {
|
|
const role = assignment.role?.name
|
|
const assignee = assignment.assignee?.name || assignment.assignee?.email
|
|
if (!role || !assignee) return undefined
|
|
return `${role}: ${assignee}`
|
|
})
|
|
.filter((line): line is string => Boolean(line))
|
|
if (roleLines.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Roles ---')
|
|
parts.push(...roleLines)
|
|
}
|
|
|
|
const customFieldLines = (incident.custom_field_entries ?? [])
|
|
.map((entry) => {
|
|
const name = entry.custom_field?.name
|
|
if (!name) return undefined
|
|
const rendered = (entry.values ?? [])
|
|
.map(renderCustomFieldValue)
|
|
.filter((v): v is string => Boolean(v))
|
|
if (rendered.length === 0) return undefined
|
|
return `${name}: ${rendered.join(', ')}`
|
|
})
|
|
.filter((line): line is string => Boolean(line))
|
|
if (customFieldLines.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Custom Fields ---')
|
|
parts.push(...customFieldLines)
|
|
}
|
|
|
|
const timestampLines = (incident.incident_timestamp_values ?? [])
|
|
.map((entry) => {
|
|
const name = entry.incident_timestamp?.name
|
|
const value = typeof entry.value === 'string' ? entry.value : entry.value?.value
|
|
if (!name || !value) return undefined
|
|
return `${name}: ${value}`
|
|
})
|
|
.filter((line): line is string => Boolean(line))
|
|
if (timestampLines.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Timeline ---')
|
|
parts.push(...timestampLines)
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Updates ---')
|
|
for (const update of updates) {
|
|
const segments: string[] = []
|
|
if (update.created_at) segments.push(`[${update.created_at}]`)
|
|
const author = actorName(update.updater ?? update.author)
|
|
if (author) segments.push(author)
|
|
const changes: string[] = []
|
|
const newStatusName = update.new_incident_status?.name ?? update.new_status?.name
|
|
if (newStatusName) {
|
|
changes.push(`status → ${newStatusName}`)
|
|
}
|
|
if (update.new_severity?.name) changes.push(`severity → ${update.new_severity.name}`)
|
|
const message = update.message ? htmlToPlainText(update.message) : ''
|
|
const tail = [changes.join(', '), message].filter(Boolean).join(' — ')
|
|
const line = [segments.join(' '), tail].filter(Boolean).join(': ')
|
|
if (line.trim()) parts.push(line)
|
|
}
|
|
}
|
|
|
|
return parts.join('\n').trim()
|
|
}
|
|
|
|
/**
|
|
* Creates a lightweight document stub from a list entry. No API calls — content is
|
|
* deferred to getDocument and only fetched for new or changed incidents.
|
|
*/
|
|
function incidentToStub(incident: IncidentioIncident): ExternalDocument {
|
|
return {
|
|
externalId: incident.id,
|
|
title: buildTitle(incident),
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: buildSourceUrl(incident),
|
|
contentHash: buildContentHash(incident),
|
|
metadata: buildMetadata(incident),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches all status updates for an incident, following the `after` cursor and capping
|
|
* the total to keep getDocument bounded for very long-running incidents.
|
|
*/
|
|
async function fetchIncidentUpdates(
|
|
accessToken: string,
|
|
incidentId: string
|
|
): Promise<IncidentioUpdate[]> {
|
|
const updates: IncidentioUpdate[] = []
|
|
let after: string | undefined
|
|
|
|
while (updates.length < MAX_UPDATES_PER_INCIDENT) {
|
|
const url = new URL(`${INCIDENTIO_API_BASE}/v2/incident_updates`)
|
|
url.searchParams.set('incident_id', incidentId)
|
|
url.searchParams.set('page_size', String(PAGE_SIZE))
|
|
if (after) url.searchParams.set('after', after)
|
|
|
|
const response = await fetchWithRetry(url.toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
logger.warn('Failed to fetch incident updates', { incidentId, status: response.status })
|
|
break
|
|
}
|
|
|
|
const data = (await response.json()) as IncidentioUpdatesListResponse
|
|
const page = data.incident_updates ?? []
|
|
updates.push(...page)
|
|
|
|
after = data.pagination_meta?.after?.trim() || undefined
|
|
if (!after || page.length === 0) break
|
|
}
|
|
|
|
return updates.slice(0, MAX_UPDATES_PER_INCIDENT)
|
|
}
|
|
|
|
export const incidentioConnector: ConnectorConfig = {
|
|
...incidentioConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>,
|
|
lastSyncAt?: Date
|
|
): Promise<ExternalDocumentList> => {
|
|
const maxIncidents = sourceConfig.maxIncidents ? Number(sourceConfig.maxIncidents) : 0
|
|
|
|
const statusCategory =
|
|
typeof sourceConfig.statusCategory === 'string' ? sourceConfig.statusCategory.trim() : ''
|
|
const mode = typeof sourceConfig.mode === 'string' ? sourceConfig.mode.trim() : ''
|
|
|
|
const url = new URL(`${INCIDENTIO_API_BASE}/v2/incidents`)
|
|
url.searchParams.set('page_size', String(PAGE_SIZE))
|
|
url.searchParams.set('sort_by', 'created_at_oldest_first')
|
|
if (cursor) url.searchParams.set('after', cursor)
|
|
if (lastSyncAt) url.searchParams.set('updated_at[gte]', lastSyncAt.toISOString())
|
|
if (statusCategory) url.searchParams.set('status_category[one_of]', statusCategory)
|
|
if (mode) url.searchParams.set('mode[one_of]', mode)
|
|
|
|
logger.info('Listing incident.io incidents', {
|
|
cursor: cursor ?? 'initial',
|
|
incremental: Boolean(lastSyncAt),
|
|
maxIncidents,
|
|
})
|
|
|
|
const response = await fetchWithRetry(url.toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
logger.error('Failed to list incident.io incidents', {
|
|
status: response.status,
|
|
error: errorText.slice(0, 500),
|
|
})
|
|
throw new Error(`Failed to list incident.io incidents: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as IncidentioIncidentsListResponse
|
|
const incidents = (data.incidents ?? []).filter((incident) => Boolean(incident.id))
|
|
|
|
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
|
|
let documents = incidents.map(incidentToStub)
|
|
if (maxIncidents > 0) {
|
|
const remaining = Math.max(0, maxIncidents - prevFetched)
|
|
if (documents.length > remaining) {
|
|
documents = documents.slice(0, remaining)
|
|
}
|
|
}
|
|
|
|
const totalFetched = prevFetched + documents.length
|
|
if (syncContext) syncContext.totalDocsFetched = totalFetched
|
|
const hitLimit = maxIncidents > 0 && totalFetched >= maxIncidents
|
|
if (hitLimit && syncContext) syncContext.listingCapped = true
|
|
|
|
const after = data.pagination_meta?.after?.trim() || undefined
|
|
const hasMore = !hitLimit && Boolean(after)
|
|
|
|
return {
|
|
documents,
|
|
nextCursor: hasMore ? after : undefined,
|
|
hasMore,
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
_sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
if (!externalId) return null
|
|
|
|
const url = `${INCIDENTIO_API_BASE}/v2/incidents/${encodeURIComponent(externalId)}`
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404 || response.status === 410) return null
|
|
throw new Error(`Failed to fetch incident.io incident: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as IncidentioIncidentShowResponse
|
|
const incident = data.incident
|
|
if (!incident?.id) return null
|
|
|
|
const updates = await fetchIncidentUpdates(accessToken, incident.id)
|
|
const content = formatIncidentContent(incident, updates)
|
|
if (!content.trim()) return null
|
|
|
|
return {
|
|
externalId: incident.id,
|
|
title: buildTitle(incident),
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: buildSourceUrl(incident),
|
|
contentHash: buildContentHash(incident),
|
|
metadata: buildMetadata(incident),
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get incident.io incident', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const maxIncidents = sourceConfig.maxIncidents as string | undefined
|
|
if (maxIncidents && (Number.isNaN(Number(maxIncidents)) || Number(maxIncidents) < 0)) {
|
|
return { valid: false, error: 'Max incidents must be a non-negative number' }
|
|
}
|
|
|
|
try {
|
|
const response = await fetchWithRetry(
|
|
`${INCIDENTIO_API_BASE}/v2/incidents?page_size=1`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
return {
|
|
valid: false,
|
|
error: `incident.io access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`,
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, 'Failed to validate configuration')
|
|
return { valid: false, error: message }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.status === 'string' && metadata.status.trim()) {
|
|
result.status = metadata.status
|
|
}
|
|
|
|
if (typeof metadata.statusCategory === 'string' && metadata.statusCategory.trim()) {
|
|
result.statusCategory = metadata.statusCategory
|
|
}
|
|
|
|
if (typeof metadata.severity === 'string' && metadata.severity.trim()) {
|
|
result.severity = metadata.severity
|
|
}
|
|
|
|
if (typeof metadata.incidentType === 'string' && metadata.incidentType.trim()) {
|
|
result.incidentType = metadata.incidentType
|
|
}
|
|
|
|
if (typeof metadata.mode === 'string' && metadata.mode.trim()) {
|
|
result.mode = metadata.mode
|
|
}
|
|
|
|
if (typeof metadata.visibility === 'string' && metadata.visibility.trim()) {
|
|
result.visibility = metadata.visibility
|
|
}
|
|
|
|
const incidentDate = parseTagDate(metadata.incidentDate)
|
|
if (incidentDate) result.incidentDate = incidentDate
|
|
|
|
if (typeof metadata.reportedBy === 'string' && metadata.reportedBy.trim()) {
|
|
result.reportedBy = metadata.reportedBy
|
|
}
|
|
|
|
return result
|
|
},
|
|
}
|