Merge pull request #20 from artslidd/develop

develop
This commit is contained in:
Arthur Belleville 2025-10-24 14:19:51 +02:00 committed by GitHub
commit a751697356
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1047 additions and 749 deletions

View file

@ -1,31 +1,360 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
# Define executor
executors:
node-executor:
docker:
# Specify the version you desire here
# See: https://circleci.com/developer/images/image/cimg/base
- image: cimg/base:current
- image: node:20
resource_class: small
working_directory: ~/project
# Add steps to the job
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
# Reusable commands
commands:
setup-pnpm:
steps:
# Checkout the code as the first step.
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
- restore_cache:
name: Restore pnpm Package Cache
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
keys:
- pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
- run:
name: Install pnpm package manager
command: |
npm install --global corepack@latest
corepack enable
corepack prepare pnpm@latest-10 --activate
pnpm config set store-dir .pnpm-store
- run:
name: Install Dependencies
command: pnpm install
- save_cache:
name: Save pnpm Package Cache
key: pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
paths:
- .pnpm-store
# Jobs
jobs:
# ============================================
# TEST PHASE
# ============================================
test-lint:
executor: node-executor
steps:
- checkout
- setup-pnpm
- run:
name: Run linting
command: pnpm run lint
- run:
name: Check formatting
command: pnpm run format --check || echo "Format check complete"
test-typecheck:
executor: node-executor
steps:
- checkout
- setup-pnpm
- run:
name: Type check all packages
command: pnpm run typecheck
test-unit:
executor: node-executor
steps:
- checkout
- setup-pnpm
- run:
name: Run unit tests
command: pnpm run test
- store_test_results:
path: apps/main/coverage
- store_artifacts:
path: apps/main/coverage
destination: coverage
test-api:
executor: node-executor
steps:
- checkout
- restore_cache:
name: Restore npm API Cache
keys:
- npm-api-{{ checksum "api/package-lock.json" }}
- run:
name: Install API dependencies
command: |
cd api
npm ci
- save_cache:
name: Save npm API Cache
key: npm-api-{{ checksum "api/package-lock.json" }}
paths:
- api/node_modules
- run:
name: Lint API
command: |
cd api
npm run lint
- run:
name: Run API tests
command: |
cd api
npm run test
# ============================================
# BUILD PHASE
# ============================================
build-apps:
executor: node-executor
parameters:
environment:
type: string
default: "staging"
steps:
- checkout
- setup-pnpm
- run:
name: Build main app for << parameters.environment >>
command: |
cd apps/main
npm run build:<< parameters.environment >>
- run:
name: Build external app
command: |
cd apps/external
npm run build
- persist_to_workspace:
root: .
paths:
- apps/main/dist
- apps/external/dist
- packages/ui/dist
- packages/shared/dist
- store_artifacts:
path: apps/main/dist
destination: main-app-<< parameters.environment >>
- store_artifacts:
path: apps/external/dist
destination: external-app
build-api:
executor: node-executor
steps:
- checkout
- restore_cache:
name: Restore npm API Cache
keys:
- npm-api-{{ checksum "api/package-lock.json" }}
- run:
name: Install API dependencies
command: |
cd api
npm ci
- run:
name: Build API
command: |
cd api
npm run build
- persist_to_workspace:
root: .
paths:
- api/dist
- store_artifacts:
path: api/dist
destination: api
# ============================================
# DOCKER BUILD PHASE
# ============================================
build-docker-api:
machine:
image: ubuntu-2204:current
resource_class: medium
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Build API Docker image
command: |
cd api
docker build -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
- run:
name: Save Docker image
command: |
mkdir -p /tmp/docker-images
docker save xtablo-api:${CIRCLE_SHA1} -o /tmp/docker-images/api.tar
- persist_to_workspace:
root: /tmp
paths:
- docker-images/api.tar
# ============================================
# DEPLOY PHASE
# ============================================
deploy-staging:
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- setup-pnpm
- run:
name: Deploy main app to staging
command: |
cd apps/main
echo "Deploying main app to staging environment..."
npx wrangler deploy --env staging
- run:
name: Deploy external app to staging
command: |
cd apps/external
echo "Deploying external app to staging..."
# Add external app staging deployment if needed
# npx wrangler deploy --env staging
- run:
name: Deploy API to staging
command: |
echo "Deploying API to staging environment..."
# Add your API deployment commands here
# Example for Google Cloud Run:
# gcloud run deploy xtablo-api-staging --image gcr.io/${GCP_PROJECT}/xtablo-api:${CIRCLE_SHA1} --region us-central1
deploy-production:
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- setup-pnpm
- run:
name: Deploy main app to production
command: |
cd apps/main
echo "Deploying main app to production environment..."
npx wrangler deploy --env production
- run:
name: Deploy external app to production
command: |
cd apps/external
echo "Deploying external app to production..."
# Add external app production deployment if needed
# npx wrangler deploy --env production
- run:
name: Deploy API to production
command: |
echo "Deploying API to production environment..."
# Add your production API deployment commands here
# Example for Google Cloud Run:
# gcloud run deploy xtablo-api --image gcr.io/${GCP_PROJECT}/xtablo-api:${CIRCLE_SHA1} --region us-central1
# Workflows
workflows:
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
version: 2
# Run on all branches (except main and develop)
test-and-build:
when:
and:
- not:
equal: [ main, << pipeline.git.branch >> ]
- not:
equal: [ develop, << pipeline.git.branch >> ]
jobs:
- say-hello
# Test phase - run in parallel
- test-lint
- test-typecheck
- test-unit
- test-api
# Build phase - run after tests pass
- build-apps:
requires:
- test-lint
- test-typecheck
- test-unit
- build-api:
requires:
- test-api
- build-docker-api:
requires:
- build-api
# Staging deployment workflow (develop branch)
deploy-to-staging:
when:
equal: [ develop, << pipeline.git.branch >> ]
jobs:
# Test phase
- test-lint
- test-typecheck
- test-unit
- test-api
# Build phase for staging
- build-apps:
environment: "staging"
requires:
- test-lint
- test-typecheck
- test-unit
- build-api:
requires:
- test-api
- build-docker-api:
requires:
- build-api
# Deploy to staging
- deploy-staging:
requires:
- build-apps
- build-docker-api
# Production deployment workflow (main branch)
deploy-to-production:
when:
equal: [ main, << pipeline.git.branch >> ]
jobs:
# Test phase
- test-lint
- test-typecheck
- test-unit
- test-api
# Build phase for production
- build-apps:
environment: "prod"
requires:
- test-lint
- test-typecheck
- test-unit
- build-api:
requires:
- test-api
- build-docker-api:
requires:
- build-api
# Manual approval gate before production
- hold-for-approval:
type: approval
requires:
- build-apps
- build-docker-api
# Deploy to production
- deploy-production:
requires:
- hold-for-approval

