feat(companion): Add Google Calendar mark no-show functionality (#26085)

This commit is contained in:
Dhairyashil Shinde
2025-12-21 13:30:31 +01:00
committed by GitHub
parent aa127b2c2b
commit 067fa7e137
6 changed files with 1652 additions and 10 deletions
@@ -1,6 +1,6 @@
/// <reference types="chrome" />
import type { OAuthTokens } from "../../../services/oauthService";
import type { Booking } from "../../../services/types/bookings.types";
const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined;
const IS_DEV_MODE = Boolean(DEV_API_KEY && DEV_API_KEY.length > 0);
@@ -305,6 +305,41 @@ export default defineBackground(() => {
return true;
}
if (message.action === "check-auth-status") {
checkAuthStatus()
.then((isAuthenticated) => sendResponse({ isAuthenticated }))
.catch((error) => sendResponse({ isAuthenticated: false, error: error.message }));
return true;
}
if (message.action === "get-booking-status") {
const { bookingUid } = message.payload as { bookingUid: string };
getBookingStatus(bookingUid)
.then((result) => sendResponse({ success: true, data: result }))
.catch((error) => {
devLog.error("Get booking status failed:", error);
sendResponse({ success: false, error: error.message });
});
return true;
}
if (message.action === "mark-no-show") {
const { bookingUid, attendeeEmail, absent } = message.payload as {
bookingUid: string;
attendeeEmail: string;
absent: boolean;
};
markAttendeeNoShow(bookingUid, attendeeEmail, absent)
.then((result) => sendResponse({ success: true, data: result }))
.catch((error) => {
devLog.error("Mark no-show failed:", error);
sendResponse({ success: false, error: error.message });
});
return true;
}
return false;
});
}
@@ -582,3 +617,119 @@ async function fetchEventTypes(): Promise<unknown[]> {
return [];
}
/**
* Check if user is authenticated by verifying tokens exist in storage
*/
async function checkAuthStatus(): Promise<boolean> {
const storageAPI = getStorageAPI();
if (!storageAPI?.local) {
return false;
}
try {
const result = await storageAPI.local.get(["cal_oauth_tokens"]);
const oauthTokens = result.cal_oauth_tokens
? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
: null;
return Boolean(oauthTokens?.accessToken);
} catch (error) {
devLog.error("Failed to check auth status:", error);
return false;
}
}
/**
* Get booking status to check attendee no-show status
*/
async function getBookingStatus(bookingUid: string): Promise<Booking> {
const authHeader = await getAuthHeader();
const response = await fetch(`${API_BASE_URL}/bookings/${bookingUid}`, {
method: "GET",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"cal-api-version": "2024-08-13",
},
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = response.statusText;
try {
const errorJson = JSON.parse(errorBody);
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
} catch {
errorMessage = errorBody || response.statusText;
}
if (response.status === 401) {
throw new Error("Session expired. Please login again.");
}
if (response.status === 403) {
throw new Error("You don't have permission to view this booking.");
}
if (response.status === 404) {
throw new Error("Booking not found in Cal.com.");
}
throw new Error(`Failed to get booking status: ${errorMessage}`);
}
const data = await response.json();
return (data?.data ?? data) as Booking;
}
/**
* Mark an attendee as no-show for a booking
*/
async function markAttendeeNoShow(
bookingUid: string,
attendeeEmail: string,
absent: boolean
): Promise<Booking> {
const authHeader = await getAuthHeader();
const response = await fetch(`${API_BASE_URL}/bookings/${bookingUid}/mark-absent`, {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"cal-api-version": "2024-08-13",
},
body: JSON.stringify({
attendees: [{ email: attendeeEmail, absent }],
}),
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = response.statusText;
try {
const errorJson = JSON.parse(errorBody);
errorMessage = errorJson?.error?.message || errorJson?.message || response.statusText;
} catch {
errorMessage = errorBody || response.statusText;
}
if (response.status === 401) {
throw new Error("Session expired. Please login again.");
}
if (response.status === 403) {
throw new Error("You don't have permission to modify this booking.");
}
if (response.status === 404) {
throw new Error("Booking not found in Cal.com.");
}
throw new Error(`Failed to mark no-show: ${errorMessage}`);
}
const data = await response.json();
return (data?.data ?? data) as Booking;
}
+53 -9
View File
@@ -1,4 +1,5 @@
/// <reference types="chrome" />
import { initGoogleCalendarIntegration } from "../lib/google-calendar";
export default defineContentScript({
matches: ["<all_urls>"],
@@ -20,6 +21,18 @@ export default defineContentScript({
}
}
// Initialize Google Calendar integration if on Google Calendar
// Wrapped in try-catch to prevent breaking Google Calendar if anything fails
if (window.location.hostname === "calendar.google.com") {
try {
initGoogleCalendarIntegration();
console.log("Cal.com: Google Calendar integration initialized successfully");
} catch (error) {
// Fail silently - don't break Google Calendar UI
console.error("Cal.com: Failed to initialize Google Calendar integration:", error);
}
}
const sessionToken = generateSecureToken();
let iframeSessionValidated = false;
@@ -545,6 +558,15 @@ export default defineContentScript({
}
});
// Listen for custom event to open sidebar (from Google Calendar/Gmail integrations)
window.addEventListener("cal-companion-open-sidebar", () => {
if (isClosed) {
openSidebar();
} else if (!isVisible) {
openSidebar();
}
});
// Auto-open sidebar when redirected from restricted pages (like new tab)
// Detects ?openExtension=true parameter on cal.com/app or companion.cal.com
const urlParams = new URLSearchParams(window.location.search);
@@ -908,7 +930,11 @@ export default defineContentScript({
">
${length}min
</span>
${description ? `<span style="color: #5f6368; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;">${description}</span>` : ""}
${
description
? `<span style="color: #5f6368; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;">${description}</span>`
: ""
}
</div>
`;
@@ -1004,7 +1030,9 @@ export default defineContentScript({
previewBtn.addEventListener("click", (e) => {
e.stopPropagation();
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
const bookingUrl = `https://cal.com/${
eventType.users?.[0]?.username || "user"
}/${eventType.slug}`;
window.open(bookingUrl, "_blank");
});
previewBtn.addEventListener("mouseenter", () => {
@@ -1044,7 +1072,9 @@ export default defineContentScript({
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
// Copy to clipboard
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
const bookingUrl = `https://cal.com/${
eventType.users?.[0]?.username || "user"
}/${eventType.slug}`;
navigator.clipboard
.writeText(bookingUrl)
.then(() => {
@@ -1205,7 +1235,9 @@ export default defineContentScript({
function insertEventTypeLink(eventType) {
// Construct the Cal.com booking link
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${
eventType.slug
}`;
// Try to insert at cursor position in the compose field
const inserted = insertTextAtCursor(bookingUrl);
@@ -1227,7 +1259,9 @@ export default defineContentScript({
function copyEventTypeLink(eventType) {
// Construct the Cal.com booking link
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${eventType.slug}`;
const bookingUrl = `https://cal.com/${eventType.users?.[0]?.username || "user"}/${
eventType.slug
}`;
// Try to insert at cursor position in the compose field
const inserted = insertTextAtCursor(bookingUrl);
@@ -1501,7 +1535,9 @@ export default defineContentScript({
</div>
${datesHTML}
<div style="margin-top: 13px;">
<a href="https://cal.com/${username}/${eventType.slug}?cal.tz=${encodeURIComponent(timezone)}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;">
<a href="https://cal.com/${username}/${eventType.slug}?cal.tz=${encodeURIComponent(
timezone
)}" style="text-decoration: none; cursor: pointer; color: #0B57D0; font-size: 14px;">
See all available times →
</a>
</div>
@@ -1869,7 +1905,9 @@ export default defineContentScript({
}
console.log(
`Cal.com: ✅ Google chip detected - ${parsedData.slots.length} slot${parsedData.slots.length > 1 ? "s" : ""} (${parsedData.detectedDuration}min)`
`Cal.com: ✅ Google chip detected - ${parsedData.slots.length} slot${
parsedData.slots.length > 1 ? "s" : ""
} (${parsedData.detectedDuration}min)`
);
// Safely check for parent element
@@ -2417,7 +2455,11 @@ export default defineContentScript({
header.innerHTML = `
<div>
<div style="font-weight: 600; font-size: 16px; color: #000;">📅 Suggest Cal.com Links</div>
<div style="font-size: 13px; color: #666; margin-top: 4px;">${parsedData.slots.length} time slot${parsedData.slots.length > 1 ? "s" : ""}${parsedData.detectedDuration}min each</div>
<div style="font-size: 13px; color: #666; margin-top: 4px;">${
parsedData.slots.length
} time slot${parsedData.slots.length > 1 ? "s" : ""}${
parsedData.detectedDuration
}min each</div>
</div>
<button class="close-menu" style="background: none; border: none; cursor: pointer; font-size: 28px; color: #666; line-height: 1; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.2s ease;">×</button>
`;
@@ -2669,7 +2711,9 @@ export default defineContentScript({
color: #000;
cursor: pointer;
transition: background-color 0.1s;
border-bottom: ${index < matchingEventTypes.length - 1 ? "1px solid #f0f0f0" : "none"};
border-bottom: ${
index < matchingEventTypes.length - 1 ? "1px solid #f0f0f0" : "none"
};
pointer-events: auto;
`;
option.textContent = `${et.title} (${et.lengthInMinutes}min)`;
File diff suppressed because it is too large Load Diff
+42
View File
@@ -16,6 +16,8 @@ import type {
PrivateLink,
CreatePrivateLinkInput,
UpdatePrivateLinkInput,
MarkAbsentRequest,
MarkAbsentResponse,
} from "./types";
const API_BASE_URL = "https://api.cal.com/v2";
@@ -465,6 +467,46 @@ export class CalComAPIService {
}
}
/**
* Mark an attendee as absent (no-show) for a booking
* @param bookingUid - The unique identifier of the booking
* @param attendeeEmail - The email of the attendee to mark as absent
* @param absent - Whether to mark as absent (true) or undo (false)
*/
static async markAbsent(
bookingUid: string,
attendeeEmail: string,
absent: boolean = true
): Promise<Booking> {
try {
const body: MarkAbsentRequest = {
attendees: [{ email: attendeeEmail, absent }],
};
const response = await this.makeRequest<MarkAbsentResponse>(
`/bookings/${bookingUid}/mark-absent`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"cal-api-version": "2024-08-13",
},
body: JSON.stringify(body),
},
"2024-08-13"
);
if (response && response.data) {
return response.data;
}
throw new Error("Invalid response from mark absent API");
} catch (error) {
console.error("markAbsent error:", error);
throw error;
}
}
static async getEventTypes(): Promise<EventType[]> {
try {
// Get current user to extract username
@@ -62,3 +62,18 @@ export interface BookingParticipationResult {
isAttendee: boolean;
isParticipating: boolean;
}
// Mark No Show / Absent Types
export interface MarkAbsentAttendee {
email: string;
absent: boolean;
}
export interface MarkAbsentRequest {
attendees: MarkAbsentAttendee[];
}
export interface MarkAbsentResponse {
status: "success" | "error";
data: Booking;
}
+1
View File
@@ -66,6 +66,7 @@ export default defineConfig({
"https://api.cal.com/*",
"https://app.cal.com/*",
"https://mail.google.com/*",
"https://calendar.google.com/*",
// Include localhost permission for dev builds (needed for iframe to load)
...(!isBuildForStore ? ["http://localhost:*/*"] : []),
],