add backend hatchet worker for calendar predue sync

This commit is contained in:
Ruslan Bakiev
2026-03-08 19:15:30 +07:00
parent 0df426d5d6
commit e4870ce669
21 changed files with 1859 additions and 350 deletions

View File

@@ -7,6 +7,7 @@ Core CRM/omni-домен с единственной Prisma-базой.
- принимает входящие telegram-события через GraphQL mutation `ingestTelegramInbound`;
- создает исходящую задачу через GraphQL mutation `requestTelegramOutbound``telegram_backend`, далее в Hatchet);
- принимает отчет о доставке через GraphQL mutation `reportTelegramOutbound`.
- выполняет sync календарных предзаписей через GraphQL mutation `syncCalendarPredueTimeline`.
## API
@@ -27,9 +28,13 @@ Core CRM/omni-домен с единственной Prisma-базой.
- `TELEGRAM_BACKEND_GRAPHQL_URL` (required для `requestTelegramOutbound`)
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `DEFAULT_TEAM_ID` (optional fallback для inbound маршрутизации)
- `TIMELINE_EVENT_PREDUE_MINUTES` (default: `30`)
- `TIMELINE_EVENT_LOOKBACK_MINUTES` (default: `180`)
- `TIMELINE_EVENT_LOOKAHEAD_MINUTES` (default: `1440`)
- `TIMELINE_SCHEDULER_LOCK_KEY` (default: `603001`)
## Prisma policy
- Источник схемы: `Frontend/prisma/schema.prisma`.
- Источник схемы: `frontend/prisma/schema.prisma`.
- Локальная копия в `backend/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
- Миграции/`db push` выполняются только в `Frontend`.
- Миграции/`db push` выполняются только в `frontend`.

View File

@@ -4,6 +4,7 @@ import {
ingestTelegramInbound,
reportTelegramOutbound,
requestTelegramOutbound,
syncCalendarPredueTimeline,
type TelegramInboundEnvelope,
type TelegramOutboundReport,
type TelegramOutboundRequest,
@@ -31,6 +32,20 @@ const schema = buildSchema(`
omniMessageId: String
}
type SchedulerSyncResult {
ok: Boolean!
message: String!
now: String!
scanned: Int!
updated: Int!
skippedBeforeWindow: Int!
skippedLocked: Boolean!
preDueMinutes: Int!
lookbackMinutes: Int!
lookaheadMinutes: Int!
lockKey: Int!
}
input TelegramInboundInput {
version: Int!
idempotencyKey: String!
@@ -65,6 +80,7 @@ const schema = buildSchema(`
ingestTelegramInbound(input: TelegramInboundInput!): MutationResult!
reportTelegramOutbound(input: TelegramOutboundReportInput!): MutationResult!
requestTelegramOutbound(input: TelegramOutboundTaskInput!): MutationResult!
syncCalendarPredueTimeline: SchedulerSyncResult!
}
`);
@@ -172,6 +188,23 @@ const root = {
omniMessageId: null,
};
},
syncCalendarPredueTimeline: async () => {
const result = await syncCalendarPredueTimeline();
return {
ok: result.ok,
message: result.message,
now: result.now,
scanned: result.scanned,
updated: result.updated,
skippedBeforeWindow: result.skippedBeforeWindow,
skippedLocked: result.skippedLocked,
preDueMinutes: result.preDueMinutes,
lookbackMinutes: result.lookbackMinutes,
lookaheadMinutes: result.lookaheadMinutes,
lockKey: result.lockKey,
};
},
};
export function startServer() {

View File

@@ -38,6 +38,20 @@ export type TelegramOutboundRequest = {
businessConnectionId?: string | null;
};
export type CalendarPredueSyncResult = {
ok: boolean;
message: string;
now: string;
scanned: number;
updated: number;
skippedBeforeWindow: number;
skippedLocked: boolean;
preDueMinutes: number;
lookbackMinutes: number;
lookaheadMinutes: number;
lockKey: number;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
@@ -54,6 +68,13 @@ function normalizeDirection(value: string): MessageDirection {
return value === "OUT" ? "OUT" : "IN";
}
function readIntEnv(name: string, defaultValue: number) {
const raw = asString(process.env[name]);
if (!raw) return defaultValue;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
async function resolveTeamId(envelope: TelegramInboundEnvelope) {
const n = envelope.payloadNormalized;
const bcId = asString(n.businessConnectionId);
@@ -510,3 +531,107 @@ export async function requestTelegramOutbound(input: TelegramOutboundRequest) {
return { ok: true, message: "outbound_enqueued", runId: result.runId ?? null };
}
export async function syncCalendarPredueTimeline(): Promise<CalendarPredueSyncResult> {
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 lockRows = await prisma.$queryRaw<Array<{ locked: boolean }>>`
SELECT pg_try_advisory_lock(${lockKey}) AS locked
`;
const locked = Boolean(lockRows?.[0]?.locked);
if (!locked) {
return {
ok: true,
message: "lock_busy_skip",
now: now.toISOString(),
scanned: 0,
updated: 0,
skippedBeforeWindow: 0,
skippedLocked: true,
preDueMinutes,
lookbackMinutes,
lookaheadMinutes,
lockKey,
};
}
try {
const events = await prisma.calendarEvent.findMany({
where: {
isArchived: false,
contactId: { not: null },
startsAt: {
gte: rangeStart,
lte: rangeEnd,
},
},
orderBy: { startsAt: "asc" },
select: {
id: true,
teamId: true,
contactId: true,
startsAt: true,
},
});
let updated = 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,
},
});
updated += 1;
}
return {
ok: true,
message: "calendar_predue_synced",
now: now.toISOString(),
scanned: events.length,
updated,
skippedBeforeWindow,
skippedLocked: false,
preDueMinutes,
lookbackMinutes,
lookaheadMinutes,
lockKey,
};
} finally {
await prisma.$queryRaw`SELECT pg_advisory_unlock(${lockKey})`;
}
}