teqo-backup/src/utilities/activityViewModels.ts
Francisco Solla 6d676609cd feat(C151): marca atividade como evento público
Adiciona o checkbox publicEvent ("Evento público", default false, indexado)
à collection activity para registrar a intenção de expor o compromisso na
futura agenda pública do site — sem consumo agora e sem mudar agenda, lista,
detalhe ou o espelho do Google.

- Activity.ts: campo checkbox + snapshot do guard de liderança
- zod/formData/view model do form + ActivityOverlay (ao lado de "Todo o dia")
- migration add_activity_public_event + types regenerados
- unit (overlay), int (parse/persistência/reabertura) e e2e (roundtrip)
2026-09-12 20:28:59 -03:00

466 lines
13 KiB
TypeScript

import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
import {
activityResponsibleTypeLabels,
parseActivityResponsibleEntries,
type ActivityResponsibleCollection,
type ActivityResponsibleEntry,
} from '@/lib/schemas/activity'
import type {
Activity,
CampaignUser,
Contact,
Leadership,
Municipality,
Organization,
StateDeputy,
} from '@/payload-types'
import { canCampaignUserRescheduleActivity } from '@/utilities/access/activities'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
export type ActivityMunicipalitySummary = {
id: number
name: string
slug: string
}
export const activityMunicipalitySummary = (
municipality: Activity['municipality'] | null | undefined,
): ActivityMunicipalitySummary | null =>
isPopulatedRelationship<Municipality>(municipality)
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
: null
// ---------------------------------------------------------------------------
// C90 — polymorphic `responsible` display (typed chips)
// ---------------------------------------------------------------------------
export type ActivityResponsibleDisplay = {
relationTo: ActivityResponsibleCollection
id: number
name: string
typeLabel: string
}
const responsibleEntryName = (
relationTo: ActivityResponsibleEntry['relationTo'],
value: unknown,
): string | null => {
switch (relationTo) {
case 'campaignUser':
return isPopulatedRelationship<CampaignUser>(value) ? value.name : null
case 'leadership':
return isPopulatedRelationship<Leadership>(value)
? populatedContactName((value as Leadership).contact)
: null
case 'stateDeputy':
return isPopulatedRelationship<StateDeputy>(value)
? populatedContactName((value as StateDeputy).contact)
: null
}
}
/** Maps a polymorphic `responsible` value (ids or populated docs) to display chips. */
const mapActivityResponsibles = (
value: readonly unknown[] | null | undefined,
): ActivityResponsibleDisplay[] => {
const populatedByKey = new Map<string, unknown>()
for (const raw of Array.isArray(value) ? value : []) {
const record = raw as { relationTo?: unknown; value?: unknown }
const id = relationshipId(record.value)
if (typeof record.relationTo === 'string' && id !== null) {
populatedByKey.set(`${record.relationTo}:${id}`, record.value)
}
}
return parseActivityResponsibleEntries(value).map((entry) => ({
relationTo: entry.relationTo,
id: entry.value,
name:
responsibleEntryName(
entry.relationTo,
populatedByKey.get(`${entry.relationTo}:${entry.value}`),
) ?? `Responsável #${entry.value}`,
typeLabel: activityResponsibleTypeLabels[entry.relationTo],
}))
}
export const formatActivityWhenLabel = (
startAt: string | null | undefined,
options: { allDay?: boolean | null; endAt?: string | null } = {},
): string => {
if (!startAt) return 'Data a definir'
if (options.allDay) return formatAllDayRangeLabel(startAt, options.endAt)
return formatBahiaDateTimeLabel(startAt)
}
export const formatActivityHomeSearchSecondary = (
municipalityName: string | null,
startAt: string | null | undefined,
options: { allDay?: boolean | null; endAt?: string | null } = {},
): string => {
const whenLabel = formatActivityWhenLabel(startAt, options)
if (!municipalityName) return whenLabel
return `${municipalityName} · ${whenLabel}`
}
/** Short "where" line, e.g. "Itabuna · Feira do Malhado". */
const formatActivityLocationLabel = ({
municipalityName,
locality,
}: {
municipalityName: string | null
locality?: string | null
}): string => [municipalityName, locality].filter(Boolean).join(' · ')
export const activityAgendaSelect = {
title: true,
slug: true,
tags: true,
status: true,
deputyPresent: true,
allDay: true,
startAt: true,
endAt: true,
municipality: true,
locality: true,
} as const
export const activityListSelect = {
...activityAgendaSelect,
responsible: true,
taskDoneCount: true,
taskTotal: true,
} as const
export type ActivityAgendaEvent = {
id: number
title: string
href: string
tags: string[]
status: Activity['status']
deputyPresent: boolean
allDay: boolean
startAt: string
endAt: string | null
municipality: ActivityMunicipalitySummary | null
locality: string | null
canReschedule: boolean
}
export const toActivityAgendaEvent = (
activity: Activity,
user: CampaignUser,
): ActivityAgendaEvent => {
if (!activity.startAt) {
throw new Error('Atividade sem data não pode ser convertida em evento de agenda.')
}
const deputyPresent = Boolean(activity.deputyPresent)
return {
id: activity.id,
title: activity.title,
href: `/campanha/atividades/${activity.slug}`,
tags: activity.tags ?? [],
status: activity.status,
deputyPresent,
allDay: Boolean(activity.allDay),
startAt: activity.startAt,
endAt: activity.endAt ?? null,
municipality: activityMunicipalitySummary(activity.municipality),
locality: activity.locality ?? null,
canReschedule: canCampaignUserRescheduleActivity(user, deputyPresent),
}
}
export type ActivityListViewModel = {
id: number
title: string
slug: string
tags: string[]
status: Activity['status']
deputyPresent: boolean
allDay: boolean
startAt: string | null
endAt: string | null
municipalityName: string | null
locality: string | null
locationLabel: string
responsibles: ActivityResponsibleDisplay[]
taskProgress: { done: number; total: number }
}
const relationshipName = (
relationship: number | Contact | CampaignUser | null | undefined,
): string | null =>
isPopulatedRelationship<Contact | CampaignUser>(relationship) ? relationship.name : null
export const toActivityListViewModel = (activity: Activity): ActivityListViewModel => {
const municipalityName = activityMunicipalitySummary(activity.municipality)?.name ?? null
return {
id: activity.id,
title: activity.title,
slug: activity.slug,
tags: activity.tags ?? [],
status: activity.status,
deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
municipalityName,
locality: activity.locality ?? null,
locationLabel: formatActivityLocationLabel({ municipalityName, locality: activity.locality }),
responsibles: mapActivityResponsibles(activity.responsible),
taskProgress: {
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,
},
}
}
export const activityFormSelect = {
title: true,
slug: true,
tags: true,
status: true,
description: true,
deputyPresent: true,
allDay: true,
publicEvent: true,
startAt: true,
endAt: true,
municipality: true,
locality: true,
organizations: true,
responsible: true,
tasks: true,
} as const
type ActivityFormTaskViewModel = {
id: string | null
title: string
responsible: { id: number; name: string; phone: string | null } | null
done: boolean
}
export type ActivityFormViewModel = {
id: number
title: string
slug: string
tags: string[]
status: Activity['status']
description: string | null
deputyPresent: boolean
allDay: boolean
publicEvent: boolean
startAt: string | null
endAt: string | null
municipalityId: number | null
locality: string | null
organizationIDs: number[]
responsibles: ActivityResponsibleDisplay[]
tasks: ActivityFormTaskViewModel[]
}
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
export const toActivityFormViewModel = (activity: Activity): ActivityFormViewModel => ({
id: activity.id,
title: activity.title,
slug: activity.slug,
tags: activity.tags ?? [],
status: activity.status,
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
publicEvent: Boolean(activity.publicEvent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
municipalityId: relationshipId(activity.municipality),
locality: activity.locality ?? null,
organizationIDs: relationshipIds(activity.organizations),
responsibles: mapActivityResponsibles(activity.responsible),
tasks: (activity.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsible: isPopulatedRelationship<Contact>(task.responsible)
? {
id: task.responsible.id,
name: task.responsible.name,
phone: primaryPhoneOf(task.responsible.phones),
}
: null,
done: Boolean(task.done),
})),
})
const activityDetailContextSelect = {
title: true,
slug: true,
tags: true,
status: true,
description: true,
deputyPresent: true,
allDay: true,
startAt: true,
endAt: true,
municipality: true,
locality: true,
organizations: true,
responsible: true,
createdBy: true,
updatedAt: true,
createdAt: true,
} as const
export const getActivityDetailSelect = (activeTab: ActivityDetailTab) => {
if (activeTab === 'overview') {
return {
...activityDetailContextSelect,
taskDoneCount: true,
taskTotal: true,
resultSummary: true,
resultRecordedBy: true,
resultRecordedAt: true,
} as const
}
if (activeTab === 'tasks') {
return {
...activityDetailContextSelect,
tasks: true,
} as const
}
return {
...activityDetailContextSelect,
updates: true,
} as const
}
export type ActivityTaskViewModel = {
id: string | null
title: string
responsibleId: number | null
responsibleName: string | null
done: boolean
doneAt: string | null
}
export type ActivityUpdateViewModel = {
id: string | null
body: string
authorName: string | null
createdAt: string | null
}
type ActivityResultViewModel = {
summary: string
recordedByName: string | null
recordedAt: string | null
}
export type ActivityDetailViewModel = {
id: number
title: string
slug: string
tags: string[]
status: Activity['status']
description: string | null
deputyPresent: boolean
allDay: boolean
startAt: string | null
endAt: string | null
municipality: ActivityMunicipalitySummary | null
locality: string | null
locationLabel: string
organizations: Array<{ id: number; name: string }>
responsibles: ActivityResponsibleDisplay[]
taskProgress: { done: number; total: number }
tasks: ActivityTaskViewModel[]
updates: ActivityUpdateViewModel[]
result: ActivityResultViewModel | null
createdByName: string | null
updatedAt: string
createdAt: string
}
const mapActivityTasks = (activity: Activity): ActivityTaskViewModel[] =>
(activity.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsibleId: relationshipId(task.responsible),
responsibleName: relationshipName(task.responsible),
done: Boolean(task.done),
doneAt: task.doneAt ?? null,
}))
const mapActivityUpdates = (
activity: Activity,
authorNamesById: ReadonlyMap<number, string> = new Map(),
): ActivityUpdateViewModel[] =>
(activity.updates ?? []).map((update) => {
const authorId = relationshipId(update.author)
const populatedName = relationshipName(update.author)
return {
id: update.id ?? null,
body: update.body,
authorName: populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null),
createdAt: update.createdAt ?? null,
}
})
const mapActivityResult = (activity: Activity): ActivityResultViewModel | null => {
const summary = activity.resultSummary?.trim()
if (!summary) return null
return {
summary,
recordedByName: relationshipName(activity.resultRecordedBy),
recordedAt: activity.resultRecordedAt ?? null,
}
}
export const toActivityDetailViewModel = (
activity: Activity,
activeTab: ActivityDetailTab = 'overview',
authorNamesById: ReadonlyMap<number, string> = new Map(),
municipalitySummary: ActivityMunicipalitySummary | null = null,
): ActivityDetailViewModel => {
const municipality = municipalitySummary ?? activityMunicipalitySummary(activity.municipality)
return {
id: activity.id,
title: activity.title,
slug: activity.slug,
tags: activity.tags ?? [],
status: activity.status,
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
municipality,
locality: activity.locality ?? null,
locationLabel: formatActivityLocationLabel({
municipalityName: municipality?.name ?? null,
locality: activity.locality,
}),
organizations:
activity.organizations
?.filter((organization): organization is Organization =>
isPopulatedRelationship<Organization>(organization),
)
.map(({ id, name }) => ({ id, name })) ?? [],
responsibles: mapActivityResponsibles(activity.responsible),
taskProgress: {
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,
},
tasks: activeTab === 'tasks' ? mapActivityTasks(activity) : [],
updates: activeTab === 'updates' ? mapActivityUpdates(activity, authorNamesById) : [],
result: mapActivityResult(activity),
createdByName: relationshipName(activity.createdBy),
updatedAt: activity.updatedAt,
createdAt: activity.createdAt,
}
}