Format api

This commit is contained in:
Arthur Belleville 2025-10-24 08:39:16 +02:00
parent 9f255c87db
commit 370cd11dad
No known key found for this signature in database
10 changed files with 513 additions and 619 deletions

View file

@ -734,10 +734,8 @@ describe("generateTimeSlots", () => {
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(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
});
@ -1145,12 +1143,8 @@ describe("generateTimeSlots", () => {
});
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"
);
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");
});
});
});

View file

@ -47,38 +47,20 @@ function createConfig(): AppConfig {
process.env.SUPABASE_SERVICE_ROLE_KEY
),
SUPABASE_CONNECTION_STRING: process.env.SUPABASE_CONNECTION_STRING || "",
STREAM_CHAT_API_KEY: validateEnvVar(
"STREAM_CHAT_API_KEY",
process.env.STREAM_CHAT_API_KEY
),
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
STREAM_CHAT_API_SECRET: validateEnvVar(
"STREAM_CHAT_API_SECRET",
process.env.STREAM_CHAT_API_SECRET
),
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar(
"EMAIL_CLIENT_ID",
process.env.EMAIL_CLIENT_ID
),
EMAIL_CLIENT_SECRET: validateEnvVar(
"EMAIL_CLIENT_SECRET",
process.env.EMAIL_CLIENT_SECRET
),
EMAIL_REFRESH_TOKEN: validateEnvVar(
"EMAIL_REFRESH_TOKEN",
process.env.EMAIL_REFRESH_TOKEN
),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: validateEnvVar("EMAIL_CLIENT_SECRET", process.env.EMAIL_CLIENT_SECRET),
EMAIL_REFRESH_TOKEN: validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN),
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: validateEnvVar(
"R2_ACCESS_KEY_ID",
process.env.R2_ACCESS_KEY_ID
),
R2_SECRET_ACCESS_KEY: validateEnvVar(
"R2_SECRET_ACCESS_KEY",
process.env.R2_SECRET_ACCESS_KEY
),
R2_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID),
R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY),
SYNC_CALS_SECRET: process.env.SYNC_CALS_SECRET || "",
LOG_LEVEL: "info",
};

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,4 @@
import {
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo } from "./types.ts";
@ -54,14 +50,10 @@ export const generateICSFromEvents = (
`DTSTART:${startDateTime}`,
`DTEND:${endDateTime}`,
`SUMMARY:${escapeICSText(event.title)}`,
`DESCRIPTION:${escapeICSText(
`Tablo: ${event.tablo_name}\n${event.description || ""}`
)}`,
`DESCRIPTION:${escapeICSText(`Tablo: ${event.tablo_name}\n${event.description || ""}`)}`,
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`LAST-MODIFIED:${
new Date().toISOString().replace(/[-:]/g, "").split(".")[0]
}Z`,
`LAST-MODIFIED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"END:VEVENT",
@ -112,10 +104,7 @@ export const writeCalendarFileToR2 = async (
);
};
export const getTabloFileNames = async (
s3_client: S3Client,
tabloId: string
) => {
export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => {
const bucketName = "tablo-data";
const { Contents } = await s3_client.send(
@ -130,11 +119,7 @@ export const getTabloFileNames = async (
);
};
export const isTabloMember = async (
supabase: SupabaseClient,
tabloId: string,
userId: string
) => {
export const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const { data: tabloAccess, error: isMemberError } = await supabase
.from("tablo_access")
.select("*")
@ -149,11 +134,7 @@ export const isTabloMember = async (
return tabloAccess?.length > 0;
};
export const isTabloAdmin = async (
supabase: SupabaseClient,
tabloId: string,
userId: string
) => {
export const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => {
const { data: tabloAccess, error: isAdminError } = await supabase
.from("tablo_access")
.select("*")

View file

@ -51,25 +51,22 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
return c.json({ error: "Event type not found" }, 404);
}
const eventType =
eventTypeData as Database["public"]["Tables"]["event_types"]["Row"];
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();
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 weeklyAvailability = availabilities.availability_data as WeeklyAvailability;
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month

View file

@ -1,6 +1,5 @@
import type { Tables } from "./database.types.js";
import { DateTime } from "luxon";
import type { Tables } from "./database.types.js";
// Types for availability calculation
type TimeRange = {
@ -57,9 +56,7 @@ function parseTime(timeStr: string): { hours: number; minutes: number } {
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
function addMinutes(timeStr: string, minutesToAdd: number): string {
@ -82,9 +79,7 @@ function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
if (ranges.length <= 1) return ranges;
// Sort ranges by start time
const sortedRanges = [...ranges].sort((a, b) =>
a.start.localeCompare(b.start)
);
const sortedRanges = [...ranges].sort((a, b) => a.start.localeCompare(b.start));
const merged: TimeRange[] = [sortedRanges[0]];
for (let i = 1; i < sortedRanges.length; i++) {
@ -94,8 +89,7 @@ function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
// Check if current range overlaps with the last merged range
if (current.start <= lastMerged.end) {
// Merge by extending the end time if current range extends further
lastMerged.end =
current.end > lastMerged.end ? current.end : lastMerged.end;
lastMerged.end = current.end > lastMerged.end ? current.end : lastMerged.end;
} else {
// No overlap, add current range to merged array
merged.push(current);
@ -144,9 +138,7 @@ function getMinAdvanceBookingDate(
}
export function getDateStringCET(date: Date): string {
return DateTime.fromJSDate(date)
.setZone("Europe/Paris")
.toFormat("yyyy-MM-dd");
return DateTime.fromJSDate(date).setZone("Europe/Paris").toFormat("yyyy-MM-dd");
}
export function generateTimeSlots(
@ -183,10 +175,7 @@ export function generateTimeSlots(
}
// Check minimum advance booking
const minAdvanceBooking = getMinAdvanceBookingDate(
eventTypeConfig,
currentTime
);
const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, currentTime);
// Generate slots for each time range
for (const range of timeRanges) {
@ -197,17 +186,13 @@ export function generateTimeSlots(
const endMinutes = endTime.hours * 60 + endTime.minutes;
while (currentMinutes + eventTypeConfig.duration <= endMinutes) {
const slotTime = formatTime(
Math.floor(currentMinutes / 60),
currentMinutes % 60
);
const slotTime = formatTime(Math.floor(currentMinutes / 60), currentMinutes % 60);
// Check if slot is in the future (considering minimum advance booking)
// Compare dates first, then times if on the same date
const isInFuture =
dateStr > minAdvanceBooking.date ||
(dateStr === minAdvanceBooking.date &&
slotTime >= minAdvanceBooking.time);
(dateStr === minAdvanceBooking.date && slotTime >= minAdvanceBooking.time);
slots.push({
date: dateStr,
@ -233,8 +218,7 @@ export function generateTimeSlots(
if (event.start_date !== dateStr || event.deleted_at) return false;
const eventStart = event.start_time;
const eventEnd =
event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
const eventEnd = event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
// Apply buffer time around the existing event
const bufferedEventStart = addMinutes(eventStart, -bufferTime);

View file

@ -6,11 +6,7 @@ import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { writeCalendarFileToR2 } from "./helpers.js";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import { generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
@ -170,9 +166,7 @@ tabloRouter.post("/create-and-invite", async (c) => {
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
name: `${invitedUserDataTyped.name || "Invité"} / ${
ownerDataTyped.name || "Propriétaire"
}`,
name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
@ -190,22 +184,20 @@ tabloRouter.post("/create-and-invite", async (c) => {
}
// Grant access to the current user (invited user) as a non-admin member
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
const { error: tabloAccessError } = await supabase.from("tablo_access").insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
@ -298,8 +290,7 @@ tabloRouter.patch("/update", async (c) => {
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName =
tablo.name !== undefined && tablo.name !== updatedTablo.name;
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
if (error) {
return c.json({ error: error.message }, 500);
@ -344,10 +335,7 @@ tabloRouter.delete("/delete", async (c) => {
.single();
if (accessError || !tabloAccess || !tabloAccess.is_admin) {
return c.json(
{ error: "You are not authorized to delete this tablo" },
403
);
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
}
if (error) {
@ -388,10 +376,7 @@ tabloRouter.post("/invite", async (c) => {
}
if (tablo.owner_id !== sender.id) {
return c.json(
{ error: "You are not allowed to invite users to this tablo" },
400
);
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
}
const { data: introConfigData, error: introError } = await supabase
@ -425,8 +410,8 @@ tabloRouter.post("/invite", async (c) => {
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p>Cordialement.</p>
`,
@ -462,17 +447,15 @@ tabloRouter.post("/join", async (c) => {
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,
});
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);

