Create two packages: api and shared-types

This commit is contained in:
Arthur Belleville 2025-11-10 08:52:47 +01:00
parent 03a25dace3
commit 92b0646176
No known key found for this signature in database
92 changed files with 9390 additions and 11953 deletions

View file

@ -1,13 +0,0 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
STREAM_CHAT_API_KEY=t5vvvddteapa
XTABLO_URL="https://app-staging.xtablo.com"
CORS_ORIGIN="http://localhost:5173,http://localhost:5174"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
TASKS_SECRET="hello"
EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"

View file

@ -1,13 +0,0 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
STREAM_CHAT_API_KEY=v4yf8rs94aa8
XTABLO_URL=https://app.xtablo.com
CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"

View file

@ -1,11 +0,0 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
STREAM_CHAT_API_KEY=t5vvvddteapa
XTABLO_URL="https://app-staging.xtablo.com"
CORS_ORIGIN="https://app-staging.xtablo.com"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"

View file

@ -1,8 +0,0 @@
{
"extension": ["ts"],
"spec": "src/**/*.{test,spec}.ts",
"require": ["tsx/cjs"],
"timeout": 5000,
"recursive": true,
"reporter": "spec"
}

View file

@ -1,21 +0,0 @@
```txt
npm install
npm run dev
```
```txt
npm run deploy
```
[For generating/synchronizing types based on your Worker configuration run](https://developers.cloudflare.com/workers/wrangler/commands/#types):
```txt
npm run cf-typegen
```
Pass the `CloudflareBindings` as generics when instantiation `Hono`:
```ts
// src/index.ts
const app = new Hono<{ Bindings: CloudflareBindings }>()
```

8525
api/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,290 +0,0 @@
import { Hono } from "hono";
import { MiddlewareManager } from "./middleware.js";
import { createInvitedUser } from "./helpers.js";
import type { Database, TablesInsert } from "./database.types.js";
import type { EventTypeConfig } from "./slots.js";
export const getBookingRouter = () => {
const bookingRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
type BookSlotPayload = {
event_type_standard_name: string;
owner_short_id: string;
event_details: {
start_date: string;
start_time: string;
end_time: string;
};
user_details: {
name: string;
email: string;
};
};
bookingRouter.post(
"/slot",
middlewareManager.supabase,
middlewareManager.streamChat,
middlewareManager.transporter,
middlewareManager.maybeAuthenticated,
async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const transporter = c.get("transporter");
const maybeUser = c.get("user");
const data = await c.req.json<BookSlotPayload>();
// Validate that owner_id is provided
if (!data.owner_short_id) {
return c.json({ error: "owner_id is required" }, 400);
}
if (!data.event_type_standard_name) {
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", data.owner_short_id)
.single();
if (ownerError || !ownerData) {
console.error("Error fetching owner:", ownerError);
return c.json({ error: "owner_id is incorrect" }, 400);
}
let hasCreatedAccount = false;
if (!maybeUser) {
// Check if email already exists in the database
const { data: existingUser, error: existingUserError } = await supabase
.from("profiles")
.select("id, email")
.eq("email", data.user_details.email)
.maybeSingle();
if (existingUserError) {
console.error("Error checking existing user:", existingUserError);
return c.json({ error: "Failed to check existing user" }, 500);
}
if (!existingUser) {
hasCreatedAccount = true;
// Create a temporary user for the booking
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
data.user_details.email,
ownerData.email
);
if (!result.success) {
console.error("Error creating invited user:", result.error);
return c.json({ error: result.error }, 500);
}
}
}
const { data: bookerUser, error: bookerUserError } = await supabase
.from("profiles")
.select("id, name, email")
.eq("email", data.user_details.email)
.maybeSingle();
if (bookerUserError) {
console.error("Error fetching booker user:", bookerUserError);
return c.json({ error: "Failed to get booker user" }, 500);
}
const ownerDataTyped = ownerData as {
id: string;
name: string;
email: string;
};
const ownerId = ownerDataTyped.id;
const bookerUserDataTyped = bookerUser as {
id: string;
name: string;
email: string;
};
if (ownerDataTyped.email === bookerUserDataTyped.email) {
return c.json({ error: "You cannot create a tablo with yourself" }, 400);
}
const { data: eventTypeData, error: eventTypeError } = await supabase
.from("event_types")
.select("*")
.eq("user_id", ownerId)
.eq("standard_name", data.event_type_standard_name)
.is("deleted_at", null)
.single();
if (eventTypeError || !eventTypeData) {
console.error("Error fetching event type:", eventTypeError);
return c.json({ error: "Event type not found" }, 404);
}
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// 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", bookerUserDataTyped.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: `${bookerUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
})
.select()
.single();
if (error) {
console.error("Error creating tablo:", 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").upsert(
{
tablo_id: tabloData.id,
user_id: bookerUserDataTyped.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, bookerUserDataTyped.id],
});
await channel.create();
const newEvent: TablesInsert<"events"> = {
description: eventTypeConfig.description || "",
end_time: data.event_details.end_time || "",
start_date: data.event_details.start_date || "",
start_time: data.event_details.start_time || "",
title: eventTypeConfig.name || "",
tablo_id: tabloData.id,
created_by: ownerId,
};
const { error: eventError } = await supabase.from("events").insert(newEvent);
if (eventError) {
console.error("Error creating event:", eventError);
return c.json({ error: "Failed to create event" }, 500);
}
// Send a welcome message to the channel
await channel.sendMessage({
text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${newEvent.title}" est confirmé pour le ${newEvent.start_date} de ${newEvent.start_time} à ${newEvent.end_time}.`,
user_id: 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 é 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 : ${newEvent.title}</li>
<li>Date : ${newEvent.start_date}</li>
<li>Heure : ${newEvent.start_time} - ${newEvent.end_time}</li>
<li>Description : ${newEvent.description}</li>
</ul>
<p>Participant : ${bookerUserDataTyped.name} (${bookerUserDataTyped.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: bookerUserDataTyped.email,
subject: "Réservation confirmée - Nouveau tablo créé",
html: `
<h2>Votre réservation est confirmée !</h2>
<p>Bonjour ${bookerUserDataTyped.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 : ${newEvent.title}</li>
<li>Date : ${newEvent.start_date}</li>
<li>Heure : ${newEvent.start_time} - ${newEvent.end_time}</li>
<li>Description : ${newEvent.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({
message: "Booking successful",
tablo_id: tabloData.id,
hasCreatedAccount,
email: bookerUserDataTyped.email,
});
}
);
return bookingRouter;
};

View file

@ -1,88 +0,0 @@
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import type { Database } from "./database.types.js";
import { checkTabloMember } from "./helpers.js";
import { MiddlewareManager } from "./middleware.js";
export const getNotesRouter = () => {
const notesRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
type Note = Database["public"]["Tables"]["notes"]["Row"];
notesRouter.use(middlewareManager.auth);
/**
* Fetch notes shared with a specific tablo
*/
notesRouter.get("/:tabloId", checkTabloMember, async (c) => {
const { tabloId } = c.req.param();
if (!tabloId) {
return c.json({ error: "Tablo ID is required" }, 400);
}
const supabase = c.get("supabase");
// Find the tablo owner
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("owner_id")
.eq("id", tabloId)
.single();
if (tabloError) {
console.error("Error fetching tablo:", tabloError);
return c.json({ error: "Failed to fetch tablo" }, 500);
}
if (!tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
const tabloOwnerId = tabloData.owner_id;
// Find notes shared with this specific tablo or all tablos
const { data, error } = await supabase
.from("note_access")
.select(`
note_id,
notes!inner (
id,
title,
content,
user_id,
created_at,
updated_at,
deleted_at
)
`)
.eq("is_active", true)
.eq("user_id", tabloOwnerId)
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
.is("notes.deleted_at", null);
if (error) {
return c.json({ error: "An error occurred" }, 500);
}
// Extract notes from the join result and remove duplicates
type JoinedResult = { note_id: string; notes: Note[] };
const extractedNotes = (data as JoinedResult[])
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
.filter((note) => note !== null && note !== undefined);
// Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo)
const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values());
return c.json({ notes: uniqueNotes });
});
return notesRouter;
};

View file

@ -1,136 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { Hono } from "hono";
import type { Database, Tables } from "./database.types.js";
import { MiddlewareManager } from "./middleware.js";
import {
type EventTypeConfig,
type Exception,
generateTimeSlots,
getDateStringCET,
getDayOfWeek,
type TimeSlot,
type WeeklyAvailability,
} from "./slots.js";
export const getPublicRouter = () => {
const publicRouter = new Hono<{
Variables: {
supabase: SupabaseClient;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
publicRouter.use(middlewareManager.supabase);
publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
const supabase = c.get("supabase");
const shortUserId = c.req.param("shortUserId");
const standardName = c.req.param("standardName");
// Get user
const { data: userData, error: userError } = await supabase
.from("profiles")
.select("*")
.eq("short_user_id", shortUserId)
.single();
if (userError || !userData) {
return c.json({ error: "User not found" }, 404);
}
const user = userData as Tables<"profiles">;
// Get event type
const { data: eventTypeData, error: eventTypeError } = await supabase
.from("event_types")
.select("*")
.eq("user_id", user.id)
.eq("standard_name", standardName)
.is("deleted_at", null)
.single();
if (eventTypeError || !eventTypeData) {
return c.json({ error: "Event type not found" }, 404);
}
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// Get user's availabilities
const { data: availabilitiesData, error: availabilitiesError } = await supabase
.from("availabilities")
.select("*")
.eq("user_id", user.id)
.single();
if (availabilitiesError) {
return c.json({ error: "Availabilities not found" }, 404);
}
const availabilities = availabilitiesData as Tables<"availabilities">;
const weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month
// Use CET time for availability calculations
const now = new Date();
const nextMonth = new Date(now);
nextMonth.setMonth(now.getMonth() + 2);
const { data: eventsData, error: eventsError } = await supabase
.from("events")
.select("*")
.eq("created_by", user.id)
.gte("start_date", getDateStringCET(now))
.lte("start_date", getDateStringCET(nextMonth))
.is("deleted_at", null);
if (eventsError) {
return c.json({ error: "Failed to fetch events" }, 500);
}
const existingEvents = eventsData as Tables<"events">[];
// Generate slots for the next month
const slots: TimeSlot[] = [];
const currentDate = new Date(now);
while (currentDate <= nextMonth) {
const dayOfWeek = getDayOfWeek(currentDate);
const dayAvailability = weeklyAvailability[dayOfWeek];
if (dayAvailability) {
const daySlots = generateTimeSlots(
now, // Pass CET current time as first parameter
currentDate,
dayAvailability,
eventTypeConfig,
exceptions,
existingEvents
);
slots.push(...daySlots);
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Group slots by date for easier frontend consumption
const slotsByDate: { [date: string]: TimeSlot[] } = {};
slots.forEach((slot) => {
if (!slotsByDate[slot.date]) {
slotsByDate[slot.date] = [];
}
slotsByDate[slot.date].push(slot);
});
return c.json({
user: { name: user.name, avatar_url: user.avatar_url },
eventType: eventTypeConfig,
slots: slotsByDate,
availableSlots: slots.filter((slot) => slot.available),
});
});
return publicRouter;
};

View file

@ -1,35 +0,0 @@
import { Hono } from "hono";
import type Stripe from "stripe";
import type { AppConfig } from "./config.js";
import { MiddlewareManager } from "./middleware.js";
import { getNotesRouter } from "./notes.js";
import { getStripeRouter, getStripeWebhookRouter } from "./stripe.js";
import { getTabloRouter } from "./tablo.js";
import { getTabloDataRouter } from "./tablo_data.js";
import { getTaskRouter } from "./tasks.js";
import { getUserRouter } from "./user.js";
import { getBookingRouter } from "./invite.js";
export const getMainRouter = (config: AppConfig, stripe: Stripe) => {
const mainRouter = new Hono<{
Bindings: {
SESSION_ENCRYPTION_KEY: string;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
mainRouter.use(middlewareManager.supabase);
mainRouter.route("/users", getUserRouter());
mainRouter.route("/tablos", getTabloRouter(config));
mainRouter.route("/tasks", getTaskRouter(config));
mainRouter.route("/tablo-data", getTabloDataRouter());
mainRouter.route("/notes", getNotesRouter());
mainRouter.route("/book", getBookingRouter());
// stripe routes
mainRouter.route("/stripe", getStripeRouter(config, stripe));
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
return mainRouter;
};

View file

@ -1,534 +0,0 @@
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 type { AppConfig } from "./config.js";
import type { Tables } from "./database.types.ts";
import { checkTabloAdmin, createInvitedUser, writeCalendarFileToR2 } from "./helpers.js";
import { MiddlewareManager } from "./middleware.js";
import { generateToken } from "./token.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
export const getTabloRouter = (config: AppConfig) => {
const tabloRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
streamServerClient: StreamChat;
s3_client: S3Client;
transporter: Transporter;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
// const webcalRouter = new Hono<{
// Variables: {
// user: User;
// supabase: SupabaseClient;
// s3_client: S3Client;
// };
// }>();
// webcalRouter.use(r2Middleware);
tabloRouter.use(middlewareManager.transporter);
tabloRouter.use(middlewareManager.auth);
tabloRouter.use(middlewareManager.streamChat);
tabloRouter.use(middlewareManager.r2);
// tabloRouter.route("/webcal", webcalRouter);
type PostTablo = Omit<TabloInsert, "owner_id"> & {
events?: EventInsertInTablo[];
};
tabloRouter.post("/create", middlewareManager.regularUserCheck, 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" });
});
tabloRouter.patch("/update", middlewareManager.regularUserCheck, 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/:tabloId",
middlewareManager.regularUserCheck,
checkTabloAdmin,
async (c) => {
const transporter = c.get("transporter");
const sender = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { tabloId } = c.req.param();
const { email: recipientmail } = await c.req.json();
if (sender.email === recipientmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
// Get tablo name
const { data: tablo, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tabloId)
.maybeSingle();
if (tabloError || !tablo) {
return c.json({ error: "Tablo not found" }, 404);
}
const token = generateToken();
const { data: introConfigData, error: introError } = await supabase
.from("user_introductions")
.select("config")
.eq("user_id", sender.id)
.maybeSingle();
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: tabloId,
invited_by: sender.id,
invite_token: token,
is_pending: true,
});
if (error) {
// Check if this is a duplicate invite error
if (error.code === "23505") {
return c.json({ error: "User has already been invited to this tablo" }, 409);
}
return c.json({ error: error.message }, 500);
}
// Get user from recipient email
const { data: recipientUser, error: recipientError } = await supabase
.from("profiles")
.select("id")
.eq("email", recipientmail)
.maybeSingle();
if (recipientError) {
return c.json({ error: recipientError.message }, 500);
}
if (!recipientUser) {
// Create a new invited user and add them to the tablo
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientmail,
sender.email
);
if (!result.success) {
return c.json({ error: result.error }, 500);
}
// Add the user to the tablo
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id: tabloId,
user_id: result.userId,
granted_by: sender.id,
is_active: true,
// ** IMPORTANT **
is_admin: false,
// -------------
});
if (tabloAccessError) {
return c.json({ error: tabloAccessError.message }, 500);
}
return c.json({
message: "User created and invite sent successfully",
});
}
// Check if the user already has access to the tablo
const { data: existingAccess, error: existingAccessError } = await supabase
.from("tablo_access")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", recipientUser.id)
.maybeSingle();
if (existingAccessError) {
return c.json({ error: existingAccessError.message }, 500);
}
if (existingAccess) {
return c.json({ message: "User already has access to this tablo" }, 400);
}
// Let the user know that they have been invited to the tablo
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-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</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)
.eq("is_pending", true)
.maybeSingle();
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);
// Check if it's a conflict error (user already has access)
if (tabloAccessError.code === "23505") {
return c.json({ error: "User already has access to this tablo" }, 409);
}
return c.json({ error: tabloAccessError.message }, 500);
}
// Mark invite as accepted instead of deleting (maintains audit trail)
await supabase.from("tablo_invites").update({ is_pending: false }).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({ tablo_id });
});
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, email)")
.eq("tablo_id", tablo_id)
.eq("is_active", true);
const rows = data as unknown as {
is_admin: boolean;
profiles: {
id: string;
name: string;
email: string;
};
}[];
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
members: rows.map((member) => ({
...member.profiles,
is_admin: member.is_admin,
email: member.profiles.email,
})),
});
});
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", middlewareManager.regularUserCheck, 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,
});
});
return tabloRouter;
};

View file

@ -1,180 +0,0 @@
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "./helpers.js";
import { MiddlewareManager } from "./middleware.js";
export const getTabloDataRouter = () => {
const tabloDataRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
s3_client: S3Client;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
tabloDataRouter.use(middlewareManager.auth);
tabloDataRouter.use(middlewareManager.streamChat);
tabloDataRouter.use(middlewareManager.r2);
// GET /tablo-data/:tabloId/filenames - Get all files for a tablo
tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
try {
const fileNames = await getTabloFileNames(s3_client, tabloId);
return c.json({ fileNames: fileNames || [] });
} catch (error) {
console.error("Error fetching tablo files:", error);
return c.json({ error: "Failed to fetch tablo files" }, 500);
}
});
// GET /tablo-data/:tabloId/:fileName - Get a specific file
tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
if (!response.Body) {
return c.json({ error: "File not found" }, 404);
}
const content = await response.Body.transformToString();
return c.json({
fileName,
content,
contentType: response.ContentType,
lastModified: response.LastModified,
});
} catch (error) {
console.error("Error fetching file:", error);
return c.json({ error: "Failed to fetch file" }, 500);
}
});
// POST /tablo-data/:tabloId/:fileName - Create or update a file
tabloDataRouter.post(
"/:tabloId/:fileName",
middlewareManager.regularUserCheck,
checkTabloMember,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const body = await c.req.json();
const { content, contentType = "text/plain" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Body: content,
ContentType: contentType,
})
);
return c.json({
message: "File uploaded successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
}
);
// // PUT /tablo-data/:tabloId/:fileName - Update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
// const tabloId = c.req.param("tabloId");
// const fileName = c.req.param("fileName");
// const s3_client = c.get("s3_client");
// try {
// const body = await c.req.json();
// const { content, contentType = "text/plain" } = body;
// if (!content) {
// return c.json({ error: "Content is required" }, 400);
// }
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
// await s3_client.send(
// new PutObjectCommand({
// Bucket: "tablo-data",
// Key: `${tabloId}/${fileName}`,
// Body: content,
// ContentType: contentType,
// })
// );
// return c.json({
// message: "File updated successfully",
// fileName,
// tabloId,
// });
// } catch (error) {
// console.error("Error updating file:", error);
// return c.json({ error: "Failed to update file" }, 500);
// }
// });
// DELETE /tablo-data/:tabloId/:fileName - Delete a file
tabloDataRouter.delete(
"/:tabloId/:fileName",
middlewareManager.regularUserCheck,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
}
}
);
return tabloDataRouter;
};

View file

@ -1,97 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { type Context, Hono } from "hono";
import type { StreamChat } from "stream-chat";
import type { AppConfig } from "./config.js";
import { writeCalendarFileToR2 } from "./helpers.js";
import { MiddlewareManager } from "./middleware.js";
export const getTaskRouter = (config: AppConfig) => {
const taskRouter = new Hono<{
Variables: { supabase: SupabaseClient };
}>();
const middlewareManager = MiddlewareManager.getInstance();
taskRouter.use(middlewareManager.basicAuth);
taskRouter.post("/sync-calendars", middlewareManager.r2, async (c) => {
const supabase = c.get("supabase");
const s3 = c.get("s3_client");
const { data, error } = await supabase
.from("calendar_subscriptions")
.select("token, tablo_id, tablos(name)");
if (error) {
return c.json({ error: error.message }, 500);
}
const calendarSubscriptionsData = data as unknown as [
{
token: string;
tablo_id: string;
tablos: { name: string };
},
];
calendarSubscriptionsData.forEach(async (subscription) => {
const tabloName = subscription.tablos.name.replace(/ /g, "_");
await writeCalendarFileToR2(s3, supabase, {
tabloName,
token: subscription.token,
tablo_id: subscription.tablo_id,
});
});
return c.json({ message: "Synced calendars" });
});
taskRouter.post(
"/sync-tablo-names",
middlewareManager.streamChat,
async (
c: Context<{ Variables: { supabase: SupabaseClient; streamServerClient: StreamChat } }>
) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const fifteenMinutesInMilliseconds = 1000 * 60 * 15;
const { data, error } = await supabase
.from("tablos")
.select("id, name")
.gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString());
if (error) {
return c.json({ error: error.message }, 500);
}
const tablosData = data as { id: string; name: string }[];
tablosData.forEach(async (tablo) => {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.update({
// @ts-ignore
name: tablo.name,
});
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
});
return c.json({ message: `Synced ${tablosData.length} tablo names` });
}
);
taskRouter.post("/sync-stripe-subscriptions", middlewareManager.stripeSync, async (c) => {
const data = await c.get("stripeSync").syncBackfill({ object: "all" });
return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` });
});
return taskRouter;
};

View file

@ -1,33 +0,0 @@
import type { Database, Tables, TablesInsert, TablesUpdate } from "./database.types.ts";
export type Tablo = Database["public"]["Tables"]["tablos"];
export type TabloInsert = Tablo["Insert"];
export type Event = RemoveNullFromObject<Tables<"events">, "created_at" | "end_time">;
export type EventInsertInTablo = Omit<TablesInsert<"events">, "tablo_id">;
export type EventUpdate = TablesUpdate<"events">;
export type EventAndTablo = RemoveNullFromObject<
Tables<"events_and_tablos">,
| "event_id"
| "tablo_id"
| "tablo_name"
| "tablo_color"
| "tablo_status"
| "start_time"
| "end_time"
| "title"
| "start_date"
>;
/**
* Utility type to remove null from a type
*/
export type RemoveNull<T> = T extends null ? never : T;
/**
* Utility type to remove null from all properties of an object type
*/
export type RemoveNullFromObject<T, K extends keyof T = keyof T> = {
[L in keyof T]: L extends K ? RemoveNull<T[L]> : T[L];
};

