import { eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { googleCalendarConnection } from "@/lib/db/schema"; import type { BookingDTO } from "@/lib/bookings/service"; const OAUTH_SCOPE = "https://www.googleapis.com/auth/calendar"; const CALENDAR_NAME = "Бронирования"; function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } function redirectUri(): string { // APP_URL must be the exact public origin registered as the OAuth // client's redirect URI in Google Cloud Console (e.g. // https://reser.top-sysops.ru) — Google rejects any mismatch. return `${requireEnv("APP_URL").replace(/\/$/, "")}/api/google/callback`; } export function getGoogleAuthUrl(state: string): string { const params = new URLSearchParams({ client_id: requireEnv("GOOGLE_CLIENT_ID"), redirect_uri: redirectUri(), response_type: "code", scope: OAUTH_SCOPE, access_type: "offline", // Forces Google to re-issue a refresh_token even if this account already // granted consent before — without it, a second connect attempt (e.g. // after the row was deleted) silently comes back with no refresh_token. prompt: "consent", state, }); return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; } interface GoogleTokenResponse { access_token: string; refresh_token?: string; expires_in: number; token_type: string; scope: string; } async function exchangeCodeForTokens(code: string): Promise { const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: requireEnv("GOOGLE_CLIENT_ID"), client_secret: requireEnv("GOOGLE_CLIENT_SECRET"), redirect_uri: redirectUri(), grant_type: "authorization_code", }), }); if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`); return res.json(); } async function refreshAccessToken(refreshToken: string): Promise { const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ refresh_token: refreshToken, client_id: requireEnv("GOOGLE_CLIENT_ID"), client_secret: requireEnv("GOOGLE_CLIENT_SECRET"), grant_type: "refresh_token", }), }); if (!res.ok) throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`); return res.json(); } async function findOrCreateBookingsCalendar(accessToken: string): Promise { const listRes = await fetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!listRes.ok) throw new Error(`Calendar list failed: ${listRes.status} ${await listRes.text()}`); const list = await listRes.json(); const existing = (list.items ?? []).find((c: { summary?: string }) => c.summary === CALENDAR_NAME); if (existing) return existing.id; const createRes = await fetch("https://www.googleapis.com/calendar/v3/calendars", { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify({ summary: CALENDAR_NAME, timeZone: "Europe/Moscow" }), }); if (!createRes.ok) throw new Error(`Calendar create failed: ${createRes.status} ${await createRes.text()}`); const created = await createRes.json(); return created.id; } /** Completes the OAuth flow: exchanges the code, creates/finds the "Бронирования" calendar, persists the connection. */ export async function connectGoogleCalendar(code: string, connectedByUserId: string): Promise { const tokens = await exchangeCodeForTokens(code); if (!tokens.refresh_token) { throw new Error( "Google не вернул refresh_token — возможно, доступ уже был выдан ранее. Отключите приложение в " + "myaccount.google.com/permissions и попробуйте подключить снова.", ); } const calendarId = await findOrCreateBookingsCalendar(tokens.access_token); // Singleton table — clear any previous row first. await db.delete(googleCalendarConnection); await db.insert(googleCalendarConnection).values({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), calendarId, connectedByUserId, }); } export async function disconnectGoogleCalendar(): Promise { await db.delete(googleCalendarConnection); } export interface GoogleConnectionStatus { connected: boolean; calendarId?: string; } export async function getConnectionStatus(): Promise { const row = await db.query.googleCalendarConnection.findFirst(); if (!row) return { connected: false }; return { connected: true, calendarId: row.calendarId }; } /** Returns a valid access token for the connected account, refreshing and persisting it first if it's expired. Returns null if not connected. */ async function getValidAccessToken(): Promise<{ accessToken: string; calendarId: string } | null> { const row = await db.query.googleCalendarConnection.findFirst(); if (!row) return null; // Refresh a little before actual expiry to avoid a request landing right on the edge. if (row.accessTokenExpiresAt.getTime() > Date.now() + 60_000) { return { accessToken: row.accessToken, calendarId: row.calendarId }; } const tokens = await refreshAccessToken(row.refreshToken); await db .update(googleCalendarConnection) .set({ accessToken: tokens.access_token, accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000) }) .where(eq(googleCalendarConnection.id, row.id)); return { accessToken: tokens.access_token, calendarId: row.calendarId }; } function bookingToEventBody(booking: BookingDTO) { const [h1, m1] = booking.checkIn.split(":").map(Number); const [h2, m2] = booking.checkOut.split(":").map(Number); const start = new Date(booking.date); start.setHours(h1 || 0, m1 || 0, 0, 0); const end = new Date(booking.date); end.setHours(h2 || 0, m2 || 0, 0, 0); if (end <= start) end.setDate(end.getDate() + 1); // overnight check-out const objectNames = booking.objects.map((o) => o.name).join(", ") || "—"; const description = [ `Номер бронирования: ${booking.bookingNumber}`, `Контактное лицо: ${booking.contactName}`, `Телефон: ${booking.phone}`, `Количество людей: ${booking.guestCount}`, `Стоимость: ${booking.cost} ₽`, `Предоплата: ${booking.prepayment} ₽`, `Комментарий: ${booking.comment?.trim() || "-"}`, `Откуда пришли: ${booking.source?.trim() || "-"}`, `Кто забронировал: ${booking.bookedByName}`, ].join("\n"); return { summary: `#${booking.bookingNumber} ${objectNames} — ${booking.contactName}`, description, start: { dateTime: start.toISOString(), timeZone: "Europe/Moscow" }, end: { dateTime: end.toISOString(), timeZone: "Europe/Moscow" }, }; } // Every export below is best-effort by design — callers (the booking // service) wrap these in try/catch so a Calendar API hiccup never blocks a // booking write; the booking itself is always the source of truth. export async function createCalendarEvent(booking: BookingDTO): Promise { const conn = await getValidAccessToken(); if (!conn) return null; const res = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(conn.calendarId)}/events`, { method: "POST", headers: { Authorization: `Bearer ${conn.accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(bookingToEventBody(booking)), }); if (!res.ok) throw new Error(`Create event failed: ${res.status} ${await res.text()}`); const created = await res.json(); return created.id; } export async function updateCalendarEvent(booking: BookingDTO): Promise { if (!booking.googleEventId) return; const conn = await getValidAccessToken(); if (!conn) return; const res = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(conn.calendarId)}/events/${encodeURIComponent(booking.googleEventId)}`, { method: "PATCH", headers: { Authorization: `Bearer ${conn.accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(bookingToEventBody(booking)), }, ); if (!res.ok && res.status !== 404) throw new Error(`Update event failed: ${res.status} ${await res.text()}`); } export async function deleteCalendarEvent(googleEventId: string): Promise { const conn = await getValidAccessToken(); if (!conn) return; const res = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(conn.calendarId)}/events/${encodeURIComponent(googleEventId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${conn.accessToken}` } }, ); // 410 Gone means it's already deleted on Google's side — fine either way. if (!res.ok && res.status !== 404 && res.status !== 410) { throw new Error(`Delete event failed: ${res.status} ${await res.text()}`); } }