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()}`);
+ }
+}