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:
ogrechko committed 2026-08-29 11:56:52 +00:00
1 parent 7654a7b616
commit 47b003bbe3
18 files changed
+499 -487

No files matched your search

+53
View File
@@ -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()}`;
}
+2 -40
View File
@@ -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<BookingD
status: row.status,
objects: objRows,
bookedByName: bookedBy?.name ?? "—",
googleEventId: row.googleEventId,
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 })));
}
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<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": {}
}
}
+7
View File
@@ -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
}
]
}
-22
View File
@@ -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",
-228
View File
@@ -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()}`);
}
}