Перейти к основному содержимому

DHTMLX Scheduler: Двусторонняя синхронизация с Google Calendar (Node.js)

В этом руководстве вы реализуете двустороннюю синхронизацию между DHTMLX Scheduler и Google Calendar в небольшом приложении на Node.js:

  • Загрузка календарей и событий из Google Calendar в Scheduler
  • Отправка операций создания/обновления/удаления Scheduler обратно в Google Calendar
примечание

Этот подход реализует двустороннюю синхронизацию через вызовы API (Scheduler → backend → Google Calendar). Он не реализует обновления в реальном времени Google → Scheduler (webhooks). Если вы напрямую изменяете события в Google Calendar, обновите приложение (или обновите диапазон дат), чтобы увидеть обновлённые данные в Scheduler.

Вы создадите:

  • бэкенд на Node.js + Express с Google OAuth 2.0 (Passport) и небольшим REST API для Scheduler
  • слой сопоставления событий (Google ↔ Scheduler), включая базовую обработку повторяющихся событий/исключений
  • клиента Scheduler, подключённого к бэкенду через scheduler.createDataProcessor()
примечание

Полный исходный код доступен на GitHub.

Требования​

  • Node.js 18+
  • Учётная запись Google с доступом к Google Cloud Console
  • Базовые знания TypeScript и Express
  • Доступ к пакетам DHTMLX Scheduler (в примере используется @dhx/trial-scheduler)

Демонстрационный репозиторий​

Полный рабочий проект, соответствующий данному руководству, доступен на GitHub:

Руководство объясняет ключевые шаги и демонстрирует важный код интеграции. Репозиторий является «полной рабочей ссылкой».

Настройка проекта​

В этом разделе вы подготовите учетные данные Google OAuth, сконфигурируете проект и запустите приложение локально.

1) Получение кода проекта​

Выполните одно из следующих действий:

  • Клонировать репозиторий:
Terminal
git clone https://github.com/dhtmlx/scheduler-google-calendar-demo.git
cd scheduler-google-calendar-demo

