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
160 changes: 160 additions & 0 deletions src/components/EditEventDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import "@testing-library/jest-dom/vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { User } from "@supabase/supabase-js";
import { QueryClientProvider, queryClient } from "@/hooks/useReactQueryReplacement";
import { EditEventDialog } from "./EditEventDialog";

// Mock Supabase client
const mockSingle = vi.fn();
const mockUpdate = vi.fn();

vi.mock("@/lib/supabase/client", () => ({
createClient: () => ({
from: vi.fn().mockImplementation((table: string) => {
if (table === "event_categories") {
return {
select: vi.fn().mockReturnValue({
order: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({
data: [{ id: "cat-1", name: "Tech" }],
error: null,
}),
}),
}),
};
}
if (table === "events") {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({ single: mockSingle }),
}),
update: mockUpdate,
};
}
return {};
}),
}),
}));

const mockUser = { id: "user-1" } as User;

const baseEvent = {
id: "evt-1",
title: "Hackathon 2024",
description: "Original description",
category_id: "cat-1",
location: "Main Auditorium",
start_date: "2026-09-15T10:00:00.000Z",
end_date: "2026-09-15T11:00:00.000Z",
tags: [] as string[],
version: 1,
version_vector: {},
};

function renderDialog() {
return render(
<QueryClientProvider client={queryClient}>
<EditEventDialog event={baseEvent} user={mockUser} onSuccess={vi.fn()} />
</QueryClientProvider>,
);
}

describe("EditEventDialog Optimistic Concurrency Control", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("rejects a stale save (0 rows updated) and shows the merge conflict modal with the new DB state", async () => {
// Pre-save merge fetch: server still on version 1 (no field conflict yet)
mockSingle.mockResolvedValueOnce({
data: { ...baseEvent, version: 1 },
error: null,
});

// Capture the OCC predicates used on the UPDATE
const predicates: { key: string; value: unknown }[] = [];
mockUpdate.mockReturnValue({
eq: vi.fn((key: string, value: unknown) => {
predicates.push({ key, value });
return {
eq: vi.fn((key2: string, value2: unknown) => {
predicates.push({ key: key2, value: value2 });
return {
select: vi.fn().mockResolvedValue({
data: [],
error: null,
}),
};
}),
};
}),
});

// UI recovery fetch: another admin already bumped the version to 2
mockSingle.mockResolvedValueOnce({
data: { ...baseEvent, description: "Server edited description", version: 2 },
error: null,
});

renderDialog();

fireEvent.click(screen.getByRole("button", { name: "Edit Event" }));
fireEvent.change(await screen.findByPlaceholderText("Event description"), {
target: { value: "My local edit" },
});
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));

// The UPDATE must be guarded by id + version (optimistic locking)
await waitFor(() => {
expect(predicates).toContainEqual({ key: "version", value: 1 });
expect(predicates).toContainEqual({ key: "id", value: "evt-1" });
});

// Conflict modal pops up showing exactly what the other admin changed
await waitFor(() => {
expect(screen.getByText("Concurrent Edit Conflict Detected")).toBeInTheDocument();
});
expect(screen.getByText("Server edited description")).toBeInTheDocument();
expect(screen.getAllByText("My local edit").length).toBeGreaterThan(0);
});

it("saves successfully when the submitted version still matches the database", async () => {
mockSingle.mockResolvedValueOnce({
data: { ...baseEvent, version: 1 },
error: null,
});

mockUpdate.mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockResolvedValue({
data: [{ id: "evt-1", version: 2 }],
error: null,
}),
}),
}),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

renderDialog();

fireEvent.click(screen.getByRole("button", { name: "Edit Event" }));
fireEvent.click(await screen.findByRole("button", { name: "Save Changes" }));

// The save must write the next version (2) atomically in the update payload
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
title: "Hackathon 2024",
description: "Original description",
version: 2,
}),
);
});

// Dialog closes on success
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Save Changes" })).not.toBeInTheDocument();
});
});
});
69 changes: 65 additions & 4 deletions src/components/EditEventDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
import { TagMultiSelect } from "@/components/ui/TagMultiSelect";
import { DateTimePicker } from "@/components/DateTimePicker";

const EVENT_CONCURRENT_EDIT_CONFLICT = "EVENT_CONCURRENT_EDIT_CONFLICT";

