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

import { eq,and } 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({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")
.max(50,"You can select up to 50 time slots")
});
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 };
}


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
}
}

function normalizeSlots(slot: TimeSlot): TimeSlot {
return {
start: new Date(slot.start).toISOString(),
end: new Date(slot.end).toISOString()
}
}


/**
* Set (or replace) the available time slots on an existing coaching session.
*
* 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
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: rawTimeSlots
})

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

const sessionId = parsed.data.session_id;
const timeSlots = parsed.data.time_slots.map(normalizeSlots)

// 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("[SELECT_AVAILABILITIES]", e);
return {
errors: {
_form: ["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} = parsed.data;
const {start, end} = normalizeSlots({start: parsed.data.start, end: parsed.data.end})

// 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 {
const updatedRows = await db
.update(coachingSessions)
.set({
scheduledAt: new Date(start),
status: "confirmed",
updatedAt: new Date(),
})
Comment on lines +246 to +250
.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("[SELECT_TIME_SLOT]", e);
return {
errors: {
_form: ["Could not confirm the time slot"],
},
};
}

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