View file

@ -1,287 +0,0 @@
import {
DeleteObjectsCommand,
ListObjectsV2Command,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import { StreamChat } from "stream-chat";
import type { Tables } from "./database.types.ts";
import { MiddlewareManager } from "./middleware.js";
import type { Transporter } from "nodemailer";
export const getUserRouter = () => {
const userRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
streamServerClient: StreamChat;
s3_client: S3Client;
transporter: Transporter;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
userRouter.use(middlewareManager.auth);
userRouter.use(middlewareManager.streamChat);
userRouter.use(middlewareManager.r2);
userRouter.use(middlewareManager.transporter);
userRouter.post("/sign-up-to-stream", async (c) => {
const { id } = c.get("user");
const supabase = c.get("supabase");
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const user = data as Tables<"profiles">;
const streamServerClient = c.get("streamServerClient");
await streamServerClient.upsertUser({
id,
name: user.name ?? "",
language: "fr",
});
return c.json({
message: "User signed up to stream",
});
});
userRouter.get("/me", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
const userData = data as Tables<"profiles">;
if (!userData) {
return c.json({ error: "User not found" }, 404);
}
if (error) {
return c.json({ error: error.message }, 500);
}
const user_id = data.id;
const token = streamServerClient.createToken(user_id);
return c.json({
...userData,
streamToken: token,
});
});
userRouter.post("/mark-temporary", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
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);
}
const transporter = c.get("transporter");
try {
if (profile?.email && transporter) {
const mailOptions = {
from: "Xtablo <noreply@xtablo.com>",
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; 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.com"}"
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);
}
} catch (error) {
console.error("Failed to send welcome email:", error);
}
return c.json({
message: "User marked as temporary",
});
});
// userRouter.put("/profile", async (c) => {
// const user = c.get("user");
// const supabase = c.get("supabase");
// const body = await c.req.json();
// const { first_name, last_name } = body;
// // Deprecated: name field is deprecated, use first_name and last_name instead
// // Combine first_name and last_name into a single name field
// const name = [first_name, last_name].filter(Boolean).join(" ");
// const updateData =
// first_name && last_name
// ? {
// name,
// first_name,
// last_name,
// }
// : {};
// const { data: profile, error } = await supabase
// .from("profiles")
// .update(updateData)
// .eq("id", user.id)
// .select()
// .single();
// if (error) {
// return c.json({ error: error.message }, 500);
// }
// return c.json({
// message: "Profile updated successfully",
// profile,
// });
// });
userRouter.post("/profile/avatar", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
const body = await c.req.json();
const { content, contentType = "image/jpeg" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
const randomString = Math.random().toString(36).substring(2, 15);
const base64Content = Buffer.from(content, "base64");
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
try {
await s3Client.send(
new PutObjectCommand({
Bucket: "web-assets",
Key: key,
Body: base64Content,
ContentType: contentType,
ContentEncoding: "base64",
})
);
} catch (error) {
console.error("Failed to upload avatar:", error);
return c.json({ error: "Failed to upload avatar" }, 500);
}
const avatarUrl = `https://assets.xtablo.com/${key}`;
const { data, error } = await supabase
.from("profiles")
.update({ avatar_url: avatarUrl })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar updated successfully",
profile: data,
});
});
userRouter.delete("/profile/avatar", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
try {
const listedObjects = await s3Client.send(
new ListObjectsV2Command({
Bucket: "web-assets",
Prefix: `${user.id}/`,
})
);
if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404);
await s3Client.send(
new DeleteObjectsCommand({
Bucket: "web-assets",
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
})
);
} catch (error) {
console.error("Failed to delete avatar:", error);
return c.json({ error: "Failed to delete avatar" }, 500);
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar deleted successfully",
});
});
return userRouter;
};

View file

@ -1,23 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
o/bKiIz+Fq8=
-----END CERTIFICATE-----

90
apps/api/README.md Normal file
View file

@ -0,0 +1,90 @@
# Xtablo API
This is the backend API for Xtablo, built with [Hono](https://hono.dev/) and TypeScript.
## Development
This package is part of the Xtablo monorepo managed with Turborepo and pnpm workspaces.
### Prerequisites
- Node.js >= 20.0.0
- pnpm 10.x
### Available Commands
From the root of the monorepo:
```bash
# Start development server
pnpm run dev:api
# Build the API
turbo run build --filter=@xtablo/api
# Run tests
pnpm run test:api
# Type checking
turbo run typecheck --filter=@xtablo/api
# Linting
turbo run lint --filter=@xtablo/api
turbo run lint:fix --filter=@xtablo/api
# Format code
turbo run format --filter=@xtablo/api
```
From within the `apps/api` directory:
```bash
# Start development server
pnpm dev
# Build
pnpm build
# Start production server
pnpm start
# Run tests
pnpm test
pnpm test:watch
# Linting and formatting
pnpm lint
pnpm lint:fix
pnpm format
```
## Project Structure
```
apps/api/
├── src/
│ ├── __tests__/ # Test files
│ ├── helpers/ # Helper functions
│ ├── middlewares/ # Hono middlewares
│ ├── routers/ # API route handlers
│ ├── types/ # TypeScript type definitions
│ ├── config.ts # Configuration
│ ├── index.ts # Entry point
│ └── secrets.ts # Secret management
├── dist/ # Compiled output
├── docs/ # API documentation
├── examples/ # Usage examples
├── Dockerfile # Docker configuration
├── cloudbuild.yaml # Google Cloud Build config
├── package.json
├── tsconfig.json
└── turbo.json # Turborepo config
```
## Environment Variables
See `.env.example` for required environment variables.
## Deployment
The API is deployed to Google Cloud Run. See `cloudbuild.yaml` for deployment configuration.

175
apps/api/biome.json Normal file
View file

@ -0,0 +1,175 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"files": {
"ignoreUnknown": true,
"maxSize": 10485760,
"includes": ["src/**/*"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"nursery": {},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": []
},
"json": {
"parser": { "allowComments": true, "allowTrailingCommas": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"trailingCommas": "none"
}
},
"overrides": [
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
{
"linter": {
"rules": {
"style": { "useNodejsImportProtocol": "off" },
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error", "noCommonJs": "off" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
]
}

View file

@ -1,6 +1,6 @@
steps:
- name: 'gcr.io/cloud-builders/docker'
args: [ 'build', '--target', '$_NODE_ENV', '-t', 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA', 'api' ]
args: [ 'build', '--target', '$_NODE_ENV', '-t', 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA', 'apps/api' ]
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA']
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'

View file

@ -0,0 +1,207 @@
# API Type Improvements Summary
This document summarizes the type safety improvements made to the API based on [Hono's best practices](https://hono.dev/docs/guides/rpc).
## Changes Made
### 1. Created Centralized Type System
**New File: `src/app.types.ts`**
Introduced shared environment types that all routers can use:
- `BaseEnv` - Base environment with Supabase client
- `AuthEnv` - Environment with authenticated user
- `MaybeAuthEnv` - Environment with optional authentication
- `AppEnv` - Full environment with all services
- `ExtractEnv<T>` - Helper type to extract environment from Hono instances
**Benefits:**
- ✅ Eliminates duplicate type definitions across routers
- ✅ Ensures consistency in environment variable types
- ✅ Makes it easy to add new global services
### 2. Added RPC Client Type Exports
**New File: `src/client.ts`**
Exports type-safe client types for frontend and testing:
```typescript
import { hc } from 'hono/client'
import type { AppType } from './client'
const client = hc<AppType>('http://localhost:8080')
// Full type safety and autocomplete!
```
**Benefits:**
- ✅ Type-safe API calls from frontend
- ✅ Autocomplete for all endpoints
- ✅ Compile-time error checking
- ✅ No need to manually sync types
### 3. Enhanced Router Type Safety
**Updated Files:**
- `src/routers.ts` - Main router now exports `ApiRoutes` type
- `src/notes.ts` - Uses `AuthEnv` instead of custom type
- `src/public.ts` - Uses `BaseEnv` instead of custom type
**Benefits:**
- ✅ Better type inference in handlers
- ✅ Typed route parameters
- ✅ Typed context variables
### 4. Created Comprehensive Documentation
**New File: `docs/TYPE_SAFETY.md`**
Complete guide covering:
- How to use environment types
- RPC client usage examples
- Best practices for type-safe routers
- Migration guide for existing code
- Testing with type safety
**Benefits:**
- ✅ Team members can easily understand the type system
- ✅ Examples for common patterns
- ✅ Clear migration path for existing code
### 5. Updated Test Examples
**Updated File: `src/__tests__/notes/notes.test.ts`**
Added documentation showing type-safe testing patterns.
**Benefits:**
- ✅ Tests serve as examples for developers
- ✅ Demonstrates type inference in action
## Type Safety Improvements
### Before
```typescript
// Each router defined its own types
type NotesEnv = {
Variables: {
user: User;
supabase: SupabaseClient;
};
};
// No RPC type exports
// Manual type definitions needed in frontend
```
### After
```typescript
// Shared types across all routers
import type { AuthEnv } from "./app.types.js";
// Exported RPC types for frontend
export type ApiRoutes = ReturnType<typeof getMainRouter>;
// Full type inference everywhere!
```
## Example Usage
### In Backend Router
```typescript
import { createFactory } from "hono/factory";
import type { AuthEnv } from "./app.types.js";
const factory = createFactory<AuthEnv>();
const getUser = factory.createHandlers(async (c) => {
const user = c.get("user"); // ✅ Typed as User
const id = c.req.param("id"); // ✅ Typed as string
return c.json({ user });
});
```
### In Frontend
```typescript
import { hc } from "hono/client";
import type { ApiRoutes } from "@api/client";
const client = hc<ApiRoutes>("https://api.example.com/api/v1");
const res = await client.users[":id"].$get({
param: { id: "123" } // ✅ Type-checked
});
if (res.ok) {
const data = await res.json(); // ✅ Fully typed response
}
```
### In Tests
```typescript
import { testClient } from "hono/testing";
const app = getNotesRouter();
const client = testClient(app);
const res = await client[":tabloId"].$get({
param: { tabloId: "123" } // ✅ Type-checked parameter
});
```
## Test Results
All 67 tests pass successfully with the new type system:
```
✓ Notes Endpoint (4.953917ms)
✓ generateTimeSlots (28.547792ms)
✓ encodeURIComponent with slashes (3.631208ms)
tests 67
pass 67
fail 0
```
## Next Steps
### Recommended Actions
1. **Update remaining routers** to use shared types from `app.types.ts`:
- `user.ts`
- `stripe.ts`
- `tablo.ts`
- `tablo_data.ts`
- `tasks.ts`
- `invite.ts`
2. **Create frontend client** using the exported types:
```typescript
import { hc } from "hono/client";
import type { ApiRoutes } from "@api/client";
```
3. **Add more type-safe tests** following the pattern in `notes.test.ts`
4. **Consider adding Zod validation** for request/response schemas with full type inference
## Resources
- 📚 [Hono RPC Documentation](https://hono.dev/docs/guides/rpc)
- 📚 [Hono Factory Pattern](https://hono.dev/docs/api/factory)
- 📚 [Hono TypeScript Best Practices](https://hono.dev/docs/guides/best-practices#typescript)
- 📄 [Local Documentation](./docs/TYPE_SAFETY.md)
## Performance Impact
**Zero runtime impact** - All type improvements are compile-time only
## Breaking Changes
**None** - All changes are backward compatible. Existing code continues to work while gaining better type safety.
## Conclusion
These improvements bring world-class type safety to the API, leveraging Hono's powerful RPC pattern for end-to-end type inference from server to client. The codebase is now more maintainable, self-documenting, and less prone to runtime errors.

View file

@ -0,0 +1,248 @@
# Type-Safe API with Hono
This document explains how to use the improved type system in the API, leveraging Hono's best practices for type safety.
## Overview
The API now uses a centralized type system based on [Hono's RPC pattern](https://hono.dev/docs/guides/rpc) for maximum type safety across the entire application.
## Key Files
- **`app.types.ts`** - Centralized environment type definitions
- **`client.ts`** - Exported types for RPC client usage
- **`routers.ts`** - Main router with type exports
## Environment Types
### BaseEnv
The most basic environment with Supabase client access:
```typescript
type BaseEnv = {
Variables: {
supabase: SupabaseClient;
};
};
```
### AuthEnv
Environment with authenticated user:
```typescript
type AuthEnv = BaseEnv & {
Variables: BaseEnv["Variables"] & {
user: User;
};
};
```
### MaybeAuthEnv
Environment where user may or may not be authenticated:
```typescript
type MaybeAuthEnv = BaseEnv & {
Variables: BaseEnv["Variables"] & {
user: User | null;
};
};
```
### AppEnv
Full environment with all services:
```typescript
type AppEnv = AuthEnv & {
Variables: AuthEnv["Variables"] & {
streamServerClient: StreamChat;
s3_client: S3Client;
transporter: Transporter;
stripe: Stripe;
stripeSync: StripeSync;
};
};
```
## Using Types in Routers
### Example Router
```typescript
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import type { AuthEnv } from "./app.types.js";
// Create a typed factory
const factory = createFactory<AuthEnv>();
// Create handlers with full type inference
const getUser = factory.createHandlers(async (c) => {
const user = c.get("user"); // Fully typed as User
const supabase = c.get("supabase"); // Fully typed as SupabaseClient
return c.json({ user });
});
// Export typed router
export const getUserRouter = () => {
const router = new Hono<AuthEnv>();
router.use(middlewareManager.auth);
router.get("/me", ...getUser);
return router;
};
```
## RPC Client Usage
### In Tests
```typescript
import { testClient } from "hono/testing";
import type { ApiRoutes } from "../routers.js";
import { getNotesRouter } from "../notes.js";
describe("Notes API", () => {
it("should get tablo notes", async () => {
const app = getNotesRouter();
const client = testClient(app);
// Full type safety on the client
const res = await client[":tabloId"].$get(
{ param: { tabloId: "123" } },
{ headers: { Authorization: "Bearer token" } }
);
// Response is fully typed
const data = await res.json();
// TypeScript knows the shape of data!
});
});
```
### In Frontend Applications
```typescript
import { hc } from "hono/client";
import type { ApiRoutes } from "@your-api/client";
// Create a fully typed client
const client = hc<ApiRoutes>("https://api.yourdomain.com/api/v1");
// All endpoints are typed with autocomplete
const response = await client.users.me.$get(
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
// Response types are inferred
if (response.ok) {
const data = await response.json();
console.log(data.user); // Fully typed!
}
```
## Benefits
1. **End-to-End Type Safety** - From server to client, all types are inferred
2. **Autocomplete** - Your IDE will suggest all available endpoints and methods
3. **Compile-Time Errors** - Catch API mismatches before runtime
4. **Refactoring Safety** - Rename fields and TypeScript will update all usages
5. **Self-Documenting** - Types serve as documentation for the API
## Best Practices
### 1. Use Shared Environment Types
Instead of defining types in each router:
```typescript
// ❌ Before
type NotesEnv = {
Variables: {
user: User;
supabase: SupabaseClient;
};
};
// ✅ After
import type { AuthEnv } from "./app.types.js";
const factory = createFactory<AuthEnv>();
```
### 2. Extract Parameter Types
Use `c.req.param()` with type inference:
```typescript
const getTabloNotes = factory.createHandlers(async (c) => {
const tabloId = c.req.param("tabloId"); // Typed as string
// ...
});
```
### 3. Export Router Types
Always export the router type for RPC usage:
```typescript
export const getNotesRouter = () => {
const router = new Hono<AuthEnv>();
// ... setup routes
return router;
};
// Export type for RPC
export type NotesRouter = ReturnType<typeof getNotesRouter>;
```
### 4. Use createFactory for Consistency
Always use `createFactory` with the appropriate environment:
```typescript
// For authenticated routes
const factory = createFactory<AuthEnv>();
// For public routes
const factory = createFactory<BaseEnv>();
// For mixed auth routes
const factory = createFactory<MaybeAuthEnv>();
```
## Migration Guide
If you're updating an existing router:
1. Import the appropriate environment type from `app.types.ts`
2. Replace custom environment types with shared ones
3. Update `new Hono<YourEnv>()` to use shared types
4. Use `createFactory<Env>()` for all handlers
5. Update `c.req.param()` calls to use the type-safe version
## Testing Type Safety
Run the TypeScript compiler to verify type safety:
```bash
npm run type-check
```
Run tests to ensure everything works:
```bash
npm test
```
## Resources
- [Hono RPC Documentation](https://hono.dev/docs/guides/rpc)
- [Hono Factory Pattern](https://hono.dev/docs/api/factory)
- [Hono TypeScript Guide](https://hono.dev/docs/guides/best-practices#typescript)

View file

@ -0,0 +1,284 @@
// /**
// * Example: Using the Type-Safe RPC Client
// *
// * This file demonstrates how to use the Hono RPC client for type-safe API calls
// * from a frontend application or another service.
// *
// * Based on: https://hono.dev/docs/guides/rpc
// */
// import { hc } from "hono/client";
// import type { ApiRoutes } from "../src/client.js";
// // ============================================================================
// // 1. Basic Setup
// // ============================================================================
// // Create a typed client pointing to your API
// const client = hc<ApiRoutes>("https://api.yourdomain.com/api/v1", {
// headers: {
// // Add any default headers here
// "Content-Type": "application/json",
// },
// });
// // ============================================================================
// // 2. Making Authenticated Requests
// // ============================================================================
// async function getUserProfile(token: string) {
// // The client knows all available routes and their types!
// const res = await client.users.me.$get(
// {},
// {
// headers: {
// Authorization: `Bearer ${token}`,
// },
// }
// );
// if (!res.ok) {
// throw new Error(`Failed to fetch user: ${res.status}`);
// }
// // Response is fully typed - TypeScript knows the shape!
// const data = await res.json();
// return data; // TypeScript knows: { user: User }
// }
// // ============================================================================
// // 3. POST Requests with Body
// // ============================================================================
// async function createTablo(token: string, tabloData: { name: string; color: string }) {
// const res = await client.tablos.create.$post(
// {
// json: tabloData, // Typed request body
// },
// {
// headers: {
// Authorization: `Bearer ${token}`,
// },
// }
// );
// if (!res.ok) {
// const error = await res.json();
// throw new Error(`Failed to create tablo: ${error.error}`);
// }
// return await res.json(); // Typed response
// }
// // ============================================================================
// // 4. Route Parameters
// // ============================================================================
// async function getTabloNotes(token: string, tabloId: string) {
// // TypeScript will ensure tabloId is provided and typed correctly
// const res = await client.notes[":tabloId"].$get(
// {
// param: { tabloId }, // Type-checked parameter
// },
// {
// headers: {
// Authorization: `Bearer ${token}`,
// },
// }
// );
// if (!res.ok) {
// throw new Error("Failed to fetch notes");
// }
// const data = await res.json();
// return data.notes; // Fully typed array of notes
// }
// // ============================================================================
// // 5. Query Parameters
// // ============================================================================
// async function searchPublicSlots(shortUserId: string, standardName: string) {
// // Public routes don't need authentication
// const publicClient = hc<ApiRoutes>("https://api.yourdomain.com/api/public");
// const res = await publicClient.slots[":shortUserId"][":standardName"].$get({
// param: {
// shortUserId,
// standardName,
// },
// });
// if (!res.ok) {
// throw new Error("Failed to fetch slots");
// }
// return await res.json();
// }
// // ============================================================================
// // 6. Error Handling with Type Safety
// // ============================================================================
// type ApiError = {
// error: string;
// };
// async function safeApiCall<T>(
// apiCall: () => Promise<Response>
// ): Promise<{ data?: T; error?: string }> {
// try {
// const res = await apiCall();
// if (!res.ok) {
// const errorData = (await res.json()) as ApiError;
// return { error: errorData.error };
// }
// const data = (await res.json()) as T;
// return { data };
// } catch (err) {
// return {
// error: err instanceof Error ? err.message : "Unknown error",
// };
// }
// }
// // Usage
// async function example(token: string) {
// const result = await safeApiCall(() =>
// client.users.me.$get(
// {},
// {
// headers: { Authorization: `Bearer ${token}` },
// }
// )
// );
// if (result.error) {
// console.error("API Error:", result.error);
// return;
// }
// console.log("User data:", result.data);
// }
// // ============================================================================
// // 7. React Hook Example
// // ============================================================================
// /*
// import { useState, useEffect } from 'react';
// function useUser(token: string | null) {
// const [user, setUser] = useState(null);
// const [loading, setLoading] = useState(true);
// const [error, setError] = useState<string | null>(null);
// useEffect(() => {
// if (!token) {
// setLoading(false);
// return;
// }
// getUserProfile(token)
// .then(data => {
// setUser(data.user);
// setError(null);
// })
// .catch(err => {
// setError(err.message);
// })
// .finally(() => {
// setLoading(false);
// });
// }, [token]);
// return { user, loading, error };
// }
// */
// // ============================================================================
// // 8. Advanced: Custom Fetch with Interceptors
// // ============================================================================
// // You can customize the fetch function for things like:
// // - Adding auth tokens automatically
// // - Logging requests
// // - Retry logic
// // - Error handling
// function createAuthenticatedClient(getToken: () => string) {
// return hc<ApiRoutes>("https://api.yourdomain.com/api/v1", {
// fetch: async (input, init) => {
// // Add token to every request
// const token = getToken();
// const headers = new Headers(init?.headers);
// headers.set("Authorization", `Bearer ${token}`);
// // Log requests in development
// if (process.env.NODE_ENV === "development") {
// console.log(`API Request: ${input}`);
// }
// // Make the request
// const response = await fetch(input, { ...init, headers });
// // Log responses in development
// if (process.env.NODE_ENV === "development") {
// console.log(`API Response: ${response.status}`);
// }
// return response;
// },
// });
// }
// // Usage
// const authenticatedClient = createAuthenticatedClient(() => {
// // Get token from your auth system
// return localStorage.getItem("auth_token") || "";
// });
// // Now all requests automatically include the token!
// async function autoAuthExample() {
// const res = await authenticatedClient.users.me.$get({});
// // Token is automatically added
// }
// // ============================================================================
// // 9. Type Exports for Props/State
// // ============================================================================
// // You can extract types from the API responses for use in your components
// type User = Awaited<ReturnType<typeof getUserProfile>>["user"];
// type TabloNotes = Awaited<ReturnType<typeof getTabloNotes>>;
// // Usage in React props
// /*
// interface UserProfileProps {
// user: User;
// }
// function UserProfile({ user }: UserProfileProps) {
// return <div>{user.name}</div>;
// }
// */
// // ============================================================================
// // Export for use in your app
// // ============================================================================
// export {
// client,
// getUserProfile,
// createTablo,
// getTabloNotes,
// searchPublicSlots,
// safeApiCall,
// createAuthenticatedClient,
// };
// export type { User, TabloNotes };

View file

@ -1,15 +1,19 @@
{
"name": "@xtablo/api",
"private": true,
"version": "1.0.0",
"type": "module",
"name": "xtablo-api",
"scripts": {
"dev": "NODE_ENV=development tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js",
"test": "mocha",
"test:watch": "mocha --watch",
"test": "NODE_ENV=test glob -c \"tsx --test --test-reporter spec \" \"./src/__tests__/**/*.test.ts\"",
"test:watch": "NODE_ENV=test glob -c \"tsx --watch --test --test-reporter spec \" \"./src/__tests__/**/*.test.ts\"",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
"format": "biome format --write .",
"clean": "rm -rf dist node_modules/.cache"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
@ -17,9 +21,11 @@
"@hono/node-server": "^1.14.4",
"@supabase/stripe-sync-engine": "^0.45.0",
"@supabase/supabase-js": "^2.49.4",
"@xtablo/shared-types": "workspace:*",
"cors": "^2.8.5",
"dd-trace": "^5.74.0",
"dotenv": "^16.5.0",
"glob": "^11.0.3",
"googleapis": "^161.0.0",
"graphile-worker": "^0.16.6",
"hono": "^4.7.7",
@ -35,15 +41,9 @@
"@biomejs/biome": "2.2.5",
"@datadog/datadog-ci-base": "^4.0.2",
"@datadog/datadog-ci-plugin-cloud-run": "^4.0.2",
"@types/chai": "^4.3.0",
"@types/mocha": "^10.0.0",
"@types/node": "^20.11.17",
"@types/nodemailer": "^6.4.17",
"@types/sinon": "^17.0.0",
"chai": "^4.3.0",
"mocha": "^10.0.0",
"pino": "^10.1.0",
"sinon": "^17.0.0",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
},

View file

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Authenticated Router", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should require authentication", async () => {
// Try to access a protected route without token
const res = await client["v1"].users.me.$get();
// Should fail due to missing authentication
assert.ok(res.status >= 400);
});
it("should handle authenticated requests", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].users.me.$get({
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
// Will fail in test environment but should attempt authentication
assert.ok(res.status >= 400);
});
});

View file

@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Booking Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should book a slot", async () => {
const res = await client["v1"].book.slot.$post(
{
json: {
owner_short_id: "abc123",
event_type_standard_name: "consultation",
event_details: {
start_date: "2025-01-15",
start_time: "10:00",
end_time: "11:00",
},
user_details: {
name: "Test User",
email: "test@example.com",
},
},
},
{
headers: {
"Content-Type": "application/json",
},
}
);
// Should fail due to invalid owner_short_id in test environment
assert.ok(res.status >= 400);
});
it("should require owner_short_id", async () => {
const res = await client["v1"].book.slot.$post(
{
json: {
event_type_standard_name: "consultation",
event_details: {
start_date: "2025-01-15",
start_time: "10:00",
end_time: "11:00",
},
user_details: {
name: "Test User",
email: "test@example.com",
},
},
},
{
headers: {
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should require event_type_standard_name", async () => {
const res = await client["v1"].book.slot.$post(
{
json: {
owner_short_id: "abc123",
event_details: {
start_date: "2025-01-15",
start_time: "10:00",
end_time: "11:00",
},
user_details: {
name: "Test User",
email: "test@example.com",
},
},
},
{
headers: {
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
});

View file

@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Maybe Authenticated Router", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should allow unauthenticated requests", async () => {
const res = await client["v1"].book.slot.$post({
json: {
owner_short_id: "abc123",
event_type_standard_name: "consultation",
event_details: {
start_date: "2025-01-15",
start_time: "10:00",
end_time: "11:00",
},
user_details: {
name: "Test User",
email: "test@example.com",
},
},
});
// Should process but fail due to invalid data in test environment
assert.ok(res.status >= 400);
});
it("should handle authenticated requests", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].book.slot.$post(
{
json: {
owner_short_id: "abc123",
event_type_standard_name: "consultation",
event_details: {
start_date: "2025-01-15",
start_time: "10:00",
end_time: "11:00",
},
user_details: {
name: "Test User",
email: "test@example.com",
},
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
});

View file

@ -0,0 +1,441 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Hono } from "hono";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
describe("Middleware Tests", () => {
const config = createConfig();
MiddlewareManager.initialize(config);
const middlewareManager = MiddlewareManager.getInstance();
describe("Supabase Middleware", () => {
it("should inject supabase client into context", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.get("/test", (c) => {
const supabase = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("supabase");
return c.json({ hasSupabase: !!supabase });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasSupabase, true);
});
});
describe("Auth Middleware", () => {
it("should reject requests without authorization header", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 401);
assert.strictEqual(data.error, "Missing or invalid authorization header");
});
it("should reject requests with invalid Bearer prefix", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Token invalid-format",
},
});
const data = await res.json();
assert.strictEqual(res.status, 401);
assert.strictEqual(data.error, "Missing or invalid authorization header");
});
it("should reject requests with invalid token", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Bearer invalid-token-xyz-123",
},
});
const data = await res.json();
assert.strictEqual(res.status, 401);
// Should get auth error (may vary based on token format)
assert.ok(data.error.includes("Invalid") || data.error.includes("authorization"));
});
it("should reject requests with empty Bearer token", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Bearer ",
},
});
const data = await res.json();
assert.strictEqual(res.status, 401);
// May get "Missing or invalid authorization header" or "Invalid or expired token"
assert.ok(data.error.includes("Invalid") || data.error.includes("authorization"));
});
});
describe("Maybe Authenticated Middleware", () => {
it("should allow requests without authorization header", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.maybeAuthenticated);
app.get("/test", (c) => {
const user = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("user");
return c.json({ hasUser: !!user, user });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasUser, false);
assert.strictEqual(data.user, null);
});
it("should set user to null with invalid token", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.maybeAuthenticated);
app.get("/test", (c) => {
const user = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("user");
return c.json({ hasUser: !!user, user });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Bearer invalid-token",
},
});
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasUser, false);
assert.strictEqual(data.user, null);
});
it("should ignore malformed authorization header", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.maybeAuthenticated);
app.get("/test", (c) => {
const user = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("user");
return c.json({ hasUser: !!user });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Invalid format",
},
});
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasUser, false);
});
});
describe("Basic Auth Middleware", () => {
it("should reject requests without authorization header", async () => {
const app = new Hono();
app.use(middlewareManager.basicAuth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 401);
assert.strictEqual(data.error, "Missing or invalid authorization header");
});
it("should reject requests with Bearer instead of Basic", async () => {
const app = new Hono();
app.use(middlewareManager.basicAuth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Bearer some-token",
},
});
const data = await res.json();
assert.strictEqual(res.status, 401);
assert.strictEqual(data.error, "Missing or invalid authorization header");
});
it("should reject requests with invalid secret", async () => {
const app = new Hono();
app.use(middlewareManager.basicAuth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Basic wrong-secret",
},
});
const data = await res.json();
assert.strictEqual(res.status, 401);
assert.ok(
data.error === "Unauthorized" || data.error === "Missing or invalid authorization header"
);
});
it("should accept requests with correct secret", async () => {
const app = new Hono();
app.use(middlewareManager.basicAuth);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: `Basic ${config.TASKS_SECRET}`,
},
});
// Basic auth should work if TASKS_SECRET is set, otherwise it will fail
assert.ok(res.status === 200 || res.status === 401);
});
});
describe("Regular User Check Middleware", () => {
it("should require auth middleware to be called first", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
// Skipping auth middleware intentionally
app.use(middlewareManager.regularUserCheck);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
// Should fail because user is not set (auth middleware not called)
assert.ok(res.status >= 400);
});
it("should check if user profile exists", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth);
app.use(middlewareManager.regularUserCheck);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get({
headers: {
Authorization: "Bearer invalid-token",
},
});
// Should fail due to invalid token in auth middleware
assert.strictEqual(res.status, 401);
});
});
describe("StreamChat Middleware", () => {
it("should inject StreamChat client into context", async () => {
const app = new Hono();
app.use(middlewareManager.streamChat);
app.get("/test", (c) => {
const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("streamServerClient");
return c.json({ hasStreamClient: !!streamClient });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasStreamClient, true);
});
});
describe("R2 Middleware", () => {
it("should inject S3 client into context", async () => {
const app = new Hono();
app.use(middlewareManager.r2);
app.get("/test", (c) => {
const s3Client = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("s3_client");
return c.json({ hasS3Client: !!s3Client });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasS3Client, true);
});
});
describe("Transporter Middleware", () => {
it("should inject email transporter into context", async () => {
const app = new Hono();
app.use(middlewareManager.transporter);
app.get("/test", (c) => {
const transporter = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("transporter");
return c.json({ hasTransporter: !!transporter });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasTransporter, true);
});
});
describe("Stripe Middleware", () => {
it("should inject Stripe client into context", async () => {
const app = new Hono();
app.use(middlewareManager.stripe);
app.get("/test", (c) => {
const stripe = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("stripe");
return c.json({ hasStripe: !!stripe });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasStripe, true);
});
});
describe("Stripe Sync Middleware", () => {
it("should inject Stripe Sync client into context", async () => {
const app = new Hono();
app.use(middlewareManager.stripeSync);
app.get("/test", (c) => {
const stripeSync = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("stripeSync");
return c.json({ hasStripeSync: !!stripeSync });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasStripeSync, true);
});
});
describe("Middleware Chaining", () => {
it("should chain multiple middlewares correctly", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.streamChat);
app.use(middlewareManager.stripe);
app.get("/test", (c) => {
const supabase = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("supabase");
const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("streamServerClient");
const stripe = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("stripe");
return c.json({
hasSupabase: !!supabase,
hasStreamClient: !!streamClient,
hasStripe: !!stripe,
});
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.hasSupabase, true);
assert.strictEqual(data.hasStreamClient, true);
assert.strictEqual(data.hasStripe, true);
});
it("should stop middleware chain on auth failure", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth); // This will fail
app.use(middlewareManager.streamChat); // This should not execute
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
assert.strictEqual(res.status, 401);
assert.strictEqual(data.error, "Missing or invalid authorization header");
});
});
describe("MiddlewareManager Singleton", () => {
it("should maintain singleton instance", () => {
const instance1 = MiddlewareManager.getInstance();
const instance2 = MiddlewareManager.getInstance();
assert.strictEqual(instance1, instance2);
});
});
});