Если ваш проект устанавливает пакеты @dhx/* из частного реестра, настройте npm:

Terminal
npm config set @dhx:registry https://npm.dhtmlx.com

2) Настройка Google Cloud (OAuth 2.0)​

На этом шаге вы создадите учётные данные OAuth, которые бэкенд будет использовать для доступа к Google Calendar от имени пользователя.

Руководство использует OAuth в режиме тестирования (рекомендуется для разработки). В этом режиме авторизовать приложение могут только пользователи, перечисленные как тестовые.

2.1 Создать или выбрать проект Google Cloud​

  1. Открыть Google Cloud Console.
  2. Выбрать существующий проект или создать новый.

2.2 Включить Google Calendar API​

  1. Перейти в APIs & Services → Library.
  2. Найти Google Calendar API.
  3. Нажать Enable.

2.3 Настроить экран согласия OAuth​

  1. Перейти в APIs & Services → OAuth consent screen.
  2. Выбрать External (типично для потребительских учётных записей Google), затем нажать Create.
  3. Заполнить обязательные поля:
    • App name
    • User support email
    • Developer contact email
  4. Установить Publishing status в Testing.
  5. Добавить Test users:
    • Добавить учётные записи Google, которыми вы будете входить при разработке/тестировании.
примечание

Если пропустить тестовых пользователей в режиме Testing, Google заблокирует авторизацию для учётных записей, не включённых явно в список.

2.4 Создать OAuth-клиентские креденциалы​

  1. Перейти в APIs & Services → Credentials.
  2. Нажать Create credentials → OAuth client ID.
  3. Тип приложения: Web application.
  4. Добавить этот Authorized JavaScript origin:
JavaScript origin
http://localhost:3000
  1. Добавить этот Authorized redirect URI:
Redirect URI
http://localhost:3000/auth/google/callback
  1. Сохранить и скопировать:
    • Client ID
    • Client Secret

2.5 Область доступа, используемая этой интеграцией​

Бэкенд запрашивает доступ к Google Calendar через:

  • https://www.googleapis.com/auth/calendar

Эта область доступа достаточна для перечисления календарей и выполнения операций CRUD над событиями.

3) Настройка переменных окружения​

На этом этапе вы предоставите креденциалы OAuth и настройки сессий бэкенду.

Скопируйте .env.example в .env, затем заполните значения:

.env
GOOGLE_CLIENT_ID=<Client ID из предыдущих шагов>
GOOGLE_CLIENT_SECRET=<Client Secret из предыдущих шагов>
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
SESSION_SECRET=some-long-random-string
PORT=3000

4) Установка зависимостей и запуск​

Terminal
npm install
npm run start

Откройте:

App URL
http://localhost:3000

На этом этапе вы сможете нажать Add Google Calendars, войти в систему и увидеть Scheduler, заполненный событиями.


Реализация​

Оставшаяся часть руководства объясняет, как именно собирается интеграция. Если вы адаптируете это к уже существующему приложению, считайте каждый раздел ниже как этап реализации.

Шаг 1 - Разделение обязанностей (backend vs client)​

На этом шаге вы разделите ответственности, чтобы Scheduler оставался UI‑компонентом, а бэкенд владел OAuth + вызовами Google API.

Типичная структура:

Project structure
scheduler-google-calendar-demo/
client/
index.ejs
main.ts
styles.css
server/
config/
index.ts
passport.ts
routes/
auth.route.ts
events.route.ts
services/
googleService.ts
mappers/
eventMapper.ts
rollup.config.js
package.json
.env.example
  • server/: OAuth, хранение токенов в сессии, вызовы Google Calendar API и REST‑концевые точки для Scheduler
  • client/: инициализация Scheduler + загрузка, и DataProcessor для перенаправления CRUD‑действий на сервер

Шаг 2 - Реализация Google OAuth (Express session + Passport)​

На этом шаге вы сделаете бэкенд способным аутентифицировать пользователя и хранить токены доступа/обновления Google.

2.1 Подключение сервера (сессии + passport)​

Обновите server/index.ts, чтобы включить сессии и passport, затем примонтируйте маршруты.

Ниже приведено ядро подключения (показываются только релевантные части):

server/index.ts
app.use(
session({
secret: config.SESSION_SECRET || "fallback-secret-for-dev",
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
sameSite: "lax",
},
})
);

app.use(passport.initialize());
app.use(passport.session());

app.use("/events", (req, res, next) => {
req.isAuthenticated() ? next() : res.status(401).json({ error: "Not authenticated" });
}, eventsRoute);
app.use("/auth", authRoute);

app.get("/", (req, res) => {
res.render("index", { googleAuth: req.isAuthenticated() });
});

Inline‑middleware на /events гарантирует, что неавторизованные запросы получают ответ 401, вместо того чтобы падать сервер при попытке привести req к AuthenticatedRequest.

2.2 Настройка стратегии Google​

Обновите server/config/passport.ts, чтобы зарегистрировать passport-google-oauth20.

Ключевая идея: хранить accessToken и refreshToken в объекте пользователя, сохраняемом в сессии:

server/config/passport.ts
passport.serializeUser((user: Express.User, done) => {
done(null, user);
});

passport.deserializeUser((obj: Express.User, done) => {
done(null, obj);
});

passport.use(
new GoogleStrategy(
{
clientID: config.GOOGLE_CLIENT_ID || "",
clientSecret: config.GOOGLE_CLIENT_SECRET || "",
callbackURL: config.GOOGLE_REDIRECT_URI || "",
},
(accessToken, refreshToken, profile, done) => {
const user = {
id: profile.id,
displayName: profile.displayName,
tokens: { accessToken, refreshToken },
};
done(null, user as unknown as Express.User);
}
)
);
примечание

Обычно продакшн‑приложения сохраняют токены по пользователю в базе данных и реализуют обновление/ревокацию refresh‑токенов. В этом примере токены хранятся в сессии, чтобы упростить процесс.

2.3 Добавить маршруты OAuth​

Обновите server/routes/auth.route.ts, чтобы открыть точку входа OAuth, коллбек и выход из системы:

server/routes/auth.route.ts
router.get(
"/google",
passport.authenticate("google", {
scope: ["profile", "email", "https://www.googleapis.com/auth/calendar"],
accessType: "offline",
prompt: "consent",
})
);

router.get(
"/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
(_req, res) => res.redirect("/")
);

router.get("/google/logout", (req, res, next) => {
req.logout((err) => (err ? next(err) : res.redirect("/")));
});

На этом этапе вы должны суметь открыть /auth/google, пройти консент‑экран Google и вернуться к / с авторизованной сессией.

Создать сервис Google Calendar​

Создайте server/services/googleService.ts для обёртывания CRUD‑методов Google Calendar API v3. Он создаёт OAuth2‑клиента по токенам сессии и предоставляет помощники для перечисления календарей, перечисления событий и создания/обновления/удаления событий:

server/services/googleService.ts
import { google, calendar_v3 } from "googleapis";
import type { GoogleOAuthTokens } from "../types/auth.types.ts";
import config from "../config/index.ts";

const calendarClient = google.calendar("v3");

function oauthClient(tokens: GoogleOAuthTokens) {
const client = new google.auth.OAuth2(
config.GOOGLE_CLIENT_ID,
config.GOOGLE_CLIENT_SECRET,
config.GOOGLE_REDIRECT_URI
);
client.setCredentials({
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
});
return client;
}

/* ------ CRUD helpers ------- */