View file

@ -5,7 +5,7 @@ STREAM_CHAT_API_KEY=v4yf8rs94aa8
STREAM_CHAT_API_SECRET=jq2szvv73ua7sz9tvr9y24dxg37sw8ue8t576fu7ggr4h6wvcmunby4gvte8tm8f
XTABLO_URL=https://app.xtablo.com
CORS_ORIGIN="https://app.xtablo.com"
CORS_ORIGIN="https://app.xtablo.com,https://embed.xtablo.com"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"

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({

View file

@ -6,13 +6,14 @@
"scripts": {
"dev": "vite dev",
"build": "tsc -b && vite build",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"deploy": "echo 'Configure deployment command for external app (e.g., wrangler pages deploy dist, vercel deploy, etc.)'",
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
@ -24,7 +25,8 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "^4.24.3"
},
"dependencies": {
"@xtablo/ui": "workspace:*",

View file

@ -25,7 +25,6 @@ import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { CustomModal } from "./CustomModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { api } from "./lib/api";
import { supabase } from "./lib/supabase";
import { useMaybeUser } from "./UserStoreProvider";
@ -214,8 +213,7 @@ export function EmbeddedBookingPage() {
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
console.log({ shortUserId, eventTypeStandardName });
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
const { data: publicSlots } = usePublicSlots(
api,
shortUserId || "",
eventTypeStandardName || ""
@ -454,17 +452,6 @@ export function EmbeddedBookingPage() {
}
};
if (isLoadingSlots) {
return (
<div className="w-[1130px] h-[700px] flex items-center justify-center bg-gray-50 dark:from-gray-900 dark:to-gray-800">
<div className="text-center">
<LoadingSpinner />
<p className="mt-4 text-gray-600 dark:text-gray-400">Chargement des disponibilités...</p>
</div>
</div>
);
}
return (
<div className="w-[1130px] h-[700px] bg-transparent overflow-hidden">
<div className="h-full bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 flex overflow-hidden">

View file

@ -21,7 +21,6 @@ import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { CustomModal } from "./CustomModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { api } from "./lib/api";
import { supabase } from "./lib/supabase";
// import { useCreateTabloWithOwner } from "@xtablo/shared";
@ -126,7 +125,7 @@ export function FloatingBookingWidget() {
// Get color schemes based on variants
const btnColors = buttonColors[buttonVariant];
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
const { data: publicSlots } = usePublicSlots(
api,
shortUserId || "",
eventTypeStandardName || ""
@ -371,24 +370,6 @@ export function FloatingBookingWidget() {
}
};
if (isLoadingSlots) {
return (
<div className="fixed inset-0 pointer-events-none">
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg transition-all duration-200",
btnColors.floating
)}
disabled
>
<LoadingSpinner />
</Button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 pointer-events-none">

View file

@ -1,12 +0,0 @@
export const LoadingSpinner = () => {
return (
<div className="flex items-center justify-center min-h-screen">
<img
src="/icon.jpg"
alt="Loading..."
role="status"
className="animate-spin rounded-full h-16 w-16 object-cover"
/>
</div>
);
};

View file

@ -3,7 +3,6 @@ import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Tables } from "@xtablo/shared/types/database.types";
import React from "react";
import { createStore, StoreApi, useStore } from "zustand";
import { LoadingSpinner } from "./LoadingSpinner";
import { api } from "./lib/api";
export type User = Tables<"profiles"> & {
@ -34,7 +33,7 @@ export const UserStoreProvider = ({ children }: { children: React.ReactNode }) =
});
if (isPending && shouldFetchUser) {
return <LoadingSpinner />;
return <div>Loading...</div>;
}
if (!user) {

View file

@ -1 +1 @@
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/loadingspinner.tsx","./src/userstoreprovider.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}
{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"}

12
apps/external/turbo.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"deploy": {
"dependsOn": ["build"],
"cache": false,
"outputLogs": "new-only"
}
}
}

4
apps/external/worker/index.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare const _default: {
fetch(request: any): Response;
};
export default _default;

16
apps/external/worker/index.ts vendored Normal file
View file

@ -0,0 +1,16 @@
// @ts-nocheck
// biome-ignore-file: Worker entry point with dynamic imports
/* eslint-disable */
export default {
fetch(request) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return Response.json({
name: "Cloudflare",
});
}
return new Response(null, { status: 404 });
},
};

17
apps/external/wrangler.toml vendored Normal file
View file

@ -0,0 +1,17 @@
name = "xtablo-embeds"
main = "worker/index.ts"
compatibility_date = "2025-07-09"
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"
[observability]
enabled = true
[vars]
PYTHON_VERSION = "3.11.5"
[[routes]]
pattern = "embed.xtablo.com"
custom_domain = true

View file

@ -13,7 +13,8 @@
"preview": "vite preview",
"build:staging": "tsc -b && vite build --mode staging",
"build:prod": "tsc -b && vite build --mode production",
"deploy": "wrangler deploy --env=\"\"",
"deploy:staging": "wrangler deploy --env=\"\"",
"deploy:prod": "wrangler deploy --env=\"\"",
"cf-typegen": "wrangler types",
"test": "vitest run --mode dev --passWithNoTests",
"test:watch": "vitest watch --passWithNoTests",

View file

@ -42,7 +42,7 @@ export const ProtectedRoute = ({ fallback, shouldRedirectToCurrentPage }: Protec
return match(status)
.with("loading", () => <LoadingSpinner />)
.with("should-land-user", () => <Navigate to={redirectUrl} replace />)
.with("should-land-user", () => <Navigate to="/landing" replace />)
.with("should-redirect", () => <Navigate to={redirectUrl} replace />)
.with("should-pass", () => <Outlet />)
.exhaustive();

File diff suppressed because one or more lines are too long

View file

@ -29,6 +29,16 @@
"outputs": ["dist/**", "tsconfig.tsbuildinfo"],
"outputLogs": "new-only",
"env": ["NODE_ENV", "VITE_*"]
},
"deploy:staging": {
"dependsOn": ["build:staging"],
"cache": false,
"outputLogs": "new-only"
},
"deploy:prod": {
"dependsOn": ["build:prod"],
"cache": false,
"outputLogs": "new-only"
}
}
}

