622 lines
17 KiB
TypeScript
622 lines
17 KiB
TypeScript
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
|
|
import {
|
|
PostgrestError,
|
|
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 { generateICSFromEvents, writeCalendarFileToR2 } from "./helpers.js";
|
|
import {
|
|
authMiddleware,
|
|
r2Middleware,
|
|
streamChatMiddleware,
|
|
} from "./middleware.js";
|
|
import { generateToken } from "./token.js";
|
|
import { transporter } from "./transporter.js";
|
|
import type {
|
|
EventAndTablo,
|
|
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);
|
|
|
|
// TODO: verify in tablo access that the user is admin
|
|
|
|
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 transporter = c.get("transporter");
|
|
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 { 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: `<p>Vous avez été invité à un tablo avec <a href="${
|
|
config.XTABLO_URL
|
|
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
|
|
token
|
|
)}">ce lien</a></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);
|
|
|
|
const channel = streamServerClient.channel("messaging", tablo_id);
|
|
await channel.addMembers([joiner.id]);
|
|
|
|
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,
|
|
});
|
|
});
|