View file

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Notes Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should return notes", async () => {
// Include the token in the headers and set the content type
const token = "this-is-a-very-clean-token";
const res = await client.v1.notes[":tabloId"].$get(
{
param: { tabloId: "123" },
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
// Assertions
// Auth middleware is initialized but the test Supabase client returns an error
// In a real test setup, you would mock the Supabase client properly
assert.ok(res.status >= 400); // Expecting either 401 (auth fail) or 500 (supabase error)
});
});

View file

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Public Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should get public slots for user and event type", async () => {
const res = await client.public.slots[":shortUserId"][":standardName"].$get({
param: {
shortUserId: "abc123",
standardName: "consultation",
},
});
// Should fail due to invalid user in test environment
assert.ok(res.status >= 400);
});
it("should return 404 for non-existent user", async () => {
const res = await client.public.slots[":shortUserId"][":standardName"].$get({
param: {
shortUserId: "nonexistent",
standardName: "consultation",
},
});
assert.ok(res.status >= 400);
});
});

View file

@ -1,6 +1,6 @@
import { expect } from "chai";
import { beforeEach, describe, it } from "mocha";
import type { Tables } from "../database.types.js";
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import type { Tables } from "@xtablo/shared-types";
import {
type EventTypeConfig,
type Exception,
@ -8,7 +8,7 @@ import {
getDateStringCET,
getDayOfWeek,
type WeeklyAvailability,
} from "../slots.js";
} from "../helpers/slots.js";
// Mock the current date for consistent testing
@ -48,18 +48,18 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(16); // 8 hours * 2 slots per hour (30min each)
expect(slots[0]).to.deep.equal({
assert.strictEqual(slots.length, 16); // 8 hours * 2 slots per hour (30min each)
assert.deepStrictEqual(slots[0], {
date: "2024-01-16",
time: "09:00",
available: true,
});
expect(slots[1]).to.deep.equal({
assert.deepStrictEqual(slots[1], {
date: "2024-01-16",
time: "09:30",
available: true,
});
expect(slots[slots.length - 1]).to.deep.equal({
assert.deepStrictEqual(slots[slots.length - 1], {
date: "2024-01-16",
time: "16:30",
available: true,
@ -84,15 +84,15 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(12); // 6 slots morning + 6 slots afternoon
assert.strictEqual(slots.length, 12); // 6 slots morning + 6 slots afternoon
// Check morning slots
expect(slots[0].time).to.equal("09:00");
expect(slots[5].time).to.equal("11:30");
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[5].time, "11:30");
// Check afternoon slots
expect(slots[6].time).to.equal("14:00");
expect(slots[11].time).to.equal("16:30");
assert.strictEqual(slots[6].time, "14:00");
assert.strictEqual(slots[11].time, "16:30");
});
it("should return empty array when day is not enabled", () => {
@ -110,7 +110,7 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(0);
assert.strictEqual(slots.length, 0);
});
it("should handle 15-minute duration slots", () => {
@ -128,13 +128,13 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(32); // 8 hours * 4 slots per hour
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:15");
expect(slots[2].time).to.equal("09:30");
expect(slots[3].time).to.equal("09:45");
expect(slots[4].time).to.equal("10:00");
expect(slots[31].time).to.equal("16:45");
assert.strictEqual(slots.length, 32); // 8 hours * 4 slots per hour
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:15");
assert.strictEqual(slots[2].time, "09:30");
assert.strictEqual(slots[3].time, "09:45");
assert.strictEqual(slots[4].time, "10:00");
assert.strictEqual(slots[31].time, "16:45");
});
it("should handle different event durations", () => {
@ -152,10 +152,10 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(8); // 8 hours, 1 slot per hour
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("10:00");
expect(slots[7].time).to.equal("16:00");
assert.strictEqual(slots.length, 8); // 8 hours, 1 slot per hour
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "10:00");
assert.strictEqual(slots[7].time, "16:00");
});
it("should generate slots for the next day correctly", () => {
@ -171,15 +171,15 @@ describe("generateTimeSlots", () => {
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");
assert.strictEqual(slots.length, 16); // 8 hours * 2 slots per hour
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[0].date, "2025-10-01");
assert.strictEqual(slots[15].time, "16:30");
assert.strictEqual(slots[15].date, "2025-10-01");
// All slots should be available since it's a future day
slots.forEach((slot) => {
expect(slot.available).to.be.true;
assert.strictEqual(slot.available, true);
});
});
});
@ -200,7 +200,7 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(0);
assert.strictEqual(slots.length, 0);
});
it("should handle exception with date with minutes and seconds", () => {
@ -218,7 +218,7 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(0);
assert.strictEqual(slots.length, 0);
});
it("should handle hours exception with date containing time components", () => {
@ -237,9 +237,9 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(4); // 2 hours * 2 slots per hour
expect(slots[0].time).to.equal("10:00");
expect(slots[3].time).to.equal("11:30");
assert.strictEqual(slots.length, 4); // 2 hours * 2 slots per hour
assert.strictEqual(slots[0].time, "10:00");
assert.strictEqual(slots[3].time, "11:30");
});
it("should use exception hours instead of regular availability", () => {
@ -258,9 +258,9 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(4); // 2 hours * 2 slots per hour
expect(slots[0].time).to.equal("10:00");
expect(slots[3].time).to.equal("11:30");
assert.strictEqual(slots.length, 4); // 2 hours * 2 slots per hour
assert.strictEqual(slots[0].time, "10:00");
assert.strictEqual(slots[3].time, "11:30");
});
it("should handle multiple time ranges in hours exception", () => {
@ -282,17 +282,17 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(6); // 1 hour + 2 hours = 3 hours * 2 slots per hour
assert.strictEqual(slots.length, 6); // 1 hour + 2 hours = 3 hours * 2 slots per hour
// First range: 09:00-10:00
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:30");
// Second range: 14:00-16:00
expect(slots[2].time).to.equal("14:00");
expect(slots[3].time).to.equal("14:30");
expect(slots[4].time).to.equal("15:00");
expect(slots[5].time).to.equal("15:30");
assert.strictEqual(slots[2].time, "14:00");
assert.strictEqual(slots[3].time, "14:30");
assert.strictEqual(slots[4].time, "15:00");
assert.strictEqual(slots[5].time, "15:30");
});
it("should handle overlapping time ranges in hours exception", () => {
@ -315,13 +315,13 @@ describe("generateTimeSlots", () => {
);
// Should generate slots for the combined range 09:00-12:00
expect(slots).to.have.length(6); // 3 hours * 2 slots per hour
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
expect(slots[3].time).to.equal("10:30");
expect(slots[4].time).to.equal("11:00");
expect(slots[5].time).to.equal("11:30");
assert.strictEqual(slots.length, 6); // 3 hours * 2 slots per hour
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:30");
assert.strictEqual(slots[2].time, "10:00");
assert.strictEqual(slots[3].time, "10:30");
assert.strictEqual(slots[4].time, "11:00");
assert.strictEqual(slots[5].time, "11:30");
});
it("should handle multiple overlapping time ranges", () => {
@ -346,19 +346,19 @@ describe("generateTimeSlots", () => {
);
// Should generate slots for merged range 09:00-12:00 and separate range 14:00-15:00
expect(slots).to.have.length(8); // 3 hours + 1 hour = 4 hours * 2 slots per hour
assert.strictEqual(slots.length, 8); // 3 hours + 1 hour = 4 hours * 2 slots per hour
// First merged range: 09:00-12:00
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
expect(slots[3].time).to.equal("10:30");
expect(slots[4].time).to.equal("11:00");
expect(slots[5].time).to.equal("11:30");
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:30");
assert.strictEqual(slots[2].time, "10:00");
assert.strictEqual(slots[3].time, "10:30");
assert.strictEqual(slots[4].time, "11:00");
assert.strictEqual(slots[5].time, "11:30");
// Separate range: 14:00-15:00
expect(slots[6].time).to.equal("14:00");
expect(slots[7].time).to.equal("14:30");
assert.strictEqual(slots[6].time, "14:00");
assert.strictEqual(slots[7].time, "14:30");
});
it("should handle adjacent time ranges (touching but not overlapping)", () => {
@ -381,11 +381,11 @@ describe("generateTimeSlots", () => {
);
// Should merge into one continuous range 09:00-11:00
expect(slots).to.have.length(4); // 2 hours * 2 slots per hour
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
expect(slots[3].time).to.equal("10:30");
assert.strictEqual(slots.length, 4); // 2 hours * 2 slots per hour
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:30");
assert.strictEqual(slots[2].time, "10:00");
assert.strictEqual(slots[3].time, "10:30");
});
it("should ignore exceptions for different dates", () => {
@ -403,7 +403,7 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(16); // Normal availability should apply
assert.strictEqual(slots.length, 16); // Normal availability should apply
});
});
@ -438,10 +438,10 @@ describe("generateTimeSlots", () => {
const slot10_30 = slots.find((s) => s.time === "10:30");
const slot11_00 = slots.find((s) => s.time === "11:00");
expect(slot10_00?.available).to.be.false; // Starts during event
expect(slot10_30?.available).to.be.false; // Starts during event
expect(slot09_30?.available).to.be.true; // Ends right when the event starts
expect(slot11_00?.available).to.be.true; // Starts after event ends
assert.strictEqual(slot10_00?.available, false); // Starts during event
assert.strictEqual(slot10_30?.available, false); // Starts during event
assert.strictEqual(slot09_30?.available, true); // Ends right when the event starts
assert.strictEqual(slot11_00?.available, true); // Starts after event ends
});
it("should ignore deleted events", () => {
@ -469,7 +469,7 @@ describe("generateTimeSlots", () => {
// All slots should be available since the event is deleted
const slot10_00 = slots.find((s) => s.time === "10:00");
expect(slot10_00?.available).to.be.true;
assert.strictEqual(slot10_00?.available, true);
});
it("should handle events without end_time using event duration", () => {
@ -498,8 +498,8 @@ describe("generateTimeSlots", () => {
const slot10_00 = slots.find((s) => s.time === "10:00");
const slot10_30 = slots.find((s) => s.time === "10:30");
expect(slot10_00?.available).to.be.false; // Event uses duration (30 min)
expect(slot10_30?.available).to.be.true; // Should be available after 30 min
assert.strictEqual(slot10_00?.available, false); // Event uses duration (30 min)
assert.strictEqual(slot10_30?.available, true); // Should be available after 30 min
});
});
@ -528,12 +528,12 @@ describe("generateTimeSlots", () => {
const slot11_00 = slots.find((s) => s.time === "11:00");
const slot11_30 = slots.find((s) => s.time === "11:30");
expect(slot09_00?.available).to.be.false;
expect(slot09_30?.available).to.be.false;
expect(slot10_00?.available).to.be.false;
expect(slot10_30?.available).to.be.false;
expect(slot11_00?.available).to.be.true;
expect(slot11_30?.available).to.be.true;
assert.strictEqual(slot09_00?.available, false);
assert.strictEqual(slot09_30?.available, false);
assert.strictEqual(slot10_00?.available, false);
assert.strictEqual(slot10_30?.available, false);
assert.strictEqual(slot11_00?.available, true);
assert.strictEqual(slot11_30?.available, true);
});
it("should respect minimum advance booking in hours", () => {
@ -562,8 +562,8 @@ describe("generateTimeSlots", () => {
const slot11_30 = slots.find((s) => s.time === "11:30");
const slot12_00 = slots.find((s) => s.time === "12:00");
expect(slot11_30?.available).to.be.false;
expect(slot12_00?.available).to.be.true;
assert.strictEqual(slot11_30?.available, false);
assert.strictEqual(slot12_00?.available, true);
});
it("should respect minimum advance booking in days", () => {
@ -591,7 +591,7 @@ describe("generateTimeSlots", () => {
// All slots today should be unavailable due to 1-day advance booking
slots.forEach((slot) => {
expect(slot.available).to.be.false;
assert.strictEqual(slot.available, false);
});
});
@ -622,8 +622,8 @@ describe("generateTimeSlots", () => {
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
expect(slot09_00?.available).to.be.true;
expect(slot09_30?.available).to.be.true;
assert.strictEqual(slot09_00?.available, true);
assert.strictEqual(slot09_30?.available, true);
});
});
@ -645,11 +645,11 @@ describe("generateTimeSlots", () => {
);
// Should generate slots every 30 minutes (duration), not considering buffer time for slot generation
expect(slots).to.have.length(16); // Same as without buffer time
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
expect(slots[3].time).to.equal("10:30");
assert.strictEqual(slots.length, 16); // Same as without buffer time
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:30");
assert.strictEqual(slots[2].time, "10:00");
assert.strictEqual(slots[3].time, "10:30");
});
it("should apply buffer time around existing events to disable slots", () => {
@ -689,11 +689,11 @@ describe("generateTimeSlots", () => {
const slot10_30 = slots.find((s) => s.time === "10:30");
const slot11_00 = slots.find((s) => s.time === "11:00");
expect(slot09_00?.available).to.be.true; // Ends before buffer starts
expect(slot09_30?.available).to.be.false; // Ends at 10:00, overlaps with buffer (09:45-10:45)
expect(slot10_00?.available).to.be.false; // Overlaps with buffered event
expect(slot10_30?.available).to.be.false; // Starts at 10:30, overlaps with buffer until 10:45
expect(slot11_00?.available).to.be.true; // Starts after buffer ends
assert.strictEqual(slot09_00?.available, true); // Ends before buffer starts
assert.strictEqual(slot09_30?.available, false); // Ends at 10:00, overlaps with buffer (09:45-10:45)
assert.strictEqual(slot10_00?.available, false); // Overlaps with buffered event
assert.strictEqual(slot10_30?.available, false); // Starts at 10:30, overlaps with buffer until 10:45
assert.strictEqual(slot11_00?.available, true); // Starts after buffer ends
});
it("should handle buffer time with events without end_time", () => {
@ -732,11 +732,11 @@ describe("generateTimeSlots", () => {
const slot10_30 = slots.find((s) => s.time === "10:30");
const slot11_00 = slots.find((s) => s.time === "11:00");
expect(slot09_00?.available, "09:00 should be available").to.be.true;
expect(slot09_30?.available, "09:30 should not be available").to.be.false;
expect(slot10_00?.available, "10:00 should not be unavailable").to.be.false; // Within buffered time
expect(slot10_30?.available, "10:30 should not be unavailable").to.be.false; // Within buffered time
expect(slot11_00?.available, "11:00 should be available").to.be.true; // After buffered time
assert.strictEqual(slot09_00?.available, true, "09:00 should be available");
assert.strictEqual(slot09_30?.available, false, "09:30 should not be available");
assert.strictEqual(slot10_00?.available, false, "10:00 should not be unavailable"); // Within buffered time
assert.strictEqual(slot10_30?.available, false, "10:30 should not be unavailable"); // Within buffered time
assert.strictEqual(slot11_00?.available, true, "11:00 should be available"); // After buffered time
});
it("should handle large buffer time that affects multiple slots", () => {
@ -778,14 +778,14 @@ describe("generateTimeSlots", () => {
const slot13_30 = slots.find((s) => s.time === "13:30");
const slot14_00 = slots.find((s) => s.time === "14:00");
expect(slot10_30?.available).to.be.true; // Before buffer
expect(slot11_00?.available).to.be.false; // Within buffer
expect(slot11_30?.available).to.be.false; // Within buffer
expect(slot12_00?.available).to.be.false; // Within buffer
expect(slot12_30?.available).to.be.false; // Within buffer
expect(slot13_00?.available).to.be.false; // Within buffer (ends at 13:30)
expect(slot13_30?.available).to.be.true; // After buffer
expect(slot14_00?.available).to.be.true; // After buffer
assert.strictEqual(slot10_30?.available, true); // Before buffer
assert.strictEqual(slot11_00?.available, false); // Within buffer
assert.strictEqual(slot11_30?.available, false); // Within buffer
assert.strictEqual(slot12_00?.available, false); // Within buffer
assert.strictEqual(slot12_30?.available, false); // Within buffer
assert.strictEqual(slot13_00?.available, false); // Within buffer (ends at 13:30)
assert.strictEqual(slot13_30?.available, true); // After buffer
assert.strictEqual(slot14_00?.available, true); // After buffer
});
it("should handle multiple events with overlapping buffer times", () => {
@ -843,13 +843,13 @@ describe("generateTimeSlots", () => {
const slot12_30 = slots.find((s) => s.time === "12:30");
const slot13_00 = slots.find((s) => s.time === "13:00");
expect(slot09_00?.available).to.be.true; // Before any buffer
expect(slot09_30?.available).to.be.false; // Within first event's buffer
expect(slot10_00?.available).to.be.false; // Within first event's buffer
expect(slot11_00?.available).to.be.false; // Within second event's buffer
expect(slot12_00?.available).to.be.false; // Within second event's buffer
expect(slot12_30?.available).to.be.true; // After all buffers
expect(slot13_00?.available).to.be.true; // After all buffers
assert.strictEqual(slot09_00?.available, true); // Before any buffer
assert.strictEqual(slot09_30?.available, false); // Within first event's buffer
assert.strictEqual(slot10_00?.available, false); // Within first event's buffer
assert.strictEqual(slot11_00?.available, false); // Within second event's buffer
assert.strictEqual(slot12_00?.available, false); // Within second event's buffer
assert.strictEqual(slot12_30?.available, true); // After all buffers
assert.strictEqual(slot13_00?.available, true); // After all buffers
});
it("should not affect slot generation in short availability windows", () => {
@ -874,12 +874,15 @@ describe("generateTimeSlots", () => {
);
// Should generate all possible slots within the availability window
expect(slots).to.have.length(3); // 09:00, 09:30, 10:00
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
assert.strictEqual(slots.length, 3); // 09:00, 09:30, 10:00
assert.strictEqual(slots[0].time, "09:00");
assert.strictEqual(slots[1].time, "09:30");
assert.strictEqual(slots[2].time, "10:00");
// All should be available since there are no existing events
expect(slots.every((slot) => slot.available)).to.be.true;
assert.strictEqual(
slots.every((slot) => slot.available),
true
);
});
it("should handle buffer time that extends before start of day", () => {
@ -924,11 +927,11 @@ describe("generateTimeSlots", () => {
const slot09_30 = slots.find((s) => s.time === "09:30");
const slot10_00 = slots.find((s) => s.time === "10:00");
expect(slot08_00?.available).to.be.false; // Within buffer
expect(slot08_30?.available).to.be.false; // Within buffer
expect(slot09_00?.available).to.be.false; // Within buffer
expect(slot09_30?.available).to.be.false; // Within buffer (ends at 10:00)
expect(slot10_00?.available).to.be.true; // After buffer
assert.strictEqual(slot08_00?.available, false); // Within buffer
assert.strictEqual(slot08_30?.available, false); // Within buffer
assert.strictEqual(slot09_00?.available, false); // Within buffer
assert.strictEqual(slot09_30?.available, false); // Within buffer (ends at 10:00)
assert.strictEqual(slot10_00?.available, true); // After buffer
});
});
@ -977,7 +980,7 @@ describe("generateTimeSlots", () => {
// All slots should be unavailable
slots.forEach((slot) => {
expect(slot.available).to.be.false;
assert.strictEqual(slot.available, false);
});
});
@ -1025,7 +1028,7 @@ describe("generateTimeSlots", () => {
// Should have available slots since only 1 active booking exists
const availableSlots = slots.filter((slot) => slot.available);
expect(availableSlots.length).to.be.greaterThan(0);
assert(availableSlots.length > 0);
});
});
@ -1045,7 +1048,7 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(0);
assert.strictEqual(slots.length, 0);
});
it("should handle time ranges where duration does not fit", () => {
@ -1063,7 +1066,7 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(0);
assert.strictEqual(slots.length, 0);
});
it("should handle midnight crossing time ranges", () => {
@ -1081,8 +1084,8 @@ describe("generateTimeSlots", () => {
existingEvents
);
expect(slots).to.have.length(1);
expect(slots[0].time).to.equal("23:00");
assert.strictEqual(slots.length, 1);
assert.strictEqual(slots[0].time, "23:00");
});
it("should handle complex overlapping event scenarios", () => {
@ -1126,25 +1129,25 @@ describe("generateTimeSlots", () => {
const conflictingTimes = ["10:00", "10:30", "11:00", "11:30"];
conflictingTimes.forEach((time) => {
const slot = slots.find((s) => s.time === time);
expect(slot?.available).to.be.false;
assert.strictEqual(slot?.available, false);
});
// Check that non-conflicting slots are available
const slot12_00 = slots.find((s) => s.time === "12:00");
expect(slot12_00?.available).to.be.true;
assert.strictEqual(slot12_00?.available, true);
});
});
describe("Helper functions", () => {
it("should correctly convert day of week", () => {
expect(getDayOfWeek(new Date("2024-01-15"))).to.equal(0); // Monday
expect(getDayOfWeek(new Date("2024-01-16"))).to.equal(1); // Tuesday
expect(getDayOfWeek(new Date("2024-01-21"))).to.equal(6); // Sunday
assert.strictEqual(getDayOfWeek(new Date("2024-01-15")), 0); // Monday
assert.strictEqual(getDayOfWeek(new Date("2024-01-16")), 1); // Tuesday
assert.strictEqual(getDayOfWeek(new Date("2024-01-21")), 6); // Sunday
});
it("should format date strings correctly", () => {
expect(getDateStringCET(new Date("2024-01-15T10:30:00Z"))).to.equal("2024-01-15");
expect(getDateStringCET(new Date("2024-12-31T23:59:59Z"))).to.equal("2025-01-01");
assert.strictEqual(getDateStringCET(new Date("2024-01-15T10:30:00Z")), "2024-01-15");
assert.strictEqual(getDateStringCET(new Date("2024-12-31T23:59:59Z")), "2025-01-01");
});
});
});

View file

@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Stripe Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should create checkout session", async () => {
const token = "this-is-a-very-clean-token";
const res = await client.v1.stripe["create-checkout-session"].$post(
{
json: {
price_id: "price_123",
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should create portal session", async () => {
const token = "this-is-a-very-clean-token";
const res = await client.v1.stripe["create-portal-session"].$post(
{
json: {
return_url: "https://example.com/account",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should get subscription status", async () => {
const token = "this-is-a-very-clean-token";
const res = await client.v1.stripe["subscription-status"].$get(
{},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should handle webhook", async () => {
const res = await client["stripe-webhook"].$post(
{
json: {
type: "checkout.session.completed",
data: {},
},
},
{
headers: {
"stripe-signature": "test-signature",
"Content-Type": "application/json",
},
}
);
// Will fail due to signature verification
assert.ok(res.status >= 400);
});
});

View file

@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Tablo Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should create tablo", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tablos.create.$post(
{
json: {
name: "Test Tablo",
status: "todo",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should update tablo", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tablos.update.$patch(
{
json: {
id: "123",
name: "Updated Tablo",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should delete tablo", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tablos.delete.$delete(
{
json: {
id: "123",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should get tablo members", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tablos.members[":tablo_id"].$get(
{
param: { tablo_id: "123" },
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
});

View file

@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("TabloData Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should create tablo file", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"]["tablo-data"].$post(
{
json: {
tablo_id: "123",
name: "test.pdf",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should get tablo files", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"]["tablo-data"][":tablo_id"].$get(
{
param: { tablo_id: "123" },
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should delete tablo file", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"]["tablo-data"][":tablo_id"][":file_id"].$delete(
{
param: { file_id: "123" },
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
});

View file

@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("Task Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should create task", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tasks.$post(
{
json: {
tablo_id: "123",
title: "Test Task",
status: "todo",
priority: "medium",
type: "task",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should get tasks for tablo", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tasks[":tablo_id"].$get(
{
param: { tablo_id: "123" },
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should update task", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tasks[":task_id"].$patch(
{
param: { task_id: "123" },
json: {
title: "Updated Task",
},
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should delete task", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].tasks[":task_id"].$delete(
{
param: { task_id: "123" },
},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
});

View file

@ -1,30 +1,30 @@
import { expect } from "chai";
import { describe, it } from "mocha";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
describe("encodeURIComponent with slashes", () => {
describe("Basic slash encoding", () => {
it("should encode a single forward slash", () => {
const input = "/";
const result = encodeURIComponent(input);
expect(result).to.equal("%2F");
assert.strictEqual(result, "%2F");
});
it("should encode multiple forward slashes", () => {
const input = "///";
const result = encodeURIComponent(input);
expect(result).to.equal("%2F%2F%2F");
assert.strictEqual(result, "%2F%2F%2F");
});
it("should encode slashes in a path-like string", () => {
const input = "path/to/resource";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fto%2Fresource");
assert.strictEqual(result, "path%2Fto%2Fresource");
});
it("should encode slashes with alphanumeric characters", () => {
const input = "user123/folder456/file789";
const result = encodeURIComponent(input);
expect(result).to.equal("user123%2Ffolder456%2Ffile789");
assert.strictEqual(result, "user123%2Ffolder456%2Ffile789");
});
});
@ -32,31 +32,31 @@ describe("encodeURIComponent with slashes", () => {
it("should encode slashes with spaces", () => {
const input = "path with spaces/folder with spaces";
const result = encodeURIComponent(input);
expect(result).to.equal("path%20with%20spaces%2Ffolder%20with%20spaces");
assert.strictEqual(result, "path%20with%20spaces%2Ffolder%20with%20spaces");
});
it("should encode slashes with query parameters", () => {
const input = "path/to/resource?param=value";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fto%2Fresource%3Fparam%3Dvalue");
assert.strictEqual(result, "path%2Fto%2Fresource%3Fparam%3Dvalue");
});
it("should encode slashes with ampersands", () => {
const input = "path/to/resource&another";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fto%2Fresource%26another");
assert.strictEqual(result, "path%2Fto%2Fresource%26another");
});
it("should encode slashes with hash symbols", () => {
const input = "path/to/#section";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fto%2F%23section");
assert.strictEqual(result, "path%2Fto%2F%23section");
});
it("should encode slashes with equals signs", () => {
const input = "path/to/key=value";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fto%2Fkey%3Dvalue");
assert.strictEqual(result, "path%2Fto%2Fkey%3Dvalue");
});
});
@ -64,37 +64,37 @@ describe("encodeURIComponent with slashes", () => {
it("should handle leading slash", () => {
const input = "/path/to/resource";
const result = encodeURIComponent(input);
expect(result).to.equal("%2Fpath%2Fto%2Fresource");
assert.strictEqual(result, "%2Fpath%2Fto%2Fresource");
});
it("should handle trailing slash", () => {
const input = "path/to/resource/";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fto%2Fresource%2F");
assert.strictEqual(result, "path%2Fto%2Fresource%2F");
});
it("should handle both leading and trailing slashes", () => {
const input = "/path/to/resource/";
const result = encodeURIComponent(input);
expect(result).to.equal("%2Fpath%2Fto%2Fresource%2F");
assert.strictEqual(result, "%2Fpath%2Fto%2Fresource%2F");
});
it("should handle consecutive slashes", () => {
const input = "path//to///resource";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2F%2Fto%2F%2F%2Fresource");
assert.strictEqual(result, "path%2F%2Fto%2F%2F%2Fresource");
});
it("should handle empty string", () => {
const input = "";
const result = encodeURIComponent(input);
expect(result).to.equal("");
assert.strictEqual(result, "");
});
it("should handle string with only slashes", () => {
const input = "////";
const result = encodeURIComponent(input);
expect(result).to.equal("%2F%2F%2F%2F");
assert.strictEqual(result, "%2F%2F%2F%2F");
});
});
@ -102,31 +102,31 @@ describe("encodeURIComponent with slashes", () => {
it("should encode file paths", () => {
const input = "documents/2024/report.pdf";
const result = encodeURIComponent(input);
expect(result).to.equal("documents%2F2024%2Freport.pdf");
assert.strictEqual(result, "documents%2F2024%2Freport.pdf");
});
it("should encode URL-like strings", () => {
const input = "https://example.com/path/to/resource";
const result = encodeURIComponent(input);
expect(result).to.equal("https%3A%2F%2Fexample.com%2Fpath%2Fto%2Fresource");
assert.strictEqual(result, "https%3A%2F%2Fexample.com%2Fpath%2Fto%2Fresource");
});
it("should encode user input with slashes", () => {
const input = "user/name/with/slashes";
const result = encodeURIComponent(input);
expect(result).to.equal("user%2Fname%2Fwith%2Fslashes");
assert.strictEqual(result, "user%2Fname%2Fwith%2Fslashes");
});
it("should encode file path with spaces and slashes", () => {
const input = "My Documents/Project Files/report 2024.pdf";
const result = encodeURIComponent(input);
expect(result).to.equal("My%20Documents%2FProject%20Files%2Freport%202024.pdf");
assert.strictEqual(result, "My%20Documents%2FProject%20Files%2Freport%202024.pdf");
});
it("should encode nested folder structure", () => {
const input = "root/subfolder1/subfolder2/subfolder3/file.txt";
const result = encodeURIComponent(input);
expect(result).to.equal("root%2Fsubfolder1%2Fsubfolder2%2Fsubfolder3%2Ffile.txt");
assert.strictEqual(result, "root%2Fsubfolder1%2Fsubfolder2%2Fsubfolder3%2Ffile.txt");
});
});
@ -134,20 +134,20 @@ describe("encodeURIComponent with slashes", () => {
it("should encode backslashes differently than forward slashes", () => {
const forwardSlash = "/";
const backslash = "\\";
expect(encodeURIComponent(forwardSlash)).to.equal("%2F");
expect(encodeURIComponent(backslash)).to.equal("%5C");
assert.strictEqual(encodeURIComponent(forwardSlash), "%2F");
assert.strictEqual(encodeURIComponent(backslash), "%5C");
});
it("should not encode unreserved characters", () => {
const input = "abc123-._~";
const result = encodeURIComponent(input);
expect(result).to.equal("abc123-._~");
assert.strictEqual(result, "abc123-._~");
});
it("should encode slashes but not alphanumeric characters", () => {
const input = "a/b/c/1/2/3";
const result = encodeURIComponent(input);
expect(result).to.equal("a%2Fb%2Fc%2F1%2F2%2F3");
assert.strictEqual(result, "a%2Fb%2Fc%2F1%2F2%2F3");
});
});
@ -155,19 +155,19 @@ describe("encodeURIComponent with slashes", () => {
it("should encode Unicode characters and slashes", () => {
const input = "文档/文件";
const result = encodeURIComponent(input);
expect(result).to.equal("%E6%96%87%E6%A1%A3%2F%E6%96%87%E4%BB%B6");
assert.strictEqual(result, "%E6%96%87%E6%A1%A3%2F%E6%96%87%E4%BB%B6");
});
it("should encode emoji with slashes", () => {
const input = "folder/😀/file";
const result = encodeURIComponent(input);
expect(result).to.equal("folder%2F%F0%9F%98%80%2Ffile");
assert.strictEqual(result, "folder%2F%F0%9F%98%80%2Ffile");
});
it("should encode mixed Unicode and ASCII with slashes", () => {
const input = "path/café/über";
const result = encodeURIComponent(input);
expect(result).to.equal("path%2Fcaf%C3%A9%2F%C3%BCber");
assert.strictEqual(result, "path%2Fcaf%C3%A9%2F%C3%BCber");
});
});
@ -175,14 +175,14 @@ describe("encodeURIComponent with slashes", () => {
it("should correctly decode encoded slashes", () => {
const encoded = "path%2Fto%2Fresource";
const decoded = decodeURIComponent(encoded);
expect(decoded).to.equal("path/to/resource");
assert.strictEqual(decoded, "path/to/resource");
});
it("should correctly encode and decode round-trip", () => {
const original = "path/to/resource/with/slashes";
const encoded = encodeURIComponent(original);
const decoded = decodeURIComponent(encoded);
expect(decoded).to.equal(original);
assert.strictEqual(decoded, original);
});
it("should handle multiple encode/decode cycles", () => {
@ -191,7 +191,7 @@ describe("encodeURIComponent with slashes", () => {
const encoded2 = encodeURIComponent(encoded1);
const decoded1 = decodeURIComponent(encoded2);
const decoded2 = decodeURIComponent(decoded1);
expect(decoded2).to.equal(original);
assert.strictEqual(decoded2, original);
});
});
});

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { testClient } from "hono/testing";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
describe("User Endpoint", () => {
// In test mode, createConfig() reads from .env.test
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
it("should return user profile", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["v1"].me.$get(
{},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
// Auth middleware is initialized but the test Supabase client returns an error
assert.ok(res.status >= 400);
});
it("should sign up user to stream", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["sign-up-to-stream"].$post(
{},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
it("should mark user as temporary", async () => {
const token = "this-is-a-very-clean-token";
const res = await client["mark-temporary"].$post(
{},
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
assert.ok(res.status >= 400);
});
});

37
apps/api/src/client.ts Normal file
View file

@ -0,0 +1,37 @@
/**
* Client type exports for RPC usage
*
* Use this in your frontend or tests for type-safe API calls:
*
* @example
* ```ts
* import { hc } from 'hono/client'
* import type { AppType } from './client'
*
* const client = hc<AppType>('http://localhost:8080')
*
* // Now you get full type safety:
* const res = await client.api.v1.users.me.$get()
* const data = await res.json() // fully typed!
* ```
*/
import { Hono } from "hono";
import type { ApiRoutes } from "./routers/index.js";
import type { getPublicRouter } from "./routers/public.js";
/**
* Main application type for RPC client
* Combines all routes for type-safe client usage
*/
export type AppType = Hono<{
Variables: Record<string, never>;
}> & {
"/api/v1": ApiRoutes;
"/api/public": ReturnType<typeof getPublicRouter>;
};
/**
* Re-export the base API routes type
*/
export type { ApiRoutes };

View file

@ -32,35 +32,59 @@ function validateEnvVar(name: string, value: string | undefined): string {
return value;
}
export function createConfig(secrets: Secrets): AppConfig {
export function createConfig(secrets?: Secrets): AppConfig {
const NODE_ENV = (process.env.NODE_ENV || "development") as
| "development"
| "production"
| "staging";
| "staging"
| "test";
dotenv.config({ path: `.env.${NODE_ENV}` });
// In test mode, use environment variables directly instead of secrets
const isTestMode = NODE_ENV === "test";
// Base configuration
const baseConfig: AppConfig = {
NODE_ENV,
PORT: parseInt(process.env.PORT || "8080", 10),
SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL),
SUPABASE_SERVICE_ROLE_KEY: secrets.supabaseServiceRoleKey,
SUPABASE_CONNECTION_STRING: secrets.supabaseConnectionString,
SUPABASE_CA_CERT: secrets.supabaseCaCert,
SUPABASE_SERVICE_ROLE_KEY: isTestMode
? validateEnvVar("SUPABASE_SERVICE_ROLE_KEY", process.env.SUPABASE_SERVICE_ROLE_KEY)
: secrets!.supabaseServiceRoleKey,
SUPABASE_CONNECTION_STRING: isTestMode
? validateEnvVar("SUPABASE_CONNECTION_STRING", process.env.SUPABASE_CONNECTION_STRING)
: secrets!.supabaseConnectionString,
SUPABASE_CA_CERT: isTestMode
? validateEnvVar("SUPABASE_CA_CERT", process.env.SUPABASE_CA_CERT)
: secrets!.supabaseCaCert,
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
STREAM_CHAT_API_SECRET: secrets.streamChatApiSecret,
STRIPE_SECRET_KEY: secrets.stripeSecretKey,
STRIPE_WEBHOOK_SECRET: secrets.stripeWebhookSecret,
STREAM_CHAT_API_SECRET: isTestMode
? validateEnvVar("STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET)
: secrets!.streamChatApiSecret,
STRIPE_SECRET_KEY: isTestMode
? validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY)
: secrets!.stripeSecretKey,
STRIPE_WEBHOOK_SECRET: isTestMode
? validateEnvVar("STRIPE_WEBHOOK_SECRET", process.env.STRIPE_WEBHOOK_SECRET)
: secrets!.stripeWebhookSecret,
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: secrets.emailClientSecret,
EMAIL_REFRESH_TOKEN: secrets.emailRefreshToken,
EMAIL_CLIENT_SECRET: isTestMode
? validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET)
: secrets!.emailClientSecret,
EMAIL_REFRESH_TOKEN: isTestMode
? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN)
: secrets!.emailRefreshToken,
CORS_ORIGIN: process.env.CORS_ORIGIN || "https://app.xtablo.com",
XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: secrets.r2AccessKeyId,
R2_SECRET_ACCESS_KEY: secrets.r2SecretAccessKey,
R2_ACCESS_KEY_ID: isTestMode
? validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID)
: secrets!.r2AccessKeyId,
R2_SECRET_ACCESS_KEY: isTestMode
? validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY)
: secrets!.r2SecretAccessKey,
TASKS_SECRET: process.env.TASKS_SECRET || "",
LOG_LEVEL: "info",
};

View file

@ -1,10 +1,10 @@
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo } from "@xtablo/shared-types";
import type { Context, Next } from "hono";
import type { Transporter } from "nodemailer";
import { generatePassword } from "./token.js";
import type { EventAndTablo } from "./types.ts";
import type { StreamChat } from "stream-chat";
import { generatePassword } from "./token.js";
export const generateICSFromEvents = (
events: EventAndTablo[],

View file

@ -1,5 +1,5 @@
import type { Tables } from "@xtablo/shared-types";
import { DateTime } from "luxon";
import type { Tables } from "./database.types.js";
// Types for availability calculation
type TimeRange = {

View file

@ -4,12 +4,11 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import path from "path";
import Stripe from "stripe";
import { fileURLToPath } from "url";
import { createConfig } from "./config.js";
import { MiddlewareManager } from "./middleware.js";
import { getPublicRouter } from "./public.js";
import { getMainRouter } from "./routers.js";
import { MiddlewareManager } from "./middlewares/middleware.js";
import { getMainRouter } from "./routers/index.js";
import { getPublicRouter } from "./routers/public.js";
import { loadSecrets, type Secrets } from "./secrets.js";
tracer.init({
@ -28,11 +27,6 @@ async function startServer(secrets: Secrets) {
// Initialize middleware manager globally
MiddlewareManager.initialize(config);
// Initialize Stripe client
const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", {
apiVersion: "2025-10-29.clover",
});
const app = new Hono();
app.use(logger());
@ -55,7 +49,7 @@ async function startServer(secrets: Secrets) {
return corsMiddleware(c, next);
});
app.route("/api/v1", getMainRouter(config, stripe));
app.route("/api/v1", getMainRouter(config));
app.route("/api/public", getPublicRouter());
serve(

View file

@ -1,13 +1,14 @@
import { S3Client } from "@aws-sdk/client-s3";
import type { StripeSync } from "@supabase/stripe-sync-engine";
import { createClient, type SupabaseClient, type User } from "@supabase/supabase-js";
import type { Context, MiddlewareHandler, Next } from "hono";
import { createMiddleware } from "hono/factory";
import { StreamChat } from "stream-chat";
import { type AppConfig } from "./config.js";
import { createTransporter } from "./transporter.js";
import type { Transporter } from "nodemailer";
import type { StripeSync } from "@supabase/stripe-sync-engine";
import { StreamChat } from "stream-chat";
import { Stripe } from "stripe";
import { type AppConfig } from "../config.js";
import { createStripeSync } from "./stripeSync.js";
import { createTransporter } from "./transporter.js";
export type Middlewares = {
supabaseMiddleware: MiddlewareHandler<{
@ -39,6 +40,9 @@ export type Middlewares = {
stripeSyncMiddleware: MiddlewareHandler<{
Variables: { stripeSync: StripeSync };
}>;
stripeMiddleware: MiddlewareHandler<{
Variables: { stripe: Stripe };
}>;
};
export class MiddlewareManager {
@ -184,6 +188,14 @@ export class MiddlewareManager {
await next();
});
const stripeMiddleware = createMiddleware(async (c: Context, next: Next) => {
const stripe = new Stripe(config.STRIPE_SECRET_KEY || "", {
apiVersion: "2025-10-29.clover",
});
c.set("stripe", stripe);
await next();
});
return {
supabaseMiddleware,
basicAuthMiddleware,
@ -194,6 +206,7 @@ export class MiddlewareManager {
regularUserCheckMiddleware,
transporterMiddleware,
stripeSyncMiddleware,
stripeMiddleware,
};
}
@ -232,4 +245,8 @@ export class MiddlewareManager {
get stripeSync() {
return this.middlewares.stripeSyncMiddleware;
}
get stripe() {
return this.middlewares.stripeMiddleware;
}
}

View file

@ -1,5 +1,5 @@
import { StripeSync } from "@supabase/stripe-sync-engine";
import type { AppConfig } from "./config.js";
import type { AppConfig } from "../config.js";
export const createStripeSync = (config: AppConfig): StripeSync => {
const ssl = {

View file

@ -1,6 +1,6 @@
import { google } from "googleapis";
import nodemailer from "nodemailer";
import type { AppConfig } from "./config.js";
import type { AppConfig } from "../config.js";
const OAuth2 = google.auth.OAuth2;

View file

@ -0,0 +1,28 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import { getBookingRouter } from "./invite.js";
import { getNotesRouter } from "./notes.js";
import { getStripeRouter } from "./stripe.js";
import { getTabloRouter } from "./tablo.js";
import { getTabloDataRouter } from "./tablo_data.js";
import { getUserRouter } from "./user.js";
export const getAuthenticatedRouter = (config: AppConfig) => {
const authRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
// Apply authentication middleware to all routes in this router
authRouter.use(middlewareManager.auth);
authRouter.route("/users", getUserRouter());
authRouter.route("/tablos", getTabloRouter(config));
authRouter.route("/tablo-data", getTabloDataRouter());
authRouter.route("/notes", getNotesRouter());
authRouter.route("/book", getBookingRouter());
// stripe routes
authRouter.route("/stripe", getStripeRouter(config));
return authRouter;
};

View file

@ -0,0 +1,44 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
import { getAuthenticatedRouter } from "./authRouter.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
import { getPublicRouter } from "./public.js";
import { getStripeWebhookRouter } from "./stripe.js";
import { getTaskRouter } from "./tasks.js";
export const getMainRouter = (config: AppConfig) => {
const mainRouter = new Hono<BaseEnv>();
const middlewareManager = MiddlewareManager.getInstance();
mainRouter.use(middlewareManager.supabase);
mainRouter.use(middlewareManager.streamChat);
mainRouter.use(middlewareManager.r2);
mainRouter.use(middlewareManager.transporter);
mainRouter.use(middlewareManager.stripe);
mainRouter.use(middlewareManager.stripeSync);
// authenticated routes
mainRouter.route("/", getAuthenticatedRouter(config));
// maybe authenticated routes
mainRouter.route("/", getMaybeAuthenticatedRouter());
// public routes
mainRouter.route("/public", getPublicRouter());
// tasks routes
mainRouter.route("/tasks", getTaskRouter(config));
// webhooks
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
return mainRouter;
};
/**
* Type-safe API routes for RPC usage
* Use with hono/client: const client = hc<ApiRoutes>('http://localhost:8080')
*/
export type ApiRoutes = ReturnType<typeof getMainRouter>;

View file

@ -0,0 +1,272 @@
import type { Database, TablesInsert } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { createInvitedUser } from "../helpers/helpers.js";
import type { EventTypeConfig } from "../helpers/slots.js";
import type { MaybeAuthEnv } from "../types/app.types.js";
const factory = createFactory<MaybeAuthEnv>();
const bookSlot = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const transporter = c.get("transporter");
const maybeUser = c.get("user");
const data = await c.req.json();
// Validate that owner_id is provided
if (!data.owner_short_id) {
return c.json({ error: "owner_id is required" }, 400);
}
if (!data.event_type_standard_name) {
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", data.owner_short_id)
.single();
if (ownerError || !ownerData) {
console.error("Error fetching owner:", ownerError);
return c.json({ error: "owner_id is incorrect" }, 400);
}
let hasCreatedAccount = false;
if (!maybeUser) {
// Check if email already exists in the database
const { data: existingUser, error: existingUserError } = await supabase
.from("profiles")
.select("id, email")
.eq("email", data.user_details.email)
.maybeSingle();
if (existingUserError) {
console.error("Error checking existing user:", existingUserError);
return c.json({ error: "Failed to check existing user" }, 500);
}
if (!existingUser) {
hasCreatedAccount = true;
// Create a temporary user for the booking
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
data.user_details.email,
ownerData.email
);
if (!result.success) {
console.error("Error creating invited user:", result.error);
return c.json({ error: result.error }, 500);
}
}
}
const { data: bookerUser, error: bookerUserError } = await supabase
.from("profiles")
.select("id, name, email")
.eq("email", data.user_details.email)
.maybeSingle();
if (bookerUserError) {
console.error("Error fetching booker user:", bookerUserError);
return c.json({ error: "Failed to get booker user" }, 500);
}
const ownerDataTyped = ownerData as {
id: string;
name: string;
email: string;
};
const ownerId = ownerDataTyped.id;
const bookerUserDataTyped = bookerUser as {
id: string;
name: string;
email: string;
};
if (ownerDataTyped.email === bookerUserDataTyped.email) {
return c.json({ error: "You cannot create a tablo with yourself" }, 400);
}
const { data: eventTypeData, error: eventTypeError } = await supabase
.from("event_types")
.select("*")
.eq("user_id", ownerId)
.eq("standard_name", data.event_type_standard_name)
.is("deleted_at", null)
.single();
if (eventTypeError || !eventTypeData) {
console.error("Error fetching event type:", eventTypeError);
return c.json({ error: "Event type not found" }, 404);
}
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// 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", bookerUserDataTyped.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: `${bookerUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
})
.select()
.single();
if (error) {
console.error("Error creating tablo:", 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").upsert(
{
tablo_id: tabloData.id,
user_id: bookerUserDataTyped.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, bookerUserDataTyped.id],
});
await channel.create();
const newEvent: TablesInsert<"events"> = {
description: eventTypeConfig.description || "",
end_time: data.event_details.end_time || "",
start_date: data.event_details.start_date || "",
start_time: data.event_details.start_time || "",
title: eventTypeConfig.name || "",
tablo_id: tabloData.id,
created_by: ownerId,
};
const { error: eventError } = await supabase.from("events").insert(newEvent);
if (eventError) {
console.error("Error creating event:", eventError);
return c.json({ error: "Failed to create event" }, 500);
}
// Send a welcome message to the channel
await channel.sendMessage({
text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${newEvent.title}" est confirmé pour le ${newEvent.start_date} de ${newEvent.start_time} à ${newEvent.end_time}.`,
user_id: 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 é 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 : ${newEvent.title}</li>
<li>Date : ${newEvent.start_date}</li>
<li>Heure : ${newEvent.start_time} - ${newEvent.end_time}</li>
<li>Description : ${newEvent.description}</li>
</ul>
<p>Participant : ${bookerUserDataTyped.name} (${bookerUserDataTyped.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: bookerUserDataTyped.email,
subject: "Réservation confirmée - Nouveau tablo créé",
html: `
<h2>Votre réservation est confirmée !</h2>
<p>Bonjour ${bookerUserDataTyped.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 : ${newEvent.title}</li>
<li>Date : ${newEvent.start_date}</li>
<li>Heure : ${newEvent.start_time} - ${newEvent.end_time}</li>
<li>Description : ${newEvent.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({
message: "Booking successful",
tablo_id: tabloData.id,
hasCreatedAccount,
email: bookerUserDataTyped.email,
});
});
export const getBookingRouter = () => {
const bookingRouter = new Hono();
bookingRouter.post("/slot", ...bookSlot);
return bookingRouter;
};

View file

@ -0,0 +1,16 @@
import { Hono } from "hono";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { MaybeAuthEnv } from "../types/app.types.js";
import { getBookingRouter } from "./invite.js";
export const getMaybeAuthenticatedRouter = () => {
const maybeAuthenticated = new Hono<MaybeAuthEnv>();
const middlewareManager = MiddlewareManager.getInstance();
maybeAuthenticated.use(middlewareManager.maybeAuthenticated);
maybeAuthenticated.route("/book", getBookingRouter());
return maybeAuthenticated;
};

View file

@ -0,0 +1,82 @@
import type { Database } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { checkTabloMember } from "../helpers/helpers.js";
import type { AuthEnv } from "../types/app.types.js";
type Note = Database["public"]["Tables"]["notes"]["Row"];
const factory = createFactory<AuthEnv>();
/**
* Fetch notes shared with a specific tablo
*/
const getTabloNotes = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
if (!tabloId) {
return c.json({ error: "Tablo ID is required" }, 400);
}
const supabase = c.get("supabase");
// Find the tablo owner
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("owner_id")
.eq("id", tabloId)
.single();
if (tabloError) {
console.error("Error fetching tablo:", tabloError);
return c.json({ error: "Failed to fetch tablo" }, 500);
}
if (!tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
const tabloOwnerId = tabloData.owner_id;
// Find notes shared with this specific tablo or all tablos
const { data, error } = await supabase
.from("note_access")
.select(`
note_id,
notes!inner (
id,
title,
content,
user_id,
created_at,
updated_at,
deleted_at
)
`)
.eq("is_active", true)
.eq("user_id", tabloOwnerId)
.or(`tablo_id.eq.${tabloId},tablo_id.is.null`)
.is("notes.deleted_at", null);
if (error) {
return c.json({ error: "An error occurred" }, 500);
}
// Extract notes from the join result and remove duplicates
type JoinedResult = { note_id: string; notes: Note[] };
const extractedNotes = (data as JoinedResult[])
.map((item) => (Array.isArray(item.notes) ? item.notes[0] : item.notes))
.filter((note) => note !== null && note !== undefined);
// Remove duplicates by note id (in case a note is shared both with all tablos and this specific tablo)
const uniqueNotes = Array.from(new Map(extractedNotes.map((note) => [note.id, note])).values());
return c.json({ notes: uniqueNotes });
});
export const getNotesRouter = () => {
const notesRouter = new Hono();
notesRouter.get("/:tabloId", ...getTabloNotes);
return notesRouter;
};

View file

@ -0,0 +1,131 @@
import type { Database, Tables } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import {
type EventTypeConfig,
type Exception,
generateTimeSlots,
getDateStringCET,
getDayOfWeek,
type TimeSlot,
type WeeklyAvailability,
} from "../helpers/slots.js";
import type { BaseEnv } from "../types/app.types.js";
const factory = createFactory<BaseEnv>();
const getPublicSlots = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const shortUserId = c.req.param("shortUserId");
const standardName = c.req.param("standardName");
// Get user
const { data: userData, error: userError } = await supabase
.from("profiles")
.select("*")
.eq("short_user_id", shortUserId)
.single();
if (userError || !userData) {
return c.json({ error: "User not found" }, 404);
}
const user = userData as Tables<"profiles">;
// Get event type
const { data: eventTypeData, error: eventTypeError } = await supabase
.from("event_types")
.select("*")
.eq("user_id", user.id)
.eq("standard_name", standardName)
.is("deleted_at", null)
.single();
if (eventTypeError || !eventTypeData) {
return c.json({ error: "Event type not found" }, 404);
}
const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
const eventTypeConfig = eventType.config as EventTypeConfig;
// Get user's availabilities
const { data: availabilitiesData, error: availabilitiesError } = await supabase
.from("availabilities")
.select("*")
.eq("user_id", user.id)
.single();
if (availabilitiesError) {
return c.json({ error: "Availabilities not found" }, 404);
}
const availabilities = availabilitiesData as Tables<"availabilities">;
const weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month
// Use CET time for availability calculations
const now = new Date();
const nextMonth = new Date(now);
nextMonth.setMonth(now.getMonth() + 2);
const { data: eventsData, error: eventsError } = await supabase
.from("events")
.select("*")
.eq("created_by", user.id)
.gte("start_date", getDateStringCET(now))
.lte("start_date", getDateStringCET(nextMonth))
.is("deleted_at", null);
if (eventsError) {
return c.json({ error: "Failed to fetch events" }, 500);
}
const existingEvents = eventsData as Tables<"events">[];
// Generate slots for the next month
const slots: TimeSlot[] = [];
const currentDate = new Date(now);
while (currentDate <= nextMonth) {
const dayOfWeek = getDayOfWeek(currentDate);
const dayAvailability = weeklyAvailability[dayOfWeek];
if (dayAvailability) {
const daySlots = generateTimeSlots(
now, // Pass CET current time as first parameter
currentDate,
dayAvailability,
eventTypeConfig,
exceptions,
existingEvents
);
slots.push(...daySlots);
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Group slots by date for easier frontend consumption
const slotsByDate: { [date: string]: TimeSlot[] } = {};
slots.forEach((slot) => {
if (!slotsByDate[slot.date]) {
slotsByDate[slot.date] = [];
}
slotsByDate[slot.date].push(slot);
});
return c.json({
user: { name: user.name, avatar_url: user.avatar_url },
eventType: eventTypeConfig,
slots: slotsByDate,
availableSlots: slots.filter((slot) => slot.available),
});
});
export const getPublicRouter = () => {
const publicRouter = new Hono();
publicRouter.get("/slots/:shortUserId/:standardName", ...getPublicSlots);
return publicRouter;
};

View file

@ -1,79 +1,66 @@
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import Stripe from "stripe";
import type { AppConfig } from "./config.js";
import { MiddlewareManager } from "./middleware.js";
import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv, BaseEnv } from "../types/app.types.js";
const webhookFactory = createFactory<BaseEnv>();
/**
* Stripe webhook handler using @supabase/stripe-sync-engine
* This automatically syncs all Stripe events to Supabase tables
* Repository: https://github.com/supabase/stripe-sync-engine
*/
const handleWebhook = webhookFactory.createHandlers(async (c) => {
const stripeSync = c.get("stripeSync");
try {
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "No signature provided" }, 400);
}
// Get raw body for signature verification
const rawBody = await c.req.text();
// Process webhook using Stripe Sync Engine
// This handles signature verification and syncing automatically
await stripeSync.processWebhook(rawBody, signature);
return c.json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Webhook processing failed" },
400
);
}
});
export const getStripeWebhookRouter = () => {
const stripeWebhookRouter = new Hono<{
Variables: {
stripeSync: StripeSync;
};
}>();
const middlewareManager = MiddlewareManager.getInstance();
stripeWebhookRouter.use(middlewareManager.stripeSync);
/**
* Stripe webhook handler using @supabase/stripe-sync-engine
* This automatically syncs all Stripe events to Supabase tables
* Repository: https://github.com/supabase/stripe-sync-engine
*/
stripeWebhookRouter.post("/", async (c) => {
const stripeSync = c.get("stripeSync");
try {
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "No signature provided" }, 400);
}
// Get raw body for signature verification
const rawBody = await c.req.text();
// Process webhook using Stripe Sync Engine
// This handles signature verification and syncing automatically
await stripeSync.processWebhook(rawBody, signature);
return c.json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Webhook processing failed" },
400
);
}
});
const stripeWebhookRouter = new Hono();
stripeWebhookRouter.post("/", ...handleWebhook);
return stripeWebhookRouter;
};
export const getStripeRouter = (config: AppConfig, stripe: Stripe) => {
const stripeRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
};
}>();
const stripeFactory = createFactory<AuthEnv>();
const middlewareManager = MiddlewareManager.getInstance();
stripeRouter.use(middlewareManager.auth);
// ============================================================================
// Authenticated endpoints
// ============================================================================
/**
* Create a Stripe Checkout Session
* POST /api/v1/stripe/create-checkout-session
*/
stripeRouter.post("/create-checkout-session", middlewareManager.regularUserCheck, async (c) => {
/**
* Create a Stripe Checkout Session
* POST /api/v1/stripe/create-checkout-session
*/
const createCheckoutSession = (
config: AppConfig,
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
stripeFactory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const stripe = c.get("stripe");
const body = await c.req.json();
const { priceId, successUrl, cancelUrl } = body;
@ -144,13 +131,19 @@ export const getStripeRouter = (config: AppConfig, stripe: Stripe) => {
}
});
/**
* Create a Stripe Customer Portal Session
* POST /api/v1/stripe/create-portal-session
*/
stripeRouter.post("/create-portal-session", middlewareManager.regularUserCheck, async (c) => {
/**
* Create a Stripe Customer Portal Session
* POST /api/v1/stripe/create-portal-session
*/
const createPortalSession = (
config: AppConfig,
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
stripeFactory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const stripe = c.get("stripe");
const body = await c.req.json();
const { returnUrl } = body;
@ -183,16 +176,18 @@ export const getStripeRouter = (config: AppConfig, stripe: Stripe) => {
}
});
// Note: Subscription status queries are handled directly from the frontend
// using Supabase client with RLS policies. No API endpoints needed for reads.
// Note: Subscription status queries are handled directly from the frontend
// using Supabase client with RLS policies. No API endpoints needed for reads.
/**
* Cancel subscription at period end
* POST /api/v1/stripe/cancel-subscription
*/
stripeRouter.post("/cancel-subscription", middlewareManager.regularUserCheck, async (c) => {
/**
* Cancel subscription at period end
* POST /api/v1/stripe/cancel-subscription
*/
const cancelSubscription = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
stripeFactory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const stripe = c.get("stripe");
try {
// Get user's Stripe customer first
@ -237,13 +232,17 @@ export const getStripeRouter = (config: AppConfig, stripe: Stripe) => {
}
});
/**
* Reactivate a canceled subscription
* POST /api/v1/stripe/reactivate-subscription
*/
stripeRouter.post("/reactivate-subscription", middlewareManager.regularUserCheck, async (c) => {
/**
* Reactivate a canceled subscription
* POST /api/v1/stripe/reactivate-subscription
*/
const reactivateSubscription = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
stripeFactory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const stripe = c.get("stripe");
try {
// Get user's Stripe customer first
@ -287,5 +286,17 @@ export const getStripeRouter = (config: AppConfig, stripe: Stripe) => {
}
});
export const getStripeRouter = (config: AppConfig) => {
const stripeRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
stripeRouter.post(
"/create-checkout-session",
...createCheckoutSession(config, middlewareManager)
);
stripeRouter.post("/create-portal-session", ...createPortalSession(config, middlewareManager));
stripeRouter.post("/cancel-subscription", ...cancelSubscription(middlewareManager));
stripeRouter.post("/reactivate-subscription", ...reactivateSubscription(middlewareManager));
return stripeRouter;
};

View file

@ -0,0 +1,518 @@
import type { EventInsertInTablo, Tables, TabloInsert } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import type { AppConfig } from "../config.js";
import { checkTabloAdmin, createInvitedUser, writeCalendarFileToR2 } from "../helpers/helpers.js";
import { generateToken } from "../helpers/token.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv } from "../types/app.types.js";
type PostTablo = Omit<TabloInsert, "owner_id"> & {
events?: EventInsertInTablo[];
};
const factory = createFactory<AuthEnv>();
const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, 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" });
});
const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, 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" });
});
const deleteTablo = factory.createHandlers(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" });
});
const inviteToTablo = (
config: AppConfig,
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const transporter = c.get("transporter");
const sender = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
const { email: recipientmail } = await c.req.json();
if (sender.email === recipientmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
// Get tablo name
const { data: tablo, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tabloId)
.maybeSingle();
if (tabloError || !tablo) {
return c.json({ error: "Tablo not found" }, 404);
}
const token = generateToken();
const { data: introConfigData, error: introError } = await supabase
.from("user_introductions")
.select("config")
.eq("user_id", sender.id)
.maybeSingle();
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: tabloId,
invited_by: sender.id,
invite_token: token,
is_pending: true,
});
if (error) {
// Check if this is a duplicate invite error
if (error.code === "23505") {
return c.json({ error: "User has already been invited to this tablo" }, 409);
}
return c.json({ error: error.message }, 500);
}
// Get user from recipient email
const { data: recipientUser, error: recipientError } = await supabase
.from("profiles")
.select("id")
.eq("email", recipientmail)
.maybeSingle();
if (recipientError) {
return c.json({ error: recipientError.message }, 500);
}
if (!recipientUser) {
// Create a new invited user and add them to the tablo
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientmail,
sender.email
);
if (!result.success) {
return c.json({ error: result.error }, 500);
}
// Add the user to the tablo
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id: tabloId,
user_id: result.userId,
granted_by: sender.id,
is_active: true,
// ** IMPORTANT **
is_admin: false,
// -------------
});
if (tabloAccessError) {
return c.json({ error: tabloAccessError.message }, 500);
}
return c.json({
message: "User created and invite sent successfully",
});
}
// Check if the user already has access to the tablo
const { data: existingAccess, error: existingAccessError } = await supabase
.from("tablo_access")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", recipientUser.id)
.maybeSingle();
if (existingAccessError) {
return c.json({ error: existingAccessError.message }, 500);
}
if (existingAccess) {
return c.json({ message: "User already has access to this tablo" }, 400);
}
// Let the user know that they have been invited to the tablo
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-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</p>
`,
});
return c.json({
message: "Invite sent successfully",
});
});
const joinTablo = factory.createHandlers(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)
.eq("is_pending", true)
.maybeSingle();
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);
// Check if it's a conflict error (user already has access)
if (tabloAccessError.code === "23505") {
return c.json({ error: "User already has access to this tablo" }, 409);
}
return c.json({ error: tabloAccessError.message }, 500);
}
// Mark invite as accepted instead of deleting (maintains audit trail)
await supabase.from("tablo_invites").update({ is_pending: false }).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({ tablo_id });
});
const getTabloMembers = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const tablo_id = c.req.param("tablo_id");
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, email)")
.eq("tablo_id", tablo_id)
.eq("is_active", true);
const rows = data as unknown as {
is_admin: boolean;
profiles: {
id: string;
name: string;
email: string;
};
}[];
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
members: rows.map((member) => ({
...member.profiles,
is_admin: member.is_admin,
email: member.profiles.email,
})),
});
});
const leaveTablo = factory.createHandlers(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" });
});
const generateWebcalUrl = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, 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,
});
});
export const getTabloRouter = (config: AppConfig) => {
const tabloRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
tabloRouter.post("/create", ...createTablo(middlewareManager));
tabloRouter.patch("/update", ...updateTablo(middlewareManager));
tabloRouter.delete("/delete", ...deleteTablo);
tabloRouter.post("/invite/:tabloId", ...inviteToTablo(config, middlewareManager));
tabloRouter.post("/join", ...joinTablo);
tabloRouter.get("/members/:tablo_id", ...getTabloMembers);
tabloRouter.post("/leave", ...leaveTablo);
tabloRouter.post("/webcal/generate-url", ...generateWebcalUrl(middlewareManager));
return tabloRouter;
};

View file

@ -0,0 +1,169 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "../helpers/helpers.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
try {
const fileNames = await getTabloFileNames(s3_client, tabloId);
return c.json({ fileNames: fileNames || [] });
} catch (error) {
console.error("Error fetching tablo files:", error);
return c.json({ error: "Failed to fetch tablo files" }, 500);
}
});
const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
if (!response.Body) {
return c.json({ error: "File not found" }, 404);
}
const content = await response.Body.transformToString();
return c.json({
fileName,
content,
contentType: response.ContentType,
lastModified: response.LastModified,
});
} catch (error) {
console.error("Error fetching file:", error);
return c.json({ error: "Failed to fetch file" }, 500);
}
});
const postTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const body = await c.req.json();
const { content, contentType = "text/plain" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Body: content,
ContentType: contentType,
})
);
return c.json({
message: "File uploaded successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
});
// // PUT /tablo-data/:tabloId/:fileName - Update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
// const tabloId = c.req.param("tabloId");
// const fileName = c.req.param("fileName");
// const s3_client = c.get("s3_client");
// try {
// const body = await c.req.json();
// const { content, contentType = "text/plain" } = body;
// if (!content) {
// return c.json({ error: "Content is required" }, 400);
// }
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
// await s3_client.send(
// new PutObjectCommand({
// Bucket: "tablo-data",
// Key: `${tabloId}/${fileName}`,
// Body: content,
// ContentType: contentType,
// })
// );
// return c.json({
// message: "File updated successfully",
// fileName,
// tabloId,
// });
// } catch (error) {
// console.error("Error updating file:", error);
// return c.json({ error: "Failed to update file" }, 500);
// }
// });
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
}
});
export const getTabloDataRouter = () => {
const tabloDataRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
tabloDataRouter.use(middlewareManager.auth);
tabloDataRouter.use(middlewareManager.streamChat);
tabloDataRouter.use(middlewareManager.r2);
tabloDataRouter.get("/:tabloId/filenames", ...getTabloFilenames);
tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile);
tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile(middlewareManager));
tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager));
return tabloDataRouter;
};

View file

@ -0,0 +1,95 @@
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import type { AppConfig } from "../config.js";
import { writeCalendarFileToR2 } from "../helpers/helpers.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
const factory = createFactory<BaseEnv>();
const syncCalendars = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const s3 = c.get("s3_client");
const { data, error } = await supabase
.from("calendar_subscriptions")
.select("token, tablo_id, tablos(name)");
if (error) {
return c.json({ error: error.message }, 500);
}
const calendarSubscriptionsData = data as unknown as [
{
token: string;
tablo_id: string;
tablos: { name: string };
},
];
calendarSubscriptionsData.forEach(async (subscription) => {
const tabloName = subscription.tablos.name.replace(/ /g, "_");
await writeCalendarFileToR2(s3, supabase, {
tabloName,
token: subscription.token,
tablo_id: subscription.tablo_id,
});
});
return c.json({ message: "Synced calendars" });
});
const syncTabloNames = (config: AppConfig) =>
factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const fifteenMinutesInMilliseconds = 1000 * 60 * 15;
const { data, error } = await supabase
.from("tablos")
.select("id, name")
.gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString());
if (error) {
return c.json({ error: error.message }, 500);
}
const tablosData = data as { id: string; name: string }[];
tablosData.forEach(async (tablo) => {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.update({
// @ts-ignore
name: tablo.name,
});
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
});
return c.json({ message: `Synced ${tablosData.length} tablo names` });
});
const syncStripeSubscriptions = factory.createHandlers(async (c) => {
const data = await c.get("stripeSync").syncBackfill({ object: "all" });
return c.json({ message: `Synced ${data.subscriptions?.synced} stripe subscriptions` });
});
export const getTaskRouter = (config: AppConfig) => {
const taskRouter = new Hono();
const middlewareManager = MiddlewareManager.getInstance();
taskRouter.use(middlewareManager.basicAuth);
taskRouter.post("/sync-calendars", ...syncCalendars);
taskRouter.post("/sync-tablo-names", ...syncTabloNames(config));
taskRouter.post("/sync-stripe-subscriptions", ...syncStripeSubscriptions);
return taskRouter;
};