export async function listCalendars(tokens: GoogleOAuthTokens): Promise<calendar_v3.Schema$CalendarListEntry[]> {
const { data } = await calendarClient.calendarList.list({ auth: oauthClient(tokens) });
return data.items ?? [];
}

export async function listEvents(
tokens: GoogleOAuthTokens,
opts: calendar_v3.Params$Resource$Events$List
): Promise<calendar_v3.Schema$Event[]> {
const { data } = await calendarClient.events.list({
auth: oauthClient(tokens),
...opts,
});
return data.items ?? [];
}

export async function createEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
gEvent: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.insert({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
requestBody: gEvent,
conferenceDataVersion: 1,
});
return data;
}

export async function updateEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
eventId: string,
gPatch: calendar_v3.Schema$Event
): Promise<calendar_v3.Schema$Event> {
const { data } = await calendarClient.events.patch({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
eventId,
requestBody: gPatch,
});
return data;
}

export async function deleteEvent(
tokens: GoogleOAuthTokens,
calendarId: string | undefined,
eventId: string
): Promise<void> {
await calendarClient.events.delete({
auth: oauthClient(tokens),
calendarId: calendarId || "primary",
eventId,
});
}

Шаг 3 - Экспорт REST API для CRUD Scheduler​

На этом шаге вы реализуете контракт API, который использует Scheduler:

  • GET /events — загрузить календари + события
  • POST /events — создать
  • PUT /events/:eventId — обновить
  • DELETE /events/:eventId — удалить

3.1 Загрузка календарей + событий (GET /events)​

Обновите server/routes/events.route.ts, чтобы возвращать:

  • data с событиями в формате Scheduler
  • collections.calendars с списком календарей, который будет доступен на клиенте

Ниже приведён рабочий обработчик:

server/routes/events.route.ts
router.get("/", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;

try {
const calendars = await googleService.listCalendars(authedReq.user.tokens);

const mappedCals = calendars
.filter((calendar) => Boolean(calendar.id))
.map((calendar) => ({
id: calendar.id as string,
key: calendar.id as string,
label: calendar.summary ?? "",
backgroundColor: calendar.backgroundColor ?? undefined,
}));

const fromQuery = typeof req.query.from === "string" ? req.query.from : undefined;
const toQuery = typeof req.query.to === "string" ? req.query.to : undefined;

const minDate = fromQuery ? new Date(fromQuery).toISOString() : new Date().toISOString();
const maxDate = toQuery ? new Date(toQuery).toISOString() : undefined;

const googleEvents = await Promise.all(
mappedCals.map(async (calendar) => {
const params: calendar_v3.Params$Resource$Events$List = { calendarId: calendar.id, timeMin: minDate };
if (maxDate) params.timeMax = maxDate;

const calendarEventsResponse = await googleService.listEvents(authedReq.user.tokens, params);

return (calendarEventsResponse as Array<Record<string, unknown>>).map((event) =>
toDhxEvent(event as calendar_v3.Schema$Event, calendar)
);
})
);

res.json({
success: true,
data: googleEvents.flat(),
collections: { calendars: mappedCals },
});
} catch (error) {
next(error);
}
});

