add backend hatchet worker for calendar predue sync
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user