xtablo-source/api/src/tablo.ts
Arthur Belleville e2d4f3b56b
Fix email
2025-10-18 11:06:11 +02:00

647 lines
17 KiB
TypeScript

import { type S3Client } from "@aws-sdk/client-s3";
import { type SupabaseClient, type User } from "@supabase/supabase-js";
import { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { writeCalendarFileToR2 } from "./helpers.js";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import { generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
export const tabloRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
transporter: Transporter;
streamServerClient: StreamChat;
s3_client: S3Client;
};
}>();
// const webcalRouter = new Hono<{
// Variables: {
// user: User;
// supabase: SupabaseClient;
// s3_client: S3Client;
// };
// }>();
// webcalRouter.use(r2Middleware);
tabloRouter.use(authMiddleware);
tabloRouter.use(streamChatMiddleware);
tabloRouter.use(r2Middleware);
// tabloRouter.route("/webcal", webcalRouter);
type PostTablo = Omit<TabloInsert, "owner_id"> & {
events?: EventInsertInTablo[];
};
tabloRouter.post("/create", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const typedPayload = data as PostTablo;
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...typedPayload,
owner_id: user.id,
events: undefined,
})
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
const tabloData = insertedTablo as Tables<"tablos">;
const streamServerClient = c.get("streamServerClient");
const channel = streamServerClient.channel("messaging", tabloData.id, {
// @ts-ignore
name: tabloData.name,
created_by_id: user.id,
members: [user.id],
});
await channel.create();
if (typedPayload.events) {
const eventsToInsert = typedPayload.events.map((event) => ({
...event,
tablo_id: tabloData.id,
created_by: user.id,
}));
await supabase.from("events").insert(eventsToInsert);
}
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, email")
.eq("short_user_id", typedPayload.owner_short_id)
.single();
const { data: invitedUser, error: invitedUserError } = await supabase
.from("profiles")
.select("id, name, email")
.eq("id", user.id)
.single();
if (ownerError || !ownerData || invitedUserError || !invitedUser) {
return c.json({ error: "owner_id or invited_user_id is incorrect" }, 400);
}
const ownerDataTyped = ownerData as {
id: string;
name: string;
email: string;
};
const ownerId = ownerDataTyped.id;
const invitedUserDataTyped = invitedUser as {
id: string;
name: string;
email: string;
};
// TODO: Verify that the event start and end correspond to a slot
// Check if there's already a tablo between the owner and the invited user
const { data: existingTablo, error: existingTabloError } = await supabase
.from("tablos")
.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 (existingTabloError) {
console.error("existingTabloError", existingTabloError);
return c.json({ error: existingTabloError.message }, 500);
}
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: `${invitedUserDataTyped.name || "Invité"} / ${
ownerDataTyped.name || "Propriétaire"
}`,
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
.from("tablo_access")
.insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
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,
});
// Send email notifications to both owner and invited user
// Send email to the owner
await transporter.sendMail({
from: "Xtablo <noreply@xtablo.com>",
to: ownerDataTyped.email,
subject: "Nouveau tablo créé - Réservation confirmée",
html: `
<h2>Votre tablo a été créé avec succès !</h2>
<p>Bonjour ${ownerDataTyped.name},</p>
<p>Un nouveau tablo "${tabloData.name}" a été 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({
from: "Xtablo <noreply@xtablo.com>",
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 été confirmée et un tablo "${tabloData.name}" a été 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) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id, ...tablo } = data;
const { data: update, error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
// TODO: this condition will need to be modified in the future
.eq("owner_id", user.id)
.select()
.single();
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName =
tablo.name !== undefined && tablo.name !== updatedTablo.name;
if (error) {
return c.json({ error: error.message }, 500);
}
if (isUpdatingName) {
const channel = streamServerClient.channel("messaging", updatedTablo.id);
try {
await channel.update({
// @ts-ignore
name: updatedTablo.name,
});
} catch (error) {
console.error("error updating channel", error);
}
}
return c.json({ message: "Tablo updated successfully" });
});
tabloRouter.delete("/delete", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id } = data;
const { error } = await supabase
.from("tablos")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id)
.eq("owner_id", user.id);
// Verify that the user has admin access to this tablo
const { data: tabloAccess, error: accessError } = await supabase
.from("tablo_access")
.select("is_admin")
.eq("tablo_id", id)
.eq("user_id", user.id)
.eq("is_active", true)
.single();
if (accessError || !tabloAccess || !tabloAccess.is_admin) {
return c.json(
{ error: "You are not authorized to delete this tablo" },
403
);
}
if (error) {
return c.json({ error: error.message }, 500);
}
const channel = streamServerClient.channel("messaging", id);
try {
await channel.delete();
} catch (error) {
console.error("error deleting channel", error);
}
return c.json({ message: "Tablo deleted successfully" });
});
tabloRouter.post("/invite", async (c) => {
const sender = c.get("user");
const supabase = c.get("supabase");
const { email: recipientmail, tablo_id } = await c.req.json();
const token = generateToken();
const { data, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.single();
const tablo = data as Tables<"tablos">;
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
}
if (!tablo) {
return c.json({ error: "Tablo not found" }, 404);
}
if (tablo.owner_id !== sender.id) {
return c.json(
{ error: "You are not allowed to invite users to this tablo" },
400
);
}
const { data: introConfigData, error: introError } = await supabase
.from("user_introductions")
.select("config")
.eq("user_id", sender.id)
.single();
if (introError) {
return c.json({ error: introError.message }, 500);
}
const introEmail = introConfigData?.config?.intro_email;
const { error } = await supabase.from("tablo_invites").insert({
invited_email: recipientmail,
tablo_id: tablo_id,
invited_by: sender.id,
invite_token: token,
});
if (error) {
return c.json({ error: error.message }, 500);
}
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
subject: "Vous avez été invité à un tablo",
html: `
${introEmail ? `<p>${introEmail}</p>` : ""}
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p>Cordialement.</p>
`,
});
return c.json({
message: "Invite sent successfully",
});
});
tabloRouter.post("/join", async (c) => {
const { token } = await c.req.json();
const joiner = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data: inviteData, error } = await supabase
.from("tablo_invites")
.select("id, tablo_id, invited_by")
.eq("invite_token", token)
.eq("invited_email", joiner.email)
.single();
if (error) {
console.error("error", error);
return c.json({ error: error.message }, 500);
}
if (!inviteData) {
return c.json({ error: "Invalid token or email" }, 400);
}
const { id: invite_id, tablo_id, invited_by } = inviteData;
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert({
tablo_id,
user_id: joiner.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: invited_by,
});
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
return c.json({ error: tabloAccessError.message }, 500);
}
await supabase.from("tablo_invites").delete().eq("id", invite_id);
try {
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.addMembers([joiner.id]);
} catch (error) {
console.error("error adding member to channel", error);
}
return c.json({ message: "Tablo joined successfully" });
});
tabloRouter.get("/members/:tablo_id", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { tablo_id } = c.req.param();
const { data: tabloData, error: tabloError } = await supabase
.from("user_tablos")
.select("*")
.eq("id", tablo_id)
.eq("user_id", user.id);
if (!tabloData || tabloData.length === 0) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
if (tabloError) {
return c.json({ error: "Internal server error" }, 500);
}
const { data, error } = await supabase
.from("tablo_access")
.select("is_admin, profiles(id, name)")
.eq("tablo_id", tablo_id)
.eq("is_active", true);
const rows = data as unknown as {
is_admin: boolean;
profiles: {
id: string;
name: string;
};
}[];
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
members: rows.map((member) => ({
...member.profiles,
is_admin: member.is_admin,
})),
});
});
tabloRouter.post("/leave", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { tablo_id } = await c.req.json();
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.removeMembers([user.id]);
const { error } = await supabase
.from("tablo_access")
.update({ is_active: false })
.eq("tablo_id", tablo_id)
.eq("user_id", user.id);
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ message: "Tablo left successfully" });
});
tabloRouter.post("/webcal/generate-url", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3_client = c.get("s3_client");
const { tablo_id } = await c.req.json();
if (tablo_id === null) {
return c.json({ error: "All tablos are not supported" }, 400);
}
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tablo_id)
.single();
if (tabloError || !tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
const tabloName = tabloData.name.replace(/ /g, "_");
const { data: accessData, error: accessError } = await supabase
.from("user_tablos")
.select("id")
.eq("id", tablo_id)
.eq("user_id", user.id)
.single();
if (accessError || !accessData) {
return c.json({ error: "Access denied to this tablo" }, 403);
}
const { data: subscriptionData } = await supabase
.from("calendar_subscriptions")
.select("*")
.eq("tablo_id", tablo_id)
.single();
// if (subscriptionError || !subscriptionData) {
// return c.json({ error: "Subscription already exists" }, 400);
// }
if (subscriptionData) {
const token = subscriptionData.token;
const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
return c.json({
webcal_url: null,
http_url: httpUrl,
});
}
const token = generateToken();
const { error } = await supabase.from("calendar_subscriptions").insert({
tablo_id: tablo_id,
token: token,
});
if (error) {
return c.json({ error: "Failed to generate token" }, 500);
}
try {
await writeCalendarFileToR2(s3_client, supabase, {
token,
tabloName,
tablo_id,
});
} catch (error) {
console.error("error writing calendar file to R2", error);
return c.json({ error: "Failed to write calendar file to R2" }, 500);
}
// Return the webcal URL
// const webcalUrl = `webcal://${
// c.req.header("host") || "localhost:3000"
// }/api/v1/tablos/webcal/${tablo_id}/${token}`;
const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
return c.json({
webcal_url: null,
http_url: httpUrl,
});
});