Связанные документы: Loading data.

3.2 Перенаправление CRUD‑операций в Google Calendar​

Обновите тот же модуль маршрутов, чтобы обрабатывать создание/обновление/удаление.

Создание:

server/routes/events.route.ts
router.post("/", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
const calendarId = (req.body as DhxEvent)?.calendarId as string | undefined;

try {
const gEvent = await googleService.createEvent(
authedReq.user.tokens,
calendarId,
toGoogleEventPayload(req.body)
);

res.status(201).json({ action: "inserted", tid: gEvent.id });
} catch (error) {
next(error);
}
});

Обновление:

server/routes/events.route.ts
router.put("/:eventId", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
const calendarId = (req.body as DhxEvent)?.calendarId as string | undefined;

try {
const gEvent = await googleService.updateEvent(
authedReq.user.tokens,
calendarId,
req.params.eventId as string,
toGoogleEventPayload(req.body)
);

res.json({ action: "updated", tid: gEvent.id });
} catch (error) {
next(error);
}
});

Удаление:

server/routes/events.route.ts
router.delete("/:eventId", async (req, res, next) => {
const authedReq = req as AuthenticatedRequest;
const calendarId = (req.body as DhxEvent)?.calendarId as string | undefined;

// Если это исключение повторного события (id содержит "_"), удалить на стороне Google ничего не нужно.
// Google Calendar удаляетOccurrence при удалении основного повторяющегося события.
const dhxId = req.body?.id as string | undefined;
if (typeof dhxId === "string" && dhxId.indexOf("_") > -1) {
res.json({ action: "deleted" });
return;
}

try {
await googleService.deleteEvent(authedReq.user.tokens, calendarId, req.params.eventId as string);
res.json({ action: "deleted" });
} catch (error) {
next(error);
}
});

На данный момент Scheduler может загружать /events, а базовые операции CRUD можно реализовать на клиенте.

Шаг 4 - Соответствие Google‑событий событиям Scheduler (и обратно)​

На этом шаге вы реализуете маппер, который преобразует между:

  • полями Google‑события (start.dateTime / start.date, recurrence, и т. д.)
  • полями события Scheduler (start_date, end_date, rrule, и т. д.)

Основные различия, которые нужно учесть​

  1. Целодневные vs события с указанием времени
  • Google: целодневные используют start.date / end.date
  • Google: события с временем используют start.dateTime / end.dateTime и могут содержать timeZone
  • Scheduler: использует start_date / end_date (объекты Date)
  1. Правила повторения
  • Google хранит повторение в виде массива строк с префиксом RRULE:
  • Scheduler использует rrule без префикса
  1. Конец серии повторений
  • Scheduler ожидает end_date для повторяющихся серий.
  • Google может использовать UNTIL= в RRULE, или вовсе не указывать UNTIL (бесконечная серия).

Связанные документы: Recurring events.

Google → Scheduler​

Обновите server/mappers/eventMapper.ts, чтобы преобразовать форму события Google в форму события Scheduler (см. фрагмент ниже; сохраняйте вспомогательные функции, такие как calculateEndDate(), в том же модуле):

server/mappers/eventMapper.ts
import moment from "moment-timezone";
import type { DhxEvent, MappedCalendar } from "../types/types.ts";
import type { calendar_v3 } from "googleapis";

