Add slot + public page + first sign up

This commit is contained in:
Arthur Belleville 2025-10-02 18:45:35 +02:00
parent 5486d89726
commit 9488f6e5b6
No known key found for this signature in database
9 changed files with 585 additions and 19 deletions

View file

@ -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", () => {

View file

@ -98,6 +98,107 @@ tabloRouter.post("/create", async (c) => {
return c.json({ message: "Tablo created successfully" });
});
type PostTabloWithOwner = Omit<TabloInsert, "owner_id"> & {
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");

View file

@ -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 é 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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Bienvenue sur XTablo !</h2>
<p>Votre compte a é créé avec succès. Voici vos informations de connexion :</p>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Email :</strong> ${profile.email}</p>
<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>
<a href="${process.env.FRONTEND_URL || "https://app.tablo.fr"}"
style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
Se connecter à XTablo
</a>
</p>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</p>
</div>
`,
};
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",
});
});

8
sql/21_is_temporary.sql Normal file
View file

@ -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';

View file

@ -110,6 +110,79 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
return { mutate, isPending, errors };
}
export function useSignUpWithoutPassword() {
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, string> = {};
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) => {

View file

@ -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<WeeklyAvailability | null>(null);
@ -119,5 +149,7 @@ export function useAvailabilities() {
updateAvailabilities,
draftAvailabilities: draftAvailabilities || DEFAULT_AVAILABILITIES,
setDraftAvailabilities,
exceptions: (availabilities?.exceptions as Exception[] | null) || [],
deleteException,
};
}

View file

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

View file

@ -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() {
<Button variant="outline" onPress={handleCloseModal}>
Annuler
</Button>
<Button variant="solid" onPress={handleSubmit}>
<Button
variant="solid"
onPress={handleSubmit}
isPending={isSigningUpWithoutPassword}
pendingLabel="Création du compte..."
>
Créer le compte et réserver
</Button>
</div>

View file

@ -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() {
<div>
<h2 className="text-2xl font-bold">Disponibilités</h2>
<Strong className="text-gray-500 mt-2 text-xl">
{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é"}
</Strong>
</div>
{activeTab === "reglages" && (
{activeTab === "availabilities" && (
<div className="flex gap-2">
<Button
size="lg"
@ -180,14 +187,24 @@ export function AvailabilitiesPage() {
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-6">
<button
onClick={() => setActiveTab("reglages")}
onClick={() => setActiveTab("availabilities")}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === "reglages"
activeTab === "availabilities"
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
Réglages
Disponibilités
</button>
<button
onClick={() => setActiveTab("exceptions")}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === "exceptions"
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
Exceptions
</button>
<button
onClick={() => setActiveTab("visualisation")}
@ -202,7 +219,7 @@ export function AvailabilitiesPage() {
</div>
<div className="flex-1 overflow-auto">
{activeTab === "reglages" && (
{activeTab === "availabilities" && (
<div className="flex items-start">
<div className="flex-1 pr-6 border-r border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4">
@ -285,6 +302,130 @@ export function AvailabilitiesPage() {
slotDurationMinutes={60}
/>
)}
{activeTab === "exceptions" && (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xl font-semibold">Mes exceptions</h3>
<Text className="text-gray-500 mt-1">
Gérez vos exceptions de disponibilité pour des dates
spécifiques
</Text>
</div>
<Button
variant="solid"
size="lg"
onPress={() => setExceptionModalOpen(true)}
className="[--btn-bg:var(--color-blue-800)]"
>
<PlusIcon /> Ajouter une exception
</Button>
</div>
{exceptions.length === 0 ? (
<div className="text-center py-12">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-8">
<Text className="text-gray-500 dark:text-gray-400 text-lg mb-4">
Aucune exception définie
</Text>
<Text className="text-gray-400 dark:text-gray-500 text-sm">
Les exceptions vous permettent de modifier vos
disponibilités pour des dates spécifiques.
</Text>
</div>
</div>
) : (
<div className="grid gap-4">
{exceptions.map((exception, index) => (
<div
key={`${exception.date}-${index}`}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Strong className="text-lg">
{new Date(exception.date).toLocaleDateString(
"fr-FR",
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}
)}
</Strong>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
exception.type === "day"
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
}`}
>
{exception.type === "day"
? "Indisponible"
: "Horaires personnalisés"}
</span>
</div>
{exception.type === "hours" && "hours" in exception && (
<div className="space-y-1">
<Text className="text-sm text-gray-600 dark:text-gray-400 font-medium">
Créneaux disponibles :
</Text>
<div className="flex flex-wrap gap-2">
{exception.hours.map((timeRange, timeIndex) => (
<span
key={timeIndex}
className="px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded text-sm"
>
{timeRange.start} - {timeRange.end}
</span>
))}
</div>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onPress={() => {
deleteException(
{ exceptionIndex: index },
{
onSuccess: () => {
toast.add({
title: "Succès",
description:
"Exception supprimée avec succès",
type: "success",
});
},
onError: (err) => {
console.error(err);
toast.add({
title: "Erreur",
description:
"Erreur lors de la suppression de l'exception",
type: "error",
});
},
}
);
}}
className="text-red-600 hover:text-red-700 border-red-300 hover:border-red-400"
>
Supprimer
</Button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Copy Modal */}