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