From 9488f6e5b6abe48853e2aefb4b57d02bd2449061 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 2 Oct 2025 18:45:35 +0200 Subject: [PATCH] Add slot + public page + first sign up --- api/src/__tests__/slots.test.ts | 25 +++++ api/src/tablo.ts | 101 ++++++++++++++++++ api/src/user.ts | 86 ++++++++++++++- sql/21_is_temporary.sql | 8 ++ ui/src/hooks/auth.ts | 73 +++++++++++++ ui/src/hooks/availabilities.ts | 32 ++++++ ui/src/hooks/tablos.ts | 55 ++++++++++ ui/src/pages/PublicBookingPage.tsx | 59 +++++++++-- ui/src/pages/availabilities.tsx | 165 ++++++++++++++++++++++++++--- 9 files changed, 585 insertions(+), 19 deletions(-) create mode 100644 sql/21_is_temporary.sql diff --git a/api/src/__tests__/slots.test.ts b/api/src/__tests__/slots.test.ts index 2f6e815..d7b7851 100644 --- a/api/src/__tests__/slots.test.ts +++ b/api/src/__tests__/slots.test.ts @@ -157,6 +157,31 @@ describe("generateTimeSlots", () => { expect(slots[1].time).to.equal("10:00"); expect(slots[7].time).to.equal("16:00"); }); + + it("should generate slots for the next day correctly", () => { + const currentTime = new Date("2025-09-30T19:39:30.000Z"); // Current time + const nextDay = new Date("2025-10-01T19:39:30.000Z"); // Next day + + const slots = generateTimeSlots( + currentTime, // currentTime (2025-09-30) + nextDay, // date (2025-10-01) + basicAvailability, + basicEventTypeConfig, + [], + existingEvents + ); + + expect(slots).to.have.length(16); // 8 hours * 2 slots per hour + expect(slots[0].time).to.equal("09:00"); + expect(slots[0].date).to.equal("2025-10-01"); + expect(slots[15].time).to.equal("16:30"); + expect(slots[15].date).to.equal("2025-10-01"); + + // All slots should be available since it's a future day + slots.forEach((slot) => { + expect(slot.available).to.be.true; + }); + }); }); describe("Exception handling", () => { diff --git a/api/src/tablo.ts b/api/src/tablo.ts index 2f8edc8..42f64d5 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -98,6 +98,107 @@ tabloRouter.post("/create", async (c) => { return c.json({ message: "Tablo created successfully" }); }); +type PostTabloWithOwner = Omit & { + event: EventInsertInTablo; + owner_short_id: string; +}; + +tabloRouter.post("/create-and-invite", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + const data = await c.req.json(); + + const typedPayload = data as PostTabloWithOwner; + + // Validate that owner_id is provided + if (!typedPayload.owner_short_id) { + return c.json({ error: "owner_id is required" }, 400); + } + + if (!typedPayload.event) { + return c.json({ error: "event is required" }, 400); + } + + // TODO: Verify that the owner_id is correct + const { data: ownerData, error: ownerError } = await supabase + .from("profiles") + .select("id, name") + .eq("short_user_id", typedPayload.owner_short_id) + .single(); + + const { data: invitedUser, error: invitedUserError } = await supabase + .from("profiles") + .select("id, name") + .eq("id", user.id) + .single(); + + if (ownerError || !ownerData) { + return c.json({ error: "owner_id is incorrect" }, 400); + } + + const ownerDataTyped = ownerData as { id: string; name: string }; + const ownerId = ownerDataTyped.id; + const invitedUserDataTyped = invitedUser as { id: string; name: string }; + + // TODO: Verify that the event start and end correspond to a slot + + // Create the tablo with the specified owner + const { data: insertedTablo, error } = await supabase + .from("tablos") + .insert({ + name: `${ownerDataTyped.name || "Propriétaire"} / ${ + invitedUserDataTyped.name || "Invité" + }`, + color: "bg-blue-500", + status: "todo", + owner_id: ownerId, + }) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + const tabloData = insertedTablo as Tables<"tablos">; + + // Create Stream chat channel with the owner as creator + const channel = streamServerClient.channel("messaging", tabloData.id, { + // @ts-ignore + name: tabloData.name, + created_by_id: ownerId, + members: [ownerId, user.id], + }); + await channel.create(); + + // Grant access to the current user (invited user) as a non-admin member + const { error: tabloAccessError } = await supabase + .from("tablo_access") + .insert({ + tablo_id: tabloData.id, + user_id: user.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: ownerId, + }); + + if (tabloAccessError) { + console.error("tabloAccessError", tabloAccessError); + return c.json({ error: tabloAccessError.message }, 500); + } + + await supabase.from("events").insert({ + ...typedPayload.event, + tablo_id: tabloData.id, + created_by: ownerId, + }); + + return c.json({ message: "Tablo created and user invited successfully" }); +}); + tabloRouter.patch("/update", async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); diff --git a/api/src/user.ts b/api/src/user.ts index f7c620b..7803d4d 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -1,5 +1,9 @@ import { Hono } from "hono"; -import { authMiddleware, streamChatMiddleware } from "./middleware.js"; +import { + authMiddleware, + emailMiddleware, + streamChatMiddleware, +} from "./middleware.js"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import { StreamChat } from "stream-chat"; import type { Transporter } from "nodemailer"; @@ -16,6 +20,7 @@ export const userRouter = new Hono<{ userRouter.use(authMiddleware); userRouter.use(streamChatMiddleware); +userRouter.use(emailMiddleware); userRouter.post("/sign-up-to-stream", async (c) => { const { id } = c.get("user"); @@ -70,3 +75,82 @@ userRouter.get("/me", async (c) => { streamToken: token, }); }); + +userRouter.post("/mark-temporary", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const transporter = c.get("transporter"); + + const body = await c.req.json(); + const { temporary_password } = body; + + const { data: profile, error } = await supabase + .from("profiles") + .update({ + is_temporary: true, + }) + .eq("id", user.id) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + try { + if (profile?.email) { + const mailOptions = { + from: process.env.EMAIL_USER, + to: profile.email, + subject: "Bienvenue sur XTablo - Votre mot de passe temporaire", + text: `Bienvenue sur XTablo ! + +Votre compte a été créé avec succès. Voici vos informations de connexion : + +Email : ${profile.email} +Mot de passe temporaire : ${temporary_password} + +Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion. + +Connectez-vous sur : ${process.env.FRONTEND_URL || "https://app.xtablo.com"} + +Cordialement, +L'équipe XTablo`, + html: ` +
+

Bienvenue sur XTablo !

+ +

Votre compte a été créé avec succès. Voici vos informations de connexion :

+ +
+

Email : ${profile.email}

+

Mot de passe temporaire : ${temporary_password}

+
+ +

Important : Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.

+ +

+ + Se connecter à XTablo + +

+ +

+ Cordialement,
+ L'équipe XTablo +

+
+ `, + }; + await transporter.sendMail(mailOptions); + console.log(`Sending welcome email to temporary user: ${profile.email}`); + } + } catch (error) { + console.error("Failed to send welcome email:", error); + } + + return c.json({ + message: "User marked as temporary", + }); +}); diff --git a/sql/21_is_temporary.sql b/sql/21_is_temporary.sql new file mode 100644 index 0000000..a796a36 --- /dev/null +++ b/sql/21_is_temporary.sql @@ -0,0 +1,8 @@ +-- Add is_temporary column to profiles table +ALTER TABLE profiles ADD COLUMN is_temporary BOOLEAN DEFAULT FALSE; + +-- Update the column to be NOT NULL with default value +ALTER TABLE profiles ALTER COLUMN is_temporary SET NOT NULL; + +-- Add a comment to document the column purpose +COMMENT ON COLUMN profiles.is_temporary IS 'Indicates if the user account was created with a temporary password and needs to be changed on first login'; diff --git a/ui/src/hooks/auth.ts b/ui/src/hooks/auth.ts index 44cd4d6..be8c490 100644 --- a/ui/src/hooks/auth.ts +++ b/ui/src/hooks/auth.ts @@ -110,6 +110,79 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { return { mutate, isPending, errors }; } +export function useSignUpWithoutPassword() { + const [errors, setErrors] = useState>({}); + const { signUpToStream } = useSignUpToStream(); + const { mutateAsync, isPending } = useMutation< + AuthResponse, + { message: string; code: string }, + { email: string; name: string } + >({ + mutationFn: async (data: { email: string; name: string }) => { + // Generate a temporary password for the user + const tempPassword = + Math.random().toString(36).slice(-8) + + Math.random().toString(36).slice(-8); + + const { data: response, error } = await supabase.auth.signUp({ + email: data.email.trim(), + password: tempPassword, + options: { + data: { + first_name: data.name.trim().split(" ")[0] || "", + last_name: data.name.trim().split(" ").slice(1).join(" ") || "", + business_name: "", + }, + }, + }); + if (error) throw error; + if (response.session?.access_token) { + await signUpToStream(response.session.access_token); + } + + // Mark the user as temporary + if (response.session?.access_token) { + await api.post( + "/api/v1/users/mark-temporary", + { + temporary_password: tempPassword, + }, + { + headers: { + Authorization: `Bearer ${response.session.access_token}`, + }, + } + ); + } + return response; + }, + onError: (error) => { + const errMap: Record = {}; + + match(error.code) + .with("user_already_exists", () => { + errMap["email"] = "Cette adresse email est déjà utilisée"; + }) + .otherwise(() => { + toast.add( + { + title: "Erreur", + description: error.message, + type: "error", + position: "top-left", + }, + { + timeout: 5000, + } + ); + }); + + setErrors(errMap); + }, + }); + return { mutateAsync, isPending, errors }; +} + export function useSignUpToStream() { const { mutate: signUpToStream } = useMutation({ mutationFn: async (accessToken: string) => { diff --git a/ui/src/hooks/availabilities.ts b/ui/src/hooks/availabilities.ts index e7a239d..4a601ab 100644 --- a/ui/src/hooks/availabilities.ts +++ b/ui/src/hooks/availabilities.ts @@ -103,6 +103,36 @@ export function useAvailabilities() { }, }); + const { mutate: deleteException } = useMutation< + void, + Error, + { exceptionIndex: number } + >({ + mutationFn: async ({ exceptionIndex }: { exceptionIndex: number }) => { + const currentExceptions = + (availabilities?.exceptions as Exception[] | null) || []; + const updatedExceptions = currentExceptions.filter( + (_, index) => index !== exceptionIndex + ); + + const { error } = await supabase.from("availabilities").upsert( + { + availability_data: + availabilities?.availability_data || DEFAULT_AVAILABILITIES, + exceptions: updatedExceptions, + user_id: session?.user.id, + }, + { + onConflict: "user_id", + } + ); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["availabilities"] }); + }, + }); + const [draftAvailabilities, setDraftAvailabilities] = useState(null); @@ -119,5 +149,7 @@ export function useAvailabilities() { updateAvailabilities, draftAvailabilities: draftAvailabilities || DEFAULT_AVAILABILITIES, setDraftAvailabilities, + exceptions: (availabilities?.exceptions as Exception[] | null) || [], + deleteException, }; } diff --git a/ui/src/hooks/tablos.ts b/ui/src/hooks/tablos.ts index 2511ee3..73a9e82 100644 --- a/ui/src/hooks/tablos.ts +++ b/ui/src/hooks/tablos.ts @@ -7,6 +7,7 @@ import { toast } from "@ui/ui-library/toast/toast-queue"; import { RemoveNullFromObject } from "@ui/types/removeNull"; import { useUser } from "@ui/providers/UserStoreProvider"; import { CreateTablo } from "@ui/types/tablos.types"; +import { EventInsertInTablo } from "@ui/types/events.types"; type Tablo = Database["public"]["Tables"]["tablos"]; @@ -68,6 +69,7 @@ export const useTabloMembers = (tabloId: string) => { return { data, isLoading, error }; }; + // Create new tablo export const useCreateTablo = () => { const { session } = useSession(); @@ -101,6 +103,59 @@ export const useCreateTablo = () => { }); }; +// Create tablo with owner +export const useCreateTabloWithOwner = () => { + const queryClient = useQueryClient(); + + return useMutation< + unknown, + unknown, + CreateTablo & { + owner_short_id: string; + event: EventInsertInTablo; + access_token: string; + } + >({ + mutationFn: async ( + tabloAndToken: CreateTablo & { + owner_short_id: string; + event: EventInsertInTablo; + access_token: string; + } + ) => { + const { data } = await api.post( + "/api/v1/tablos/create-and-invite", + { + owner_short_id: tabloAndToken.owner_short_id, + event: tabloAndToken.event, + }, + { + headers: { + Authorization: `Bearer ${tabloAndToken.access_token}`, + }, + } + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tablos"] }); + }, + onError: (error) => { + console.error(error); + toast.add( + { + title: "Échec de la création du tablo", + description: "Veuillez réessayer", + type: "error", + }, + { + timeout: 5000, + } + ); + }, + }); +}; + // Update tablo export const useUpdateTablo = () => { const queryClient = useQueryClient(); diff --git a/ui/src/pages/PublicBookingPage.tsx b/ui/src/pages/PublicBookingPage.tsx index b899aa9..e9916af 100644 --- a/ui/src/pages/PublicBookingPage.tsx +++ b/ui/src/pages/PublicBookingPage.tsx @@ -18,13 +18,19 @@ import { MonitorIcon, } from "lucide-react"; import { usePublicSlots, TimeSlot } from "@ui/hooks/public"; +import { useSignUpWithoutPassword } from "@ui/hooks/auth"; +import { useCreateTabloWithOwner } from "@ui/hooks/tablos"; +import { EventInsertInTablo } from "@ui/types/events.types"; export function PublicBookingPage() { const { user_info, event_type_standard_name } = useParams<{ user_info: string; event_type_standard_name: string; }>(); - + const { + mutateAsync: signUpWithoutPassword, + isPending: isSigningUpWithoutPassword, + } = useSignUpWithoutPassword(); const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1); const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots( @@ -32,6 +38,8 @@ export function PublicBookingPage() { event_type_standard_name || "" ); + const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(); + const userProfile = publicSlots?.user; const eventType = publicSlots?.eventType; const slotsData = publicSlots?.slots || {}; @@ -236,14 +244,48 @@ export function PublicBookingPage() { return isValid; }; - const handleSubmit = () => { + const handleSubmit = async () => { if (validateForm()) { - // TODO: Implement account creation logic - console.log("Creating account with:", { + const { session } = await signUpWithoutPassword({ email: formData.email, name: formData.name, - slot: selectedSlot, }); + + // Calculate end time based on start time and duration + const startTime = selectedSlot?.slot.time || ""; + const duration = eventType?.duration || 60; // duration in minutes + + const calculateEndTime = ( + startTime: string, + durationMinutes: number + ): string => { + if (!startTime) return ""; + + const [hours, minutes] = startTime.split(":").map(Number); + const startDate = new Date(); + startDate.setHours(hours, minutes, 0, 0); + + const endDate = new Date(startDate.getTime() + durationMinutes * 60000); + + return endDate.toTimeString().slice(0, 5); // Format as HH:MM + }; + + const endTime = calculateEndTime(startTime, duration); + + await createTabloWithOwner({ + name: eventType?.name || "", + status: "todo", + owner_short_id: shortUserId || "", + event: { + description: eventType?.description || "", + end_time: endTime || "", + start_date: selectedSlot?.slot.date || "", + start_time: selectedSlot?.slot.time || "", + title: eventType?.name || "", + } as EventInsertInTablo, + access_token: session?.access_token || "", + }); + handleCloseModal(); } }; @@ -555,7 +597,12 @@ export function PublicBookingPage() { - diff --git a/ui/src/pages/availabilities.tsx b/ui/src/pages/availabilities.tsx index cea95c3..fb32070 100644 --- a/ui/src/pages/availabilities.tsx +++ b/ui/src/pages/availabilities.tsx @@ -36,12 +36,17 @@ interface TimeRange { } export function AvailabilitiesPage() { - const { updateAvailabilities, draftAvailabilities, setDraftAvailabilities } = - useAvailabilities(); + const { + updateAvailabilities, + draftAvailabilities, + setDraftAvailabilities, + exceptions, + deleteException, + } = useAvailabilities(); - const [activeTab, setActiveTab] = useState<"reglages" | "visualisation">( - "reglages" - ); + const [activeTab, setActiveTab] = useState< + "availabilities" | "visualisation" | "exceptions" + >("availabilities"); const [copyModalOpen, setCopyModalOpen] = useState(false); const [sourceDayData, setSourceDayData] = useState<{ day: number; @@ -95,12 +100,14 @@ export function AvailabilitiesPage() {

Disponibilités

- {activeTab === "reglages" + {activeTab === "availabilities" ? "Définissez vos horaires de disponibilité pour chaque jour de la semaine" - : "Visualisez votre planning hebdomadaire"} + : activeTab === "visualisation" + ? "Visualisez votre planning hebdomadaire" + : "Gérez vos exceptions de disponibilité"}
- {activeTab === "reglages" && ( + {activeTab === "availabilities" && (
+
- {activeTab === "reglages" && ( + {activeTab === "availabilities" && (
@@ -285,6 +302,130 @@ export function AvailabilitiesPage() { slotDurationMinutes={60} /> )} + + {activeTab === "exceptions" && ( +
+
+
+

Mes exceptions

+ + Gérez vos exceptions de disponibilité pour des dates + spécifiques + +
+ +
+ + {exceptions.length === 0 ? ( +
+
+ + Aucune exception définie + + + Les exceptions vous permettent de modifier vos + disponibilités pour des dates spécifiques. + +
+
+ ) : ( +
+ {exceptions.map((exception, index) => ( +
+
+
+
+ + {new Date(exception.date).toLocaleDateString( + "fr-FR", + { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + } + )} + + + {exception.type === "day" + ? "Indisponible" + : "Horaires personnalisés"} + +
+ + {exception.type === "hours" && "hours" in exception && ( +
+ + Créneaux disponibles : + +
+ {exception.hours.map((timeRange, timeIndex) => ( + + {timeRange.start} - {timeRange.end} + + ))} +
+
+ )} +
+ + +
+
+ ))} +
+ )} +
+ )}
{/* Copy Modal */}