Conversation
There was a problem hiding this comment.
Pull request overview
Adds server actions to support scheduling flows for existing coaching_sessions rows: setting available time slots and confirming a chosen slot, with auth + authorization checks and Zod validation.
Changes:
- Introduces
selectAvailabilitiesto store/replaceselected_time_slotsfor a session. - Introduces
selectTimeSlotto confirm a chosen slot by settingscheduled_atandstatus = confirmed. - Adds shared helpers/schemas for auth gating and session authorization.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .set({ | ||
| scheduledAt: new Date(start), | ||
| status: "confirmed", | ||
| updatedAt: new Date(), | ||
| }) |
| await db | ||
| .update(coachingSessions) | ||
| .set({ | ||
| selectedTimeSlots: timeSlots, |
| export async function selectAvailabilities( | ||
| _prev: SchedulingActionState, | ||
| formData: FormData, | ||
| ): Promise<SchedulingActionState> { |
| export async function selectTimeSlot( | ||
| _prev: SchedulingActionState, | ||
| formData: FormData, | ||
| ): Promise<SchedulingActionState> { | ||
| // 1. Auth gate |
| message?: string; | ||
| } | null; | ||
|
|
||
| const iso8601 = z.string().datetime({ message: "Must be an ISO 8601 datetime" }); |
There was a problem hiding this comment.
| 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 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"), | ||
| }); |
There was a problem hiding this comment.
| 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
| let sessionId: string; | ||
| let timeSlots: TimeSlot[]; | ||
| try { | ||
| const rawSlots = formData.get("time_slots")?.toString() ?? ""; |
There was a problem hiding this comment.
i would isolate the parsing.
| 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"] } }; | |
| } |
| } | ||
|
|
||
| sessionId = parsed.data.session_id; | ||
| timeSlots = parsed.data.time_slots as TimeSlot[]; |
There was a problem hiding this comment.
| 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?
| await db | ||
| .update(coachingSessions) | ||
| .set({ | ||
| scheduledAt: new Date(start), | ||
| status: "confirmed", | ||
| updatedAt: new Date(), | ||
| }) | ||
| .where(eq(coachingSessions.id, session_id)); |
There was a problem hiding this comment.
race condition issue between the SELECT and the UPDATE another request could confirm the session.
| 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"], | |
| }, | |
| }; | |
| } |
| * 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( |
There was a problem hiding this comment.
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"],
},
};
}There was a problem hiding this comment.
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.
| errors: { | ||
| _form: [ | ||
| e instanceof Error | ||
| ? e.message | ||
| : "Could not update availabilities", | ||
| ], | ||
| }, |
There was a problem hiding this comment.
avoid sending e.message to the client it could leak sql schema details.
| errors: { | |
| _form: [ | |
| e instanceof Error | |
| ? e.message | |
| : "Could not update availabilities", | |
| ], | |
| }, | |
| console.error("[SELECT_AVAILABILITIES]", e); | |
| return { | |
| errors: { | |
| _form: ["Could not update availabilities"], | |
| }, | |
| }; |
| time_slots: z | ||
| .array(timeSlotSchema) | ||
| .min(1, "At least one time slot is required") | ||
| .max(20,"You can select up to 20 time slots") |
There was a problem hiding this comment.
20 is too low, make it 50
| * 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( |
There was a problem hiding this comment.
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.
… choach and user can select a timeslot
Closes #47
Overview
selectAvailabilities — Sets available time slots ([{start, end}]) on an existing coaching_sessions row.
selectTimeSlot — Picks one of those slots, setting scheduled_at and confirming the session.
Testing
Manual testing: Used Drizzle Studio (pnpm db:studio) to create a test coaching session, verified that selected_time_slots, scheduled_at, and status columns update correctly through the full scheduling flow.
Unit testing: Wrote 18 Jest tests covering auth rejection, input validation (Zod), authorization checks, and success paths for both coach and user roles. All passing.
Checklist