export function toDhxEvent(gEvent: calendar_v3.Schema$Event, calendar: MappedCalendar): DhxEvent {
const ev: DhxEvent = {
id: gEvent.id as string,
text: gEvent.summary ?? "",
details: gEvent.description ?? "",
calendarId: calendar.id,
calendarLabel: calendar.label,
timeZone: gEvent.start?.timeZone,
recurring_event_id: gEvent.recurringEventId,
status: gEvent.status,
deleted: gEvent.status === "cancelled",
};

const start = gEvent.start;
const end = gEvent.end;

// Non-recurring
if (start?.dateTime && end?.dateTime && !gEvent.recurrence?.length) {
ev.start_date = new Date(start.dateTime);
ev.end_date = new Date(end.dateTime);
} else if (start?.date && end?.date && !gEvent.recurrence?.length) {
ev.start_date = new Date(start.date + "T00:00:00");
ev.end_date = new Date(end.date + "T00:00:00");
}

// Recurring
if (gEvent.recurrence?.length) {
ev.rrule = String(gEvent.recurrence[0]).replace("RRULE:", "");

if (start?.dateTime && end?.dateTime) {
ev.start_date = new Date(start.dateTime);
ev.duration = (new Date(end.dateTime).getTime() - ev.start_date.getTime()) / 1000;
} else if (start?.date && end?.date) {
ev.start_date = new Date(start.date + "T00:00:00");
ev.duration = (new Date(end.date).getTime() - ev.start_date.getTime()) / 1000;
}

ev.end_date = calculateEndDate(gEvent);
}

// Исключения: исходное время начала
if (gEvent.originalStartTime?.dateTime) {
ev.original_start = new Date(gEvent.originalStartTime.dateTime);
}

return ev;
}

helper calculateEndDate()​

Извлекает дату UNTIL из правила повторения Google или возвращает дату далеко в будущем для бесконечных серий. Scheduler ожидает end_date у повторяющихся событий, поэтому этот хелпер возвращает её:

server/mappers/eventMapper.ts
// convert UNTIL=20260129T205959Z -> '2026-01-29T20:59:59Z' если существует
// если UNTIL нет -> повторение бесконечно -> вернуть '9999-02-01T00:00:00Z'
function calculateEndDate(gEvent: calendar_v3.Schema$Event): Date {
const until = String(gEvent.recurrence?.[0] ?? "").match(/RRULE:.*?UNTIL=([^;]+)/)?.[1];

return until
? new Date(
until.replace(/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z$/, "$1-$2-$3T$4:$5:$6Z")
)
: new Date(9999, 1, 1);
}

Scheduler → Google​

Обновите сопоставитель, чтобы преобразовать поля Scheduler обратно в схему Google:

