@timesheet/plugin-google-calendar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import { GoogleCalendarConfig, GoogleCalendarSyncInput } from '../lib/types';
2
+ import { GoogleCalendarSyncResult } from '../lib/taskSync';
3
+ export declare const handleWebhook: import("@timesheet/integration-sdk").IntegrationHandler<GoogleCalendarSyncInput, GoogleCalendarSyncResult, GoogleCalendarConfig>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleWebhook = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.handleWebhook = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Handling Google Calendar webhook', {
8
+ installationId: context.installationId
9
+ });
10
+ return await (0, taskSync_1.handleGoogleWebhook)(input, context);
11
+ });
@@ -0,0 +1,3 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { GoogleCalendarConfig } from '../lib/types';
3
+ export declare const listExternalProjects: import("@timesheet/integration-sdk").IntegrationHandler<void, ExternalEntity[], GoogleCalendarConfig>;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listExternalProjects = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const googleCalendarClient_1 = require("../lib/googleCalendarClient");
6
+ const SYSTEM = 'google-calendar';
7
+ function createClient(context) {
8
+ return new googleCalendarClient_1.GoogleCalendarClient({
9
+ getAccessToken: () => context.credentials.getAccessToken('google'),
10
+ refreshAccessToken: () => context.credentials.refreshToken('google')
11
+ });
12
+ }
13
+ exports.listExternalProjects = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
14
+ context.logger.info('Listing Google Calendar calendars', {
15
+ system: SYSTEM,
16
+ installationId: context.installationId
17
+ });
18
+ const client = createClient(context);
19
+ return await client.listCalendars();
20
+ });
@@ -0,0 +1,3 @@
1
+ import { GoogleCalendarConfig } from '../lib/types';
2
+ import { GoogleCalendarSyncResult } from '../lib/taskSync';
3
+ export declare const runFullSync: import("@timesheet/integration-sdk").IntegrationHandler<void, GoogleCalendarSyncResult, GoogleCalendarConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runFullSync = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.runFullSync = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
7
+ context.logger.info('Running Google Calendar full sync', {
8
+ installationId: context.installationId,
9
+ syncDirection: context.config?.syncDirection ?? 'bidirectional'
10
+ });
11
+ return await (0, taskSync_1.runGoogleCalendarFullSync)(context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { GoogleCalendarConfig, GoogleCalendarSyncInput } from '../lib/types';
2
+ import { GoogleCalendarSyncResult } from '../lib/taskSync';
3
+ export declare const syncTaskFromExternal: import("@timesheet/integration-sdk").IntegrationHandler<GoogleCalendarSyncInput, GoogleCalendarSyncResult, GoogleCalendarConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskFromExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTaskFromExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing task from Google Calendar payload', {
8
+ installationId: context.installationId,
9
+ externalTaskId: input?.externalTaskId
10
+ });
11
+ return await (0, taskSync_1.handleGoogleWebhook)(input, context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { GoogleCalendarConfig, GoogleCalendarSyncInput } from '../lib/types';
2
+ import { GoogleCalendarSyncResult } from '../lib/taskSync';
3
+ export declare const syncTaskToExternal: import("@timesheet/integration-sdk").IntegrationHandler<GoogleCalendarSyncInput, GoogleCalendarSyncResult, GoogleCalendarConfig>;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskToExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTaskToExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing task to Google Calendar', {
8
+ installationId: context.installationId,
9
+ taskId: input?.taskId ?? input?.item?.id,
10
+ event: input?.event
11
+ });
12
+ return await (0, taskSync_1.syncTaskToGoogleCalendar)(input, context);
13
+ });
@@ -0,0 +1,6 @@
1
+ import { GoogleCalendarConfig } from '../lib/types';
2
+ export declare const testConnection: import("@timesheet/integration-sdk").IntegrationHandler<void, {
3
+ system: string;
4
+ ok: boolean;
5
+ installationId: string;
6
+ }, GoogleCalendarConfig>;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testConnection = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const googleCalendarClient_1 = require("../lib/googleCalendarClient");
6
+ const SYSTEM = 'google-calendar';
7
+ function createClient(context) {
8
+ return new googleCalendarClient_1.GoogleCalendarClient({
9
+ getAccessToken: () => context.credentials.getAccessToken('google'),
10
+ refreshAccessToken: () => context.credentials.refreshToken('google')
11
+ });
12
+ }
13
+ exports.testConnection = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
14
+ const client = createClient(context);
15
+ const ok = await client.testConnection();
16
+ return {
17
+ system: SYSTEM,
18
+ ok,
19
+ installationId: context.installationId
20
+ };
21
+ });
@@ -0,0 +1,8 @@
1
+ export { syncTaskToExternal } from './handlers/syncTaskToExternal';
2
+ export { syncTaskFromExternal } from './handlers/syncTaskFromExternal';
3
+ export { handleWebhook } from './handlers/handleWebhook';
4
+ export { runFullSync } from './handlers/runFullSync';
5
+ export { testConnection } from './handlers/testConnection';
6
+ export { listExternalProjects } from './handlers/listExternalProjects';
7
+ export declare const PLUGIN_SYSTEM = "google-calendar";
8
+ export declare const PLUGIN_NAME = "Google Calendar Sync";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_NAME = exports.PLUGIN_SYSTEM = exports.listExternalProjects = exports.testConnection = exports.runFullSync = exports.handleWebhook = exports.syncTaskFromExternal = exports.syncTaskToExternal = void 0;
4
+ var syncTaskToExternal_1 = require("./handlers/syncTaskToExternal");
5
+ Object.defineProperty(exports, "syncTaskToExternal", { enumerable: true, get: function () { return syncTaskToExternal_1.syncTaskToExternal; } });
6
+ var syncTaskFromExternal_1 = require("./handlers/syncTaskFromExternal");
7
+ Object.defineProperty(exports, "syncTaskFromExternal", { enumerable: true, get: function () { return syncTaskFromExternal_1.syncTaskFromExternal; } });
8
+ var handleWebhook_1 = require("./handlers/handleWebhook");
9
+ Object.defineProperty(exports, "handleWebhook", { enumerable: true, get: function () { return handleWebhook_1.handleWebhook; } });
10
+ var runFullSync_1 = require("./handlers/runFullSync");
11
+ Object.defineProperty(exports, "runFullSync", { enumerable: true, get: function () { return runFullSync_1.runFullSync; } });
12
+ var testConnection_1 = require("./handlers/testConnection");
13
+ Object.defineProperty(exports, "testConnection", { enumerable: true, get: function () { return testConnection_1.testConnection; } });
14
+ var listExternalProjects_1 = require("./handlers/listExternalProjects");
15
+ Object.defineProperty(exports, "listExternalProjects", { enumerable: true, get: function () { return listExternalProjects_1.listExternalProjects; } });
16
+ exports.PLUGIN_SYSTEM = 'google-calendar';
17
+ exports.PLUGIN_NAME = 'Google Calendar Sync';
@@ -0,0 +1,21 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { GoogleCalendarEvent, GoogleCalendarEventsResponse } from './types';
3
+ interface GoogleCalendarClientOptions {
4
+ getAccessToken: () => Promise<string>;
5
+ refreshAccessToken: () => Promise<string>;
6
+ }
7
+ export declare class GoogleCalendarClient {
8
+ private readonly getAccessToken;
9
+ private readonly refreshAccessToken;
10
+ constructor(options: GoogleCalendarClientOptions);
11
+ testConnection(): Promise<boolean>;
12
+ listCalendars(): Promise<ExternalEntity[]>;
13
+ listEvents(calendarId: string, params: Record<string, string | number | boolean | undefined>): Promise<GoogleCalendarEventsResponse>;
14
+ getEvent(calendarId: string, eventId: string): Promise<GoogleCalendarEvent | null>;
15
+ createEvent(calendarId: string, payload: Record<string, unknown>): Promise<GoogleCalendarEvent>;
16
+ updateEvent(calendarId: string, eventId: string, payload: Record<string, unknown>): Promise<GoogleCalendarEvent>;
17
+ deleteEvent(calendarId: string, eventId: string): Promise<void>;
18
+ private request;
19
+ private buildUrl;
20
+ }
21
+ export {};
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GoogleCalendarClient = void 0;
4
+ class GoogleCalendarClient {
5
+ constructor(options) {
6
+ this.getAccessToken = options.getAccessToken;
7
+ this.refreshAccessToken = options.refreshAccessToken;
8
+ }
9
+ async testConnection() {
10
+ const calendars = await this.listCalendars();
11
+ return calendars.length > 0;
12
+ }
13
+ async listCalendars() {
14
+ const result = [];
15
+ let pageToken;
16
+ do {
17
+ const response = await this.request('GET', '/users/me/calendarList', {
18
+ pageToken
19
+ });
20
+ for (const item of response.items ?? []) {
21
+ if (!item?.id) {
22
+ continue;
23
+ }
24
+ result.push({
25
+ id: item.id,
26
+ name: item.summary ?? item.id,
27
+ primary: item.primary ?? false
28
+ });
29
+ }
30
+ pageToken = response.nextPageToken;
31
+ } while (pageToken);
32
+ return result;
33
+ }
34
+ async listEvents(calendarId, params) {
35
+ return this.request('GET', `/calendars/${encodeURIComponent(calendarId)}/events`, params);
36
+ }
37
+ async getEvent(calendarId, eventId) {
38
+ try {
39
+ return await this.request('GET', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`);
40
+ }
41
+ catch (error) {
42
+ if (String(error).includes('(404)')) {
43
+ return null;
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+ async createEvent(calendarId, payload) {
49
+ return this.request('POST', `/calendars/${encodeURIComponent(calendarId)}/events`, undefined, payload);
50
+ }
51
+ async updateEvent(calendarId, eventId, payload) {
52
+ return this.request('PUT', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, undefined, payload);
53
+ }
54
+ async deleteEvent(calendarId, eventId) {
55
+ await this.request('DELETE', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`);
56
+ }
57
+ async request(method, path, query, body, retried = false) {
58
+ const token = await this.getAccessToken();
59
+ const response = await fetch(this.buildUrl(path, query), {
60
+ method,
61
+ headers: {
62
+ Authorization: `Bearer ${token}`,
63
+ Accept: 'application/json',
64
+ 'Content-Type': 'application/json'
65
+ },
66
+ body: body !== undefined ? JSON.stringify(body) : undefined
67
+ });
68
+ if (response.status === 401 && !retried) {
69
+ const refreshed = await this.refreshAccessToken();
70
+ if (refreshed) {
71
+ return this.request(method, path, query, body, true);
72
+ }
73
+ }
74
+ if (!response.ok) {
75
+ const errorText = await response.text();
76
+ throw new Error(`Google Calendar API ${method} ${path} failed (${response.status}): ${errorText}`);
77
+ }
78
+ if (response.status === 204) {
79
+ return undefined;
80
+ }
81
+ return (await response.json());
82
+ }
83
+ buildUrl(path, query) {
84
+ const url = new URL(`https://www.googleapis.com/calendar/v3${path}`);
85
+ if (query) {
86
+ for (const [key, value] of Object.entries(query)) {
87
+ if (value === undefined || value === null || value === '') {
88
+ continue;
89
+ }
90
+ url.searchParams.append(key, String(value));
91
+ }
92
+ }
93
+ return url.toString();
94
+ }
95
+ }
96
+ exports.GoogleCalendarClient = GoogleCalendarClient;
@@ -0,0 +1,11 @@
1
+ import { IntegrationContext } from '@timesheet/integration-sdk';
2
+ import { GoogleCalendarConfig, GoogleCalendarSyncInput } from './types';
3
+ export interface GoogleCalendarSyncResult {
4
+ system: string;
5
+ status: string;
6
+ syncedCount: number;
7
+ details?: Record<string, unknown>;
8
+ }
9
+ export declare function syncTaskToGoogleCalendar(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
10
+ export declare function runGoogleCalendarFullSync(context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
11
+ export declare function handleGoogleWebhook(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
@@ -0,0 +1,392 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskToGoogleCalendar = syncTaskToGoogleCalendar;
4
+ exports.runGoogleCalendarFullSync = runGoogleCalendarFullSync;
5
+ exports.handleGoogleWebhook = handleGoogleWebhook;
6
+ const googleCalendarClient_1 = require("./googleCalendarClient");
7
+ const SYSTEM = 'google-calendar';
8
+ const PROJECT_ENTITY = 'project';
9
+ const TASK_ENTITY = 'task';
10
+ async function syncTaskToGoogleCalendar(input, context) {
11
+ const taskId = resolveTaskId(input);
12
+ if (!taskId) {
13
+ return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-task-id' } };
14
+ }
15
+ const task = await loadTask(taskId, input, context);
16
+ if (!task) {
17
+ return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'task-not-found' } };
18
+ }
19
+ const projectId = task.project?.id;
20
+ if (!projectId) {
21
+ return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-project' } };
22
+ }
23
+ const projectMapping = await context.mappings.get({
24
+ system: SYSTEM,
25
+ entity: PROJECT_ENTITY,
26
+ localId: projectId
27
+ });
28
+ const externalCalendarId = projectMapping?.externalId;
29
+ if (!externalCalendarId) {
30
+ return {
31
+ system: SYSTEM,
32
+ status: 'skipped',
33
+ syncedCount: 0,
34
+ details: { reason: 'missing-project-mapping', projectId }
35
+ };
36
+ }
37
+ const client = createClient(context);
38
+ const taskMapping = await context.mappings.get({
39
+ system: SYSTEM,
40
+ entity: TASK_ENTITY,
41
+ localId: task.id
42
+ });
43
+ if (task.deleted) {
44
+ if (taskMapping?.externalId) {
45
+ const mappedCalendarId = getMappedCalendarId(taskMapping) ?? externalCalendarId;
46
+ await client.deleteEvent(mappedCalendarId, taskMapping.externalId);
47
+ await context.mappings.delete({
48
+ system: SYSTEM,
49
+ entity: TASK_ENTITY,
50
+ localId: task.id
51
+ });
52
+ return { system: SYSTEM, status: 'deleted', syncedCount: 1 };
53
+ }
54
+ return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'already-deleted' } };
55
+ }
56
+ const payload = buildGoogleEventPayload(task);
57
+ let externalEvent;
58
+ if (taskMapping?.externalId) {
59
+ const mappedCalendarId = getMappedCalendarId(taskMapping) ?? externalCalendarId;
60
+ externalEvent = await client.updateEvent(mappedCalendarId, taskMapping.externalId, payload);
61
+ }
62
+ else {
63
+ const duplicate = await findEventByTimesheetId(client, externalCalendarId, task.id);
64
+ if (duplicate?.id) {
65
+ externalEvent = duplicate;
66
+ }
67
+ else {
68
+ externalEvent = await client.createEvent(externalCalendarId, payload);
69
+ }
70
+ }
71
+ if (!externalEvent?.id) {
72
+ return {
73
+ system: SYSTEM,
74
+ status: 'failed',
75
+ syncedCount: 0,
76
+ details: { reason: 'missing-external-id' }
77
+ };
78
+ }
79
+ await context.mappings.upsert({
80
+ system: SYSTEM,
81
+ entity: TASK_ENTITY,
82
+ localId: task.id,
83
+ externalId: externalEvent.id,
84
+ externalLabel: externalEvent.summary ?? task.description ?? task.id,
85
+ metadata: {
86
+ calendarId: externalCalendarId,
87
+ etag: externalEvent.etag ?? '',
88
+ updated: externalEvent.updated ?? ''
89
+ },
90
+ syncStatus: 'SYNCED'
91
+ });
92
+ return {
93
+ system: SYSTEM,
94
+ status: 'synced',
95
+ syncedCount: 1,
96
+ details: { taskId: task.id, externalTaskId: externalEvent.id, calendarId: externalCalendarId }
97
+ };
98
+ }
99
+ async function runGoogleCalendarFullSync(context) {
100
+ const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
101
+ if (projectMappings.length === 0) {
102
+ return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-project-mappings' } };
103
+ }
104
+ let syncedCount = 0;
105
+ for (const mapping of projectMappings) {
106
+ if (!mapping.externalId) {
107
+ continue;
108
+ }
109
+ const perCalendarCount = await syncCalendar(context, mapping);
110
+ syncedCount += perCalendarCount;
111
+ }
112
+ return {
113
+ system: SYSTEM,
114
+ status: 'completed',
115
+ syncedCount,
116
+ details: { calendarCount: projectMappings.length }
117
+ };
118
+ }
119
+ async function handleGoogleWebhook(input, context) {
120
+ const resourceState = getHeader(input, 'x-goog-resource-state')?.toLowerCase();
121
+ if (resourceState === 'sync') {
122
+ return {
123
+ system: SYSTEM,
124
+ status: 'acknowledged',
125
+ syncedCount: 0,
126
+ details: { resourceState }
127
+ };
128
+ }
129
+ const channelId = getHeader(input, 'x-goog-channel-id');
130
+ const allProjectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
131
+ let projectMappings = allProjectMappings;
132
+ if (channelId) {
133
+ projectMappings = allProjectMappings.filter((mapping) => {
134
+ const metadata = mapping.metadata ?? {};
135
+ const watchChannelId = readMetadataString(metadata, 'watchChannelId')
136
+ || readMetadataString(metadata, 'channelId');
137
+ return watchChannelId === channelId;
138
+ });
139
+ }
140
+ if (projectMappings.length === 0 && input.calendarId) {
141
+ projectMappings = allProjectMappings.filter((mapping) => mapping.externalId === input.calendarId);
142
+ }
143
+ if (projectMappings.length === 0) {
144
+ return {
145
+ system: SYSTEM,
146
+ status: 'ignored',
147
+ syncedCount: 0,
148
+ details: { reason: 'no-matching-calendar-mapping' }
149
+ };
150
+ }
151
+ let syncedCount = 0;
152
+ for (const mapping of projectMappings) {
153
+ syncedCount += await syncCalendar(context, mapping);
154
+ }
155
+ return {
156
+ system: SYSTEM,
157
+ status: 'completed',
158
+ syncedCount,
159
+ details: {
160
+ channelId: channelId ?? '',
161
+ mappedCalendars: projectMappings.length
162
+ }
163
+ };
164
+ }
165
+ async function syncCalendar(context, projectMapping) {
166
+ if (!projectMapping.externalId) {
167
+ return 0;
168
+ }
169
+ const client = createClient(context);
170
+ const calendarId = projectMapping.externalId;
171
+ const syncStateKey = getSyncTokenStateKey(calendarId);
172
+ const metadataSyncToken = readMetadataString(projectMapping.metadata ?? {}, 'syncToken');
173
+ let syncToken = (await context.state.get(syncStateKey)) ?? undefined;
174
+ if (!syncToken && metadataSyncToken) {
175
+ syncToken = metadataSyncToken;
176
+ }
177
+ let pageToken;
178
+ let nextSyncToken;
179
+ let syncedCount = 0;
180
+ do {
181
+ const response = await client.listEvents(calendarId, {
182
+ showDeleted: true,
183
+ singleEvents: true,
184
+ syncToken,
185
+ pageToken,
186
+ timeMin: !syncToken ? new Date(Date.now() - (365 * 24 * 60 * 60 * 1000)).toISOString() : undefined
187
+ });
188
+ for (const event of response.items ?? []) {
189
+ const synced = await syncSingleGoogleEvent(context, projectMapping, calendarId, event);
190
+ if (synced) {
191
+ syncedCount += 1;
192
+ }
193
+ }
194
+ nextSyncToken = response.nextSyncToken ?? nextSyncToken;
195
+ pageToken = response.nextPageToken;
196
+ } while (pageToken);
197
+ if (nextSyncToken) {
198
+ await context.state.set(syncStateKey, nextSyncToken);
199
+ }
200
+ return syncedCount;
201
+ }
202
+ async function syncSingleGoogleEvent(context, projectMapping, calendarId, event) {
203
+ if (!event?.id) {
204
+ return false;
205
+ }
206
+ const taskMapping = await context.mappings.findByExternal({
207
+ system: SYSTEM,
208
+ entity: TASK_ENTITY,
209
+ externalId: event.id
210
+ });
211
+ if (event.status === 'cancelled') {
212
+ if (taskMapping?.localId) {
213
+ await context.data.deleteTask(taskMapping.localId);
214
+ await context.mappings.delete({
215
+ system: SYSTEM,
216
+ entity: TASK_ENTITY,
217
+ localId: taskMapping.localId
218
+ });
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ const dateRange = toTaskDateRange(event);
224
+ if (!dateRange) {
225
+ return false;
226
+ }
227
+ const localTaskIdFromExtendedProperties = event.extendedProperties?.private?.timesheetId;
228
+ if (!taskMapping?.localId) {
229
+ if (localTaskIdFromExtendedProperties) {
230
+ return false;
231
+ }
232
+ const created = await context.data.createTask({
233
+ projectId: projectMapping.localId,
234
+ startDateTime: dateRange.startDateTime,
235
+ endDateTime: dateRange.endDateTime,
236
+ description: event.description ?? '',
237
+ location: event.location
238
+ });
239
+ await context.mappings.upsert({
240
+ system: SYSTEM,
241
+ entity: TASK_ENTITY,
242
+ localId: created.id,
243
+ externalId: event.id,
244
+ externalLabel: event.summary ?? event.id,
245
+ metadata: {
246
+ calendarId,
247
+ etag: event.etag ?? '',
248
+ updated: event.updated ?? ''
249
+ },
250
+ syncStatus: 'SYNCED'
251
+ });
252
+ return true;
253
+ }
254
+ const existing = await context.data.getTask(taskMapping.localId);
255
+ const externalUpdatedAt = event.updated ? Date.parse(event.updated) : 0;
256
+ if (existing?.lastUpdate && externalUpdatedAt > 0 && externalUpdatedAt <= existing.lastUpdate) {
257
+ return false;
258
+ }
259
+ await context.data.updateTask(taskMapping.localId, {
260
+ projectId: projectMapping.localId,
261
+ startDateTime: dateRange.startDateTime,
262
+ endDateTime: dateRange.endDateTime,
263
+ description: event.description ?? '',
264
+ location: event.location
265
+ });
266
+ await context.mappings.upsert({
267
+ system: SYSTEM,
268
+ entity: TASK_ENTITY,
269
+ localId: taskMapping.localId,
270
+ externalId: event.id,
271
+ externalLabel: event.summary ?? event.id,
272
+ metadata: {
273
+ calendarId,
274
+ etag: event.etag ?? '',
275
+ updated: event.updated ?? ''
276
+ },
277
+ syncStatus: 'SYNCED'
278
+ });
279
+ return true;
280
+ }
281
+ function toTaskDateRange(event) {
282
+ const startDateTime = event.start?.dateTime;
283
+ const endDateTime = event.end?.dateTime;
284
+ if (!startDateTime || !endDateTime) {
285
+ return null;
286
+ }
287
+ const start = new Date(startDateTime);
288
+ const end = new Date(endDateTime);
289
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
290
+ return null;
291
+ }
292
+ return {
293
+ startDateTime: start.toISOString(),
294
+ endDateTime: end.toISOString()
295
+ };
296
+ }
297
+ function buildGoogleEventPayload(task) {
298
+ const startDateTime = task.startDateTime ? new Date(task.startDateTime).toISOString() : undefined;
299
+ const endDateTime = task.endDateTime ? new Date(task.endDateTime).toISOString() : undefined;
300
+ if (!startDateTime || !endDateTime) {
301
+ throw new Error(`Task ${task.id} is missing start or end datetime.`);
302
+ }
303
+ const summary = task.project?.title
304
+ ? [task.project.title, task.project.employer].filter(Boolean).join(' - ')
305
+ : (task.description ?? 'Timesheet task');
306
+ const creatorEmail = task.member?.email;
307
+ const creatorDisplayName = task.member?.displayName;
308
+ return {
309
+ summary,
310
+ location: task.location ?? null,
311
+ description: task.description ?? '',
312
+ start: {
313
+ dateTime: startDateTime
314
+ },
315
+ end: {
316
+ dateTime: endDateTime
317
+ },
318
+ extendedProperties: {
319
+ private: {
320
+ timesheetId: task.id
321
+ }
322
+ },
323
+ creator: creatorEmail
324
+ ? {
325
+ email: creatorEmail,
326
+ displayName: creatorDisplayName
327
+ }
328
+ : undefined
329
+ };
330
+ }
331
+ async function findEventByTimesheetId(client, calendarId, taskId) {
332
+ const response = await client.listEvents(calendarId, {
333
+ privateExtendedProperty: `timesheetId=${taskId}`,
334
+ maxResults: 1,
335
+ singleEvents: true
336
+ });
337
+ const item = (response.items ?? [])[0];
338
+ return item ?? null;
339
+ }
340
+ function getMappedCalendarId(mapping) {
341
+ const metadata = mapping.metadata ?? {};
342
+ return readMetadataString(metadata, 'calendarId');
343
+ }
344
+ function getSyncTokenStateKey(calendarId) {
345
+ return `google-calendar:sync-token:${calendarId}`;
346
+ }
347
+ function createClient(context) {
348
+ return new googleCalendarClient_1.GoogleCalendarClient({
349
+ getAccessToken: () => context.credentials.getAccessToken('google'),
350
+ refreshAccessToken: () => context.credentials.refreshToken('google')
351
+ });
352
+ }
353
+ async function loadTask(taskId, input, context) {
354
+ try {
355
+ return await context.data.getTask(taskId);
356
+ }
357
+ catch {
358
+ if (input?.item && typeof input.item === 'object') {
359
+ return input.item;
360
+ }
361
+ return null;
362
+ }
363
+ }
364
+ function resolveTaskId(input) {
365
+ return input?.taskId
366
+ || input?.item?.taskId
367
+ || input?.item?.id;
368
+ }
369
+ function getHeader(input, name) {
370
+ const mergedHeaders = {
371
+ ...(input?.headers ?? {})
372
+ };
373
+ if (input?.body && typeof input.body === 'object') {
374
+ const nestedHeaders = input.body.headers;
375
+ if (nestedHeaders && typeof nestedHeaders === 'object') {
376
+ Object.assign(mergedHeaders, nestedHeaders);
377
+ }
378
+ }
379
+ const key = Object.keys(mergedHeaders).find((header) => header.toLowerCase() === name.toLowerCase());
380
+ if (!key) {
381
+ return undefined;
382
+ }
383
+ const value = mergedHeaders[key];
384
+ return value === undefined || value === null ? undefined : String(value);
385
+ }
386
+ function readMetadataString(metadata, key) {
387
+ const value = metadata[key];
388
+ if (typeof value === 'string' && value.trim().length > 0) {
389
+ return value;
390
+ }
391
+ return undefined;
392
+ }
@@ -0,0 +1,52 @@
1
+ import { TaskDto } from '@timesheet/integration-sdk';
2
+ export interface GoogleCalendarConfig {
3
+ syncDirection?: 'bidirectional' | 'timesheet-to-google' | 'google-to-timesheet' | 'timesheet-to-external' | 'external-to-timesheet';
4
+ }
5
+ export interface GoogleCalendarDateTime {
6
+ dateTime?: string;
7
+ date?: string;
8
+ timeZone?: string;
9
+ }
10
+ export interface GoogleCalendarEvent {
11
+ id: string;
12
+ status?: string;
13
+ summary?: string;
14
+ description?: string;
15
+ location?: string;
16
+ created?: string;
17
+ updated?: string;
18
+ start?: GoogleCalendarDateTime;
19
+ end?: GoogleCalendarDateTime;
20
+ etag?: string;
21
+ extendedProperties?: {
22
+ private?: Record<string, string>;
23
+ };
24
+ }
25
+ export interface GoogleCalendarEventsResponse {
26
+ items?: GoogleCalendarEvent[];
27
+ nextPageToken?: string;
28
+ nextSyncToken?: string;
29
+ }
30
+ export interface GoogleCalendarListResponse {
31
+ items?: Array<{
32
+ id: string;
33
+ summary?: string;
34
+ primary?: boolean;
35
+ }>;
36
+ nextPageToken?: string;
37
+ }
38
+ export interface GoogleCalendarSyncInput {
39
+ event?: string;
40
+ triggerId?: string;
41
+ taskId?: string;
42
+ item?: Partial<TaskDto> & {
43
+ taskId?: string;
44
+ id?: string;
45
+ };
46
+ method?: string;
47
+ headers?: Record<string, string>;
48
+ query?: Record<string, string>;
49
+ body?: unknown;
50
+ externalTaskId?: string;
51
+ calendarId?: string;
52
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/manifest.json ADDED
@@ -0,0 +1,154 @@
1
+ {
2
+ "id": "google-calendar-sync",
3
+ "name": "Google Calendar Sync",
4
+ "version": "1.0.0",
5
+ "description": "Synchronize Timesheet tasks with Google Calendar",
6
+ "longDescription": "Bidirectional synchronization between Timesheet tasks and Google Calendar events.",
7
+ "icon": "google-calendar",
8
+ "category": "calendar",
9
+ "tags": ["sync", "timesheet", "google-calendar", "calendar"],
10
+ "dataAccess": ["projects", "tasks", "colleagues", "settings"],
11
+ "externalAuth": [
12
+ {
13
+ "id": "google",
14
+ "type": "oauth2",
15
+ "oauth": {
16
+ "authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth",
17
+ "tokenUrl": "https://oauth2.googleapis.com/token",
18
+ "scopes": [
19
+ "https://www.googleapis.com/auth/calendar.readonly",
20
+ "https://www.googleapis.com/auth/calendar.events"
21
+ ]
22
+ }
23
+ }
24
+ ],
25
+ "configSchema": {
26
+ "type": "object",
27
+ "properties": {
28
+ "syncDirection": {
29
+ "type": "string",
30
+ "title": "Sync Direction",
31
+ "enum": ["bidirectional", "timesheet-to-google", "google-to-timesheet"],
32
+ "default": "bidirectional",
33
+ "x-ui-widget": "radio"
34
+ }
35
+ },
36
+ "required": ["syncDirection"]
37
+ },
38
+ "mappingSchema": {
39
+ "system": "google-calendar",
40
+ "mappings": [
41
+ {
42
+ "id": "projects",
43
+ "title": "Calendar Mapping",
44
+ "description": "Map Timesheet projects to Google Calendars",
45
+ "localEntity": {
46
+ "type": "project",
47
+ "label": "Timesheet Project",
48
+ "display": "{{title}}"
49
+ },
50
+ "externalEntity": {
51
+ "type": "calendar",
52
+ "label": "Google Calendar",
53
+ "fetchAction": "list-external-projects"
54
+ },
55
+ "required": true,
56
+ "minMappings": 1
57
+ }
58
+ ]
59
+ },
60
+ "triggers": [
61
+ {
62
+ "id": "task-changed",
63
+ "type": "event",
64
+ "name": "Task Changed",
65
+ "description": "Sync task create/update/delete events to Google Calendar",
66
+ "events": ["task.create", "task.update", "task.delete"],
67
+ "configurable": true,
68
+ "actionId": "sync-task-to-external"
69
+ },
70
+ {
71
+ "id": "integration-webhook",
72
+ "type": "webhook",
73
+ "name": "Google Calendar Webhook",
74
+ "description": "Receive inbound changes from Google Calendar",
75
+ "configurable": false,
76
+ "actionId": "handle-webhook",
77
+ "webhook": {}
78
+ },
79
+ {
80
+ "id": "full-sync",
81
+ "type": "schedule",
82
+ "name": "Scheduled Full Sync",
83
+ "description": "Run incremental synchronization for all mapped calendars",
84
+ "schedule": "0 2 * * *",
85
+ "timezone": "UTC",
86
+ "configurable": true,
87
+ "actionId": "run-full-sync"
88
+ },
89
+ {
90
+ "id": "manual-sync",
91
+ "type": "user_action",
92
+ "name": "Manual Sync",
93
+ "description": "Trigger a sync from settings",
94
+ "userAction": {
95
+ "label": "Sync Now",
96
+ "placement": "settings"
97
+ },
98
+ "actionId": "run-full-sync"
99
+ }
100
+ ],
101
+ "actions": [
102
+ {
103
+ "id": "sync-task-to-external",
104
+ "name": "Sync task to Google Calendar",
105
+ "handler": "syncTaskToExternal"
106
+ },
107
+ {
108
+ "id": "sync-task-from-external",
109
+ "name": "Sync task from Google Calendar",
110
+ "handler": "syncTaskFromExternal",
111
+ "internal": true
112
+ },
113
+ {
114
+ "id": "handle-webhook",
115
+ "handler": "handleWebhook",
116
+ "internal": true
117
+ },
118
+ {
119
+ "id": "run-full-sync",
120
+ "name": "Run full sync",
121
+ "handler": "runFullSync"
122
+ },
123
+ {
124
+ "id": "test-connection",
125
+ "name": "Test connection",
126
+ "handler": "testConnection"
127
+ },
128
+ {
129
+ "id": "list-external-projects",
130
+ "handler": "listExternalProjects",
131
+ "internal": true
132
+ }
133
+ ],
134
+ "pages": [
135
+ {
136
+ "id": "dashboard",
137
+ "title": "Sync Dashboard",
138
+ "icon": "chart-bar",
139
+ "layout": "dashboard",
140
+ "widgets": [
141
+ {
142
+ "id": "manual-sync-btn",
143
+ "type": "action_button",
144
+ "title": "Manual Sync",
145
+ "config": {
146
+ "actionId": "run-full-sync",
147
+ "label": "Run Full Sync",
148
+ "variant": "primary"
149
+ }
150
+ }
151
+ ]
152
+ }
153
+ ]
154
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@timesheet/plugin-google-calendar",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "files": [
7
+ "dist",
8
+ "manifest.json"
9
+ ],
10
+ "scripts": {
11
+ "build": "rm -rf dist && tsc -p tsconfig.json",
12
+ "typecheck": "tsc --noEmit",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "dependencies": {
16
+ "@timesheet/integration-sdk": "^0.1.0"
17
+ }
18
+ }