xtablo-source/api/src/tablo.ts
2025-07-06 21:27:37 +02:00

277 lines
6.8 KiB
TypeScript

import { Hono } from "hono";
import {
authMiddleware,
emailMiddleware,
streamChatMiddleware,
} from "./middleware.js";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Transporter } from "nodemailer";
import { generateToken } from "./token.js";
import { config } from "./config.js";
import type { Database, Tables } from "./database.types.js";
import type { StreamChat } from "stream-chat";
type Tablo = Database["public"]["Tables"]["tablos"];
type TabloInsert = Tablo["Insert"];
export const tabloRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
transporter: Transporter;
streamServerClient: StreamChat;
};
}>();
tabloRouter.use(authMiddleware);
tabloRouter.use(emailMiddleware);
tabloRouter.use(streamChatMiddleware);
tabloRouter.post("/create", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const tablo = data as Omit<TabloInsert, "owner_id">;
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...tablo,
owner_id: user.id,
})
.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: tablo.name,
created_by_id: user.id,
members: [user.id],
});
await channel.create();
return c.json({ message: "Tablo created successfully" });
});
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">;
if (error) {
return c.json({ error: error.message }, 500);
}
const channel = streamServerClient.channel("messaging", updatedTablo.id);
await channel.update({
// @ts-ignore
name: updatedTablo.name,
});
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);
}
const info = 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,
})),
});
});