server/mappers/eventMapper.ts
export function toGoogleEventPayload(dhx: DhxEvent): calendar_v3.Schema$Event {
const gEvent: calendar_v3.Schema$Event = {
summary: dhx.text,
description: dhx.details ?? "",
};

const tz = dhx.timeZone || "UTC";

if (dhx.start_date && dhx.end_date) {
const isAllDay =
dhx.duration === 24 * 60 * 60 ||
new Date(dhx.end_date).getTime() - new Date(dhx.start_date).getTime() === 24 * 60 * 60 * 1000;

// Recurring
if (dhx.rrule && dhx.duration) {
gEvent.recurrence = ["RRULE:" + dhx.rrule];

if (isAllDay) {
gEvent.start = { date: moment(dhx.start_date).format("YYYY-MM-DD") };
gEvent.end = { date: moment(dhx.start_date).add(1, "day").format("YYYY-MM-DD") };
} else {
gEvent.start = {
dateTime: moment(new Date(dhx.start_date).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};

const endDate = new Date(new Date(dhx.start_date).getTime() + dhx.duration * 1000).toISOString();
gEvent.end = {
dateTime: moment(endDate).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
}
} else {
// Non-recurring
if (isAllDay) {
gEvent.start = { date: moment(dhx.start_date).format("YYYY-MM-DD") };
gEvent.end = { date: moment(dhx.start_date).add(1, "day").format("YYYY-MM-DD") };
} else {
gEvent.start = {
dateTime: moment(new Date(dhx.start_date).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
gEvent.end = {
dateTime: moment(new Date(dhx.end_date).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
}
}

// Поддержка исключений повторяющихся событий
if (dhx.recurring_event_id) {
gEvent.recurringEventId = dhx.recurring_event_id.toString();
}
if (dhx.original_start) {
gEvent.originalStartTime = {
dateTime: moment(new Date(dhx.original_start).toISOString()).tz(tz).format("YYYY-MM-DDTHH:mm:ssZZ"),
timeZone: tz,
};
}
if (dhx.deleted) {
gEvent.status = "cancelled";
}
}

return gEvent;
}

Шаг 5 - Подключение Scheduler к бэкенду (загрузка + CRUD)​

На этом шаге вы инициализируете Scheduler, загрузите данные через GET /events и отправляете операции CRUD на бэкенд через DataProcessor.

5.1 Отображение другого UI для "авторизован/не авторизован"​

Обновите client/index.ejs, чтобы передать клиенту флаг авторизации:

client/index.ejs
<script>
const GOOGLE_AUTHORIZED = <%= !!googleAuth %>;
</script>

5.2 Инициализация Scheduler и загрузка событий​

Обновите client/main.ts, чтобы инициализировать Scheduler и загрузить данные после авторизации пользователя.

Только релевантная часть:

client/main.ts
scheduler.plugins({ recurring: true });
scheduler.config.header = ["day", "week", "month", "date", "prev", "today", "next"];

scheduler.init("scheduler_here", new Date(), "week");
scheduler.setLoadMode("week");

if (GOOGLE_AUTHORIZED) {
scheduler.load("/events");
} else {
window.alert("You must authorize Google Calendar to use this app.");
}

5.3 Включение двусторонней синхронизации через DataProcessor​

Обновите client/main.ts, чтобы пересылать CRUD‑действия Scheduler на сервер.

client/main.ts
scheduler.createDataProcessor(async (entity, action, data, id) => {
const calendars = scheduler.serverList("calendars") as MappedCalendar[];

// Упрощение демо: отправляем всё в первый доступный календарь.
// В реальном приложении позвольте пользователю выбрать целевой календарь.
data.calendarId = calendars[0]?.id;

// Предоставьте клиентский часовой пояс, чтобы сервер мог корректно генерировать значения dateTime.
data.timeZone = momentTz.tz.guess();

return fetchEvent(action, data, id);
});

async function fetchEvent(action, data, id) {
const requestConfigs = {
create: ["POST", "events/", data],
update: ["PUT", `events/${id}`, data],
delete: ["DELETE", `events/${id}`, data],
};

const [method, url, payload] = requestConfigs[action];

const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json;charset=utf-8" },
body: JSON.stringify(payload),
});

return response.json();
}

Связанные документы: DataProcessor.

На данный момент:

  • события из Google Calendar должны появиться в Scheduler после авторизации
  • создание/обновление/удаление в Scheduler должно обновлять Google Calendar

Устранение неполадок​

"Error 400: redirect_uri_mismatch"​

  • Причина: перенаправляющий URI в учетных данных Google Cloud не совпадает с данным URL‑адресом вашего приложения.
  • Исправление: убедитесь, что Authorized redirect URI EXACTLY такой же:
    • http://localhost:3000/auth/google/callback

"Access blocked: app has not completed the Google verification process"​

  • Причина: экран согласия не в режиме Testing или вы не добавлены в список тестовых пользователей.
  • Исправление: установите Publishing status в Testing и добавьте вашу учётную запись в Test users.

"No refresh token returned"​

  • Причина: Google может вернуть refresh token только при первом согласии пользователя для данного client ID.
  • Исправление: убедитесь, что ваш запрос аутентификации включает accessType: "offline" и prompt: "consent". Если вы уже авторизованы ранее, отзовите доступ в настройках разрешений Google Account и повторно авторизуйтесь.

Резюме​

Вы реализовали двустороннюю синхронизацию между Scheduler и Google Calendar:

  • Бэкенд аутентифицирует пользователей через Google OAuth 2.0 и хранит токены в сессии
  • Scheduler загружает календари и события через GET /events
  • CRUD‑операции Scheduler пересылаются в Google Calendar через POST/PUT/DELETE /events
  • Сопоставитель конвертируетTimed/целодневные и повторяющиеся события между Google Calendar и Scheduler

Ссылка на демонстрационный репозиторий​

Полный рабочий исходник:

Need help?
Got a question about the documentation? Reach out to our technical support team for help and guidance. For custom component solutions, visit the Services page.