Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 53 additions & 24 deletions convex/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,6 @@ export const disconnectGoogleCalendar = action({

export const syncGoogleCalendarForUser = internalAction({
args: {
userId: v.id("users"),
startTime: v.optional(v.number()),
endTime: v.optional(v.number()),
limit: v.optional(v.number()),
Expand All @@ -213,12 +212,19 @@ export const syncGoogleCalendarForUser = internalAction({
importedRemovedCount: v.number(),
}),
handler: async (ctx, args) => {
return await ctx.runAction(calendarNodeInternal.syncGoogleCalendarForUserInternal, args);
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("Authentication required");
}
return await ctx.runAction(calendarNodeInternal.syncGoogleCalendarForUserInternal, {
...args,
userId: identity.subject,
});
},
});

export const getCalendarProfileForUser = internalQuery({
args: { userId: v.id("users") },
args: {},
returns: v.union(
v.object({
role: v.literal("instructor"),
Expand All @@ -236,10 +242,14 @@ export const getCalendarProfileForUser = internalQuery({
}),
v.null(),
),
handler: async (ctx, args) => {
handler: async (ctx) => {
const user = await getCurrentUserDoc(ctx);
if (!user) {
return null;
}
const instructorProfile = (await ctx.db
.query("instructorProfiles")
.withIndex("by_user_id", (q) => q.eq("userId", args.userId))
.withIndex("by_user_id", (q) => q.eq("userId", user._id))
.unique()) as {
_id: Id<"instructorProfiles">;
calendarProvider?: "none" | "google" | "apple";
Expand All @@ -260,7 +270,7 @@ export const getCalendarProfileForUser = internalQuery({

const studioProfile = (await ctx.db
.query("studioProfiles")
.withIndex("by_user_id", (q) => q.eq("userId", args.userId))
.withIndex("by_user_id", (q) => q.eq("userId", user._id))
.unique()) as {
_id: Id<"studioProfiles">;
calendarProvider?: "none" | "google" | "apple";
Expand All @@ -285,7 +295,6 @@ export const getCalendarProfileForUser = internalQuery({

export const getCalendarTimelineForUser = internalQuery({
args: {
userId: v.id("users"),
startTime: v.number(),
endTime: v.number(),
limit: v.optional(v.number()),
Expand All @@ -309,6 +318,11 @@ export const getCalendarTimelineForUser = internalQuery({
}),
),
handler: async (ctx, args) => {
const user = await getCurrentUserDoc(ctx);
if (!user) {
return [];
}

if (!Number.isFinite(args.startTime) || !Number.isFinite(args.endTime)) {
throw new ConvexError("startTime and endTime must be finite numbers");
}
Expand All @@ -321,7 +335,7 @@ export const getCalendarTimelineForUser = internalQuery({

const instructorProfile = (await ctx.db
.query("instructorProfiles")
.withIndex("by_user_id", (q) => q.eq("userId", args.userId))
.withIndex("by_user_id", (q) => q.eq("userId", user._id))
.unique()) as { _id: Id<"instructorProfiles">; displayName: string } | null;
if (instructorProfile) {
const jobs = await ctx.db
Expand Down Expand Up @@ -361,7 +375,7 @@ export const getCalendarTimelineForUser = internalQuery({

const studioProfile = (await ctx.db
.query("studioProfiles")
.withIndex("by_user_id", (q) => q.eq("userId", args.userId))
.withIndex("by_user_id", (q) => q.eq("userId", user._id))
.unique()) as { _id: Id<"studioProfiles">; studioName: string } | null;
if (!studioProfile) {
return [];
Expand Down Expand Up @@ -416,44 +430,47 @@ export const getCalendarTimelineForUser = internalQuery({
});

export const getGoogleIntegrationForUser = internalQuery({
args: { userId: v.id("users") },
args: {},
returns: v.union(
v.object({
_id: v.id("calendarIntegrations"),
role: v.union(v.literal("instructor"), v.literal("studio")),
status: v.union(v.literal("connected"), v.literal("error"), v.literal("revoked")),
instructorId: v.optional(v.id("instructorProfiles")),
studioId: v.optional(v.id("studioProfiles")),
accessToken: v.optional(v.string()),
refreshToken: v.optional(v.string()),
oauthClientId: v.optional(v.string()),
// NOTE: accessToken, refreshToken, oauthClientId intentionally omitted
// These should NEVER be returned to clients β€” use server-side only
accessTokenExpiresAt: v.optional(v.number()),
agendaSyncToken: v.optional(v.string()),
}),
v.null(),
),
handler: async (ctx, args) => {
handler: async (ctx) => {
const user = await getCurrentUserDoc(ctx);
if (!user) {
return null;
}
const integration = await ctx.db
.query("calendarIntegrations")
.withIndex("by_user_provider", (q) =>
q.eq("userId", args.userId).eq("provider", GOOGLE_PROVIDER),
q.eq("userId", user._id).eq("provider", GOOGLE_PROVIDER),
)
.unique();
if (!integration) {
return null;
}
const inferredRole =
integration.role ?? (integration.studioId ? ("studio" as const) : ("instructor" as const));
// SECURITY: Do NOT return accessToken, refreshToken, oauthClientId
// These OAuth tokens should only be used server-side, never exposed to clients
return {
_id: integration._id,
role: inferredRole,
status: integration.status,
...omitUndefined({
instructorId: integration.instructorId,
studioId: integration.studioId,
accessToken: integration.accessToken,
refreshToken: integration.refreshToken,
oauthClientId: integration.oauthClientId,
// accessToken, refreshToken, oauthClientId intentionally NOT included
accessTokenExpiresAt: integration.accessTokenExpiresAt,
agendaSyncToken: integration.agendaSyncToken,
}),
Expand All @@ -470,6 +487,16 @@ export const getEventMappingsForIntegration = internalQuery({
}),
),
handler: async (ctx, args) => {
const user = await getCurrentUserDoc(ctx);
if (!user) {
return [];
}
// SECURITY: Validate that this integration belongs to the authenticated user
const integration = await ctx.db.get(args.integrationId);
if (!integration || integration.userId !== user._id) {
// Integration doesn't exist or doesn't belong to user β€” reject
throw new ConvexError("Not authorized to access this integration");
}
const rows = await ctx.db
.query("calendarEventMappings")
.withIndex("by_integration", (q) => q.eq("integrationId", args.integrationId))
Expand Down Expand Up @@ -764,20 +791,22 @@ export const applyGoogleAgendaSyncResult = internalMutation({
});

export const disconnectGoogleIntegrationLocally = internalMutation({
args: { userId: v.id("users") },
args: {},
returns: v.object({ ok: v.boolean() }),
handler: async (ctx, args) => {
const profile = (await ctx.runQuery(internal.calendar.getCalendarProfileForUser, {
userId: args.userId,
})) as CalendarOwnerProfile | null;
handler: async (ctx) => {
const user = await getCurrentUserDoc(ctx);
if (!user) {
throw new ConvexError("Not authenticated");
}
const profile = (await ctx.runQuery(internal.calendar.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null;
if (!profile) {
throw new ConvexError("Calendar profile not found");
}

const integration = await ctx.db
.query("calendarIntegrations")
.withIndex("by_user_provider", (q) =>
q.eq("userId", args.userId).eq("provider", GOOGLE_PROVIDER),
q.eq("userId", user._id).eq("provider", GOOGLE_PROVIDER),
)
.unique();

Expand Down
62 changes: 28 additions & 34 deletions convex/calendarNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,8 @@ async function syncQueueEventsToGoogle(args: {
const startTime = args.startTime ?? args.now - 7 * 24 * 60 * 60 * 1000;
const endTime = args.endTime ?? args.now + 90 * 24 * 60 * 60 * 1000;
const limit = Math.max(50, Math.min(1000, args.limit ?? 400));
// getCalendarTimelineForUser now gets user from auth context
const timeline = (await args.ctx.runQuery(calendarInternal.getCalendarTimelineForUser, {
userId: args.userId,
startTime,
endTime,
limit,
Expand All @@ -397,7 +397,7 @@ async function syncQueueEventsToGoogle(args: {
)
.sort((a, b) => a.startTime - b.startTime);

const existingMappings = (await args.ctx.runQuery(
const existingMappings = (await args.ctx.runQuery(
calendarInternal.getEventMappingsForIntegration,
{
integrationId: args.integrationId,
Expand Down Expand Up @@ -517,9 +517,8 @@ async function runGoogleCalendarSync(
requireConnected: boolean;
},
) {
const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {
userId: args.userId,
})) as GoogleIntegrationRecord | null;
// The internal queries now get user from auth context β€” no need to pass userId
const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null;
if (!integration || integration.status !== "connected") {
if (args.requireConnected) {
throw new ConvexError("Google Calendar is not connected");
Expand All @@ -533,9 +532,7 @@ async function runGoogleCalendarSync(
};
}

const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {
userId: args.userId,
})) as CalendarOwnerProfile | null;
const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null;
if (!profile) {
if (args.requireConnected) {
throw new ConvexError("Calendar profile not found");
Expand All @@ -552,9 +549,8 @@ async function runGoogleCalendarSync(
const now = Date.now();
try {
const accessToken = await getGoogleAccessToken(ctx, integration, now);
const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, {
integrationId: integration._id,
})) as Array<{ externalEventId: string; providerEventId: string }>;
// getEventMappingsForIntegration now looks up user's integration internally
const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, {})) as Array<{ externalEventId: string; providerEventId: string }>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”₯ The Roast: You're calling getEventMappingsForIntegration with {} but the function signature (calendar.ts:482) still requires integrationId: v.id("calendarIntegrations"). When this runs, args.integrationId will be undefined and ctx.db.get(undefined) will throw. Classic "I changed the comment but not the code" bug.

🩹 The Fix:

Suggested change
const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, {})) as Array<{ externalEventId: string; providerEventId: string }>;
const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, {
integrationId: integration._id,
})) as Array<{ externalEventId: string; providerEventId: string }>;

πŸ“ Severity: warning

let pushResult = {
syncedCount: 0,
removedCount: 0,
Expand Down Expand Up @@ -635,16 +631,14 @@ export const connectGoogleCalendarWithCodeInternal = internalAction({

assertGoogleClientIdAllowed(args.clientId);

const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {
userId: currentUser._id,
})) as CalendarOwnerProfile | null;
// getCalendarProfileForUser now gets user from auth context
const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null;
if (!profile) {
throw new ConvexError("Calendar profile not found");
}

const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {
userId: currentUser._id,
})) as GoogleIntegrationRecord | null;
// getGoogleIntegrationForUser now gets user from auth context
const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null;

const token = await exchangeGoogleAuthorizationCode({
code: args.code,
Expand Down Expand Up @@ -705,16 +699,14 @@ export const connectGoogleCalendarWithServerAuthCodeInternal = internalAction({
throw new ConvexError("Only instructors and studios can connect Google Calendar");
}

const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {
userId: currentUser._id,
})) as CalendarOwnerProfile | null;
// getCalendarProfileForUser now gets user from auth context
const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null;
if (!profile) {
throw new ConvexError("Calendar profile not found");
}

const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {
userId: currentUser._id,
})) as GoogleIntegrationRecord | null;
// getGoogleIntegrationForUser now gets user from auth context
const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null;

const clientId = getGoogleServerClientId();
assertGoogleClientIdAllowed(clientId);
Expand Down Expand Up @@ -818,19 +810,18 @@ export const disconnectGoogleCalendarInternal = internalAction({
throw new ConvexError("Only instructors and studios can disconnect Google Calendar");
}

const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {
userId: currentUser._id,
})) as GoogleIntegrationRecord | null;
// getGoogleIntegrationForUser now gets user from auth context
const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null;
if (!integration) {
await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, {
userId: currentUser._id,
});
// disconnectGoogleIntegrationLocally now gets user from auth context
await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, {});
return { ok: true, deletedRemoteEvents: true };
}

let deletedRemoteEvents = true;
try {
const accessToken = await getGoogleAccessToken(ctx, integration, Date.now());
// getEventMappingsForIntegration validates ownership of integrationId
const mappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, {
integrationId: integration._id,
})) as Array<{ providerEventId: string }>;
Expand All @@ -846,9 +837,8 @@ export const disconnectGoogleCalendarInternal = internalAction({
deletedRemoteEvents = false;
}

await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, {
userId: currentUser._id,
});
// disconnectGoogleIntegrationLocally now gets user from auth context
await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, {});

return {
ok: true,
Expand All @@ -859,7 +849,6 @@ export const disconnectGoogleCalendarInternal = internalAction({

export const syncGoogleCalendarForUserInternal = internalAction({
args: {
userId: v.id("users"),
startTime: v.optional(v.number()),
endTime: v.optional(v.number()),
limit: v.optional(v.number()),
Expand All @@ -872,8 +861,13 @@ export const syncGoogleCalendarForUserInternal = internalAction({
importedRemovedCount: v.number(),
}),
handler: async (ctx, args) => {
// Get userId from auth context β€” the action runs with the user's identity
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("Authentication required");
}
return await runGoogleCalendarSync(ctx, {
userId: args.userId,
userId: identity.subject,
...omitUndefined({
startTime: args.startTime,
endTime: args.endTime,
Expand Down
6 changes: 4 additions & 2 deletions convex/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1768,7 +1768,6 @@ export const getMyStudioJobsWithApplications = query({

export const checkInstructorConflicts = query({
args: {
instructorId: v.id("instructorProfiles"),
startTime: v.number(),
endTime: v.number(),
excludeJobId: v.optional(v.id("jobs")),
Expand All @@ -1786,10 +1785,13 @@ export const checkInstructorConflicts = query({
),
}),
handler: async (ctx, args) => {
// SECURITY: Get instructor from auth context, not from args
const instructor = await requireInstructorProfile(ctx);

const jobs = await ctx.db
.query("jobs")
.withIndex("by_filledByInstructor_startTime", (q) =>
q.eq("filledByInstructorId", args.instructorId),
q.eq("filledByInstructorId", instructor._id),
)
.collect();

Expand Down
Loading
Loading