View file

@ -0,0 +1,273 @@
import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
import type { Tables } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
const signUpToStream = factory.createHandlers(async (c) => {
const { id } = c.get("user");
const supabase = c.get("supabase");
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const user = data as Tables<"profiles">;
const streamServerClient = c.get("streamServerClient");
await streamServerClient.upsertUser({
id,
name: user.name ?? "",
language: "fr",
});
return c.json({
message: "User signed up to stream",
});
});
const getMe = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
const userData = data as Tables<"profiles">;
if (!userData) {
return c.json({ error: "User not found" }, 404);
}
if (error) {
return c.json({ error: error.message }, 500);
}
const user_id = data.id;
const token = streamServerClient.createToken(user_id);
return c.json({
...userData,
streamToken: token,
});
});
const markTemporary = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
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);
}
const transporter = c.get("transporter");
try {
if (profile?.email && transporter) {
const mailOptions = {
from: "Xtablo <noreply@xtablo.com>",
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; 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.com"}"
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);
}
} catch (error) {
console.error("Failed to send welcome email:", error);
}
return c.json({
message: "User marked as temporary",
});
});
// userRouter.put("/profile", async (c) => {
// const user = c.get("user");
// const supabase = c.get("supabase");
// const body = await c.req.json();
// const { first_name, last_name } = body;
// // Deprecated: name field is deprecated, use first_name and last_name instead
// // Combine first_name and last_name into a single name field
// const name = [first_name, last_name].filter(Boolean).join(" ");
// const updateData =
// first_name && last_name
// ? {
// name,
// first_name,
// last_name,
// }
// : {};
// const { data: profile, error } = await supabase
// .from("profiles")
// .update(updateData)
// .eq("id", user.id)
// .select()
// .single();
// if (error) {
// return c.json({ error: error.message }, 500);
// }
// return c.json({
// message: "Profile updated successfully",
// profile,
// });
// });
const uploadAvatar = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
const body = await c.req.json();
const { content, contentType = "image/jpeg" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
const randomString = Math.random().toString(36).substring(2, 15);
const base64Content = Buffer.from(content, "base64");
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
try {
await s3Client.send(
new PutObjectCommand({
Bucket: "web-assets",
Key: key,
Body: base64Content,
ContentType: contentType,
ContentEncoding: "base64",
})
);
} catch (error) {
console.error("Failed to upload avatar:", error);
return c.json({ error: "Failed to upload avatar" }, 500);
}
const avatarUrl = `https://assets.xtablo.com/${key}`;
const { data, error } = await supabase
.from("profiles")
.update({ avatar_url: avatarUrl })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar updated successfully",
profile: data,
});
});
const deleteAvatar = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
try {
const listedObjects = await s3Client.send(
new ListObjectsV2Command({
Bucket: "web-assets",
Prefix: `${user.id}/`,
})
);
if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404);
await s3Client.send(
new DeleteObjectsCommand({
Bucket: "web-assets",
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
})
);
} catch (error) {
console.error("Failed to delete avatar:", error);
return c.json({ error: "Failed to delete avatar" }, 500);
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar deleted successfully",
});
});
export const getUserRouter = () => {
const userRouter = new Hono();
userRouter.post("/sign-up-to-stream", ...signUpToStream);
userRouter.get("/me", ...getMe);
userRouter.post("/mark-temporary", ...markTemporary);
userRouter.post("/profile/avatar", ...uploadAvatar);
userRouter.delete("/profile/avatar", ...deleteAvatar);
return userRouter;
};

