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> {