From f67cef22be7f2bc48c4219328d7e40c11f58efc3 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:54:06 +0700 Subject: [PATCH] feat: add dedicated calendar timeline scheduler service --- deploy-map.toml | 1 + .../client-timeline-calendar/Dockerfile | 19 +++ .../client-timeline-calendar/README.md | 38 ++++++ .../client-timeline-calendar/run.mjs | 109 ++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 frontend/schedulers/client-timeline-calendar/Dockerfile create mode 100644 frontend/schedulers/client-timeline-calendar/README.md create mode 100644 frontend/schedulers/client-timeline-calendar/run.mjs diff --git a/deploy-map.toml b/deploy-map.toml index a112981..7c776f6 100644 --- a/deploy-map.toml +++ b/deploy-map.toml @@ -2,6 +2,7 @@ version = 1 [services] frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } +"frontend/schedulers/client-timeline-calendar" = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } omni_outbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } omni_inbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } omni_chat = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } diff --git a/frontend/schedulers/client-timeline-calendar/Dockerfile b/frontend/schedulers/client-timeline-calendar/Dockerfile new file mode 100644 index 0000000..8f21ffb --- /dev/null +++ b/frontend/schedulers/client-timeline-calendar/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim + +WORKDIR /app/frontend + +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY package*.json ./ +RUN npm ci --ignore-scripts --legacy-peer-deps + +COPY prisma ./prisma +RUN npx prisma generate --schema=prisma/schema.prisma + +COPY schedulers ./schedulers + +ENV NODE_ENV=production + +CMD ["node", "schedulers/client-timeline-calendar/run.mjs"] diff --git a/frontend/schedulers/client-timeline-calendar/README.md b/frontend/schedulers/client-timeline-calendar/README.md new file mode 100644 index 0000000..f573244 --- /dev/null +++ b/frontend/schedulers/client-timeline-calendar/README.md @@ -0,0 +1,38 @@ +# Client Timeline Calendar Scheduler + +Одноразовый scheduler-раннер для актуализации `ClientTimelineEntry.datetime` по календарным событиям. + +## Что делает + +- Ищет неархивные `CalendarEvent` с привязкой к контакту в окне времени вокруг `now`. +- Если событие уже вошло в окно `start - 30 минут`, делает `upsert` в `ClientTimelineEntry` с `contentType=CALENDAR_EVENT` и `datetime=start-30m`. +- Логика идемпотентна: повторный запуск только подтверждает корректное значение. +- Берёт Postgres advisory lock, чтобы параллельные запуски не конфликтовали. + +## Переменные окружения + +- `DATABASE_URL` (обязательно) +- `TIMELINE_EVENT_PREDUE_MINUTES` (по умолчанию `30`) +- `TIMELINE_EVENT_LOOKBACK_MINUTES` (по умолчанию `180`) +- `TIMELINE_EVENT_LOOKAHEAD_MINUTES` (по умолчанию `1440`) +- `TIMELINE_SCHEDULER_LOCK_KEY` (по умолчанию `603001`) + +## Локальный запуск + +```bash +cd frontend +node schedulers/client-timeline-calendar/run.mjs +``` + +## Docker запуск + +```bash +docker build -f schedulers/client-timeline-calendar/Dockerfile -t client-timeline-calendar-scheduler . +docker run --rm \ + -e DATABASE_URL="$DATABASE_URL" \ + client-timeline-calendar-scheduler +``` + +## Dokploy schedule + +Создай Scheduled Job и поставь период `* * * * *` (раз в минуту), который запускает этот контейнер/команду. diff --git a/frontend/schedulers/client-timeline-calendar/run.mjs b/frontend/schedulers/client-timeline-calendar/run.mjs new file mode 100644 index 0000000..7c14919 --- /dev/null +++ b/frontend/schedulers/client-timeline-calendar/run.mjs @@ -0,0 +1,109 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +function readIntEnv(name, defaultValue) { + const raw = String(process.env[name] ?? "").trim(); + if (!raw) return defaultValue; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +async function acquirePgLock(lockKey) { + const rows = await prisma.$queryRawUnsafe("SELECT pg_try_advisory_lock($1) AS locked", lockKey); + const first = Array.isArray(rows) ? rows[0] : null; + return Boolean(first && (first.locked === true || first.locked === "t" || first.locked === 1)); +} + +async function releasePgLock(lockKey) { + await prisma.$queryRawUnsafe("SELECT pg_advisory_unlock($1)", lockKey).catch(() => undefined); +} + +async function run() { + const preDueMinutes = Math.max(1, readIntEnv("TIMELINE_EVENT_PREDUE_MINUTES", 30)); + const lookbackMinutes = Math.max(preDueMinutes, readIntEnv("TIMELINE_EVENT_LOOKBACK_MINUTES", 180)); + const lookaheadMinutes = Math.max(preDueMinutes, readIntEnv("TIMELINE_EVENT_LOOKAHEAD_MINUTES", 1440)); + const lockKey = readIntEnv("TIMELINE_SCHEDULER_LOCK_KEY", 603001); + + const now = new Date(); + const rangeStart = new Date(now.getTime() - lookbackMinutes * 60_000); + const rangeEnd = new Date(now.getTime() + lookaheadMinutes * 60_000); + + const locked = await acquirePgLock(lockKey); + if (!locked) { + console.log(`[timeline-calendar-scheduler] skipped: lock ${lockKey} is busy`); + return; + } + + try { + const events = await prisma.calendarEvent.findMany({ + where: { + isArchived: false, + contactId: { not: null }, + startsAt: { + gte: rangeStart, + lte: rangeEnd, + }, + }, + select: { + id: true, + teamId: true, + contactId: true, + startsAt: true, + }, + orderBy: { startsAt: "asc" }, + }); + + let touched = 0; + let skippedBeforeWindow = 0; + + for (const event of events) { + if (!event.contactId) continue; + + const preDueAt = new Date(event.startsAt.getTime() - preDueMinutes * 60_000); + if (now < preDueAt) { + skippedBeforeWindow += 1; + continue; + } + + await prisma.clientTimelineEntry.upsert({ + where: { + teamId_contentType_contentId: { + teamId: event.teamId, + contentType: "CALENDAR_EVENT", + contentId: event.id, + }, + }, + create: { + teamId: event.teamId, + contactId: event.contactId, + contentType: "CALENDAR_EVENT", + contentId: event.id, + datetime: preDueAt, + }, + update: { + contactId: event.contactId, + datetime: preDueAt, + }, + }); + + touched += 1; + } + + console.log( + `[timeline-calendar-scheduler] done: scanned=${events.length} updated=${touched} skipped_before_window=${skippedBeforeWindow} at=${now.toISOString()}`, + ); + } finally { + await releasePgLock(lockKey); + } +} + +run() + .catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error(`[timeline-calendar-scheduler] failed: ${message}`); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + });