From 47b003bbe39861d3e864fca77d410f993df51a0a Mon Sep 17 00:00:00 2001 From: ogrechko Date: Sat, 29 Aug 2026 11:56:52 +0000 Subject: [PATCH] =?UTF-8?q?Replace=20OAuth=20Google=20Calendar=20sync=20wi?= =?UTF-8?q?th=20a=20one-click=20'Add=20to=20calendar'=20link=20=E2=80=94?= =?UTF-8?q?=20no=20setup=20needed,=20works=20for=20any=20Google=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 - src/app/(admin)/bookings/booking-card.tsx | 7 +- 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/components/add-to-calendar-button.tsx | 26 ++ src/components/booking-summary-card.tsx | 7 +- src/lib/bookings/calendar-link.ts | 53 +++ src/lib/bookings/service.ts | 42 +- .../migrations/0002_plain_frightful_four.sql | 2 + src/lib/db/migrations/meta/0002_snapshot.json | 396 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 22 - src/lib/google/calendar.ts | 228 ---------- 18 files changed, 499 insertions(+), 487 deletions(-) delete mode 100644 src/app/(admin)/settings/google/google-calendar-settings.tsx delete mode 100644 src/app/(admin)/settings/google/page.tsx delete mode 100644 src/app/api/google/callback/route.ts delete mode 100644 src/app/api/google/connect/route.ts delete mode 100644 src/app/api/google/disconnect/route.ts delete mode 100644 src/app/api/google/status/route.ts create mode 100644 src/components/add-to-calendar-button.tsx create mode 100644 src/lib/bookings/calendar-link.ts create mode 100644 src/lib/db/migrations/0002_plain_frightful_four.sql create mode 100644 src/lib/db/migrations/meta/0002_snapshot.json delete mode 100644 src/lib/google/calendar.ts diff --git a/.env.example b/.env.example index e300362..a87fc1d 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,3 @@ 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)/bookings/booking-card.tsx b/src/app/(admin)/bookings/booking-card.tsx index 08343ac..bd4e7e3 100644 --- a/src/app/(admin)/bookings/booking-card.tsx +++ b/src/app/(admin)/bookings/booking-card.tsx @@ -1,7 +1,9 @@ import Link from "next/link"; import { Users, Wallet } from "lucide-react"; import { CopyButton } from "@/components/copy-button"; +import { AddToCalendarButton } from "@/components/add-to-calendar-button"; import { formatBookingSummary } from "@/lib/bookings/format"; +import { buildGoogleCalendarUrl } from "@/lib/bookings/calendar-link"; import type { BookingDTO } from "@/lib/bookings/service"; function formatDate(d: Date): string { @@ -51,7 +53,10 @@ export function BookingCard({ booking }: { booking: BookingDTO }) { {booking.phone} - +
+ + +
); diff --git a/src/app/(admin)/nav-links.tsx b/src/app/(admin)/nav-links.tsx index 3dfd121..9896c88 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, CalendarDays } from "lucide-react"; +import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react"; const links = [ { href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true }, @@ -11,7 +11,6 @@ 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 deleted file mode 100644 index 037a2ef..0000000 --- a/src/app/(admin)/settings/google/google-calendar-settings.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"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 deleted file mode 100644 index 34ee91d..0000000 --- a/src/app/(admin)/settings/google/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index b040a00..0000000 --- a/src/app/api/google/callback/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index e418e41..0000000 --- a/src/app/api/google/connect/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index c5a05d1..0000000 --- a/src/app/api/google/disconnect/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 2943dd9..0000000 --- a/src/app/api/google/status/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/components/add-to-calendar-button.tsx b/src/components/add-to-calendar-button.tsx new file mode 100644 index 0000000..ac78020 --- /dev/null +++ b/src/components/add-to-calendar-button.tsx @@ -0,0 +1,26 @@ +import { CalendarPlus } from "lucide-react"; + +export function AddToCalendarButton({ + url, + size = "sm", + className = "", +}: { + url: string; + size?: "sm" | "lg"; + className?: string; +}) { + if (size === "lg") { + return ( + + + Добавить в календарь + + ); + } + + return ( + + + + ); +} diff --git a/src/components/booking-summary-card.tsx b/src/components/booking-summary-card.tsx index 10fd7a2..65d1aba 100644 --- a/src/components/booking-summary-card.tsx +++ b/src/components/booking-summary-card.tsx @@ -1,5 +1,7 @@ import { CopyButton } from "./copy-button"; +import { AddToCalendarButton } from "./add-to-calendar-button"; import { formatBookingSummary } from "@/lib/bookings/format"; +import { buildGoogleCalendarUrl } from "@/lib/bookings/calendar-link"; import type { BookingDTO } from "@/lib/bookings/service"; function formatDate(d: Date): string { @@ -44,7 +46,10 @@ export function BookingSummaryCard({ booking, title }: { booking: BookingDTO; ti

{title ?? "Бронирование"}

- +
+ + +
{ROWS.map((row) => ( diff --git a/src/lib/bookings/calendar-link.ts b/src/lib/bookings/calendar-link.ts new file mode 100644 index 0000000..bc5af4e --- /dev/null +++ b/src/lib/bookings/calendar-link.ts @@ -0,0 +1,53 @@ +import { formatBookingSummary } from "./format"; +import type { BookingDTO } from "./service"; + +// Russia abolished DST in 2014 — Moscow is a fixed UTC+3 year-round, so this +// doesn't need a timezone library or the server's own TZ setting. +const MOSCOW_UTC_OFFSET_HOURS = 3; + +function moscowLocalToUtc(dayUtcMidnight: Date, time: string): Date { + const [hh, mm] = time.split(":").map(Number); + const ms = dayUtcMidnight.getTime() + ((hh || 0) - MOSCOW_UTC_OFFSET_HOURS) * 3600_000 + (mm || 0) * 60_000; + return new Date(ms); +} + +function toGoogleDateParam(d: Date): string { + return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +/** + * Google Calendar's "quick add" URL — pre-fills a new-event form that the + * viewer saves to their own calendar with one click. No API keys, no OAuth, + * works for anyone with a Google account. https://calendar.google.com/calendar/render?action=TEMPLATE... + */ +export function buildGoogleCalendarUrl(booking: BookingDTO): string { + const start = moscowLocalToUtc(booking.date, booking.checkIn); + let end = moscowLocalToUtc(booking.date, booking.checkOut); + if (end <= start) end = new Date(end.getTime() + 24 * 3600_000); // overnight check-out + + const objectNames = booking.objects.map((o) => o.name).join(", ") || "—"; + const details = formatBookingSummary({ + bookingNumber: booking.bookingNumber, + contactName: booking.contactName, + phone: booking.phone, + objectNames: booking.objects.map((o) => o.name), + date: booking.date, + checkIn: booking.checkIn, + checkOut: booking.checkOut, + guestCount: booking.guestCount, + cost: booking.cost, + prepayment: booking.prepayment, + comment: booking.comment, + source: booking.source, + bookedByName: booking.bookedByName, + }); + + const params = new URLSearchParams({ + action: "TEMPLATE", + text: `#${booking.bookingNumber} ${objectNames} — ${booking.contactName}`, + dates: `${toGoogleDateParam(start)}/${toGoogleDateParam(end)}`, + details, + }); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} diff --git a/src/lib/bookings/service.ts b/src/lib/bookings/service.ts index 810452c..a6fb5c1 100644 --- a/src/lib/bookings/service.ts +++ b/src/lib/bookings/service.ts @@ -1,7 +1,6 @@ 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; @@ -53,7 +52,6 @@ export interface BookingDTO { status: "confirmed" | "cancelled"; objects: { id: string; name: string }[]; bookedByName: string; - googleEventId: string | null; createdAt: Date; } @@ -83,7 +81,6 @@ async function toBookingDTO(row: typeof bookings.$inferSelect): Promise ({ bookingId: row.id, objectId }))); } - 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; + return toBookingDTO(row); } export interface UpdateBookingInput { @@ -176,29 +160,7 @@ 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; - 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; + return toBookingDTO(row); } export async function getBooking(id: string): Promise { diff --git a/src/lib/db/migrations/0002_plain_frightful_four.sql b/src/lib/db/migrations/0002_plain_frightful_four.sql new file mode 100644 index 0000000..42e168a --- /dev/null +++ b/src/lib/db/migrations/0002_plain_frightful_four.sql @@ -0,0 +1,2 @@ +DROP TABLE `google_calendar_connection`;--> statement-breakpoint +ALTER TABLE `bookings` DROP COLUMN `google_event_id`; \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0002_snapshot.json b/src/lib/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..228a14a --- /dev/null +++ b/src/lib/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,396 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "046ad45a-9948-42e4-930f-7dd5278f1d04", + "prevId": "faf632a0-2681-4a8a-9631-e35ad1bb25e2", + "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'" + }, + "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": {} + }, + "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 18c3cd2..7dcc0b0 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1788003717768, "tag": "0001_simple_invisible_woman", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1788004461991, + "tag": "0002_plain_frightful_four", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index f829747..a3537dc 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -78,32 +78,10 @@ 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 deleted file mode 100644 index 60e1fba..0000000 --- a/src/lib/google/calendar.ts +++ /dev/null @@ -1,228 +0,0 @@ -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()}`); - } -}