Replace OAuth Google Calendar sync with a one-click 'Add to calendar' link — no setup needed, works for any Google account
This commit is contained in:
1 parent
7654a7b616
commit
47b003bbe3
18 files changed
+499
-487
No files matched your search
@@ -4,12 +4,3 @@
|
|||||||
ADMIN_BOOTSTRAP_EMAIL=admin@top-sysops.ru
|
ADMIN_BOOTSTRAP_EMAIL=admin@top-sysops.ru
|
||||||
ADMIN_BOOTSTRAP_PASSWORD=
|
ADMIN_BOOTSTRAP_PASSWORD=
|
||||||
ADMIN_BOOTSTRAP_NAME=Admin
|
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=
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Users, Wallet } from "lucide-react";
|
import { Users, Wallet } from "lucide-react";
|
||||||
import { CopyButton } from "@/components/copy-button";
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { AddToCalendarButton } from "@/components/add-to-calendar-button";
|
||||||
import { formatBookingSummary } from "@/lib/bookings/format";
|
import { formatBookingSummary } from "@/lib/bookings/format";
|
||||||
|
import { buildGoogleCalendarUrl } from "@/lib/bookings/calendar-link";
|
||||||
import type { BookingDTO } from "@/lib/bookings/service";
|
import type { BookingDTO } from "@/lib/bookings/service";
|
||||||
|
|
||||||
function formatDate(d: Date): string {
|
function formatDate(d: Date): string {
|
||||||
@@ -51,7 +53,10 @@ export function BookingCard({ booking }: { booking: BookingDTO }) {
|
|||||||
<a href={`tel:${booking.phone}`} className="truncate text-xs font-medium text-accent hover:underline">
|
<a href={`tel:${booking.phone}`} className="truncate text-xs font-medium text-accent hover:underline">
|
||||||
{booking.phone}
|
{booking.phone}
|
||||||
</a>
|
</a>
|
||||||
<CopyButton text={text} label="Скопировать бронирование" />
|
<div className="flex shrink-0 gap-1">
|
||||||
|
<AddToCalendarButton url={buildGoogleCalendarUrl(booking)} />
|
||||||
|
<CopyButton text={text} label="Скопировать бронирование" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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 = [
|
const links = [
|
||||||
{ href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true },
|
{ href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true },
|
||||||
@@ -11,7 +11,6 @@ const links = [
|
|||||||
{ href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false },
|
{ href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false },
|
||||||
{ href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
|
{ href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
|
||||||
{ href: "/settings/users", label: "Сотрудники", icon: Users, 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 }) {
|
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{justConnected && (
|
|
||||||
<p className="flex items-center gap-2 rounded-md bg-success-soft px-3 py-2 text-sm font-medium text-success-soft-text">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
Google Calendar подключён.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
|
||||||
|
|
||||||
<div className="card flex flex-wrap items-center gap-4 p-5">
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
|
|
||||||
<CalendarDays size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-semibold">{status.connected ? "Подключено" : "Не подключено"}</p>
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
{status.connected
|
|
||||||
? "Календарь «Бронирования» получает событие на каждое новое бронирование."
|
|
||||||
: "Подключите Google-аккаунт, чтобы бронирования появлялись в календаре."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{status.connected ? (
|
|
||||||
<button onClick={handleDisconnect} disabled={loading} className="btn btn-ghost text-danger">
|
|
||||||
<Unlink size={15} />
|
|
||||||
Отключить
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<a href="/api/google/connect" className="btn btn-primary">
|
|
||||||
<LinkIcon size={15} />
|
|
||||||
Подключить Google Calendar
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="max-w-lg">
|
|
||||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Google Calendar</h1>
|
|
||||||
<p className="mb-6 text-sm text-text-muted">
|
|
||||||
Каждое подтверждённое бронирование появляется отдельным событием в календаре «Бронирования» на подключённом
|
|
||||||
Google-аккаунте.
|
|
||||||
</p>
|
|
||||||
<GoogleCalendarSettings status={status} justConnected={connected === "1"} error={error} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string, string>) {
|
|
||||||
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" });
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" className={`btn btn-ghost ${className}`}>
|
||||||
|
<CalendarPlus size={16} />
|
||||||
|
Добавить в календарь
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" className={`btn btn-ghost shrink-0 px-2 py-1 ${className}`} title="Добавить в календарь">
|
||||||
|
<CalendarPlus size={13} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { CopyButton } from "./copy-button";
|
import { CopyButton } from "./copy-button";
|
||||||
|
import { AddToCalendarButton } from "./add-to-calendar-button";
|
||||||
import { formatBookingSummary } from "@/lib/bookings/format";
|
import { formatBookingSummary } from "@/lib/bookings/format";
|
||||||
|
import { buildGoogleCalendarUrl } from "@/lib/bookings/calendar-link";
|
||||||
import type { BookingDTO } from "@/lib/bookings/service";
|
import type { BookingDTO } from "@/lib/bookings/service";
|
||||||
|
|
||||||
function formatDate(d: Date): string {
|
function formatDate(d: Date): string {
|
||||||
@@ -44,7 +46,10 @@ export function BookingSummaryCard({ booking, title }: { booking: BookingDTO; ti
|
|||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="font-display text-lg font-bold tracking-tight">{title ?? "Бронирование"}</h2>
|
<h2 className="font-display text-lg font-bold tracking-tight">{title ?? "Бронирование"}</h2>
|
||||||
<CopyButton text={text} label="Скопировать бронирование" size="lg" />
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<AddToCalendarButton url={buildGoogleCalendarUrl(booking)} size="lg" />
|
||||||
|
<CopyButton text={text} label="Скопировать бронирование" size="lg" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||||
{ROWS.map((row) => (
|
{ROWS.map((row) => (
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { and, eq, gte, lt, inArray, sql } from "drizzle-orm";
|
import { and, eq, gte, lt, inArray, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { bookings, bookingObjects, bookableObjects, users } from "@/lib/db/schema";
|
import { bookings, bookingObjects, bookableObjects, users } from "@/lib/db/schema";
|
||||||
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from "@/lib/google/calendar";
|
|
||||||
|
|
||||||
export interface BookableObjectDTO {
|
export interface BookableObjectDTO {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,7 +52,6 @@ export interface BookingDTO {
|
|||||||
status: "confirmed" | "cancelled";
|
status: "confirmed" | "cancelled";
|
||||||
objects: { id: string; name: string }[];
|
objects: { id: string; name: string }[];
|
||||||
bookedByName: string;
|
bookedByName: string;
|
||||||
googleEventId: string | null;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +81,6 @@ async function toBookingDTO(row: typeof bookings.$inferSelect): Promise<BookingD
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
objects: objRows,
|
objects: objRows,
|
||||||
bookedByName: bookedBy?.name ?? "—",
|
bookedByName: bookedBy?.name ?? "—",
|
||||||
googleEventId: row.googleEventId,
|
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -129,20 +126,7 @@ export async function createBooking(input: CreateBookingInput): Promise<BookingD
|
|||||||
await db.insert(bookingObjects).values(input.objectIds.map((objectId) => ({ bookingId: row.id, objectId })));
|
await db.insert(bookingObjects).values(input.objectIds.map((objectId) => ({ bookingId: row.id, objectId })));
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto = await toBookingDTO(row);
|
return 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 {
|
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) });
|
const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) });
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const dto = await toBookingDTO(row);
|
return 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<BookingDTO | null> {
|
export async function getBooking(id: string): Promise<BookingDTO | null> {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE `google_calendar_connection`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `bookings` DROP COLUMN `google_event_id`;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1788003717768,
|
"when": 1788003717768,
|
||||||
"tag": "0001_simple_invisible_woman",
|
"tag": "0001_simple_invisible_woman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788004461991,
|
||||||
|
"tag": "0002_plain_frightful_four",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -78,32 +78,10 @@ export const bookings = sqliteTable("bookings", {
|
|||||||
status: text("status", { enum: ["confirmed", "cancelled"] })
|
status: text("status", { enum: ["confirmed", "cancelled"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("confirmed"),
|
.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,
|
createdAt: timestamps.createdAt,
|
||||||
updatedAt: timestamps.updatedAt,
|
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. */
|
/** Junction table for the booking <-> bookable-object multi-select. */
|
||||||
export const bookingObjects = sqliteTable(
|
export const bookingObjects = sqliteTable(
|
||||||
"booking_objects",
|
"booking_objects",
|
||||||
|
|||||||
@@ -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<GoogleTokenResponse> {
|
|
||||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code,
|
|
||||||
client_id: requireEnv("GOOGLE_CLIENT_ID"),
|
|
||||||
client_secret: requireEnv("GOOGLE_CLIENT_SECRET"),
|
|
||||||
redirect_uri: redirectUri(),
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAccessToken(refreshToken: string): Promise<GoogleTokenResponse> {
|
|
||||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: requireEnv("GOOGLE_CLIENT_ID"),
|
|
||||||
client_secret: requireEnv("GOOGLE_CLIENT_SECRET"),
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findOrCreateBookingsCalendar(accessToken: string): Promise<string> {
|
|
||||||
const listRes = await fetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
if (!listRes.ok) throw new Error(`Calendar list failed: ${listRes.status} ${await listRes.text()}`);
|
|
||||||
const list = await listRes.json();
|
|
||||||
const existing = (list.items ?? []).find((c: { summary?: string }) => c.summary === CALENDAR_NAME);
|
|
||||||
if (existing) return existing.id;
|
|
||||||
|
|
||||||
const createRes = await fetch("https://www.googleapis.com/calendar/v3/calendars", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ summary: CALENDAR_NAME, timeZone: "Europe/Moscow" }),
|
|
||||||
});
|
|
||||||
if (!createRes.ok) throw new Error(`Calendar create failed: ${createRes.status} ${await createRes.text()}`);
|
|
||||||
const created = await createRes.json();
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Completes the OAuth flow: exchanges the code, creates/finds the "Бронирования" calendar, persists the connection. */
|
|
||||||
export async function connectGoogleCalendar(code: string, connectedByUserId: string): Promise<void> {
|
|
||||||
const tokens = await exchangeCodeForTokens(code);
|
|
||||||
if (!tokens.refresh_token) {
|
|
||||||
throw new Error(
|
|
||||||
"Google не вернул refresh_token — возможно, доступ уже был выдан ранее. Отключите приложение в " +
|
|
||||||
"myaccount.google.com/permissions и попробуйте подключить снова.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const calendarId = await findOrCreateBookingsCalendar(tokens.access_token);
|
|
||||||
|
|
||||||
// Singleton table — clear any previous row first.
|
|
||||||
await db.delete(googleCalendarConnection);
|
|
||||||
await db.insert(googleCalendarConnection).values({
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token,
|
|
||||||
accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
|
||||||
calendarId,
|
|
||||||
connectedByUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectGoogleCalendar(): Promise<void> {
|
|
||||||
await db.delete(googleCalendarConnection);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GoogleConnectionStatus {
|
|
||||||
connected: boolean;
|
|
||||||
calendarId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConnectionStatus(): Promise<GoogleConnectionStatus> {
|
|
||||||
const row = await db.query.googleCalendarConnection.findFirst();
|
|
||||||
if (!row) return { connected: false };
|
|
||||||
return { connected: true, calendarId: row.calendarId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a valid access token for the connected account, refreshing and persisting it first if it's expired. Returns null if not connected. */
|
|
||||||
async function getValidAccessToken(): Promise<{ accessToken: string; calendarId: string } | null> {
|
|
||||||
const row = await db.query.googleCalendarConnection.findFirst();
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
// Refresh a little before actual expiry to avoid a request landing right on the edge.
|
|
||||||
if (row.accessTokenExpiresAt.getTime() > Date.now() + 60_000) {
|
|
||||||
return { accessToken: row.accessToken, calendarId: row.calendarId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await refreshAccessToken(row.refreshToken);
|
|
||||||
await db
|
|
||||||
.update(googleCalendarConnection)
|
|
||||||
.set({ accessToken: tokens.access_token, accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000) })
|
|
||||||
.where(eq(googleCalendarConnection.id, row.id));
|
|
||||||
|
|
||||||
return { accessToken: tokens.access_token, calendarId: row.calendarId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function bookingToEventBody(booking: BookingDTO) {
|
|
||||||
const [h1, m1] = booking.checkIn.split(":").map(Number);
|
|
||||||
const [h2, m2] = booking.checkOut.split(":").map(Number);
|
|
||||||
const start = new Date(booking.date);
|
|
||||||
start.setHours(h1 || 0, m1 || 0, 0, 0);
|
|
||||||
const end = new Date(booking.date);
|
|
||||||
end.setHours(h2 || 0, m2 || 0, 0, 0);
|
|
||||||
if (end <= start) end.setDate(end.getDate() + 1); // overnight check-out
|
|
||||||
|
|
||||||
const objectNames = booking.objects.map((o) => o.name).join(", ") || "—";
|
|
||||||
const description = [
|
|
||||||
`Номер бронирования: ${booking.bookingNumber}`,
|
|
||||||
`Контактное лицо: ${booking.contactName}`,
|
|
||||||
`Телефон: ${booking.phone}`,
|
|
||||||
`Количество людей: ${booking.guestCount}`,
|
|
||||||
`Стоимость: ${booking.cost} ₽`,
|
|
||||||
`Предоплата: ${booking.prepayment} ₽`,
|
|
||||||
`Комментарий: ${booking.comment?.trim() || "-"}`,
|
|
||||||
`Откуда пришли: ${booking.source?.trim() || "-"}`,
|
|
||||||
`Кто забронировал: ${booking.bookedByName}`,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary: `#${booking.bookingNumber} ${objectNames} — ${booking.contactName}`,
|
|
||||||
description,
|
|
||||||
start: { dateTime: start.toISOString(), timeZone: "Europe/Moscow" },
|
|
||||||
end: { dateTime: end.toISOString(), timeZone: "Europe/Moscow" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every export below is best-effort by design — callers (the booking
|
|
||||||
// service) wrap these in try/catch so a Calendar API hiccup never blocks a
|
|
||||||
// booking write; the booking itself is always the source of truth.
|
|
||||||
|
|
||||||
export async function createCalendarEvent(booking: BookingDTO): Promise<string | null> {
|
|
||||||
const conn = await getValidAccessToken();
|
|
||||||
if (!conn) return null;
|
|
||||||
|
|
||||||
const res = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(conn.calendarId)}/events`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${conn.accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(bookingToEventBody(booking)),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Create event failed: ${res.status} ${await res.text()}`);
|
|
||||||
const created = await res.json();
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCalendarEvent(booking: BookingDTO): Promise<void> {
|
|
||||||
if (!booking.googleEventId) return;
|
|
||||||
const conn = await getValidAccessToken();
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(conn.calendarId)}/events/${encodeURIComponent(booking.googleEventId)}`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { Authorization: `Bearer ${conn.accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(bookingToEventBody(booking)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok && res.status !== 404) throw new Error(`Update event failed: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCalendarEvent(googleEventId: string): Promise<void> {
|
|
||||||
const conn = await getValidAccessToken();
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(conn.calendarId)}/events/${encodeURIComponent(googleEventId)}`,
|
|
||||||
{ method: "DELETE", headers: { Authorization: `Bearer ${conn.accessToken}` } },
|
|
||||||
);
|
|
||||||
// 410 Gone means it's already deleted on Google's side — fine either way.
|
|
||||||
if (!res.ok && res.status !== 404 && res.status !== 410) {
|
|
||||||
throw new Error(`Delete event failed: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user