Skip to content
Merged
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
46 changes: 46 additions & 0 deletions graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ export const typeDefs = /* GraphQL */ `
created_at: String
updated_at: String
is_private: Boolean
max_attendees: Int
maxAttendees: Int
available_spots: Int
availableSpots: Int
version: Int
club: Club
organizer: Profile
}
Expand Down Expand Up @@ -266,8 +271,26 @@ export const typeDefs = /* GraphQL */ `
event(id: ID!): Event
}

"""
Result payload returned by event RSVP mutation.
"""
type RsvpPayload {
success: Boolean!
code: String!
message: String!
availableSpots: Int
status: String
version: Int
}

type Mutation {
suspendUsers(ids: [ID!]!): [Profile!]!
"""
Manage event RSVPs with strict row-level locking (SELECT FOR UPDATE)
and optimistic concurrency control (version increments).
Prevents race conditions and overbooking.
"""
rsvpToEvent(eventId: ID!, userId: ID, action: String): RsvpPayload!
}

"""
Expand Down Expand Up @@ -412,6 +435,27 @@ export const resolvers = {
if (error) throw error;
return data || [];
},
rsvpToEvent: async (
_: unknown,
{ eventId, userId, action = "RSVP" }: { eventId: string; userId?: string; action?: string },
) => {
const { data, error } = await supabase.rpc("manage_event_rsvp", {
p_event_id: eventId,
p_user_id: userId || null,
p_action: action,
});

if (error) throw new Error(error.message);

return {
success: data?.success ?? false,
code: data?.code ?? "ERROR",
message: data?.message ?? "An error occurred during RSVP processing.",
availableSpots: data?.available_spots ?? null,
status: data?.status ?? null,
version: data?.version ?? null,
};
},
},

Post: {
Expand Down Expand Up @@ -439,6 +483,8 @@ export const resolvers = {
organizer: (parent: { created_by: string }) => {
return parent.created_by ? profileLoader.load(parent.created_by) : null;
},
maxAttendees: (parent: { max_attendees?: number | null }) => parent.max_attendees ?? null,
availableSpots: (parent: { available_spots?: number | null }) => parent.available_spots ?? null,
},

Subscription: {
Expand Down
119 changes: 119 additions & 0 deletions src/graphql/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ vi.mock("../../src/lib/supabase/client", () => {
}
return { select: vi.fn() };
}),
rpc: vi
.fn()
.mockImplementation(
(fnName: string, args: { p_event_id: string; p_user_id: string; p_action: string }) => {
if (fnName === "manage_event_rsvp") {
if (args.p_event_id === "evt-full") {
return Promise.resolve({
data: {
success: false,
code: "EVENT_FULL",
message: "Event is fully booked. No available spots remaining.",
available_spots: 0,
version: 5,
},
error: null,
});
}
return Promise.resolve({
data: {
success: true,
code: "RSVP_SUCCESS",
message: "RSVP confirmed!",
status: "CONFIRMED",
available_spots: 10,
version: 2,
},
error: null,
});
}
return Promise.resolve({ data: null, error: null });
},
),
})),
};
});
Expand Down Expand Up @@ -243,3 +275,90 @@ describe("publishNotification helper", () => {
}
});
});

// ─────────────────────────────────────────────────────────────────────────────
// GraphQL rsvpToEvent Mutation Tests
// ─────────────────────────────────────────────────────────────────────────────

describe("GraphQL rsvpToEvent Mutation", () => {
it("schema includes rsvpToEvent mutation field and RsvpPayload type", () => {
const mutationType = schema.getMutationType();
expect(mutationType).toBeDefined();
const field = mutationType!.getFields()["rsvpToEvent"];
expect(field).toBeDefined();
expect(field.type.toString()).toBe("RsvpPayload!");

const payloadType = schema.getType("RsvpPayload");
expect(payloadType).toBeDefined();
// @ts-expect-error getFields is available on object types
const fields = payloadType!.getFields();
expect(fields).toHaveProperty("success");
expect(fields).toHaveProperty("code");
expect(fields).toHaveProperty("message");
expect(fields).toHaveProperty("availableSpots");
expect(fields).toHaveProperty("status");
expect(fields).toHaveProperty("version");
});

it("executes rsvpToEvent mutation successfully via GraphQL Yoga", async () => {
const query = /* GraphQL */ `
mutation RsvpTest {
rsvpToEvent(eventId: "evt-1", userId: "usr-1", action: "RSVP") {
success
code
message
availableSpots
status
version
}
}
`;

const response = await yoga.fetch("http://localhost:4000/api/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});

const result = await response.json();
expect(result.errors).toBeUndefined();
expect(result.data.rsvpToEvent).toEqual({
success: true,
code: "RSVP_SUCCESS",
message: "RSVP confirmed!",
availableSpots: 10,
status: "CONFIRMED",
version: 2,
});
});

it("returns EVENT_FULL code when event is fully booked", async () => {
const query = /* GraphQL */ `
mutation RsvpFullTest {
rsvpToEvent(eventId: "evt-full", userId: "usr-99", action: "RSVP") {
success
code
message
availableSpots
version
}
}
`;

const response = await yoga.fetch("http://localhost:4000/api/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});

const result = await response.json();
expect(result.errors).toBeUndefined();
expect(result.data.rsvpToEvent).toEqual({
success: false,
code: "EVENT_FULL",
message: "Event is fully booked. No available spots remaining.",
availableSpots: 0,
version: 5,
});
});
});
177 changes: 177 additions & 0 deletions supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
-- Migration: 20260729120000_concurrency_rsvp_mutation.sql
-- Description: Add optimistic concurrency version tracking and pessimistic row-level locking
-- RPC function (public.manage_event_rsvp) to prevent overbooking on concurrent RSVPs.

-- 1. Add version and available_spots columns to events table if they don't exist
ALTER TABLE public.events
ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS available_spots INTEGER;

-- Initialize available_spots for existing events based on max_attendees and current RSVP count
UPDATE public.events e
SET available_spots = CASE
WHEN e.max_attendees IS NULL THEN NULL
ELSE GREATEST(0, e.max_attendees - (
SELECT COUNT(*) FROM public.event_rsvps r
WHERE r.event_id = e.id AND (r.status IS NULL OR r.status NOT IN ('CANCELLED', 'REJECTED'))
))
END
WHERE e.available_spots IS NULL AND e.max_attendees IS NOT NULL;
Comment on lines +11 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

The new RPC references two columns that don't exist in supabase/schema.sql. event_rsvps.status and events.requires_approval are absent from the table definitions (lines 141-148 and 88-109), so the migration's backfill fails immediately and the function body fails on every call. Root cause: neither column is created by this PR.

  • supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql#L11-L19: add ALTER TABLE public.event_rsvps ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'CONFIRMED'; before the backfill, and mirror it in supabase/schema.sql, or remove the status filters entirely.
  • supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql#L58-L62: add requires_approval BOOLEAN NOT NULL DEFAULT FALSE to events in both this migration and supabase/schema.sql, or drop requires_approval from the SELECT ... INTO and hardcode 'CONFIRMED'.
📍 Affects 1 file
  • supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql#L11-L19 (this comment)
  • supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql#L58-L62
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql` around
lines 11 - 19, The migration references missing event_rsvps.status and
events.requires_approval columns. At
supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql lines 11-19,
add event_rsvps.status with the specified default before the backfill and mirror
it in supabase/schema.sql; at lines 58-62, add events.requires_approval with its
specified default in both locations, or remove the corresponding references
while preserving confirmed RSVP behavior.



-- 2. Create manage_event_rsvp RPC function with strict SELECT FOR UPDATE locking
CREATE OR REPLACE FUNCTION public.manage_event_rsvp(
p_event_id UUID,
p_user_id UUID DEFAULT auth.uid(),
p_action TEXT DEFAULT 'RSVP'
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
Comment on lines +23 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Any caller can RSVP or cancel on behalf of an arbitrary user. The actor is never bound to the authenticated session at either layer: the RPC runs SECURITY DEFINER (bypassing RLS) and trusts p_user_id, and the resolver forwards a client-supplied userId verbatim.

  • supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql#L23-L31: reject the call when p_user_id differs from auth.uid() unless the caller is service_role.
  • graphql/resolvers/index.ts#L438-L458: derive the user id from the GraphQL request context instead of the userId argument, and remove or admin-gate that argument in the rsvpToEvent schema field.
📍 Affects 2 files
  • supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql#L23-L31 (this comment)
  • graphql/resolvers/index.ts#L438-L458
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql` around
lines 23 - 31, Bind RSVP mutations to the authenticated actor at both layers: in
supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql, update
manage_event_rsvp to reject mismatched p_user_id unless the caller is
service_role; in graphql/resolvers/index.ts lines 438-458, make rsvpToEvent
derive the user ID from request context, and remove or admin-gate the
client-supplied userId schema argument.

AS $$
DECLARE
v_effective_user_id UUID;
v_max_capacity INT;
v_requires_approval BOOLEAN;
v_version INT;
v_current_rsvps INT;
v_has_rsvped BOOLEAN;
v_rsvp_status TEXT;
v_new_version INT;
v_new_available_spots INT;
v_normalized_action TEXT;
BEGIN
-- Fallback to auth.uid() if p_user_id is NULL
v_effective_user_id := COALESCE(p_user_id, auth.uid());
IF v_effective_user_id IS NULL THEN
RETURN jsonb_build_object(
'success', false,
'code', 'UNAUTHORIZED',
'message', 'User ID is required to RSVP.'
);
END IF;

v_normalized_action := UPPER(COALESCE(p_action, 'RSVP'));

-- A. Pessimistically lock the event row to block concurrent RSVP mutations on this event
SELECT max_attendees, requires_approval, COALESCE(version, 1)
INTO v_max_capacity, v_requires_approval, v_version
FROM public.events
WHERE id = p_event_id
FOR UPDATE;

IF NOT FOUND THEN
RETURN jsonb_build_object(
'success', false,
'code', 'EVENT_NOT_FOUND',
'message', 'Target event does not exist.'
);
END IF;

-- Count existing active RSVPs for this event
SELECT COUNT(*)
INTO v_current_rsvps
FROM public.event_rsvps
WHERE event_id = p_event_id
AND (status IS NULL OR status NOT IN ('CANCELLED', 'REJECTED'));

-- B. Handle RSVP action
IF v_normalized_action IN ('RSVP', 'ADD', 'REGISTER') THEN
-- Check if user is already RSVP'd
SELECT EXISTS (
SELECT 1 FROM public.event_rsvps
WHERE event_id = p_event_id
AND user_id = v_effective_user_id
AND (status IS NULL OR status NOT IN ('CANCELLED', 'REJECTED'))
) INTO v_has_rsvped;

IF v_has_rsvped THEN
RETURN jsonb_build_object(
'success', false,
'code', 'ALREADY_RSVPED',
'message', 'You have already RSVPed for this event.',
'available_spots', CASE WHEN v_max_capacity IS NULL THEN NULL ELSE GREATEST(0, v_max_capacity - v_current_rsvps) END,
'version', v_version
);
END IF;

-- Strict Capacity Check: Ensure max_attendees is not exceeded
IF v_max_capacity IS NOT NULL AND v_current_rsvps >= v_max_capacity THEN
RETURN jsonb_build_object(
'success', false,
'code', 'EVENT_FULL',
'message', 'Event is fully booked. No available spots remaining.',
'available_spots', 0,
'version', v_version
);
END IF;

-- Determine RSVP status based on whether approval is required
v_rsvp_status := CASE WHEN v_requires_approval = TRUE THEN 'PENDING' ELSE 'CONFIRMED' END;

-- Upsert RSVP record
INSERT INTO public.event_rsvps (event_id, user_id, status, rsvp_at)
VALUES (p_event_id, v_effective_user_id, v_rsvp_status, NOW())
ON CONFLICT (event_id, user_id)
DO UPDATE SET status = v_rsvp_status, rsvp_at = NOW();

-- Increment version and update available_spots on the events table
v_new_version := v_version + 1;
v_new_available_spots := CASE WHEN v_max_capacity IS NULL THEN NULL ELSE GREATEST(0, v_max_capacity - (v_current_rsvps + 1)) END;

UPDATE public.events
SET version = v_new_version,
available_spots = v_new_available_spots,
updated_at = NOW()
WHERE id = p_event_id;

RETURN jsonb_build_object(
'success', true,
'code', 'RSVP_SUCCESS',
'message', CASE WHEN v_requires_approval = TRUE THEN 'RSVP submitted! Pending approval.' ELSE 'RSVP confirmed!' END,
'status', v_rsvp_status,
'available_spots', v_new_available_spots,
'version', v_new_version
);

-- C. Handle CANCEL action
ELSIF v_normalized_action IN ('CANCEL', 'REMOVE', 'UNRSVP') THEN
DELETE FROM public.event_rsvps
WHERE event_id = p_event_id AND user_id = v_effective_user_id;

-- Recount RSVPs after deletion
SELECT COUNT(*) INTO v_current_rsvps
FROM public.event_rsvps
WHERE event_id = p_event_id
AND (status IS NULL OR status NOT IN ('CANCELLED', 'REJECTED'));

v_new_version := v_version + 1;
v_new_available_spots := CASE WHEN v_max_capacity IS NULL THEN NULL ELSE GREATEST(0, v_max_capacity - v_current_rsvps) END;

UPDATE public.events
SET version = v_new_version,
available_spots = v_new_available_spots,
updated_at = NOW()
WHERE id = p_event_id;

RETURN jsonb_build_object(
'success', true,
'code', 'CANCEL_SUCCESS',
'message', 'RSVP cancelled successfully.',
'available_spots', v_new_available_spots,
'version', v_new_version
);
ELSE
RETURN jsonb_build_object(
'success', false,
'code', 'INVALID_ACTION',
'message', 'Action must be RSVP or CANCEL.'
);
END IF;
END;
$$;

-- 3. Grant execution permissions
GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO authenticated;
GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO service_role;
Comment on lines +176 to +177

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Revoke default PUBLIC EXECUTE on this SECURITY DEFINER function.

Postgres grants EXECUTE to PUBLIC by default, so anon can invoke it regardless of these explicit grants.

🔒 Tighten grants
+REVOKE ALL ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) FROM PUBLIC;
 GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO authenticated;
 GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO service_role;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO authenticated;
GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO service_role;
REVOKE ALL ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO authenticated;
GRANT EXECUTE ON FUNCTION public.manage_event_rsvp(UUID, UUID, TEXT) TO service_role;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260729120000_concurrency_rsvp_mutation.sql` around
lines 176 - 177, Update the grants for public.manage_event_rsvp(UUID, UUID,
TEXT) to explicitly revoke EXECUTE from PUBLIC before granting it to
authenticated and service_role, ensuring anonymous callers cannot invoke this
SECURITY DEFINER function.

2 changes: 2 additions & 0 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ CREATE TABLE events (
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
max_attendees INTEGER,
available_spots INTEGER,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'scheduled',
created_by UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
Expand Down
Loading
Loading