View file

@ -0,0 +1,44 @@
import type { S3Client } from "@aws-sdk/client-s3";
import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import type Stripe from "stripe";
/**
* Base environment variables available across all routes
*/
export type BaseEnv = {
Variables: {
supabase: SupabaseClient;
streamServerClient: StreamChat;
s3_client: S3Client;
transporter: Transporter;
stripe: Stripe;
stripeSync: StripeSync;
};
};
/**
* Environment with authenticated user
*/
export type AuthEnv = BaseEnv & {
Variables: BaseEnv["Variables"] & {
user: User;
};
};
/**
* Environment with optional authentication (may be null)
*/
export type MaybeAuthEnv = BaseEnv & {
Variables: BaseEnv["Variables"] & {
user: User | null;
};
};
/**
* Type helper to extract the app type from a Hono instance
*/
export type ExtractEnv<T> = T extends Hono<infer E> ? E : never;

View file

@ -11,5 +11,5 @@
"verbatimModuleSyntax": true,
"types": ["node"]
},
"exclude": ["node_modules", "src/__tests__", "dist"]
"exclude": ["node_modules", "dist"]
}

25
apps/api/turbo.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"inputs": [
"src/**",
"tsconfig.json",
"package.json"
],
"outputs": ["dist/**"],
"outputLogs": "new-only"
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"inputs": ["src/**", "**/*.test.ts", "package.json"],
"outputs": [],
"outputLogs": "new-only"
}
}
}

