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 :
+
+ - Titre : ${typedPayload.event.title}
+ - Date : ${typedPayload.event.start_date}
+ - Heure : ${typedPayload.event.start_time} - ${typedPayload.event.end_time}
+ - Description : ${typedPayload.event.description}
+
+ 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 :
+
+ - Titre : ${typedPayload.event.title}
+ - Date : ${typedPayload.event.start_date}
+ - Heure : ${typedPayload.event.start_time} - ${typedPayload.event.end_time}
+ - Description : ${typedPayload.event.description}
+
+ 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() {
)}
@@ -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,