2
apps/main/vite.config.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfigFnObject;
export default _default;

View file

@ -1,7 +1,6 @@
/// <reference types="vitest" />
import { cloudflare } from "@cloudflare/vite-plugin";
// import { cloudflare } from "@cloudflare/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";
@ -9,14 +8,34 @@ import { defineConfig, type PluginOption } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), visualizer() as PluginOption, tailwindcss(), tsconfigPaths(), cloudflare()],
server: {
cors: false,
},
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/setupTests.ts",
},
export default defineConfig(({ mode }) => {
const plugins: PluginOption[] = [
react(),
visualizer() as PluginOption,
tailwindcss(),
tsconfigPaths(),
];
// Only include cloudflare plugin when not in test mode
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare());
}
return {
plugins,
server: {
cors: false,
},
define: process.env.VITEST
? {
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify("https://test.supabase.co"),
"import.meta.env.VITE_SUPABASE_ANON_KEY": JSON.stringify("test-anon-key"),
}
: undefined,
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/setupTests.ts",
},
};
});

View file

@ -4,6 +4,9 @@
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@10.19.0",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "turbo build",
"build:apps": "turbo build --filter='./apps/*'",
@ -12,9 +15,9 @@
"dev": "turbo dev",
"dev:main": "turbo dev --filter=@xtablo/main",
"dev:external": "turbo dev --filter=@xtablo/external",
"deploy:main": "turbo deploy --filter=@xtablo/main",
"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",
"deploy:all": "pnpm deploy:main && pnpm deploy:external",
"lint": "turbo lint",
"lint:fix": "turbo lint:fix",
"format": "turbo format",

