Add Google Calendar sync: OAuth connect flow, dedicated Бронирования calendar, best-effort event create/update/delete on booking changes
This commit is contained in:
1 parent
14a849bfb9
commit
7654a7b616
14 files changed
+981
-3
No files matched your search
@@ -0,0 +1,228 @@
|
||||
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<GoogleTokenResponse> {
|
||||
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<GoogleTokenResponse> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db.delete(googleCalendarConnection);
|
||||
}
|
||||
|
||||
export interface GoogleConnectionStatus {
|
||||
connected: boolean;
|
||||
calendarId?: string;
|
||||
}
|
||||
|
||||
export async function getConnectionStatus(): Promise<GoogleConnectionStatus> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user