mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
309 lines
9.9 KiB
TypeScript
309 lines
9.9 KiB
TypeScript
import 'server-only'
|
|
|
|
import type { Payload } from 'payload'
|
|
|
|
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
|
|
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
|
|
import type { CampaignUser, Speech } from '@/payload-types'
|
|
import {
|
|
expandSpeechSearchTheme,
|
|
type SpeechThemeExpansionResolver,
|
|
} from '@/utilities/ai/expandSpeechSearchTheme'
|
|
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
|
|
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
|
|
import { getYouTubeVideoStart } from '@/utilities/socialFeed/youtubeFeed'
|
|
import {
|
|
loadSpeechCutOriginSpeechIds,
|
|
loadSpeechCutsForSpeeches,
|
|
} from '@/utilities/speech/speechCutPageData'
|
|
import { buildSpeechListWhereIncludingCutOrigins } from '@/utilities/speech/speechListFilters'
|
|
import {
|
|
resolveSpeechListUrl,
|
|
speechPageSize,
|
|
type SpeechFilterOptions,
|
|
type SpeechListState,
|
|
} from '@/utilities/speech/speechListUrl'
|
|
import {
|
|
municipalityIdsOfSpeech,
|
|
toSpeechDetailViewModel,
|
|
toSpeechListItemViewModel,
|
|
type SpeechDetailRecord,
|
|
type SpeechDetailViewModel,
|
|
type SpeechListItemViewModel,
|
|
type SpeechSegmentRecord,
|
|
} from '@/utilities/speech/speechViewModels'
|
|
|
|
type SpeechListSearchParams = Record<string, string | string[] | undefined>
|
|
|
|
export const SpeechNotFoundError = createEntityNotFoundError('Speech', 'Fala não encontrada.')
|
|
|
|
const speechListSelect = {
|
|
speechAt: true,
|
|
type: true,
|
|
durationSeconds: true,
|
|
summary: true,
|
|
officialTranscript: true,
|
|
keywords: true,
|
|
topics: true,
|
|
scopes: true,
|
|
mentionedMunicipalities: true,
|
|
presidingOfficer: true,
|
|
officialTextUrl: true,
|
|
youtubeUrl: true,
|
|
// C182 — the list decides the frame from the Câmara coordinates alone; the
|
|
// stored VOD links stay out of the list query (the poster job reads them).
|
|
eventId: true,
|
|
audioId: true,
|
|
excerptTMs: true,
|
|
} as const
|
|
|
|
const speechDetailSelect = {
|
|
...speechListSelect,
|
|
phase: true,
|
|
vodPlaybackUrl: true,
|
|
vodDownloadUrl: true,
|
|
eventStartAt: true,
|
|
} as const
|
|
|
|
const segmentSelect = {
|
|
speech: true,
|
|
startSeconds: true,
|
|
endSeconds: true,
|
|
text: true,
|
|
} as const
|
|
|
|
const municipalityNameMap = (
|
|
labels: Awaited<ReturnType<typeof loadMunicipalityLabelsByIds>>,
|
|
): Map<number, string> => new Map([...labels.entries()].map(([id, entry]) => [id, entry.name]))
|
|
|
|
/**
|
|
* Options the URL filters offer: the years and phases actually present in the
|
|
* catalog and the municipalities cited by at least one speech (labels resolved
|
|
* through the justified `loadMunicipalityLabelsByIds` bypass — the ids come
|
|
* from speeches the actor was already authorized to read).
|
|
*/
|
|
const loadSpeechFilterOptions = async (
|
|
payload: Payload,
|
|
user: CampaignUser,
|
|
): Promise<SpeechFilterOptions> => {
|
|
const result = await payload.find({
|
|
collection: 'speech',
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
select: { year: true, phase: true, mentionedMunicipalities: true },
|
|
user,
|
|
overrideAccess: false,
|
|
})
|
|
|
|
const years = new Set<number>()
|
|
const phases = new Set<string>()
|
|
const municipalityIds = new Set<number>()
|
|
for (const speech of result.docs) {
|
|
if (typeof speech.year === 'number') years.add(speech.year)
|
|
if (speech.phase) phases.add(speech.phase)
|
|
for (const id of municipalityIdsOfSpeech(speech)) municipalityIds.add(id)
|
|
}
|
|
|
|
const labels = await loadMunicipalityLabelsByIds(payload, [...municipalityIds])
|
|
const municipalities = [...municipalityIds]
|
|
.flatMap((id) => {
|
|
const entry = labels.get(id)
|
|
return entry ? [{ value: String(id), label: entry.name }] : []
|
|
})
|
|
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
|
|
|
return {
|
|
years: [...years].sort((left, right) => right - left),
|
|
phases: [...phases].sort((left, right) => left.localeCompare(right, 'pt-BR')),
|
|
municipalities,
|
|
}
|
|
}
|
|
|
|
export const loadSegmentsForSpeeches = async (
|
|
payload: Payload,
|
|
user: CampaignUser,
|
|
speechIds: readonly number[],
|
|
): Promise<Map<number, SpeechSegmentRecord[]>> => {
|
|
const bySpeech = new Map<number, SpeechSegmentRecord[]>()
|
|
if (speechIds.length === 0) return bySpeech
|
|
|
|
const result = await payload.find({
|
|
collection: 'speechSegment',
|
|
where: { speech: { in: [...speechIds] } },
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
sort: 'order',
|
|
select: segmentSelect,
|
|
user,
|
|
overrideAccess: false,
|
|
})
|
|
|
|
for (const segment of result.docs) {
|
|
const speechId = typeof segment.speech === 'number' ? segment.speech : segment.speech.id
|
|
const list = bySpeech.get(speechId) ?? []
|
|
list.push({
|
|
startSeconds: segment.startSeconds,
|
|
endSeconds: segment.endSeconds,
|
|
text: segment.text,
|
|
})
|
|
bySpeech.set(speechId, list)
|
|
}
|
|
return bySpeech
|
|
}
|
|
|
|
export type SpeechAcervoPageData = {
|
|
rows: SpeechListItemViewModel[]
|
|
state: SpeechListState
|
|
redirectHref?: string
|
|
totalDocs: number
|
|
totalPages: number
|
|
filterOptions: SpeechFilterOptions
|
|
/**
|
|
* C192 — true when the actor asked for `mode=tema` but the expansion
|
|
* mechanism was unavailable; the page shows the discreet fallback notice.
|
|
*/
|
|
themeUnavailable: boolean
|
|
/** C192 — true when the expansion actually contributed terms. */
|
|
themeApplied: boolean
|
|
}
|
|
|
|
export const loadSpeechAcervoPageData = async (
|
|
payload: Payload,
|
|
user: CampaignUser,
|
|
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
|
|
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
|
|
): Promise<SpeechAcervoPageData> => {
|
|
const rawSearchParams = await searchParams
|
|
const canonicalUrl = resolveSpeechListUrl(rawSearchParams)
|
|
const state = canonicalUrl.state
|
|
|
|
// C192 — only expand for an actor who may read the catalog (the `find` below
|
|
// is still the final, fail-closed barrier) and only with a query.
|
|
const themeRequested = state.mode === 'tema' && Boolean(state.q)
|
|
let themeTerms: readonly string[] = []
|
|
let themeUnavailable = false
|
|
if (themeRequested && canReadCommunicationCatalog(user.role)) {
|
|
const expansion = await expandTheme(state.q ?? '')
|
|
if (expansion) themeTerms = expansion.terms
|
|
else themeUnavailable = true
|
|
}
|
|
|
|
// C174 (option B) — a speech also shows up when one of its cuts matches the
|
|
// term by title/description, even if the speech text itself does not.
|
|
const originSpeechIds = state.q ? await loadSpeechCutOriginSpeechIds(payload, user, state.q) : []
|
|
|
|
const result = await payload.find({
|
|
collection: 'speech',
|
|
depth: 0,
|
|
limit: speechPageSize,
|
|
page: state.page,
|
|
sort: '-speechAt',
|
|
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds, themeTerms),
|
|
// C174 — the row only needs its normalized text to tell whether it matched
|
|
// the term itself (option B); the unsearched list does not pay for it.
|
|
select: state.q ? { ...speechListSelect, searchText: true } : speechListSelect,
|
|
user,
|
|
overrideAccess: false,
|
|
})
|
|
|
|
const resolvedUrl = resolveSpeechListUrl(rawSearchParams, result.totalPages)
|
|
const speeches = result.docs as Speech[]
|
|
const speechIds = speeches.map((speech) => speech.id)
|
|
|
|
const [segmentsBySpeech, cutsBySpeech, filterOptions] = await Promise.all([
|
|
loadSegmentsForSpeeches(payload, user, speechIds),
|
|
loadSpeechCutsForSpeeches(payload, user, speechIds),
|
|
loadSpeechFilterOptions(payload, user),
|
|
])
|
|
|
|
const municipalityIds = new Set<number>()
|
|
for (const speech of speeches) {
|
|
for (const id of municipalityIdsOfSpeech(speech)) municipalityIds.add(id)
|
|
}
|
|
const municipalityLabels = municipalityNameMap(
|
|
await loadMunicipalityLabelsByIds(payload, [...municipalityIds]),
|
|
)
|
|
|
|
return {
|
|
rows: speeches.map((speech) =>
|
|
toSpeechListItemViewModel({
|
|
speech,
|
|
segments: segmentsBySpeech.get(speech.id) ?? [],
|
|
query: state.q,
|
|
municipalityLabels,
|
|
cuts: cutsBySpeech.get(speech.id) ?? [],
|
|
themeTerms,
|
|
}),
|
|
),
|
|
state: resolvedUrl.state,
|
|
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
|
|
totalDocs: result.totalDocs,
|
|
totalPages: result.totalPages,
|
|
filterOptions,
|
|
themeUnavailable,
|
|
themeApplied: themeTerms.length > 0,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the broadcast start (ISO) of a session video. The default reads the
|
|
* cached YouTube anchor (C172); tests inject their own to stay offline.
|
|
*/
|
|
export type SpeechVideoStartResolver = (videoId: string) => Promise<string | null>
|
|
|
|
export const loadSpeechDetailPageData = async (
|
|
payload: Payload,
|
|
user: CampaignUser,
|
|
speechId: number,
|
|
query?: string,
|
|
resolveVideoStart: SpeechVideoStartResolver = getYouTubeVideoStart,
|
|
): Promise<SpeechDetailViewModel> => {
|
|
const result = await payload.find({
|
|
collection: 'speech',
|
|
where: { id: { equals: speechId } },
|
|
depth: 0,
|
|
limit: 1,
|
|
pagination: false,
|
|
select: speechDetailSelect,
|
|
user,
|
|
overrideAccess: false,
|
|
})
|
|
const speech = result.docs[0]
|
|
if (!speech) throw new SpeechNotFoundError()
|
|
|
|
// C172 — the anchor belongs to the session video and is only worth asking
|
|
// for when the row links one AND no measurement already covers it; unknown
|
|
// stays null (no correction).
|
|
const videoId = parseYoutubeVideoId(speech.youtubeUrl)
|
|
const skipAnchor = videoId === null || measuredVideoLagSeconds(videoId) !== null
|
|
|
|
const [segments, municipalityLabels, youtubeVideoStartAt] = await Promise.all([
|
|
payload.find({
|
|
collection: 'speechSegment',
|
|
where: { speech: { equals: speechId } },
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
sort: 'order',
|
|
select: segmentSelect,
|
|
user,
|
|
overrideAccess: false,
|
|
}),
|
|
loadMunicipalityLabelsByIds(payload, municipalityIdsOfSpeech(speech)).then(municipalityNameMap),
|
|
skipAnchor ? null : resolveVideoStart(videoId),
|
|
])
|
|
|
|
return toSpeechDetailViewModel({
|
|
speech: speech as SpeechDetailRecord,
|
|
segments: segments.docs.map((segment) => ({
|
|
startSeconds: segment.startSeconds,
|
|
endSeconds: segment.endSeconds,
|
|
text: segment.text,
|
|
})),
|
|
query,
|
|
municipalityLabels,
|
|
youtubeVideoStartAt,
|
|
})
|
|
}
|