View file

@ -193,11 +193,11 @@ export const parseICSFile = (icsContent: string): ParsedICSEvent[] => {
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
let line = lines[i]?.trim() || "";
// Handle line folding (lines starting with space or tab)
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) {
line += lines[i + 1].substring(1);
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1] || "")) {
line += lines[i + 1]?.substring(1) || "";
i++;
}

View file

@ -26,6 +26,7 @@ function ButtonGroup({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: This is a UI grouping element, not a form fieldset
<div
role="group"
data-slot="button-group"

View file

@ -1,11 +1,52 @@
"use client";
import { cn } from "@xtablo/shared";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { Button, buttonVariants } from "./button";
// Component definitions outside to avoid recreation on every render
const CalendarRoot = ({
className,
rootRef,
...props
}: React.HTMLAttributes<HTMLDivElement> & { rootRef?: React.Ref<HTMLDivElement> }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
};
const CalendarChevron = ({
className,
orientation,
...props
}: React.SVGProps<SVGSVGElement> & { orientation?: "up" | "left" | "right" | "down" }) => {
switch (orientation) {
case "up":
return <ChevronUpIcon className={cn("size-4", className)} {...props} />;
case "left":
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
case "right":
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
case "down":
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
default:
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
}
};
const CalendarWeekNumber = ({
children,
...props
}: React.TdHTMLAttributes<HTMLTableCellElement>) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
};
function Calendar({
className,
classNames,
@ -104,30 +145,10 @@ function Calendar({
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
Root: CalendarRoot,
Chevron: CalendarChevron,
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
WeekNumber: CalendarWeekNumber,
...components,
}}
{...props}

View file

@ -113,6 +113,7 @@ export function DateFieldLabel({
"aria-required"?: boolean;
}) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: This label is for display only within a custom date picker component
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{children}
{ariaRequired && <span className="text-destructive ml-1">*</span>}

View file

@ -3,7 +3,7 @@
import { CalendarDate } from "@internationalized/date";
import { cn } from "@xtablo/shared";
import { Calendar as CalendarIcon, ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { useId, useState } from "react";
import { Button } from "./button";
import { Calendar } from "./calendar";
import { Label } from "./label";
@ -91,15 +91,16 @@ export const DatePickerV1 = ({
onChange: (date: Date | undefined) => void;
}) => {
const [open, setOpen] = useState(false);
const id = useId();
return (
<div className="w-full max-w-xs space-y-2">
<Label htmlFor="date" className="px-1">
<Label htmlFor={id} className="px-1">
{label}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" id="date" className="w-full justify-between font-normal">
<Button variant="outline" id={id} className="w-full justify-between font-normal">
{value ? value.toLocaleDateString() : "Choisir une date"}
<ChevronDownIcon />
</Button>

View file

@ -78,6 +78,7 @@ function Field({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: This is a UI grouping element, not a form fieldset
<div
role="group"
data-slot="field"
@ -194,7 +195,10 @@ function FieldError({
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
{errors.map(
(error, index) =>
error?.message && <li key={`error-${error.message}-${index}`}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);

View file

@ -6,7 +6,9 @@ export function useCopyToClipboard({ timeout = 2000 } = {}) {
const [copyTimeout, setCopyTimeout] = React.useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
if (copyTimeout !== null) {
window.clearTimeout(copyTimeout);
}
setCopyTimeout(window.setTimeout(() => setCopied(false), timeout));
setCopied(value);
};
@ -25,7 +27,9 @@ export function useCopyToClipboard({ timeout = 2000 } = {}) {
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
if (copyTimeout !== null) {
window.clearTimeout(copyTimeout);
}
};
return { copy, reset, error, copied };

View file

@ -88,6 +88,9 @@ importers:
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))
wrangler:
specifier: ^4.24.3
version: 4.44.0
apps/main:
dependencies:

View file

@ -22,6 +22,10 @@
"inputs": ["src/**", "biome.json", "package.json"],
"outputLogs": "new-only"
},
"lint:fix": {
"inputs": ["src/**", "biome.json", "package.json"],
"outputLogs": "new-only"
},
"format": {
"inputs": ["src/**", "biome.json", "package.json"],
"outputLogs": "errors-only"
@ -39,11 +43,6 @@
"cache": false,
"persistent": true
},
"deploy": {
"dependsOn": ["build"],
"cache": false,
"outputLogs": "new-only"
},
"clean": {
"cache": false
}