View file

@ -2,11 +2,7 @@ import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { type Context, Hono, type Next } from "hono";
import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
export const tabloDataRouter = new Hono<{
Variables: {

View file

@ -34,7 +34,7 @@ taskRouter.post("/sync-calendars", async (c) => {
token: string;
tablo_id: string;
tablos: { name: string };
}
},
];
calendarSubscriptionsData.forEach(async (subscription) => {

View file

@ -1,20 +1,16 @@
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat";
import type { Tables } from "./database.types.ts";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import { transporter } from "./transporter.js";
import {
DeleteObjectsCommand,
ListObjectsV2Command,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import { Hono } from "hono";
import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat";
import type { Tables } from "./database.types.ts";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import { transporter } from "./transporter.js";
export const userRouter = new Hono<{
Variables: {
@ -34,11 +30,7 @@ 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 { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const user = data as Tables<"profiles">;
@ -59,11 +51,7 @@ userRouter.get("/me", async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
const userData = data as Tables<"profiles">;
@ -212,9 +200,7 @@ userRouter.post("/profile/avatar", async (c) => {
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]
}`;
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
try {
await s3Client.send(
@ -263,8 +249,7 @@ userRouter.delete("/profile/avatar", async (c) => {
})
);
if (listedObjects.Contents.length === 0)
return c.json({ error: "No objects found" }, 404);
if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404);
await s3Client.send(
new DeleteObjectsCommand({