Create two packages: api and shared-types
This commit is contained in:
parent
03a25dace3
commit
92b0646176
92 changed files with 9390 additions and 11953 deletions
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "src/**/*.{test,spec}.ts",
|
||||
"require": ["tsx/cjs"],
|
||||
"timeout": 5000,
|
||||
"recursive": true,
|
||||
"reporter": "spec"
|
||||
}
|
||||
|
|
@ -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
8525
api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 été créé avec succès !</h2>
|
||||
<p>Bonjour ${ownerDataTyped.name},</p>
|
||||
<p>Un nouveau tablo "${tabloData.name}" a été créé suite à une réservation.</p>
|
||||
<p><strong>Détails de l'événement :</strong></p>
|
||||
<ul>
|
||||
<li>Titre : ${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 été confirmée et un tablo "${tabloData.name}" a été créé.</p>
|
||||
<p><strong>Détails de votre rendez-vous :</strong></p>
|
||||
<ul>
|
||||
<li>Titre : ${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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
534
api/src/tablo.ts
534
api/src/tablo.ts
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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];
|
||||
};
|
||||
287
api/src/user.ts
287
api/src/user.ts
|
|
@ -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 été 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 été 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;
|
||||
};
|
||||
|
|
@ -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-----
|
||||
0
api/.gitignore → apps/api/.gitignore
vendored
0
api/.gitignore → apps/api/.gitignore
vendored
90
apps/api/README.md
Normal file
90
apps/api/README.md
Normal 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
175
apps/api/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -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'
|
||||
207
apps/api/docs/TYPE_IMPROVEMENTS_SUMMARY.md
Normal file
207
apps/api/docs/TYPE_IMPROVEMENTS_SUMMARY.md
Normal 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.
|
||||
|
||||
|
||||
248
apps/api/docs/TYPE_SAFETY.md
Normal file
248
apps/api/docs/TYPE_SAFETY.md
Normal 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)
|
||||
284
apps/api/examples/rpc-client-usage.ts
Normal file
284
apps/api/examples/rpc-client-usage.ts
Normal 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 };
|
||||
|
||||
|
|
@ -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"
|
||||
},
|
||||
36
apps/api/src/__tests__/auth/auth.test.ts
Normal file
36
apps/api/src/__tests__/auth/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
apps/api/src/__tests__/invite/invite.test.ts
Normal file
95
apps/api/src/__tests__/invite/invite.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
65
apps/api/src/__tests__/maybeAuth/maybeAuth.test.ts
Normal file
65
apps/api/src/__tests__/maybeAuth/maybeAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
441
apps/api/src/__tests__/middlewares/middlewares.test.ts
Normal file
441
apps/api/src/__tests__/middlewares/middlewares.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
apps/api/src/__tests__/notes/notes.test.ts
Normal file
36
apps/api/src/__tests__/notes/notes.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
38
apps/api/src/__tests__/public/public.test.ts
Normal file
38
apps/api/src/__tests__/public/public.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/api/src/__tests__/stripe/stripe.test.ts
Normal file
90
apps/api/src/__tests__/stripe/stripe.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
apps/api/src/__tests__/tablo/tablo.test.ts
Normal file
91
apps/api/src/__tests__/tablo/tablo.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
apps/api/src/__tests__/tablo_data/tablo_data.test.ts
Normal file
69
apps/api/src/__tests__/tablo_data/tablo_data.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
92
apps/api/src/__tests__/tasks/tasks.test.ts
Normal file
92
apps/api/src/__tests__/tasks/tasks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
apps/api/src/__tests__/user/user.test.ts
Normal file
61
apps/api/src/__tests__/user/user.test.ts
Normal 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
37
apps/api/src/client.ts
Normal 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 };
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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[],
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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(
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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;
|
||||
|
||||
28
apps/api/src/routers/authRouter.ts
Normal file
28
apps/api/src/routers/authRouter.ts
Normal 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;
|
||||
};
|
||||
44
apps/api/src/routers/index.ts
Normal file
44
apps/api/src/routers/index.ts
Normal 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>;
|
||||
272
apps/api/src/routers/invite.ts
Normal file
272
apps/api/src/routers/invite.ts
Normal 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 été créé avec succès !</h2>
|
||||
<p>Bonjour ${ownerDataTyped.name},</p>
|
||||
<p>Un nouveau tablo "${tabloData.name}" a été créé suite à une réservation.</p>
|
||||
<p><strong>Détails de l'événement :</strong></p>
|
||||
<ul>
|
||||
<li>Titre : ${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 été confirmée et un tablo "${tabloData.name}" a été créé.</p>
|
||||
<p><strong>Détails de votre rendez-vous :</strong></p>
|
||||
<ul>
|
||||
<li>Titre : ${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;
|
||||
};
|
||||
16
apps/api/src/routers/maybeAuthRouter.ts
Normal file
16
apps/api/src/routers/maybeAuthRouter.ts
Normal 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;
|
||||
};
|
||||
82
apps/api/src/routers/notes.ts
Normal file
82
apps/api/src/routers/notes.ts
Normal 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;
|
||||
};
|
||||
131
apps/api/src/routers/public.ts
Normal file
131
apps/api/src/routers/public.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
518
apps/api/src/routers/tablo.ts
Normal file
518
apps/api/src/routers/tablo.ts
Normal 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;
|
||||
};
|
||||
169
apps/api/src/routers/tablo_data.ts
Normal file
169
apps/api/src/routers/tablo_data.ts
Normal 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;
|
||||
};
|
||||
95
apps/api/src/routers/tasks.ts
Normal file
95
apps/api/src/routers/tasks.ts
Normal 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;
|
||||
};
|
||||
273
apps/api/src/routers/user.ts
Normal file
273
apps/api/src/routers/user.ts
Normal 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 été 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 été 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;
|
||||
};
|
||||
44
apps/api/src/types/app.types.ts
Normal file
44
apps/api/src/types/app.types.ts
Normal 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;
|
||||
|
|
@ -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
25
apps/api/turbo.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
apps/external/src/EmbeddedBookingPage.tsx
vendored
51
apps/external/src/EmbeddedBookingPage.tsx
vendored
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
53
apps/external/src/FloatingBookingWidget.tsx
vendored
53
apps/external/src/FloatingBookingWidget.tsx
vendored
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
14
biome.json
14
biome.json
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
215
packages/shared-types/README.md
Normal file
215
packages/shared-types/README.md
Normal 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
|
||||
|
||||
176
packages/shared-types/biome.json
Normal file
176
packages/shared-types/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
29
packages/shared-types/package.json
Normal file
29
packages/shared-types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/shared-types/src/events.types.ts
Normal file
20
packages/shared-types/src/events.types.ts
Normal 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"
|
||||
>;
|
||||
66
packages/shared-types/src/index.ts
Normal file
66
packages/shared-types/src/index.ts
Normal 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";
|
||||
148
packages/shared-types/src/kanban.types.ts
Normal file
148
packages/shared-types/src/kanban.types.ts
Normal 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;
|
||||
}
|
||||
120
packages/shared-types/src/stripe.types.ts
Normal file
120
packages/shared-types/src/stripe.types.ts
Normal 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;
|
||||
}
|
||||
25
packages/shared-types/src/tablos.types.ts
Normal file
25
packages/shared-types/src/tablos.types.ts
Normal 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[];
|
||||
};
|
||||
31
packages/shared-types/src/utils.ts
Normal file
31
packages/shared-types/src/utils.ts
Normal 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"];
|
||||
26
packages/shared-types/tsconfig.json
Normal file
26
packages/shared-types/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
5
packages/shared-types/turbo.json
Normal file
5
packages/shared-types/turbo.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"extends": ["//"]
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
89
packages/shared/src/hooks/book.ts
Normal file
89
packages/shared/src/hooks/book.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
4155
pnpm-lock.yaml
4155
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue