From 7654a7b61675ce920c5cded909b7cac1cde781c7 Mon Sep 17 00:00:00 2001 From: ogrechko Date: Sat, 29 Aug 2026 11:45:46 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Google=20Calendar=20sync:=20OAuth=20conne?= =?UTF-8?q?ct=20flow,=20dedicated=20=D0=91=D1=80=D0=BE=D0=BD=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20calendar,=20best-effort?= =?UTF-8?q?=20event=20create/update/delete=20on=20booking=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + src/app/(admin)/nav-links.tsx | 3 +- .../google/google-calendar-settings.tsx | 63 +++ src/app/(admin)/settings/google/page.tsx | 26 + src/app/api/google/callback/route.ts | 43 ++ src/app/api/google/connect/route.ts | 27 + src/app/api/google/disconnect/route.ts | 13 + src/app/api/google/status/route.ts | 12 + src/lib/bookings/service.ts | 42 +- .../0001_simple_invisible_woman.sql | 12 + src/lib/db/migrations/meta/0001_snapshot.json | 477 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 22 + src/lib/google/calendar.ts | 228 +++++++++ 14 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 src/app/(admin)/settings/google/google-calendar-settings.tsx create mode 100644 src/app/(admin)/settings/google/page.tsx create mode 100644 src/app/api/google/callback/route.ts create mode 100644 src/app/api/google/connect/route.ts create mode 100644 src/app/api/google/disconnect/route.ts create mode 100644 src/app/api/google/status/route.ts create mode 100644 src/lib/db/migrations/0001_simple_invisible_woman.sql create mode 100644 src/lib/db/migrations/meta/0001_snapshot.json create mode 100644 src/lib/google/calendar.ts diff --git a/.env.example b/.env.example index a87fc1d..e300362 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,12 @@ ADMIN_BOOTSTRAP_EMAIL=admin@top-sysops.ru ADMIN_BOOTSTRAP_PASSWORD= ADMIN_BOOTSTRAP_NAME=Admin + +# Public origin this app is reachable at — must match exactly (scheme + +# host, no trailing slash). Used to build the Google OAuth redirect URI. +APP_URL=https://reser.top-sysops.ru + +# Google Cloud Console → APIs & Services → Credentials → OAuth client ID. +# Add ${APP_URL}/api/google/callback as an authorized redirect URI there. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/src/app/(admin)/nav-links.tsx b/src/app/(admin)/nav-links.tsx index 9896c88..3dfd121 100644 --- a/src/app/(admin)/nav-links.tsx +++ b/src/app/(admin)/nav-links.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react"; +import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users, CalendarDays } from "lucide-react"; const links = [ { href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true }, @@ -11,6 +11,7 @@ const links = [ { href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false }, { href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false }, { href: "/settings/users", label: "Сотрудники", icon: Users, adminOnly: true, exact: false }, + { href: "/settings/google", label: "Google Calendar", icon: CalendarDays, adminOnly: true, exact: false }, ]; export function NavLinks({ isAdmin }: { isAdmin: boolean }) { diff --git a/src/app/(admin)/settings/google/google-calendar-settings.tsx b/src/app/(admin)/settings/google/google-calendar-settings.tsx new file mode 100644 index 0000000..037a2ef --- /dev/null +++ b/src/app/(admin)/settings/google/google-calendar-settings.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { CalendarDays, CheckCircle2, LinkIcon, Unlink } from "lucide-react"; +import type { GoogleConnectionStatus } from "@/lib/google/calendar"; + +export function GoogleCalendarSettings({ + status, + justConnected, + error, +}: { + status: GoogleConnectionStatus; + justConnected: boolean; + error?: string; +}) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + async function handleDisconnect() { + setLoading(true); + await fetch("/api/google/disconnect", { method: "POST" }); + setLoading(false); + router.refresh(); + } + + return ( +
+ {justConnected && ( +

+ + Google Calendar подключён. +

+ )} + {error &&

{error}

} + +
+
+ +
+
+