View file

@ -1,8 +1,7 @@
import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
import { useBookSlot } from "@xtablo/shared/hooks/book";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
import { EventInsertInTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
@ -217,7 +216,7 @@ export function EmbeddedBookingPage() {
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
const { mutateAsync: bookSlot } = useBookSlot(api, session?.access_token, () => {
handleCloseModal();
});
@ -399,7 +398,7 @@ export function EmbeddedBookingPage() {
const handleSubmitIfNotLoggedIn = async () => {
if (validateForm()) {
const { session: sessionFromSignUp } = await signUpWithoutPassword({
await signUpWithoutPassword({
email: formData.email,
name: formData.name,
});
@ -408,21 +407,19 @@ export function EmbeddedBookingPage() {
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
await bookSlot({
event_type_standard_name: eventTypeStandardName || "",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
event_details: {
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: sessionFromSignUp?.access_token || "",
start_time: startTime,
end_time: endTime,
},
user_details: {
name: formData.name,
email: formData.email,
},
});
handleCloseModal();
}
};
@ -432,21 +429,19 @@ export function EmbeddedBookingPage() {
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
await bookSlot({
event_type_standard_name: eventTypeStandardName || "",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
event_details: {
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: session?.access_token || "",
start_time: startTime,
end_time: endTime,
},
user_details: {
name: user.name || "",
email: user.email || "",
},
});
handleCloseModal();
}
};

View file

@ -1,8 +1,7 @@
import { useCreateTabloWithOwner } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpWithoutPassword } from "@xtablo/shared/hooks/auth";
import { useBookSlot } from "@xtablo/shared/hooks/book";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
import { EventInsertInTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
@ -128,7 +127,7 @@ export function FloatingBookingWidget() {
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
const { mutateAsync: bookSlot } = useBookSlot(api, session?.access_token, () => {
handleCloseModal();
if (view === "modal") {
// Send message to parent to close the modal
@ -319,7 +318,7 @@ export function FloatingBookingWidget() {
const handleSubmitIfNotLoggedIn = async () => {
if (validateForm()) {
const { session: sessionFromSignUp } = await signUpWithoutPassword({
await signUpWithoutPassword({
email: formData.email,
name: formData.name,
});
@ -328,22 +327,19 @@ export function FloatingBookingWidget() {
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
await bookSlot({
event_type_standard_name: eventTypeStandardName || "",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
event_details: {
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: sessionFromSignUp?.access_token || "",
start_time: startTime,
end_time: endTime,
},
user_details: {
name: formData.name,
email: formData.email,
},
});
handleCloseModal();
setIsWidgetOpen(false);
}
};
@ -353,22 +349,19 @@ export function FloatingBookingWidget() {
const duration = eventType?.duration || 60; // duration in minutes
const endTime = calculateEndTime(startTime, duration);
await createTabloWithOwner({
name: eventType?.name || "",
status: "todo",
await bookSlot({
event_type_standard_name: eventTypeStandardName || "",
owner_short_id: shortUserId || "",
event: {
description: eventType?.description || "",
end_time: endTime || "",
event_details: {
start_date: selectedSlot?.slot.date || "",
start_time: selectedSlot?.slot.time || "",
title: eventType?.name || "",
} as EventInsertInTablo,
access_token: session?.access_token || "",
start_time: startTime,
end_time: endTime,
},
user_details: {
name: user.name || "",
email: user.email || "",
},
});
handleCloseModal();
setIsWidgetOpen(false);
}
};

View file

@ -1,69 +1,2 @@
import { useMutation } from "@tanstack/react-query";
import { invalidatePublicSlots, queryClient, toast, useSession } from "@xtablo/shared";
import { api } from "../lib/api";
import { useNavigate } from "react-router-dom";
type BookSlotPayload = {
event_type_standard_name: string;
owner_short_id: string;
event_details: {
start_date: string;
start_time: string;
end_time: string;
};
user_details: {
name: string;
email: string;
};
};
// Book a slot with an event type owner
export const useBookSlot = () => {
const navigate = useNavigate();
const { session } = useSession();
return useMutation<
{ tablo_id: string; hasCreatedAccount: boolean; email: string },
unknown,
BookSlotPayload
>({
mutationFn: async (payload: BookSlotPayload) => {
const { data } = await api.post("/api/v1/book/slot", payload, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
timeout: 10000,
});
return data;
},
onSuccess: ({ tablo_id, hasCreatedAccount, email }) => {
toast.add({
title: "Réservation confirmée avec succès",
description: hasCreatedAccount
? "Vous avez reçu un email de confirmation et votre compte a été créé automatiquement. Un mot de passe vous a été envoyé par email."
: "Vous recevrez un email de confirmation dans quelques instants",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["tablos"] });
invalidatePublicSlots();
if (hasCreatedAccount) {
navigate(`/login?email=${email}`, { replace: true });
} else {
navigate(`/tablos/${tablo_id}`, { replace: true });
}
// navigate(0);
},
onError: (error) => {
console.error(error);
toast.add(
{
title: "Échec de la réservation",
description: "Veuillez réessayer",
type: "error",
},
{
timeout: 5000,
}
);
},
});
};
// Re-export from shared package for backward compatibility
export { useBookSlot } from "@xtablo/shared";

View file

@ -1,5 +1,4 @@
import { CustomModal } from "@ui/components/CustomModal";
import { useBookSlot } from "../hooks/book";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
import { TimeSlot, usePublicSlots } from "@xtablo/shared/hooks/public";
@ -28,6 +27,7 @@ import {
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useBookSlot } from "../hooks/book";
import { api } from "../lib/api";
export function PublicBookingPage() {
@ -48,7 +48,10 @@ export function PublicBookingPage() {
event_type_standard_name || ""
);
const { mutateAsync: bookSlot, isPending: isBookingSlot } = useBookSlot();
const { mutateAsync: bookSlot, isPending: isBookingSlot } = useBookSlot(
api,
session?.access_token
);
const isPending = isBookingSlot;

File diff suppressed because one or more lines are too long

View file

@ -9,12 +9,14 @@
"apps/external/src/**/*",
"apps/external/worker/**/*",
"apps/external/*.{ts,tsx,js,jsx,json}",
"apps/api/src/**/*",
"apps/api/*.{ts,tsx,js,jsx,json}",
"packages/ui/src/**/*",
"packages/ui/*.{ts,tsx,js,jsx,json}",
"packages/shared/src/**/*",
"packages/shared/*.{ts,tsx,js,jsx,json}",
"api/src/**/*",
"api/*.{ts,tsx,js,jsx,json}",
"packages/shared-types/src/**/*",
"packages/shared-types/*.{ts,tsx,js,jsx,json}",
"xtablo-expo/app/**/*",
"xtablo-expo/components/**/*",
"xtablo-expo/hooks/**/*",
@ -292,12 +294,14 @@
"apps/external/src/**/*.{ts,tsx}",
"apps/external/worker/**/*.{ts,tsx}",
"apps/external/*.{ts,tsx}",
"apps/api/src/**/*.{ts,tsx}",
"apps/api/*.{ts,tsx}",
"packages/ui/src/**/*.{ts,tsx}",
"packages/ui/*.{ts,tsx}",
"packages/shared/src/**/*.{ts,tsx}",
"packages/shared/*.{ts,tsx}",
"api/src/**/*.{ts,tsx}",
"api/*.{ts,tsx}",
"packages/shared-types/src/**/*.{ts,tsx}",
"packages/shared-types/*.{ts,tsx}",
"xtablo-expo/**/*.{ts,tsx}"
],
"linter": {
@ -343,7 +347,7 @@
}
},
{
"includes": ["api/src/**/*.{js,ts}"],
"includes": ["apps/api/src/**/*.{js,ts}"],
"linter": {
"rules": {
"style": {

View file

@ -15,6 +15,7 @@
"dev": "turbo dev",
"dev:main": "turbo dev --filter=@xtablo/main",
"dev:external": "turbo dev --filter=@xtablo/external",
"dev:api": "turbo dev --filter=@xtablo/api",
"deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main",
"deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main",
"deploy:external": "turbo deploy --filter=@xtablo/external",
@ -24,6 +25,7 @@
"typecheck": "turbo typecheck",
"test": "turbo test",
"test:watch": "turbo test:watch",
"test:api": "turbo test --filter=@xtablo/api",
"clean": "turbo clean && rm -rf node_modules"
},
"devDependencies": {

View file

@ -0,0 +1,215 @@
# @xtablo/shared-types
Shared TypeScript type definitions for the Xtablo monorepo. This package provides a centralized location for all type definitions used across all applications, including database types, domain types, and utility types.
> **Note**: This package has **zero runtime dependencies** and is purely for TypeScript types. It can be safely imported by any app in the monorepo (API, frontend, mobile) without bringing in unnecessary dependencies.
## Features
- **Zero dependencies**: Pure TypeScript types with no runtime dependencies
- **Supabase integration**: Auto-generated database types from Supabase schema
- **Domain types**: Strongly-typed definitions for Events, Tablos, Stripe, Kanban, etc.
- **Utility types**: Helper types for working with database types
- **Tree-shakeable**: Import only what you need
## Installation
This package is part of the Xtablo monorepo and is available to all apps via workspace protocol:
```json
{
"dependencies": {
"@xtablo/shared-types": "workspace:*"
}
}
```
## Usage
### Import from main export
```typescript
import type { Event, Tablo, UserSubscriptionStatus } from "@xtablo/shared-types";
```
### Import from specific modules
```typescript
import type { Event } from "@xtablo/shared-types/events";
import type { Tablo } from "@xtablo/shared-types/tablos";
import type { StripePrice } from "@xtablo/shared-types/stripe";
import type { KanbanTask } from "@xtablo/shared-types/kanban";
import type { RemoveNull } from "@xtablo/shared-types/utils";
```
## Available Types
### Database Types
Generated from Supabase schema:
```typescript
import type { Database, Json } from "@xtablo/shared-types";
```
### Utility Types
Helper types for working with database types:
```typescript
import type {
Tables, // Extract table row types
TablesInsert, // Extract table insert types
TablesUpdate, // Extract table update types
RemoveNull, // Remove null from a type
RemoveNullFromObject, // Remove null from object properties
} from "@xtablo/shared-types";
// Example usage:
type EventRow = Tables<"events">;
type EventInsert = TablesInsert<"events">;
type EventUpdate = TablesUpdate<"events">;
```
### Event Types
```typescript
import type {
Event, // Event with non-null fields
EventInsert, // Type for inserting events
EventUpdate, // Type for updating events
EventInsertInTablo, // Event insert without tablo_id
EventAndTablo, // Joined event and tablo view
} from "@xtablo/shared-types";
```
### Tablo Types
```typescript
import type {
Tablo, // Tablo table type
TabloInsert, // Type for inserting tablos
TabloUpdate, // Type for updating tablos
UserTablo, // User's tablo view
CreateTablo, // Type for creating a tablo with events
} from "@xtablo/shared-types";
```
### Stripe Types
```typescript
import type {
StripeSubscription,
StripeProduct,
StripePrice,
SubscriptionStatus,
BillingInterval,
UserSubscriptionStatus,
PriceWithProduct,
SubscriptionWithDetails,
} from "@xtablo/shared-types";
```
### Kanban Types
```typescript
import type {
KanbanTask,
KanbanBoard,
KanbanColumn,
TaskStatus,
Priority,
TaskType,
KanbanTaskInsert,
KanbanTaskUpdate,
} from "@xtablo/shared-types";
```
## Type Generation
Database types are generated from Supabase and should be updated when the schema changes:
```bash
# From the root of the monorepo
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > packages/shared-types/src/database.types.ts
```
## Structure
```
packages/shared-types/
├── src/
│ ├── database.types.ts # Supabase-generated types
│ ├── events.types.ts # Event-related types
│ ├── tablos.types.ts # Tablo-related types
│ ├── stripe.types.ts # Stripe integration types
│ ├── kanban.types.ts # Kanban board types
│ ├── utils.ts # Utility types
│ └── index.ts # Main export
├── package.json
├── tsconfig.json
└── README.md
```
## Best Practices
1. **Import specific types**: Use granular imports when possible to improve tree-shaking
2. **Type safety**: Leverage utility types like `RemoveNull` to enforce non-null values
3. **Reusability**: Define types here instead of duplicating them across apps
4. **Documentation**: Add JSDoc comments for complex types
## Scripts
```bash
# Type checking
pnpm typecheck
# Linting
pnpm lint
pnpm lint:fix
# Formatting
pnpm format
```
## Integration with Apps
### API (Node.js/Hono)
```typescript
import type { Event, Tablo, Database } from "@xtablo/shared-types";
export function getEvents(): Promise<Event[]> {
// Implementation
}
```
### Frontend (React)
```typescript
import type { Event, UserTablo } from "@xtablo/shared-types";
function EventList({ events }: { events: Event[] }) {
// Component implementation
}
```
### Expo (React Native)
```typescript
import type { Event } from "@xtablo/shared-types";
const EventScreen = ({ event }: { event: Event }) => {
// Screen implementation
};
```
## Contributing
When adding new types:
1. Place them in the appropriate module file (e.g., `events.types.ts`)
2. Export them from `index.ts`
3. Add documentation in this README
4. Run type checking and linting before committing

View file

@ -0,0 +1,176 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"files": {
"ignoreUnknown": true,
"maxSize": 10485760,
"includes": ["src/**/*"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"nursery": {},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": []
},
"json": {
"parser": { "allowComments": true, "allowTrailingCommas": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"trailingCommas": "none"
}
},
"overrides": [
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
{
"linter": {
"rules": {
"style": { "useNodejsImportProtocol": "off" },
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/**/*.ts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
]
}

View file

@ -0,0 +1,29 @@
{
"name": "@xtablo/shared-types",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./database": "./src/database.types.ts",
"./events": "./src/events.types.ts",
"./tablos": "./src/tablos.types.ts",
"./stripe": "./src/stripe.types.ts",
"./kanban": "./src/kanban.types.ts",
"./utils": "./src/utils.ts"
},
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
"clean": "rm -rf node_modules/.cache"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"typescript": "^5.7.0"
}
}

View file

@ -0,0 +1,20 @@
import type { Database } from "./database.types.js";
import type { RemoveNullFromObject, Tables, TablesInsert, TablesUpdate } from "./utils.js";
export type Event = RemoveNullFromObject<Tables<"events">, "created_at" | "end_time">;
export type EventInsert = TablesInsert<"events">;
export type EventUpdate = TablesUpdate<"events">;
export type EventInsertInTablo = Omit<EventInsert, "tablo_id">;
export type EventAndTablo = RemoveNullFromObject<
Database["public"]["Views"]["events_and_tablos"]["Row"],
| "event_id"
| "tablo_id"
| "tablo_name"
| "tablo_color"
| "tablo_status"
| "start_time"
| "end_time"
| "title"
| "start_date"
>;

View file

@ -0,0 +1,66 @@
// ============================================================================
// Database Types
// ============================================================================
export type { Database, Json } from "./database.types.js";
// ============================================================================
// Event Types
// ============================================================================
export type {
Event,
EventAndTablo,
EventInsert,
EventInsertInTablo,
EventUpdate,
} from "./events.types.js";
// ============================================================================
// Kanban Types
// ============================================================================
export type {
DragItem,
DropResult,
KanbanBoard,
KanbanColumn,
KanbanColumnUpdate,
KanbanFilters,
KanbanSort,
KanbanTask,
KanbanTaskInsert,
KanbanTaskUpdate,
KanbanUser,
Priority,
TaskAttachment,
TaskComment,
TaskStatus,
TaskType,
} from "./kanban.types.js";
// ============================================================================
// Stripe Types
// ============================================================================
export type {
BillingInterval,
PriceWithProduct,
StripeApiPrice,
StripeApiProduct,
StripeApiSubscription,
StripeCustomer,
StripePrice,
StripeProduct,
StripeSubscription,
SubscriptionStatus,
SubscriptionWithDetails,
UserSubscriptionStatus,
} from "./stripe.types.js";
// ============================================================================
// Tablo Types
// ============================================================================
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
// ============================================================================
// Utility Types
// ============================================================================
export type {
RemoveNull,
RemoveNullFromObject,
Tables,
TablesInsert,
TablesUpdate,
} from "./utils.js";

View file

@ -0,0 +1,148 @@
export type Priority = "lowest" | "low" | "medium" | "high" | "highest";
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done";
export type TaskType = "story" | "bug" | "task" | "epic" | "subtask";
export interface KanbanTask {
id: string;
title: string;
description?: string;
status: TaskStatus;
priority: Priority;
type: TaskType;
assignee_id?: string;
assignee_name?: string;
assignee_avatar?: string;
reporter_id: string;
reporter_name?: string;
tablo_id: string;
tablo_name?: string;
story_points?: number;
labels?: string[];
due_date?: string;
created_at: string;
updated_at: string;
position: number;
parent_task_id?: string; // For subtasks
comments_count?: number;
attachments_count?: number;
}
export interface KanbanColumn {
id: string;
title: string;
status: TaskStatus;
position: number;
color?: string;
task_limit?: number;
tasks: KanbanTask[];
}
export interface KanbanBoard {
id: string;
name: string;
description?: string;
tablo_id: string;
columns: KanbanColumn[];
created_at: string;
updated_at: string;
}
export interface TaskComment {
id: string;
task_id: string;
user_id: string;
user_name: string;
user_avatar?: string;
content: string;
created_at: string;
updated_at: string;
}
export interface TaskAttachment {
id: string;
task_id: string;
filename: string;
file_url: string;
file_size: number;
file_type: string;
uploaded_by: string;
uploaded_at: string;
}
// Insert types for creating new items
export interface KanbanTaskInsert {
title: string;
description?: string;
priority: Priority;
type: TaskType;
assignee_id?: string;
tablo_id: string;
story_points?: number;
labels?: string[];
due_date?: string;
position: number;
parent_task_id?: string;
}
export interface KanbanTaskUpdate {
title?: string;
description?: string;
status?: TaskStatus;
priority?: Priority;
type?: TaskType;
assignee_id?: string;
story_points?: number;
labels?: string[];
due_date?: string;
position?: number;
parent_task_id?: string;
}
export interface KanbanColumnUpdate {
title?: string;
position?: number;
color?: string;
task_limit?: number;
}
// UI-specific types for drag and drop
export interface DragItem {
id: string;
type: "task" | "column";
sourceColumnId: string;
sourceIndex: number;
}
export interface DropResult {
draggedId: string;
source: {
columnId: string;
index: number;
};
destination: {
columnId: string;
index: number;
};
}
// Filter and sort types
export interface KanbanFilters {
assignee?: string;
priority?: Priority[];
type?: TaskType[];
labels?: string[];
search?: string;
}
export interface KanbanSort {
field: "priority" | "created_at" | "updated_at" | "due_date" | "story_points";
direction: "asc" | "desc";
}
// User type for assignee selection
export interface KanbanUser {
id: string;
name: string;
email: string;
avatar_url?: string;
}

View file

@ -0,0 +1,120 @@
// ============================================================================
// Stripe Types for Supabase Integration
// ============================================================================
// Types matching what the RPC functions return (Stripe API objects)
export interface StripeApiSubscription {
id: string;
customer: string;
user_id: string;
status: string;
items: unknown;
cancel_at_period_end: boolean;
current_period_start: number;
current_period_end: number;
created: number;
canceled_at: number | null;
trial_start: unknown;
trial_end: unknown;
metadata: Record<string, unknown>;
}
export interface StripeApiProduct {
id: string;
name: string;
description: string | null;
active: boolean;
created: number;
metadata: Record<string, unknown>;
}
export interface StripeApiPrice {
id: string;
product: string;
active: boolean;
currency: string;
unit_amount: number;
recurring: unknown;
created: number;
metadata: Record<string, unknown>;
}
export interface StripeCustomer {
id: string;
user_id: string;
stripe_customer_id: string;
email: string | null;
created_at: string;
updated_at: string;
}
export interface StripeSubscription {
id: string;
user_id: string;
stripe_customer_id: string;
status: SubscriptionStatus;
price_id: string | null;
quantity: number | null;
cancel_at_period_end: boolean;
current_period_start: string | null;
current_period_end: string | null;
created_at: string;
updated_at: string;
canceled_at: string | null;
trial_end: string | null;
trial_start: string | null;
}
export interface StripeProduct {
id: string;
active: boolean;
name: string;
description: string | null;
image: string | null;
metadata: Record<string, string | number | boolean> | null;
created_at: string;
updated_at: string;
}
export interface StripePrice {
id: string;
product_id: string;
active: boolean;
currency: string;
unit_amount: number;
interval: BillingInterval | null;
interval_count: number | null;
trial_period_days: number | null;
metadata: Record<string, string | number | boolean> | null;
created_at: string;
updated_at: string;
}
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "unpaid"
| "incomplete"
| "incomplete_expired";
export type BillingInterval = "day" | "week" | "month" | "year";
export interface UserSubscriptionStatus {
subscription_id: string;
status: SubscriptionStatus;
current_period_end: string;
cancel_at_period_end: boolean;
price_id: string;
product_name: string;
}
export interface PriceWithProduct extends StripePrice {
product: StripeProduct;
}
export interface SubscriptionWithDetails extends StripeSubscription {
price: StripePrice;
product: StripeProduct;
}

View file

@ -0,0 +1,25 @@
import type { Database } from "./database.types.js";
import type { EventInsertInTablo } from "./events.types.js";
import type { RemoveNullFromObject } from "./utils.js";
export type UserTablo = RemoveNullFromObject<
Database["public"]["Views"]["user_tablos"]["Row"],
| "id"
| "access_level"
| "is_admin"
| "created_at"
| "deleted_at"
| "position"
| "user_id"
| "name"
| "status"
>;
export type Tablo = Database["public"]["Tables"]["tablos"];
export type TabloInsert = Tablo["Insert"];
export type TabloUpdate = Tablo["Update"];
export type CreateTablo = Pick<TabloInsert, "name" | "color" | "image" | "status"> & {
events?: EventInsertInTablo[];
};

View file

@ -0,0 +1,31 @@
import type { Database } from "./database.types.js";
/**
* Utility type to remove null from a type
*/
export type RemoveNull<T> = T extends null ? never : T;
/**
* Utility type to remove null from all properties of an object type
*/
export type RemoveNullFromObject<T, K extends keyof T = keyof T> = {
[L in keyof T]: L extends K ? RemoveNull<T[L]> : T[L];
};
/**
* Helper type to extract table types from Database
*/
export type Tables<TableName extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][TableName]["Row"];
/**
* Helper type to extract insert types from Database
*/
export type TablesInsert<TableName extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][TableName]["Insert"];
/**
* Helper type to extract update types from Database
*/
export type TablesUpdate<TableName extends keyof Database["public"]["Tables"]> =
Database["public"]["Tables"][TableName]["Update"];

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowJs": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,5 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"]
}

View file

@ -24,6 +24,7 @@
"@datadog/browser-rum-react": "^6.13.0",
"@supabase/supabase-js": "^2.49.3",
"@tanstack/react-query": "^5.69.0",
"@xtablo/shared-types": "workspace:*",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View file

@ -0,0 +1,89 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosInstance } from "axios";
import { useNavigate } from "react-router-dom";
import { queryClient } from "../lib/api";
import { toast } from "../lib/toast";
import { invalidatePublicSlots } from "./public";
type BookSlotPayload = {
event_type_standard_name: string;
owner_short_id: string;
event_details: {
start_date: string;
start_time: string;
end_time: string;
};
user_details: {
name: string;
email: string;
};
};
type BookSlotResponse = {
tablo_id: string;
hasCreatedAccount: boolean;
email: string;
};
// Book a slot with an event type owner
export const useBookSlot = (
api: AxiosInstance,
accessToken?: string,
onSuccess?: (data: BookSlotResponse) => void
) => {
const navigate = useNavigate();
return useMutation<BookSlotResponse, unknown, BookSlotPayload>({
mutationFn: async (payload: BookSlotPayload) => {
const headers: Record<string, string> = {};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const { data } = await api.post("/api/v1/book/slot", payload, {
headers,
timeout: 10000,
});
return data;
},
onSuccess: (data) => {
const { tablo_id, hasCreatedAccount, email } = data;
toast.add({
title: "Réservation confirmée avec succès",
description: hasCreatedAccount
? "Vous avez reçu un email de confirmation et votre compte a été créé automatiquement. Un mot de passe vous a été envoyé par email."
: "Vous recevrez un email de confirmation dans quelques instants",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["tablos"] });
invalidatePublicSlots();
// Call custom onSuccess callback if provided
if (onSuccess) {
onSuccess(data);
} else {
// Default navigation behavior
if (hasCreatedAccount) {
navigate(`/login?email=${email}`, { replace: true });
} else {
navigate(`/tablos/${tablo_id}`, { replace: true });
}
}
},
onError: (error) => {
console.error(error);
toast.add(
{
title: "Échec de la réservation",
description: "Veuillez réessayer",
type: "error",
},
{
timeout: 5000,
}
);
},
});
};

View file

@ -3,6 +3,7 @@ export * from "./contexts/SessionContext";
export * from "./contexts/ThemeContext";
// Export hooks
export * from "./hooks/auth";
export * from "./hooks/book";
export * from "./hooks/public";
export * from "./hooks/useClickOutside";
export * from "./lib/api";

View file

@ -1,854 +1,2 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.4";
};
public: {
Tables: {
availabilities: {
Row: {
availability_data: Json;
created_at: string;
exceptions: Json | null;
id: number;
updated_at: string;
user_id: string;
};
Insert: {
availability_data?: Json;
created_at?: string;
exceptions?: Json | null;
id?: number;
updated_at?: string;
user_id: string;
};
Update: {
availability_data?: Json;
created_at?: string;
exceptions?: Json | null;
id?: number;
updated_at?: string;
user_id?: string;
};
Relationships: [];
};
calendar_subscriptions: {
Row: {
created_at: string | null;
id: string;
tablo_id: string;
token: string;
};
Insert: {
created_at?: string | null;
id?: string;
tablo_id: string;
token: string;
};
Update: {
created_at?: string | null;
id?: string;
tablo_id?: string;
token?: string;
};
Relationships: [
{
foreignKeyName: "calendar_subscriptions_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: true;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "calendar_subscriptions_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: true;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "calendar_subscriptions_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: true;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
devis: {
Row: {
client_email: string;
created_at: string;
date: string;
due_date: string;
id: string;
items: Json;
notes: string | null;
number: string;
status: Database["public"]["Enums"]["devis_status"];
subtotal: number;
tax: number;
terms: string | null;
total: number;
updated_at: string;
user_id: string;
};
Insert: {
client_email: string;
created_at?: string;
date: string;
due_date: string;
id?: string;
items?: Json;
notes?: string | null;
number: string;
status?: Database["public"]["Enums"]["devis_status"];
subtotal: number;
tax: number;
terms?: string | null;
total: number;
updated_at?: string;
user_id: string;
};
Update: {
client_email?: string;
created_at?: string;
date?: string;
due_date?: string;
id?: string;
items?: Json;
notes?: string | null;
number?: string;
status?: Database["public"]["Enums"]["devis_status"];
subtotal?: number;
tax?: number;
terms?: string | null;
total?: number;
updated_at?: string;
user_id?: string;
};
Relationships: [];
};
event_types: {
Row: {
config: Json;
created_at: string | null;
deleted_at: string | null;
id: string;
is_active: boolean;
standard_name: string | null;
updated_at: string | null;
user_id: string;
};
Insert: {
config?: Json;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
is_active?: boolean;
standard_name?: string | null;
updated_at?: string | null;
user_id: string;
};
Update: {
config?: Json;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
is_active?: boolean;
standard_name?: string | null;
updated_at?: string | null;
user_id?: string;
};
Relationships: [];
};
events: {
Row: {
created_at: string | null;
created_by: string;
deleted_at: string | null;
description: string | null;
end_time: string | null;
id: string;
start_date: string;
start_time: string;
tablo_id: string;
title: string;
};
Insert: {
created_at?: string | null;
created_by: string;
deleted_at?: string | null;
description?: string | null;
end_time?: string | null;
id?: string;
start_date: string;
start_time: string;
tablo_id: string;
title: string;
};
Update: {
created_at?: string | null;
created_by?: string;
deleted_at?: string | null;
description?: string | null;
end_time?: string | null;
id?: string;
start_date?: string;
start_time?: string;
tablo_id?: string;
title?: string;
};
Relationships: [
{
foreignKeyName: "fk_events_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "fk_events_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "fk_events_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
feedbacks: {
Row: {
created_at: string | null;
fd_type: string;
id: number;
message: string;
user_id: string;
};
Insert: {
created_at?: string | null;
fd_type: string;
id?: number;
message: string;
user_id: string;
};
Update: {
created_at?: string | null;
fd_type?: string;
id?: number;
message?: string;
user_id?: string;
};
Relationships: [];
};
note_access: {
Row: {
created_at: string | null;
id: number;
is_active: boolean | null;
note_id: string;
tablo_id: string | null;
updated_at: string | null;
user_id: string;
};
Insert: {
created_at?: string | null;
id?: number;
is_active?: boolean | null;
note_id: string;
tablo_id?: string | null;
updated_at?: string | null;
user_id: string;
};
Update: {
created_at?: string | null;
id?: number;
is_active?: boolean | null;
note_id?: string;
tablo_id?: string | null;
updated_at?: string | null;
user_id?: string;
};
Relationships: [
{
foreignKeyName: "fk_note_access_note_id";
columns: ["note_id"];
isOneToOne: false;
referencedRelation: "notes";
referencedColumns: ["id"];
},
{
foreignKeyName: "fk_note_access_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "fk_note_access_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "fk_note_access_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
notes: {
Row: {
content: string | null;
created_at: string | null;
deleted_at: string | null;
id: string;
title: string;
updated_at: string | null;
user_id: string;
};
Insert: {
content?: string | null;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
title: string;
updated_at?: string | null;
user_id: string;
};
Update: {
content?: string | null;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
title?: string;
updated_at?: string | null;
user_id?: string;
};
Relationships: [];
};
profiles: {
Row: {
avatar_url: string | null;
email: string | null;
first_name: string | null;
id: string;
is_temporary: boolean;
last_name: string | null;
last_signed_in: string | null;
name: string | null;
plan: Database["public"]["Enums"]["subscription_plan"] | null;
short_user_id: string;
};
Insert: {
avatar_url?: string | null;
email?: string | null;
first_name?: string | null;
id: string;
is_temporary?: boolean;
last_name?: string | null;
last_signed_in?: string | null;
name?: string | null;
plan?: Database["public"]["Enums"]["subscription_plan"] | null;
short_user_id: string;
};
Update: {
avatar_url?: string | null;
email?: string | null;
first_name?: string | null;
id?: string;
is_temporary?: boolean;
last_name?: string | null;
last_signed_in?: string | null;
name?: string | null;
plan?: Database["public"]["Enums"]["subscription_plan"] | null;
short_user_id?: string;
};
Relationships: [];
};
shared_notes: {
Row: {
created_at: string | null;
is_public: boolean | null;
note_id: string;
updated_at: string | null;
user_id: string;
};
Insert: {
created_at?: string | null;
is_public?: boolean | null;
note_id: string;
updated_at?: string | null;
user_id: string;
};
Update: {
created_at?: string | null;
is_public?: boolean | null;
note_id?: string;
updated_at?: string | null;
user_id?: string;
};
Relationships: [
{
foreignKeyName: "fk_shared_notes_note_id";
columns: ["note_id"];
isOneToOne: true;
referencedRelation: "notes";
referencedColumns: ["id"];
},
];
};
tablo_access: {
Row: {
created_at: string | null;
granted_by: string;
id: number;
is_active: boolean | null;
is_admin: boolean | null;
tablo_id: string;
user_id: string;
};
Insert: {
created_at?: string | null;
granted_by: string;
id?: number;
is_active?: boolean | null;
is_admin?: boolean | null;
tablo_id: string;
user_id: string;
};
Update: {
created_at?: string | null;
granted_by?: string;
id?: number;
is_active?: boolean | null;
is_admin?: boolean | null;
tablo_id?: string;
user_id?: string;
};
Relationships: [
{
foreignKeyName: "fk_tablo_access_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "fk_tablo_access_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "fk_tablo_access_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "fk_tablo_access_user_id_from_profiles";
columns: ["user_id"];
isOneToOne: false;
referencedRelation: "profiles";
referencedColumns: ["id"];
},
];
};
tablo_invites: {
Row: {
created_at: string;
id: number;
invite_token: string;
invited_by: string;
invited_email: string;
is_pending: boolean;
tablo_id: string;
};
Insert: {
created_at?: string;
id?: number;
invite_token: string;
invited_by: string;
invited_email: string;
is_pending?: boolean;
tablo_id: string;
};
Update: {
created_at?: string;
id?: number;
invite_token?: string;
invited_by?: string;
invited_email?: string;
is_pending?: boolean;
tablo_id?: string;
};
Relationships: [
{
foreignKeyName: "fk_tablo_invitations_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "fk_tablo_invitations_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "fk_tablo_invitations_tablo_id";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
tablos: {
Row: {
color: string | null;
created_at: string | null;
deleted_at: string | null;
id: string;
image: string | null;
name: string;
owner_id: string;
position: number;
status: string;
updated_at: string | null;
};
Insert: {
color?: string | null;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
image?: string | null;
name: string;
owner_id: string;
position?: number;
status?: string;
updated_at?: string | null;
};
Update: {
color?: string | null;
created_at?: string | null;
deleted_at?: string | null;
id?: string;
image?: string | null;
name?: string;
owner_id?: string;
position?: number;
status?: string;
updated_at?: string | null;
};
Relationships: [];
};
user_introductions: {
Row: {
config: Json;
created_at: string | null;
updated_at: string | null;
user_id: string;
};
Insert: {
config?: Json;
created_at?: string | null;
updated_at?: string | null;
user_id: string;
};
Update: {
config?: Json;
created_at?: string | null;
updated_at?: string | null;
user_id?: string;
};
Relationships: [];
};
};
Views: {
events_and_tablos: {
Row: {
description: string | null;
end_time: string | null;
event_id: string | null;
start_date: string | null;
start_time: string | null;
tablo_color: string | null;
tablo_id: string | null;
tablo_name: string | null;
tablo_status: string | null;
title: string | null;
};
Relationships: [];
};
user_tablos: {
Row: {
access_level: string | null;
color: string | null;
created_at: string | null;
deleted_at: string | null;
id: string | null;
image: string | null;
is_admin: boolean | null;
name: string | null;
position: number | null;
status: string | null;
user_id: string | null;
};
Relationships: [
{
foreignKeyName: "fk_tablo_access_user_id_from_profiles";
columns: ["user_id"];
isOneToOne: false;
referencedRelation: "profiles";
referencedColumns: ["id"];
},
];
};
};
Functions: {
generate_random_string: { Args: { length?: number }; Returns: string };
get_my_active_subscription: {
Args: never;
Returns: {
billing_interval: string;
cancel_at_period_end: boolean;
currency: string;
current_period_end: string;
current_period_start: string;
first_name: string;
last_name: string;
plan: Database["public"]["Enums"]["subscription_plan"];
product_name: string;
status: string;
subscription_id: string;
unit_amount: number;
user_email: string;
user_id: string;
}[];
};
get_stripe_prices: {
Args: never;
Returns: {
active: boolean;
created: number;
currency: string;
id: string;
metadata: Json;
product: string;
recurring: Json;
unit_amount: number;
}[];
};
get_stripe_products: {
Args: never;
Returns: {
active: boolean;
created: number;
description: string;
id: string;
metadata: Json;
name: string;
}[];
};
get_user_stripe_customer: {
Args: never;
Returns: {
created: number;
email: string;
id: string;
metadata: Json;
user_id: string;
}[];
};
get_user_stripe_customer_id: {
Args: { user_uuid: string };
Returns: string;
};
get_user_stripe_subscriptions: {
Args: never;
Returns: {
cancel_at_period_end: boolean;
canceled_at: number;
created: number;
current_period_end: number;
current_period_start: number;
customer: string;
id: string;
metadata: Json;
price_id: string;
quantity: number;
status: string;
trial_end: Json;
trial_start: Json;
user_id: string;
}[];
};
get_user_subscription_status: {
Args: { user_uuid: string };
Returns: {
cancel_at_period_end: boolean;
current_period_end: number;
current_period_start: number;
plan: Database["public"]["Enums"]["subscription_plan"];
price_id: string;
product_name: string;
status: string;
subscription_id: string;
}[];
};
is_paying_user: { Args: { user_uuid: string }; Returns: boolean };
};
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
subscription_plan: "none" | "trial" | "standard";
};
CompositeTypes: {
time_range: {
start_time: string | null;
end_time: string | null;
};
};
};
};
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">;
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">];
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R;
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R;
}
? R
: never
: never;
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I;
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I;
}
? I
: never
: never;
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U;
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U;
}
? U
: never
: never;
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never;
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals;
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never;
export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"],
},
},
} as const;
// Re-export from shared types package
export type { Database, Json, Tables, TablesInsert, TablesUpdate } from "@xtablo/shared-types";

View file

@ -1,20 +1,13 @@
import type { Tables, TablesInsert, TablesUpdate } from "../types/database.types";
import type { RemoveNullFromObject } from "../types/removeNull";
export type Event = RemoveNullFromObject<Tables<"events">, "created_at" | "end_time">;
export type EventInsert = TablesInsert<"events">;
export type EventUpdate = TablesUpdate<"events">;
export type EventInsertInTablo = Omit<EventInsert, "tablo_id">;
export type EventAndTablo = RemoveNullFromObject<
Tables<"events_and_tablos">,
| "event_id"
| "tablo_id"
| "tablo_name"
| "tablo_color"
| "tablo_status"
| "start_time"
| "end_time"
| "title"
| "start_date"
>;
// Re-export from shared types package
// Keep legacy exports for backward compatibility
export type {
Event,
EventAndTablo,
EventInsert,
EventInsertInTablo,
EventUpdate,
RemoveNullFromObject,
Tables,
TablesInsert,
TablesUpdate,
} from "@xtablo/shared-types";

View file

@ -1,148 +1,19 @@
export type Priority = "lowest" | "low" | "medium" | "high" | "highest";
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done";
export type TaskType = "story" | "bug" | "task" | "epic" | "subtask";
export interface KanbanTask {
id: string;
title: string;
description?: string;
status: TaskStatus;
priority: Priority;
type: TaskType;
assignee_id?: string;
assignee_name?: string;
assignee_avatar?: string;
reporter_id: string;
reporter_name?: string;
tablo_id: string;
tablo_name?: string;
story_points?: number;
labels?: string[];
due_date?: string;
created_at: string;
updated_at: string;
position: number;
parent_task_id?: string; // For subtasks
comments_count?: number;
attachments_count?: number;
}
export interface KanbanColumn {
id: string;
title: string;
status: TaskStatus;
position: number;
color?: string;
task_limit?: number;
tasks: KanbanTask[];
}
export interface KanbanBoard {
id: string;
name: string;
description?: string;
tablo_id: string;
columns: KanbanColumn[];
created_at: string;
updated_at: string;
}
export interface TaskComment {
id: string;
task_id: string;
user_id: string;
user_name: string;
user_avatar?: string;
content: string;
created_at: string;
updated_at: string;
}
export interface TaskAttachment {
id: string;
task_id: string;
filename: string;
file_url: string;
file_size: number;
file_type: string;
uploaded_by: string;
uploaded_at: string;
}
// Insert types for creating new items
export interface KanbanTaskInsert {
title: string;
description?: string;
priority: Priority;
type: TaskType;
assignee_id?: string;
tablo_id: string;
story_points?: number;
labels?: string[];
due_date?: string;
position: number;
parent_task_id?: string;
}
export interface KanbanTaskUpdate {
title?: string;
description?: string;
status?: TaskStatus;
priority?: Priority;
type?: TaskType;
assignee_id?: string;
story_points?: number;
labels?: string[];
due_date?: string;
position?: number;
parent_task_id?: string;
}
export interface KanbanColumnUpdate {
title?: string;
position?: number;
color?: string;
task_limit?: number;
}
// UI-specific types for drag and drop
export interface DragItem {
id: string;
type: "task" | "column";
sourceColumnId: string;
sourceIndex: number;
}
export interface DropResult {
draggedId: string;
source: {
columnId: string;
index: number;
};
destination: {
columnId: string;
index: number;
};
}
// Filter and sort types
export interface KanbanFilters {
assignee?: string;
priority?: Priority[];
type?: TaskType[];
labels?: string[];
search?: string;
}
export interface KanbanSort {
field: "priority" | "created_at" | "updated_at" | "due_date" | "story_points";
direction: "asc" | "desc";
}
// User type for assignee selection
export interface KanbanUser {
id: string;
name: string;
email: string;
avatar_url?: string;
}
// Re-export from shared types package
export type {
DragItem,
DropResult,
KanbanBoard,
KanbanColumn,
KanbanColumnUpdate,
KanbanFilters,
KanbanSort,
KanbanTask,
KanbanTaskInsert,
KanbanTaskUpdate,
KanbanUser,
Priority,
TaskAttachment,
TaskComment,
TaskStatus,
TaskType,
} from "@xtablo/shared-types";

View file

@ -1,11 +1,2 @@
/**
* Utility type to remove null from a type
*/
export type RemoveNull<T> = T extends null ? never : T;
/**
* Utility type to remove null from all properties of an object type
*/
export type RemoveNullFromObject<T, K extends keyof T = keyof T> = {
[L in keyof T]: L extends K ? RemoveNull<T[L]> : T[L];
};
// Re-export from shared types package
export type { RemoveNull, RemoveNullFromObject } from "@xtablo/shared-types";

View file

@ -1,123 +1,21 @@
// ============================================================================
// Stripe Types for Supabase Integration
// ============================================================================
// Re-export types from shared types package
export type {
BillingInterval,
PriceWithProduct,
StripeApiPrice,
StripeApiProduct,
StripeApiSubscription,
StripeCustomer,
StripePrice,
StripeProduct,
StripeSubscription,
SubscriptionStatus,
SubscriptionWithDetails,
UserSubscriptionStatus,
} from "@xtablo/shared-types";
// Types matching what the RPC functions return (Stripe API objects)
export interface StripeApiSubscription {
id: string;
customer: string;
user_id: string;
status: string;
items: unknown;
cancel_at_period_end: boolean;
current_period_start: number;
current_period_end: number;
created: number;
canceled_at: number | null;
trial_start: unknown;
trial_end: unknown;
metadata: Record<string, unknown>;
}
export interface StripeApiProduct {
id: string;
name: string;
description: string | null;
active: boolean;
created: number;
metadata: Record<string, unknown>;
}
export interface StripeApiPrice {
id: string;
product: string;
active: boolean;
currency: string;
unit_amount: number;
recurring: unknown;
created: number;
metadata: Record<string, unknown>;
}
export interface StripeCustomer {
id: string;
user_id: string;
stripe_customer_id: string;
email: string | null;
created_at: string;
updated_at: string;
}
export interface StripeSubscription {
id: string;
user_id: string;
stripe_customer_id: string;
status: SubscriptionStatus;
price_id: string | null;
quantity: number | null;
cancel_at_period_end: boolean;
current_period_start: string | null;
current_period_end: string | null;
created_at: string;
updated_at: string;
canceled_at: string | null;
trial_end: string | null;
trial_start: string | null;
}
export interface StripeProduct {
id: string;
active: boolean;
name: string;
description: string | null;
image: string | null;
metadata: Record<string, string | number | boolean> | null;
created_at: string;
updated_at: string;
}
export interface StripePrice {
id: string;
product_id: string;
active: boolean;
currency: string;
unit_amount: number;
interval: BillingInterval | null;
interval_count: number | null;
trial_period_days: number | null;
metadata: Record<string, string | number | boolean> | null;
created_at: string;
updated_at: string;
}
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "unpaid"
| "incomplete"
| "incomplete_expired";
export type BillingInterval = "day" | "week" | "month" | "year";
export interface UserSubscriptionStatus {
subscription_id: string;
status: SubscriptionStatus;
current_period_end: string;
cancel_at_period_end: boolean;
price_id: string;
product_name: string;
}
export interface PriceWithProduct extends StripePrice {
product: StripeProduct;
}
export interface SubscriptionWithDetails extends StripeSubscription {
price: StripePrice;
product: StripeProduct;
}
// Import types for use in helper functions
import type { BillingInterval, SubscriptionStatus } from "@xtablo/shared-types";
// Helper type guards
export function isActiveSubscription(status: SubscriptionStatus): boolean {
@ -133,7 +31,7 @@ export function isCanceled(status: SubscriptionStatus): boolean {
}
// Format currency helper
export function formatPrice(amount: number, currency: string = "eur"): string {
export function formatPrice(amount: number, currency = "eur"): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: currency.toUpperCase(),
@ -142,7 +40,7 @@ export function formatPrice(amount: number, currency: string = "eur"): string {
}
// Format interval helper
export function formatInterval(interval: BillingInterval | null, count: number = 1): string {
export function formatInterval(interval: BillingInterval | null, count = 1): string {
if (!interval) return "";
const intervals: Record<BillingInterval, string> = {

View file

@ -1,25 +1,12 @@
import type { Database } from "../types/database.types";
import type { EventInsertInTablo } from "../types/events.types";
import type { RemoveNullFromObject } from "../types/removeNull";
export type UserTablo = RemoveNullFromObject<
Database["public"]["Views"]["user_tablos"]["Row"],
| "id"
| "access_level"
| "is_admin"
| "created_at"
| "deleted_at"
| "position"
| "user_id"
| "name"
| "status"
>;
export type Tablo = Database["public"]["Tables"]["tablos"];
export type TabloInsert = Tablo["Insert"];
export type TabloUpdate = Tablo["Update"];
export type CreateTablo = Pick<TabloInsert, "name" | "color" | "image" | "status"> & {
events?: EventInsertInTablo[];
};
// Re-export from shared types package
// Keep legacy exports for backward compatibility
export type {
CreateTablo,
Database,
EventInsertInTablo,
RemoveNullFromObject,
Tablo,
TabloInsert,
TabloUpdate,
UserTablo,
} from "@xtablo/shared-types";

View file

@ -1,6 +1,5 @@
import type { Database, EventAndTablo } from "@xtablo/shared-types";
import jsPDF from "jspdf";
import type { Database } from "../types/database.types";
import type { EventAndTablo } from "../types/events.types";
export const calculateTax = (amount: number, taxRate: number) => {
return (amount * taxRate) / 100;

File diff suppressed because it is too large Load diff