diff --git a/apps/api/README.md b/apps/api/README.md index cfb930a..124ccb9 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -88,3 +88,54 @@ See `.env.example` for required environment variables. ## Deployment The API is deployed to Google Cloud Run. See `cloudbuild.yaml` for deployment configuration. + +## Dokploy Deployment + +Prefer a Dokploy `Application` instead of a Compose service. + +Dokploy supports Dockerfile-based applications directly, which fits this API better than maintaining a dedicated compose wrapper. Configure the application like this: + +- Build Type: `Dockerfile` +- Dockerfile Path: `apps/api/Dockerfile` +- Docker Context Path: `.` +- Docker Build Stage: `final` +- Port: `8080` +- Run Command: leave empty and use the image `CMD` + +Set these Dokploy environment variables: + +- `NODE_ENV` +- `DD_SERVICE=xtablo-api` +- `DD_ENV` +- `DD_VERSION` +- `DD_LOGS_INJECTION=true` +- `SUPABASE_URL` +- `SUPABASE_SERVICE_ROLE_KEY` +- `SUPABASE_CONNECTION_STRING` +- `SUPABASE_CA_CERT` +- `STRIPE_SECRET_KEY` +- `STRIPE_WEBHOOK_SECRET` +- `STRIPE_SOLO_PRICE_ID` +- `STRIPE_TEAM_PRICE_ID` +- `STRIPE_FOUNDER_PRICE_ID` +- `EMAIL_USER` +- `EMAIL_CLIENT_ID` +- `EMAIL_CLIENT_SECRET` +- `EMAIL_REFRESH_TOKEN` +- `R2_ACCOUNT_ID` +- `R2_ACCESS_KEY_ID` +- `R2_SECRET_ACCESS_KEY` +- `TASKS_SECRET` + +For Datadog logs, the Dokploy host should already run a Datadog Agent with Docker log collection enabled. Then add these Docker Swarm labels in Dokploy `Advanced Settings -> Swarm Settings -> Labels`: + +- `com.datadoghq.tags.service=xtablo-api` +- `com.datadoghq.tags.env=${DD_ENV}` +- `com.datadoghq.tags.version=${DD_VERSION}` +- `com.datadoghq.ad.logs=[{"source":"nodejs","service":"xtablo-api"}]` + +This gives you: + +- container logs in Datadog with `source: nodejs` +- service tagging as `xtablo-api` +- APM/log correlation via `dd-trace` plus `DD_LOGS_INJECTION=true` diff --git a/apps/main/src/pages/planning.test.tsx b/apps/main/src/pages/planning.test.tsx new file mode 100644 index 0000000..d4bf8f6 --- /dev/null +++ b/apps/main/src/pages/planning.test.tsx @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext"; +import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; +import { I18nextProvider } from "react-i18next"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import testI18n from "../i18n.test"; +import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider"; +import { PlanningPage } from "./planning"; + +vi.mock("../hooks/events", () => ({ + useDeleteEvent: () => ({ mutate: vi.fn(), isPending: false }), + useEventsByTablo: () => ({ + data: [ + { + event_id: "event-1", + tablo_id: "tablo-1", + tablo_name: "Tablo Alpha", + tablo_color: "bg-blue-500", + start_date: "2026-04-22", + start_time: "09:00:00", + end_time: "10:00:00", + title: "Kickoff", + description: "Planning event", + }, + ], + isLoading: false, + }), +})); + +vi.mock("../hooks/tablos", () => ({ + useGetAllTabloAccess: () => ({ + data: [{ tablo_id: "tablo-1", is_admin: true }], + }), + useTablosList: () => ({ + data: [{ id: "tablo-1", name: "Tablo Alpha" }], + isLoading: false, + }), +})); + +vi.mock("../providers/UserStoreProvider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useIsReadOnlyUser: () => false, + }; +}); + +vi.mock("../components/EventModal", () => ({ + EventModal: () =>
, +})); + +const testUser: User = { + id: "user-1", + short_user_id: "u1", + name: "John Doe", + first_name: "John", + last_name: "Doe", + email: "john@example.com", + avatar_url: null, + is_temporary: false, + is_client: false, + client_onboarded_at: null, + last_signed_in: null, + plan: "none", + created_at: new Date("2026-01-01").toISOString(), +}; + +const createTestRouter = (initialEntry: string) => + createMemoryRouter( + [ + { + path: "/planning", + element: , + }, + { + path: "/tasks", + element:
Tasks page
, + }, + ], + { initialEntries: [initialEntry] } + ); + +const renderPlanningRouter = (initialEntry: string) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); + const router = createTestRouter(initialEntry); + + return { + router, + ...render( + + + + + + + + + + + + ), + }; +}; + +describe("PlanningPage", () => { + beforeEach(() => { + testI18n.changeLanguage("fr"); + }); + + it("does not throw when leaving the planning page from the events tab", async () => { + const { router } = renderPlanningRouter("/planning?tab=events"); + + expect(screen.getByText("Événements")).toBeInTheDocument(); + + await act(async () => { + await expect(router.navigate("/tasks")).resolves.toBeUndefined(); + }); + + await waitFor(() => { + expect(screen.getByText("Tasks page")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/main/src/pages/planning.tsx b/apps/main/src/pages/planning.tsx index 58ea4f4..e5e7d38 100644 --- a/apps/main/src/pages/planning.tsx +++ b/apps/main/src/pages/planning.tsx @@ -1055,254 +1055,262 @@ export const PlanningPage = () => { ); }; - if (currentTab === "events") { - return ( -
- {renderEventsView()} - setIsCreateEventOpen(false)} - defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined} - defaultDate={currentDate} - /> - -
- ); - } - return (
-
- {/* Sidebar */} -
-
- {/* Tablo Selector */} -
- setSelectedTabloId(value)} + disabled={tablosLoading} + > + + + + + {t("planning:allTablos")} + {tablos?.map((tablo) => ( + + {tablo.name} + + ))} + + +
+ + + + + +
- - - - - -
- - {/* Mini Calendar */} -
-
- {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()} -
-
- {dayNamesShort.map((day) => ( -
- {day.slice(0, 1)} -
- ))} - {getDaysInMonth(currentDate).map((day, index) => ( -
{ - if (day) { - navigateToCreateEvent(day, selectedTabloId); - } - }} - > - {day ? day.getDate() : ""} -
- ))} -
-
-
- - {/* Main Content */} -
- {/* Header */} -
-
-
- {t("planning:title")} - -
- - -
- {getViewTitle()} + {/* Mini Calendar */} +
+
+ {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
+
+ {dayNamesShort.map((day) => ( +
+ {day.slice(0, 1)} +
+ ))} + {getDaysInMonth(currentDate).map((day, index) => ( +
{ + if (day) { + navigateToCreateEvent(day, selectedTabloId); + } + }} + > + {day ? day.getDate() : ""} +
+ ))} +
+
+
-
- -
- {(["month", "week", "day"] as ViewType[]).map((view) => ( - +
+ - ))} + +
+ {getViewTitle()} +
+ +
+ +
+ {(["month", "week", "day"] as ViewType[]).map((view) => ( + + ))} +
-
- {/* Calendar Views */} -
- {tabloEventsLoading ? ( -
- Loading... - {t("planning:loadingEvents")} -
- ) : ( - <> - {currentView === "month" && renderMonthView()} - {currentView === "week" && renderWeekView()} - {currentView === "day" && renderDayView()} - - )} + {/* Calendar Views */} +
+ {tabloEventsLoading ? ( +
+ Loading... + {t("planning:loadingEvents")} +
+ ) : ( + <> + {currentView === "month" && renderMonthView()} + {currentView === "week" && renderWeekView()} + {currentView === "day" && renderDayView()} + + )} +
-
+ )} + + setIsCreateEventOpen(false)} + defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined} + defaultDate={currentDate} + /> {isImportModalOpen && setIsImportModalOpen(false)} />} - + {isWebcalModalOpen && ( + + )}
); };