Skip to content

Commit f6ed3e3

Browse files
authored
add gcal oauth permission (#554)
1 parent a73999b commit f6ed3e3

File tree

2 files changed

+254
-1
lines changed

2 files changed

+254
-1
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { h } from "@commontools/html";
2+
import { cell, derive, handler, NAME, recipe, UI } from "@commontools/builder";
3+
import { z } from "zod";
4+
5+
// Define a CalendarEvent type
6+
const CalendarEvent = z.object({
7+
id: z.string(),
8+
summary: z.string().optional(),
9+
description: z.string().optional(),
10+
start: z.string(),
11+
end: z.string(),
12+
location: z.string().optional(),
13+
eventType: z.string().optional(),
14+
});
15+
type CalendarEvent = z.infer<typeof CalendarEvent>;
16+
17+
const Auth = z.object({
18+
token: z.string(),
19+
tokenType: z.string(),
20+
scope: z.array(z.string()),
21+
expiresIn: z.number(),
22+
expiresAt: z.number(),
23+
refreshToken: z.string(),
24+
user: z.object({
25+
email: z.string(),
26+
name: z.string(),
27+
picture: z.string(),
28+
}),
29+
});
30+
type Auth = z.infer<typeof Auth>;
31+
32+
// Recipe settings now include calendarId and limit
33+
const Recipe = z
34+
.object({
35+
settings: z.object({
36+
calendarId: z
37+
.string()
38+
.default("primary")
39+
.describe("Calendar ID to fetch events from"),
40+
limit: z
41+
.number()
42+
.default(250)
43+
.describe("number of events to import"),
44+
}),
45+
})
46+
.describe("fake calendar");
47+
48+
// Updated result schema for calendar events
49+
const ResultSchema = {
50+
type: "object",
51+
properties: {
52+
events: {
53+
type: "array",
54+
items: {
55+
type: "object",
56+
properties: {
57+
id: { type: "string" },
58+
summary: { type: "string" },
59+
description: { type: "string" },
60+
start: { type: "string" },
61+
end: { type: "string" },
62+
location: { type: "string" },
63+
eventType: { type: "string" },
64+
},
65+
},
66+
},
67+
googleUpdater: { asCell: true, type: "action" },
68+
auth: {
69+
type: "object",
70+
properties: {
71+
token: { type: "string" },
72+
tokenType: { type: "string" },
73+
scope: { type: "array", items: { type: "string" } },
74+
expiresIn: { type: "number" },
75+
expiresAt: { type: "number" },
76+
refreshToken: { type: "string" },
77+
},
78+
},
79+
},
80+
};
81+
82+
// Handler to update the limit for events to import
83+
const updateLimit = handler<{ detail: { value: string } }, { limit: number }>(
84+
({ detail }, state) => {
85+
state.limit = parseInt(detail?.value ?? "10") || 0;
86+
},
87+
);
88+
89+
// Handler to update the calendar ID
90+
const updateCalendarId = handler<
91+
{ detail: { value: string } },
92+
{ calendarId: string }
93+
>(({ detail }, state) => {
94+
state.calendarId = detail?.value ?? "primary";
95+
});
96+
97+
// The updater now fetches calendar events using Fetch
98+
const calendarUpdater = handler<
99+
NonNullable<unknown>,
100+
{ events: CalendarEvent[]; auth: Auth; settings: { calendarId: string; limit: number } }
101+
>((_event, state) => {
102+
console.log("calendarUpdater!");
103+
104+
if (!state.auth.token) {
105+
console.log("no token");
106+
return;
107+
}
108+
if (state.auth.expiresAt && state.auth.expiresAt < Date.now()) {
109+
console.log("token expired at ", state.auth.expiresAt);
110+
return;
111+
}
112+
113+
// Get existing event IDs for lookup
114+
const existingEventIds = new Set((state.events || []).map((event) => event.id));
115+
console.log("existing event ids", existingEventIds);
116+
117+
fetchCalendar(
118+
state.auth.token,
119+
state.settings.limit,
120+
state.settings.calendarId,
121+
existingEventIds,
122+
).then((result) => {
123+
// Filter out any duplicates by ID
124+
const newEvents = result.items.filter((event) => !existingEventIds.has(event.id));
125+
if (newEvents.length > 0) {
126+
console.log(`Adding ${newEvents.length} new events`);
127+
state.events.push(...newEvents);
128+
} else {
129+
console.log("No new events found");
130+
}
131+
});
132+
});
133+
134+
// Helper function to fetch calendar events using the Google Calendar API
135+
export async function fetchCalendar(
136+
accessToken: string,
137+
maxResults: number = 250,
138+
calendarId: string = "primary",
139+
existingEventIds: Set<string>,
140+
) {
141+
// Get current date in ISO format for timeMin parameter
142+
const now = new Date().toISOString();
143+
144+
const listResponse = await fetch(
145+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
146+
calendarId,
147+
)}/events?maxResults=${maxResults}&timeMin=${encodeURIComponent(now)}&singleEvents=true&orderBy=startTime`,
148+
{
149+
headers: {
150+
Authorization: `Bearer ${accessToken}`,
151+
},
152+
},
153+
);
154+
155+
const listData = await listResponse.json();
156+
157+
if (!listData.items || !Array.isArray(listData.items)) {
158+
return { items: [] };
159+
}
160+
161+
const events = listData.items
162+
.filter((event: { id: string }) => !existingEventIds.has(event.id))
163+
.map((event: any) => ({
164+
id: event.id,
165+
summary: event.summary || "",
166+
description: event.description || "",
167+
start: event.start
168+
? event.start.dateTime || event.start.date || ""
169+
: "",
170+
end: event.end ? event.end.dateTime || event.end.date || "" : "",
171+
location: event.location || "",
172+
eventType: event.eventType || "",
173+
}));
174+
175+
return { items: events };
176+
}
177+
178+
// Export the recipe, wiring up state cells, UI and the updater
179+
export default recipe(Recipe, ResultSchema, ({ settings }) => {
180+
const auth = cell<Auth>({
181+
token: "",
182+
tokenType: "",
183+
scope: [],
184+
expiresIn: 0,
185+
expiresAt: 0,
186+
refreshToken: "",
187+
user: {
188+
email: "",
189+
name: "",
190+
picture: "",
191+
},
192+
});
193+
194+
const events = cell<CalendarEvent[]>([]);
195+
196+
derive(events, (events) => {
197+
console.log("events", events.length);
198+
});
199+
200+
return {
201+
[NAME]: "calendar importer",
202+
[UI]: (
203+
<div>
204+
<h1>Calendar Importer</h1>
205+
<common-hstack>
206+
<label>Import Limit</label>
207+
<common-input
208+
value={settings.limit}
209+
placeholder="count of events to import"
210+
oncommon-input={updateLimit({ limit: settings.limit })}
211+
/>
212+
</common-hstack>
213+
<common-hstack>
214+
<label>Calendar ID</label>
215+
<common-input
216+
value={settings.calendarId}
217+
placeholder="Calendar ID (e.g. primary)"
218+
oncommon-input={updateCalendarId({ calendarId: settings.calendarId })}
219+
/>
220+
</common-hstack>
221+
<common-google-oauth $authCell={auth} auth={auth} />
222+
<div>
223+
<table>
224+
<thead>
225+
<tr>
226+
<th>Start</th>
227+
<th>End</th>
228+
<th>Summary</th>
229+
<th>Location</th>
230+
<th>Type</th>
231+
</tr>
232+
</thead>
233+
<tbody>
234+
{events.map((event) => (
235+
<tr>
236+
<td>&nbsp;{event.start}&nbsp;</td>
237+
<td>&nbsp;{event.end}&nbsp;</td>
238+
<td>&nbsp;{event.summary}&nbsp;</td>
239+
<td>&nbsp;{event.location}&nbsp;</td>
240+
<td>&nbsp;{event.eventType}&nbsp;</td>
241+
</tr>
242+
))}
243+
</tbody>
244+
</table>
245+
</div>
246+
</div>
247+
),
248+
events,
249+
auth,
250+
googleUpdater: calendarUpdater({ events, auth, settings }),
251+
};
252+
});

typescript/packages/toolshed/routes/integrations/google-oauth/google-oauth.utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export const createOAuthClient = (redirectUri: string) => {
6060
authorizationEndpointUri: "https://accounts.google.com/o/oauth2/v2/auth",
6161
redirectUri,
6262
defaults: {
63-
scope: "email profile https://www.googleapis.com/auth/gmail.readonly",
63+
scope:
64+
"email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly",
6465
},
6566
});
6667
};

0 commit comments

Comments
 (0)