{status.connected ? "Подключено" : "Не подключено"}

+

+ {status.connected + ? "Календарь «Бронирования» получает событие на каждое новое бронирование." + : "Подключите Google-аккаунт, чтобы бронирования появлялись в календаре."} +

+
+ {status.connected ? ( + + ) : ( + + + Подключить Google Calendar + + )} +
+
+ ); +} diff --git a/src/app/(admin)/settings/google/page.tsx b/src/app/(admin)/settings/google/page.tsx new file mode 100644 index 0000000..34ee91d --- /dev/null +++ b/src/app/(admin)/settings/google/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; +import { getCurrentSession } from "@/lib/auth/session"; +import { getConnectionStatus } from "@/lib/google/calendar"; +import { GoogleCalendarSettings } from "./google-calendar-settings"; + +export default async function GoogleCalendarSettingsPage({ + searchParams, +}: { + searchParams: Promise<{ connected?: string; error?: string }>; +}) { + const session = await getCurrentSession(); + if (!session || session.user.role !== "admin") redirect("/"); + + const [status, { connected, error }] = await Promise.all([getConnectionStatus(), searchParams]); + + return ( +
+

Google Calendar

+

+ Каждое подтверждённое бронирование появляется отдельным событием в календаре «Бронирования» на подключённом + Google-аккаунте. +

+ +
+ ); +} diff --git a/src/app/api/google/callback/route.ts b/src/app/api/google/callback/route.ts new file mode 100644 index 0000000..b040a00 --- /dev/null +++ b/src/app/api/google/callback/route.ts @@ -0,0 +1,43 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { requireAdminSession } from "@/lib/auth/require"; +import { connectGoogleCalendar } from "@/lib/google/calendar"; + +const STATE_COOKIE = "google_oauth_state"; + +function redirectToSettings(request: Request, params: Record) { + const url = new URL("/settings/google", request.url); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return NextResponse.redirect(url); +} + +export async function GET(request: Request) { + const { session, response } = await requireAdminSession(); + if (!session) return response; + + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + const cookieStore = await cookies(); + const expectedState = cookieStore.get(STATE_COOKIE)?.value; + cookieStore.delete(STATE_COOKIE); + + if (error) { + return redirectToSettings(request, { error: "Доступ не предоставлен" }); + } + if (!code || !state || !expectedState || state !== expectedState) { + return redirectToSettings(request, { error: "Некорректный ответ от Google — попробуйте подключить снова" }); + } + + try { + await connectGoogleCalendar(code, session.user.id); + } catch (err) { + return redirectToSettings(request, { error: err instanceof Error ? err.message : "Не удалось подключить Google Calendar" }); + } + + return redirectToSettings(request, { connected: "1" }); +} diff --git a/src/app/api/google/connect/route.ts b/src/app/api/google/connect/route.ts new file mode 100644 index 0000000..e418e41 --- /dev/null +++ b/src/app/api/google/connect/route.ts @@ -0,0 +1,27 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { cookies } from "next/headers"; +import { requireAdminSession } from "@/lib/auth/require"; +import { getGoogleAuthUrl } from "@/lib/google/calendar"; + +const STATE_COOKIE = "google_oauth_state"; + +/** Redirects the admin to Google's consent screen. GET so it can be a plain link/button, not a fetch call. */ +export async function GET() { + const { session, response } = await requireAdminSession(); + if (!session) return response; + + const state = crypto.randomBytes(16).toString("base64url"); + const cookieStore = await cookies(); + cookieStore.set(STATE_COOKIE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true", + sameSite: "lax", + path: "/", + maxAge: 600, + }); + + return NextResponse.redirect(getGoogleAuthUrl(state)); +} diff --git a/src/app/api/google/disconnect/route.ts b/src/app/api/google/disconnect/route.ts new file mode 100644 index 0000000..c5a05d1 --- /dev/null +++ b/src/app/api/google/disconnect/route.ts @@ -0,0 +1,13 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { requireAdminSession } from "@/lib/auth/require"; +import { disconnectGoogleCalendar } from "@/lib/google/calendar"; + +export async function POST() { + const { session, response } = await requireAdminSession(); + if (!session) return response; + + await disconnectGoogleCalendar(); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/google/status/route.ts b/src/app/api/google/status/route.ts new file mode 100644 index 0000000..2943dd9 --- /dev/null +++ b/src/app/api/google/status/route.ts @@ -0,0 +1,12 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { requireSession } from "@/lib/auth/require"; +import { getConnectionStatus } from "@/lib/google/calendar"; + +export async function GET() { + const { session, response } = await requireSession(); + if (!session) return response; + + return NextResponse.json(await getConnectionStatus()); +} diff --git a/src/lib/bookings/service.ts b/src/lib/bookings/service.ts index a6fb5c1..810452c 100644 --- a/src/lib/bookings/service.ts +++ b/src/lib/bookings/service.ts @@ -1,6 +1,7 @@ import { and, eq, gte, lt, inArray, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { bookings, bookingObjects, bookableObjects, users } from "@/lib/db/schema"; +import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from "@/lib/google/calendar"; export interface BookableObjectDTO { id: string; @@ -52,6 +53,7 @@ export interface BookingDTO { status: "confirmed" | "cancelled"; objects: { id: string; name: string }[]; bookedByName: string; + googleEventId: string | null; createdAt: Date; } @@ -81,6 +83,7 @@ async function toBookingDTO(row: typeof bookings.$inferSelect): Promise ({ bookingId: row.id, objectId }))); } - return toBookingDTO(row); + const dto = await toBookingDTO(row); + + // Best-effort Google Calendar sync — never blocks/fails booking creation. + try { + const googleEventId = await createCalendarEvent(dto); + if (googleEventId) { + await db.update(bookings).set({ googleEventId }).where(eq(bookings.id, row.id)); + dto.googleEventId = googleEventId; + } + } catch (err) { + console.error("Google Calendar sync failed for booking", row.bookingNumber, err); + } + + return dto; } export interface UpdateBookingInput { @@ -160,7 +176,29 @@ export async function updateBooking(id: string, input: UpdateBookingInput): Prom } const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) }); if (!row) return null; - return toBookingDTO(row); + const dto = await toBookingDTO(row); + + // Best-effort Google Calendar sync — never blocks/fails the update. + try { + if (dto.status === "cancelled" && dto.googleEventId) { + await deleteCalendarEvent(dto.googleEventId); + await db.update(bookings).set({ googleEventId: null }).where(eq(bookings.id, id)); + dto.googleEventId = null; + } else if (dto.status === "confirmed" && dto.googleEventId) { + await updateCalendarEvent(dto); + } else if (dto.status === "confirmed" && !dto.googleEventId) { + // Never synced yet (created before Google was connected, or just restored from cancelled). + const googleEventId = await createCalendarEvent(dto); + if (googleEventId) { + await db.update(bookings).set({ googleEventId }).where(eq(bookings.id, id)); + dto.googleEventId = googleEventId; + } + } + } catch (err) { + console.error("Google Calendar sync failed for booking", dto.bookingNumber, err); + } + + return dto; } export async function getBooking(id: string): Promise { diff --git a/src/lib/db/migrations/0001_simple_invisible_woman.sql b/src/lib/db/migrations/0001_simple_invisible_woman.sql new file mode 100644 index 0000000..173bbbc --- /dev/null +++ b/src/lib/db/migrations/0001_simple_invisible_woman.sql @@ -0,0 +1,12 @@ +CREATE TABLE `google_calendar_connection` ( + `id` text PRIMARY KEY NOT NULL, + `access_token` text NOT NULL, + `refresh_token` text NOT NULL, + `access_token_expires_at` integer NOT NULL, + `calendar_id` text NOT NULL, + `connected_by_user_id` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`connected_by_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `bookings` ADD `google_event_id` text; \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0001_snapshot.json b/src/lib/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..d9986b5 --- /dev/null +++ b/src/lib/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,477 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "faf632a0-2681-4a8a-9631-e35ad1bb25e2", + "prevId": "1e6cef10-8b62-4e22-9659-2a6797bc15f0", + "tables": { + "bookable_objects": { + "name": "bookable_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "bookable_objects_name_unique": { + "name": "bookable_objects_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "booking_objects": { + "name": "booking_objects", + "columns": { + "booking_id": { + "name": "booking_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "booking_objects_booking_id_bookings_id_fk": { + "name": "booking_objects_booking_id_bookings_id_fk", + "tableFrom": "booking_objects", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "booking_objects_object_id_bookable_objects_id_fk": { + "name": "booking_objects_object_id_bookable_objects_id_fk", + "tableFrom": "booking_objects", + "tableTo": "bookable_objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "booking_objects_booking_id_object_id_pk": { + "columns": [ + "booking_id", + "object_id" + ], + "name": "booking_objects_booking_id_object_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookings": { + "name": "bookings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "booking_number": { + "name": "booking_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_name": { + "name": "contact_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_in": { + "name": "check_in", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_out": { + "name": "check_out", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guest_count": { + "name": "guest_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prepayment": { + "name": "prepayment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "booked_by_user_id": { + "name": "booked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'confirmed'" + }, + "google_event_id": { + "name": "google_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "bookings_booking_number_unique": { + "name": "bookings_booking_number_unique", + "columns": [ + "booking_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookings_booked_by_user_id_users_id_fk": { + "name": "bookings_booked_by_user_id_users_id_fk", + "tableFrom": "bookings", + "tableTo": "users", + "columnsFrom": [ + "booked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_calendar_connection": { + "name": "google_calendar_connection", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "google_calendar_connection_connected_by_user_id_users_id_fk": { + "name": "google_calendar_connection_connected_by_user_id_users_id_fk", + "tableFrom": "google_calendar_connection", + "tableTo": "users", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'staff'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 0a2fed7..18c3cd2 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1788002528986, "tag": "0000_flippant_the_enforcers", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1788003717768, + "tag": "0001_simple_invisible_woman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index a3537dc..f829747 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -78,10 +78,32 @@ export const bookings = sqliteTable("bookings", { status: text("status", { enum: ["confirmed", "cancelled"] }) .notNull() .default("confirmed"), + // Google Calendar event id for this booking, if the integration is + // connected — null if never synced (integration off, or the sync call + // failed; sync is always best-effort and never blocks booking writes). + googleEventId: text("google_event_id"), createdAt: timestamps.createdAt, updatedAt: timestamps.updatedAt, }); +/** + * Singleton row (at most one) holding the OAuth connection to a single + * Google account's Calendar — there's only ever one business, so no need + * for a real multi-row settings table. accessToken/expiresAt are refreshed + * in place as needed; refreshToken is the long-lived credential. + */ +export const googleCalendarConnection = sqliteTable("google_calendar_connection", { + id: id(), + accessToken: text("access_token").notNull(), + refreshToken: text("refresh_token").notNull(), + accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp_ms" }).notNull(), + calendarId: text("calendar_id").notNull(), + connectedByUserId: text("connected_by_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamps.createdAt, +}); + /** Junction table for the booking <-> bookable-object multi-select. */ export const bookingObjects = sqliteTable( "booking_objects", diff --git a/src/lib/google/calendar.ts b/src/lib/google/calendar.ts new file mode 100644 index 0000000..60e1fba --- /dev/null +++ b/src/lib/google/calendar.ts @@ -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 { + 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()}`); + } +}