feat: add dedicated calendar timeline scheduler service
This commit is contained in:
@@ -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" }
|
||||
|
||||
19
frontend/schedulers/client-timeline-calendar/Dockerfile
Normal file
19
frontend/schedulers/client-timeline-calendar/Dockerfile
Normal file
@@ -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"]
|
||||
38
frontend/schedulers/client-timeline-calendar/README.md
Normal file
38
frontend/schedulers/client-timeline-calendar/README.md
Normal file
@@ -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 и поставь период `* * * * *` (раз в минуту), который запускает этот контейнер/команду.
|
||||
109
frontend/schedulers/client-timeline-calendar/run.mjs
Normal file
109
frontend/schedulers/client-timeline-calendar/run.mjs
Normal file
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user