diff --git a/api/src/tablo.ts b/api/src/tablo.ts index 42f64d5..16fa032 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -123,13 +123,13 @@ tabloRouter.post("/create-and-invite", async (c) => { // TODO: Verify that the owner_id is correct const { data: ownerData, error: ownerError } = await supabase .from("profiles") - .select("id, name") + .select("id, name, email") .eq("short_user_id", typedPayload.owner_short_id) .single(); const { data: invitedUser, error: invitedUserError } = await supabase .from("profiles") - .select("id, name") + .select("id, name, email") .eq("id", user.id) .single(); @@ -137,40 +137,68 @@ tabloRouter.post("/create-and-invite", async (c) => { return c.json({ error: "owner_id is incorrect" }, 400); } - const ownerDataTyped = ownerData as { id: string; name: string }; + const ownerDataTyped = ownerData as { + id: string; + name: string; + email: string; + }; const ownerId = ownerDataTyped.id; - const invitedUserDataTyped = invitedUser as { id: string; name: string }; + const invitedUserDataTyped = invitedUser as { + id: string; + name: string; + email: 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 + // Check if there's already a tablo between the owner and the invited user + const { data: existingTablo, error: existingTabloError } = await supabase .from("tablos") - .insert({ - name: `${ownerDataTyped.name || "Propriétaire"} / ${ - invitedUserDataTyped.name || "Invité" - }`, - color: "bg-blue-500", - status: "todo", - owner_id: ownerId, - }) - .select() - .single(); + .select( + ` + id, + name, + owner_id, + tablo_access!inner(user_id) + ` + ) + .eq("owner_id", ownerId) + .eq("tablo_access.user_id", user.id) + .is("deleted_at", null) + .limit(1); - if (error) { - return c.json({ error: error.message }, 500); + if (existingTabloError) { + console.error("existingTabloError", existingTabloError); + return c.json({ error: existingTabloError.message }, 500); } - const tabloData = insertedTablo as Tables<"tablos">; + console.log("existingTablo", existingTablo); - // 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(); + let tabloData: { id: string; name: string } | null = null; + + if (!existingTablo.length) { + // 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); + } + + tabloData = insertedTablo as { id: string; name: string }; + } else { + tabloData = existingTablo[0] as { id: string; name: string }; + } // Grant access to the current user (invited user) as a non-admin member const { error: tabloAccessError } = await supabase @@ -190,13 +218,73 @@ tabloRouter.post("/create-and-invite", async (c) => { return c.json({ error: tabloAccessError.message }, 500); } + // 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(); + + // Send a welcome message to the channel + await channel.sendMessage({ + text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${typedPayload.event.title}" est confirmé pour le ${typedPayload.event.start_date} de ${typedPayload.event.start_time} à ${typedPayload.event.end_time}.`, + user_id: ownerId, + }); + await supabase.from("events").insert({ ...typedPayload.event, tablo_id: tabloData.id, created_by: ownerId, }); - return c.json({ message: "Tablo created and user invited successfully" }); + // Send email notifications to both owner and invited user + const transporter = c.get("transporter"); + + if (transporter) { + // Send email to the owner + await transporter.sendMail({ + to: ownerDataTyped.email, + subject: "Nouveau tablo créé - Réservation confirmée", + html: ` +

Votre tablo a été créé avec succès !

+

Bonjour ${ownerDataTyped.name},

+

Un nouveau tablo "${tabloData.name}" a été créé suite à une réservation.

+

Détails de l'événement :

+ +

Participant : ${invitedUserDataTyped.name} (${invitedUserDataTyped.email})

+

Vous pouvez gérer ce tablo depuis votre tableau de bord.

+ `, + }); + + // Send email to the invited user + await transporter.sendMail({ + to: invitedUserDataTyped.email, + subject: "Réservation confirmée - Nouveau tablo créé", + html: ` +

Votre réservation est confirmée !

+

Bonjour ${invitedUserDataTyped.name},

+

Votre réservation a été confirmée et un tablo "${tabloData.name}" a été créé.

+

Détails de votre rendez-vous :

+ +

Avec : ${ownerDataTyped.name}

+

Vous recevrez bientôt plus d'informations pour accéder à votre espace de collaboration.

+ `, + }); + } + + return c.json({ id: tabloData.id }); }); tabloRouter.patch("/update", async (c) => { diff --git a/api/src/user.ts b/api/src/user.ts index 7803d4d..16e185b 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -127,7 +127,7 @@ L'équipe XTablo`,

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.

+

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

{ // Create tablo with owner export const useCreateTabloWithOwner = () => { const queryClient = useQueryClient(); + const navigate = useNavigate(); return useMutation< - unknown, + { id: string }, unknown, CreateTablo & { owner_short_id: string; @@ -137,8 +139,10 @@ export const useCreateTabloWithOwner = () => { ); return data; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["tablos"] }); + navigate(`/chat/${data.id}`, { replace: true }); + navigate(0); }, onError: (error) => { console.error(error); diff --git a/ui/src/pages/PublicBookingPage.tsx b/ui/src/pages/PublicBookingPage.tsx index e9916af..d7b22bd 100644 --- a/ui/src/pages/PublicBookingPage.tsx +++ b/ui/src/pages/PublicBookingPage.tsx @@ -6,6 +6,7 @@ import { Button } from "@ui/ui-library/button"; import { CustomModal } from "@ui/components/CustomModal"; import { TextField, Label, Input, FieldError } from "@ui/ui-library/field"; import { useTheme } from "@ui/contexts/ThemeContext"; +import { useMaybeUser } from "@ui/providers/UserStoreProvider"; import { CalendarIcon, ClockIcon, @@ -21,6 +22,7 @@ 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"; +import { useSession } from "@ui/contexts/SessionContext"; export function PublicBookingPage() { const { user_info, event_type_standard_name } = useParams<{ @@ -31,6 +33,8 @@ export function PublicBookingPage() { mutateAsync: signUpWithoutPassword, isPending: isSigningUpWithoutPassword, } = useSignUpWithoutPassword(); + const { session } = useSession(); + const user = useMaybeUser(); const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1); const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots( @@ -38,7 +42,8 @@ export function PublicBookingPage() { event_type_standard_name || "" ); - const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(); + const { mutateAsync: createTabloWithOwner, isPending: isCreatingTablo } = + useCreateTabloWithOwner(); const userProfile = publicSlots?.user; const eventType = publicSlots?.eventType; @@ -244,32 +249,55 @@ export function PublicBookingPage() { return isValid; }; - const handleSubmit = async () => { + // Calculate end time based on start time and duration + 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 handleSubmitIfNotLoggedIn = async () => { if (validateForm()) { - const { session } = await signUpWithoutPassword({ + const { session: sessionFromSignUp } = await signUpWithoutPassword({ email: formData.email, name: formData.name, }); - // Calculate end time based on start time and duration const startTime = selectedSlot?.slot.time || ""; const duration = eventType?.duration || 60; // duration in minutes + const endTime = calculateEndTime(startTime, duration); - const calculateEndTime = ( - startTime: string, - durationMinutes: number - ): string => { - if (!startTime) return ""; + 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: sessionFromSignUp?.access_token || "", + }); - 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 - }; + handleCloseModal(); + } + }; + const handleSubmitIfLoggedIn = async () => { + if (user) { + const startTime = selectedSlot?.slot.time || ""; + const duration = eventType?.duration || 60; // duration in minutes const endTime = calculateEndTime(startTime, duration); await createTabloWithOwner({ @@ -536,7 +564,9 @@ export function PublicBookingPage() { {selectedSlot && ( @@ -559,38 +589,42 @@ export function PublicBookingPage() { )}

- + + setFormData((prev) => ({ ...prev, name: value })) + } + isInvalid={!!formErrors.name} + isDisabled={!!user} + > - - setFormData((prev) => ({ ...prev, name: e.target.value })) - } - placeholder="Votre nom complet" - /> + {formErrors.name && {formErrors.name}} - + + setFormData((prev) => ({ ...prev, email: value })) + } + isInvalid={!!formErrors.email} + isDisabled={!!user} + > - - setFormData((prev) => ({ ...prev, email: e.target.value })) - } - placeholder="votre@email.com" - /> + {formErrors.email && {formErrors.email}} -
- - Un compte sera créé avec ces informations pour gérer votre - réservation. - -
+ {!user && ( +
+ + Un compte sera créé avec ces informations pour gérer votre + réservation. + +
+ )}
@@ -599,11 +633,11 @@ export function PublicBookingPage() {
diff --git a/ui/src/pages/bookings.tsx b/ui/src/pages/bookings.tsx index 7ed89be..acab950 100644 --- a/ui/src/pages/bookings.tsx +++ b/ui/src/pages/bookings.tsx @@ -101,7 +101,7 @@ export const BookingsPage = () => { return filtered.sort((a, b) => { if (!a.start_date || !b.start_date) return 0; return ( - new Date(b.start_date).getTime() - new Date(a.start_date).getTime() + new Date(a.start_date).getTime() - new Date(b.start_date).getTime() ); }); }, [events, searchTerm, statusFilter]); diff --git a/ui/src/providers/UserStoreProvider.tsx b/ui/src/providers/UserStoreProvider.tsx index 6f6a463..c7da51b 100644 --- a/ui/src/providers/UserStoreProvider.tsx +++ b/ui/src/providers/UserStoreProvider.tsx @@ -62,6 +62,14 @@ export const useUser = () => { return useStore(store); }; +export const useMaybeUser = () => { + const store = React.useContext(UserStoreContext); + if (!store) { + return null; + } + return useStore(store); +}; + // TestUserStoreProvider component export const TestUserStoreProvider = ({ children,