Prod ready let's goooo

This commit is contained in:
Arthur Belleville 2025-10-02 19:54:42 +02:00
parent f5e02a50fa
commit 3d040c065a
No known key found for this signature in database
6 changed files with 212 additions and 78 deletions

View file

@ -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: `
<h2>Votre tablo a é créé avec succès !</h2>
<p>Bonjour ${ownerDataTyped.name},</p>
<p>Un nouveau tablo "${tabloData.name}" a é créé suite à une réservation.</p>
<p><strong>Détails de l'événement :</strong></p>
<ul>
<li>Titre : ${typedPayload.event.title}</li>
<li>Date : ${typedPayload.event.start_date}</li>
<li>Heure : ${typedPayload.event.start_time} - ${typedPayload.event.end_time}</li>
<li>Description : ${typedPayload.event.description}</li>
</ul>
<p>Participant : ${invitedUserDataTyped.name} (${invitedUserDataTyped.email})</p>
<p>Vous pouvez gérer ce tablo depuis votre tableau de bord.</p>
`,
});
// Send email to the invited user
await transporter.sendMail({
to: invitedUserDataTyped.email,
subject: "Réservation confirmée - Nouveau tablo créé",
html: `
<h2>Votre réservation est confirmée !</h2>
<p>Bonjour ${invitedUserDataTyped.name},</p>
<p>Votre réservation a é confirmée et un tablo "${tabloData.name}" a é créé.</p>
<p><strong>Détails de votre rendez-vous :</strong></p>
<ul>
<li>Titre : ${typedPayload.event.title}</li>
<li>Date : ${typedPayload.event.start_date}</li>
<li>Heure : ${typedPayload.event.start_time} - ${typedPayload.event.end_time}</li>
<li>Description : ${typedPayload.event.description}</li>
</ul>
<p>Avec : ${ownerDataTyped.name}</p>
<p>Vous recevrez bientôt plus d'informations pour accéder à votre espace de collaboration.</p>
`,
});
}
return c.json({ id: tabloData.id });
});
tabloRouter.patch("/update", async (c) => {

View file

@ -127,7 +127,7 @@ L'équipe XTablo`,
<p><strong>Mot de passe temporaire :</strong> <code style="background-color: #e1e1e1; padding: 2px 4px; border-radius: 3px;">${temporary_password}</code></p>
</div>
<p style="color: #d9534f;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
<p>
<a href="${process.env.FRONTEND_URL || "https://app.tablo.fr"}"

View file

@ -8,6 +8,7 @@ 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";
import { useNavigate } from "react-router-dom";
type Tablo = Database["public"]["Tables"]["tablos"];
@ -106,9 +107,10 @@ export const useCreateTablo = () => {
// 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);

View file

@ -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() {
<CustomModal
isOpen={isModalOpen}
onClose={handleCloseModal}
title="Créer un compte pour réserver"
title={
user ? "Confirmer la réservation" : "Créer un compte pour réserver"
}
width="md"
>
{selectedSlot && (
@ -559,38 +589,42 @@ export function PublicBookingPage() {
)}
<div className="space-y-4">
<TextField isRequired isInvalid={!!formErrors.name}>
<TextField
isRequired
value={user?.name || formData.name}
onChange={(value) =>
setFormData((prev) => ({ ...prev, name: value }))
}
isInvalid={!!formErrors.name}
isDisabled={!!user}
>
<Label>Nom complet</Label>
<Input
type="text"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Votre nom complet"
/>
<Input type="text" placeholder="Votre nom complet" />
{formErrors.name && <FieldError>{formErrors.name}</FieldError>}
</TextField>
<TextField isRequired isInvalid={!!formErrors.email}>
<TextField
isRequired
value={user?.email || formData.email}
onChange={(value) =>
setFormData((prev) => ({ ...prev, email: value }))
}
isInvalid={!!formErrors.email}
isDisabled={!!user}
>
<Label>Adresse email</Label>
<Input
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
placeholder="votre@email.com"
/>
<Input type="email" placeholder="votre@email.com" />
{formErrors.email && <FieldError>{formErrors.email}</FieldError>}
</TextField>
<div className="pt-2">
<Text className="text-sm text-gray-600 dark:text-gray-400">
Un compte sera créé avec ces informations pour gérer votre
réservation.
</Text>
</div>
{!user && (
<div className="pt-2">
<Text className="text-sm text-gray-600 dark:text-gray-400">
Un compte sera créé avec ces informations pour gérer votre
réservation.
</Text>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
@ -599,11 +633,11 @@ export function PublicBookingPage() {
</Button>
<Button
variant="solid"
onPress={handleSubmit}
isPending={isSigningUpWithoutPassword}
pendingLabel="Création du compte..."
onPress={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
isPending={user ? isCreatingTablo : isSigningUpWithoutPassword}
pendingLabel={user ? "Réservation..." : "Création du compte..."}
>
Créer le compte et réserver
{user ? "Confirmer la réservation" : "Créer le compte et réserver"}
</Button>
</div>
</CustomModal>

View file

@ -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]);

View file

@ -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,