feat(companion): Add Google Calendar mark no-show functionality (#26085)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:*/*"] : []),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user