interface EditEventDialogProps {
event: EventDocument;
user: User | null;
Expand Down Expand Up @@ -113,7 +115,12 @@ export function EditEventDialog({ event, user, onSuccess }: EditEventDialogProps
setIsSaving(true);

try {
const { error } = await supabase
// Optimistic concurrency control: the version the document was merged
// against (docToSave.version is the NEXT version to write, so the WHERE
// predicate must target the CURRENT database version).
const targetVersion = (docToSave.version || 1) - 1;

const { data, error } = await supabase
.from("events")
.update({
title: docToSave.title,
Expand All @@ -125,24 +132,73 @@ export function EditEventDialog({ event, user, onSuccess }: EditEventDialogProps
event_date: docToSave.start_date,
tags: docToSave.tags || [],
version_vector: docToSave.version_vector || {},
version: (docToSave.version || 1) + 1,
version: docToSave.version || 1,
})
.eq("id", event.id);
.eq("id", event.id)
.eq("version", targetVersion)
.select("id, version");

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

toast.success("Event updated with CRDT differential merge!");
// rowCount === 0 -> the database version no longer matches the version
// this user fetched (another admin already bumped it). Reject the save.
if (!data || data.length === 0) {
await handleConcurrentConflict(docToSave);
throw new Error(EVENT_CONCURRENT_EDIT_CONFLICT);
}

toast.success("Event updated with optimistic concurrency control!");
window.dispatchEvent(new Event("refetchEvents"));
setOpen(false);
if (onSuccess) onSuccess();
} catch (err) {
if (err instanceof Error && err.message === EVENT_CONCURRENT_EDIT_CONFLICT) {
return;
}
console.error("[EditEventDialog] Save error:", err);
toast.error("Failed to update event. Please try again.");
} finally {
setIsSaving(false);
}
};

const handleConcurrentConflict = async (docToSave: EventDocument) => {
toast.error(
"Conflict detected: This event was modified by another user while you were editing.",
);

// UI recovery: fetch the new database state and show exactly what changed
// so the user never loses their work.
const { data: freshServer, error: serverError } = await supabase
.from("events")
.select("*")
.eq("id", event.id)
.single();

if (serverError || !freshServer) {
toast.error("Failed to load the latest event state. Please refresh and try again.");
return;
}

const serverDoc = freshServer as EventDocument;
const localDraft: EventDocument = {
...docToSave,
version: baseSnapshot.version || 1,
version_vector: baseSnapshot.version_vector || {},
};

const mergeResult = mergeEventDocuments(
baseSnapshot,
localDraft,
serverDoc,
user?.id || "local-admin",
);

setConflicts(mergeResult.conflicts);
setMergedDoc(mergeResult.mergedDocument);
setConflictModalOpen(true);
};

const handleFormSubmit = async (values: EventFormValues) => {
if (!user || !event.id) return;
setIsSaving(true);
Expand Down Expand Up @@ -187,6 +243,11 @@ export function EditEventDialog({ event, user, onSuccess }: EditEventDialogProps
await executeSave(mergeResult.mergedDocument);
}
} catch (err) {
if (err instanceof Error && err.message === EVENT_CONCURRENT_EDIT_CONFLICT) {
// Conflict UI already surfaced by executeSave
setIsSaving(false);
return;
}
console.error("[EditEventDialog] Submit error:", err);
toast.error("Error evaluating concurrent event edits.");
setIsSaving(false);
Expand Down
60 changes: 60 additions & 0 deletions src/routes/api/events/$id/reschedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createClient } from "@/lib/supabase/client";

interface RescheduleBody {
start_date: string;
end_date: string;
event_date: string;
version: number;
}

export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const eventId = params.id;
const supabase = createClient();

let body: RescheduleBody;
try {
body = (await req.json()) as RescheduleBody;
} catch {
return new Response("Invalid request body", { status: 400 });
}

const targetVersion = Number(body.version);
if (!Number.isInteger(targetVersion)) {
return new Response("Missing expected version for optimistic locking", { status: 400 });
}

// Guarded update: only succeeds when the event is still on the version the
// client fetched, so a concurrent reschedule/edit cannot be silently overwritten.
const { data, error } = await supabase
.from("events")
.update({
start_date: body.start_date,
end_date: body.end_date,
event_date: body.event_date,
updated_at: new Date().toISOString(),
version: targetVersion + 1,
})
.eq("id", eventId)
.eq("version", targetVersion)
.select("id, version");

if (error) {
return new Response(error.message, { status: 500 });
}

// 0 rows affected -> another user bumped the version first.
if (!data || data.length === 0) {
return new Response(
"Conflict: This event was modified by another user. Please refresh and try again.",
{ status: 409 },
);
Comment on lines +46 to +50

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 | 🟠 Major | 🏗️ Heavy lift

Return the current event state with the conflict response.

The 409 response contains only text. It does not provide the current database state required for conflict recovery. src/services/adminEventRescheduleApi.ts:23-108 also does not handle 409 before it attempts the fallback update.

After the guarded update affects zero rows, return a structured conflict payload with the current event fields and version. Update the client to handle 409 and use that payload instead of falling through to the direct-update fallback.

🤖 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 `@src/routes/api/events/`$id/reschedule.ts around lines 46 - 50, The
guarded-update conflict branch in the reschedule route should return a
structured 409 payload containing the current event fields and version, rather
than text alone. Update the client flow in the reschedule API service to detect
409 responses, consume that payload for conflict recovery, and avoid falling
through to the direct-update fallback.

}

return Response.json({
success: true,
eventId,
updatedStart: body.start_date,
updatedEnd: body.end_date,
message: "Event rescheduled successfully",
});
}
4 changes: 3 additions & 1 deletion src/routes/events.$eventId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { isCaptchaConfigured, shouldRequireCaptcha } from "@/lib/captcha";
import { EditEventDialog } from "@/components/EditEventDialog";
import { DragDropContext, Droppable, Draggable, DropResult } from "@hello-pangea/dnd";
import { CreatePollDialog } from "@/components/polls/CreatePollDialog";
import { ActivePoll } from "@/components/polls/ActivePoll";
Expand Down Expand Up @@ -384,7 +385,7 @@ export default function EventDetailsPage() {
.from("events")
.select(
`
id, title, description, event_date, start_date, end_date, location, banner_url, created_by, short_id, max_attendees, requires_approval,
id, title, description, event_date, start_date, end_date, location, banner_url, created_by, short_id, max_attendees, requires_approval, category_id, tags, version, version_vector,
profiles (full_name, email),
clubs (name, slug),
event_rsvps (id, user_id, status, checked_in, rsvp_at, profiles (first_name, last_name, avatar_url)),
Expand Down Expand Up @@ -1309,6 +1310,7 @@ export default function EventDetailsPage() {
{exportCsv.isPending ? "Exporting..." : "Export CSV"}
</Button>
<CreatePollDialog eventId={eventId} user={user!} onPollCreated={() => refetch()} />
<EditEventDialog event={event} user={user} onSuccess={() => refetch()} />
</>
)}

Expand Down
Loading
Loading