@agent-native/core 0.192.0 → 0.192.1-nightly-20260926122223
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.
- package/corpus/README.md +1 -1
- package/corpus/templates/calendar/actions/cancel-booking.ts +12 -4
- package/corpus/templates/calendar/actions/list-bookings.ts +23 -2
- package/corpus/templates/calendar/actions/list-events.ts +42 -29
- package/corpus/templates/calendar/actions/update-booking-link.ts +27 -1
- package/corpus/templates/calendar/app/hooks/use-bookings.ts +9 -2
- package/corpus/templates/calendar/app/i18n/en-US.ts +8 -0
- package/corpus/templates/calendar/app/lib/booking-sorting.ts +7 -0
- package/corpus/templates/calendar/app/lib/event-colors.ts +29 -4
- package/corpus/templates/calendar/app/pages/BookingsList.tsx +99 -19
- package/corpus/templates/calendar/app/pages/CalendarView.tsx +5 -11
- package/corpus/templates/calendar/app/pages/ManageBookingPage.tsx +5 -0
- package/corpus/templates/calendar/changelog/2026-09-25-ambiguous-zoom-bookings-need-review.md +6 -0
- package/corpus/templates/calendar/changelog/2026-09-25-bookings-list-shows-newest-bookings-first.md +6 -0
- package/corpus/templates/calendar/changelog/2026-09-25-canceling-a-booking-now-resolves-its-zoom-meeting-first.md +6 -0
- package/corpus/templates/calendar/changelog/2026-09-25-colleague-events-use-sidebar-colors.md +6 -0
- package/corpus/templates/calendar/changelog/2026-09-25-public-booking-pages-reject-malformed-meeting-settings-befor.md +6 -0
- package/corpus/templates/calendar/changelog/2026-09-25-shared-booking-cancellations-require-editor-access-and-unres.md +6 -0
- package/corpus/templates/calendar/server/db/schema.ts +5 -0
- package/corpus/templates/calendar/server/handlers/bookings.ts +142 -26
- package/corpus/templates/calendar/server/lib/booking-link-utils.ts +46 -1
- package/corpus/templates/calendar/server/lib/zoom.ts +66 -1
- package/corpus/templates/calendar/server/plugins/db.ts +13 -0
- package/corpus/templates/calendar/shared/api.ts +4 -0
- package/corpus/templates/clips/app/components/capture-install-options.tsx +9 -6
- package/corpus/templates/clips/app/components/clips-command-menu.tsx +4 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +5 -4
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +4 -0
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -30
- package/corpus/templates/clips/changelog/2026-09-26-desktop-platform-icon.md +6 -0
- package/corpus/templates/clips/changelog/2026-09-26-the-empty-comments-state-now-explains-how-screen-recordings-.md +6 -0
- package/corpus/templates/design/app/routes/design-systems.tsx +2 -4
- package/corpus/templates/design/app/routes/design.$id.tsx +2 -1
- package/corpus/templates/design/app/routes/public-design-meta.ts +0 -39
- package/corpus/templates/design/app/routes/templates.tsx +2 -4
- package/corpus/templates/design/app/routes/visual-edit_.$id.tsx +2 -1
- package/corpus/templates/design/changelog/2026-09-26-opening-shared-designs-no-longer-fails-during-client-startup.md +6 -0
- package/corpus/templates/design/package.json +1 -1
- package/corpus/templates/design/scripts/check-client-server-boundary.ts +55 -0
- package/corpus/templates/design/server/lib/public-design-meta.server.ts +40 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/client/settings/UsageSection.js +18 -5
- package/dist/client/settings/recent-prompt-groups.d.ts +12 -0
- package/dist/client/settings/recent-prompt-groups.js +20 -0
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts +1 -1
- package/dist/sharing/actions/list-resource-shares.d.ts +1 -1
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { getOrgContext, orgMembers } from "@agent-native/core/org";
|
|
|
3
3
|
import {
|
|
4
4
|
getSession,
|
|
5
5
|
getAppProductionUrl,
|
|
6
|
+
getRequestContext,
|
|
6
7
|
recordChange,
|
|
7
8
|
readBody,
|
|
8
9
|
runWithRequestContext,
|
|
@@ -29,7 +30,6 @@ import type {
|
|
|
29
30
|
Booking,
|
|
30
31
|
CalendarEvent,
|
|
31
32
|
AvailabilityConfig,
|
|
32
|
-
ConferencingConfig,
|
|
33
33
|
CustomField,
|
|
34
34
|
TimeSlot,
|
|
35
35
|
} from "../../shared/api.js";
|
|
@@ -55,12 +55,18 @@ import {
|
|
|
55
55
|
import {
|
|
56
56
|
getBookingLinkCoHostEmails,
|
|
57
57
|
getBookingLinkRequiredHostEmails,
|
|
58
|
+
isBookingLinkHost,
|
|
58
59
|
normalizeBookingHosts,
|
|
60
|
+
parseBookingConferencingConfig,
|
|
59
61
|
} from "../lib/booking-link-utils.js";
|
|
60
62
|
import { getOwnerBookingTimeZone } from "../lib/booking-timezone.js";
|
|
61
63
|
import { eventBlocksAvailability } from "../lib/calendar-availability.js";
|
|
62
64
|
import * as googleCalendar from "../lib/google-calendar.js";
|
|
63
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
createZoomMeeting,
|
|
67
|
+
deleteZoomMeeting,
|
|
68
|
+
needsZoomCancellationReview,
|
|
69
|
+
} from "../lib/zoom.js";
|
|
64
70
|
import { getBookingUsernameOwner } from "./booking-usernames.js";
|
|
65
71
|
|
|
66
72
|
async function requireRequestContext<T>(
|
|
@@ -89,16 +95,23 @@ async function getBookingLinkSlugsForOwners(
|
|
|
89
95
|
return Array.from(new Set(rows.map((row) => row.slug)));
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
async function
|
|
93
|
-
slug: string,
|
|
94
|
-
): Promise<string | undefined> {
|
|
98
|
+
async function getBookingLinkDetails(slug: string) {
|
|
95
99
|
if (!slug) return undefined;
|
|
96
|
-
|
|
97
|
-
.select({
|
|
100
|
+
return getDb()
|
|
101
|
+
.select({
|
|
102
|
+
ownerEmail: schema.bookingLinks.ownerEmail,
|
|
103
|
+
hosts: schema.bookingLinks.hosts,
|
|
104
|
+
conferencing: schema.bookingLinks.conferencing,
|
|
105
|
+
})
|
|
98
106
|
.from(schema.bookingLinks)
|
|
99
107
|
.where(eq(schema.bookingLinks.slug, slug))
|
|
100
108
|
.then((rows) => rows[0]);
|
|
101
|
-
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function getBookingLinkOwnerEmail(
|
|
112
|
+
slug: string,
|
|
113
|
+
): Promise<string | undefined> {
|
|
114
|
+
return (await getBookingLinkDetails(slug))?.ownerEmail;
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
function stripCrlf(value: unknown): string {
|
|
@@ -1387,6 +1400,20 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1387
1400
|
}
|
|
1388
1401
|
|
|
1389
1402
|
// Check for conflicts + insert atomically in a transaction
|
|
1403
|
+
const parsedConferencing = parseBookingConferencingConfig(
|
|
1404
|
+
link?.conferencing,
|
|
1405
|
+
);
|
|
1406
|
+
if (parsedConferencing.status === "invalid") {
|
|
1407
|
+
setResponseStatus(event, 422);
|
|
1408
|
+
return {
|
|
1409
|
+
error: "Failed to create booking",
|
|
1410
|
+
code: "invalid_conferencing_config",
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
const conferencing =
|
|
1414
|
+
parsedConferencing.status === "valid"
|
|
1415
|
+
? parsedConferencing.config
|
|
1416
|
+
: undefined;
|
|
1390
1417
|
const db = getDb();
|
|
1391
1418
|
const insertResult = await db.transaction(async (tx) => {
|
|
1392
1419
|
if (viewer) {
|
|
@@ -1469,6 +1496,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1469
1496
|
? JSON.stringify(fieldResponses)
|
|
1470
1497
|
: null,
|
|
1471
1498
|
cancelToken,
|
|
1499
|
+
zoomNeedsReview: conferencing?.type === "zoom",
|
|
1472
1500
|
status: "confirmed",
|
|
1473
1501
|
createdAt: now,
|
|
1474
1502
|
ownerEmail: hostEmail,
|
|
@@ -1486,17 +1514,12 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1486
1514
|
return { error: "This time slot is no longer available" };
|
|
1487
1515
|
}
|
|
1488
1516
|
|
|
1489
|
-
// Resolve conferencing config
|
|
1490
|
-
let conferencing: ConferencingConfig | undefined;
|
|
1491
|
-
if (link?.conferencing) {
|
|
1492
|
-
try {
|
|
1493
|
-
conferencing = JSON.parse(link.conferencing);
|
|
1494
|
-
} catch {}
|
|
1495
|
-
}
|
|
1496
1517
|
let meetingLink: string | undefined;
|
|
1497
1518
|
let googleEventId: string | undefined;
|
|
1498
1519
|
let calendarAccountId: string | undefined;
|
|
1499
1520
|
let meetingLinkPending = false;
|
|
1521
|
+
let zoomMeetingId: string | undefined;
|
|
1522
|
+
let zoomAccountId: string | undefined;
|
|
1500
1523
|
|
|
1501
1524
|
// For custom-URL conferencing, use the static URL — only http(s).
|
|
1502
1525
|
if (conferencing?.type === "custom" && conferencing.url) {
|
|
@@ -1536,6 +1559,8 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1536
1559
|
throw new Error("Zoom meeting was not created");
|
|
1537
1560
|
}
|
|
1538
1561
|
meetingLink = zoomResult.meetingUrl;
|
|
1562
|
+
zoomMeetingId = zoomResult.meetingId;
|
|
1563
|
+
zoomAccountId = zoomResult.accountId;
|
|
1539
1564
|
} catch (error) {
|
|
1540
1565
|
console.error(
|
|
1541
1566
|
`[bookings] Failed to create Zoom meeting for ${hostEmail}:`,
|
|
@@ -1543,8 +1568,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1543
1568
|
);
|
|
1544
1569
|
// Zoom may have created the meeting even if its response was lost or
|
|
1545
1570
|
// unreadable, so keep the booking to reserve the slot and prevent a
|
|
1546
|
-
// retry from silently creating a duplicate meeting.
|
|
1547
|
-
// independent calendar write before returning the provider error.
|
|
1571
|
+
// retry from silently creating a duplicate meeting.
|
|
1548
1572
|
meetingLinkPending = true;
|
|
1549
1573
|
}
|
|
1550
1574
|
}
|
|
@@ -1634,10 +1658,23 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1634
1658
|
meetingLink?: string;
|
|
1635
1659
|
googleEventId?: string;
|
|
1636
1660
|
calendarAccountId?: string;
|
|
1661
|
+
zoomNeedsReview?: boolean;
|
|
1662
|
+
zoomMeetingId?: string;
|
|
1663
|
+
zoomAccountId?: string;
|
|
1637
1664
|
meetingLinkPending: boolean;
|
|
1638
1665
|
} = { meetingLinkPending };
|
|
1639
1666
|
if (meetingLink) providerUpdates.meetingLink = meetingLink;
|
|
1640
1667
|
if (googleEventId) providerUpdates.googleEventId = googleEventId;
|
|
1668
|
+
if (zoomMeetingId) providerUpdates.zoomMeetingId = zoomMeetingId;
|
|
1669
|
+
if (zoomAccountId) providerUpdates.zoomAccountId = zoomAccountId;
|
|
1670
|
+
if (
|
|
1671
|
+
conferencing?.type === "zoom" &&
|
|
1672
|
+
meetingLink &&
|
|
1673
|
+
zoomMeetingId &&
|
|
1674
|
+
zoomAccountId
|
|
1675
|
+
) {
|
|
1676
|
+
providerUpdates.zoomNeedsReview = false;
|
|
1677
|
+
}
|
|
1641
1678
|
if (googleEventId && calendarAccountId) {
|
|
1642
1679
|
providerUpdates.calendarAccountId = calendarAccountId;
|
|
1643
1680
|
}
|
|
@@ -1664,6 +1701,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
|
|
|
1664
1701
|
...(meetingLinkPending ? { meetingLinkPending: true } : {}),
|
|
1665
1702
|
googleEventId,
|
|
1666
1703
|
cancelToken,
|
|
1704
|
+
zoomNeedsReview: conferencing?.type === "zoom" && meetingLinkPending,
|
|
1667
1705
|
status: "confirmed",
|
|
1668
1706
|
createdAt: now,
|
|
1669
1707
|
};
|
|
@@ -1897,6 +1935,7 @@ export const getAvailableSlots = defineEventHandler(async (event: H3Event) => {
|
|
|
1897
1935
|
export async function cancelBookingById(
|
|
1898
1936
|
id: string,
|
|
1899
1937
|
origin = getAppProductionUrl(),
|
|
1938
|
+
options: { zoomMeetingResolved?: boolean } = {},
|
|
1900
1939
|
) {
|
|
1901
1940
|
if (!id)
|
|
1902
1941
|
throw createError({ statusCode: 400, statusMessage: "id is required" });
|
|
@@ -1914,11 +1953,23 @@ export async function cancelBookingById(
|
|
|
1914
1953
|
|
|
1915
1954
|
// Bookings have no ownerEmail of their own — scope through the booking link.
|
|
1916
1955
|
const accessibleLinks = await db
|
|
1917
|
-
.select({
|
|
1956
|
+
.select({
|
|
1957
|
+
slug: schema.bookingLinks.slug,
|
|
1958
|
+
ownerEmail: schema.bookingLinks.ownerEmail,
|
|
1959
|
+
hosts: schema.bookingLinks.hosts,
|
|
1960
|
+
conferencing: schema.bookingLinks.conferencing,
|
|
1961
|
+
})
|
|
1918
1962
|
.from(schema.bookingLinks)
|
|
1919
|
-
.where(
|
|
1920
|
-
|
|
1921
|
-
|
|
1963
|
+
.where(
|
|
1964
|
+
accessFilter(
|
|
1965
|
+
schema.bookingLinks,
|
|
1966
|
+
schema.bookingLinkShares,
|
|
1967
|
+
undefined,
|
|
1968
|
+
"editor",
|
|
1969
|
+
),
|
|
1970
|
+
);
|
|
1971
|
+
const link = accessibleLinks.find((item) => item.slug === existing.slug);
|
|
1972
|
+
if (!link) {
|
|
1922
1973
|
throw createError({ statusCode: 403, statusMessage: "Access denied" });
|
|
1923
1974
|
}
|
|
1924
1975
|
|
|
@@ -1926,7 +1977,35 @@ export async function cancelBookingById(
|
|
|
1926
1977
|
return { success: true, alreadyCancelled: true };
|
|
1927
1978
|
}
|
|
1928
1979
|
|
|
1929
|
-
|
|
1980
|
+
if (
|
|
1981
|
+
options.zoomMeetingResolved === true &&
|
|
1982
|
+
!isBookingLinkHost(link, getRequestContext()?.userEmail)
|
|
1983
|
+
) {
|
|
1984
|
+
throw createError({ statusCode: 403, statusMessage: "Access denied" });
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
if (
|
|
1988
|
+
needsZoomCancellationReview({
|
|
1989
|
+
...existing,
|
|
1990
|
+
conferencing: link.conferencing,
|
|
1991
|
+
}) &&
|
|
1992
|
+
options.zoomMeetingResolved !== true
|
|
1993
|
+
) {
|
|
1994
|
+
throw createError({
|
|
1995
|
+
statusCode: 409,
|
|
1996
|
+
statusMessage:
|
|
1997
|
+
"Check the Zoom meeting and resolve it before canceling this booking",
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
if (existing.zoomMeetingId && existing.zoomAccountId) {
|
|
2002
|
+
await deleteZoomMeeting({
|
|
2003
|
+
accountId: existing.zoomAccountId,
|
|
2004
|
+
meetingId: existing.zoomMeetingId,
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
const hostEmail = link.ownerEmail;
|
|
1930
2009
|
const bookingTimeZone = await getOwnerBookingTimeZone(hostEmail);
|
|
1931
2010
|
const bookAgainUrl = existing.slug
|
|
1932
2011
|
? `${origin}/book/${existing.slug}`
|
|
@@ -1940,7 +2019,7 @@ export async function cancelBookingById(
|
|
|
1940
2019
|
await deleteGoogleEventForBooking({ booking: existing, hostEmail });
|
|
1941
2020
|
await db
|
|
1942
2021
|
.update(schema.bookings)
|
|
1943
|
-
.set({ status: "cancelled" })
|
|
2022
|
+
.set({ status: "cancelled", zoomNeedsReview: false })
|
|
1944
2023
|
.where(eq(schema.bookings.id, id));
|
|
1945
2024
|
recordBookingsChanged(hostEmail);
|
|
1946
2025
|
return { success: true };
|
|
@@ -1949,9 +2028,19 @@ export async function cancelBookingById(
|
|
|
1949
2028
|
export const deleteBooking = defineEventHandler(async (event: H3Event) => {
|
|
1950
2029
|
return requireRequestContext(event, async () => {
|
|
1951
2030
|
try {
|
|
2031
|
+
const body = await readBody(event);
|
|
2032
|
+
const parsed = z
|
|
2033
|
+
.object({ zoomMeetingResolved: z.boolean().optional() })
|
|
2034
|
+
.strict()
|
|
2035
|
+
.safeParse(body ?? {});
|
|
2036
|
+
if (!parsed.success) {
|
|
2037
|
+
setResponseStatus(event, 400);
|
|
2038
|
+
return { error: "Invalid cancellation options" };
|
|
2039
|
+
}
|
|
1952
2040
|
return await cancelBookingById(
|
|
1953
2041
|
getRouterParam(event, "id") as string,
|
|
1954
2042
|
getRequestURL(event).origin,
|
|
2043
|
+
parsed.data,
|
|
1955
2044
|
);
|
|
1956
2045
|
} catch (error: any) {
|
|
1957
2046
|
setResponseStatus(event, error?.statusCode ?? 500);
|
|
@@ -1982,6 +2071,7 @@ export const getBookingByToken = defineEventHandler(async (event: H3Event) => {
|
|
|
1982
2071
|
|
|
1983
2072
|
// Return limited info — don't expose internal IDs
|
|
1984
2073
|
const booking = rowToBooking(row);
|
|
2074
|
+
const link = await getBookingLinkDetails(row.slug);
|
|
1985
2075
|
return {
|
|
1986
2076
|
eventTitle: booking.eventTitle,
|
|
1987
2077
|
name: booking.name,
|
|
@@ -1989,6 +2079,10 @@ export const getBookingByToken = defineEventHandler(async (event: H3Event) => {
|
|
|
1989
2079
|
end: booking.end,
|
|
1990
2080
|
slug: booking.slug,
|
|
1991
2081
|
meetingLink: booking.meetingLink,
|
|
2082
|
+
zoomCancellationNeedsReview: needsZoomCancellationReview({
|
|
2083
|
+
...row,
|
|
2084
|
+
conferencing: link?.conferencing,
|
|
2085
|
+
}),
|
|
1992
2086
|
meetingLinkPending: booking.meetingLinkPending,
|
|
1993
2087
|
status: booking.status,
|
|
1994
2088
|
};
|
|
@@ -2024,12 +2118,34 @@ export const cancelBookingByToken = defineEventHandler(
|
|
|
2024
2118
|
return { success: true, alreadyCancelled: true };
|
|
2025
2119
|
}
|
|
2026
2120
|
|
|
2121
|
+
const link = await getBookingLinkDetails(row.slug);
|
|
2122
|
+
if (
|
|
2123
|
+
needsZoomCancellationReview({
|
|
2124
|
+
...row,
|
|
2125
|
+
conferencing: link?.conferencing,
|
|
2126
|
+
})
|
|
2127
|
+
) {
|
|
2128
|
+
setResponseStatus(event, 409);
|
|
2129
|
+
return {
|
|
2130
|
+
error:
|
|
2131
|
+
"The organizer must review the Zoom meeting before this booking can be canceled",
|
|
2132
|
+
code: "zoom_meeting_review_required",
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
if (row.zoomMeetingId && row.zoomAccountId) {
|
|
2137
|
+
await deleteZoomMeeting({
|
|
2138
|
+
accountId: row.zoomAccountId,
|
|
2139
|
+
meetingId: row.zoomMeetingId,
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2027
2143
|
await db
|
|
2028
2144
|
.update(schema.bookings)
|
|
2029
|
-
.set({ status: "cancelled" })
|
|
2145
|
+
.set({ status: "cancelled", zoomNeedsReview: false })
|
|
2030
2146
|
.where(eq(schema.bookings.id, row.id));
|
|
2031
2147
|
|
|
2032
|
-
const hostEmail =
|
|
2148
|
+
const hostEmail = link?.ownerEmail;
|
|
2033
2149
|
const bookingTimeZone = await getOwnerBookingTimeZone(hostEmail);
|
|
2034
2150
|
const reqUrl = getRequestURL(event);
|
|
2035
2151
|
const bookAgainUrl = row.slug
|
|
@@ -2046,7 +2162,7 @@ export const cancelBookingByToken = defineEventHandler(
|
|
|
2046
2162
|
|
|
2047
2163
|
return { success: true, slug: row.slug };
|
|
2048
2164
|
} catch (error: any) {
|
|
2049
|
-
setResponseStatus(event, 500);
|
|
2165
|
+
setResponseStatus(event, error?.statusCode ?? 500);
|
|
2050
2166
|
return { error: error.message };
|
|
2051
2167
|
}
|
|
2052
2168
|
},
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
BookingHost,
|
|
5
|
+
BookingLink,
|
|
6
|
+
ConferencingConfig,
|
|
7
|
+
} from "../../shared/api.js";
|
|
2
8
|
import { schema } from "../db/index.js";
|
|
3
9
|
|
|
4
10
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
11
|
+
const httpUrlSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.url()
|
|
14
|
+
.refine((value) => /^https?:\/\//i.test(value));
|
|
5
15
|
|
|
6
16
|
function stripCrlf(value: unknown): string {
|
|
7
17
|
return (
|
|
@@ -29,6 +39,33 @@ function parseJson<T>(value: string | null, fallback: T): T {
|
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
const conferencingConfigSchema = z.discriminatedUnion("type", [
|
|
43
|
+
z.object({ type: z.literal("none"), url: z.string().optional() }),
|
|
44
|
+
z.object({ type: z.literal("google_meet"), url: z.string().optional() }),
|
|
45
|
+
z.object({ type: z.literal("zoom"), url: z.string().optional() }),
|
|
46
|
+
z.object({
|
|
47
|
+
type: z.literal("custom"),
|
|
48
|
+
url: httpUrlSchema,
|
|
49
|
+
}),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export function parseBookingConferencingConfig(
|
|
53
|
+
value: string | null | undefined,
|
|
54
|
+
):
|
|
55
|
+
| { status: "absent" }
|
|
56
|
+
| { status: "invalid" }
|
|
57
|
+
| { status: "valid"; config: ConferencingConfig } {
|
|
58
|
+
if (value == null) return { status: "absent" };
|
|
59
|
+
try {
|
|
60
|
+
const parsed = conferencingConfigSchema.safeParse(JSON.parse(value));
|
|
61
|
+
return parsed.success
|
|
62
|
+
? { status: "valid", config: parsed.data }
|
|
63
|
+
: { status: "invalid" };
|
|
64
|
+
} catch {
|
|
65
|
+
return { status: "invalid" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
export function normalizeBookingHosts(
|
|
33
70
|
input: unknown,
|
|
34
71
|
ownerEmail?: string | null,
|
|
@@ -100,6 +137,14 @@ export function getBookingLinkRequiredHostEmails(
|
|
|
100
137
|
return emails;
|
|
101
138
|
}
|
|
102
139
|
|
|
140
|
+
export function isBookingLinkHost(
|
|
141
|
+
row: Pick<typeof schema.bookingLinks.$inferSelect, "hosts" | "ownerEmail">,
|
|
142
|
+
userEmail: unknown,
|
|
143
|
+
): boolean {
|
|
144
|
+
const email = normalizeBookingHostEmail(userEmail);
|
|
145
|
+
return !!email && getBookingLinkRequiredHostEmails(row).includes(email);
|
|
146
|
+
}
|
|
147
|
+
|
|
103
148
|
export function rowToBookingLink(
|
|
104
149
|
row: typeof schema.bookingLinks.$inferSelect,
|
|
105
150
|
): BookingLink {
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
*/
|
|
25
25
|
import { nanoid } from "nanoid";
|
|
26
26
|
|
|
27
|
+
import { parseBookingConferencingConfig } from "./booking-link-utils.js";
|
|
28
|
+
|
|
27
29
|
const PROVIDER = "zoom_video";
|
|
28
30
|
const SCOPES = [
|
|
29
31
|
"meeting:write:meeting",
|
|
@@ -56,6 +58,51 @@ export function getZoomAuthUrl(redirectUri: string, state: string) {
|
|
|
56
58
|
return `https://zoom.us/oauth/authorize?${params}`;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
function createProvider(creds: { clientId: string; clientSecret: string }) {
|
|
62
|
+
return createZoomProvider({
|
|
63
|
+
clientId: creds.clientId,
|
|
64
|
+
clientSecret: creds.clientSecret,
|
|
65
|
+
getAccessToken: (credentialId) => resolveAccessToken(credentialId),
|
|
66
|
+
updateTokens: async (credentialId, tokens) => {
|
|
67
|
+
const existing = await getOAuthTokens(PROVIDER, credentialId);
|
|
68
|
+
await saveOAuthTokens(PROVIDER, credentialId, {
|
|
69
|
+
...existing,
|
|
70
|
+
accessToken: tokens.accessToken,
|
|
71
|
+
refreshToken: tokens.refreshToken ?? (existing as any)?.refreshToken,
|
|
72
|
+
expiresAt: tokens.expiresAt?.getTime(),
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function needsZoomCancellationReview(booking: {
|
|
79
|
+
zoomNeedsReview?: boolean;
|
|
80
|
+
meetingLink?: string | null;
|
|
81
|
+
zoomMeetingId?: string | null;
|
|
82
|
+
zoomAccountId?: string | null;
|
|
83
|
+
conferencing?: string | null;
|
|
84
|
+
status?: string;
|
|
85
|
+
}): boolean {
|
|
86
|
+
if (booking.status === "cancelled") return false;
|
|
87
|
+
if (booking.zoomMeetingId && booking.zoomAccountId) return false;
|
|
88
|
+
if (booking.zoomNeedsReview) return true;
|
|
89
|
+
const conferencing = parseBookingConferencingConfig(booking.conferencing);
|
|
90
|
+
if (conferencing.status === "invalid") return true;
|
|
91
|
+
if (conferencing.status === "valid" && conferencing.config.type === "zoom") {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (!booking.meetingLink) return false;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const hostname = new URL(booking.meetingLink).hostname.toLowerCase();
|
|
98
|
+
return ["zoom.us", "zoom.com", "zoomgov.com"].some(
|
|
99
|
+
(domain) => hostname === domain || hostname.endsWith(`.${domain}`),
|
|
100
|
+
);
|
|
101
|
+
} catch {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
59
106
|
/**
|
|
60
107
|
* Exchange an authorization code for Zoom tokens. Stores them in
|
|
61
108
|
* `oauth_tokens(provider="zoom_video", account_id=<zoom_user_id>, owner=<ownerEmail>)`.
|
|
@@ -168,7 +215,12 @@ export async function disconnectZoom(ownerEmail: string) {
|
|
|
168
215
|
* owned by the host. `not_started` means the meeting creation request was not sent.
|
|
169
216
|
*/
|
|
170
217
|
export type ZoomMeetingResult =
|
|
171
|
-
| {
|
|
218
|
+
| {
|
|
219
|
+
status: "created";
|
|
220
|
+
meetingUrl: string;
|
|
221
|
+
meetingId: string;
|
|
222
|
+
accountId: string;
|
|
223
|
+
}
|
|
172
224
|
| { status: "not_started" }
|
|
173
225
|
| { status: "rejected" };
|
|
174
226
|
|
|
@@ -248,9 +300,22 @@ export async function createZoomMeeting(opts: {
|
|
|
248
300
|
status: "created",
|
|
249
301
|
meetingUrl: result.meetingUrl,
|
|
250
302
|
meetingId: result.meetingId,
|
|
303
|
+
accountId: credentialId,
|
|
251
304
|
};
|
|
252
305
|
}
|
|
253
306
|
|
|
307
|
+
export async function deleteZoomMeeting(opts: {
|
|
308
|
+
accountId: string;
|
|
309
|
+
meetingId: string;
|
|
310
|
+
}): Promise<void> {
|
|
311
|
+
const creds = getZoomCreds();
|
|
312
|
+
if (!creds) throw new Error("Zoom OAuth is not configured");
|
|
313
|
+
await createProvider(creds).deleteMeeting!({
|
|
314
|
+
credentialId: opts.accountId,
|
|
315
|
+
meetingId: opts.meetingId,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
254
319
|
/**
|
|
255
320
|
* Resolve a fresh access token for a Zoom credential, refreshing it if it's
|
|
256
321
|
* expired (or near-expiry). Persists the refreshed tokens back to oauth_tokens.
|
|
@@ -222,6 +222,19 @@ CREATE INDEX IF NOT EXISTS idx_bookings_slug_start ON bookings (slug, "start");`
|
|
|
222
222
|
name: "bookings-meeting-link-pending",
|
|
223
223
|
sql: `ALTER TABLE bookings ADD COLUMN IF NOT EXISTS meeting_link_pending BOOLEAN NOT NULL DEFAULT false`,
|
|
224
224
|
},
|
|
225
|
+
{
|
|
226
|
+
version: 25,
|
|
227
|
+
name: "bookings-zoom-needs-review",
|
|
228
|
+
sql: `ALTER TABLE bookings ADD COLUMN IF NOT EXISTS zoom_needs_review BOOLEAN NOT NULL DEFAULT false`,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
version: 26,
|
|
232
|
+
name: "bookings-zoom-provider-identifiers",
|
|
233
|
+
sql: `
|
|
234
|
+
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS zoom_meeting_id TEXT;
|
|
235
|
+
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS zoom_account_id TEXT;
|
|
236
|
+
`,
|
|
237
|
+
},
|
|
225
238
|
],
|
|
226
239
|
{ table: "calendar_migrations" },
|
|
227
240
|
);
|
|
@@ -407,6 +407,10 @@ export interface Booking {
|
|
|
407
407
|
googleEventId?: string;
|
|
408
408
|
/** Token for cancel/reschedule link (only returned to the booker) */
|
|
409
409
|
cancelToken?: string;
|
|
410
|
+
/** Zoom may have created the meeting even though its booking request failed. */
|
|
411
|
+
zoomNeedsReview?: boolean;
|
|
412
|
+
/** Cancellation requires a manual Zoom check because no provider ID was saved. */
|
|
413
|
+
zoomCancellationNeedsReview?: boolean;
|
|
410
414
|
status: "confirmed" | "cancelled";
|
|
411
415
|
createdAt: string;
|
|
412
416
|
}
|
|
@@ -80,10 +80,16 @@ function desktopOsIcon(): typeof IconDeviceDesktop {
|
|
|
80
80
|
return IconDeviceDesktop;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
const subscribeToDesktopPlatform = () => () => undefined;
|
|
84
|
+
|
|
83
85
|
export function DesktopPlatformIcon(
|
|
84
86
|
props: ComponentProps<typeof IconDeviceDesktop>,
|
|
85
87
|
) {
|
|
86
|
-
const DesktopIcon =
|
|
88
|
+
const DesktopIcon = useSyncExternalStore(
|
|
89
|
+
subscribeToDesktopPlatform,
|
|
90
|
+
desktopOsIcon,
|
|
91
|
+
() => IconDeviceDesktop,
|
|
92
|
+
);
|
|
87
93
|
return <DesktopIcon {...props} />;
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -198,11 +204,6 @@ export function CaptureInstallButton({
|
|
|
198
204
|
);
|
|
199
205
|
}
|
|
200
206
|
|
|
201
|
-
/**
|
|
202
|
-
* Compact recorder CTA for the web capture surface. Keep the trigger as plain
|
|
203
|
-
* text so the action reads like a source choice; install destinations belong
|
|
204
|
-
* in the menu rather than in a platform-specific icon treatment.
|
|
205
|
-
*/
|
|
206
207
|
export function CaptureInstallMenu({
|
|
207
208
|
children,
|
|
208
209
|
className,
|
|
@@ -229,6 +230,7 @@ export function CaptureInstallMenu({
|
|
|
229
230
|
attemptOpenDesktopApp(desktopHref);
|
|
230
231
|
}}
|
|
231
232
|
>
|
|
233
|
+
<DesktopPlatformIcon aria-hidden="true" className="size-4" />
|
|
232
234
|
{children}
|
|
233
235
|
</Button>
|
|
234
236
|
);
|
|
@@ -243,6 +245,7 @@ export function CaptureInstallMenu({
|
|
|
243
245
|
variant={variant}
|
|
244
246
|
{...buttonProps}
|
|
245
247
|
>
|
|
248
|
+
<DesktopPlatformIcon aria-hidden="true" className="size-4" />
|
|
246
249
|
{children}
|
|
247
250
|
<IconChevronDown className="size-3.5" />
|
|
248
251
|
</Button>
|
|
@@ -451,6 +451,10 @@ export function ClipsCommandMenu({
|
|
|
451
451
|
"The no-comments sidebar gives viewers a concise reason to try Clips and a clear path to sign up.",
|
|
452
452
|
)
|
|
453
453
|
.join(t("settings.changelogCommentSignup"))
|
|
454
|
+
.split(
|
|
455
|
+
"The empty comments state now explains how screen recordings help AI agents.",
|
|
456
|
+
)
|
|
457
|
+
.join(t("settings.changelogCommentsEmptyState"))
|
|
454
458
|
.split(
|
|
455
459
|
'Signed-in viewers who hit an unavailable, expired, or private share link now land in their library instead of the public marketing page when they choose "Go home."',
|
|
456
460
|
)
|
|
@@ -359,10 +359,9 @@ const messages = {
|
|
|
359
359
|
downloadForWindows: "Download for Windows",
|
|
360
360
|
downloadForLinux: "Download for Linux",
|
|
361
361
|
downloadDesktopApp: "Download desktop app",
|
|
362
|
-
commentSignupTitle: "
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
commentSignupDebug: "Capture console errors and failed requests",
|
|
362
|
+
commentSignupTitle: "Screen recordings your AI agent can see and hear",
|
|
363
|
+
commentSignupDescription:
|
|
364
|
+
"Clips is a free and open-source screen recorder for sharing bugs, feedback, and walkthroughs with AI agents.",
|
|
366
365
|
agentEmptyTitle: "Join the conversation",
|
|
367
366
|
agentEmptyDescription:
|
|
368
367
|
"Create a free Clips account to comment, react, and ask about this clip.",
|
|
@@ -755,6 +754,8 @@ const messages = {
|
|
|
755
754
|
changelogEmpty: "No updates yet.",
|
|
756
755
|
changelogCommentSignup:
|
|
757
756
|
"The no-comments sidebar gives viewers a concise reason to try Clips and a clear path to sign up.",
|
|
757
|
+
changelogCommentsEmptyState:
|
|
758
|
+
"The empty comments state now explains how screen recordings help AI agents.",
|
|
758
759
|
changelogShareLink:
|
|
759
760
|
'Signed-in viewers who hit an unavailable, expired, or private share link now land in their library instead of the public marketing page when they choose "Go home."',
|
|
760
761
|
viewAllUpdates: "View all updates",
|
|
@@ -459,6 +459,10 @@ export default function SettingsIndexRoute() {
|
|
|
459
459
|
"The no-comments sidebar gives viewers a concise reason to try Clips and a clear path to sign up.",
|
|
460
460
|
)
|
|
461
461
|
.join(t("settings.changelogCommentSignup"))
|
|
462
|
+
.split(
|
|
463
|
+
"The empty comments state now explains how screen recordings help AI agents.",
|
|
464
|
+
)
|
|
465
|
+
.join(t("settings.changelogCommentsEmptyState"))
|
|
462
466
|
.split(
|
|
463
467
|
'Signed-in viewers who hit an unavailable, expired, or private share link now land in their library instead of the public marketing page when they choose "Go home."',
|
|
464
468
|
)
|
|
@@ -1869,12 +1869,10 @@ export default function ShareRoute() {
|
|
|
1869
1869
|
sessionStatus !== "signing-out" &&
|
|
1870
1870
|
comments.length === 0 ? (
|
|
1871
1871
|
<PublicCommentsEmptyState
|
|
1872
|
-
signInHref={signInHref}
|
|
1873
1872
|
onSignUp={() => {
|
|
1874
1873
|
fireShareCtaClick("signup");
|
|
1875
1874
|
openCreateAccount("comment");
|
|
1876
1875
|
}}
|
|
1877
|
-
onSignIn={() => fireShareCtaClick("signin")}
|
|
1878
1876
|
/>
|
|
1879
1877
|
) : (
|
|
1880
1878
|
<CommentsPanel
|
|
@@ -2054,45 +2052,23 @@ function formatRecordedOn(
|
|
|
2054
2052
|
}).format(date);
|
|
2055
2053
|
}
|
|
2056
2054
|
|
|
2057
|
-
function PublicCommentsEmptyState({
|
|
2058
|
-
signInHref,
|
|
2059
|
-
onSignUp,
|
|
2060
|
-
onSignIn,
|
|
2061
|
-
}: {
|
|
2062
|
-
signInHref: string;
|
|
2063
|
-
onSignUp: () => void;
|
|
2064
|
-
onSignIn: () => void;
|
|
2065
|
-
}) {
|
|
2055
|
+
function PublicCommentsEmptyState({ onSignUp }: { onSignUp: () => void }) {
|
|
2066
2056
|
const t = useT();
|
|
2067
2057
|
|
|
2068
2058
|
return (
|
|
2069
2059
|
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-5 py-5 text-center">
|
|
2070
2060
|
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
2071
|
-
<
|
|
2061
|
+
<AgentNativeIcon aria-hidden="true" className="h-3.5 w-6" />
|
|
2072
2062
|
</div>
|
|
2073
2063
|
<h2 className="text-base font-semibold tracking-tight">
|
|
2074
2064
|
{t("sharePage.commentSignupTitle")}
|
|
2075
2065
|
</h2>
|
|
2076
2066
|
<p className="max-w-xs text-sm leading-5 text-muted-foreground">
|
|
2077
|
-
{t("sharePage.
|
|
2078
|
-
{t("sharePage.commentSignupFeedback")} ·{" "}
|
|
2079
|
-
{t("sharePage.commentSignupDebug")}
|
|
2067
|
+
{t("sharePage.commentSignupDescription")}
|
|
2080
2068
|
</p>
|
|
2081
|
-
<
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
</Button>
|
|
2085
|
-
<p className="text-center text-xs text-muted-foreground">
|
|
2086
|
-
{t("sharePage.agentEmptySignInPrompt")}{" "}
|
|
2087
|
-
<a
|
|
2088
|
-
href={signInHref}
|
|
2089
|
-
onClick={onSignIn}
|
|
2090
|
-
className="font-medium text-foreground underline underline-offset-4 hover:no-underline"
|
|
2091
|
-
>
|
|
2092
|
-
{t("signInPrompt.signIn")}
|
|
2093
|
-
</a>
|
|
2094
|
-
</p>
|
|
2095
|
-
</div>
|
|
2069
|
+
<Button type="button" className="w-full" onClick={onSignUp}>
|
|
2070
|
+
{t("signInPrompt.createAccount")}
|
|
2071
|
+
</Button>
|
|
2096
2072
|
</div>
|
|
2097
2073
|
);
|
|
2098
2074
|
}
|