feat(api): automatizaciones Monday — round-robin de leads, recordatorio inactividad 10d, webhooks reales y polling cron

- Servicio de automatizaciones con estado persistido en SQLite (índice RR, dedup de recordatorios)
- Webhook Monday procesa create_item del board Leads y asigna vendedor
- Crons: polling cada 5 min (respaldo sin webhook público) + escaneo diario 9am
- Endpoints admin: /api/automatizaciones/estado, /round-robin/run, /inactividad/preview
- 15 tests nuevos (70/70 verdes)
This commit is contained in:
2026-08-22 02:37:16 +00:00
parent 05f6ceb01a
commit 8407fc24c3
13 changed files with 781 additions and 12 deletions

View File

@@ -23,6 +23,27 @@ interface ItemMonday {
column_values: ColumnValue[]
}
interface ItemConFechas extends ItemMonday {
created_at: string
updated_at: string
}
/** Lead del tablero Leads tal como lo consumen las automatizaciones */
export interface LeadMonday {
id: string
nombre: string
updatedAt: string // ISO (updated_at del item en Monday)
estado: string // label de la columna status 'Estado' ('' si no tiene)
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
export interface UsuarioMonday {
id: number
nombre: string
email: string
}
interface GraphQLErrorItem {
message: string
extensions?: { code?: string }
@@ -350,6 +371,114 @@ export class MondayService {
)
this.invalidarCacheUnidades()
}
// ---------- Leads (automatizaciones) ----------
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
async getLeads(): Promise<LeadMonday[]> {
const boardId = config.monday.boards.leads
const cols = await this.columnasDe(boardId)
const colVendedor = cols.get('Vendedor')
if (!colVendedor || colVendedor.type !== 'people') {
throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads (${boardId})`)
}
const colEstado = cols.get('Estado')
const items: ItemConFechas[] = []
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 200) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 200, cursor: $cursor) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const rawVendedor = porId.get(colVendedor.id)?.value
let vendedorIds: number[] = []
if (rawVendedor) {
try {
const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] }
vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
} catch {
vendedorIds = []
}
}
return {
id: item.id,
nombre: item.name,
updatedAt: item.updated_at,
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
vendedorIds,
}
})
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
const col = await this.columnaId(boardId, 'Vendedor')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{
boardId,
itemId,
columnId: col.id,
value: JSON.stringify({ personsAndTeams: [{ id: userId, kind: 'person' }] }),
},
)
}
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
const data = await this.graphql<{ create_update: { id: string } }>(
`mutation ($itemId: ID!, $body: String!) {
create_update(item_id: $itemId, body: $body) { id }
}`,
{ itemId, body },
)
return data.create_update.id
}
/**
* Usuarios activos no-guest del workspace (excluye las cuentas bot de Monday,
* cuyo email termina en @agent.monday.com).
*/
async getUsuariosWorkspace(): Promise<UsuarioMonday[]> {
const data = await this.graphql<{
users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[]
}>(`query { users(limit: 50) { id name email is_guest enabled } }`)
return data.users
.filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com'))
.map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email }))
}
}
// ---------- DTOs de escritura ----------