Skip to content
Merged
Changes from 2 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
252 changes: 252 additions & 0 deletions app/scheduling/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
"use server";

import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { coachingSessions } from "@/lib/db/schema";
import { createClient } from "@/utils/supabase/server";

export type TimeSlot = { start: string; end: string };

export type SchedulingActionState = {
errors?: Record<string, string[]>;
message?: string;
} | null;

const iso8601 = z.string().datetime({ message: "Must be an ISO 8601 datetime" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const iso8601 = z.string().datetime({ message: "Must be an ISO 8601 datetime" });
const iso8601 = z
.string()
.datetime({ offset: true, message: "Must be an ISO 8601 datetime with timezone" });


const timeSlotSchema = z
.object({
start: iso8601,
end: iso8601,
})
.refine((slot) => new Date(slot.end) > new Date(slot.start), {
message: "End time must be after start time",
});

const selectAvailabilitiesSchema = z.object({
session_id: z.string().uuid("Invalid session ID"),
time_slots: z
.array(timeSlotSchema)
.min(1, "At least one time slot is required"),
});
Comment on lines +27 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const selectAvailabilitiesSchema = z.object({
session_id: z.string().uuid("Invalid session ID"),
time_slots: z
.array(timeSlotSchema)
.min(1, "At least one time slot is required"),
});
const selectAvailabilitiesSchema = z.object({
session_id: z.string().uuid("Invalid session ID"),
time_slots: z
.array(timeSlotSchema)
.min(1, "At least one time slot is required")
.max(20, "You can select up to 20 time slots"),
});

add a limit


const selectTimeSlotSchema = z.object({
session_id: z.string().uuid("Invalid session ID"),
start: iso8601,
end: iso8601,
});

async function getAuthenticatedUserId(): Promise<string | null> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return user?.id ?? null;
}

/**
* Fetch a coaching session and verify the caller is either the coach or user.
* Returns the session row or an error state.
*/
async function getAuthorizedSession(
sessionId: string,
userId: string,
): Promise<
| { ok: true; session: typeof coachingSessions.$inferSelect }
| { ok: false; state: SchedulingActionState }
> {
const [session] = await db
.select()
.from(coachingSessions)
.where(eq(coachingSessions.id, sessionId))
.limit(1);

if (!session) {
return {
ok: false,
state: { errors: { _form: ["Coaching session not found"] } },
};
}

if (session.coachId !== userId && session.userId !== userId) {
return {
ok: false,
state: { errors: { _form: ["You are not authorized to modify this session"] } },
};
}

return { ok: true, session };
}

/**
* Set (or replace) the available time slots on an existing coaching session.
*
* Both the assigned coach and the session user can call this action.
* The payload is an array of `{ start, end }` ISO 8601 strings.
*/
export async function selectAvailabilities(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

selectAvailabilities -> coach only
selectTimeSlot -> user only

? if so we can add a helper.

function isCoach(session: typeof coachingSessions.$inferSelect, userId: string) {
  return session.coachId === userId;
}

function isSessionUser(session: typeof coachingSessions.$inferSelect, userId: string) {
  return session.userId === userId;
}

if (!isCoach(result.session, callerId)) {
  return {
    errors: {
      _form: ["Only the coach can set availabilities"],
    },
  };
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No, both have to be callable by both roles. This is because we might change the flow to allow both parties to have a back and forth where they tell each other's availabilities.

_prev: SchedulingActionState,
formData: FormData,
): Promise<SchedulingActionState> {
Comment on lines +107 to +110
// 1. Auth gate
const callerId = await getAuthenticatedUserId();
if (!callerId) {
return { errors: { _form: ["You must be logged in"] } };
}

// 2. Parse & validate input
let sessionId: string;
let timeSlots: TimeSlot[];
try {
const rawSlots = formData.get("time_slots")?.toString() ?? "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i would isolate the parsing.

Suggested change
const rawSlots = formData.get("time_slots")?.toString() ?? "";
function parseJsonField<T>(formData: FormData, key: string): T | null {
const value = formData.get(key);
if (typeof value !== "string") {
return null;
}
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
const rawTimeSlots = parseJsonField<TimeSlot[]>(formData, "time_slots");
if (!rawTimeSlots) {
return { errors: { time_slots: ["Invalid time slots format"] } };
}

const parsed = selectAvailabilitiesSchema.safeParse({
session_id: formData.get("session_id")?.toString(),
time_slots: JSON.parse(rawSlots),
});

if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}

sessionId = parsed.data.session_id;
timeSlots = parsed.data.time_slots as TimeSlot[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
timeSlots = parsed.data.time_slots as TimeSlot[];
timeSlots = parsed.data.time_slots.map(normalizeSlot);
function normalizeSlot(slot: TimeSlot): TimeSlot {
  return {
    start: new Date(slot.start).toISOString(),
    end: new Date(slot.end).toISOString(),
  };
}
const normalizedStart = new Date(start).toISOString();
const normalizedEnd = new Date(end).toISOString();

normalize the slots before storing, if you store "2026-05-03T10:00:00+02:00" and then later compare against "2026-05-03T08:00:00.000Z" then s.start === start && s.end === end this will fail?

} catch {
return { errors: { time_slots: ["Invalid time slots format"] } };
}

// 3. Authorization: caller must be the coach or user on this session
const result = await getAuthorizedSession(sessionId, callerId);
if (!result.ok) {
return result.state;
}

// 4. Only pending sessions can have their availabilities changed
if (result.session.status !== "pending") {
return {
errors: {
_form: ["Availabilities can only be set for pending sessions"],
},
};
}

// 5. Update the session
try {
await db
.update(coachingSessions)
.set({
selectedTimeSlots: timeSlots,
updatedAt: new Date(),
})
.where(eq(coachingSessions.id, sessionId));
} catch (e) {
console.error(e);
return {
errors: {
_form: [
e instanceof Error
? e.message
: "Could not update availabilities",
],
},
Comment on lines +162 to +164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

avoid sending e.message to the client it could leak sql schema details.

Suggested change
errors: {
_form: [
e instanceof Error
? e.message
: "Could not update availabilities",
],
},
console.error("[SELECT_AVAILABILITIES]", e);
return {
errors: {
_form: ["Could not update availabilities"],
},
};

};
}

return { message: "Availabilities updated." };
}

/**
* Select a confirmed time slot for an existing coaching session.
*
* The chosen `{ start, end }` must be one of the `selected_time_slots`
* already stored on the session. On success, `scheduled_at` is set to the
* slot's start time and the session status becomes `confirmed`.
*/
export async function selectTimeSlot(
_prev: SchedulingActionState,
formData: FormData,
): Promise<SchedulingActionState> {
// 1. Auth gate
Comment on lines +178 to +182
const callerId = await getAuthenticatedUserId();
if (!callerId) {
return { errors: { _form: ["You must be logged in"] } };
}

// 2. Parse & validate input
const parsed = selectTimeSlotSchema.safeParse({
session_id: formData.get("session_id")?.toString(),
start: formData.get("start")?.toString(),
end: formData.get("end")?.toString(),
});

if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}

const { session_id, start, end } = parsed.data;

// 3. Authorization
const result = await getAuthorizedSession(session_id, callerId);
if (!result.ok) {
return result.state;
}

const { session } = result;

// 4. Only pending sessions can be confirmed
if (session.status !== "pending") {
return {
errors: {
_form: ["Only pending sessions can be confirmed"],
},
};
}

// 5. Verify the slot exists in the session's available time slots
const slots = session.selectedTimeSlots as TimeSlot[] | null;
if (!slots || !Array.isArray(slots)) {
return {
errors: {
_form: ["No available time slots have been set for this session"],
},
};
}

const matchingSlot = slots.find(
(s) => s.start === start && s.end === end,
);
if (!matchingSlot) {
return {
errors: {
_form: [
"The selected time slot is not among the available options",
],
},
};
}

// 5. Confirm the session with the chosen slot
try {
await db
.update(coachingSessions)
.set({
scheduledAt: new Date(start),
status: "confirmed",
updatedAt: new Date(),
})
Comment on lines +246 to +250
.where(eq(coachingSessions.id, session_id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

race condition issue between the SELECT and the UPDATE another request could confirm the session.

Suggested change
await db
.update(coachingSessions)
.set({
scheduledAt: new Date(start),
status: "confirmed",
updatedAt: new Date(),
})
.where(eq(coachingSessions.id, session_id));
const updatedRows = await db
.update(coachingSessions)
.set({
scheduledAt: new Date(normalizedStart),
status: "confirmed",
updatedAt: new Date(),
})
.where(
and(
eq(coachingSessions.id, session_id),
eq(coachingSessions.status, "pending"),
),
)
.returning({ id: coachingSessions.id });
if (updatedRows.length === 0) {
return {
errors: {
_form: ["This session is no longer available for confirmation"],
},
};
}

} catch (e) {
console.error(e);
return {
errors: {
_form: [
e instanceof Error
? e.message
: "Could not confirm the time slot",
],
},
};
}

return { message: "Time slot confirmed." };
}
Loading