diff --git a/apps/api/.env.production b/apps/api/.env.production index f55c086..7b3f239 100644 --- a/apps/api/.env.production +++ b/apps/api/.env.production @@ -1,6 +1,5 @@ SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co -STREAM_CHAT_API_KEY=h7bwnn8ynjpx XTABLO_URL=https://app.xtablo.com diff --git a/apps/api/cloudbuild.yaml b/apps/api/cloudbuild.yaml index 9dd6343..e8a7038 100644 --- a/apps/api/cloudbuild.yaml +++ b/apps/api/cloudbuild.yaml @@ -14,7 +14,7 @@ steps: - '--region' - 'europe-west1' - '--set-env-vars' - - 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,STREAM_CHAT_API_KEY=$_STREAM_CHAT_API_KEY,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID' + - 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID' images: - 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA' diff --git a/apps/api/package.json b/apps/api/package.json index 31fe087..5c18feb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,7 +33,6 @@ "multer": "^2.0.2", "nodemailer": "^7.0.4", "sharp": "^0.34.5", - "stream-chat": "^9.8.0", "stripe": "^20.0.0", "ts-node": "^10.9.2" }, diff --git a/apps/api/src/__tests__/config/stripe-config.test.ts b/apps/api/src/__tests__/config/stripe-config.test.ts index e8f9ca6..fdc130b 100644 --- a/apps/api/src/__tests__/config/stripe-config.test.ts +++ b/apps/api/src/__tests__/config/stripe-config.test.ts @@ -10,10 +10,8 @@ const baseSecrets: Secrets = { emailRefreshToken: "email-refresh-token", r2AccessKeyId: "r2-access-key-id", r2SecretAccessKey: "r2-secret-access-key", - streamChatApiSecret: "stream-chat-api-secret", stripeSecretKey: "sk_live_secret_manager", stripeWebhookSecret: "whsec_live_secret_manager", - streamChatApiSecretStaging: "stream-chat-api-secret-staging", stripeSecretKeyStaging: "sk_live_staging_secret_manager", stripeWebhookSecretStaging: "whsec_live_staging_secret_manager", }; diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts index c43a874..62c0d11 100644 --- a/apps/api/src/__tests__/helpers/billing.test.ts +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -28,12 +28,14 @@ describe("billing helpers", () => { id: "owner-user", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "annual", }, { id: "late-user", created_at: "2026-01-02T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "solo", }, ]); @@ -47,18 +49,21 @@ describe("billing helpers", () => { id: "user-1", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "solo", }, { id: "temp-1", created_at: "2026-01-02T10:00:00.000Z", is_temporary: true, + is_client: false, plan: "solo", }, { id: "user-2", created_at: "2026-01-03T10:00:00.000Z", is_temporary: null, + is_client: false, plan: "team", }, ]); diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index c18bf7e..52a9b20 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -12,7 +12,7 @@ describe("Middleware Tests", () => { const middlewareManager = MiddlewareManager.getInstance(); const createProfilesSupabaseMock = (result: { - data: { is_temporary: boolean } | null; + data: { is_temporary?: boolean; is_client?: boolean } | null; error: { message: string } | null; }) => ({ from: vi.fn().mockReturnValue({ @@ -342,6 +342,33 @@ describe("Middleware Tests", () => { expect(res.status).toBe(401); expect(data.error).toBe("User is read only"); }); + + it("should return 401 for client users", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createProfilesSupabaseMock({ + data: { is_temporary: false, is_client: true }, + error: null, + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "client-user" } as any); + await next(); + }); + 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(); + const data = await res.json(); + + expect(res.status).toBe(401); + expect(data.error).toBe("User is read only"); + }); }); describe("Billing Checkout Access Middleware", () => { @@ -427,26 +454,6 @@ describe("Middleware Tests", () => { }); }); - 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(); - - expect(res.status).toBe(200); - expect(data.hasStreamClient).toBe(true); - }); - }); - describe("R2 Middleware", () => { it("should inject S3 client into context", async () => { const app = new Hono(); @@ -531,18 +538,14 @@ describe("Middleware Tests", () => { 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, }); }); @@ -554,7 +557,6 @@ describe("Middleware Tests", () => { expect(res.status).toBe(200); expect(data.hasSupabase).toBe(true); - expect(data.hasStreamClient).toBe(true); expect(data.hasStripe).toBe(true); }); @@ -562,7 +564,6 @@ describe("Middleware Tests", () => { 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 diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts new file mode 100644 index 0000000..6b7cb25 --- /dev/null +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -0,0 +1,386 @@ +import { createClient } from "@supabase/supabase-js"; +import { testClient } from "hono/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createConfig } from "../../config.js"; +import { MiddlewareManager } from "../../middlewares/middleware.js"; +import { getMainRouter } from "../../routers/index.js"; +import type { TestUserData } from "../helpers/dbSetup.js"; +import { getTestUser } from "../helpers/dbSetup.js"; + +// Mock nodemailer +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), +})); + +describe("Client Invites Endpoints", () => { + 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; + + const ownerUser = getTestUser("owner"); + const tempUser = getTestUser("temp"); + + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + // The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner") + const adminTabloId = "test_tablo_owner_private"; + + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + }); + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + const postInvite = (user: TestUserData, tabloId: string, email: string) => + client["client-invites"][":tabloId"].$post( + { param: { tabloId }, json: { email } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const getPending = (user: TestUserData, tabloId: string) => + client["client-invites"][":tabloId"].pending.$get( + { param: { tabloId } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const deleteInvite = (user: TestUserData, tabloId: string, inviteId: number) => + client["client-invites"][":tabloId"][":inviteId"].$delete( + { param: { tabloId, inviteId: String(inviteId) } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const acceptInvite = (user: TestUserData, token: string) => + client["client-invites"].accept[":token"].$post( + { param: { token } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + // ─── Helper: insert a client_invite row directly via admin ────────────────── + + const insertClientInvite = async (opts: { + tabloId: string; + invitedEmail: string; + invitedBy: string; + token: string; + isPending?: boolean; + expiresAt?: string; + }) => { + const expiresAt = opts.expiresAt ?? new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(); + + const { data, error } = await supabaseAdmin + .from("client_invites") + .insert({ + tablo_id: opts.tabloId, + invited_email: opts.invitedEmail, + invited_by: opts.invitedBy, + invite_token: opts.token, + is_pending: opts.isPending ?? true, + expires_at: expiresAt, + }) + .select("id") + .single(); + + if (error) throw new Error(`Failed to insert client_invite: ${error.message}`); + return data.id as number; + }; + + // ─── Cleanup helper ────────────────────────────────────────────────────────── + + const cleanupInvitesByEmail = async (email: string) => { + await supabaseAdmin.from("client_invites").delete().eq("invited_email", email); + // Also clean up any client user that may have been created + const { data: usersData } = await supabaseAdmin.auth.admin.listUsers(); + // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime + const users = usersData as any; + // biome-ignore lint/suspicious/noExplicitAny: admin user type + const clientUser = users?.users?.find((u: any) => u.email === email); + if (clientUser) { + await supabaseAdmin.from("tablo_access").delete().eq("user_id", clientUser.id); + await supabaseAdmin.auth.admin.deleteUser(clientUser.id); + } + }; + + // ════════════════════════════════════════════════════════════════════════════ + // POST /:tabloId — Create client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("POST /client-invites/:tabloId", () => { + const testEmail = "test_client_invite_new@example.com"; + + beforeEach(async () => { + await cleanupInvitesByEmail(testEmail); + }); + + it("should create a client invite for a valid email (admin)", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + // Verify row was inserted + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("id, invited_email, is_pending") + .eq("tablo_id", adminTabloId) + .eq("invited_email", testEmail) + .single(); + + expect(invite).toBeDefined(); + expect(invite?.is_pending).toBe(true); + }); + + it("should reject non-admin users with 403", async () => { + // tempUser is NOT admin of adminTabloId (owner user owns it) + const res = await postInvite(tempUser, adminTabloId, testEmail); + expect(res.status).toBe(403); + }); + + it("should return 400 for an invalid email", async () => { + const res = await postInvite(ownerUser, adminTabloId, "not-an-email"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("valid email"); + }); + + it("should return 400 for a missing email", async () => { + const res = client["client-invites"][":tabloId"].$post( + { param: { tabloId: adminTabloId }, json: {} }, + { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } } + ); + expect((await res).status).toBe(400); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"].$post({ + param: { tabloId: adminTabloId }, + json: { email: testEmail }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // POST /accept/:token — Accept a client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("POST /client-invites/accept/:token", () => { + it("should accept an invite and return tabloId", async () => { + const token = `test_accept_valid_${Date.now()}`; + + // Insert invite for the owner user's email + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: ownerUser.email, + invitedBy: ownerUser.userId, + token, + }); + + try { + const res = await acceptInvite(ownerUser, token); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.tabloId).toBe(adminTabloId); + + // Verify invite is now not pending + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("is_pending") + .eq("invite_token", token) + .single(); + expect(invite?.is_pending).toBe(false); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 410 for an expired invite", async () => { + const token = `test_expired_${Date.now()}`; + const pastDate = new Date(Date.now() - 1000).toISOString(); // already expired + + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: ownerUser.email, + invitedBy: ownerUser.userId, + token, + expiresAt: pastDate, + }); + + try { + const res = await acceptInvite(ownerUser, token); + expect(res.status).toBe(410); + const data = await res.json(); + expect(data.error).toContain("expired"); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 403 when email does not match the authenticated user", async () => { + const token = `test_email_mismatch_${Date.now()}`; + + // Invite is for tempUser's email but we authenticate as ownerUser + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: tempUser.email, + invitedBy: ownerUser.userId, + token, + }); + + try { + const res = await acceptInvite(ownerUser, token); // wrong user + expect(res.status).toBe(403); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 404 for a non-existent token", async () => { + const res = await acceptInvite(ownerUser, "nonexistent_token_xyz"); + expect(res.status).toBe(404); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"].accept[":token"].$post({ + param: { token: "some_token" }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // GET /:tabloId/pending — List pending client invites + // ════════════════════════════════════════════════════════════════════════════ + + describe("GET /client-invites/:tabloId/pending", () => { + const pendingEmail = "test_client_pending_list@example.com"; + let insertedId: number; + + beforeEach(async () => { + await cleanupInvitesByEmail(pendingEmail); + insertedId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: pendingEmail, + invitedBy: ownerUser.userId, + token: `test_pending_${Date.now()}`, + }); + }); + + it("should return pending invites for an admin", async () => { + const res = await getPending(ownerUser, adminTabloId); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.invites)).toBe(true); + + const found = data.invites.find((inv: { id: number }) => inv.id === insertedId); + expect(found).toBeDefined(); + expect(found.invited_email).toBe(pendingEmail); + expect(found.is_pending).toBe(true); + }); + + it("should return 403 for a non-admin user", async () => { + const res = await getPending(tempUser, adminTabloId); + expect(res.status).toBe(403); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"].pending.$get({ + param: { tabloId: adminTabloId }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // DELETE /:tabloId/:inviteId — Cancel a client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("DELETE /client-invites/:tabloId/:inviteId", () => { + const cancelEmail = "test_client_cancel@example.com"; + + beforeEach(async () => { + await cleanupInvitesByEmail(cancelEmail); + }); + + it("should cancel a pending invite and revoke client access", async () => { + // First create a client user and tablo_access entry via the API + const token = `test_cancel_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + }); + + // Create a mock profile to revoke (uses admin client to simulate client user existing) + // We'll skip verifying the user's actual auth account since we just need to test cancellation + const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + // Verify invite is now not pending + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("is_pending") + .eq("id", inviteId) + .single(); + expect(invite?.is_pending).toBe(false); + }); + + it("should return 403 for a non-admin user", async () => { + const token = `test_cancel_nonadmin_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + }); + + const res = await deleteInvite(tempUser, adminTabloId, inviteId); + expect(res.status).toBe(403); + }); + + it("should return 404 for a non-existent invite", async () => { + const res = await deleteInvite(ownerUser, adminTabloId, 999999); + expect(res.status).toBe(404); + }); + + it("should return 400 for an already-cancelled invite", async () => { + const token = `test_cancel_already_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + isPending: false, // already cancelled + }); + + const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("pending"); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"][":inviteId"].$delete({ + param: { tabloId: adminTabloId, inviteId: "1" }, + }); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/apps/api/src/__tests__/routes/invite.test.ts b/apps/api/src/__tests__/routes/invite.test.ts index 8fd7f5f..4f8fb8c 100644 --- a/apps/api/src/__tests__/routes/invite.test.ts +++ b/apps/api/src/__tests__/routes/invite.test.ts @@ -1,31 +1,11 @@ import { createClient } from "@supabase/supabase-js"; import { testClient } from "hono/testing"; -import type { Channel, StreamChat } from "stream-chat"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import { getTestUser } from "../helpers/dbSetup.js"; -// Mock the stream-chat module -vi.mock("stream-chat", () => { - const mockChannel = { - create: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), - }; - - const mockStreamChatInstance = { - channel: vi.fn(() => mockChannel), - upsertUser: vi.fn().mockResolvedValue({ users: {} }), - }; - - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstance), - }, - }; -}); - // Mock nodemailer const mockSendMail = vi.fn(); vi.mock("nodemailer", () => ({ @@ -54,16 +34,7 @@ describe("Booking Endpoint", () => { const createdTablos: string[] = []; const createdUsers: string[] = []; - // Get references to the mocked functions for assertions - let mockStreamChat: StreamChat; - let mockChannel: Channel; - beforeAll(async () => { - // Get references to the mocked instances - const { StreamChat } = await import("stream-chat"); - mockStreamChat = StreamChat.getInstance("test_api_key", "test_api_secret"); - mockChannel = mockStreamChat.channel("messaging", "test_channel_id"); - // Get owner's short_user_id const { data: ownerProfile } = await supabase .from("profiles") @@ -324,10 +295,6 @@ describe("Booking Endpoint", () => { createdUsers.push(userProfile.id); } - // Verify Stream Chat channel was created - expect(mockChannel.create).toHaveBeenCalledTimes(1); - expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1); - // Verify emails were sent (3 emails: welcome to new user, one to owner, one to booker) expect(mockSendMail).toHaveBeenCalledTimes(3); @@ -407,10 +374,6 @@ describe("Booking Endpoint", () => { createdTablos.push(data.tablo_id); createdBookings.push(data.tablo_id); - // Verify Stream Chat channel was created - expect(mockChannel.create).toHaveBeenCalledTimes(1); - expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1); - // Verify emails were sent (2 emails: one to owner, one to booker) expect(mockSendMail).toHaveBeenCalledTimes(2); @@ -511,9 +474,6 @@ describe("Booking Endpoint", () => { expect(data2.tablo_id).toBe(firstTabloId); expect(data2.hasCreatedAccount).toBe(false); - // Stream Chat channel should still be created for the second booking - expect(mockChannel.create).toHaveBeenCalledTimes(1); - // Verify emails were sent for second booking (2 emails) expect(mockSendMail).toHaveBeenCalledTimes(2); diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index f8c7c7b..b445c90 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -8,35 +8,6 @@ import { getMainRouter } from "../../routers/index.js"; import type { TestUserData } from "../helpers/dbSetup.js"; import { getTestUser } from "../helpers/dbSetup.js"; -// Mock Stream Chat operations -const mockChannelCreate = vi.fn(); -const mockChannelUpdate = vi.fn(); -const mockChannelDelete = vi.fn(); -const mockChannelRemoveMembers = vi.fn(); -const mockChannelAddMembers = vi.fn(); - -// Mock the channel method to return our mocked channel -const mockChannel = { - create: mockChannelCreate, - update: mockChannelUpdate, - delete: mockChannelDelete, - removeMembers: mockChannelRemoveMembers, - addMembers: mockChannelAddMembers, -}; - -// Mock the stream-chat module -vi.mock("stream-chat", () => { - const mockStreamChatInstance = { - channel: vi.fn(() => mockChannel), - }; - - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstance), - }, - }; -}); - // Mock nodemailer for email sending const mockSendMail = vi.fn(); vi.mock("nodemailer", () => ({ @@ -67,11 +38,6 @@ describe("Tablo Endpoint", () => { beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); - mockChannelCreate.mockResolvedValue(undefined); - mockChannelUpdate.mockResolvedValue(undefined); - mockChannelDelete.mockResolvedValue(undefined); - mockChannelRemoveMembers.mockResolvedValue(undefined); - mockChannelAddMembers.mockResolvedValue(undefined); mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); }); @@ -195,7 +161,7 @@ describe("Tablo Endpoint", () => { await supabaseAdmin.from("profiles").update({ plan: "standard" }).eq("id", ownerUser.userId); }); - it("should allow owner to create a tablo and create a Stream Chat channel", async () => { + it("should allow owner to create a tablo", async () => { const res = await createTabloRequest(ownerUser, client, { name: "New Owner Tablo", status: "todo", @@ -205,11 +171,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo created successfully"); - - // Verify Stream Chat channel was created - expect(mockChannelCreate).toHaveBeenCalledTimes(1); - // Verify it was called (the channel is created with tablo data) - expect(mockChannelCreate).toHaveBeenCalled(); }); it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => { @@ -323,7 +284,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toBe("You have reached your tablo limit"); - expect(mockChannelCreate).not.toHaveBeenCalled(); } finally { await supabaseAdmin .from("profiles") @@ -392,17 +352,13 @@ describe("Tablo Endpoint", () => { }); describe("DELETE /tablos/delete - Delete Tablo", () => { - it("should allow owner with admin access to delete tablo and delete Stream Chat channel", async () => { + it("should allow owner with admin access to delete tablo", async () => { // Owner has admin access to their tablos const res = await deleteTabloRequest(ownerUser, client, "test_tablo_owner_private"); expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo deleted successfully"); - - // Verify Stream Chat channel was deleted - expect(mockChannelDelete).toHaveBeenCalledTimes(1); - expect(mockChannelDelete).toHaveBeenCalled(); }); it("should deny temp user without admin access from deleting tablo", async () => { @@ -558,7 +514,7 @@ describe("Tablo Endpoint", () => { return tabloId; }; - it("should allow temp user to leave a shared tablo and remove from Stream Chat channel", async () => { + it("should allow temp user to leave a shared tablo", async () => { const tabloId = await createSharedTabloForLeaveTest({ ownerId: ownerUser.userId, memberId: temporaryUser.userId, @@ -569,13 +525,9 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo left successfully"); - - // Verify Stream Chat channel removeMembers was called - expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1); - expect(mockChannelRemoveMembers).toHaveBeenCalledWith([temporaryUser.userId]); }); - it("should allow owner to leave a tablo and remove from Stream Chat channel", async () => { + it("should allow owner to leave a tablo", async () => { const tabloId = await createSharedTabloForLeaveTest({ ownerId: temporaryUser.userId, memberId: ownerUser.userId, @@ -587,10 +539,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo left successfully"); - - // Verify Stream Chat channel removeMembers was called - expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1); - expect(mockChannelRemoveMembers).toHaveBeenCalledWith([ownerUser.userId]); }); it("should deny unauthenticated leave request", async () => { diff --git a/apps/api/src/__tests__/routes/tasks.test.ts b/apps/api/src/__tests__/routes/tasks.test.ts index e6d320e..fc1bbd3 100644 --- a/apps/api/src/__tests__/routes/tasks.test.ts +++ b/apps/api/src/__tests__/routes/tasks.test.ts @@ -6,27 +6,6 @@ import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; -// Mock Stream Chat operations -const mockChannelUpdate = vi.fn(); - -// Mock the channel method to return our mocked channel -const mockChannel = { - update: mockChannelUpdate, -}; - -// Mock the stream-chat module -vi.mock("stream-chat", () => { - const mockStreamChatInstance = { - channel: vi.fn(() => mockChannel), - }; - - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstance), - }, - }; -}); - // Create S3 mock for calendar file operations const s3Mock = mockClient(S3Client); @@ -45,9 +24,6 @@ describe("Tasks Endpoint", () => { // Mock PutObjectCommand for calendar file writes s3Mock.on(PutObjectCommand).resolves({}); - - // Mock Stream Chat channel update - mockChannelUpdate.mockResolvedValue(undefined); }); describe("POST /tasks/sync-calendars - Sync Calendar Files", () => { @@ -107,8 +83,8 @@ describe("Tasks Endpoint", () => { }); }); - describe("POST /tasks/sync-tablo-names - Sync Tablo Names to Stream", () => { - it("should call sync tablo names endpoint with basic auth and update Stream Chat channels (returns 200 if TASKS_SECRET properly configured)", async () => { + describe("POST /tasks/sync-tablo-names - Sync Tablo Names", () => { + it("should call sync tablo names endpoint with basic auth (returns 200 if TASKS_SECRET properly configured)", async () => { const res = await client.tasks["sync-tablo-names"].$post( {}, { diff --git a/apps/api/src/__tests__/routes/user.test.ts b/apps/api/src/__tests__/routes/user.test.ts index 0567f9e..753c550 100644 --- a/apps/api/src/__tests__/routes/user.test.ts +++ b/apps/api/src/__tests__/routes/user.test.ts @@ -12,25 +12,6 @@ import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import { getTestUser } from "../helpers/dbSetup.js"; -// Mock Stream Chat operations -const mockUpsertUser = vi.fn(); -const mockCreateToken = vi.fn(); - -// Create an instance object that holds the mocks (like the working pattern in tablo.test.ts) -const mockStreamChatInstanceMethods = { - upsertUser: mockUpsertUser, - createToken: mockCreateToken, -}; - -// Mock the stream-chat module -vi.mock("stream-chat", () => { - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstanceMethods), - }, - }; -}); - // Create S3 mock for avatar operations const s3Mock = mockClient(S3Client); @@ -50,10 +31,6 @@ describe("User Endpoint", () => { vi.clearAllMocks(); s3Mock.reset(); - // Mock Stream Chat operations - mockUpsertUser.mockResolvedValue({ users: { [ownerUser.userId]: {} } }); - mockCreateToken.mockReturnValue("mock-stream-token-123"); - // Mock S3 operations s3Mock.on(PutObjectCommand).resolves({}); s3Mock.on(ListObjectsV2Command).resolves({ @@ -63,7 +40,7 @@ describe("User Endpoint", () => { }); describe("GET /me - Get User Profile", () => { - it("should return owner user profile with stream token", async () => { + it("should return owner user profile", async () => { const res = await client.users.me.$get( {}, { @@ -78,14 +55,9 @@ describe("User Endpoint", () => { const data = await res.json(); expect(data.id).toBe(ownerUser.userId); expect(data.email).toBe(ownerUser.email); - expect(data.streamToken).toBe("mock-stream-token-123"); - - // Verify Stream Chat createToken was called - expect(mockCreateToken).toHaveBeenCalledTimes(1); - expect(mockCreateToken).toHaveBeenCalledWith(ownerUser.userId); }); - it("should return temp user profile with stream token", async () => { + it("should return temp user profile", async () => { const res = await client.users.me.$get( {}, { @@ -100,11 +72,6 @@ describe("User Endpoint", () => { const data = await res.json(); expect(data.id).toBe(temporaryUser.userId); expect(data.email).toBe(temporaryUser.email); - expect(data.streamToken).toBe("mock-stream-token-123"); - - // Verify Stream Chat createToken was called - expect(mockCreateToken).toHaveBeenCalledTimes(1); - expect(mockCreateToken).toHaveBeenCalledWith(temporaryUser.userId); }); it("should deny unauthenticated access", async () => { @@ -114,63 +81,6 @@ describe("User Endpoint", () => { }); }); - describe("POST /sign-up-to-stream - Sign Up User to Stream Chat", () => { - it("should sign up owner user to stream chat", async () => { - const res = await client.users["sign-up-to-stream"].$post( - {}, - { - headers: { - Authorization: `Bearer ${ownerUser.accessToken}`, - "Content-Type": "application/json", - }, - } - ); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.message).toBe("User signed up to stream"); - - // Verify Stream Chat upsertUser was called - expect(mockUpsertUser).toHaveBeenCalledTimes(1); - expect(mockUpsertUser).toHaveBeenCalledWith({ - id: ownerUser.userId, - name: expect.any(String), - language: "fr", - }); - }); - - it("should sign up temp user to stream chat", async () => { - const res = await client.users["sign-up-to-stream"].$post( - {}, - { - headers: { - Authorization: `Bearer ${temporaryUser.accessToken}`, - "Content-Type": "application/json", - }, - } - ); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.message).toBe("User signed up to stream"); - - // Verify Stream Chat upsertUser was called - expect(mockUpsertUser).toHaveBeenCalledTimes(1); - expect(mockUpsertUser).toHaveBeenCalledWith({ - id: temporaryUser.userId, - name: expect.any(String), - language: "fr", - }); - }); - - it("should deny unauthenticated stream signup", async () => { - const res = await client.users["sign-up-to-stream"].$post({}); - - expect(res.status).toBe(401); - expect(mockUpsertUser).not.toHaveBeenCalled(); - }); - }); - describe("POST /profile/avatar - Upload Avatar", () => { it("should upload avatar for owner user", async () => { const res = await client.users.profile.avatar.$post( diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index d7c5b14..38f74f0 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -8,8 +8,6 @@ export interface AppConfig { SUPABASE_SERVICE_ROLE_KEY: string; SUPABASE_CONNECTION_STRING: string; SUPABASE_CA_CERT: string; - STREAM_CHAT_API_KEY: string; - STREAM_CHAT_API_SECRET: string; STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string; STRIPE_SOLO_PRICE_ID: string; @@ -59,8 +57,6 @@ export function createConfig(secrets?: Secrets): AppConfig { const isTestMode = NODE_ENV === "test"; const isStagingMode = NODE_ENV === "staging"; - const getStreamChatApiSecret = (isStagingMode: boolean) => - isStagingMode ? secrets!.streamChatApiSecretStaging : secrets!.streamChatApiSecret; const getStripeSecretKey = (isStagingMode: boolean) => isStagingMode ? secrets!.stripeSecretKeyStaging : secrets!.stripeSecretKey; const getStripeWebhookSecret = (isStagingMode: boolean) => @@ -82,11 +78,6 @@ export function createConfig(secrets?: Secrets): AppConfig { 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), - // Env dependent - STREAM_CHAT_API_SECRET: isTestMode - ? validateEnvVar("STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET) - : getStreamChatApiSecret(isStagingMode), STRIPE_SECRET_KEY: isTestMode ? validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY) : getStripeSecretKeyFromEnv() || getStripeSecretKey(isStagingMode), diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts index 0e043d4..e0e10da 100644 --- a/apps/api/src/helpers/billing.ts +++ b/apps/api/src/helpers/billing.ts @@ -7,6 +7,7 @@ type BillingProfileRow = { id: string; created_at: string | null; is_temporary: boolean | null; + is_client: boolean | null; plan: string | null; }; @@ -87,7 +88,7 @@ export const parseTrialRolloutDate = ( export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null; export const getBillableMemberCount = (profiles: BillingProfileRow[]) => - profiles.filter((profile) => profile.is_temporary !== true).length; + profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length; export const getTrialWindow = (input: { ownerCreatedAt: Date; @@ -179,7 +180,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => { const { data, error } = await supabase .from("profiles") - .select("id, created_at, is_temporary, plan") + .select("id, created_at, is_temporary, is_client, plan") .eq("organization_id", organizationId) .order("created_at", { ascending: true }); diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index eba5984..f559288 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -3,7 +3,6 @@ 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 type { StreamChat } from "stream-chat"; import { generatePassword } from "./token.js"; export const MAX_TABLO_LIMIT = 10; @@ -290,7 +289,6 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => { */ export const createInvitedUser = async ( supabase: SupabaseClient, - streamServerClient: StreamChat, transporter: Transporter, recipientEmail: string, senderEmail: string, @@ -334,12 +332,6 @@ export const createInvitedUser = async ( return { success: false, error: updateProfileError.message }; } - await streamServerClient.upsertUser({ - id: newUser.user.id, - name: recipientEmail.split("@")[0], - language: "fr", - }); - // Send welcome email to the new user await transporter.sendMail({ from: `${senderEmail} via XTablo `, @@ -371,3 +363,68 @@ export const createInvitedUser = async ( return { success: true, userId: newUser.user.id }; }; + +/** + * Creates or finds a client user, marks them as is_client, and grants tablo access. + */ +export async function createClientUser( + supabase: SupabaseClient, + recipientEmail: string, + tabloId: string, + grantedBy: string +): Promise<{ success: boolean; error?: string; userId?: string }> { + // Check if user already exists + const { data: existingUsersData } = await supabase.auth.admin.listUsers(); + // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime + const existingUsers = existingUsersData as any; + const existingUser = existingUsers?.users?.find( + // biome-ignore lint/suspicious/noExplicitAny: admin user type + (u: any) => u.email?.toLowerCase() === recipientEmail.toLowerCase() + ); + + let userId: string; + + if (existingUser) { + userId = existingUser.id; + // Mark as client if not already + await supabase + .from("profiles") + .update({ is_client: true }) + .eq("id", userId) + .eq("is_client", false); + } else { + // Create new auth user (no password — magic link only) + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email: recipientEmail, + email_confirm: true, + user_metadata: { role: "client" }, + }); + if (authError || !authData?.user) { + return { success: false, error: authError?.message ?? "Failed to create user" }; + } + userId = authData.user.id; + await supabase.from("profiles").update({ is_client: true }).eq("id", userId); + } + + // Grant tablo access if not already granted + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", tabloId) + .eq("user_id", userId) + .single(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: userId, + granted_by: grantedBy, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id); + } + + return { success: true, userId }; +} diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 989e670..7c153e6 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -4,7 +4,6 @@ import { createClient, type SupabaseClient, type User } from "@supabase/supabase import type { Context, MiddlewareHandler, Next } from "hono"; import { createMiddleware } from "hono/factory"; import type { Transporter } from "nodemailer"; -import { StreamChat } from "stream-chat"; import { Stripe } from "stripe"; import { type AppConfig } from "../config.js"; import { authenticateFromHeader } from "../helpers/auth.js"; @@ -25,9 +24,6 @@ export type Middlewares = { Variables: { supabase: SupabaseClient; user: User }; Bindings: { user: User }; }>; - streamChatMiddleware: MiddlewareHandler<{ - Variables: { streamServerClient: StreamChat }; - }>; r2Middleware: MiddlewareHandler<{ Variables: { s3_client: S3Client }; }>; @@ -88,7 +84,7 @@ export class MiddlewareManager { const { data: profile, error } = await supabase .from("profiles") - .select("is_temporary") + .select("is_temporary, is_client") .eq("id", user.id) .single(); @@ -96,7 +92,7 @@ export class MiddlewareManager { return c.json({ error: error?.message ?? "Profile not found" }, 500); } - if (!allowTemporaryUsers && profile.is_temporary) { + if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) { return c.json({ error: "User is read only" }, 401); } @@ -168,15 +164,6 @@ export class MiddlewareManager { await next(); }); - const streamChatMiddleware = createMiddleware(async (c: Context, next: Next) => { - const serverClient = StreamChat.getInstance( - config.STREAM_CHAT_API_KEY, - config.STREAM_CHAT_API_SECRET - ); - c.set("streamServerClient", serverClient); - await next(); - }); - const r2Middleware = createMiddleware(async (c: Context, next: Next) => { const s3 = new S3Client({ region: "auto", @@ -255,7 +242,6 @@ export class MiddlewareManager { basicAuthMiddleware, authMiddleware, maybeAuthenticatedMiddleware, - streamChatMiddleware, r2Middleware, regularUserCheckMiddleware, billingCheckoutAccessMiddleware, @@ -282,10 +268,6 @@ export class MiddlewareManager { return this.middlewares.maybeAuthenticatedMiddleware; } - get streamChat() { - return this.middlewares.streamChatMiddleware; - } - get r2() { return this.middlewares.r2Middleware; } diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts index 41f2c53..4c308b8 100644 --- a/apps/api/src/routers/authRouter.ts +++ b/apps/api/src/routers/authRouter.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { AppConfig } from "../config.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; +import { getClientInvitesRouter } from "./clientInvites.js"; import { getNotesRouter } from "./notes.js"; import { getStripeRouter } from "./stripe.js"; import { getTabloRouter } from "./tablo.js"; @@ -19,6 +20,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => { authRouter.route("/tablos", getTabloRouter(config)); authRouter.route("/tablo-data", getTabloDataRouter()); authRouter.route("/notes", getNotesRouter()); + authRouter.route("/client-invites", getClientInvitesRouter()); // stripe routes authRouter.route("/stripe", getStripeRouter(config)); diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts new file mode 100644 index 0000000..ba91a15 --- /dev/null +++ b/apps/api/src/routers/clientInvites.ts @@ -0,0 +1,241 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import { checkTabloAdmin, createClientUser } from "../helpers/helpers.js"; +import { generateToken } from "../helpers/token.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { AuthEnv } from "../types/app.types.js"; + +const factory = createFactory(); + +const CLIENT_INVITE_EXPIRY_HOURS = 72; + +/** POST /:tabloId — Create a client invite (admin only) */ +const createClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const body = await c.req.json(); + const rawEmail = String(body.email || "") + .trim() + .toLowerCase(); + + if (!rawEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail)) { + return c.json({ error: "A valid email is required" }, 400); + } + + // Create / find the client user and grant tablo access + const result = await createClientUser(supabase, rawEmail, tabloId, user.id); + if (!result.success || !result.userId) { + return c.json({ error: result.error ?? "Failed to create client user" }, 500); + } + + const token = generateToken(); + const expiresAt = new Date( + Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000 + ).toISOString(); + + const { error: insertError } = await supabase.from("client_invites").insert({ + tablo_id: tabloId, + invited_email: rawEmail, + invited_by: user.id, + invite_token: token, + is_pending: true, + expires_at: expiresAt, + }); + + if (insertError) { + if (insertError.code === "23505") { + return c.json({ error: "A pending invite already exists for this email and tablo" }, 409); + } + return c.json({ error: insertError.message }, 500); + } + + // Generate a Supabase magic link that redirects to the client portal callback + const clientsUrl = process.env.CLIENTS_URL || "https://clients.xtablo.com"; + const redirectTo = `${clientsUrl}/auth/callback?token=${encodeURIComponent(token)}`; + + const { data: linkData, error: magicLinkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", + email: rawEmail, + options: { redirectTo }, + }); + + if (magicLinkError) { + console.error("Failed to generate magic link:", magicLinkError); + // Non-fatal: invite record is already created + } else if (linkData?.properties?.action_link) { + const transporter = c.get("transporter"); + try { + await transporter.sendMail({ + from: "Xtablo ", + to: rawEmail, + subject: "Vous avez été invité sur Xtablo", + html: ` +

Vous avez été invité à collaborer sur un tablo

+

Bonjour,

+

Cliquez sur le lien ci-dessous pour accéder à votre espace client :

+

Accéder à mon espace

+

Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures.

+ `, + }); + } catch (emailError) { + console.error("Failed to send client invite email:", emailError); + } + } + + return c.json({ success: true }); + }); + +/** POST /accept/:token — Accept a client invite */ +const acceptClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const token = c.req.param("token"); + + const { data: invite, error: inviteError } = await supabase + .from("client_invites") + .select("id, tablo_id, invited_email, invited_by, is_pending, expires_at") + .eq("invite_token", token) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite || !invite.is_pending) { + return c.json({ error: "Invite not found or already used" }, 404); + } + + // Check expiration + if (invite.expires_at && new Date(invite.expires_at) < new Date()) { + return c.json({ error: "This invite has expired" }, 410); + } + + // Email must match the authenticated user + if (invite.invited_email?.toLowerCase() !== user.email?.toLowerCase()) { + return c.json({ error: "This invite was not issued to your account" }, 403); + } + + // Mark invite as accepted + await supabase.from("client_invites").update({ is_pending: false }).eq("id", invite.id); + + // Ensure tablo access is active + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", invite.tablo_id) + .eq("user_id", user.id) + .maybeSingle(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: invite.tablo_id, + user_id: user.id, + granted_by: invite.invited_by, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id); + } + + return c.json({ success: true, tabloId: invite.tablo_id }); + }); + +/** GET /:tabloId/pending — List pending client invites (admin only) */ +const getPendingClientInvites = ( + middlewareManager: ReturnType +) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data: invites, error } = await supabase + .from("client_invites") + .select("id, invited_email, expires_at, is_pending, created_at") + .eq("tablo_id", tabloId) + .eq("is_pending", true) + .order("created_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ invites: invites ?? [] }); + }); + +/** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */ +const cancelClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const inviteId = Number(c.req.param("inviteId")); + + if (!Number.isInteger(inviteId) || inviteId <= 0) { + return c.json({ error: "Invalid invite id" }, 400); + } + + const { data: invite, error: inviteError } = await supabase + .from("client_invites") + .select("id, invited_email, is_pending") + .eq("id", inviteId) + .eq("tablo_id", tabloId) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite) { + return c.json({ error: "Invite not found" }, 404); + } + + if (!invite.is_pending) { + return c.json({ error: "Invite is no longer pending" }, 400); + } + + // Mark invite as cancelled + const { error: cancelError } = await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", inviteId) + .eq("tablo_id", tabloId); + + if (cancelError) { + return c.json({ error: cancelError.message }, 500); + } + + // Revoke tablo access for the client user + if (invite.invited_email) { + const { data: clientProfile } = await supabase + .from("profiles") + .select("id") + .eq("email", invite.invited_email) + .maybeSingle(); + + if (clientProfile?.id) { + await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", clientProfile.id); + } + } + + return c.json({ success: true }); + }); + +export const getClientInvitesRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.post("/:tabloId", ...createClientInvite(middlewareManager)); + router.post("/accept/:token", ...acceptClientInvite(middlewareManager)); + router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager)); + router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager)); + + return router; +}; diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts index ab4d2a8..1ca996e 100644 --- a/apps/api/src/routers/index.ts +++ b/apps/api/src/routers/index.ts @@ -17,7 +17,6 @@ export const getMainRouter = (config: AppConfig) => { mainRouter.use(middlewareManager.supabase); // Apply remaining middlewares after public routes - mainRouter.use(middlewareManager.streamChat); mainRouter.use(middlewareManager.r2); mainRouter.use(middlewareManager.transporter); mainRouter.use(middlewareManager.stripe); diff --git a/apps/api/src/routers/invite.ts b/apps/api/src/routers/invite.ts index b48c440..b2b47b2 100644 --- a/apps/api/src/routers/invite.ts +++ b/apps/api/src/routers/invite.ts @@ -9,7 +9,6 @@ const factory = createFactory(); 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"); @@ -55,7 +54,6 @@ const bookSlot = factory.createHandlers(async (c) => { // Create a temporary user for the booking const result = await createInvitedUser( supabase, - streamServerClient, transporter, data.user_details.email, ownerData.email, @@ -220,28 +218,6 @@ const bookSlot = factory.createHandlers(async (c) => { return c.json({ error: tabloAccessError.message }, 500); } - // Create Stream chat channel with the owner as creator - const { data: organizationMembers, error: organizationMembersError } = await supabase - .from("profiles") - .select("id") - .eq("organization_id", ownerOrganizationId); - - if (organizationMembersError) { - return c.json({ error: "Failed to load organization members" }, 500); - } - - const channelMembers = Array.from( - new Set((organizationMembers || []).map((member) => member.id).concat(bookerUserDataTyped.id)) - ); - - const channel = streamServerClient.channel("messaging", tabloData.id, { - // @ts-ignore - name: tabloData.name, - created_by_id: ownerId, - members: channelMembers, - }); - await channel.create(); - const newEvent: TablesInsert<"events"> = { description: eventTypeConfig.description || "", end_time: data.event_details.end_time || "", @@ -258,12 +234,6 @@ const bookSlot = factory.createHandlers(async (c) => { 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({ diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 43f6ace..5ce02b7 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -18,83 +18,6 @@ type PostTablo = Omit & { const factory = createFactory(); -const isAlreadyMemberError = (error: unknown): boolean => { - if (!error) return false; - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - return ( - message.includes("already a member") || - message.includes("already member") || - message.includes("member already exists") - ); -}; - -const upsertStreamUserFromProfile = async ( - supabase: AuthEnv["Variables"]["supabase"], - streamServerClient: AuthEnv["Variables"]["streamServerClient"], - userId: string -) => { - const { data: profile } = await supabase - .from("profiles") - .select("name") - .eq("id", userId) - .maybeSingle(); - - await streamServerClient.upsertUser({ - id: userId, - name: profile?.name ?? "", - language: "fr", - }); -}; - -const ensureTabloChannelMember = async ( - supabase: AuthEnv["Variables"]["supabase"], - streamServerClient: AuthEnv["Variables"]["streamServerClient"], - tabloId: string, - userId: string -) => { - const channel = streamServerClient.channel("messaging", tabloId); - - try { - await channel.addMembers([userId]); - return; - } catch (error) { - if (isAlreadyMemberError(error)) { - return; - } - } - - const { data: tablo } = await supabase - .from("tablos") - .select("name, owner_id") - .eq("id", tabloId) - .maybeSingle(); - - const { data: accessRows } = await supabase - .from("tablo_access") - .select("user_id") - .eq("tablo_id", tabloId) - .eq("is_active", true); - - const members = Array.from(new Set((accessRows || []).map((row) => row.user_id).concat(userId))); - - const channelToCreate = streamServerClient.channel("messaging", tabloId, { - // @ts-ignore - name: tablo?.name ?? "Tablo", - created_by_id: tablo?.owner_id ?? userId, - members, - }); - - try { - await channelToCreate.create(); - } catch (error) { - if (isAlreadyMemberError(error)) { - return; - } - - await channel.addMembers([userId]); - } -}; - const createTablo = (middlewareManager: ReturnType) => factory.createHandlers( middlewareManager.regularUserCheck, @@ -134,28 +57,6 @@ const createTablo = (middlewareManager: ReturnType; - const { data: organizationMembers, error: membersError } = await supabase - .from("profiles") - .select("id") - .eq("organization_id", profile.organization_id); - - if (membersError) { - return c.json({ error: "Failed to load organization members" }, 500); - } - - const channelMembers = Array.from( - new Set((organizationMembers || []).map((member) => member.id).concat(user.id)) - ); - - const streamServerClient = c.get("streamServerClient"); - const channel = streamServerClient.channel("messaging", tabloData.id, { - // @ts-ignore - name: tabloData.name, - created_by_id: user.id, - members: channelMembers, - }); - await channel.create(); - if (typedPayload.events) { const eventsToInsert = typedPayload.events.map((event) => ({ ...event, @@ -173,7 +74,6 @@ const updateTablo = (middlewareManager: ReturnType { 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; @@ -190,7 +90,7 @@ const updateTablo = (middlewareManager: ReturnType; - const isUpdatingName = tablo.name !== undefined; - - 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; @@ -270,13 +154,6 @@ const deleteTablo = factory.createHandlers(async (c) => { 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" }); }); @@ -288,7 +165,6 @@ const inviteToTablo = ( 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(); @@ -355,7 +231,6 @@ const inviteToTablo = ( // Create a new invited user and add them to the tablo const result = await createInvitedUser( supabase, - streamServerClient, transporter, recipientEmail, sender.email, @@ -381,13 +256,6 @@ const inviteToTablo = ( return c.json({ error: tabloAccessError.message }, 500); } - try { - await ensureTabloChannelMember(supabase, streamServerClient, tabloId, result.userId); - } catch (streamError) { - console.error("error adding temporary invited user to channel", streamError); - return c.json({ error: "Failed to sync chat access for invited user" }, 500); - } - return c.json({ message: "User created and invite sent successfully", }); @@ -438,7 +306,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const tabloId = c.req.param("tabloId"); const inviteId = Number(c.req.param("inviteId")); @@ -513,13 +380,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const inviteId = Number(c.req.param("inviteId")); if (!Number.isInteger(inviteId) || inviteId <= 0) { @@ -598,13 +457,6 @@ const acceptInviteById = (middlewareManager: ReturnType { const joiner = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const { data: inviteData, error } = await supabase .from("tablo_invites") @@ -657,13 +501,6 @@ const joinTablo = factory.createHandlers(async (c) => { const { id: invite_id, tablo_id, invited_by } = inviteData; - try { - await upsertStreamUserFromProfile(supabase, streamServerClient, joiner.id); - } catch (error) { - console.error("error upserting joining user to stream", error); - return c.json({ error: "Failed to provision chat user" }, 500); - } - const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ tablo_id, user_id: joiner.id, @@ -686,13 +523,6 @@ const joinTablo = factory.createHandlers(async (c) => { // Mark invite as accepted instead of deleting (maintains audit trail) await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id); - try { - await ensureTabloChannelMember(supabase, streamServerClient, tablo_id, joiner.id); - } catch (error) { - console.error("error adding member to channel", error); - return c.json({ error: "Failed to sync chat access for this tablo" }, 500); - } - return c.json({ tablo_id }); }); @@ -748,12 +578,8 @@ const getTabloMembers = factory.createHandlers(async (c) => { 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 }) @@ -872,7 +698,6 @@ export const getTabloRouter = (config: AppConfig) => { tabloRouter.use(middlewareManager.supabase); tabloRouter.use(middlewareManager.auth); - tabloRouter.use(middlewareManager.streamChat); tabloRouter.post("/create", ...createTablo(middlewareManager)); tabloRouter.patch("/update", ...updateTablo(middlewareManager)); diff --git a/apps/api/src/routers/tasks.ts b/apps/api/src/routers/tasks.ts index 428f313..fb17794 100644 --- a/apps/api/src/routers/tasks.ts +++ b/apps/api/src/routers/tasks.ts @@ -39,7 +39,6 @@ const syncCalendars = factory.createHandlers(async (c) => { const syncTabloNames = factory.createHandlers(async (c) => { const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const fifteenMinutesInMilliseconds = 1000 * 60 * 15; @@ -54,18 +53,6 @@ const syncTabloNames = factory.createHandlers(async (c) => { 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` }); }); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 7616c79..f5cdde4 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -11,30 +11,9 @@ const factory = createFactory(); const isMissingRelationError = (code: string | undefined) => code === "42P01" || code === "PGRST205"; -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(); @@ -60,14 +39,7 @@ const getMe = factory.createHandlers(async (c) => { effectivePlan = organizationPlan; } - const user_id = data.id; - const token = streamServerClient.createToken(user_id); - - return c.json({ - ...userData, - plan: effectivePlan, - streamToken: token, - }); + return c.json({ ...userData, plan: effectivePlan }); }); const markTemporary = factory.createHandlers(async (c) => { @@ -515,7 +487,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); const transporter = c.get("transporter"); - const streamServerClient = c.get("streamServerClient"); const body = await c.req.json(); const rawEmail = typeof body?.email === "string" ? body.email : ""; const recipientEmail = rawEmail.trim().toLowerCase(); @@ -613,7 +584,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const invitedUser = await createInvitedUser( supabase, - streamServerClient, transporter, recipientEmail, senderProfile.email, @@ -673,15 +643,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { } } - for (const tablo of organizationTablos || []) { - const channel = streamServerClient.channel("messaging", tablo.id); - try { - await channel.addMembers([invitedUser.userId]); - } catch (error) { - console.error("Failed to add invited user to Stream channel:", error); - } - } - if (oldOrganizationId && oldOrganizationId !== organizationId) { const { count: oldOrgMembersCount } = await supabase .from("profiles") @@ -717,7 +678,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const removeOrganizationMember = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const memberId = c.req.param("memberId"); if (!memberId) { @@ -826,14 +786,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => { return c.json({ error: "Failed to revoke member tablo permissions" }, 500); } - for (const tabloId of tabloIds) { - try { - const channel = streamServerClient.channel("messaging", tabloId); - await channel.removeMembers([memberId]); - } catch (error) { - console.error("Failed to remove organization member from Stream channel:", error); - } - } } const { error: inviteCleanupError } = await supabase @@ -852,7 +804,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => { 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); diff --git a/apps/api/src/secrets.ts b/apps/api/src/secrets.ts index 4a69b8e..2126133 100644 --- a/apps/api/src/secrets.ts +++ b/apps/api/src/secrets.ts @@ -26,11 +26,9 @@ export type Secrets = { r2AccessKeyId: string; r2SecretAccessKey: string; // Env dependent - streamChatApiSecret: string; stripeSecretKey: string; stripeWebhookSecret: string; // Staging - streamChatApiSecretStaging: string; stripeSecretKeyStaging: string; stripeWebhookSecretStaging: string; }; @@ -50,11 +48,9 @@ export async function loadSecrets(): Promise { r2SecretAccessKey: await fetchSecret("r2-secret-access-key"), // Env dependent // Staging - streamChatApiSecretStaging: await fetchSecret("stream-chat-api-secret-staging"), stripeSecretKeyStaging: await fetchSecret("stripe-secret-key-staging"), stripeWebhookSecretStaging: await fetchSecret("stripe-webhook-secret-staging"), // Production - streamChatApiSecret: await fetchSecret("stream-chat-api-secret"), stripeSecretKey: await fetchSecret("stripe-secret-key"), stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"), }; diff --git a/apps/api/src/types/app.types.ts b/apps/api/src/types/app.types.ts index f895966..1f14da7 100644 --- a/apps/api/src/types/app.types.ts +++ b/apps/api/src/types/app.types.ts @@ -3,7 +3,6 @@ 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"; /** @@ -12,7 +11,6 @@ import type Stripe from "stripe"; export type BaseEnv = { Variables: { supabase: SupabaseClient; - streamServerClient: StreamChat; s3_client: S3Client; transporter: Transporter; stripe: Stripe; diff --git a/apps/chat-worker/package.json b/apps/chat-worker/package.json new file mode 100644 index 0000000..d0fefce --- /dev/null +++ b/apps/chat-worker/package.json @@ -0,0 +1,20 @@ +{ + "name": "@xtablo/chat-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.7", + "jose": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250410.0", + "typescript": "^5.8.3", + "wrangler": "^4.14.0" + } +} diff --git a/apps/chat-worker/src/durable-objects/ChatRoom.ts b/apps/chat-worker/src/durable-objects/ChatRoom.ts new file mode 100644 index 0000000..95e4e7f --- /dev/null +++ b/apps/chat-worker/src/durable-objects/ChatRoom.ts @@ -0,0 +1,166 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env, ClientMessage, ServerMessage } from "../lib/types"; +import { PostgREST } from "../lib/supabase"; + +export class ChatRoom extends DurableObject { + private postgrest: PostgREST | null = null; + + private getPostgREST(): PostgREST { + if (!this.postgrest) { + this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY); + } + return this.postgrest; + } + + /** + * Handle incoming fetch requests — WebSocket upgrades are forwarded here by the Worker. + * userId and channelId are passed via custom headers set by the Worker. + */ + async fetch(request: Request): Promise { + const userId = request.headers.get("X-User-Id"); + const channelId = request.headers.get("X-Channel-Id"); + + if (!userId || !channelId) { + return new Response("Missing user or channel identity", { status: 400 }); + } + + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + const stored = await this.ctx.storage.get("channelId"); + if (!stored) { + await this.ctx.storage.put("channelId", channelId); + } + + this.ctx.acceptWebSocket(server, [userId]); + + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (!userId) { + ws.close(4001, "Missing user identity"); + return; + } + + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" }); + return; + } + + switch (msg.type) { + case "message.send": + await this.handleSendMessage(ws, userId, msg.text, msg.clientId); + break; + case "typing.start": + this.broadcast({ type: "typing", userId, isTyping: true }, ws); + break; + case "typing.stop": + this.broadcast({ type: "typing", userId, isTyping: false }, ws); + break; + case "presence.ping": + break; + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (userId) { + const remaining = this.ctx.getWebSockets(userId); + if (remaining.length === 0) { + this.broadcast({ type: "presence.update", userId, status: "offline" }); + } + } + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + console.error("WebSocket error:", error); + ws.close(1011, "Internal error"); + } + + private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise { + if (!text || text.trim().length === 0) { + this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" }); + return; + } + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const channelId = await this.getChannelId(); + + const serverMsg: ServerMessage = { + type: "message.new", + id, + userId, + text: text.trim(), + createdAt, + clientId, + }; + + this.broadcast(serverMsg); + this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt)); + } + + private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise { + const db = this.getPostgREST(); + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await db.insert("messages", { + id, + channel_id: channelId, + user_id: userId, + text, + created_at: createdAt, + }); + return; + } catch (error) { + console.error(`Message persist attempt ${attempt + 1} failed:`, error); + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); + } + } + } + console.error(`Failed to persist message ${id} after ${maxRetries} attempts`); + } + + private async getChannelId(): Promise { + const channelId = await this.ctx.storage.get("channelId"); + if (!channelId) throw new Error("channelId not stored in DO"); + return channelId; + } + + private sendTo(ws: WebSocket, msg: ServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection already closed + } + } + + private broadcast(msg: ServerMessage, exclude?: WebSocket): void { + const payload = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets()) { + if (ws !== exclude) { + try { + ws.send(payload); + } catch { + // Connection already closed + } + } + } + } +} diff --git a/apps/chat-worker/src/index.ts b/apps/chat-worker/src/index.ts new file mode 100644 index 0000000..5eb2a65 --- /dev/null +++ b/apps/chat-worker/src/index.ts @@ -0,0 +1,165 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { ChatRoom } from "./durable-objects/ChatRoom"; +import { extractToken, verifyJwt } from "./lib/auth"; +import { PostgREST } from "./lib/supabase"; +import type { Env, ChatMessage, UnreadCount } from "./lib/types"; + +// Re-export DO class for wrangler +export { ChatRoom }; + +const app = new Hono<{ Bindings: Env }>(); + +// CORS — allow the main app origins +app.use("*", cors({ + origin: [ + "http://localhost:5173", + "https://app.xtablo.com", + "https://app-staging.xtablo.com", + ], + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], +})); + +// Auth middleware — extract and verify JWT for all routes +// For WebSocket upgrades, the token comes via query param (?token=...) since browsers +// cannot send custom headers on WebSocket connections. +// For REST requests, the token comes via the Authorization header. +app.use("*", async (c, next) => { + const isWebSocket = c.req.header("Upgrade") === "websocket"; + const token = isWebSocket + ? new URL(c.req.url).searchParams.get("token") + : extractToken(c.req.header("Authorization")); + + if (!token) { + return c.json({ error: "Missing authorization" }, 401); + } + try { + const auth = await verifyJwt(token, c.env.JWT_SECRET); + c.set("userId" as never, auth.userId); + } catch (error) { + return c.json({ error: "Invalid token" }, 401); + } + await next(); +}); + +// Helper: check tablo membership via PostgREST +async function checkMembership(db: PostgREST, channelId: string, userId: string): Promise { + const rows = await db.select<{ user_id: string }>( + "tablo_access", + `tablo_id=eq.${channelId}&user_id=eq.${userId}&is_active=eq.true&select=user_id&limit=1` + ); + return rows.length > 0; +} + +// WebSocket upgrade — route to Durable Object +app.get("/chat/ws/:channelId", async (c) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader !== "websocket") { + return c.json({ error: "Expected WebSocket upgrade" }, 426); + } + + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const id = c.env.CHAT_ROOM.idFromName(channelId); + const stub = c.env.CHAT_ROOM.get(id); + + // Forward the WebSocket upgrade via fetch — DO RPC doesn't support WebSocket upgrades. + // Pass userId and channelId via headers so the DO can read them. + const url = new URL(c.req.url); + const doRequest = new Request(url.toString(), c.req.raw); + doRequest.headers.set("X-User-Id", userId); + doRequest.headers.set("X-Channel-Id", channelId); + return stub.fetch(doRequest); +}); + +// GET message history — paginated +app.get("/chat/channels/:channelId/messages", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const before = c.req.query("before"); + const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 100); + + let query = `channel_id=eq.${channelId}&deleted_at=is.null&select=id,channel_id,user_id,text,created_at&order=created_at.desc&limit=${limit}`; + if (before) { + query += `&created_at=lt.${before}`; + } + + const messages = await db.select( + "messages", + query + ); + + return c.json({ messages: messages.reverse(), hasMore: messages.length === limit }); +}); + +// POST mark channel as read +app.post("/chat/channels/:channelId/read", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + await db.upsert("channel_read_state", { + user_id: userId, + channel_id: channelId, + last_read_at: new Date().toISOString(), + }, "user_id,channel_id"); + + return c.json({ ok: true }); +}); + +// GET unread counts for current user across all channels +app.get("/chat/unread", async (c) => { + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + // Get all tablos the user has access to + const accessRows = await db.select<{ tablo_id: string }>( + "tablo_access", + `user_id=eq.${userId}&is_active=eq.true&select=tablo_id` + ); + + if (accessRows.length === 0) { + return c.json({ unread: [] }); + } + + // For each channel, get unread count + const unread: UnreadCount[] = []; + + for (const { tablo_id } of accessRows) { + // Get last read time + const readState = await db.select<{ last_read_at: string }>( + "channel_read_state", + `user_id=eq.${userId}&channel_id=eq.${tablo_id}&select=last_read_at&limit=1` + ); + + const lastReadAt = readState[0]?.last_read_at ?? "1970-01-01T00:00:00Z"; + + const count = await db.count( + "messages", + `channel_id=eq.${tablo_id}&deleted_at=is.null&created_at=gt.${lastReadAt}` + ); + + if (count > 0) { + unread.push({ channel_id: tablo_id, unread_count: count }); + } + } + + return c.json({ unread }); +}); + +export default app; diff --git a/apps/chat-worker/src/lib/auth.ts b/apps/chat-worker/src/lib/auth.ts new file mode 100644 index 0000000..62ef91e --- /dev/null +++ b/apps/chat-worker/src/lib/auth.ts @@ -0,0 +1,34 @@ +import { jwtVerify } from "jose"; + +interface AuthResult { + userId: string; + email: string | null; +} + +/** + * Verify a Supabase JWT and extract the user ID. + * Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim. + */ +export async function verifyJwt(token: string, jwtSecret: string): Promise { + const secret = new TextEncoder().encode(jwtSecret); + const { payload } = await jwtVerify(token, secret, { + issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1", + }); + + if (!payload.sub) { + throw new Error("Missing sub claim in JWT"); + } + + return { + userId: payload.sub, + email: (payload.email as string) ?? null, + }; +} + +/** + * Extract Bearer token from Authorization header. + */ +export function extractToken(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith("Bearer ")) return null; + return authHeader.slice(7); +} diff --git a/apps/chat-worker/src/lib/supabase.ts b/apps/chat-worker/src/lib/supabase.ts new file mode 100644 index 0000000..f572bd0 --- /dev/null +++ b/apps/chat-worker/src/lib/supabase.ts @@ -0,0 +1,82 @@ +/** + * Thin PostgREST client using fetch — no Supabase SDK dependency. + * Used by both the Worker (history queries) and the Durable Object (message persistence). + */ +export class PostgREST { + private baseUrl: string; + private serviceRoleKey: string; + + constructor(supabaseUrl: string, serviceRoleKey: string) { + this.baseUrl = `${supabaseUrl}/rest/v1`; + this.serviceRoleKey = serviceRoleKey; + } + + private headers(): Record { + return { + "apikey": this.serviceRoleKey, + "Authorization": `Bearer ${this.serviceRoleKey}`, + "Content-Type": "application/json", + "Prefer": "return=representation", + }; + } + + /** Insert a row and return the inserted data. */ + async insert(table: string, data: Record): Promise { + const res = await fetch(`${this.baseUrl}/${table}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST insert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Upsert a row (requires Prefer: resolution=merge-duplicates). */ + async upsert(table: string, data: Record, onConflict: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "return=representation,resolution=merge-duplicates"; + const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST upsert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select rows with PostgREST query string. */ + async select(table: string, query: string): Promise { + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "GET", + headers: this.headers(), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST select failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select with exact count header for unread queries. */ + async count(table: string, query: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "count=exact"; + headers["Range-Unit"] = "items"; + headers["Range"] = "0-0"; + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "HEAD", + headers, + }); + const contentRange = res.headers.get("Content-Range"); + if (!contentRange) return 0; + // Content-Range format: "0-0/42" or "*/0" + const total = contentRange.split("/")[1]; + return total === "*" ? 0 : parseInt(total, 10); + } +} diff --git a/apps/chat-worker/src/lib/types.ts b/apps/chat-worker/src/lib/types.ts new file mode 100644 index 0000000..882f57e --- /dev/null +++ b/apps/chat-worker/src/lib/types.ts @@ -0,0 +1,37 @@ +// WebSocket message types — client to server +export type ClientMessage = + | { type: "message.send"; text: string; clientId: string } + | { type: "typing.start" } + | { type: "typing.stop" } + | { type: "presence.ping" }; + +// WebSocket message types — server to client +export type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +// REST API types +export interface ChatMessage { + id: string; + channel_id: string; + user_id: string; + text: string; + created_at: string; + updated_at: string | null; + deleted_at: string | null; +} + +export interface UnreadCount { + channel_id: string; + unread_count: number; +} + +// Worker environment bindings +export interface Env { + CHAT_ROOM: DurableObjectNamespace; + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + JWT_SECRET: string; +} diff --git a/apps/chat-worker/tsconfig.json b/apps/chat-worker/tsconfig.json new file mode 100644 index 0000000..a4473e3 --- /dev/null +++ b/apps/chat-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/apps/chat-worker/turbo.json b/apps/chat-worker/turbo.json new file mode 100644 index 0000000..1188f54 --- /dev/null +++ b/apps/chat-worker/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "deploy": { + "passThroughEnv": [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_TOKEN" + ], + "cache": false, + "outputLogs": "new-only" + } + } +} diff --git a/apps/chat-worker/wrangler.toml b/apps/chat-worker/wrangler.toml new file mode 100644 index 0000000..8e63f10 --- /dev/null +++ b/apps/chat-worker/wrangler.toml @@ -0,0 +1,26 @@ +name = "xtablo-chat" +main = "src/index.ts" +compatibility_date = "2025-07-09" + +[durable_objects] +bindings = [ + { name = "CHAT_ROOM", class_name = "ChatRoom" } +] + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["ChatRoom"] + +[observability] +enabled = true + +[vars] +SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co" + +# Secrets (set via `wrangler secret put`): +# SUPABASE_SERVICE_ROLE_KEY +# JWT_SECRET + +[[routes]] +pattern = "chat.xtablo.com" +custom_domain = true diff --git a/apps/clients/biome.json b/apps/clients/biome.json new file mode 100644 index 0000000..fa8b1ab --- /dev/null +++ b/apps/clients/biome.json @@ -0,0 +1,299 @@ +{ + "root": false, + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "files": { + "ignoreUnknown": true, + "includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"] + }, + "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": [ + "onanimationend", + "ongamepadconnected", + "onlostpointercapture", + "onanimationiteration", + "onkeyup", + "onmousedown", + "onanimationstart", + "onslotchange", + "onprogress", + "ontransitionstart", + "onpause", + "onended", + "onpointerover", + "onscrollend", + "onformdata", + "ontransitionrun", + "onanimationcancel", + "ondrag", + "onchange", + "onbeforeinstallprompt", + "onbeforexrselect", + "onmessage", + "ontransitioncancel", + "onpointerdown", + "onabort", + "onpointerout", + "oncuechange", + "ongotpointercapture", + "onscrollsnapchanging", + "onsearch", + "onsubmit", + "onstalled", + "onsuspend", + "onreset", + "onerror", + "onresize", + "onmouseenter", + "ongamepaddisconnected", + "ondragover", + "onbeforetoggle", + "onmouseover", + "onpagehide", + "onmousemove", + "onratechange", + "onmessageerror", + "onwheel", + "ondevicemotion", + "onauxclick", + "ontransitionend", + "onpaste", + "onpageswap", + "ononline", + "ondeviceorientationabsolute", + "onkeydown", + "onclose", + "onselect", + "onpageshow", + "onpointercancel", + "onbeforematch", + "onpointerrawupdate", + "ondragleave", + "onscrollsnapchange", + "onseeked", + "onwaiting", + "onbeforeunload", + "onplaying", + "onvolumechange", + "ondragend", + "onstorage", + "onloadeddata", + "onfocus", + "onoffline", + "onplay", + "onafterprint", + "onclick", + "oncut", + "onmouseout", + "ondblclick", + "oncanplay", + "onloadstart", + "onappinstalled", + "onpointermove", + "ontoggle", + "oncontextmenu", + "onblur", + "oncancel", + "onbeforeprint", + "oncontextrestored", + "onloadedmetadata", + "onpointerup", + "onlanguagechange", + "oncopy", + "onselectstart", + "onscroll", + "onload", + "ondragstart", + "onbeforeinput", + "oncanplaythrough", + "oninput", + "oninvalid", + "ontimeupdate", + "ondurationchange", + "onselectionchange", + "onmouseup", + "location", + "onkeypress", + "onpointerleave", + "oncontextlost", + "ondrop", + "onsecuritypolicyviolation", + "oncontentvisibilityautostatechange", + "ondeviceorientation", + "onseeking", + "onrejectionhandled", + "onunload", + "onmouseleave", + "onhashchange", + "onpointerenter", + "onmousewheel", + "onunhandledrejection", + "ondragenter", + "onpopstate", + "onpagereveal", + "onemptied" + ] + }, + "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" }, + "suspicious": { + "noClassAssign": "off", + "noDuplicateClassMembers": "off", + "noDuplicateObjectKeys": "off", + "noDuplicateParameters": "off", + "noFunctionAssign": "off", + "noImportAssign": "off", + "noRedeclare": "off", + "noUnsafeNegation": "off", + "noVar": "error", + "useGetterReturn": "off" + } + } + } + } + ] +} diff --git a/apps/clients/index.html b/apps/clients/index.html new file mode 100644 index 0000000..9fb3e81 --- /dev/null +++ b/apps/clients/index.html @@ -0,0 +1,12 @@ + + + + + + Xtablo — Client Portal + + +
+ + + diff --git a/apps/clients/package.json b/apps/clients/package.json new file mode 100644 index 0000000..ce72e83 --- /dev/null +++ b/apps/clients/package.json @@ -0,0 +1,50 @@ +{ + "name": "@xtablo/clients", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --port 5175", + "build": "tsc -b && vite build --mode production", + "build:staging": "tsc -b && vite build --mode staging", + "build:prod": "tsc -b && vite build --mode production", + "deploy:prod": "wrangler deploy --env=\"\"", + "typecheck": "tsc -b", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "preview": "vite preview", + "clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@cloudflare/vite-plugin": "^1.9.4", + "@tailwindcss/vite": "^4.0.14", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.14", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.0", + "vite": "^6.2.2", + "vite-tsconfig-paths": "^5.1.4", + "wrangler": "^4.24.3" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/tablo-views": "workspace:*", + "@xtablo/ui": "workspace:*", + "@xtablo/chat-ui": "workspace:*", + "i18next": "^25.6.0", + "i18next-browser-languagedetector": "^8.2.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2", + "zustand": "^5.0.5" + } +} diff --git a/apps/clients/src/App.tsx b/apps/clients/src/App.tsx new file mode 100644 index 0000000..3683784 --- /dev/null +++ b/apps/clients/src/App.tsx @@ -0,0 +1,9 @@ +import AppRoutes from "./routes"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/clients/src/components/ClientLayout.tsx b/apps/clients/src/components/ClientLayout.tsx new file mode 100644 index 0000000..20719a0 --- /dev/null +++ b/apps/clients/src/components/ClientLayout.tsx @@ -0,0 +1,67 @@ +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar"; +import { Button } from "@xtablo/ui/components/button"; +import { Outlet } from "react-router-dom"; +import { supabase } from "../lib/supabase"; + +function getInitials(email: string): string { + const parts = email.split("@")[0].split(/[._-]/); + return parts + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? "") + .join(""); +} + +export function ClientLayout() { + const { session } = useSession(); + + if (!session) { + return ( +
+
+

Accès non autorisé

+

+ Veuillez utiliser le lien reçu dans votre email pour accéder à cette page. +

+
+
+ ); + } + + const email = session.user.email ?? ""; + const initials = email ? getInitials(email) : "?"; + + const handleLogout = async () => { + await supabase.auth.signOut(); + }; + + return ( +
+ {/* Top bar */} +
+
+ {/* Brand */} + Xtablo + + {/* User info + logout */} +
+
+ + {initials} + + {email} +
+ +
+
+
+ + {/* Page content */} +
+ +
+
+ ); +} diff --git a/apps/clients/src/i18n.ts b/apps/clients/src/i18n.ts new file mode 100644 index 0000000..334b18e --- /dev/null +++ b/apps/clients/src/i18n.ts @@ -0,0 +1,31 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import bookingEn from "./locales/en/booking.json"; +// Import translation files +import bookingFr from "./locales/fr/booking.json"; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + fr: { + booking: bookingFr, + }, + en: { + booking: bookingEn, + }, + }, + fallbackLng: "fr", + defaultNS: "booking", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + }, + }); + +export default i18n; diff --git a/apps/clients/src/lib/supabase.ts b/apps/clients/src/lib/supabase.ts new file mode 100644 index 0000000..99c2e17 --- /dev/null +++ b/apps/clients/src/lib/supabase.ts @@ -0,0 +1,10 @@ +import { createSupabaseClient } from "@xtablo/shared"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing Supabase environment variables"); +} + +export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey); diff --git a/apps/clients/src/locales/en/booking.json b/apps/clients/src/locales/en/booking.json new file mode 100644 index 0000000..5c560df --- /dev/null +++ b/apps/clients/src/locales/en/booking.json @@ -0,0 +1,3 @@ +{ + "welcome": "Welcome" +} diff --git a/apps/clients/src/locales/fr/booking.json b/apps/clients/src/locales/fr/booking.json new file mode 100644 index 0000000..ead2829 --- /dev/null +++ b/apps/clients/src/locales/fr/booking.json @@ -0,0 +1,3 @@ +{ + "welcome": "Bienvenue" +} diff --git a/apps/clients/src/main.css b/apps/clients/src/main.css new file mode 100644 index 0000000..a896ff7 --- /dev/null +++ b/apps/clients/src/main.css @@ -0,0 +1,1266 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-navbar-background: #292e39; + --color-navbar-darker: #171920; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +.str-chat { + --str-chat__primary-color: #8b7396; + --str-chat__active-primary-color: #6e5c7d; + --str-chat__surface-color: #f5f3f7; + --str-chat__secondary-surface-color: #e8e4ec; + --str-chat__primary-surface-color: #ebe7f0; + --str-chat__primary-surface-color-low-emphasis: #f2f0f5; + --str-chat__border-radius-circle: 6px; +} + +.dark .str-chat { + --str-chat__primary-color: #a68bb5; + --str-chat__active-primary-color: #8b7396; + --str-chat__surface-color: rgba(120, 107, 130, 0.25); + --str-chat__secondary-surface-color: rgba(140, 130, 150, 0.18); + --str-chat__primary-surface-color: rgba(166, 139, 181, 0.12); + --str-chat__primary-surface-color-low-emphasis: rgba(166, 139, 181, 0.06); + --str-chat__background-color: rgba(110, 100, 120, 0.2); + --str-chat__secondary-background-color: rgba(80, 72, 88, 0.35); + --str-chat__border-color: rgba(120, 107, 130, 0.28); + --str-chat__text-color: #f5f3f7; + --str-chat__text-low-emphasis-color: #b8b0c0; + --str-chat__disabled-color: rgba(155, 143, 165, 0.35); +} + +@keyframes gradient-x { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +.animate-gradient-x { + animation: gradient-x 15s ease infinite; +} + +@keyframes wave-float { + 0%, + 100% { + transform: translateY(0px) rotate(0deg); + } + 25% { + transform: translateY(-20px) rotate(1deg); + } + 50% { + transform: translateY(-10px) rotate(-1deg); + } + 75% { + transform: translateY(-15px) rotate(0.5deg); + } +} + +@keyframes wave-pulse { + 0%, + 100% { + transform: scale(1) rotateZ(0deg); + opacity: 0.3; + } + 25% { + transform: scale(1.05) rotateZ(1deg); + opacity: 0.4; + } + 50% { + transform: scale(0.95) rotateZ(-1deg); + opacity: 0.5; + } + 75% { + transform: scale(1.02) rotateZ(0.5deg); + opacity: 0.35; + } +} + +.animate-wave-float { + animation: wave-float 8s ease-in-out infinite; +} + +.animate-wave-float-delayed { + animation: wave-float 10s ease-in-out infinite 2s; +} + +.animate-wave-float-slow { + animation: wave-float 12s ease-in-out infinite 4s; +} + +.animate-wave-pulse { + animation: wave-pulse 6s ease-in-out infinite; +} + +.animate-wave-pulse-delayed { + animation: wave-pulse 8s ease-in-out infinite 3s; +} + +.animate-wave-pulse-slow { + animation: wave-pulse 10s ease-in-out infinite 1s; +} + +/* Moving Animations */ +@keyframes move-right-slow { + 0% { + transform: translateX(-100px); + } + 100% { + transform: translateX(calc(100vw + 100px)); + } +} + +@keyframes move-right-medium { + 0% { + transform: translateX(-80px); + } + 100% { + transform: translateX(calc(100vw + 80px)); + } +} + +@keyframes move-right-fast { + 0% { + transform: translateX(-120px); + } + 100% { + transform: translateX(calc(100vw + 120px)); + } +} + +@keyframes move-down-slow { + 0% { + transform: translateY(-100px); + } + 100% { + transform: translateY(calc(100vh + 100px)); + } +} + +@keyframes move-down-medium { + 0% { + transform: translateY(-80px); + } + 100% { + transform: translateY(calc(100vh + 80px)); + } +} + +@keyframes move-diagonal-1 { + 0% { + transform: translate(-100px, -100px); + } + 100% { + transform: translate(calc(100vw + 100px), calc(100vh + 100px)); + } +} + +@keyframes move-diagonal-2 { + 0% { + transform: translate(-80px, -50px); + } + 100% { + transform: translate(calc(100vw + 80px), calc(100vh + 50px)); + } +} + +@keyframes move-diagonal-3 { + 0% { + transform: translate(-60px, -80px); + } + 100% { + transform: translate(calc(100vw + 60px), calc(100vh + 80px)); + } +} + +@keyframes orbit-1 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(150px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(150px) rotate(-360deg); + } +} + +@keyframes orbit-2 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(200px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(-360deg) translateX(200px) rotate(360deg); + } +} + +@keyframes orbit-3 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(100px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(100px) rotate(-360deg); + } +} + +/* Gentle Animations */ +@keyframes spin-slow { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(-360deg); + } +} + +@keyframes bounce-gentle { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes bounce-soft { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-8px); + } +} + +@keyframes pulse-gentle { + 0%, + 100% { + transform: scale(1); + opacity: 0.4; + } + 50% { + transform: scale(1.1); + opacity: 0.6; + } +} + +@keyframes wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(3deg); + } + 75% { + transform: rotate(-3deg); + } +} + +@keyframes float-gentle { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-5px); + } +} + +@keyframes scale-gentle { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +@keyframes rotate-gentle { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(180deg); + } +} + +@keyframes sway { + 0%, + 100% { + transform: translateX(0px); + } + 50% { + transform: translateX(10px); + } +} + +/* Animation Classes */ +.animate-move-right-slow { + animation: move-right-slow 25s linear infinite; +} +.animate-move-right-medium { + animation: move-right-medium 20s linear infinite; +} +.animate-move-right-fast { + animation: move-right-fast 15s linear infinite; +} +.animate-move-down-slow { + animation: move-down-slow 30s linear infinite; +} +.animate-move-down-medium { + animation: move-down-medium 25s linear infinite; +} +.animate-move-diagonal-1 { + animation: move-diagonal-1 35s linear infinite; +} +.animate-move-diagonal-2 { + animation: move-diagonal-2 28s linear infinite; +} +.animate-move-diagonal-3 { + animation: move-diagonal-3 32s linear infinite; +} +.animate-orbit-1 { + animation: orbit-1 20s linear infinite; +} +.animate-orbit-2 { + animation: orbit-2 25s linear infinite reverse; +} +.animate-orbit-3 { + animation: orbit-3 15s linear infinite; +} +.animate-spin-slow { + animation: spin-slow 8s linear infinite; +} +.animate-spin-reverse { + animation: spin-reverse 6s linear infinite; +} +.animate-bounce-gentle { + animation: bounce-gentle 3s ease-in-out infinite; +} +.animate-bounce-soft { + animation: bounce-soft 4s ease-in-out infinite; +} +.animate-pulse-gentle { + animation: pulse-gentle 4s ease-in-out infinite; +} +.animate-wiggle { + animation: wiggle 2s ease-in-out infinite; +} +.animate-float-gentle { + animation: float-gentle 5s ease-in-out infinite; +} +.animate-scale-gentle { + animation: scale-gentle 6s ease-in-out infinite; +} +.animate-rotate-gentle { + animation: rotate-gentle 8s ease-in-out infinite; +} +.animate-sway { + animation: sway 3s ease-in-out infinite; +} + +/* Enhanced Animations */ +@keyframes orbit-4 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(250px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(250px) rotate(-360deg); + } +} + +@keyframes orbit-5 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(120px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(-360deg) translateX(120px) rotate(360deg); + } +} + +@keyframes zigzag-1 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 25% { + transform: translateX(25vw) translateY(-50px); + } + 50% { + transform: translateX(50vw) translateY(50px); + } + 75% { + transform: translateX(75vw) translateY(-30px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(20px); + } +} + +@keyframes zigzag-2 { + 0% { + transform: translateX(-80px) translateY(0px); + } + 20% { + transform: translateX(20vw) translateY(40px); + } + 40% { + transform: translateX(40vw) translateY(-60px); + } + 60% { + transform: translateX(60vw) translateY(30px); + } + 80% { + transform: translateX(80vw) translateY(-40px); + } + 100% { + transform: translateX(calc(100vw + 80px)) translateY(0px); + } +} + +@keyframes zigzag-3 { + 0% { + transform: translateX(-120px) translateY(0px); + } + 16% { + transform: translateX(16vw) translateY(-70px); + } + 33% { + transform: translateX(33vw) translateY(80px); + } + 50% { + transform: translateX(50vw) translateY(-50px); + } + 66% { + transform: translateX(66vw) translateY(60px); + } + 83% { + transform: translateX(83vw) translateY(-40px); + } + 100% { + transform: translateX(calc(100vw + 120px)) translateY(0px); + } +} + +@keyframes spiral-1 { + 0% { + transform: translate(0px, 0px) rotate(0deg) scale(0.5); + } + 25% { + transform: translate(25vw, 25vh) rotate(90deg) scale(1); + } + 50% { + transform: translate(50vw, 50vh) rotate(180deg) scale(1.5); + } + 75% { + transform: translate(75vw, 75vh) rotate(270deg) scale(1); + } + 100% { + transform: translate(100vw, 100vh) rotate(360deg) scale(0.5); + } +} + +@keyframes spiral-2 { + 0% { + transform: translate(0px, 0px) rotate(0deg) scale(1.5); + } + 25% { + transform: translate(-25vw, 25vh) rotate(-90deg) scale(0.8); + } + 50% { + transform: translate(-50vw, 50vh) rotate(-180deg) scale(0.5); + } + 75% { + transform: translate(-75vw, 75vh) rotate(-270deg) scale(1.2); + } + 100% { + transform: translate(-100vw, 100vh) rotate(-360deg) scale(1.5); + } +} + +@keyframes float-random-1 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 25% { + transform: translate(50px, -30px) rotate(45deg); + } + 50% { + transform: translate(-30px, 40px) rotate(-30deg); + } + 75% { + transform: translate(40px, 20px) rotate(60deg); + } +} + +@keyframes float-random-2 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 20% { + transform: translate(-40px, -50px) rotate(-45deg); + } + 40% { + transform: translate(60px, -20px) rotate(90deg); + } + 60% { + transform: translate(-20px, 60px) rotate(-60deg); + } + 80% { + transform: translate(30px, -40px) rotate(120deg); + } +} + +@keyframes float-random-3 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 33% { + transform: translate(70px, 30px) rotate(180deg); + } + 66% { + transform: translate(-50px, -40px) rotate(-90deg); + } +} + +@keyframes float-random-4 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 25% { + transform: translate(-60px, 50px) rotate(270deg); + } + 50% { + transform: translate(80px, -30px) rotate(180deg); + } + 75% { + transform: translate(-40px, -60px) rotate(90deg); + } +} + +@keyframes wave-1 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 25% { + transform: translateX(25vw) translateY(-80px); + } + 50% { + transform: translateX(50vw) translateY(0px); + } + 75% { + transform: translateX(75vw) translateY(80px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes wave-2 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 20% { + transform: translateX(20vw) translateY(60px); + } + 40% { + transform: translateX(40vw) translateY(-60px); + } + 60% { + transform: translateX(60vw) translateY(60px); + } + 80% { + transform: translateX(80vw) translateY(-60px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes wave-3 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 33% { + transform: translateX(33vw) translateY(-100px); + } + 66% { + transform: translateX(66vw) translateY(100px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes wave-4 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 16% { + transform: translateX(16vw) translateY(40px); + } + 33% { + transform: translateX(33vw) translateY(-80px); + } + 50% { + transform: translateX(50vw) translateY(40px); + } + 66% { + transform: translateX(66vw) translateY(-80px); + } + 83% { + transform: translateX(83vw) translateY(40px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes corner-shoot-1 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(100vw, 100vh) rotate(720deg); + } +} + +@keyframes corner-shoot-2 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(-100vw, 100vh) rotate(-720deg); + } +} + +@keyframes corner-shoot-3 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(100vw, -100vh) rotate(720deg); + } +} + +@keyframes corner-shoot-4 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(-100vw, -100vh) rotate(-720deg); + } +} + +@keyframes bounce-ball-1 { + 0%, + 100% { + transform: translate(0px, 0px); + } + 25% { + transform: translate(200px, -150px); + } + 50% { + transform: translate(400px, 0px); + } + 75% { + transform: translate(600px, -100px); + } +} + +@keyframes bounce-ball-2 { + 0%, + 100% { + transform: translate(0px, 0px); + } + 33% { + transform: translate(-300px, -200px); + } + 66% { + transform: translate(-600px, 0px); + } +} + +@keyframes bounce-ball-3 { + 0%, + 100% { + transform: translate(0px, 0px); + } + 20% { + transform: translate(150px, -100px); + } + 40% { + transform: translate(300px, 50px); + } + 60% { + transform: translate(150px, -80px); + } + 80% { + transform: translate(-150px, 30px); + } +} + +/* Crazy Animations */ +@keyframes spin-fast { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(720deg); + } +} +@keyframes pulse-fast { + 0%, + 100% { + transform: scale(0.8); + opacity: 0.3; + } + 50% { + transform: scale(1.3); + opacity: 0.8; + } +} +@keyframes wobble { + 0%, + 100% { + transform: rotate(0deg) scale(1); + } + 25% { + transform: rotate(5deg) scale(1.1); + } + 50% { + transform: rotate(-5deg) scale(0.9); + } + 75% { + transform: rotate(3deg) scale(1.05); + } +} +@keyframes shake { + 0%, + 100% { + transform: translateX(0px); + } + 25% { + transform: translateX(-10px); + } + 75% { + transform: translateX(10px); + } +} +@keyframes bounce-crazy { + 0%, + 100% { + transform: translateY(0px) scale(1); + } + 50% { + transform: translateY(-50px) scale(1.2); + } +} +@keyframes spin-wobble { + 0% { + transform: rotate(0deg) scale(1); + } + 25% { + transform: rotate(90deg) scale(1.1); + } + 50% { + transform: rotate(180deg) scale(0.9); + } + 75% { + transform: rotate(270deg) scale(1.05); + } + 100% { + transform: rotate(360deg) scale(1); + } +} +@keyframes flip { + 0% { + transform: rotateY(0deg); + } + 50% { + transform: rotateY(180deg); + } + 100% { + transform: rotateY(360deg); + } +} +@keyframes twirl { + 0% { + transform: rotate(0deg) translateX(0px); + } + 25% { + transform: rotate(90deg) translateX(20px); + } + 50% { + transform: rotate(180deg) translateX(0px); + } + 75% { + transform: rotate(270deg) translateX(-20px); + } + 100% { + transform: rotate(360deg) translateX(0px); + } +} +@keyframes dance { + 0%, + 100% { + transform: translateY(0px) rotate(0deg); + } + 25% { + transform: translateY(-20px) rotate(10deg); + } + 50% { + transform: translateY(10px) rotate(-5deg); + } + 75% { + transform: translateY(-15px) rotate(8deg); + } +} +@keyframes jiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(2deg) translateX(2px); + } + 50% { + transform: rotate(-2deg) translateX(-2px); + } + 75% { + transform: rotate(1deg) translateX(1px); + } +} +@keyframes vibrate { + 0%, + 100% { + transform: translate(0px, 0px); + } + 25% { + transform: translate(2px, -2px); + } + 50% { + transform: translate(-2px, 2px); + } + 75% { + transform: translate(2px, 2px); + } +} +@keyframes swing { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(15deg); + } + 75% { + transform: rotate(-15deg); + } +} +@keyframes pendulum { + 0%, + 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(30deg); + } +} +@keyframes elastic { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.3) rotate(180deg); + } +} +@keyframes rubber { + 0%, + 100% { + transform: scaleX(1) scaleY(1); + } + 25% { + transform: scaleX(1.2) scaleY(0.8); + } + 75% { + transform: scaleX(0.8) scaleY(1.2); + } +} +@keyframes rocket { + 0% { + transform: scale(0.5) rotate(0deg); + } + 100% { + transform: scale(2) rotate(360deg); + } +} +@keyframes comet { + 0% { + transform: scale(1) rotate(0deg); + opacity: 1; + } + 100% { + transform: scale(0.2) rotate(720deg); + opacity: 0.2; + } +} +@keyframes meteor { + 0% { + transform: scale(0.2) rotate(0deg); + opacity: 0.2; + } + 100% { + transform: scale(1.5) rotate(-720deg); + opacity: 1; + } +} +@keyframes blast { + 0%, + 100% { + transform: scale(1) rotate(0deg); + } + 50% { + transform: scale(2) rotate(180deg); + } +} +@keyframes spin-bounce { + 0%, + 100% { + transform: rotate(0deg) translateY(0px); + } + 50% { + transform: rotate(180deg) translateY(-30px); + } +} +@keyframes flip-bounce { + 0%, + 100% { + transform: rotateX(0deg) translateY(0px); + } + 50% { + transform: rotateX(180deg) translateY(-25px); + } +} +@keyframes scale-bounce { + 0%, + 100% { + transform: scale(1) translateY(0px); + } + 50% { + transform: scale(1.5) translateY(-40px); + } +} + +/* New Animation Classes */ +.animate-orbit-4 { + animation: orbit-4 18s linear infinite; +} +.animate-orbit-5 { + animation: orbit-5 22s linear infinite reverse; +} +.animate-zigzag-1 { + animation: zigzag-1 18s linear infinite; +} +.animate-zigzag-2 { + animation: zigzag-2 22s linear infinite; +} +.animate-zigzag-3 { + animation: zigzag-3 16s linear infinite; +} +.animate-spiral-1 { + animation: spiral-1 30s linear infinite; +} +.animate-spiral-2 { + animation: spiral-2 25s linear infinite; +} +.animate-float-random-1 { + animation: float-random-1 8s ease-in-out infinite; +} +.animate-float-random-2 { + animation: float-random-2 10s ease-in-out infinite; +} +.animate-float-random-3 { + animation: float-random-3 12s ease-in-out infinite; +} +.animate-float-random-4 { + animation: float-random-4 9s ease-in-out infinite; +} +.animate-wave-1 { + animation: wave-1 20s linear infinite; +} +.animate-wave-2 { + animation: wave-2 24s linear infinite; +} +.animate-wave-3 { + animation: wave-3 18s linear infinite; +} +.animate-wave-4 { + animation: wave-4 26s linear infinite; +} +.animate-corner-shoot-1 { + animation: corner-shoot-1 15s linear infinite; +} +.animate-corner-shoot-2 { + animation: corner-shoot-2 18s linear infinite; +} +.animate-corner-shoot-3 { + animation: corner-shoot-3 20s linear infinite; +} +.animate-corner-shoot-4 { + animation: corner-shoot-4 16s linear infinite; +} +.animate-bounce-ball-1 { + animation: bounce-ball-1 12s ease-in-out infinite; +} +.animate-bounce-ball-2 { + animation: bounce-ball-2 14s ease-in-out infinite; +} +.animate-bounce-ball-3 { + animation: bounce-ball-3 10s ease-in-out infinite; +} +.animate-spin-fast { + animation: spin-fast 2s linear infinite; +} +.animate-pulse-fast { + animation: pulse-fast 1.5s ease-in-out infinite; +} +.animate-wobble { + animation: wobble 2s ease-in-out infinite; +} +.animate-shake { + animation: shake 0.5s ease-in-out infinite; +} +.animate-bounce-crazy { + animation: bounce-crazy 1s ease-in-out infinite; +} +.animate-spin-wobble { + animation: spin-wobble 4s ease-in-out infinite; +} +.animate-flip { + animation: flip 3s ease-in-out infinite; +} +.animate-twirl { + animation: twirl 5s ease-in-out infinite; +} +.animate-dance { + animation: dance 3s ease-in-out infinite; +} +.animate-jiggle { + animation: jiggle 1s ease-in-out infinite; +} +.animate-vibrate { + animation: vibrate 0.3s ease-in-out infinite; +} +.animate-swing { + animation: swing 4s ease-in-out infinite; +} +.animate-pendulum { + animation: pendulum 6s ease-in-out infinite; +} +.animate-elastic { + animation: elastic 4s ease-in-out infinite; +} +.animate-rubber { + animation: rubber 2s ease-in-out infinite; +} +.animate-rocket { + animation: rocket 8s ease-in-out infinite; +} +.animate-comet { + animation: comet 12s ease-in-out infinite; +} +.animate-meteor { + animation: meteor 10s ease-in-out infinite; +} +.animate-blast { + animation: blast 3s ease-in-out infinite; +} +.animate-spin-bounce { + animation: spin-bounce 4s ease-in-out infinite; +} +.animate-flip-bounce { + animation: flip-bounce 5s ease-in-out infinite; +} +.animate-scale-bounce { + animation: scale-bounce 3s ease-in-out infinite; +} + +/* Animated Border Light */ +@keyframes border-light { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.animate-border-light { + position: relative; + border-radius: 1rem; +} + +.animate-border-light::before { + content: ""; + position: absolute; + inset: -2px; + background: conic-gradient( + from 0deg, + transparent 0deg, + transparent 270deg, + rgba(168, 85, 247, 0.8) 300deg, + rgba(147, 51, 234, 1) 330deg, + rgba(168, 85, 247, 0.8) 360deg, + transparent 30deg, + transparent 360deg + ); + border-radius: inherit; + animation: border-light 3s linear infinite; + z-index: -1; +} + +.animate-border-light::after { + content: ""; + position: absolute; + inset: 2px; + background: inherit; + border-radius: inherit; + z-index: -1; +} diff --git a/apps/clients/src/main.tsx b/apps/clients/src/main.tsx new file mode 100644 index 0000000..d158df1 --- /dev/null +++ b/apps/clients/src/main.tsx @@ -0,0 +1,29 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@xtablo/shared"; +import { SessionProvider } from "@xtablo/shared/contexts/SessionContext"; +import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; +import { Toaster } from "@xtablo/ui/components/sonner"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter as Router } from "react-router-dom"; +import App from "./App"; +import { supabase } from "./lib/supabase"; + +import "@xtablo/ui/styles/globals.css"; +import "./main.css"; +import "./i18n"; + +createRoot(document.getElementById("client-root")!).render( + + + + + + + + + + + + +); diff --git a/apps/clients/src/pages/AuthCallback.tsx b/apps/clients/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..b34427b --- /dev/null +++ b/apps/clients/src/pages/AuthCallback.tsx @@ -0,0 +1,66 @@ +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +export function AuthCallback() { + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + const { session } = useSession(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + const hasAccepted = useRef(false); + + useEffect(() => { + if (!session || !token || hasAccepted.current) { + return; + } + + hasAccepted.current = true; + + const apiUrl = import.meta.env.VITE_API_URL as string; + + fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + }, + }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation"); + } + return res.json() as Promise<{ tabloId: string }>; + }) + .then((data) => { + navigate(`/tablo/${data.tabloId}`, { replace: true }); + }) + .catch((err: unknown) => { + console.error("Accept invite error:", err); + setError( + "Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invité." + ); + }); + }, [session, token, navigate]); + + if (error) { + return ( +
+
+

Erreur

+

{error}

+
+
+ ); + } + + return ( +
+
+
+

Authentification en cours...

+
+
+ ); +} diff --git a/apps/clients/src/pages/ClientTabloListPage.tsx b/apps/clients/src/pages/ClientTabloListPage.tsx new file mode 100644 index 0000000..e3ce7c6 --- /dev/null +++ b/apps/clients/src/pages/ClientTabloListPage.tsx @@ -0,0 +1,63 @@ +import { useQuery } from "@tanstack/react-query"; +import type { UserTablo } from "@xtablo/shared-types"; +import { Navigate, Link } from "react-router-dom"; +import { supabase } from "../lib/supabase"; + +function useClientTablosList() { + return useQuery({ + queryKey: ["client-tablos-list"], + queryFn: async () => { + const { data, error } = await supabase.from("user_tablos").select("*"); + if (error) throw error; + return (data ?? []) as UserTablo[]; + }, + }); +} + +export function ClientTabloListPage() { + const { data: tablos, isLoading } = useClientTablosList(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!tablos || tablos.length === 0) { + return ( +
+

Aucun projet disponible.

+
+ ); + } + + if (tablos.length === 1) { + return ; + } + + return ( +
+
+

Mes projets

+

Sélectionnez un projet pour y accéder.

+
+ +
+ {tablos.map((tablo) => ( + + {tablo.color && ( +
+ )} +

{tablo.name}

+ + ))} +
+
+ ); +} diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx new file mode 100644 index 0000000..3da9be0 --- /dev/null +++ b/apps/clients/src/pages/ClientTabloPage.tsx @@ -0,0 +1,310 @@ +import { useQuery } from "@tanstack/react-query"; +import { buildApi } from "@xtablo/shared"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types"; +import { CalendarIcon, FolderIcon, KanbanIcon, ListChecksIcon, MapIcon, MessageCircleIcon } from "lucide-react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { + EtapesSection, + RoadmapSection, + TabloDiscussionSection, + TabloEventsSection, + TabloFilesSection, + TabloTasksSection, +} from "@xtablo/tablo-views"; +import { supabase } from "../lib/supabase"; + +const API_URL = import.meta.env.VITE_API_URL as string; + +// ─── Local hooks ────────────────────────────────────────────────────────────── + +function useAuthedApi(accessToken: string | undefined) { + return buildApi(API_URL).create({ + headers: { + Authorization: `Bearer ${accessToken ?? ""}`, + }, + }); +} + +function useClientTablo(tabloId: string) { + return useQuery({ + queryKey: ["client-tablo", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("user_tablos") + .select("*") + .eq("id", tabloId) + .single(); + if (error) throw error; + return data as UserTablo; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloTasks(tabloId: string) { + return useQuery({ + queryKey: ["client-tasks", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tasks_with_assignee") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", false) + .order("updated_at", { ascending: false }); + if (error) throw error; + return (data ?? []) as KanbanTask[]; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloEtapes(tabloId: string) { + return useQuery({ + queryKey: ["client-etapes", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tasks") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", true) + .order("position", { ascending: true }); + if (error) throw error; + return (data ?? []) as Etape[]; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloEvents(tabloId: string) { + return useQuery({ + queryKey: ["client-events", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("events_and_tablos") + .select("*") + .eq("tablo_id", tabloId) + .order("start_date", { ascending: false }); + if (error) throw error; + return data ?? []; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloMembers(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + return useQuery({ + queryKey: ["client-members", tabloId], + queryFn: async () => { + const { data } = await api.get<{ + members: { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; + }[]; + }>(`/api/v1/tablos/members/${tabloId}`); + return data.members; + }, + enabled: !!tabloId && !!accessToken, + }); +} + +function useClientTabloFiles(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + return useQuery<{ fileNames: string[] }>({ + queryKey: ["client-tablo-files", tabloId], + queryFn: async () => { + const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`); + return data as { fileNames: string[] }; + }, + enabled: !!tabloId && !!accessToken, + }); +} + +function useClientTabloFolders(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + return useQuery({ + queryKey: ["client-tablo-folders", tabloId], + queryFn: async () => { + const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`); + return data.folders ?? []; + }, + enabled: !!tabloId && !!accessToken, + }); +} + +// ─── Tabs ───────────────────────────────────────────────────────────────────── + +type TabId = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap"; + +const TABS: { id: TabId; label: string; icon: React.ElementType }[] = [ + { id: "overview", label: "Aperçu", icon: ListChecksIcon }, + { id: "etapes", label: "Étapes", icon: ListChecksIcon }, + { id: "tasks", label: "Tâches", icon: KanbanIcon }, + { id: "files", label: "Fichiers", icon: FolderIcon }, + { id: "discussion", label: "Discussion", icon: MessageCircleIcon }, + { id: "events", label: "Événements", icon: CalendarIcon }, + { id: "roadmap", label: "Roadmap", icon: MapIcon }, +]; + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export function ClientTabloPage() { + const { tabloId } = useParams<{ tabloId: string }>(); + const { session } = useSession(); + const [activeTab, setActiveTab] = useState("overview"); + + const accessToken = session?.access_token; + const currentUserId = session?.user.id ?? ""; + + const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? ""); + const { data: tasks = [] } = useClientTabloTasks(tabloId ?? ""); + const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? ""); + const { data: events, isLoading: eventsLoading, error: eventsError } = useClientTabloEvents(tabloId ?? ""); + const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken); + const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken); + const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken); + + const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith(".")); + + const currentUser = { id: currentUserId, avatar_url: null }; + + if (tabloLoading) { + return ( +
+
+
+ ); + } + + if (!tablo) { + return ( +
+

Projet introuvable.

+
+ ); + } + + return ( +
+ {/* Tablo header */} +
+

{tablo.name}

+
+ + {/* Tab bar */} +
+ +
+ + {/* Tab content */} +
+ {activeTab === "overview" && ( +
+ {/* Simple overview: list etapes with progress */} + {}} + onCreateEtape={async () => {}} + /> +
+ )} + + {activeTab === "etapes" && ( + {}} + onCreateEtape={async () => {}} + /> + )} + + {activeTab === "tasks" && ( + + )} + + {activeTab === "files" && ( + + )} + + {activeTab === "discussion" && ( + + )} + + {activeTab === "events" && ( + [0]["events"]} + isLoading={eventsLoading} + error={eventsError instanceof Error ? eventsError : null} + currentUser={currentUser} + members={members} + /> + )} + + {activeTab === "roadmap" && ( + {}} + onTaskStatusChange={() => {}} + /> + )} +
+
+ ); +} diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx new file mode 100644 index 0000000..57a23ce --- /dev/null +++ b/apps/clients/src/routes.tsx @@ -0,0 +1,17 @@ +import { Route, Routes } from "react-router-dom"; +import { ClientLayout } from "./components/ClientLayout"; +import { AuthCallback } from "./pages/AuthCallback"; +import { ClientTabloListPage } from "./pages/ClientTabloListPage"; +import { ClientTabloPage } from "./pages/ClientTabloPage"; + +export default function AppRoutes() { + return ( + + } /> + }> + } /> + } /> + + + ); +} diff --git a/apps/clients/tsconfig.json b/apps/clients/tsconfig.json new file mode 100644 index 0000000..f2fa327 --- /dev/null +++ b/apps/clients/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@xtablo/ui": ["../../packages/ui/src"], + "@xtablo/ui/*": ["../../packages/ui/src/*"], + "@xtablo/shared": ["../../packages/shared/src"], + "@xtablo/shared/*": ["../../packages/shared/src/*"], + "@xtablo/tablo-views": ["../../packages/tablo-views/src"], + "@xtablo/tablo-views/*": ["../../packages/tablo-views/src/*"] + } + }, + "include": ["src"], + "references": [] +} diff --git a/apps/clients/tsconfig.tsbuildinfo b/apps/clients/tsconfig.tsbuildinfo new file mode 100644 index 0000000..a7db947 --- /dev/null +++ b/apps/clients/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/components/clientlayout.tsx","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/clients/vite.config.ts b/apps/clients/vite.config.ts new file mode 100644 index 0000000..dfed7ff --- /dev/null +++ b/apps/clients/vite.config.ts @@ -0,0 +1,18 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type PluginOption } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => { + const plugins: PluginOption[] = [react(), tailwindcss(), tsconfigPaths({ ignoreConfigErrors: true })]; + + if (mode !== "test" && process.env.VITEST !== "true") { + plugins.push(cloudflare({ inspectorPort: 9232 })); + } + + return { + plugins, + server: { cors: false }, + }; +}); diff --git a/apps/clients/worker/index.ts b/apps/clients/worker/index.ts new file mode 100644 index 0000000..0dcbb86 --- /dev/null +++ b/apps/clients/worker/index.ts @@ -0,0 +1,9 @@ +export default { + fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + return new Response(null, { status: 404 }); + }, +}; diff --git a/apps/clients/wrangler.toml b/apps/clients/wrangler.toml new file mode 100644 index 0000000..8baaf4a --- /dev/null +++ b/apps/clients/wrangler.toml @@ -0,0 +1,11 @@ +name = "xtablo-clients" +main = "worker/index.ts" +compatibility_date = "2025-07-09" +route = { pattern = "clients.xtablo.com", custom_domain = true } + +[assets] +directory = "./dist/" +not_found_handling = "single-page-application" + +[observability] +enabled = true diff --git a/apps/external/.env.production b/apps/external/.env.production index 49c8887..0c8f9cf 100644 --- a/apps/external/.env.production +++ b/apps/external/.env.production @@ -2,6 +2,5 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app \ No newline at end of file diff --git a/apps/external/src/UserStoreProvider.tsx b/apps/external/src/UserStoreProvider.tsx index 8861f98..1df3c10 100644 --- a/apps/external/src/UserStoreProvider.tsx +++ b/apps/external/src/UserStoreProvider.tsx @@ -5,9 +5,7 @@ import React from "react"; import { createStore, StoreApi, useStore } from "zustand"; import { api } from "./lib/api"; -export type User = Tables<"profiles"> & { - streamToken: string | null; -}; +export type User = Tables<"profiles">; const UserStoreContext = React.createContext | null>(null); diff --git a/apps/external/vite.config.ts b/apps/external/vite.config.ts index 964bddd..8bd72da 100644 --- a/apps/external/vite.config.ts +++ b/apps/external/vite.config.ts @@ -16,12 +16,12 @@ export default defineConfig(({ mode }) => { react(), // visualizer() as PluginOption, tailwindcss(), - tsconfigPaths(), + tsconfigPaths({ ignoreConfigErrors: true }), ]; // Only include cloudflare plugin when not in test mode if (mode !== "test" && process.env.VITEST !== "true") { - plugins.push(cloudflare()); + plugins.push(cloudflare({ inspectorPort: 9231 })); } return { diff --git a/apps/external/worker-configuration.d.ts b/apps/external/worker-configuration.d.ts index 64d868a..8c48ac3 100644 --- a/apps/external/worker-configuration.d.ts +++ b/apps/external/worker-configuration.d.ts @@ -10,7 +10,6 @@ declare namespace Cloudflare { VITE_SUPABASE_URL: string; VITE_SUPABASE_ANON_KEY: string; VITE_SUPABASE_ID: string; - VITE_STREAM_CHAT_API_KEY: string; VITE_API_URL: string; } } diff --git a/apps/main/.env.production b/apps/main/.env.production index cf7799d..eec9fa3 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -4,7 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx" +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpXuXgpirUWOj24dnb7DzbcEAu45nU1S5k66Nm4liY3IlGOW00pndRsgUM VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF diff --git a/apps/main/.env.staging b/apps/main/.env.staging index dfcb28d..a6bb6b9 100644 --- a/apps/main/.env.staging +++ b/apps/main/.env.staging @@ -4,7 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0 VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva diff --git a/apps/main/package.json b/apps/main/package.json index 3775a5c..18574d9 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -99,8 +99,10 @@ "@tanstack/react-query": "^5.69.0", "@types/react-router-dom": "^5.3.3", "@typescript/native-preview": "7.0.0-dev.20251010.1", + "@xtablo/chat-ui": "workspace:*", "@xtablo/shared": "workspace:*", "@xtablo/shared-types": "workspace:*", + "@xtablo/tablo-views": "workspace:*", "@xtablo/ui": "workspace:*", "ag-grid-community": "^33.2.1", "ag-grid-react": "^33.2.1", @@ -119,8 +121,6 @@ "react-router-dom": "^7.9.4", "react-stately": "^3.36.1", "sonner": "^2.0.7", - "stream-chat": "^9.6.1", - "stream-chat-react": "^13.1.0", "ts-pattern": "^5.6.2", "uuid": "^11.1.0", "zod": "^4.1.12", diff --git a/apps/main/src/components/ChannelPreview.test.tsx b/apps/main/src/components/ChannelPreview.test.tsx deleted file mode 100644 index db1b493..0000000 --- a/apps/main/src/components/ChannelPreview.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { Channel } from "stream-chat"; -import { describe, expect, it, vi } from "vitest"; -import { ChannelPreview } from "./ChannelPreview"; - -// Mock ChannelBadge -vi.mock("./ChannelBadge", () => ({ - ChannelBadge: ({ displayTitle, isOnline }: { displayTitle?: string; isOnline: boolean }) => ( -
- {displayTitle}-{isOnline ? "online" : "offline"} -
- ), -})); - -describe("ChannelPreview", () => { - const mockChannel = { - id: "channel-1", - data: { - created_at: new Date("2024-01-01").toISOString(), - config: { - name: "Test Channel", - }, - }, - state: { - members: {}, - }, - } as unknown as Channel; - - const mockTablo = { - id: "tablo-1", - name: "Test Tablo", - color: "bg-blue-500", - user_id: "user-id", - access_level: "admin", - is_admin: true, - created_at: "2024-01-01T00:00:00Z", - deleted_at: "2024-01-01T00:00:00Z", - position: 0, - status: "active", - image: null, - }; - - const defaultProps = { - channel: mockChannel, - tablo: mockTablo, - displayTitle: "Test Channel", - }; - - it("renders without crashing", () => { - render(); - expect(screen.getByText("Test Channel")).toBeInTheDocument(); - }); - - it("displays channel title", () => { - render(); - expect(screen.getByText("Test Channel")).toBeInTheDocument(); - }); - - it("renders ChannelBadge component", () => { - render(); - expect(screen.getByTestId("channel-badge")).toBeInTheDocument(); - }); - - it("shows unread count badge when unreadCount > 0", () => { - render(); - expect(screen.getByText("5")).toBeInTheDocument(); - }); - - it("shows 99+ for unread counts over 99", () => { - render(); - expect(screen.getByText("99+")).toBeInTheDocument(); - }); - - it("does not show unread badge when count is 0", () => { - const { container } = render(); - expect(container.querySelector(".min-w-\\[20px\\]")).not.toBeInTheDocument(); - }); - - it("calls setActiveChannel when clicked", () => { - const setActiveChannel = vi.fn(); - render(); - fireEvent.click(screen.getByText("Test Channel")); - expect(setActiveChannel).toHaveBeenCalledWith(mockChannel); - }); - - it("highlights active channel", () => { - const { container } = render(); - expect(container.querySelector(".bg-purple-50")).toBeInTheDocument(); - }); - - it("displays latest message preview", () => { - render(); - expect(screen.getByText("Hello world")).toBeInTheDocument(); - }); - - it("applies custom className", () => { - const { container } = render(); - expect(container.querySelector(".custom-class")).toBeInTheDocument(); - }); - - it("shows active indicator for active channel", () => { - const { container } = render(); - expect(container.querySelector(".absolute.left-0.top-0.bottom-0.w-1")).toBeInTheDocument(); - }); -}); diff --git a/apps/main/src/components/ChannelPreview.tsx b/apps/main/src/components/ChannelPreview.tsx deleted file mode 100644 index abe2c03..0000000 --- a/apps/main/src/components/ChannelPreview.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { ChannelBadge } from "@ui/components/ChannelBadge"; -import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import { Badge } from "@xtablo/ui/components/badge"; -import { ReactNode } from "react"; -import { Channel } from "stream-chat"; -import { twMerge } from "tailwind-merge"; - -interface ChannelPreviewProps { - channel: Channel; - tablo: UserTablo | null; - displayTitle: string | undefined; - activeChannel?: Channel; - setActiveChannel?: (channel: Channel) => void; - unreadCount?: number; - latestMessagePreview?: ReactNode; - className?: string; -} - -function formatTimestamp(timestamp: string | Date): string { - const date = new Date(timestamp); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (minutes < 1) return "now"; - if (minutes < 60) return `${minutes}m`; - if (hours < 24) return `${hours}h`; - if (days < 7) return `${days}d`; - return date.toLocaleDateString(); -} - -// function getLastMessagePreview(lastMessage?: StreamMessage): string { -// if (!lastMessage) return "No messages yet"; - -// if (lastMessage.deleted_at) return "Message deleted"; - -// if (lastMessage.text) { -// return lastMessage.text.length > 50 -// ? lastMessage.text.substring(0, 50) + "..." -// : lastMessage.text; -// } - -// if (lastMessage.attachments?.length && lastMessage.attachments.length > 0) { -// const attachment = lastMessage.attachments[0]; -// if (attachment.type === "image") return "📷 Image"; -// if (attachment.type === "video") return "🎥 Video"; -// if (attachment.type === "file") return "📎 File"; -// } - -// return "Message"; -// } - -function isUserOnline(channel: Channel): boolean { - const members = Object.values(channel.state?.members || {}); - - const otherMembers = members.filter( - // @ts-expect-error TODO: fix this - (member) => member.user?.id !== channel.data?.config?.created_by?.id - ); - - return otherMembers.some((member) => member.user?.online); -} - -export function ChannelPreview({ - displayTitle, - channel, - tablo, - activeChannel, - setActiveChannel, - unreadCount = 0, - latestMessagePreview, - className, -}: ChannelPreviewProps) { - const isActive = activeChannel?.id === channel.id; - const isOnline = isUserOnline(channel); - const timestamp = channel.data?.created_at; - - const handleClick = () => { - setActiveChannel?.(channel); - }; - - return ( -
- - - {/* Channel info */} -
-
-

- {displayTitle} -

- {timestamp && ( - - {formatTimestamp(timestamp)} - - )} -
- -
-

- {latestMessagePreview} -

- - {/* Unread count badge */} - {unreadCount > 0 && ( -
- - {unreadCount > 99 ? "99+" : unreadCount} - -
- )} -
-
- - {/* Active indicator */} - {isActive && ( -
- )} -
- ); -} diff --git a/apps/main/src/components/ChatChannelPreview.tsx b/apps/main/src/components/ChatChannelPreview.tsx new file mode 100644 index 0000000..f647967 --- /dev/null +++ b/apps/main/src/components/ChatChannelPreview.tsx @@ -0,0 +1,90 @@ +import { ChannelBadge } from "@ui/components/ChannelBadge"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { Badge } from "@xtablo/ui/components/badge"; +import { twMerge } from "tailwind-merge"; + +interface ChatChannelPreviewProps { + tablo: UserTablo; + isActive: boolean; + onClick: () => void; + unreadCount: number; + lastMessage?: string; + lastMessageTime?: string; + isOnline: boolean; +} + +function formatTimestamp(timestamp: string | Date): string { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + return date.toLocaleDateString(); +} + +export function ChatChannelPreview({ + tablo, + isActive, + onClick, + unreadCount, + lastMessage, + lastMessageTime, + isOnline, +}: ChatChannelPreviewProps) { + return ( +
+ + +
+
+

+ {tablo.name} +

+ {lastMessageTime && ( + + {formatTimestamp(lastMessageTime)} + + )} +
+ +
+

+ {lastMessage ?? "No messages yet"} +

+ + {unreadCount > 0 && ( +
+ + {unreadCount > 99 ? "99+" : unreadCount} + +
+ )} +
+
+ + {isActive && ( +
+ )} +
+ ); +} diff --git a/apps/main/src/components/ChatHeader.tsx b/apps/main/src/components/ChatHeader.tsx new file mode 100644 index 0000000..76a765f --- /dev/null +++ b/apps/main/src/components/ChatHeader.tsx @@ -0,0 +1,52 @@ +import { ChannelBadge } from "@ui/components/ChannelBadge"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; + +interface ChatHeaderProps { + tablo: UserTablo | null; + onToggleChannelList?: () => void; + isChannelListExpanded?: boolean; + onlineUsers: string[]; +} + +export function ChatHeader({ + tablo, + onToggleChannelList, + isChannelListExpanded = false, + onlineUsers, +}: ChatHeaderProps) { + const memberCount = onlineUsers.length; + + return ( +
+ {onToggleChannelList && ( + + )} + {tablo && ( + <> + 0} /> +
+

{tablo.name}

+ {memberCount > 0 && ( +

{memberCount} online

+ )} +
+ + )} +
+ ); +} diff --git a/apps/main/src/components/CustomChannelHeader.test.tsx b/apps/main/src/components/CustomChannelHeader.test.tsx deleted file mode 100644 index 59146b3..0000000 --- a/apps/main/src/components/CustomChannelHeader.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { CustomChannelHeader } from "./CustomChannelHeader"; - -// Mock stream-chat-react -vi.mock("stream-chat-react", () => ({ - ChannelHeader: ({ Avatar }: { Avatar?: () => React.ReactElement }) => ( -
{Avatar && }
- ), - useChannelStateContext: () => ({ - channel: { - id: "test-channel", - data: { - config: { - name: "Test Channel", - }, - }, - }, - }), -})); - -// Mock ChannelBadge -vi.mock("./ChannelBadge", () => ({ - ChannelBadge: ({ displayTitle }: { displayTitle?: string }) => ( -
{displayTitle}
- ), -})); - -describe("CustomChannelHeader", () => { - const mockTablos = [ - { - id: "test-channel", - name: "Test Tablo", - color: "bg-blue-500", - user_id: "user-id", - access_level: "admin", - is_admin: true, - created_at: "2024-01-01T00:00:00Z", - deleted_at: "2024-01-01T00:00:00Z", - position: 0, - status: "active", - image: null, - }, - ]; - - it("renders without crashing", () => { - render(); - expect(screen.getByTestId("channel-header")).toBeInTheDocument(); - }); - - it("renders ChannelHeader component", () => { - render(); - expect(screen.getByTestId("channel-header")).toBeInTheDocument(); - }); - - it("shows toggle button when showToggleButton is true", () => { - render( - - ); - const toggleButton = screen.getByLabelText("Toggle channel list"); - expect(toggleButton).toBeInTheDocument(); - }); - - it("hides toggle button when showToggleButton is false", () => { - render( - - ); - const toggleButton = screen.queryByLabelText("Toggle channel list"); - expect(toggleButton).not.toBeInTheDocument(); - }); - - it("calls onToggleChannelList when toggle button is clicked", () => { - const onToggleChannelList = vi.fn(); - render( - - ); - const toggleButton = screen.getByLabelText("Toggle channel list"); - fireEvent.click(toggleButton); - expect(onToggleChannelList).toHaveBeenCalled(); - }); - - it("applies rotation class when isChannelListExpanded is true", () => { - const { container } = render( - - ); - const svg = container.querySelector(".rotate-180"); - expect(svg).toBeInTheDocument(); - }); - - it("renders without toggle button when onToggleChannelList is not provided", () => { - render(); - const toggleButton = screen.queryByLabelText("Toggle channel list"); - expect(toggleButton).not.toBeInTheDocument(); - }); - - it("renders ChannelBadge with correct props", () => { - render(); - expect(screen.getByTestId("channel-badge")).toBeInTheDocument(); - }); -}); diff --git a/apps/main/src/components/CustomChannelHeader.tsx b/apps/main/src/components/CustomChannelHeader.tsx deleted file mode 100644 index bc7373c..0000000 --- a/apps/main/src/components/CustomChannelHeader.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import { ChannelHeader, useChannelStateContext } from "stream-chat-react"; -import { ChannelBadge } from "./ChannelBadge"; - -interface CustomChannelHeaderProps { - tablos: UserTablo[]; - onToggleChannelList?: () => void; - isChannelListExpanded?: boolean; - showToggleButton?: boolean; -} - -export const CustomChannelHeader = ({ - tablos, - onToggleChannelList, - isChannelListExpanded = false, - showToggleButton = true, -}: CustomChannelHeaderProps) => { - const { channel } = useChannelStateContext(); - - return ( -
- {showToggleButton && onToggleChannelList && ( - - )} - { - return ( - t.id === channel?.id) ?? null} - displayTitle={channel?.data?.config?.name} - isOnline={false} - /> - ); - }} - /> -
- ); -}; diff --git a/apps/main/src/components/DashboardTaskList.tsx b/apps/main/src/components/DashboardTaskList.tsx index bb9a93a..0746bf5 100644 --- a/apps/main/src/components/DashboardTaskList.tsx +++ b/apps/main/src/components/DashboardTaskList.tsx @@ -1,5 +1,6 @@ import { cn } from "@xtablo/shared"; import type { KanbanTask, TaskStatus } from "@xtablo/shared-types"; +import { TaskModal } from "@xtablo/tablo-views"; import { CheckCircle2, Plus } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,7 +8,6 @@ import { useNavigate } from "react-router-dom"; import { useTablosList } from "../hooks/tablos"; import { useAllTasks, useUpdateTask } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; -import { TaskModal } from "./kanban/TaskModal"; type TaskWithTablo = KanbanTask & { tablos: { id: string; name: string; color: string | null } | null; diff --git a/apps/main/src/components/Layout.tsx b/apps/main/src/components/Layout.tsx index a844ede..cf96f1b 100644 --- a/apps/main/src/components/Layout.tsx +++ b/apps/main/src/components/Layout.tsx @@ -54,11 +54,7 @@ export function Layout() { aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"} aria-expanded={isMobileMenuOpen} > - {isMobileMenuOpen ? ( - - ) : ( - - )} + {isMobileMenuOpen ? : } {/* Mobile backdrop overlay */} @@ -66,9 +62,7 @@ export function Layout() { className={twMerge( "fixed inset-0 z-40 bg-black/50 md:hidden", "transition-opacity duration-300 ease-in-out", - isMobileMenuOpen - ? "opacity-100 pointer-events-auto" - : "opacity-0 pointer-events-none" + isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" )} onClick={closeMobileMenu} aria-hidden="true" diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index 1195355..12ed900 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -163,6 +163,12 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { )} > + {organizationData?.organization?.logo_url && ( + + )} {organizationData?.organization?.name?.charAt(0).toUpperCase() ?? "O"} @@ -295,11 +301,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean className={twMerge( "group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-navbar-background transition-all duration-300", "h-full md:h-screen", - isMobileMenuOpen - ? "w-40" - : effectivelyCollapsed - ? "w-16" - : "w-48", + isMobileMenuOpen ? "w-40" : effectivelyCollapsed ? "w-16" : "w-48", "md:flex", // On mobile in standalone mode, respect safe area insets "pl-[env(safe-area-inset-left,0px)] pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]" @@ -346,7 +348,11 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean "hover:scale-110" )} > - {effectivelyCollapsed ?
diff --git a/apps/main/src/components/PlanAnnouncement.tsx b/apps/main/src/components/PlanAnnouncement.tsx index 9e2ed27..b68d05b 100644 --- a/apps/main/src/components/PlanAnnouncement.tsx +++ b/apps/main/src/components/PlanAnnouncement.tsx @@ -32,11 +32,11 @@ export function PlanAnnouncement() { const { active_subscription_plan } = organizationData; if (!active_subscription_plan) return; - const lastAnnouncedPlan = sessionStorage.getItem(PLAN_ANNOUNCED_KEY); + const lastAnnouncedPlan = localStorage.getItem(PLAN_ANNOUNCED_KEY); if (lastAnnouncedPlan === active_subscription_plan) return; hasAnnounced.current = true; - sessionStorage.setItem(PLAN_ANNOUNCED_KEY, active_subscription_plan); + localStorage.setItem(PLAN_ANNOUNCED_KEY, active_subscription_plan); const label = PLAN_LABELS[active_subscription_plan]; if (!label) return; diff --git a/apps/main/src/components/ProtectedRoute.test.tsx b/apps/main/src/components/ProtectedRoute.test.tsx index 457a53e..19143ed 100644 --- a/apps/main/src/components/ProtectedRoute.test.tsx +++ b/apps/main/src/components/ProtectedRoute.test.tsx @@ -80,11 +80,11 @@ describe("ProtectedRoute", () => { name: "Test User", email: "test@example.com", avatar_url: "https://example.com/avatar.jpg", - streamToken: null, short_user_id: "123", first_name: "Test", last_name: "User", is_temporary: false, + is_client: false, last_signed_in: null, plan: "none" as const, created_at: new Date().toISOString(), diff --git a/apps/main/src/components/SubscriptionCard.test.tsx b/apps/main/src/components/SubscriptionCard.test.tsx new file mode 100644 index 0000000..f065c5b --- /dev/null +++ b/apps/main/src/components/SubscriptionCard.test.tsx @@ -0,0 +1,202 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider"; +import { SubscriptionCard } from "./SubscriptionCard"; + +vi.mock("../hooks/organization", () => ({ + useOrganization: vi.fn(), +})); + +vi.mock("../hooks/stripe", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSubscription: vi.fn(), + useCreateCheckoutSession: () => ({ mutate: vi.fn(), isPending: false }), + useCreatePortalSession: () => ({ mutate: vi.fn(), isPending: false }), + useCancelSubscription: () => ({ mutate: vi.fn(), isPending: false }), + useReactivateSubscription: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("../hooks/auth", () => ({ + useAuthedApi: () => ({}), +})); + +import { useOrganization } from "../hooks/organization"; +import { useSubscription } from "../hooks/stripe"; + +const mockUseOrganization = vi.mocked(useOrganization); +const mockUseSubscription = vi.mocked(useSubscription); + +const baseUser: User = { + id: "user-1", + short_user_id: "u1", + name: "Test User", + first_name: "Test", + last_name: "User", + email: "test@example.com", + avatar_url: null, + is_temporary: false, + is_client: false, + last_signed_in: null, + plan: "none", + created_at: new Date().toISOString(), +}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function renderCard( + user: User, + orgData: ReturnType["data"], + subscription: ReturnType["data"] = undefined +) { + mockUseOrganization.mockReturnValue({ + data: orgData, + isLoading: false, + error: null, + } as ReturnType); + + mockUseSubscription.mockReturnValue({ + data: subscription, + isLoading: false, + error: null, + } as ReturnType); + + return render( + + + + + + ); +} + +const baseOrg = { + organization: { + id: 1, + name: "Org", + plan: "none", + member_count: 1, + tablo_count: 0, + logo_url: null, + }, + members: [], + invites_sent: [], + trial_starts_at: "2026-01-01", + trial_ends_at: "2026-02-01", + is_trial_expired: false, + required_plan: "solo" as const, + required_team_quantity: 1, + active_subscription_plan: null, + active_subscription_quantity: 0, + is_billing_owner: true, +}; + +describe("SubscriptionCard", () => { + it("shows 'Sans abonnement' badge when there is no subscription", () => { + renderCard(baseUser, baseOrg); + expect(screen.getByText("Sans abonnement")).toBeInTheDocument(); + }); + + it("shows Founder badge and unlimited info for annual plan", () => { + const founderOrg = { ...baseOrg, active_subscription_plan: "annual" as const }; + const founderUser = { ...baseUser, plan: "standard" as const }; + renderCard(founderUser, founderOrg); + expect(screen.getByText("Founder")).toBeInTheDocument(); + expect(screen.getByText(/Plan Founder \(annuel\)/)).toBeInTheDocument(); + }); + + it("shows recommended plan for non-paying billing owner", () => { + renderCard(baseUser, baseOrg); + expect(screen.getByText(/Plan recommandé: Solo/)).toBeInTheDocument(); + expect(screen.getByText(/Passer au plan Solo/)).toBeInTheDocument(); + expect(screen.getByText("Devenir Founder (99€/an)")).toBeInTheDocument(); + }); + + it("shows team as recommended plan when required", () => { + const teamOrg = { + ...baseOrg, + required_plan: "team" as const, + required_team_quantity: 3, + }; + renderCard(baseUser, teamOrg); + expect(screen.getByText(/Plan recommandé: Teams/)).toBeInTheDocument(); + expect(screen.getByText(/3 sièges Teams/)).toBeInTheDocument(); + }); + + it("shows billing owner restriction when user is not billing owner", () => { + const nonOwnerOrg = { ...baseOrg, is_billing_owner: false }; + renderCard(baseUser, nonOwnerOrg); + expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument(); + expect(screen.queryByText(/Passer au plan/)).not.toBeInTheDocument(); + }); + + it("shows active subscription details with manage and cancel buttons", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + active_subscription_quantity: 3, + }; + const activeSubscription = { + id: "sub_1", + status: "active", + current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30, + cancel_at_period_end: false, + }; + renderCard(baseUser, teamOrg, activeSubscription as any); + expect(screen.getByText("Actif")).toBeInTheDocument(); + expect(screen.getByText("Gérer l'abonnement")).toBeInTheDocument(); + expect(screen.getByText("Annuler")).toBeInTheDocument(); + }); + + it("shows trialing badge for trialing subscription", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + }; + const trialingSubscription = { + id: "sub_1", + status: "trialing", + current_period_end: Math.floor(Date.now() / 1000) + 86400 * 14, + cancel_at_period_end: false, + }; + renderCard(baseUser, teamOrg, trialingSubscription as any); + expect(screen.getByText("Période d'essai")).toBeInTheDocument(); + }); + + it("shows past_due badge for overdue subscription", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + }; + const pastDueSubscription = { + id: "sub_1", + status: "past_due", + current_period_end: Math.floor(Date.now() / 1000) - 86400, + cancel_at_period_end: false, + }; + renderCard(baseUser, teamOrg, pastDueSubscription as any); + expect(screen.getByText("Paiement en retard")).toBeInTheDocument(); + }); + + it("shows reactivation UI for canceled subscription", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + active_subscription_quantity: 2, + }; + const canceledSubscription = { + id: "sub_1", + status: "active", + current_period_end: Math.floor(Date.now() / 1000) + 86400 * 15, + cancel_at_period_end: true, + }; + renderCard(baseUser, teamOrg, canceledSubscription as any); + expect(screen.getByText(/Abonnement en cours d'annulation/)).toBeInTheDocument(); + expect(screen.getByText("Réactiver l'abonnement")).toBeInTheDocument(); + }); +}); diff --git a/apps/main/src/components/TabloDiscussionSection.test.tsx b/apps/main/src/components/TabloDiscussionSection.test.tsx index b288db9..08218e3 100644 --- a/apps/main/src/components/TabloDiscussionSection.test.tsx +++ b/apps/main/src/components/TabloDiscussionSection.test.tsx @@ -1,34 +1,21 @@ +import { TabloDiscussionSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloDiscussionSection } from "./TabloDiscussionSection"; -// Mock Stream Chat -vi.mock("stream-chat-react", () => ({ - Chat: ({ children }: { children: React.ReactNode }) =>
{children}
, - Channel: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Window: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - MessageList: () =>
Messages
, - MessageInput: () =>
Input
, - useChannelStateContext: () => ({ channel: null }), - useCreateChatClient: () => null, - useChatContext: () => ({ - client: null, - setActiveChannel: vi.fn(), +vi.mock("@xtablo/tablo-views/hooks/useChat", () => ({ + useChat: () => ({ + messages: [], + sendMessage: vi.fn(), + sendTyping: vi.fn(), + isConnected: false, + typingUsers: [], + onlineUsers: [], + loadMoreMessages: vi.fn(), + hasMoreMessages: false, + markAsRead: vi.fn(), }), })); -vi.mock("../providers/ChatProvider", () => ({ - useChatContext: () => ({ - client: null, - setActiveChannel: vi.fn(), - }), - default: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - describe("TabloDiscussionSection", () => { const mockTablo = { id: "test-tablo-id", @@ -46,7 +33,7 @@ describe("TabloDiscussionSection", () => { it("renders without crashing", () => { const { container } = renderWithProviders( - + ); expect(container).toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloDiscussionSection.tsx b/apps/main/src/components/TabloDiscussionSection.tsx deleted file mode 100644 index 64c7ac5..0000000 --- a/apps/main/src/components/TabloDiscussionSection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { CustomChannelHeader } from "@ui/components/CustomChannelHeader"; -import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import { useEffect, useState } from "react"; -import { Channel as StreamChannel } from "stream-chat"; -import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react"; -import ChatProvider from "../providers/ChatProvider"; -import { LoadingSpinner } from "./LoadingSpinner"; -import { TabloHeaderActions } from "./TabloHeaderActions"; - -interface TabloDiscussionSectionProps { - tablo: UserTablo; - isAdmin: boolean; -} - -const TabloChat = ({ tablo }: { tablo: UserTablo }) => { - const { client, setActiveChannel } = useChatContext(); - const [channel, setChannel] = useState(null); - - useEffect(() => { - const initChannel = async () => { - if (client && tablo.id) { - const newChannel = client.channel("messaging", tablo.id); - await newChannel.watch(); - setChannel(newChannel); - setActiveChannel(newChannel); - } - }; - - initChannel(); - }, [client, tablo.id, setActiveChannel]); - - if (!channel) { - return ( -
- -
- ); - } - - return ( - - - - - - - - ); -}; - -export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectionProps) => { - return ( -
-
-
-

Discussion

-

Conversations liées à ce tablo

-
- -
- -
- - - -
-
- ); -}; diff --git a/apps/main/src/components/TabloEventsSection.test.tsx b/apps/main/src/components/TabloEventsSection.test.tsx index c6cb26f..0fee1a3 100644 --- a/apps/main/src/components/TabloEventsSection.test.tsx +++ b/apps/main/src/components/TabloEventsSection.test.tsx @@ -1,8 +1,8 @@ +import { TabloEventsSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloEventsSection } from "./TabloEventsSection"; -vi.mock("../hooks/events", () => ({ +vi.mock("@xtablo/tablo-views/hooks/events", () => ({ useEventsByTablo: () => ({ data: [ { @@ -46,14 +46,14 @@ describe("TabloEventsSection", () => { it("renders without crashing", () => { const { container } = renderWithProviders( - + ); expect(container).toBeInTheDocument(); }); it("displays section title", () => { const { container } = renderWithProviders( - + ); // Just check that the component renders expect(container).toBeInTheDocument(); @@ -61,7 +61,7 @@ describe("TabloEventsSection", () => { it("displays events from the tablo", () => { const { container } = renderWithProviders( - + ); // Component should render the events section expect(container).toBeInTheDocument(); @@ -69,7 +69,7 @@ describe("TabloEventsSection", () => { it("shows add event button for admin users", () => { const { container } = renderWithProviders( - + ); // Component should render for admin users expect(container).toBeInTheDocument(); @@ -77,7 +77,7 @@ describe("TabloEventsSection", () => { it("navigates to events page when add button is clicked", () => { const { container } = renderWithProviders( - + ); // Component renders successfully expect(container).toBeInTheDocument(); @@ -85,7 +85,7 @@ describe("TabloEventsSection", () => { it("shows view all events link", () => { const { container } = renderWithProviders( - + ); // Component renders successfully expect(container).toBeInTheDocument(); @@ -93,7 +93,7 @@ describe("TabloEventsSection", () => { it("hides add button for non-admin users", () => { const { container } = renderWithProviders( - + ); // Component renders for non-admin users expect(container).toBeInTheDocument(); diff --git a/apps/main/src/components/TabloFilesSection.test.tsx b/apps/main/src/components/TabloFilesSection.test.tsx index 4abc5d7..5aedd89 100644 --- a/apps/main/src/components/TabloFilesSection.test.tsx +++ b/apps/main/src/components/TabloFilesSection.test.tsx @@ -1,6 +1,6 @@ +import { TabloFilesSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloFilesSection } from "./TabloFilesSection"; vi.mock("../hooks/files", () => ({ useTabloFileNames: () => ({ @@ -29,7 +29,12 @@ describe("TabloFilesSection", () => { it("renders without crashing", () => { const { container } = renderWithProviders( - + ); expect(container).toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloOverviewSection.tsx b/apps/main/src/components/TabloOverviewSection.tsx index f9c9e5a..d111405 100644 --- a/apps/main/src/components/TabloOverviewSection.tsx +++ b/apps/main/src/components/TabloOverviewSection.tsx @@ -1,5 +1,6 @@ import { toast } from "@xtablo/shared"; import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { TabloHeaderActions } from "@xtablo/tablo-views"; import { Button } from "@xtablo/ui/components/button"; import { Input } from "@xtablo/ui/components/input"; import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography"; @@ -16,7 +17,6 @@ import { } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; import { getEtapeProgressStats } from "../utils/etapeProgress"; -import { TabloHeaderActions } from "./TabloHeaderActions"; interface TabloOverviewSectionProps { tablo: UserTablo; @@ -289,7 +289,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro {t("tablo:overview.description")}
- +
{!canManageEtapes && ( diff --git a/apps/main/src/components/UpgradePanel.test.tsx b/apps/main/src/components/UpgradePanel.test.tsx new file mode 100644 index 0000000..3b00d34 --- /dev/null +++ b/apps/main/src/components/UpgradePanel.test.tsx @@ -0,0 +1,147 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpgradeBlockProvider } from "../contexts/UpgradeBlockContext"; +import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider"; +import { UpgradePanel } from "./UpgradePanel"; + +vi.mock("../hooks/organization", () => ({ + useOrganization: vi.fn(), +})); + +vi.mock("../hooks/stripe", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCreateCheckoutSession: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("../hooks/auth", () => ({ + useLogout: () => ({ mutate: vi.fn() }), + useAuthedApi: () => ({}), +})); + +import { useOrganization } from "../hooks/organization"; + +const mockUseOrganization = vi.mocked(useOrganization); + +const baseUser: User = { + id: "user-1", + short_user_id: "u1", + name: "Test User", + first_name: "Test", + last_name: "User", + email: "test@example.com", + avatar_url: null, + is_temporary: false, + is_client: false, + last_signed_in: null, + plan: "none", + created_at: new Date().toISOString(), +}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function renderPanel(user: User, orgData: ReturnType["data"]) { + mockUseOrganization.mockReturnValue({ + data: orgData, + isLoading: false, + error: null, + } as ReturnType); + + return render( + + + + + + + + ); +} + +const noPlanOrg = { + organization: { + id: 1, + name: "Org", + plan: "none", + member_count: 1, + tablo_count: 0, + logo_url: null, + }, + members: [], + invites_sent: [], + trial_starts_at: "2026-01-01", + trial_ends_at: "2026-02-01", + is_trial_expired: false, + required_plan: "solo" as const, + required_team_quantity: 1, + active_subscription_plan: null, + active_subscription_quantity: 0, + is_billing_owner: true, +}; + +const trialExpiredOrg = { + ...noPlanOrg, + is_trial_expired: true, + required_plan: "team" as const, + required_team_quantity: 3, + active_subscription_plan: "team" as const, + active_subscription_quantity: 1, + is_billing_owner: true, +}; + +const compliantOrg = { + ...noPlanOrg, + active_subscription_plan: "team" as const, + active_subscription_quantity: 2, + is_billing_owner: true, +}; + +describe("UpgradePanel", () => { + it("renders nothing when user has a compliant subscription", () => { + const { container } = renderPanel(baseUser, compliantOrg); + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing for temporary users even with no plan", () => { + const tempUser = { ...baseUser, is_temporary: true }; + const { container } = renderPanel(tempUser, noPlanOrg); + expect(container.innerHTML).toBe(""); + }); + + it("renders the paywall for regular users with no plan", () => { + renderPanel(baseUser, noPlanOrg); + expect(screen.getByText("Choisissez un abonnement pour continuer")).toBeInTheDocument(); + }); + + it("renders trial expired message when trial is expired", () => { + renderPanel(baseUser, trialExpiredOrg); + expect(screen.getByText("Votre période d'essai est terminée")).toBeInTheDocument(); + }); + + it("shows checkout buttons for billing owner", () => { + renderPanel(baseUser, noPlanOrg); + expect(screen.getByText("Passer au plan Solo")).toBeInTheDocument(); + expect(screen.getByText(/Passer au plan Teams/)).toBeInTheDocument(); + expect(screen.getByText("Devenir Founder (99€/an)")).toBeInTheDocument(); + }); + + it("disables buttons for non-billing-owner and shows warning", () => { + const nonOwnerOrg = { ...noPlanOrg, is_billing_owner: false }; + renderPanel(baseUser, nonOwnerOrg); + + const soloButton = screen.getByText("Passer au plan Solo").closest("button"); + expect(soloButton).toBeDisabled(); + + expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument(); + }); + + it("renders nothing when org data is not yet loaded", () => { + const { container } = renderPanel(baseUser, undefined); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx index aa2cbc9..12db069 100644 --- a/apps/main/src/components/UpgradePanel.tsx +++ b/apps/main/src/components/UpgradePanel.tsx @@ -129,7 +129,8 @@ export function UpgradePanel() { disabled={checkoutPending || !isBillingOwner} className="w-full" > - Passer au plan Teams ({requiredTeamQuantity} siège{requiredTeamQuantity > 1 ? "s" : ""}) + Passer au plan Teams ({requiredTeamQuantity} siège + {requiredTeamQuantity > 1 ? "s" : ""})
- {organizationData?.active_subscription_plan === "annual" && ( - - Founder - - )} - {organizationData?.active_subscription_plan === "team" && ( - Teams - )} - {organizationData?.active_subscription_plan === "solo" && ( - Solo - )}
@@ -541,7 +529,9 @@ export default function SettingsPage() {
- )} -
- - {/* Pending Invites */} - {pendingInvites && pendingInvites.length > 0 && ( -
-

- Invitations en attente ({pendingInvites.length}) -

-
- {pendingInvites.map((invite) => ( -
-
- - - -
-
- - {invite.invited_email} - -
- -
- ))} -
-
- )} - {/* Members List */} {filteredMembers && filteredMembers.length > 0 && (
@@ -935,373 +999,125 @@ export const TabloDetailsPage = () => {
)} + + {/* Separator */} +
+ {/* Client Access Section */} +
+

Accès client

+

+ Invitez des clients externes via un lien magique +

+
+ + {/* Client Invite Input */} +
+ setClientInviteEmail(e.target.value)} + placeholder="Email du client" + className="flex-1 min-h-[44px]" + /> + {isCreatingClientInvite ? ( +
+
+
+ ) : ( + + )} +
+ + {/* Pending Client Invites */} + {pendingClientInvites && pendingClientInvites.length > 0 && ( +
+

+ Invitations client en attente ({pendingClientInvites.length}) +

+
+ {pendingClientInvites.map((invite) => { + const daysUntilExpiry = Math.ceil( + (new Date(invite.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + const isExpiringSoon = daysUntilExpiry < 5; + return ( +
+
+ + + +
+
+ + {invite.invited_email} + + + {isExpiringSoon && "⚠ "} + Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? "s" : ""} + +
+ {isExpiringSoon && ( + + Bientôt expiré + + )} + +
+ ); + })} +
+
+ )} +
); }; - -// ─── Etapes (Steps) section ───────────────────────────────────────────────── - -function EtapesSection({ - etapes, - tabloTasks, - tabloId, - isAdmin, -}: { - etapes: Etape[]; - tabloTasks: KanbanTask[]; - tabloId: string; - isAdmin: boolean; -}) { - const [expandedEtapes, setExpandedEtapes] = useState>( - new Set(etapes.map((e) => e.id)) - ); - const [addingTaskToEtape, setAddingTaskToEtape] = useState(null); - const [newEtapeTitle, setNewEtapeTitle] = useState(""); - const [newTaskTitle, setNewTaskTitle] = useState(""); - const { mutate: createTask } = useCreateTask(); - const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape(); - - const toggleEtape = (id: string) => { - setExpandedEtapes((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const handleAddTask = (etapeId: string) => { - const title = newTaskTitle.trim(); - if (!title || !tabloId) return; - createTask({ - tablo_id: tabloId, - title, - status: "todo", - parent_task_id: etapeId, - is_parent: false, - position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length, - }); - setNewTaskTitle(""); - setAddingTaskToEtape(null); - }; - - const handleAddEtape = async () => { - const title = newEtapeTitle.trim(); - if (!title || !tabloId) { - return; - } - - const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1; - - await createEtape({ - tabloId, - title, - position: nextPosition, - }); - - setNewEtapeTitle(""); - }; - - const statusConfig: Record = { - todo: { - label: "À faire", - color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", - }, - in_progress: { - label: "En cours", - color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400", - }, - in_review: { - label: "Vérification", - color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400", - }, - done: { - label: "Terminé", - color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400", - }, - }; - - return ( -
- {isAdmin && ( -
- setNewEtapeTitle(event.target.value)} - placeholder="Nom de la nouvelle étape..." - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleAddEtape(); - } - }} - className="h-11 sm:h-9 sm:w-80" - /> - -
- )} - - {etapes.length === 0 ? ( -
- -

Aucune étape

-

- Les étapes permettent de structurer votre projet en grandes phases -

-
- ) : ( - etapes.map((etape, index) => { - const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id); - const doneCount = childTasks.filter((t) => t.status === "done").length; - const totalCount = childTasks.length; - const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; - const isExpanded = expandedEtapes.has(etape.id); - - // Derive status from child tasks instead of etape.status - const derivedStatus = - totalCount === 0 - ? "todo" - : doneCount === totalCount - ? "done" - : doneCount > 0 - ? "in_progress" - : "todo"; - const status = statusConfig[derivedStatus] ?? statusConfig.todo; - - return ( -
- {/* Etape header */} - - - {/* Child tasks + add task */} - {isExpanded && ( -
- {childTasks.length > 0 && ( -
- {childTasks.map((task) => ( -
- {task.status === "done" ? ( - - ) : ( -
- )} - - {task.title} - - {task.due_date && ( -
- - - {new Intl.DateTimeFormat("fr-FR", { - day: "2-digit", - month: "short", - }).format(new Date(task.due_date))} - -
- )} - {task.status && ( - - {(statusConfig[task.status] ?? statusConfig.todo).label} - - )} -
- ))} -
- )} - - {childTasks.length === 0 && addingTaskToEtape !== etape.id && ( -
- Aucune tâche dans cette étape -
- )} - - {/* Inline add task */} - {addingTaskToEtape === etape.id ? ( -
-
- setNewTaskTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddTask(etape.id); - if (e.key === "Escape") { - setAddingTaskToEtape(null); - setNewTaskTitle(""); - } - }} - placeholder="Nom de la tâche..." - className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0" - /> - - -
- ) : ( - - )} -
- )} -
- ); - }) - )} -
- ); -} - -// ─── Roadmap Section ───────────────────────────────────────────────────────── - -function RoadmapSection({ - tabloTasks, - onDateClick, -}: { - etapes: Etape[]; - tabloTasks: KanbanTask[]; - onDateClick: (date: Date) => void; -}) { - const { mutate: updateTask } = useUpdateTask(); - - return ( - updateTask({ id: taskId, status })} - /> - ); -} diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index 6221881..2a1caa3 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -3,6 +3,8 @@ import { DeleteTabloModal } from "@ui/components/DeleteTabloModal"; import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import { toast } from "@xtablo/shared"; import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types"; +import { TaskModal } from "@xtablo/tablo-views"; +import { Badge } from "@xtablo/ui/components/badge"; import { Button } from "@xtablo/ui/components/button"; import { Empty, @@ -40,11 +42,9 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { DashboardActionCards } from "src/components/DashboardActionCards"; import { DashboardTaskList } from "src/components/DashboardTaskList"; import { InviteOrganizationModal } from "src/components/InviteOrganizationModal"; -import { TaskModal } from "src/components/kanban/TaskModal"; import { ProjectCardList } from "src/components/ProjectCardList"; -import { Badge } from "@xtablo/ui/components/badge"; -import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; import { useOrganization } from "../hooks/organization"; +import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; function getTabloIcon(color: string | null | undefined) { @@ -107,7 +107,7 @@ export const TabloPage = () => { const user = useUser(); const { data: organizationData } = useOrganization(); - const isReadOnly = isReadOnlyUser || !canCreateTablo; + const isReadOnly = isReadOnlyUser || canCreateTablo === false; const getGreeting = () => { const hour = new Date().getHours(); diff --git a/apps/main/src/pages/tablos.tsx b/apps/main/src/pages/tablos.tsx index 87a51fe..66b59c4 100644 --- a/apps/main/src/pages/tablos.tsx +++ b/apps/main/src/pages/tablos.tsx @@ -128,16 +128,18 @@ function TabloCard({ {label} - + {tablo.is_admin && ( + + )}
{/* Icon + name */} @@ -260,16 +262,18 @@ function TabloRow({
- + {tablo.is_admin && ( + + )} ); diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index f4fa3f9..40c6568 100644 --- a/apps/main/src/pages/tasks.tsx +++ b/apps/main/src/pages/tasks.tsx @@ -1,5 +1,6 @@ import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types"; +import { GanttChart, TaskModal } from "@xtablo/tablo-views"; import { Button } from "@xtablo/ui/components/button"; import { DropdownMenu, @@ -40,8 +41,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { twMerge } from "tailwind-merge"; -import { GanttChart } from "../components/gantt/GanttChart"; -import { TaskModal } from "../components/kanban/TaskModal"; import { useTablosList } from "../hooks/tablos"; import { useAllTasks, useUpdateTask } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; diff --git a/apps/main/src/providers/ChatProvider.test.tsx b/apps/main/src/providers/ChatProvider.test.tsx deleted file mode 100644 index 35667c4..0000000 --- a/apps/main/src/providers/ChatProvider.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "../utils/testHelpers"; -import ChatProvider from "./ChatProvider"; - -// Mock Stream Chat -vi.mock("stream-chat", () => ({ - StreamChat: { - getInstance: vi.fn(() => ({ - connectUser: vi.fn(), - disconnectUser: vi.fn(), - })), - }, - StateStore: vi.fn(), - FixedSizeQueueCache: vi.fn(), -})); - -vi.mock("stream-chat-react", () => ({ - Chat: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - useCreateChatClient: () => ({ id: "test-client" }), -})); - -vi.mock("@xtablo/shared/contexts/SessionContext", async () => { - const actual = await vi.importActual("@xtablo/shared/contexts/SessionContext"); - return { - ...actual, - useSession: () => ({ - session: { - access_token: "test-token", - }, - }), - }; -}); - -describe("ChatProvider", () => { - it("renders children", () => { - renderWithProviders( - -
Test Child
-
- ); - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - it("renders without crashing", () => { - const { container } = renderWithProviders( - -
Content
-
- ); - expect(container).toBeInTheDocument(); - }); -}); diff --git a/apps/main/src/providers/ChatProvider.tsx b/apps/main/src/providers/ChatProvider.tsx deleted file mode 100644 index 037f953..0000000 --- a/apps/main/src/providers/ChatProvider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { LoadingSpinner } from "@ui/components/LoadingSpinner"; -import { Chat, useCreateChatClient } from "stream-chat-react"; -import { useUser } from "./UserStoreProvider"; - -export default function ChatProvider({ children }: { children: React.ReactNode }) { - const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string; - const user = useUser(); - const client = useCreateChatClient({ - apiKey, - options: { timeout: 5000 }, - tokenOrProvider: user.streamToken, - userData: { - id: user.id, - name: user.name ?? "", - }, - }); - - if (!user.streamToken) { - return ( -
-
-

Chat Indisponible

-

- Impossible de se connecter au service de chat. Veuillez essayer de rafraîchir la page. -

-
-
- ); - } - - if (!client) { - return ; - } - - return ( - - {children} - - ); -} diff --git a/apps/main/src/providers/UserStoreProvider.test.tsx b/apps/main/src/providers/UserStoreProvider.test.tsx index 777a3c8..7f7e908 100644 --- a/apps/main/src/providers/UserStoreProvider.test.tsx +++ b/apps/main/src/providers/UserStoreProvider.test.tsx @@ -8,7 +8,6 @@ vi.mock("@tanstack/react-query", () => ({ data: { id: "test-user-id", name: "Test User", - streamToken: null, }, isPending: false, }), @@ -29,7 +28,6 @@ vi.mock("../lib/api", () => ({ data: { id: "test-user-id", name: "Test User", - streamToken: null, }, }) ), @@ -60,11 +58,11 @@ describe("TestUserStoreProvider", () => { const mockUser = { id: "test-user-id", name: "Test User", - streamToken: null, avatar_url: null, email: null, first_name: null, is_temporary: false, + is_client: false, last_name: null, short_user_id: "short-id", last_signed_in: null, diff --git a/apps/main/src/providers/UserStoreProvider.tsx b/apps/main/src/providers/UserStoreProvider.tsx index 1e88f06..e0d6e5f 100644 --- a/apps/main/src/providers/UserStoreProvider.tsx +++ b/apps/main/src/providers/UserStoreProvider.tsx @@ -7,9 +7,7 @@ import { LoadingSpinner } from "../components/LoadingSpinner"; import { api } from "../lib/api"; import datadogRum from "../lib/rum"; -export type User = Tables<"profiles"> & { - streamToken: string | null; -}; +export type User = Tables<"profiles">; const UserStoreContext = React.createContext | null>(null); diff --git a/apps/main/src/utils/testHelpers.tsx b/apps/main/src/utils/testHelpers.tsx index f9cd0ad..f370ba2 100644 --- a/apps/main/src/utils/testHelpers.tsx +++ b/apps/main/src/utils/testHelpers.tsx @@ -17,8 +17,8 @@ const defaultUser = { last_name: "Doe", email: "john@example.com", avatar_url: "https://example.com/avatar.jpg", - streamToken: "test-stream-token", is_temporary: false, + is_client: false, last_signed_in: null, plan: "none" as const, created_at: new Date().toISOString(), diff --git a/apps/main/vite.config.ts b/apps/main/vite.config.ts index 87270d7..59e1e45 100644 --- a/apps/main/vite.config.ts +++ b/apps/main/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => { react(), visualizer() as PluginOption, tailwindcss(), - tsconfigPaths(), + tsconfigPaths({ ignoreConfigErrors: true }), ]; plugins.push( diff --git a/apps/main/worker-configuration.d.ts b/apps/main/worker-configuration.d.ts index 218a7ca..304cb21 100644 --- a/apps/main/worker-configuration.d.ts +++ b/apps/main/worker-configuration.d.ts @@ -10,7 +10,6 @@ declare namespace Cloudflare { VITE_SUPABASE_URL: string; VITE_SUPABASE_ANON_KEY: string; VITE_SUPABASE_ID: string; - VITE_STREAM_CHAT_API_KEY: string; VITE_API_URL: string; } } diff --git a/docs/superpowers/plans/2026-04-11-self-hosted-chat.md b/docs/superpowers/plans/2026-04-11-self-hosted-chat.md new file mode 100644 index 0000000..efc18c7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-self-hosted-chat.md @@ -0,0 +1,2066 @@ +# Self-Hosted Chat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Stream Chat with a self-hosted chat system using Cloudflare Durable Objects for real-time WebSocket messaging, Supabase Postgres for message persistence, and chatscope for the React UI. + +**Architecture:** A new Cloudflare Worker (`apps/chat-worker`) handles WebSocket connections via Durable Objects (one per tablo channel) and REST endpoints for message history/unread counts. Messages persist to Supabase Postgres via PostgREST. The frontend replaces `stream-chat-react` with `@chatscope/chat-ui-kit-react` components wired to a custom `useChat` hook. + +**Tech Stack:** Cloudflare Workers + Durable Objects, Supabase Postgres (PostgREST), @chatscope/chat-ui-kit-react, TypeScript, Hono (Worker routing) + +**Spec:** `docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md` + +--- + +## File Structure + +### New files + +``` +apps/chat-worker/ + package.json # Worker package with wrangler, hono, jose deps + wrangler.toml # DO bindings, env vars (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET) + tsconfig.json # TypeScript config for Workers runtime + src/ + index.ts # Hono Worker entry: JWT auth, routing, DO dispatch + durable-objects/ + ChatRoom.ts # Hibernatable WebSocket DO — broadcast, typing, presence + lib/ + supabase.ts # PostgREST helper (fetch-based, no SDK) + auth.ts # JWT verification using jose + types.ts # WebSocket message types, API response types + +apps/main/src/ + hooks/ + useChat.ts # WebSocket connection, message send/receive, reconnect, typing + useChatUnread.ts # Polls /chat/unread endpoint + pages/ + chat.tsx # REWRITE: replace Stream components with chatscope + components/ + ChatChannelPreview.tsx # chatscope Conversation wrapper (replaces ChannelPreview.tsx) + ChatHeader.tsx # Channel header (replaces CustomChannelHeader.tsx) +``` + +### Modified files + +``` +apps/main/src/lib/routes.tsx # Remove ChatProvider wrapper from chat route +apps/main/src/providers/UserStoreProvider.tsx # Remove streamToken from User type +apps/main/package.json # Remove stream-chat, stream-chat-react; add @chatscope/chat-ui-kit-react +apps/main/.env.local # Remove VITE_STREAM_CHAT_API_KEY, add VITE_CHAT_WS_URL +apps/main/.env.staging # Same +apps/main/.env.production # Same + +apps/api/src/config.ts # Remove STREAM_CHAT_API_KEY, STREAM_CHAT_API_SECRET +apps/api/src/secrets.ts # Remove streamChatApiSecret, streamChatApiSecretStaging +apps/api/src/types/app.types.ts # Remove streamServerClient from BaseEnv +apps/api/src/middlewares/middleware.ts # Remove streamChatMiddleware +apps/api/src/routers/index.ts # Remove streamChat middleware usage +apps/api/src/routers/user.ts # Remove signUpToStream, streamToken from getMe +apps/api/src/routers/tablo.ts # Remove all Stream channel operations +apps/api/src/helpers/helpers.ts # Remove streamServerClient from createInvitedUser +apps/api/package.json # Remove stream-chat dependency +``` + +### Deleted files + +``` +apps/main/src/providers/ChatProvider.tsx # Stream Chat provider — replaced by useChat +apps/main/src/components/ChannelPreview.tsx # Stream-specific — replaced by ChatChannelPreview +apps/main/src/components/CustomChannelHeader.tsx # Stream-specific — replaced by ChatHeader +apps/main/src/hooks/channel.ts # useChannelFromUrl, useTabloDiscussionUnread — replaced +``` + +### Kept as-is + +``` +apps/main/src/components/ChannelBadge.tsx # Generic component, reused in new chat UI +``` + +--- + +## Task 1: Database Migration — Create messages and channel_read_state tables + +**Files:** +- Create: `supabase/migrations/20260411_create_chat_tables.sql` + +This task creates the Postgres tables that the chat system writes to and reads from. Everything else depends on these existing. + +- [ ] **Step 1: Write the migration SQL** + +```sql +-- supabase/migrations/20260411_create_chat_tables.sql + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz, + deleted_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); + +-- Read state table +CREATE TABLE IF NOT EXISTS channel_read_state ( + user_id uuid NOT NULL REFERENCES auth.users(id), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + last_read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id) +); + +-- RLS policies +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE channel_read_state ENABLE ROW LEVEL SECURITY; + +-- Messages: users can read messages in channels they are members of +CREATE POLICY "Users can read messages in their tablos" + ON messages FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tablo_access + WHERE tablo_access.tablo_id = messages.channel_id + AND tablo_access.user_id = auth.uid() + AND tablo_access.is_active = true + ) + ); + +-- Messages: service role inserts (from chat worker) bypass RLS +-- No INSERT policy needed — the chat worker uses the service role key + +-- Read state: users can read/write their own read state +CREATE POLICY "Users can manage their own read state" + ON channel_read_state FOR ALL + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); +``` + +- [ ] **Step 2: Apply the migration** + +Run: `npx supabase db push` (or apply via Supabase dashboard if using hosted migrations) + +Expected: Tables `messages` and `channel_read_state` created with indexes and RLS policies. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260411_create_chat_tables.sql +git commit -m "feat(chat): add messages and channel_read_state tables" +``` + +--- + +## Task 2: Chat Worker — Project scaffold and configuration + +**Files:** +- Create: `apps/chat-worker/package.json` +- Create: `apps/chat-worker/tsconfig.json` +- Create: `apps/chat-worker/wrangler.toml` +- Create: `apps/chat-worker/src/lib/types.ts` + +This task sets up the new Cloudflare Worker project in the monorepo with proper configuration. + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@xtablo/chat-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "deploy:staging": "wrangler deploy --env staging", + "deploy:prod": "wrangler deploy --env production", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.7", + "jose": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250410.0", + "typescript": "^5.8.3", + "wrangler": "^4.14.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create wrangler.toml** + +```toml +name = "xtablo-chat" +main = "src/index.ts" +compatibility_date = "2025-07-09" + +[durable_objects] +bindings = [ + { name = "CHAT_ROOM", class_name = "ChatRoom" } +] + +[[migrations]] +tag = "v1" +new_classes = ["ChatRoom"] + +[observability] +enabled = true + +[vars] +SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co" + +# Secrets (set via `wrangler secret put`): +# SUPABASE_SERVICE_ROLE_KEY +# JWT_SECRET + +[env.staging] +route = { pattern = "chat-staging.xtablo.com", custom_domain = true } + +[env.production] +route = { pattern = "chat.xtablo.com", custom_domain = true } +``` + +- [ ] **Step 4: Create shared types** + +Create `apps/chat-worker/src/lib/types.ts`: + +```typescript +// WebSocket message types — client to server +export type ClientMessage = + | { type: "message.send"; text: string; clientId: string } + | { type: "typing.start" } + | { type: "typing.stop" } + | { type: "presence.ping" }; + +// WebSocket message types — server to client +export type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +// REST API types +export interface ChatMessage { + id: string; + channel_id: string; + user_id: string; + text: string; + created_at: string; + updated_at: string | null; + deleted_at: string | null; +} + +export interface UnreadCount { + channel_id: string; + unread_count: number; +} + +// Worker environment bindings +export interface Env { + CHAT_ROOM: DurableObjectNamespace; + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + JWT_SECRET: string; +} +``` + +- [ ] **Step 5: Install dependencies** + +Run: `cd apps/chat-worker && pnpm install` + +- [ ] **Step 6: Commit** + +```bash +git add apps/chat-worker/ +git commit -m "feat(chat-worker): scaffold Cloudflare Worker project" +``` + +--- + +## Task 3: Chat Worker — JWT auth and Supabase PostgREST helper + +**Files:** +- Create: `apps/chat-worker/src/lib/auth.ts` +- Create: `apps/chat-worker/src/lib/supabase.ts` + +- [ ] **Step 1: Create JWT auth helper** + +Create `apps/chat-worker/src/lib/auth.ts`: + +```typescript +import { jwtVerify, createRemoteJWKSet } from "jose"; + +interface AuthResult { + userId: string; + email: string | null; +} + +/** + * Verify a Supabase JWT and extract the user ID. + * Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim. + */ +export async function verifyJwt(token: string, jwtSecret: string): Promise { + const secret = new TextEncoder().encode(jwtSecret); + const { payload } = await jwtVerify(token, secret, { + issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1", + }); + + if (!payload.sub) { + throw new Error("Missing sub claim in JWT"); + } + + return { + userId: payload.sub, + email: (payload.email as string) ?? null, + }; +} + +/** + * Extract Bearer token from Authorization header. + */ +export function extractToken(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith("Bearer ")) return null; + return authHeader.slice(7); +} +``` + +- [ ] **Step 2: Create Supabase PostgREST helper** + +Create `apps/chat-worker/src/lib/supabase.ts`: + +```typescript +/** + * Thin PostgREST client using fetch — no Supabase SDK dependency. + * Used by both the Worker (history queries) and the Durable Object (message persistence). + */ +export class PostgREST { + private baseUrl: string; + private serviceRoleKey: string; + + constructor(supabaseUrl: string, serviceRoleKey: string) { + this.baseUrl = `${supabaseUrl}/rest/v1`; + this.serviceRoleKey = serviceRoleKey; + } + + private headers(): Record { + return { + "apikey": this.serviceRoleKey, + "Authorization": `Bearer ${this.serviceRoleKey}`, + "Content-Type": "application/json", + "Prefer": "return=representation", + }; + } + + /** Insert a row and return the inserted data. */ + async insert(table: string, data: Record): Promise { + const res = await fetch(`${this.baseUrl}/${table}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST insert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Upsert a row (requires Prefer: resolution=merge-duplicates). */ + async upsert(table: string, data: Record, onConflict: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "return=representation,resolution=merge-duplicates"; + const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST upsert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select rows with PostgREST query string. */ + async select(table: string, query: string): Promise { + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "GET", + headers: this.headers(), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST select failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select with exact count header for unread queries. */ + async count(table: string, query: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "count=exact"; + headers["Range-Unit"] = "items"; + headers["Range"] = "0-0"; + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "HEAD", + headers, + }); + const contentRange = res.headers.get("Content-Range"); + if (!contentRange) return 0; + // Content-Range format: "0-0/42" or "*/0" + const total = contentRange.split("/")[1]; + return total === "*" ? 0 : parseInt(total, 10); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/chat-worker/src/lib/ +git commit -m "feat(chat-worker): add JWT auth and PostgREST helpers" +``` + +--- + +## Task 4: Chat Worker — ChatRoom Durable Object + +**Files:** +- Create: `apps/chat-worker/src/durable-objects/ChatRoom.ts` + +This is the core real-time component. One instance per tablo channel, managing WebSocket connections, broadcasting messages, and persisting to Postgres. + +- [ ] **Step 1: Create the ChatRoom Durable Object** + +Create `apps/chat-worker/src/durable-objects/ChatRoom.ts`: + +```typescript +import { DurableObject } from "cloudflare:workers"; +import type { Env, ClientMessage, ServerMessage } from "../lib/types"; +import { PostgREST } from "../lib/supabase"; + +export class ChatRoom extends DurableObject { + private postgrest: PostgREST | null = null; + + private getPostgREST(): PostgREST { + if (!this.postgrest) { + this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY); + } + return this.postgrest; + } + + /** + * Called by the Worker to initiate a WebSocket connection. + * The userId has already been authenticated by the Worker. + */ + async handleWebSocket(request: Request, userId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + // Accept with userId as a tag for filtering later + this.ctx.acceptWebSocket(server, [userId]); + + // Broadcast presence to existing connections + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + /** + * Hibernatable WebSocket handler — called when a message arrives. + */ + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (!userId) { + ws.close(4001, "Missing user identity"); + return; + } + + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" }); + return; + } + + switch (msg.type) { + case "message.send": + await this.handleSendMessage(ws, userId, msg.text, msg.clientId); + break; + case "typing.start": + this.broadcast({ type: "typing", userId, isTyping: true }, ws); + break; + case "typing.stop": + this.broadcast({ type: "typing", userId, isTyping: false }, ws); + break; + case "presence.ping": + // No-op — the connection itself is the presence signal + break; + } + } + + /** + * Hibernatable WebSocket handler — called when a connection closes. + */ + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (userId) { + // Only broadcast offline if no other connections for this user + const remaining = this.ctx.getWebSockets(userId); + if (remaining.length === 0) { + this.broadcast({ type: "presence.update", userId, status: "offline" }); + } + } + } + + /** + * Hibernatable WebSocket handler — called on error. + */ + async webSocketError(ws: WebSocket, error: unknown): Promise { + console.error("WebSocket error:", error); + ws.close(1011, "Internal error"); + } + + private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise { + if (!text || text.trim().length === 0) { + this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" }); + return; + } + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + // Extract channelId from the DO's own ID name + // The Worker creates the DO with id = channelId, so we read it from ctx.id + const channelId = this.getChannelId(); + + const serverMsg: ServerMessage = { + type: "message.new", + id, + userId, + text: text.trim(), + createdAt, + clientId, + }; + + // Broadcast to all connections (including sender, for server echo) + this.broadcast(serverMsg); + + // Persist to Postgres asynchronously (fire-and-forget with retry) + this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt)); + } + + private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise { + const db = this.getPostgREST(); + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await db.insert("messages", { + id, + channel_id: channelId, + user_id: userId, + text, + created_at: createdAt, + }); + return; + } catch (error) { + console.error(`Message persist attempt ${attempt + 1} failed:`, error); + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); + } + } + } + console.error(`Failed to persist message ${id} after ${maxRetries} attempts`); + } + + /** + * Get the channel ID from the Durable Object's name. + * The Worker creates the DO ID using `env.CHAT_ROOM.idFromName(channelId)`. + */ + private getChannelId(): string { + // The DO name is set by the Worker when creating the stub. + // We store it on first WebSocket connect via the request URL. + // Fallback: use the hex ID (not ideal but safe). + return this.ctx.id.toString(); + } + + /** Send a typed message to a single WebSocket. */ + private sendTo(ws: WebSocket, msg: ServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection already closed + } + } + + /** Broadcast a typed message to all connected WebSockets, optionally excluding one. */ + private broadcast(msg: ServerMessage, exclude?: WebSocket): void { + const payload = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets()) { + if (ws !== exclude) { + try { + ws.send(payload); + } catch { + // Connection already closed, skip + } + } + } + } +} +``` + +Note on `getChannelId()`: The DO's own hex ID isn't the channel UUID. We need the channel ID for Postgres writes. We'll pass it via the WebSocket URL path and store it. Let me fix this: + +Actually, the cleanest approach: the Worker passes the channelId as a query param in the internal DO request URL. The DO reads it on first WebSocket accept and stores it in transactional storage. Let me update the implementation: + +Replace the `handleWebSocket` and `getChannelId` methods: + +```typescript + async handleWebSocket(request: Request, userId: string, channelId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + // Store channelId if not already stored + const stored = await this.ctx.storage.get("channelId"); + if (!stored) { + await this.ctx.storage.put("channelId", channelId); + } + + this.ctx.acceptWebSocket(server, [userId]); + + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + private async getChannelId(): Promise { + const channelId = await this.ctx.storage.get("channelId"); + if (!channelId) throw new Error("channelId not stored in DO"); + return channelId; + } +``` + +And update `handleSendMessage` to `await this.getChannelId()`. + +The full file should incorporate these changes. Here is the complete `ChatRoom.ts`: + +```typescript +import { DurableObject } from "cloudflare:workers"; +import type { Env, ClientMessage, ServerMessage } from "../lib/types"; +import { PostgREST } from "../lib/supabase"; + +export class ChatRoom extends DurableObject { + private postgrest: PostgREST | null = null; + + private getPostgREST(): PostgREST { + if (!this.postgrest) { + this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY); + } + return this.postgrest; + } + + async handleWebSocket(request: Request, userId: string, channelId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + const stored = await this.ctx.storage.get("channelId"); + if (!stored) { + await this.ctx.storage.put("channelId", channelId); + } + + this.ctx.acceptWebSocket(server, [userId]); + + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (!userId) { + ws.close(4001, "Missing user identity"); + return; + } + + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" }); + return; + } + + switch (msg.type) { + case "message.send": + await this.handleSendMessage(ws, userId, msg.text, msg.clientId); + break; + case "typing.start": + this.broadcast({ type: "typing", userId, isTyping: true }, ws); + break; + case "typing.stop": + this.broadcast({ type: "typing", userId, isTyping: false }, ws); + break; + case "presence.ping": + break; + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (userId) { + const remaining = this.ctx.getWebSockets(userId); + if (remaining.length === 0) { + this.broadcast({ type: "presence.update", userId, status: "offline" }); + } + } + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + console.error("WebSocket error:", error); + ws.close(1011, "Internal error"); + } + + private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise { + if (!text || text.trim().length === 0) { + this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" }); + return; + } + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const channelId = await this.getChannelId(); + + const serverMsg: ServerMessage = { + type: "message.new", + id, + userId, + text: text.trim(), + createdAt, + clientId, + }; + + this.broadcast(serverMsg); + this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt)); + } + + private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise { + const db = this.getPostgREST(); + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await db.insert("messages", { + id, + channel_id: channelId, + user_id: userId, + text, + created_at: createdAt, + }); + return; + } catch (error) { + console.error(`Message persist attempt ${attempt + 1} failed:`, error); + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); + } + } + } + console.error(`Failed to persist message ${id} after ${maxRetries} attempts`); + } + + private async getChannelId(): Promise { + const channelId = await this.ctx.storage.get("channelId"); + if (!channelId) throw new Error("channelId not stored in DO"); + return channelId; + } + + private sendTo(ws: WebSocket, msg: ServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection already closed + } + } + + private broadcast(msg: ServerMessage, exclude?: WebSocket): void { + const payload = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets()) { + if (ws !== exclude) { + try { + ws.send(payload); + } catch { + // Connection already closed + } + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/chat-worker/src/durable-objects/ChatRoom.ts +git commit -m "feat(chat-worker): implement ChatRoom Durable Object with WebSocket hibernation" +``` + +--- + +## Task 5: Chat Worker — Hono entry point with routing + +**Files:** +- Create: `apps/chat-worker/src/index.ts` + +The Worker entry point: authenticates requests, checks membership, dispatches WebSocket upgrades to DOs, and serves REST endpoints for message history, unread counts, and marking channels as read. + +- [ ] **Step 1: Create the Worker entry point** + +Create `apps/chat-worker/src/index.ts`: + +```typescript +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { ChatRoom } from "./durable-objects/ChatRoom"; +import { extractToken, verifyJwt } from "./lib/auth"; +import { PostgREST } from "./lib/supabase"; +import type { Env, ChatMessage, UnreadCount } from "./lib/types"; + +// Re-export DO class for wrangler +export { ChatRoom }; + +const app = new Hono<{ Bindings: Env }>(); + +// CORS — allow the main app origins +app.use("*", cors({ + origin: [ + "http://localhost:5173", + "https://app.xtablo.com", + "https://app-staging.xtablo.com", + ], + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], +})); + +// Auth middleware — extract and verify JWT for all routes +// For WebSocket upgrades, the token comes via query param (?token=...) since browsers +// cannot send custom headers on WebSocket connections. +// For REST requests, the token comes via the Authorization header. +app.use("*", async (c, next) => { + const isWebSocket = c.req.header("Upgrade") === "websocket"; + const token = isWebSocket + ? new URL(c.req.url).searchParams.get("token") + : extractToken(c.req.header("Authorization")); + + if (!token) { + return c.json({ error: "Missing authorization" }, 401); + } + try { + const auth = await verifyJwt(token, c.env.JWT_SECRET); + c.set("userId" as never, auth.userId); + } catch (error) { + return c.json({ error: "Invalid token" }, 401); + } + await next(); +}); + +// Helper: check tablo membership via PostgREST +async function checkMembership(db: PostgREST, channelId: string, userId: string): Promise { + const rows = await db.select<{ user_id: string }>( + "tablo_access", + `tablo_id=eq.${channelId}&user_id=eq.${userId}&is_active=eq.true&select=user_id&limit=1` + ); + return rows.length > 0; +} + +// WebSocket upgrade — route to Durable Object +app.get("/chat/ws/:channelId", async (c) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader !== "websocket") { + return c.json({ error: "Expected WebSocket upgrade" }, 426); + } + + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const id = c.env.CHAT_ROOM.idFromName(channelId); + const stub = c.env.CHAT_ROOM.get(id); + return stub.handleWebSocket(c.req.raw, userId, channelId); +}); + +// GET message history — paginated +app.get("/chat/channels/:channelId/messages", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const before = c.req.query("before"); + const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 100); + + let query = `channel_id=eq.${channelId}&deleted_at=is.null&select=id,channel_id,user_id,text,created_at&order=created_at.desc&limit=${limit}`; + if (before) { + query += `&created_at=lt.${before}`; + } + + const messages = await db.select( + "messages", + query + ); + + return c.json({ messages: messages.reverse(), hasMore: messages.length === limit }); +}); + +// POST mark channel as read +app.post("/chat/channels/:channelId/read", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + await db.upsert("channel_read_state", { + user_id: userId, + channel_id: channelId, + last_read_at: new Date().toISOString(), + }, "user_id,channel_id"); + + return c.json({ ok: true }); +}); + +// GET unread counts for current user across all channels +app.get("/chat/unread", async (c) => { + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + // Get all tablos the user has access to + const accessRows = await db.select<{ tablo_id: string }>( + "tablo_access", + `user_id=eq.${userId}&is_active=eq.true&select=tablo_id` + ); + + if (accessRows.length === 0) { + return c.json({ unread: [] }); + } + + // For each channel, get unread count + // Use a Postgres function or do it in a loop (at small scale, the loop is fine) + const unread: UnreadCount[] = []; + + for (const { tablo_id } of accessRows) { + // Get last read time + const readState = await db.select<{ last_read_at: string }>( + "channel_read_state", + `user_id=eq.${userId}&channel_id=eq.${tablo_id}&select=last_read_at&limit=1` + ); + + const lastReadAt = readState[0]?.last_read_at ?? "1970-01-01T00:00:00Z"; + + const count = await db.count( + "messages", + `channel_id=eq.${tablo_id}&deleted_at=is.null&created_at=gt.${lastReadAt}` + ); + + if (count > 0) { + unread.push({ channel_id: tablo_id, unread_count: count }); + } + } + + return c.json({ unread }); +}); + +export default app; +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd apps/chat-worker && pnpm typecheck` + +Expected: No type errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/chat-worker/src/index.ts +git commit -m "feat(chat-worker): add Hono entry point with WebSocket routing and REST endpoints" +``` + +--- + +## Task 6: Frontend — useChat hook + +**Files:** +- Create: `apps/main/src/hooks/useChat.ts` + +The core frontend hook that manages the WebSocket connection, message state, typing indicators, presence, and reconnection logic. + +- [ ] **Step 1: Create the useChat hook** + +Create `apps/main/src/hooks/useChat.ts`: + +```typescript +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; + +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + /** True while the message is only local (not yet echoed by server). */ + optimistic?: boolean; +} + +type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +const CHAT_WS_BASE = import.meta.env.VITE_CHAT_WS_URL as string; +const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string; + +export function useChat(channelId: string | undefined) { + const { session } = useSession(); + const token = session?.access_token; + + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [typingUsers, setTypingUsers] = useState([]); + const [onlineUsers, setOnlineUsers] = useState([]); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef>(); + const typingTimerRef = useRef>(); + const isTypingRef = useRef(false); + + // Fetch message history from REST endpoint + const fetchHistory = useCallback(async (before?: string) => { + if (!channelId || !token) return; + + const params = new URLSearchParams({ limit: "50" }); + if (before) params.set("before", before); + + const res = await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/messages?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) return; + + const data = await res.json() as { messages: ChatMessage[]; hasMore: boolean }; + setHasMoreMessages(data.hasMore); + + if (before) { + // Prepend older messages + setMessages((prev) => [...data.messages, ...prev]); + } else { + // Initial load + setMessages(data.messages); + } + }, [channelId, token]); + + // Load more (pagination) + const loadMoreMessages = useCallback(() => { + if (messages.length === 0 || !hasMoreMessages) return; + const oldest = messages[0]; + fetchHistory(oldest.createdAt); + }, [messages, hasMoreMessages, fetchHistory]); + + // WebSocket connection management + useEffect(() => { + if (!channelId || !token) return; + + const connect = () => { + // Token passed via query param because browsers cannot send custom headers on WS connections + const wsUrl = `${CHAT_WS_BASE}/chat/ws/${channelId}?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + setIsConnected(true); + reconnectAttemptRef.current = 0; + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as ServerMessage; + + switch (msg.type) { + case "message.new": + setMessages((prev) => { + // Deduplicate: replace optimistic message with server version + const withoutOptimistic = prev.filter( + (m) => !(m.clientId === msg.clientId && m.optimistic) + ); + // Avoid duplicate if message already received + if (withoutOptimistic.some((m) => m.id === msg.id)) { + return withoutOptimistic; + } + return [...withoutOptimistic, { + id: msg.id, + userId: msg.userId, + text: msg.text, + createdAt: msg.createdAt, + clientId: msg.clientId, + }]; + }); + break; + + case "typing": + setTypingUsers((prev) => + msg.isTyping + ? prev.includes(msg.userId) ? prev : [...prev, msg.userId] + : prev.filter((id) => id !== msg.userId) + ); + break; + + case "presence.update": + setOnlineUsers((prev) => + msg.status === "online" + ? prev.includes(msg.userId) ? prev : [...prev, msg.userId] + : prev.filter((id) => id !== msg.userId) + ); + break; + + case "error": + console.error("Chat error:", msg.code, msg.message); + break; + } + }; + + ws.onclose = () => { + setIsConnected(false); + wsRef.current = null; + + // Exponential backoff reconnect + const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000); + reconnectAttemptRef.current++; + reconnectTimerRef.current = setTimeout(connect, delay); + }; + + ws.onerror = () => { + ws.close(); + }; + + wsRef.current = ws; + }; + + // Load initial history then connect WebSocket + fetchHistory().then(connect); + + return () => { + clearTimeout(reconnectTimerRef.current); + clearTimeout(typingTimerRef.current); + wsRef.current?.close(); + wsRef.current = null; + setMessages([]); + setIsConnected(false); + setTypingUsers([]); + setOnlineUsers([]); + }; + }, [channelId, token, fetchHistory]); + + // Send message + const sendMessage = useCallback((text: string) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + const clientId = crypto.randomUUID(); + + // Optimistic update + setMessages((prev) => [ + ...prev, + { + id: `optimistic-${clientId}`, + userId: session?.user?.id ?? "", + text, + createdAt: new Date().toISOString(), + clientId, + optimistic: true, + }, + ]); + + wsRef.current.send(JSON.stringify({ type: "message.send", text, clientId })); + + // Stop typing when sending + if (isTypingRef.current) { + wsRef.current.send(JSON.stringify({ type: "typing.stop" })); + isTypingRef.current = false; + clearTimeout(typingTimerRef.current); + } + }, [session?.user?.id]); + + // Typing indicator + const sendTyping = useCallback(() => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + if (!isTypingRef.current) { + isTypingRef.current = true; + wsRef.current.send(JSON.stringify({ type: "typing.start" })); + } + + clearTimeout(typingTimerRef.current); + typingTimerRef.current = setTimeout(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "typing.stop" })); + } + isTypingRef.current = false; + }, 2000); + }, []); + + // Mark as read + const markAsRead = useCallback(async () => { + if (!channelId || !token) return; + await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/read`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + }, [channelId, token]); + + return { + messages, + sendMessage, + sendTyping, + isConnected, + typingUsers, + onlineUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/main/src/hooks/useChat.ts +git commit -m "feat(chat): add useChat hook with WebSocket connection and reconnection" +``` + +--- + +## Task 7: Frontend — useChatUnread hook + +**Files:** +- Create: `apps/main/src/hooks/useChatUnread.ts` + +Polls the chat worker for unread counts across all channels. Replaces `useTabloDiscussionUnread`. + +- [ ] **Step 1: Create the useChatUnread hook** + +Create `apps/main/src/hooks/useChatUnread.ts`: + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; + +const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string; + +interface UnreadCount { + channel_id: string; + unread_count: number; +} + +export function useChatUnread() { + const { session } = useSession(); + const token = session?.access_token; + + const { data } = useQuery({ + queryKey: ["chat-unread"], + queryFn: async (): Promise => { + const res = await fetch(`${CHAT_API_BASE}/chat/unread`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return []; + const json = await res.json() as { unread: UnreadCount[] }; + return json.unread; + }, + enabled: !!token, + refetchInterval: 30_000, + refetchOnWindowFocus: true, + }); + + return { + unreadCounts: data ?? [], + getUnreadCount: (channelId: string) => + data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0, + hasUnread: (channelId: string) => + (data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0) > 0, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/main/src/hooks/useChatUnread.ts +git commit -m "feat(chat): add useChatUnread hook for polling unread counts" +``` + +--- + +## Task 8: Frontend — Chat UI components with chatscope + +**Files:** +- Create: `apps/main/src/components/ChatChannelPreview.tsx` +- Create: `apps/main/src/components/ChatHeader.tsx` +- Modify: `apps/main/src/pages/chat.tsx` +- Modify: `apps/main/src/lib/routes.tsx` +- Modify: `apps/main/package.json` + +This task rewrites the chat page to use chatscope components instead of stream-chat-react. + +- [ ] **Step 1: Install chatscope** + +Run: `cd apps/main && pnpm add @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles` + +- [ ] **Step 2: Create ChatChannelPreview component** + +Create `apps/main/src/components/ChatChannelPreview.tsx`: + +```typescript +import { ChannelBadge } from "@ui/components/ChannelBadge"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { Badge } from "@xtablo/ui/components/badge"; +import { twMerge } from "tailwind-merge"; + +interface ChatChannelPreviewProps { + tablo: UserTablo; + isActive: boolean; + onClick: () => void; + unreadCount: number; + lastMessage?: string; + lastMessageTime?: string; + isOnline: boolean; +} + +function formatTimestamp(timestamp: string | Date): string { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + return date.toLocaleDateString(); +} + +export function ChatChannelPreview({ + tablo, + isActive, + onClick, + unreadCount, + lastMessage, + lastMessageTime, + isOnline, +}: ChatChannelPreviewProps) { + return ( +
+ + +
+
+

+ {tablo.name} +

+ {lastMessageTime && ( + + {formatTimestamp(lastMessageTime)} + + )} +
+ +
+

+ {lastMessage ?? "No messages yet"} +

+ + {unreadCount > 0 && ( +
+ + {unreadCount > 99 ? "99+" : unreadCount} + +
+ )} +
+
+ + {isActive && ( +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Create ChatHeader component** + +Create `apps/main/src/components/ChatHeader.tsx`: + +```typescript +import { ChannelBadge } from "@ui/components/ChannelBadge"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; + +interface ChatHeaderProps { + tablo: UserTablo | null; + onToggleChannelList?: () => void; + isChannelListExpanded?: boolean; + onlineUsers: string[]; +} + +export function ChatHeader({ + tablo, + onToggleChannelList, + isChannelListExpanded = false, + onlineUsers, +}: ChatHeaderProps) { + const memberCount = onlineUsers.length; + + return ( +
+ {onToggleChannelList && ( + + )} + {tablo && ( + <> + 0} /> +
+

{tablo.name}

+ {memberCount > 0 && ( +

+ {memberCount} online +

+ )} +
+ + )} +
+ ); +} +``` + +- [ ] **Step 4: Rewrite the chat page** + +Replace the contents of `apps/main/src/pages/chat.tsx` with: + +```typescript +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; +import { + ChatContainer, + MessageList, + Message, + MessageInput, + TypingIndicator, +} from "@chatscope/chat-ui-kit-react"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ChatChannelPreview } from "../components/ChatChannelPreview"; +import { ChatHeader } from "../components/ChatHeader"; +import { useChat } from "../hooks/useChat"; +import { useChatUnread } from "../hooks/useChatUnread"; +import { useTablosList } from "../hooks/tablos"; +import { useUser } from "../providers/UserStoreProvider"; + +export function ChatPage() { + const user = useUser(); + const { channelId } = useParams(); + const navigate = useNavigate(); + const { data: tablos } = useTablosList(); + const { getUnreadCount } = useChatUnread(); + const [isChannelListExpanded, setIsChannelListExpanded] = useState(!channelId); + + const { + messages, + sendMessage, + sendTyping, + isConnected, + typingUsers, + onlineUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + } = useChat(channelId); + + const activeTablo = tablos?.find((t) => t.id === channelId) ?? null; + + // Mark as read when channel is focused + useEffect(() => { + if (channelId && messages.length > 0) { + markAsRead(); + } + }, [channelId, messages.length, markAsRead]); + + const handleSend = (innerHtml: string, textContent: string) => { + const text = textContent.trim(); + if (!text) return; + sendMessage(text); + }; + + const handleChannelSelect = (tabloId: string) => { + navigate(`/chat/${tabloId}`); + }; + + return ( +
+
+

Discussions

+
+
+ {/* Channel list sidebar */} +
+
+ {tablos?.map((tablo) => ( + handleChannelSelect(tablo.id)} + unreadCount={getUnreadCount(tablo.id)} + isOnline={onlineUsers.some((uid) => uid !== user.id)} + /> + ))} +
+
+ + {/* Chat area */} +
+ {channelId && activeTablo ? ( + <> + setIsChannelListExpanded(!isChannelListExpanded)} + isChannelListExpanded={isChannelListExpanded} + onlineUsers={onlineUsers} + /> +
+ + 0 ? ( + + ) : undefined + } + > + {messages.map((msg) => ( + + ))} + + sendTyping()} + attachButton={false} + /> + +
+ + ) : ( +
+ Select a conversation to start chatting +
+ )} +
+
+
+ ); +} +``` + +- [ ] **Step 5: Update routes — remove ChatProvider wrapper** + +In `apps/main/src/lib/routes.tsx`, change the chat route from: + +```typescript +import ChatProvider from "../providers/ChatProvider"; +``` +and +```typescript + { + path: "chat", + element: ( + + + + ), + children: [{ index: true }, { path: ":channelId" }], + }, +``` + +to: + +```typescript + { + path: "chat", + element: , + children: [{ index: true }, { path: ":channelId" }], + }, +``` + +Remove the `import ChatProvider` line entirely. + +- [ ] **Step 6: Commit** + +```bash +git add apps/main/src/components/ChatChannelPreview.tsx apps/main/src/components/ChatHeader.tsx apps/main/src/pages/chat.tsx apps/main/src/lib/routes.tsx apps/main/package.json +git commit -m "feat(chat): rewrite chat page with chatscope UI and custom hooks" +``` + +--- + +## Task 9: Frontend — Environment variables + +**Files:** +- Modify: `apps/main/.env.local` +- Modify: `apps/main/.env.staging` +- Modify: `apps/main/.env.production` + +- [ ] **Step 1: Update .env.local** + +Remove the line `VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx"` and add: + +``` +VITE_CHAT_WS_URL=ws://localhost:8787 +VITE_CHAT_API_URL=http://localhost:8787 +``` + +- [ ] **Step 2: Update .env.staging** + +Remove the line `VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"` and add: + +``` +VITE_CHAT_WS_URL=wss://chat-staging.xtablo.com +VITE_CHAT_API_URL=https://chat-staging.xtablo.com +``` + +- [ ] **Step 3: Update .env.production** + +Remove the line `VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx"` and add: + +``` +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/.env.local apps/main/.env.staging apps/main/.env.production +git commit -m "feat(chat): update env vars — replace Stream API key with chat worker URLs" +``` + +--- + +## Task 10: Backend — Remove Stream Chat from API + +**Files:** +- Modify: `apps/api/src/types/app.types.ts` — remove `streamServerClient` from BaseEnv +- Modify: `apps/api/src/middlewares/middleware.ts` — remove streamChatMiddleware +- Modify: `apps/api/src/routers/index.ts` — remove `streamChat` middleware usage +- Modify: `apps/api/src/config.ts` — remove STREAM_CHAT_API_KEY, STREAM_CHAT_API_SECRET +- Modify: `apps/api/src/secrets.ts` — remove streamChatApiSecret, streamChatApiSecretStaging +- Modify: `apps/api/src/routers/user.ts` — remove signUpToStream, streamToken from getMe, Stream from inviteToOrganization and removeOrganizationMember +- Modify: `apps/api/src/routers/tablo.ts` — remove all Stream channel operations +- Modify: `apps/api/src/helpers/helpers.ts` — remove streamServerClient from createInvitedUser +- Modify: `apps/api/package.json` — remove stream-chat dependency +- Modify: `apps/api/src/__tests__/routes/tablo.test.ts` — remove Stream Chat mocks +- Modify: `apps/api/src/__tests__/config/stripe-config.test.ts` — remove streamChat mock data + +This is the largest task but is entirely removal. Each change is a deletion, not a rewrite. + +- [ ] **Step 1: Remove StreamChat from app.types.ts** + +In `apps/api/src/types/app.types.ts`: + +Remove the import: `import type { StreamChat } from "stream-chat";` + +Remove `streamServerClient: StreamChat;` from the `BaseEnv.Variables` type. + +The `BaseEnv` becomes: +```typescript +export type BaseEnv = { + Variables: { + supabase: SupabaseClient; + s3_client: S3Client; + transporter: Transporter; + stripe: Stripe; + stripeSync: StripeSync; + }; +}; +``` + +- [ ] **Step 2: Remove streamChatMiddleware from middleware.ts** + +In `apps/api/src/middlewares/middleware.ts`: + +1. Remove the import: `import { StreamChat } from "stream-chat";` +2. Remove `streamChatMiddleware` from the `Middlewares` type (lines 28-30) +3. Remove the `streamChatMiddleware` creation (lines 171-178) +4. Remove `streamChatMiddleware` from the return object (line 258) +5. Remove the `get streamChat()` getter (lines 285-287) + +- [ ] **Step 3: Remove streamChat from router index** + +In `apps/api/src/routers/index.ts`, remove line 20: +```typescript + mainRouter.use(middlewareManager.streamChat); +``` + +- [ ] **Step 4: Remove Stream config vars** + +In `apps/api/src/config.ts`: + +1. Remove lines 11-12 from `AppConfig`: + ```typescript + STREAM_CHAT_API_KEY: string; + STREAM_CHAT_API_SECRET: string; + ``` +2. Remove lines 62-63 (the `getStreamChatApiSecret` helper) +3. Remove lines 85-89 (the config assignments for `STREAM_CHAT_API_KEY` and `STREAM_CHAT_API_SECRET`) + +- [ ] **Step 5: Remove Stream from secrets.ts** + +In `apps/api/src/secrets.ts`: + +1. Remove from the `Secrets` type: `streamChatApiSecret: string;` and `streamChatApiSecretStaging: string;` +2. Remove from `loadSecrets()`: the lines fetching `stream-chat-api-secret-staging` and `stream-chat-api-secret` + +- [ ] **Step 6: Remove Stream from user.ts** + +In `apps/api/src/routers/user.ts`: + +1. Remove the `signUpToStream` handler entirely (lines 14-32) +2. In `getMe` handler (lines 34-71): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 37) + - Remove `const token = streamServerClient.createToken(user_id);` (line 64) + - Remove `streamToken: token` from the JSON response (line 69) + - The response becomes: `return c.json({ ...userData, plan: effectivePlan });` +3. In `inviteToOrganization` (lines 514-715): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 518) + - Remove `streamServerClient` from `createInvitedUser` call (line 614-621) — pass only `supabase, transporter, ...` + - Remove the Stream channel addMembers loop (lines 676-683) +4. In `removeOrganizationMember` (lines 717-850): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 720) + - Remove the Stream channel removeMembers loop (lines 829-836) +5. Remove the route: `userRouter.post("/sign-up-to-stream", ...signUpToStream);` (line 855) + +- [ ] **Step 7: Remove Stream from tablo.ts** + +In `apps/api/src/routers/tablo.ts`: + +1. Remove the `isAlreadyMemberError` helper (lines 21-29) +2. Remove the `upsertStreamUserFromProfile` helper (lines 31-47) +3. Remove the `ensureTabloChannelMember` helper (lines 49-96) +4. In `createTablo` (lines 98-170): + - Remove `const streamServerClient = c.get("streamServerClient");` and the channel.create block (lines 150-157) +5. In `updateTablo` (lines 172-220): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 176) + - Remove the channel.update block (lines 207-217) +6. In `deleteTablo` (lines 222-281): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 225) + - Remove the channel.delete block (lines 273-278) +7. In `inviteToTablo` (lines 283-435): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 291) + - Remove `streamServerClient` from `createInvitedUser` call (line 356-363) + - Remove the `ensureTabloChannelMember` call (lines 384-389) +8. In `cancelPendingInvite` (lines 437-526): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 441) + - Remove the channel.removeMembers block (lines 517-522) +9. In `acceptInviteById` (lines 572-632): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 576) + - Remove the `upsertStreamUserFromProfile` call (lines 601-606) + - Remove the `ensureTabloChannelMember` call (lines 624-629) +10. In `joinTablo` (lines 634-697): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 639) + - Remove the `upsertStreamUserFromProfile` call (lines 660-665) + - Remove the `ensureTabloChannelMember` call (lines 689-694) +11. In `leaveTablo` (lines 748-768): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 751) + - Remove the `channel.removeMembers` call (lines 754-755) +12. In `getTabloRouter` (lines 869-891): + - Remove `tabloRouter.use(middlewareManager.streamChat);` (line 875) + +- [ ] **Step 8: Remove Stream from helpers.ts** + +In `apps/api/src/helpers/helpers.ts`: + +1. Remove `import type { StreamChat } from "stream-chat";` (line 6) +2. In `createInvitedUser` (lines 291-373): + - Remove `streamServerClient: StreamChat` from the parameter list (line 293) + - Remove the `streamServerClient.upsertUser()` call (lines 337-341) + +- [ ] **Step 9: Remove stream-chat dependency** + +Run: `cd apps/api && pnpm remove stream-chat` + +- [ ] **Step 10: Update test files** + +In `apps/api/src/__tests__/routes/tablo.test.ts`: +- Remove the Stream Chat mock block (lines 12-38) +- Remove any `mockChannel*` expectations in individual tests + +In `apps/api/src/__tests__/config/stripe-config.test.ts`: +- Remove `streamChatApiSecret` and `streamChatApiSecretStaging` from mock data (lines 13, 16) + +Update any other test files that reference Stream Chat mocks. + +- [ ] **Step 11: Run tests** + +Run: `pnpm test:api` + +Expected: All tests pass with Stream Chat removed. + +- [ ] **Step 12: Commit** + +```bash +git add apps/api/ +git commit -m "refactor(api): remove all Stream Chat dependencies and operations" +``` + +--- + +## Task 11: Frontend — Remove Stream Chat dependencies + +**Files:** +- Delete: `apps/main/src/providers/ChatProvider.tsx` +- Delete: `apps/main/src/components/ChannelPreview.tsx` +- Delete: `apps/main/src/components/CustomChannelHeader.tsx` +- Delete: `apps/main/src/hooks/channel.ts` +- Modify: `apps/main/src/providers/UserStoreProvider.tsx` — remove streamToken +- Modify: `apps/main/package.json` — remove stream-chat, stream-chat-react +- Modify: `packages/shared/src/hooks/auth.ts` — remove useSignUpToStream + +- [ ] **Step 1: Delete Stream-specific files** + +Delete these files: +- `apps/main/src/providers/ChatProvider.tsx` +- `apps/main/src/components/ChannelPreview.tsx` +- `apps/main/src/components/CustomChannelHeader.tsx` +- `apps/main/src/hooks/channel.ts` + +- [ ] **Step 2: Remove streamToken from UserStoreProvider** + +In `apps/main/src/providers/UserStoreProvider.tsx`: + +Change the `User` type (line 10-12) from: +```typescript +export type User = Tables<"profiles"> & { + streamToken: string | null; +}; +``` +to: +```typescript +export type User = Tables<"profiles">; +``` + +- [ ] **Step 3: Remove useSignUpToStream from auth.ts** + +In `packages/shared/src/hooks/auth.ts`: + +1. Remove the `useSignUpToStream` function entirely (lines 85-101) +2. In `useSignUpWithoutPassword`, remove: + - `const { signUpToStream } = useSignUpToStream(api);` (line 15) + - The `signUpToStream` call in the mutation (lines 38-40): + ```typescript + if (response.session?.access_token) { + await signUpToStream(response.session.access_token); + } + ``` + +- [ ] **Step 4: Remove Stream packages** + +Run: `cd apps/main && pnpm remove stream-chat stream-chat-react` + +- [ ] **Step 5: Search for any remaining Stream references** + +Run: `grep -r "stream-chat\|streamToken\|STREAM_CHAT\|StreamChat\|useChannelFromUrl\|useTabloDiscussionUnread\|ChatProvider" apps/main/src/ --include="*.ts" --include="*.tsx" -l` + +Expected: No files returned (only test mocks, if any). + +- [ ] **Step 6: Remove VITE_STREAM_CHAT_API_KEY from external app if present** + +Check `apps/external/.env.production` — if it references `VITE_STREAM_CHAT_API_KEY`, remove it. + +- [ ] **Step 7: Run typecheck and tests** + +Run: `pnpm typecheck && cd apps/main && pnpm test` + +Expected: No type errors, all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(main): remove all Stream Chat dependencies and components" +``` + +--- + +## Task 12: Frontend — Remove Stream Chat API env var from API .env files + +**Files:** +- Modify: `apps/api/.env.development` + +- [ ] **Step 1: Remove STREAM_CHAT_API_KEY** + +In `apps/api/.env.development`, remove line 3: +``` +STREAM_CHAT_API_KEY=h7bwnn8ynjpx +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/.env.development +git commit -m "chore: remove STREAM_CHAT_API_KEY from API env files" +``` + +--- + +## Task 13: Integration testing — End-to-end chat flow + +**Files:** +- No new files — manual testing + +- [ ] **Step 1: Start the chat worker locally** + +Run: `cd apps/chat-worker && pnpm dev` + +Expected: Worker starts on `http://localhost:8787` with DO bindings. + +Note: Before this, set the required secrets locally: +```bash +cd apps/chat-worker +echo "your-supabase-service-role-key" | wrangler secret put SUPABASE_SERVICE_ROLE_KEY --local +echo "your-jwt-secret" | wrangler secret put JWT_SECRET --local +``` + +- [ ] **Step 2: Start the main app** + +Run: `pnpm dev:main` + +- [ ] **Step 3: Test the golden path** + +1. Log in to the app +2. Navigate to `/chat` +3. Select a tablo channel from the sidebar +4. Send a message — verify it appears immediately (optimistic UI) +5. Open a second browser tab with the same channel — verify the message appears there +6. Send a message from the second tab — verify it appears in both tabs +7. Type in one tab — verify typing indicator shows in the other + +- [ ] **Step 4: Test reconnection** + +1. Stop the chat worker (`ctrl+c` in the terminal running `pnpm dev` in chat-worker) +2. Verify the UI shows disconnected state +3. Restart the chat worker +4. Verify the client reconnects and loads missed messages + +- [ ] **Step 5: Test unread counts** + +1. Open `/chat` in one tab +2. Navigate away from the chat page in a second tab +3. Send a message from the first tab +4. In the second tab, the unread badge should appear within 30 seconds (polling interval) + +- [ ] **Step 6: Verify API has no Stream references** + +Run: `pnpm dev:api` +Run: Test various tablo operations (create, update, delete, invite) and verify they work without Stream Chat errors. + +- [ ] **Step 7: Run full test suite** + +Run: `pnpm test` + +Expected: All tests pass. diff --git a/docs/superpowers/plans/2026-04-12-chatcn-integration.md b/docs/superpowers/plans/2026-04-12-chatcn-integration.md new file mode 100644 index 0000000..53fd544 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-chatcn-integration.md @@ -0,0 +1,946 @@ +# chatcn Integration as @xtablo/chat-ui Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace chatscope with chatcn as a new `@xtablo/chat-ui` workspace package, adapting theming to xtablo's design tokens. + +**Architecture:** Copy chatcn's 7 source files into `packages/chat-ui/src/`, remap imports to use `@xtablo/shared` and `@xtablo/ui`, replace all `var(--chat-*)` CSS variables with Tailwind utility classes mapped to xtablo's existing design tokens. Then rewrite `ChatMessages.tsx` in the main app to consume the new package. + +**Tech Stack:** React 19, Tailwind CSS 4, TypeScript 5, Turborepo/pnpm workspaces + +--- + +## File Map + +**New files (packages/chat-ui/):** +- `packages/chat-ui/package.json` — Package manifest +- `packages/chat-ui/tsconfig.json` — TypeScript config +- `packages/chat-ui/src/index.ts` — Barrel export (copied from chatcn, unchanged) +- `packages/chat-ui/src/types.ts` — Type definitions (copied from chatcn, `ChatTheme` removed) +- `packages/chat-ui/src/hooks.ts` — Hooks (copied from chatcn, import remapped) +- `packages/chat-ui/src/security.ts` — Security utils (copied from chatcn, unchanged) +- `packages/chat-ui/src/components/chat.tsx` — Core components (copied, imports + theme adapted) +- `packages/chat-ui/src/components/features.tsx` — Feature components (copied, imports + theme adapted) +- `packages/chat-ui/src/components/layouts.tsx` — Layout components (copied, imports + theme adapted) +- `packages/chat-ui/src/chat-ui.css` — Minimal CSS for animations and utility classes + +**Modified files:** +- `apps/main/package.json` — Remove chatscope deps, add `@xtablo/chat-ui` +- `apps/main/src/components/ChatMessages.tsx` — Rewrite to use `@xtablo/chat-ui` + +--- + +## Complete Token Mapping Reference + +Every `var(--chat-*)` occurrence in chatcn is replaced with a Tailwind class. This table is the single source of truth for all theme adaptation across Tasks 4-6. + +| chatcn CSS variable | Tailwind class (as bg-) | Tailwind class (as text-) | Tailwind class (as border-) | Notes | +|---|---|---|---|---| +| `--chat-bg-app` | `bg-background` | — | — | | +| `--chat-bg-main` | `bg-background` | — | — | | +| `--chat-bg-sidebar` | `bg-card` | — | — | Using `card` for secondary surfaces | +| `--chat-bg-header` | `bg-card` | — | — | With `backdrop-blur` kept | +| `--chat-bg-composer` | `bg-card` | — | — | With `backdrop-blur` kept | +| `--chat-bg-code` | `bg-muted` | — | — | Code block backgrounds | +| `--chat-bg-hover` | `bg-accent` | — | — | Hover states | +| `--chat-bg-content-card` | `bg-card` | — | — | Embedded content cards | +| `--chat-bubble-outgoing` | `bg-primary` | — | — | | +| `--chat-bubble-outgoing-text` | — | `text-primary-foreground` | — | | +| `--chat-bubble-incoming` | `bg-muted` | — | — | | +| `--chat-bubble-incoming-text` | — | `text-foreground` | — | | +| `--chat-accent` | `bg-primary` | `text-primary` | — | chatcn accent = xtablo primary | +| `--chat-accent-soft` | `bg-accent` | — | — | xtablo `accent` is the soft/subtle bg | +| `--chat-text-primary` | — | `text-foreground` | — | | +| `--chat-text-secondary` | — | `text-muted-foreground` | — | | +| `--chat-text-tertiary` | — | `text-muted-foreground/60` | — | Slightly more faded | +| `--chat-border` | — | — | `border-border` | | +| `--chat-border-strong` | — | — | `border-border` | Same token, stronger is unnecessary | +| `--chat-red` | — | `text-destructive` | — | | +| `--chat-orange` | — | `text-orange-500` | — | No xtablo token; use Tailwind orange | +| `--chat-green` | — | `text-green-500` | — | Presence indicator; use Tailwind green | +| `--chat-font-mono` | `font-mono` | — | — | Tailwind built-in | +| `--chat-shadow-lg` | `shadow-lg` | — | — | Tailwind built-in | +| `--chat-shadow-md` | `shadow-md` | — | — | Tailwind built-in | +| `--chat-shadow-toolbar` | `shadow-md` | — | — | | + +--- + +### Task 1: Create package scaffold + +**Files:** +- Create: `packages/chat-ui/package.json` +- Create: `packages/chat-ui/tsconfig.json` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@xtablo/chat-ui", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks": "./src/hooks.ts", + "./security": "./src/security.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "dependencies": { + "@xtablo/shared": "workspace:*", + "@xtablo/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "lucide-react": "*", + "date-fns": "*" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Copy verbatim from `packages/ui/tsconfig.json`: + +```json +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": ["es2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Run pnpm install to register the new workspace package** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm install` +Expected: lockfile updated, `@xtablo/chat-ui` recognized as workspace package + +- [ ] **Step 4: Commit** + +```bash +git add packages/chat-ui/package.json packages/chat-ui/tsconfig.json pnpm-lock.yaml +git commit -m "feat(chat-ui): scaffold @xtablo/chat-ui package" +``` + +--- + +### Task 2: Copy non-component source files (types, hooks, security) + +**Files:** +- Create: `packages/chat-ui/src/types.ts` +- Create: `packages/chat-ui/src/hooks.ts` +- Create: `packages/chat-ui/src/security.ts` +- Source: `chatcn/src/components/ui/chat/types.ts`, `hooks.ts`, `security.ts` + +- [ ] **Step 1: Copy types.ts from chatcn and remove ChatTheme** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/types.ts` to `packages/chat-ui/src/types.ts`. + +Remove the last line: +```typescript +export type ChatTheme = "lunar" | "aurora" | "ember" | "midnight" +``` + +No other changes needed — this file has no imports. + +- [ ] **Step 2: Copy security.ts from chatcn** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/security.ts` to `packages/chat-ui/src/security.ts`. + +No changes needed — this file has no imports from chatcn internals. + +- [ ] **Step 3: Copy hooks.ts from chatcn and fix the "use client" directive** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/hooks.ts` to `packages/chat-ui/src/hooks.ts`. + +Remove the `"use client"` directive at the top (not needed in a Vite app — it's a Next.js RSC directive). + +The import `from "./types"` stays — it's a relative import within the same package. + +- [ ] **Step 4: Verify types compile** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/chat-ui typecheck` +Expected: no type errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/chat-ui/src/types.ts packages/chat-ui/src/hooks.ts packages/chat-ui/src/security.ts +git commit -m "feat(chat-ui): add types, hooks, and security utilities from chatcn" +``` + +--- + +### Task 3: Create chat-ui.css with animations and utility classes + +**Files:** +- Create: `packages/chat-ui/src/chat-ui.css` + +chatcn uses several CSS classes for animations and utility styles that aren't Tailwind classes. These need a small CSS file. + +- [ ] **Step 1: Create chat-ui.css** + +```css +/* @xtablo/chat-ui — Animations and utility classes */ + +/* ─── Message entry ─────────────────────────────────────────────── */ +@keyframes chat-message-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ─── Toolbar entrance ──────────────────────────────────────────── */ +@keyframes chat-toolbar-enter { + from { opacity: 0; transform: scale(0.95) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* ─── Reaction pop ──────────────────────────────────────────────── */ +@keyframes chat-reaction-pop { + 0% { transform: scale(0); opacity: 0; } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } +} + +/* ─── Typing indicator dots ─────────────────────────────────────── */ +@keyframes chat-typing-pulse { + 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-4px); } +} + +/* ─── Cursor blink (streaming) ──────────────────────────────────── */ +@keyframes chat-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ─── Read receipt status color transition ───────────────────────── */ +@keyframes chat-status-read-in { + from { color: var(--color-muted-foreground); } + to { color: var(--color-primary); } +} + +/* ─── Utility classes ───────────────────────────────────────────── */ +@layer base { + .chat-message { + animation: chat-message-enter 250ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-typing-dot { + animation: chat-typing-pulse 1.4s ease-in-out infinite; + } + + .chat-toolbar-enter { + transform-origin: bottom center; + } + + .group\/message:hover .chat-toolbar-enter { + animation: chat-toolbar-enter 150ms ease-out; + } + + .chat-reaction-pop { + animation: chat-reaction-pop 200ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-status-read { + animation: chat-status-read-in 400ms ease-out; + } + + .chat-streaming-cursor { + animation: chat-cursor-blink 1s step-end infinite; + } + + .chat-content-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; + } + + .chat-drop-overlay { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in oklch, var(--color-background) 80%, transparent); + border: 2px dashed var(--color-primary); + border-radius: 12px; + backdrop-filter: blur(4px); + } +} + +/* ─── Reduced motion ────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .chat-message, + .chat-typing-dot, + .chat-toolbar-enter, + .chat-reaction-pop, + .chat-status-read { + animation: none; + } +} +``` + +Note: This CSS uses `var(--color-*)` (Tailwind's theme inline variables, e.g., `--color-primary` maps to xtablo's `--primary`). This is how you reference design tokens from CSS in Tailwind v4. + +- [ ] **Step 2: Commit** + +```bash +git add packages/chat-ui/src/chat-ui.css +git commit -m "feat(chat-ui): add animation and utility CSS" +``` + +--- + +### Task 4: Copy and adapt chat.tsx (core components) + +**Files:** +- Create: `packages/chat-ui/src/components/chat.tsx` +- Source: `chatcn/src/components/ui/chat/chat.tsx` (1415 lines) + +This is the largest file. Apply three systematic transformations: + +1. **Remove `"use client"` directive** (line 0) +2. **Remap imports** (lines 2-42) +3. **Replace all `var(--chat-*)` with Tailwind classes** (throughout) +4. **Remove theme prop from ChatProvider** and `data-chat-theme` attribute + +- [ ] **Step 1: Copy the file** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/chat.tsx` to `packages/chat-ui/src/components/chat.tsx`. + +- [ ] **Step 2: Remove "use client" directive** + +Delete the first line `"use client"`. + +- [ ] **Step 3: Remap imports** + +Replace: +```typescript +import { cn } from "@/lib/utils" +``` +With: +```typescript +import { cn } from "@xtablo/shared/lib/cn" +``` + +The `import type { ..., ChatTheme } from "./types"` — remove `ChatTheme` from this import. + +All other imports (React, lucide-react, createPortal, local ./types, ./hooks) stay as-is since they're either external packages or relative imports within the package. + +- [ ] **Step 4: Remove theme from ChatProvider** + +In `ChatProviderProps` interface (~line 57-71): remove `theme?: ChatTheme` prop. + +In `ChatProvider` function (~line 73-110): +- Remove `theme = "lunar"` from destructured params +- Remove `data-chat-theme={theme}` from the wrapping div — change to just `
` + +- [ ] **Step 5: Replace all var(--chat-*) occurrences with Tailwind classes** + +This is the bulk of the work. Apply the token mapping table from above systematically throughout the file. The pattern is to replace inline `var(--chat-*)` within className strings. + +Examples of transformations: + +``` +// BEFORE +bg-[var(--chat-bg-sidebar)] +// AFTER +bg-card + +// BEFORE +text-[var(--chat-text-primary)] +// AFTER +text-foreground + +// BEFORE +text-[var(--chat-text-secondary)] +// AFTER +text-muted-foreground + +// BEFORE +text-[var(--chat-text-tertiary)] +// AFTER +text-muted-foreground/60 + +// BEFORE +border-[var(--chat-border)] +// AFTER +border-border + +// BEFORE +border-[var(--chat-border-strong)] +// AFTER +border-border + +// BEFORE +bg-[var(--chat-accent-soft)] +// AFTER +bg-accent + +// BEFORE +text-[var(--chat-accent)] +// AFTER +text-primary + +// BEFORE +bg-[var(--chat-accent)] +// AFTER +bg-primary + +// BEFORE +bg-[var(--chat-bubble-outgoing)] +// AFTER +bg-primary + +// BEFORE +text-[var(--chat-bubble-outgoing-text)] +// AFTER +text-primary-foreground + +// BEFORE +bg-[var(--chat-bubble-incoming)] +// AFTER +bg-muted + +// BEFORE +text-[var(--chat-bubble-incoming-text)] +// AFTER +text-foreground + +// BEFORE +bg-[var(--chat-bg-main)] +// AFTER +bg-background + +// BEFORE +bg-[var(--chat-bg-composer)] +// AFTER +bg-card + +// BEFORE +shadow-[var(--chat-shadow-toolbar)] +// AFTER +shadow-md + +// BEFORE +shadow-[var(--chat-shadow-lg)] +// AFTER +shadow-lg + +// BEFORE +shadow-[var(--chat-shadow-md)] +// AFTER +shadow-md + +// BEFORE +text-[var(--chat-red)] +// AFTER +text-destructive + +// BEFORE +bg-[var(--chat-bg-code)] +// AFTER +bg-muted + +// BEFORE +style={{ fontFamily: "var(--chat-font-mono)" }} +// AFTER (use className instead) +className="font-mono ..." +``` + +For inline `style` attributes that reference `var(--chat-*)`: +- `style={{ background: "var(--chat-accent)" }}` → replace with `className` using `bg-primary` +- In `ChatVoiceMessage`, the waveform bars use inline `style` with `var(--chat-accent)` — replace with `var(--color-primary)` (Tailwind v4 resolved token) +- In `ChatVoiceMessage`, `style={{ color: "white" }}` stays (it's already a concrete value) + +Special cases in `ChatMessageReply`: +- `border-[var(--chat-bubble-outgoing-text)]/30` → `border-primary-foreground/30` +- `bg-[var(--chat-bubble-outgoing-text)]/10` → `bg-primary-foreground/10` + +Special cases in `ChatMessageReactions`: +- `border-[var(--chat-accent)]/30` → `border-primary/30` + +- [ ] **Step 6: Verify types compile** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/chat-ui typecheck` +Expected: no type errors (or only errors from missing features.tsx/layouts.tsx imports, which is fine for now) + +- [ ] **Step 7: Commit** + +```bash +git add packages/chat-ui/src/components/chat.tsx +git commit -m "feat(chat-ui): add core chat components with xtablo theming" +``` + +--- + +### Task 5: Copy and adapt features.tsx + +**Files:** +- Create: `packages/chat-ui/src/components/features.tsx` +- Source: `chatcn/src/components/ui/chat/features.tsx` (510 lines) + +- [ ] **Step 1: Copy the file** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/features.tsx` to `packages/chat-ui/src/components/features.tsx`. + +- [ ] **Step 2: Remove "use client" and remap imports** + +Remove `"use client"` directive. + +Replace: +```typescript +import { cn } from "@/lib/utils" +``` +With: +```typescript +import { cn } from "@xtablo/shared/lib/cn" +``` + +Other imports (`lucide-react`, `./types`, `./hooks`) stay as-is. + +- [ ] **Step 3: Replace all var(--chat-*) with Tailwind classes** + +Apply the same token mapping as Task 4. This file uses these tokens: + +- `var(--chat-border-strong)` → remove bracket, use `border-border` +- `var(--chat-bg-sidebar)` → `bg-card` +- `var(--chat-shadow-lg)` → `shadow-lg` +- `var(--chat-shadow-toolbar)` → `shadow-md` +- `var(--chat-border)` → `border-border` +- `var(--chat-text-primary)` → `text-foreground` +- `var(--chat-text-secondary)` → `text-muted-foreground` +- `var(--chat-text-tertiary)` → `text-muted-foreground/60` +- `var(--chat-bg-main)` → `bg-background` +- `var(--chat-accent-soft)` → `bg-accent` +- `var(--chat-bubble-incoming)` → `bg-muted` +- `var(--chat-accent)` → `text-primary` or `bg-primary` depending on context +- `var(--chat-red)` → `text-destructive` +- `var(--chat-orange)` → `text-orange-500` +- `var(--chat-bg-composer)` → `bg-card` + +- [ ] **Step 4: Commit** + +```bash +git add packages/chat-ui/src/components/features.tsx +git commit -m "feat(chat-ui): add feature components with xtablo theming" +``` + +--- + +### Task 6: Copy and adapt layouts.tsx + +**Files:** +- Create: `packages/chat-ui/src/components/layouts.tsx` +- Source: `chatcn/src/components/ui/chat/layouts.tsx` (822 lines) + +- [ ] **Step 1: Copy the file** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/layouts.tsx` to `packages/chat-ui/src/components/layouts.tsx`. + +- [ ] **Step 2: Remove "use client" and remap imports** + +Remove `"use client"` directive. + +Replace: +```typescript +import { cn } from "@/lib/utils" +``` +With: +```typescript +import { cn } from "@xtablo/shared/lib/cn" +``` + +Remove `ChatTheme` from the type imports: +```typescript +import type { ChatMessageData, ChatUser, TypingUser } from "./types" +``` + +The imports from `"./chat"` stay as relative: +```typescript +import { ChatProvider, ChatMessages, ChatComposer } from "./chat" +``` + +- [ ] **Step 3: Remove theme prop from all layout components** + +Every layout component (`FullMessenger`, `ChatWidget`, `InlineChat`, `ChatBoard`, `LiveChat`) has a `theme?: ChatTheme` prop passed to ``. Remove it from: +1. Each component's Props interface — remove `theme?: ChatTheme` +2. Each component's destructured params — remove `theme = "lunar"` +3. Each `` usage — remove `theme={theme}` + +- [ ] **Step 4: Replace all var(--chat-*) with Tailwind classes** + +Same mapping as previous tasks. This file uses the same set of tokens plus: +- `var(--chat-bg-app)` → `bg-background` +- `var(--chat-bg-header)` → `bg-card` +- `var(--chat-green)` → `text-green-500` / `bg-green-500` + +- [ ] **Step 5: Commit** + +```bash +git add packages/chat-ui/src/components/layouts.tsx +git commit -m "feat(chat-ui): add layout components with xtablo theming" +``` + +--- + +### Task 7: Create barrel export (index.ts) + +**Files:** +- Create: `packages/chat-ui/src/index.ts` +- Source: `chatcn/src/components/ui/chat/index.ts` + +- [ ] **Step 1: Copy index.ts from chatcn** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/index.ts` to `packages/chat-ui/src/index.ts`. + +- [ ] **Step 2: Update import paths** + +The chatcn index.ts uses `"./chat"`, `"./features"`, `"./layouts"`, `"./security"`, `"./types"`, `"./hooks"`. Update the component imports to point into the `components/` subdirectory: + +Replace: +```typescript +} from "./chat" +``` +With: +```typescript +} from "./components/chat" +``` + +Replace: +```typescript +} from "./features" +``` +With: +```typescript +} from "./components/features" +``` + +Replace: +```typescript +} from "./layouts" +``` +With: +```typescript +} from "./components/layouts" +``` + +The `"./security"`, `"./types"`, and `"./hooks"` imports stay as-is (they're at the src root). + +Also remove `ChatTheme` from the types export section: +```typescript +// Remove this line from the type exports: +// ChatTheme, +``` + +- [ ] **Step 3: Add CSS export** + +Add at the top of the file: +```typescript +import "./chat-ui.css" +``` + +This ensures the CSS is included when the package is consumed. + +- [ ] **Step 4: Verify full package compiles** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/chat-ui typecheck` +Expected: PASS with no errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/chat-ui/src/index.ts +git commit -m "feat(chat-ui): add barrel export and wire up CSS" +``` + +--- + +### Task 8: Update main app dependencies + +**Files:** +- Modify: `apps/main/package.json` + +- [ ] **Step 1: Add @xtablo/chat-ui dependency** + +In `apps/main/package.json`, add to the `dependencies` section: +```json +"@xtablo/chat-ui": "workspace:*", +``` + +- [ ] **Step 2: Remove chatscope dependencies** + +Remove these two lines from `dependencies`: +```json +"@chatscope/chat-ui-kit-react": "^2.1.1", +"@chatscope/chat-ui-kit-styles": "^1.4.0", +``` + +- [ ] **Step 3: Run pnpm install** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm install` +Expected: lockfile updated, chatscope removed, @xtablo/chat-ui linked + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/package.json pnpm-lock.yaml +git commit -m "feat(main): switch from chatscope to @xtablo/chat-ui" +``` + +--- + +### Task 9: Rewrite ChatMessages.tsx to use @xtablo/chat-ui + +**Files:** +- Modify: `apps/main/src/components/ChatMessages.tsx` + +This is the core integration point. The current file is 184 lines using chatscope components. Replace it entirely with a new implementation using `@xtablo/chat-ui`. + +- [ ] **Step 1: Rewrite ChatMessages.tsx** + +Replace the entire contents of `apps/main/src/components/ChatMessages.tsx` with: + +```tsx +import { useMemo } from "react"; +import { + ChatProvider, + ChatMessages as ChatMessageList, + ChatComposer, + ChatTypingIndicator, +} from "@xtablo/chat-ui"; +import type { ChatMessageData, ChatUser, TypingUser } from "@xtablo/chat-ui"; + +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + optimistic?: boolean; +} + +interface Member { + id: string; + name: string; + avatar_url: string | null; +} + +interface ChatMessagesProps { + messages: ChatMessage[]; + currentUserId: string; + members: Member[]; + typingUsers: string[]; + hasMoreMessages: boolean; + onLoadMore?: () => void; + onSend: (text: string) => void; + onTyping: () => void; + placeholder?: string; +} + +export function ChatMessages({ + messages, + currentUserId, + members, + typingUsers, + hasMoreMessages, + onLoadMore, + onSend, + onTyping, + placeholder = "Envoyer un message...", +}: ChatMessagesProps) { + const membersById = useMemo(() => { + const map = new Map(); + for (const m of members) { + map.set(m.id, m); + } + return map; + }, [members]); + + const currentUser = useMemo( + () => ({ + id: currentUserId, + name: membersById.get(currentUserId)?.name ?? "Moi", + avatar: membersById.get(currentUserId)?.avatar_url ?? undefined, + }), + [currentUserId, membersById], + ); + + const chatMessages = useMemo( + () => + messages.map((msg) => { + const member = membersById.get(msg.userId); + return { + id: msg.id, + senderId: msg.userId, + senderName: member?.name ?? "Utilisateur", + senderAvatar: member?.avatar_url ?? undefined, + text: msg.text, + timestamp: new Date(msg.createdAt), + status: msg.optimistic ? "sending" : undefined, + }; + }), + [messages, membersById], + ); + + const chatTypingUsers = useMemo( + () => + typingUsers.map((userId) => ({ + id: userId, + name: membersById.get(userId)?.name ?? "Utilisateur", + avatar: membersById.get(userId)?.avatar_url ?? undefined, + })), + [typingUsers, membersById], + ); + + return ( + + + { + if (_isTyping) onTyping(); + }} + placeholder={placeholder} + /> + + ); +} +``` + +Key decisions: +- **Props interface stays identical** — `ChatPage` and `TabloDiscussionSection` don't need changes. +- **Data transformation** happens in `useMemo` — maps xtablo's flat message/member model to chatcn's richer types. +- **Typing callback bridge** — chatcn's `onTyping` fires `(isTyping: boolean)` while xtablo's `onTyping` / `sendTyping` is fire-and-forget. We only call `onTyping()` when typing starts. +- **Localization** — French strings ("Utilisateur", "Moi", "Envoyer un message...") are kept at the app level as before. The chatcn `formatDateLabel` returns English labels ("Today", "Yesterday") — this is acceptable for now and can be overridden later with i18next. + +- [ ] **Step 2: Import the CSS in the main app** + +In `apps/main/src/main.css`, add the chat-ui CSS import at the top (after tailwindcss): + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@xtablo/chat-ui/src/chat-ui.css"; +``` + +Alternatively, if the `import "./chat-ui.css"` in `index.ts` works via Vite's CSS handling (it should, since Vite processes CSS imports in JS), this explicit import may not be needed. Test by checking if animations work in the browser. If they do, skip this step. + +- [ ] **Step 3: Verify types compile** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/main typecheck` +Expected: PASS with no errors + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/src/components/ChatMessages.tsx +git commit -m "feat(main): rewrite ChatMessages to use @xtablo/chat-ui" +``` + +--- + +### Task 10: Visual testing and cleanup + +**Files:** +- Possibly modify: `apps/main/src/main.css` (CSS import if needed) +- Possibly modify: `packages/chat-ui/src/components/chat.tsx` (style fixes) + +- [ ] **Step 1: Start the dev server** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm dev:main` +Expected: Main app starts on port 5173 + +- [ ] **Step 2: Test the chat page** + +Open http://localhost:5173 in a browser. Navigate to a chat channel. Verify: + +1. Messages render correctly (incoming on left, outgoing on right) +2. Avatars show for incoming messages (initials fallback if no avatar URL) +3. Message grouping works (consecutive messages from same sender are grouped) +4. Date separators appear between days +5. Typing indicator shows when someone is typing +6. Composer works — type a message and send with Enter +7. Auto-scroll to bottom on new messages +8. Scroll-to-bottom button appears when scrolled up +9. Optimistic messages show with sending status (clock icon) +10. Dark mode looks correct (toggle dark mode if available) + +- [ ] **Step 3: Test the tablo discussion section** + +Navigate to a tablo detail page that has a discussion section. Verify the chat works there too — same component, same behavior. + +- [ ] **Step 4: Fix any visual issues** + +If any theme tokens don't look right (e.g., contrast issues, colors too similar), adjust the mapping in the affected component file. Common issues: +- If `text-muted-foreground/60` is too faint, try `text-muted-foreground/70` +- If outgoing bubble color (bg-primary) doesn't feel "chat-like", consider adding a chat-specific CSS variable in `main.css` + +- [ ] **Step 5: Remove chatscope CSS import if still present anywhere** + +Search for any remaining chatscope references: + +Run: `grep -r "chatscope" apps/main/src/` +Expected: no results + +- [ ] **Step 6: Final typecheck and lint** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm typecheck && pnpm lint` +Expected: PASS + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A +git commit -m "fix(chat-ui): visual polish and cleanup after integration" +``` + +--- + +## Post-Integration Notes + +After all tasks are complete: + +- **chatscope** is fully removed — no dependencies, no CSS import, no component usage +- **@xtablo/chat-ui** is a self-contained workspace package with all chatcn components +- **ChatMessages.tsx** keeps its original props interface — no changes needed in consuming components +- **Future features** (reactions, threads, search, pinned messages) are available in the package and can be wired up incrementally +- **Localization** — date labels are in English. To switch to French, create a custom `formatDateLabel` using i18next and pass translated labels, or override the function in the package diff --git a/docs/superpowers/plans/2026-04-15-client-magic-links.md b/docs/superpowers/plans/2026-04-15-client-magic-links.md new file mode 100644 index 0000000..5fb702e --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-client-magic-links.md @@ -0,0 +1,1822 @@ +# Client Magic Links Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace temporary user invitations with magic links, served via a new `apps/clients` portal at `clients.xtablo.com`, powered by shared tablo view components extracted into `packages/tablo-views`. + +**Architecture:** Three parallel workstreams — (1) database + API changes for `is_client` users and `client_invites`, (2) extract tablo view components into `packages/tablo-views`, (3) scaffold `apps/clients` portal. The API serves both `apps/main` and `apps/clients` with permission scoping via middleware. + +**Tech Stack:** React 19, Vite, Cloudflare Workers, Hono, Supabase Auth (magic links), TanStack Query, Tailwind CSS v4, pnpm workspaces, Turborepo. + +**Spec:** `docs/superpowers/specs/2026-04-15-client-magic-links-design.md` + +--- + +## File Structure + +### New files + +**Database:** +- `supabase/migrations/20260415120000_add_client_invites.sql` — migration: `is_client` column + `client_invites` table + RLS + +**API:** +- `apps/api/src/routers/clientInvites.ts` — client invite endpoints (create, accept, list, cancel) +- `apps/api/src/__tests__/routes/clientInvites.test.ts` — tests for client invite routes + +**Package: `packages/tablo-views`:** +- `packages/tablo-views/package.json` +- `packages/tablo-views/tsconfig.json` +- `packages/tablo-views/src/index.ts` — barrel export +- `packages/tablo-views/src/TabloTasksSection.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloFilesSection.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloDiscussionSection.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloEventsSection.tsx` — moved from apps/main +- `packages/tablo-views/src/EtapesSection.tsx` — extracted from tablo-details.tsx +- `packages/tablo-views/src/RoadmapSection.tsx` — extracted from tablo-details.tsx +- `packages/tablo-views/src/ChatMessages.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloHeaderActions.tsx` — moved from apps/main +- `packages/tablo-views/src/hooks/useChat.ts` — moved from apps/main +- `packages/tablo-views/src/hooks/useChatUnread.ts` — moved from apps/main +- `packages/tablo-views/src/components/gantt/GanttChart.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/KanbanBoard.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/KanbanColumn.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/KanbanTaskCard.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/TaskModal.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/types.ts` — moved from apps/main + +**App: `apps/clients`:** +- `apps/clients/package.json` +- `apps/clients/vite.config.ts` +- `apps/clients/wrangler.toml` +- `apps/clients/worker/index.ts` +- `apps/clients/index.html` +- `apps/clients/tsconfig.json` +- `apps/clients/src/main.tsx` +- `apps/clients/src/main.css` +- `apps/clients/src/App.tsx` +- `apps/clients/src/routes.tsx` +- `apps/clients/src/i18n.ts` +- `apps/clients/src/pages/AuthCallback.tsx` +- `apps/clients/src/pages/ClientTabloPage.tsx` +- `apps/clients/src/pages/ClientTabloListPage.tsx` +- `apps/clients/src/components/ClientLayout.tsx` + +### Modified files + +- `apps/api/src/middlewares/middleware.ts` — add `is_client` check to `createProfileAccessMiddleware` +- `apps/api/src/routers/authRouter.ts` — mount `clientInvites` router +- `apps/api/src/routers/tablo.ts` — add `checkTabloAdmin` to new client invite endpoint +- `apps/api/src/helpers/helpers.ts` — add `createClientUser()` function +- `apps/api/src/helpers/billing.ts` — exclude `is_client` from `getBillableMemberCount` +- `apps/api/src/__tests__/middlewares/middlewares.test.ts` — add `is_client` middleware tests +- `apps/main/src/pages/tablo-details.tsx` — import sections from `@xtablo/tablo-views` instead of local +- `apps/main/src/components/TabloHeaderActions.tsx` — add client invite UI to share dialog +- `packages/shared-types/src/database.types.ts` — regenerated after migration (or manually add types) +- `package.json` (root) — add `dev:clients` script +- `pnpm-workspace.yaml` — already covers `apps/*` and `packages/*`, no change needed + +--- + +## Task 1: Database Migration — `is_client` Column and `client_invites` Table + +**Files:** +- Create: `supabase/migrations/20260415120000_add_client_invites.sql` +- Modify: `packages/shared-types/src/database.types.ts` + +- [ ] **Step 1: Write the migration SQL** + +```sql +-- Add is_client column to profiles +ALTER TABLE public.profiles + ADD COLUMN is_client boolean NOT NULL DEFAULT false; + +-- Create client_invites table +CREATE TABLE public.client_invites ( + id serial PRIMARY KEY, + tablo_id text NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE, + invited_email varchar(255) NOT NULL, + invited_by uuid NOT NULL REFERENCES public.profiles(id), + invite_token text NOT NULL, + expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'), + is_pending boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Index for token lookups +CREATE UNIQUE INDEX idx_client_invites_token ON public.client_invites(invite_token); + +-- Index for listing invites by tablo +CREATE INDEX idx_client_invites_tablo ON public.client_invites(tablo_id, is_pending); + +-- RLS +ALTER TABLE public.client_invites ENABLE ROW LEVEL SECURITY; + +-- Admins can manage invites they created +CREATE POLICY "Admins can manage their client invites" + ON public.client_invites + FOR ALL + USING (invited_by = auth.uid()); + +-- Client users can read invites sent to their email +CREATE POLICY "Clients can read their own invites" + ON public.client_invites + FOR SELECT + USING ( + invited_email = ( + SELECT email FROM auth.users WHERE id = auth.uid() + ) + ); +``` + +Save to `supabase/migrations/20260415120000_add_client_invites.sql`. + +- [ ] **Step 2: Add TypeScript types for `client_invites`** + +Add the following types to `packages/shared-types/src/database.types.ts` in the `Tables` interface, following the existing pattern used by `tablo_invites`: + +```typescript +client_invites: { + Row: { + id: number; + tablo_id: string; + invited_email: string; + invited_by: string; + invite_token: string; + expires_at: string; + is_pending: boolean; + created_at: string; + }; + Insert: { + id?: number; + tablo_id: string; + invited_email: string; + invited_by: string; + invite_token: string; + expires_at?: string; + is_pending?: boolean; + created_at?: string; + }; + Update: { + id?: number; + tablo_id?: string; + invited_email?: string; + invited_by?: string; + invite_token?: string; + expires_at?: string; + is_pending?: boolean; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: "client_invites_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_invites_invited_by_fkey"; + columns: ["invited_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + } + ]; +}; +``` + +Also add `is_client: boolean` to the `profiles` Row, Insert, and Update types. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260415120000_add_client_invites.sql packages/shared-types/src/database.types.ts +git commit -m "feat(db): add is_client column and client_invites table" +``` + +--- + +## Task 2: API Middleware — Add `is_client` Permission Check + +**Files:** +- Modify: `apps/api/src/middlewares/middleware.ts:77-100` +- Modify: `apps/api/src/helpers/billing.ts:89-90` +- Test: `apps/api/src/__tests__/middlewares/middlewares.test.ts` + +- [ ] **Step 1: Write failing test for `is_client` user blocked by `regularUserCheckMiddleware`** + +In `apps/api/src/__tests__/middlewares/middlewares.test.ts`, add a new test in the "Regular user check middleware" describe block: + +```typescript +it("should return 401 for client users", async () => { + const app = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + app.use(middlewareManager.supabase); + app.use(middlewareManager.auth); + app.use(middlewareManager.regularUserCheck); + app.get("/test", (c) => c.json({ success: true })); + + mockSupabaseFrom.mockImplementation((table: string) => { + if (table === "profiles") { + return { + select: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { is_temporary: false, is_client: true }, + error: null, + }), + }), + }), + }; + } + return {}; + }); + + const res = await app.request("/test", { + headers: { Authorization: "Bearer valid-token" }, + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("User is read only"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/api && pnpm test -- --run src/__tests__/middlewares/middlewares.test.ts` +Expected: FAIL — the current middleware only checks `is_temporary`, not `is_client` + +- [ ] **Step 3: Update middleware to check `is_client`** + +In `apps/api/src/middlewares/middleware.ts`, modify `createProfileAccessMiddleware` (line 77-100): + +Change the select from: +```typescript +.select("is_temporary") +``` +to: +```typescript +.select("is_temporary, is_client") +``` + +Change the check from: +```typescript +if (!allowTemporaryUsers && profile.is_temporary) { +``` +to: +```typescript +if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) { +``` + +This blocks `is_client` users from all routes that use `regularUserCheckMiddleware`. Client-accessible routes don't use this middleware — they only require `auth`. + +- [ ] **Step 4: Update billing exclusion** + +In `apps/api/src/helpers/billing.ts`, change `getBillableMemberCount` (line 89-90): + +From: +```typescript +export const getBillableMemberCount = (profiles: BillingProfileRow[]) => + profiles.filter((profile) => profile.is_temporary !== true).length; +``` +To: +```typescript +export const getBillableMemberCount = (profiles: BillingProfileRow[]) => + profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length; +``` + +Note: The `BillingProfileRow` type (defined earlier in billing.ts) needs `is_client` added. Find the type definition and add `is_client: boolean` alongside the existing `is_temporary: boolean`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/api && pnpm test -- --run src/__tests__/middlewares/middlewares.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/middlewares/middleware.ts apps/api/src/helpers/billing.ts apps/api/src/__tests__/middlewares/middlewares.test.ts +git commit -m "feat(api): add is_client check to middleware and billing" +``` + +--- + +## Task 3: API — Client Invite Endpoints + +**Files:** +- Create: `apps/api/src/routers/clientInvites.ts` +- Modify: `apps/api/src/routers/authRouter.ts` +- Modify: `apps/api/src/helpers/helpers.ts` +- Create: `apps/api/src/__tests__/routes/clientInvites.test.ts` + +- [ ] **Step 1: Add `createClientUser` helper** + +In `apps/api/src/helpers/helpers.ts`, add a new function after `createInvitedUser`: + +```typescript +export async function createClientUser( + supabase: SupabaseClient, + recipientEmail: string, + tabloId: string, + grantedBy: string +): Promise<{ success: boolean; error?: string; userId?: string }> { + // Check if user already exists + const { data: existingUsers } = await supabase.auth.admin.listUsers(); + const existingUser = existingUsers?.users?.find( + (u) => u.email?.toLowerCase() === recipientEmail.toLowerCase() + ); + + let userId: string; + + if (existingUser) { + userId = existingUser.id; + + // Mark as client if not already + await supabase + .from("profiles") + .update({ is_client: true }) + .eq("id", userId) + .eq("is_client", false); + } else { + // Create new auth user (no password — magic link only) + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email: recipientEmail, + email_confirm: true, + user_metadata: { role: "client" }, + }); + + if (authError || !authData?.user) { + return { success: false, error: authError?.message ?? "Failed to create user" }; + } + + userId = authData.user.id; + + // Set is_client on profile + await supabase.from("profiles").update({ is_client: true }).eq("id", userId); + } + + // Grant tablo access if not already granted + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", tabloId) + .eq("user_id", userId) + .single(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: userId, + granted_by: grantedBy, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase + .from("tablo_access") + .update({ is_active: true }) + .eq("id", existingAccess.id); + } + + return { success: true, userId }; +} +``` + +- [ ] **Step 2: Create client invites router** + +Create `apps/api/src/routers/clientInvites.ts`: + +```typescript +import { Hono } from "hono"; +import { checkTabloAdmin } from "../helpers/helpers.js"; +import { generateToken } from "../helpers/token.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import { createClientUser } from "../helpers/helpers.js"; +import type { SupabaseClient, User } from "@supabase/supabase-js"; + +type Env = { + Variables: { + supabase: SupabaseClient; + user: User; + }; +}; + +export const getClientInvitesRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + // Create client invite (admin only) + router.post( + "/:tabloId", + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + const tabloId = c.req.param("tabloId"); + const { email } = await c.req.json<{ email: string }>(); + + if (!email || !email.includes("@")) { + return c.json({ error: "Invalid email" }, 400); + } + + const token = generateToken(); + + // Create client user + tablo access + const result = await createClientUser(supabase, email, tabloId, user.id); + if (!result.success) { + return c.json({ error: result.error }, 500); + } + + // Create client_invites record + const { error: insertError } = await supabase.from("client_invites").insert({ + tablo_id: tabloId, + invited_email: email.toLowerCase(), + invited_by: user.id, + invite_token: token, + }); + + if (insertError) { + return c.json({ error: insertError.message }, 500); + } + + // Generate Supabase magic link + const redirectTo = `${c.req.header("origin")?.replace("app.", "clients.") ?? "https://clients.xtablo.com"}/auth/callback?token=${token}`; + + const { error: linkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", + email, + options: { redirectTo }, + }); + + if (linkError) { + return c.json({ error: "Failed to send magic link" }, 500); + } + + return c.json({ success: true }); + } + ); + + // Accept client invite via token + router.post("/accept/:token", async (c) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + const token = c.req.param("token"); + + const { data: invite, error } = await supabase + .from("client_invites") + .select("*") + .eq("invite_token", token) + .eq("is_pending", true) + .single(); + + if (error || !invite) { + return c.json({ error: "Invalid or expired invite" }, 404); + } + + // Check expiration + if (new Date(invite.expires_at) < new Date()) { + return c.json({ error: "Invite has expired" }, 410); + } + + // Verify email matches + const { data: userProfile } = await supabase + .from("profiles") + .select("email") + .eq("id", user.id) + .single(); + + if (userProfile?.email?.toLowerCase() !== invite.invited_email.toLowerCase()) { + return c.json({ error: "Email mismatch" }, 403); + } + + // Mark invite as accepted + await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", invite.id); + + // Ensure tablo_access is active + const { data: access } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", invite.tablo_id) + .eq("user_id", user.id) + .single(); + + if (access && !access.is_active) { + await supabase + .from("tablo_access") + .update({ is_active: true }) + .eq("id", access.id); + } + + return c.json({ success: true, tabloId: invite.tablo_id }); + }); + + // List pending client invites for a tablo (admin only) + router.get( + "/:tabloId/pending", + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("client_invites") + .select("id, invited_email, expires_at, is_pending, created_at") + .eq("tablo_id", tabloId) + .eq("is_pending", true) + .order("created_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ invites: data }); + } + ); + + // Cancel client invite (admin only) + router.delete( + "/:tabloId/:inviteId", + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { + const supabase = c.get("supabase"); + const inviteId = c.req.param("inviteId"); + const tabloId = c.req.param("tabloId"); + + const { data: invite } = await supabase + .from("client_invites") + .select("invited_email") + .eq("id", Number(inviteId)) + .eq("tablo_id", tabloId) + .single(); + + if (!invite) { + return c.json({ error: "Invite not found" }, 404); + } + + // Mark as not pending + await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", Number(inviteId)); + + // Revoke tablo access for client user + const { data: profile } = await supabase + .from("profiles") + .select("id, is_client") + .eq("email", invite.invited_email) + .single(); + + if (profile?.is_client) { + await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", profile.id); + } + + return c.json({ success: true }); + } + ); + + return router; +}; +``` + +- [ ] **Step 3: Mount the router in authRouter.ts** + +In `apps/api/src/routers/authRouter.ts`, add the import and route: + +```typescript +import { getClientInvitesRouter } from "./clientInvites.js"; +``` + +Add after the existing routes (before `return authRouter`): +```typescript +authRouter.route("/client-invites", getClientInvitesRouter()); +``` + +- [ ] **Step 4: Write tests for client invite endpoints** + +Create `apps/api/src/__tests__/routes/clientInvites.test.ts`. Follow the existing test patterns from `apps/api/src/__tests__/routes/tablo.test.ts` for mocking supabase. Key test cases: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +// ... test setup matching existing patterns + +describe("Client Invites Router", () => { + describe("POST /:tabloId (create invite)", () => { + it("should create client invite and return success", async () => { + // Mock admin access check, user creation, invite insert, magic link generation + // Assert: 200, { success: true } + }); + + it("should reject non-admin users", async () => { + // Mock non-admin tablo_access + // Assert: 403 + }); + + it("should reject invalid email", async () => { + // Send email without @ + // Assert: 400 + }); + }); + + describe("POST /accept/:token", () => { + it("should accept valid invite and return tabloId", async () => { + // Mock valid pending invite, matching email, active tablo_access + // Assert: 200, { success: true, tabloId: "..." } + }); + + it("should reject expired invite", async () => { + // Mock invite with expires_at in the past + // Assert: 410 + }); + + it("should reject email mismatch", async () => { + // Mock invite with different email than authenticated user + // Assert: 403 + }); + }); + + describe("GET /:tabloId/pending", () => { + it("should return pending invites for admin", async () => { + // Mock admin + pending invites + // Assert: 200, { invites: [...] } + }); + }); + + describe("DELETE /:tabloId/:inviteId", () => { + it("should cancel invite and revoke access for client user", async () => { + // Mock invite + client profile + // Assert: 200, tablo_access set to inactive + }); + }); +}); +``` + +Fill in complete mock setup following the patterns in `apps/api/src/__tests__/routes/tablo.test.ts`. + +- [ ] **Step 5: Run tests** + +Run: `cd apps/api && pnpm test -- --run` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/routers/clientInvites.ts apps/api/src/routers/authRouter.ts apps/api/src/helpers/helpers.ts apps/api/src/__tests__/routes/clientInvites.test.ts +git commit -m "feat(api): add client invite endpoints with magic link flow" +``` + +--- + +## Task 4: Scaffold `packages/tablo-views` + +**Files:** +- Create: `packages/tablo-views/package.json` +- Create: `packages/tablo-views/tsconfig.json` +- Create: `packages/tablo-views/src/index.ts` + +- [ ] **Step 1: Create `packages/tablo-views/package.json`** + +```json +{ + "name": "@xtablo/tablo-views", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts", + "./*": "./src/*.tsx" + }, + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/chat-ui": "workspace:*", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/ui": "workspace:*", + "date-fns": "^4.1.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Create `packages/tablo-views/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@xtablo/ui": ["../ui/src"], + "@xtablo/ui/*": ["../ui/src/*"], + "@xtablo/shared": ["../shared/src"], + "@xtablo/shared/*": ["../shared/src/*"] + } + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `packages/tablo-views/src/index.ts`** + +```typescript +// Section components +export { TabloTasksSection } from "./TabloTasksSection"; +export { TabloFilesSection } from "./TabloFilesSection"; +export { TabloDiscussionSection } from "./TabloDiscussionSection"; +export { TabloEventsSection } from "./TabloEventsSection"; +export { EtapesSection } from "./EtapesSection"; +export { RoadmapSection } from "./RoadmapSection"; +export { TabloHeaderActions } from "./TabloHeaderActions"; +export { ChatMessages } from "./ChatMessages"; + +// Sub-components +export { GanttChart } from "./components/gantt/GanttChart"; +export { TaskModal } from "./components/kanban/TaskModal"; +export { KanbanBoard } from "./components/kanban/KanbanBoard"; + +// Hooks +export { useChat } from "./hooks/useChat"; +export { useChatUnread } from "./hooks/useChatUnread"; + +// Types +export type { TabloMember } from "./components/kanban/types"; +``` + +- [ ] **Step 4: Install dependencies** + +Run: `pnpm install` + +- [ ] **Step 5: Commit** + +```bash +git add packages/tablo-views/ +git commit -m "feat: scaffold packages/tablo-views package" +``` + +--- + +## Task 5: Move Tablo View Components to `packages/tablo-views` + +This is the largest task. Move each component from `apps/main/src/` to `packages/tablo-views/src/`, updating internal imports. + +**Files to move** (source -> destination): +- `apps/main/src/components/TabloTasksSection.tsx` -> `packages/tablo-views/src/TabloTasksSection.tsx` +- `apps/main/src/components/TabloFilesSection.tsx` -> `packages/tablo-views/src/TabloFilesSection.tsx` +- `apps/main/src/components/TabloDiscussionSection.tsx` -> `packages/tablo-views/src/TabloDiscussionSection.tsx` +- `apps/main/src/components/TabloEventsSection.tsx` -> `packages/tablo-views/src/TabloEventsSection.tsx` +- `apps/main/src/components/TabloHeaderActions.tsx` -> `packages/tablo-views/src/TabloHeaderActions.tsx` +- `apps/main/src/components/ChatMessages.tsx` -> `packages/tablo-views/src/ChatMessages.tsx` +- `apps/main/src/hooks/useChat.ts` -> `packages/tablo-views/src/hooks/useChat.ts` +- `apps/main/src/hooks/useChatUnread.ts` -> `packages/tablo-views/src/hooks/useChatUnread.ts` +- `apps/main/src/components/gantt/GanttChart.tsx` -> `packages/tablo-views/src/components/gantt/GanttChart.tsx` +- `apps/main/src/components/kanban/KanbanBoard.tsx` -> `packages/tablo-views/src/components/kanban/KanbanBoard.tsx` +- `apps/main/src/components/kanban/KanbanColumn.tsx` -> `packages/tablo-views/src/components/kanban/KanbanColumn.tsx` +- `apps/main/src/components/kanban/KanbanTaskCard.tsx` -> `packages/tablo-views/src/components/kanban/KanbanTaskCard.tsx` +- `apps/main/src/components/kanban/InlineTaskCreate.tsx` -> `packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx` +- `apps/main/src/components/kanban/TaskModal.tsx` -> `packages/tablo-views/src/components/kanban/TaskModal.tsx` +- `apps/main/src/components/kanban/types.ts` -> `packages/tablo-views/src/components/kanban/types.ts` + +**Files to extract from `tablo-details.tsx`:** +- `EtapesSection` function (lines 950-1288) -> `packages/tablo-views/src/EtapesSection.tsx` +- `RoadmapSection` function (lines 1292-1309) -> `packages/tablo-views/src/RoadmapSection.tsx` + +- [ ] **Step 1: Move kanban sub-components first (no import changes needed between them)** + +Copy each file from `apps/main/src/components/kanban/` to `packages/tablo-views/src/components/kanban/`. The internal relative imports between kanban files (`./KanbanColumn`, `./KanbanTaskCard`, `./InlineTaskCreate`, `./types`) stay the same. + +For `TaskModal.tsx`, update the hook imports from: +```typescript +import { useTabloMembers } from "../../hooks/tablos"; +import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks"; +``` +to: +```typescript +import { useTabloMembers } from "@xtablo/shared/hooks/tablos"; +import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "@xtablo/shared/hooks/tasks"; +``` + +**Important:** Check if these hooks exist in `@xtablo/shared`. If they are in `apps/main/src/hooks/`, they need to stay as peer dependencies. In that case, `TaskModal` should accept the needed callbacks as props instead of importing hooks directly. Examine the actual hooks to decide. If the hooks are in `apps/main`, accept them as props: + +```typescript +interface TaskModalProps { + isOpen: boolean; + tabloId?: string; + taskId?: string; + onClose: () => void; + members?: TabloMember[]; + initialStatus?: TaskStatus; + etapes?: Etape[]; + tablos?: UserTablo[]; + allowTabloSelection?: boolean; + initialDueDate?: Date; +} +``` + +The hooks are already used within TaskModal, so the simplest approach is to keep `@xtablo/tablo-views` depending on the same hooks the main app uses. Since hooks like `useCreateTask`, `useTabloMembers` etc. are in `apps/main/src/hooks/`, they need to either: +1. Move to `@xtablo/shared/hooks/` (if they're pure React Query wrappers around API calls), OR +2. Stay in `apps/main` and be passed as props/callbacks + +The decision depends on whether these hooks have dependencies on app-specific context (like `UserStoreProvider`). Check each hook — if it only uses `useSession` and API calls, move it to `@xtablo/shared`. If it uses `useUser()` from `UserStoreProvider`, keep it in the app and pass data as props. + +- [ ] **Step 2: Move GanttChart** + +Copy `apps/main/src/components/gantt/GanttChart.tsx` to `packages/tablo-views/src/components/gantt/GanttChart.tsx`. + +Update the `LoadingSpinner` import. If it comes from `@ui/components/LoadingSpinner` (a local alias in apps/main), change to the full path or use a simple inline spinner. + +- [ ] **Step 3: Move section components** + +For each section component (`TabloTasksSection`, `TabloFilesSection`, `TabloDiscussionSection`, `TabloEventsSection`, `TabloHeaderActions`, `ChatMessages`): + +1. Copy the file to `packages/tablo-views/src/` +2. Update local imports to either: + - Use `@xtablo/shared/hooks/*` if the hook exists there + - Use relative imports within `packages/tablo-views/` for co-located files (e.g., `./ChatMessages`, `./components/kanban/KanbanBoard`) +3. Replace `@ui/components/LoadingSpinner` with `@xtablo/ui/components/loading-spinner` or equivalent + +Key import changes per file: + +**TabloTasksSection.tsx:** +- `../hooks/tablos` -> check if available in `@xtablo/shared/hooks/tablos` +- `../hooks/tasks` -> check if available in `@xtablo/shared/hooks/tasks` +- `./kanban/KanbanBoard` -> `./components/kanban/KanbanBoard` +- `./kanban/TaskModal` -> `./components/kanban/TaskModal` +- `./TabloHeaderActions` -> `./TabloHeaderActions` + +**TabloFilesSection.tsx:** +- `../hooks/tablo_data` -> check if in `@xtablo/shared` +- `../hooks/tablo_folders` -> check if in `@xtablo/shared` +- `../providers/UserStoreProvider` -> This is app-specific. The `useIsReadOnlyUser` and `useUser` hooks depend on Zustand store from `apps/main`. Solution: accept `isReadOnly: boolean` and `currentUser` as props instead. + +**TabloDiscussionSection.tsx:** +- `../hooks/useChat` -> `./hooks/useChat` +- `../hooks/tablos` -> check availability +- `../providers/UserStoreProvider` -> accept `currentUser` as prop +- `./ChatMessages` -> `./ChatMessages` + +**TabloEventsSection.tsx:** +- `../hooks/events` -> check availability +- `../providers/UserStoreProvider` -> accept `isReadOnly` as prop +- `./TabloHeaderActions` -> `./TabloHeaderActions` + +- [ ] **Step 4: Extract EtapesSection from tablo-details.tsx** + +Create `packages/tablo-views/src/EtapesSection.tsx` with the content from lines 950-1288 of `tablo-details.tsx`. Add the necessary imports at the top: + +```typescript +import { cn } from "@xtablo/shared"; +import type { Etape, KanbanTask } from "@xtablo/shared-types"; +import { Button } from "@xtablo/ui/components/button"; +import { Input } from "@xtablo/ui/components/input"; +import { + CalendarIcon, + ChevronDownIcon, + ChevronRightIcon, + CircleCheckIcon, + PlusIcon, +} from "lucide-react"; +import { useState } from "react"; +``` + +The hooks `useCreateTask` and `useCreateEtape` need to be available. If they're in `apps/main/src/hooks/tasks.ts`, accept callbacks as props: + +```typescript +interface EtapesSectionProps { + etapes: Etape[]; + tabloTasks: KanbanTask[]; + tabloId: string; + isAdmin: boolean; + onCreateTask: (task: { tablo_id: string; title: string; status: string; parent_task_id: string; is_parent: boolean; position: number }) => void; + onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise; + isCreatingEtape?: boolean; +} +``` + +- [ ] **Step 5: Extract RoadmapSection from tablo-details.tsx** + +Create `packages/tablo-views/src/RoadmapSection.tsx`: + +```typescript +import type { Etape, KanbanTask } from "@xtablo/shared-types"; +import { GanttChart } from "./components/gantt/GanttChart"; + +interface RoadmapSectionProps { + etapes: Etape[]; + tabloTasks: KanbanTask[]; + onDateClick: (date: Date) => void; + onTaskStatusChange: (taskId: string, status: string) => void; +} + +export function RoadmapSection({ + tabloTasks, + onDateClick, + onTaskStatusChange, +}: RoadmapSectionProps) { + return ( + + ); +} +``` + +- [ ] **Step 6: Delete moved files from `apps/main`** + +Remove the original files from `apps/main` that were moved. Do NOT delete files that are still needed by other parts of `apps/main` (e.g., `ClickOutside`, `ImageColorPicker` used by `TabloHeaderActions` — move those too or keep them and import from the new location). + +- [ ] **Step 7: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors. Fix any broken imports. + +- [ ] **Step 8: Commit** + +```bash +git add packages/tablo-views/src/ apps/main/src/ +git commit -m "refactor: move tablo view components to packages/tablo-views" +``` + +--- + +## Task 6: Update `apps/main` to Import from `packages/tablo-views` + +**Files:** +- Modify: `apps/main/package.json` +- Modify: `apps/main/src/pages/tablo-details.tsx` +- Modify: `apps/main/src/pages/chat.tsx` (if it imports useChat or ChatMessages) + +- [ ] **Step 1: Add `@xtablo/tablo-views` dependency to `apps/main`** + +In `apps/main/package.json`, add to dependencies: +```json +"@xtablo/tablo-views": "workspace:*" +``` + +- [ ] **Step 2: Update imports in `tablo-details.tsx`** + +Replace the local imports with package imports: + +From: +```typescript +import { GanttChart } from "../components/gantt/GanttChart"; +import { TaskModal } from "../components/kanban/TaskModal"; +import { TabloDiscussionSection } from "../components/TabloDiscussionSection"; +import { TabloEventsSection } from "../components/TabloEventsSection"; +import { TabloFilesSection } from "../components/TabloFilesSection"; +import { TabloTasksSection } from "../components/TabloTasksSection"; +import { useChatUnread } from "../hooks/useChatUnread"; +``` + +To: +```typescript +import { + TabloDiscussionSection, + TabloEventsSection, + TabloFilesSection, + TabloTasksSection, + EtapesSection, + RoadmapSection, + TaskModal, + useChatUnread, +} from "@xtablo/tablo-views"; +``` + +Remove the inline `EtapesSection` and `RoadmapSection` function definitions from `tablo-details.tsx` (they now live in the package). + +Update the JSX where `EtapesSection` and `RoadmapSection` are rendered to pass the new callback props (if hooks were replaced with props in Task 5). + +- [ ] **Step 3: Update chat.tsx if needed** + +If `apps/main/src/pages/chat.tsx` imports `useChat` or `ChatMessages` from local paths, update to import from `@xtablo/tablo-views`. + +- [ ] **Step 4: Run pnpm install and typecheck** + +Run: `pnpm install && pnpm typecheck` +Expected: No errors + +- [ ] **Step 5: Run dev server and verify tablo details page works** + +Run: `pnpm dev:main` +Navigate to a tablo details page. Verify all tabs render correctly: overview, etapes, tasks, files, discussion, events, roadmap. + +- [ ] **Step 6: Commit** + +```bash +git add apps/main/package.json apps/main/src/ +git commit -m "refactor: update apps/main to import tablo views from shared package" +``` + +--- + +## Task 7: Scaffold `apps/clients` + +**Files:** +- Create: all files under `apps/clients/` +- Modify: `package.json` (root) — add `dev:clients` script + +- [ ] **Step 1: Create `apps/clients/package.json`** + +```json +{ + "name": "@xtablo/clients", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --port 5175", + "build": "tsc -b && vite build --mode production", + "build:staging": "tsc -b && vite build --mode staging", + "build:prod": "tsc -b && vite build --mode production", + "deploy": "wrangler deploy", + "typecheck": "tsc -b", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "preview": "vite preview", + "clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@cloudflare/vite-plugin": "^1.9.4", + "@tailwindcss/vite": "^4.0.14", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.14", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.0", + "vite": "^6.2.2", + "vite-tsconfig-paths": "^5.1.4", + "wrangler": "^4.24.3" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/tablo-views": "workspace:*", + "@xtablo/ui": "workspace:*", + "@xtablo/chat-ui": "workspace:*", + "i18next": "^25.6.0", + "i18next-browser-languagedetector": "^8.2.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2", + "zustand": "^5.0.5" + } +} +``` + +- [ ] **Step 2: Create `apps/clients/vite.config.ts`** + +```typescript +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type PluginOption } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => { + const plugins: PluginOption[] = [ + react(), + tailwindcss(), + tsconfigPaths(), + ]; + + if (mode !== "test" && process.env.VITEST !== "true") { + plugins.push(cloudflare()); + } + + return { + plugins, + server: { + cors: false, + }, + }; +}); +``` + +- [ ] **Step 3: Create `apps/clients/wrangler.toml`** + +```toml +name = "xtablo-clients" +main = "worker/index.ts" +compatibility_date = "2025-07-09" + +[assets] +directory = "./dist/" +not_found_handling = "single-page-application" + +[observability] +enabled = true + +[env.staging] +route = { pattern = "clients-staging.xtablo.com", custom_domain = true } + +[env.production] +route = { pattern = "clients.xtablo.com", custom_domain = true } +``` + +- [ ] **Step 4: Create `apps/clients/worker/index.ts`** + +```typescript +export default { + fetch(request: Request) { + const url = new URL(request.url); + + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + return new Response(null, { status: 404 }); + }, +}; +``` + +- [ ] **Step 5: Create `apps/clients/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@xtablo/ui": ["../../packages/ui/src"], + "@xtablo/ui/*": ["../../packages/ui/src/*"], + "@xtablo/shared": ["../../packages/shared/src"], + "@xtablo/shared/*": ["../../packages/shared/src/*"], + "@xtablo/tablo-views": ["../../packages/tablo-views/src"], + "@xtablo/tablo-views/*": ["../../packages/tablo-views/src/*"] + } + }, + "include": ["src"], + "references": [] +} +``` + +- [ ] **Step 6: Create `apps/clients/index.html`** + +```html + + + + + + Xtablo — Client Portal + + +
+ + + +``` + +- [ ] **Step 7: Create `apps/clients/src/main.css`** + +Copy from `apps/external/src/main.css` (or `apps/main/src/main.css` if it has Tailwind imports). At minimum: + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@xtablo/ui/styles/globals.css"; +``` + +- [ ] **Step 8: Create `apps/clients/src/i18n.ts`** + +Copy from `apps/external/src/i18n.ts` — same i18next setup with browser language detection. + +- [ ] **Step 9: Create `apps/clients/src/main.tsx`** + +```typescript +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@xtablo/shared"; +import { SessionProvider } from "@xtablo/shared/contexts/SessionContext"; +import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; +import { Toaster } from "@xtablo/ui/components/sonner"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter as Router } from "react-router-dom"; +import App from "./App"; + +import "@xtablo/ui/styles/globals.css"; +import "./main.css"; +import "./i18n"; + +createRoot(document.getElementById("client-root")!).render( + + + + + + + + + + + + +); +``` + +- [ ] **Step 10: Create `apps/clients/src/App.tsx`** + +```typescript +import AppRoutes from "./routes"; + +export default function App() { + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 11: Create `apps/clients/src/routes.tsx`** + +```typescript +import { Route, Routes } from "react-router-dom"; +import { ClientLayout } from "./components/ClientLayout"; +import { AuthCallback } from "./pages/AuthCallback"; +import { ClientTabloPage } from "./pages/ClientTabloPage"; +import { ClientTabloListPage } from "./pages/ClientTabloListPage"; + +export default function AppRoutes() { + return ( + + } /> + }> + } /> + } /> + + + ); +} +``` + +- [ ] **Step 12: Add `dev:clients` script to root `package.json`** + +Add to the `scripts` section of the root `package.json`: +```json +"dev:clients": "turbo dev --filter=@xtablo/clients" +``` + +- [ ] **Step 13: Run pnpm install** + +Run: `pnpm install` + +- [ ] **Step 14: Commit** + +```bash +git add apps/clients/ package.json +git commit -m "feat: scaffold apps/clients Cloudflare Worker app" +``` + +--- + +## Task 8: Build `apps/clients` Pages and Layout + +**Files:** +- Create: `apps/clients/src/components/ClientLayout.tsx` +- Create: `apps/clients/src/pages/AuthCallback.tsx` +- Create: `apps/clients/src/pages/ClientTabloPage.tsx` +- Create: `apps/clients/src/pages/ClientTabloListPage.tsx` + +- [ ] **Step 1: Create `ClientLayout.tsx`** + +```typescript +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar"; +import { Button } from "@xtablo/ui/components/button"; +import { LogOut } from "lucide-react"; +import { Outlet, useNavigate } from "react-router-dom"; +import { supabase } from "@xtablo/shared/lib/supabase"; + +export function ClientLayout() { + const { session } = useSession(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await supabase.auth.signOut(); + navigate("/auth/callback"); + }; + + if (!session) { + return ( +
+

+ Your session has expired. Please use the link sent to your email to access this portal. +

+
+ ); + } + + const userEmail = session.user.email ?? ""; + const initials = userEmail.substring(0, 2).toUpperCase(); + + return ( +
+
+
+ Xtablo +
+
+ + {initials} + + {userEmail} + +
+
+
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Create `AuthCallback.tsx`** + +```typescript +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { supabase } from "@xtablo/shared/lib/supabase"; + +export function AuthCallback() { + const [searchParams] = useSearchParams(); + const { session } = useSession(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + const inviteToken = searchParams.get("token"); + + useEffect(() => { + if (!session || !inviteToken) return; + + const acceptInvite = async () => { + const apiBase = import.meta.env.VITE_API_URL as string; + const res = await fetch(`${apiBase}/api/v1/client-invites/accept/${inviteToken}`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + const body = await res.json(); + setError(body.error ?? "Failed to accept invite"); + return; + } + + const { tabloId } = await res.json(); + navigate(`/tablo/${tabloId}`, { replace: true }); + }; + + acceptInvite(); + }, [session, inviteToken, navigate]); + + if (error) { + return ( +
+
+

{error}

+

+ Please contact the person who invited you for a new link. +

+
+
+ ); + } + + return ( +
+

Authenticating...

+
+ ); +} +``` + +- [ ] **Step 3: Create `ClientTabloPage.tsx`** + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { api } from "@xtablo/shared/lib/api"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { + TabloDiscussionSection, + TabloEventsSection, + TabloFilesSection, + TabloTasksSection, + EtapesSection, + RoadmapSection, +} from "@xtablo/tablo-views"; +import { + CalendarIcon, + FolderIcon, + KanbanIcon, + LayoutDashboardIcon, + ListChecksIcon, + MapIcon, + MessageCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; + +type TabSection = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap"; + +const TABS: { id: TabSection; label: string; icon: React.ElementType }[] = [ + { id: "overview", label: "Overview", icon: LayoutDashboardIcon }, + { id: "etapes", label: "Stages", icon: ListChecksIcon }, + { id: "tasks", label: "Tasks", icon: KanbanIcon }, + { id: "files", label: "Files", icon: FolderIcon }, + { id: "discussion", label: "Discussion", icon: MessageCircleIcon }, + { id: "events", label: "Events", icon: CalendarIcon }, + { id: "roadmap", label: "Roadmap", icon: MapIcon }, +]; + +export function ClientTabloPage() { + const { tabloId } = useParams<{ tabloId: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const { session } = useSession(); + + const sectionParam = searchParams.get("section") as TabSection | null; + const activeSection: TabSection = + sectionParam && TABS.some((t) => t.id === sectionParam) ? sectionParam : "overview"; + + // Fetch tablo details via API + const { data: tablo, isLoading } = useQuery({ + queryKey: ["tablos", tabloId], + queryFn: async () => { + const res = await api.get(`/api/v1/tablos/${tabloId}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data; + }, + enabled: !!tabloId && !!session, + }); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (!tablo) return null; + + return ( +
+ {/* Tablo header */} +
+

{tablo.name}

+ {tablo.description && ( +

{tablo.description}

+ )} +
+ + {/* Tab navigation */} +
+ {TABS.map((tab) => { + const Icon = tab.icon; + const isActive = activeSection === tab.id; + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {activeSection === "tasks" && ( + + )} + {activeSection === "files" && ( + + )} + {activeSection === "discussion" && ( + + )} + {activeSection === "events" && ( + + )} + {/* etapes, roadmap, overview sections rendered similarly */} +
+
+ ); +} +``` + +Adapt the props to match whatever interface the extracted components expose after Task 5. The key difference from `apps/main` is: `isAdmin` is always `false`, and no share/invite/delete UI is rendered. + +- [ ] **Step 4: Create `ClientTabloListPage.tsx`** + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { api } from "@xtablo/shared/lib/api"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { FolderIcon } from "lucide-react"; +import { Link, Navigate } from "react-router-dom"; + +export function ClientTabloListPage() { + const { session } = useSession(); + + const { data: tablos, isLoading } = useQuery({ + queryKey: ["tablos"], + queryFn: async () => { + const res = await api.get("/api/v1/tablos", { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data; + }, + enabled: !!session, + }); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (!tablos || tablos.length === 0) { + return ( +
+

No projects available.

+
+ ); + } + + // If only one tablo, redirect directly + if (tablos.length === 1) { + return ; + } + + return ( +
+

Your Projects

+
+ {tablos.map((tablo) => ( + + +
+

{tablo.name}

+ {tablo.description && ( +

{tablo.description}

+ )} +
+ + ))} +
+
+ ); +} +``` + +- [ ] **Step 5: Run dev server and verify** + +Run: `pnpm dev:clients` +Expected: App starts on port 5175. The auth callback page shows "Authenticating..." without a session. The list page shows "No projects available" when not authenticated. + +- [ ] **Step 6: Commit** + +```bash +git add apps/clients/src/ +git commit -m "feat(clients): add layout, auth callback, tablo page, and list page" +``` + +--- + +## Task 9: Client Invite UI in `apps/main` Share Dialog + +**Files:** +- Modify: `apps/main/src/components/TabloHeaderActions.tsx` (or wherever the share dialog lives) +- Create: `apps/main/src/hooks/client_invites.ts` + +- [ ] **Step 1: Create client invite hooks** + +Create `apps/main/src/hooks/client_invites.ts`: + +```typescript +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { api } from "@xtablo/shared/lib/api"; +import { toast } from "@xtablo/shared"; + +export function usePendingClientInvites(tabloId: string) { + const { session } = useSession(); + + return useQuery({ + queryKey: ["client-invites", tabloId], + queryFn: async () => { + const res = await api.get(`/api/v1/client-invites/${tabloId}/pending`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data.invites as { + id: number; + invited_email: string; + expires_at: string; + is_pending: boolean; + created_at: string; + }[]; + }, + enabled: !!tabloId && !!session, + }); +} + +export function useCreateClientInvite() { + const { session } = useSession(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => { + const res = await api.post( + `/api/v1/client-invites/${tabloId}`, + { email }, + { headers: { Authorization: `Bearer ${session?.access_token}` } } + ); + return res.data; + }, + onSuccess: (_, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + toast.add({ title: "Client invite sent", type: "success" }, { timeout: 3000 }); + }, + onError: () => { + toast.add({ title: "Failed to send invite", type: "error" }, { timeout: 5000 }); + }, + }); +} + +export function useCancelClientInvite() { + const { session } = useSession(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => { + const res = await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data; + }, + onSuccess: (_, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + }, + }); +} +``` + +- [ ] **Step 2: Add client invite section to the share dialog** + +In the share dialog component (either in `TabloHeaderActions.tsx` or in the share dialog in `tablo-details.tsx`), add a section below the existing invite section for client invites. This should include: + +1. A "Client Access" heading with a description +2. An email input + "Send Magic Link" button +3. A list of pending client invites with expiration dates and cancel buttons +4. An expiration warning badge when `expires_at` is less than 5 days away + +Use the hooks from step 1 (`usePendingClientInvites`, `useCreateClientInvite`, `useCancelClientInvite`). + +The exact JSX depends on the existing share dialog structure. Follow the same patterns used for the existing `pendingInvites` list. + +- [ ] **Step 3: Run typecheck and verify** + +Run: `pnpm typecheck` +Run: `pnpm dev:main` +Navigate to a tablo, open the share dialog. Verify the client invite section appears. + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/src/hooks/client_invites.ts apps/main/src/components/ apps/main/src/pages/ +git commit -m "feat(main): add client invite UI to share dialog" +``` + +--- + +## Task 10: End-to-End Verification + +- [ ] **Step 1: Run full typecheck** + +Run: `pnpm typecheck` +Expected: No errors across all packages + +- [ ] **Step 2: Run all tests** + +Run: `pnpm test` +Expected: All tests pass + +- [ ] **Step 3: Run linter** + +Run: `pnpm lint` +Expected: No errors (run `pnpm lint:fix` if needed) + +- [ ] **Step 4: Verify `apps/main` dev server** + +Run: `pnpm dev:main` +- Navigate to a tablo details page +- Verify all tabs work (overview, etapes, tasks, files, discussion, events, roadmap) +- Open share dialog, verify client invite section + +- [ ] **Step 5: Verify `apps/clients` dev server** + +Run: `pnpm dev:clients` +- Verify app loads on port 5175 +- Verify auth callback page renders +- Verify tablo list page renders + +- [ ] **Step 6: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: resolve typecheck and lint issues from client magic links implementation" +``` diff --git a/docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md b/docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md new file mode 100644 index 0000000..78f3b68 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md @@ -0,0 +1,283 @@ +# Self-Hosted Chat: Stream Chat Replacement + +**Date**: 2026-04-11 +**Status**: Approved + +## Motivation + +Replace Stream Chat (stream-chat + stream-chat-react) with a self-hosted solution to achieve: + +- **Cost control** — eliminate Stream's per-MAU pricing +- **Data ownership** — messages stored in our own Postgres +- **Vendor independence** — remove dependency on Stream's backend + +## Architecture + +Three layers: + +1. **Cloudflare Durable Objects** — one DO per tablo channel, managing WebSocket connections and real-time message broadcast. Uses the Hibernatable WebSocket API (idle rooms cost nothing). +2. **Cloudflare Worker** — authenticates WebSocket upgrades (validates Supabase JWTs), routes connections to the correct DO, and serves REST endpoints for message history and channel metadata. +3. **Supabase Postgres** — stores messages, read state. Channels and membership are implicit (tablos and tablo/org membership). + +This is a separate Cloudflare Worker from the main app, with its own DO bindings and environment variables. + +## Data Flow + +### Sending a message + +``` +User sends WS message + → DO receives + → assigns server timestamp + message ID + → broadcasts to all connected WS clients in the room + → writes to Postgres (async, non-blocking via Supabase REST API) +``` + +### Loading history (reconnect / initial load) + +``` +Client connects + → Worker authenticates JWT + → Worker checks tablo membership + → routes to DO + → DO accepts WebSocket +Client requests history + → Worker queries Postgres + → returns paginated messages +``` + +## Data Model + +### New tables + +```sql +-- Messages +CREATE TABLE messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz, + deleted_at timestamptz +); + +CREATE INDEX idx_messages_channel_created ON messages(channel_id, created_at DESC); +``` + +```sql +-- Read state (last read position per user per channel) +CREATE TABLE channel_read_state ( + user_id uuid NOT NULL REFERENCES auth.users(id), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + last_read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id) +); +``` + +### No new tables needed for + +- **Channels** — tablos are the channels (tablo ID = channel ID) +- **Membership** — existing tablo/org membership tables +- **Users** — existing Supabase auth + user tables + +### Unread count query + +```sql +SELECT COUNT(*) FROM messages +WHERE channel_id = :channel_id + AND created_at > ( + SELECT last_read_at FROM channel_read_state + WHERE user_id = :user_id AND channel_id = :channel_id + ) + AND deleted_at IS NULL; +``` + +### Soft deletes + +Messages use `deleted_at` (not hard deletion), consistent with the tablo soft-delete pattern. + +## Durable Objects — Real-time Layer + +One DO per tablo channel, identified by tablo ID. + +### WebSocket lifecycle + +- **Connect**: Worker passes authenticated user ID as a tag via `acceptWebSocket(ws, [userId])`. DO tracks connected users. +- **Message**: DO parses incoming message, assigns server timestamp and ID, broadcasts to all other connected sockets via `getWebSockets()`. +- **Close**: DO cleans up. If no connections remain, it hibernates (zero cost). + +### WebSocket message types + +**Client → Server:** + +| Type | Payload | Persisted | +|------|---------|-----------| +| `message.send` | `{ text, clientId }` | Yes | +| `typing.start` | `{}` | No | +| `typing.stop` | `{}` | No | +| `presence.ping` | `{}` | No | + +**Server → Client:** + +| Type | Payload | +|------|---------| +| `message.new` | `{ id, userId, text, createdAt, clientId }` | +| `typing` | `{ userId, isTyping }` | +| `presence.update` | `{ userId, status }` | +| `error` | `{ code, message }` | + +### What the DO does NOT do + +- No message history queries (Worker + Postgres) +- No channel membership management (existing API) +- No authentication (Worker validates JWT before routing) + +### Presence + +The DO knows who's connected via `getWebSockets()`. On connect/disconnect, it broadcasts `presence.update` to the room. + +### Typing indicators + +Purely ephemeral. Client sends `typing.start`, DO broadcasts to others, never persisted. + +## Cloudflare Worker — API & Routing + +### Endpoints + +``` +GET /chat/ws/:channelId # WebSocket upgrade → routed to DO +GET /chat/channels/:channelId/messages?before=&limit=50 # Paginated history +POST /chat/channels/:channelId/read # Mark channel as read +GET /chat/unread # Unread counts for current user +``` + +### Authentication + +Every request (including WebSocket upgrade) includes the Supabase JWT in the `Authorization` header. The Worker validates the JWT using Supabase's public JWT secret (standard JWT verification, no Supabase SDK needed). + +### Membership check + +Before routing a WebSocket connection to a DO, the Worker verifies the user is a member of the tablo by querying existing membership data via the Supabase REST API. + +### Postgres access + +Both the Worker (for history/unread queries) and the DO (for message persistence) use Supabase's PostgREST API with the service role key. The DO calls PostgREST directly — it does not route writes through the Worker. No direct Postgres connection needed at this scale. + +## Frontend + +### Chat client hook — `useChat` + +```typescript +const { + messages, // Message[] — history + live messages merged + sendMessage, // (text: string) => void + isConnected, // boolean + typingUsers, // string[] — user IDs currently typing + onlineUsers, // string[] — user IDs currently connected + loadMoreMessages, // () => void — pagination trigger + hasMoreMessages, // boolean + markAsRead, // () => void +} = useChat(channelId) +``` + +Internally: + +1. Opens WebSocket to `/chat/ws/:channelId` with JWT +2. Fetches initial message history via REST +3. Merges incoming WebSocket messages with history (dedup via `clientId`) +4. Sends typing events with debounce (start on keypress, stop after 2s idle) +5. Calls `markAsRead` when channel is visible/focused + +### Unread counts — `useChatUnread` + +Polls `GET /chat/unread` on interval (every 30s) or on window focus. Replaces `useTabloDiscussionUnread`. + +### UI components — chatscope + +Using `@chatscope/chat-ui-kit-react`, styled to match existing Radix/Tailwind design system: + +| chatscope component | Replaces | +|---------------------|----------| +| `ConversationList` + `Conversation` | Stream `ChannelList` + `ChannelPreview` | +| `ChatContainer` + `MessageList` + `Message` | Stream `Channel` + `MessageList` | +| `MessageInput` | Stream `MessageInput` | +| `TypingIndicator` | New (wasn't explicitly used before) | +| `Avatar` + `StatusIndicator` | `ChannelBadge` online dot | + +### Styling + +Override chatscope's default CSS theme to match existing Tailwind/Radix design system (colors, fonts, border radius, spacing). + +## Migration — What Changes in Existing Code + +### Removed from `apps/api` + +- Stream server client initialization in `middleware.ts` +- `stream-chat` dependency +- Stream token generation in `GET /api/v1/users/me` (`user.ts:64`) +- `STREAM_CHAT_API_KEY` and `STREAM_CHAT_API_SECRET` config variables +- All `streamServerClient.channel()` calls in `tablo.ts` +- All `streamServerClient.upsertUser()` calls in `user.ts`, `helpers.ts`, `tablo.ts` +- All `channel.addMembers()` / `channel.removeMembers()` calls + +### Replaced with Postgres operations + +- **Tablo creation**: No extra chat setup needed. The tablo IS the channel. +- **Add/remove member**: Already handled by tablo/org membership. No chat-specific call. +- **Update channel name**: Not needed. Channel name = tablo name, read from `tablos` table. +- **Delete channel**: `ON DELETE CASCADE` on `messages.channel_id` handles cleanup. +- **Upsert Stream user**: Removed entirely. No user sync needed. + +### Removed from `apps/main` + +- `stream-chat` and `stream-chat-react` dependencies +- `VITE_STREAM_CHAT_API_KEY` from all env files +- `ChatProvider.tsx` +- `streamToken` from user data flow +- `ChannelPreview.tsx`, `CustomChannelHeader.tsx`, `ChannelBadge.tsx` (replaced by chatscope equivalents) +- `useChannelFromUrl`, `useTabloDiscussionUnread` hooks (replaced by `useChat`, `useChatUnread`) + +### Unchanged + +- Tablo CRUD, membership management, auth — all stay in existing API +- Chat page routes (`/chat`, `/chat/:channelId`) — same URLs, new implementation + +## Error Handling & Edge Cases + +### WebSocket disconnection / reconnect + +- `useChat` implements automatic reconnection with exponential backoff +- On reconnect, fetches messages with `?after=` to fill the gap +- Messages sent while disconnected are queued locally and sent on reconnect (or shown as "failed to send" after timeout) + +### Message ordering + +- DO assigns server timestamps, which are authoritative +- Optimistic messages use `clientId` for dedup — replaced by server echo on arrival +- History from Postgres is ordered by `created_at DESC` + +### Membership enforcement + +- Worker checks tablo membership before allowing WebSocket connection +- If a user is removed while connected, they are evicted on next reconnect (acceptable at small scale) + +### Postgres write failures + +- DO retries up to 3 times +- Messages already delivered via WebSocket — user experience unaffected +- Persistent failure is logged as an operational alert + +### Deploys + +- DO eviction on deploy closes all WebSockets +- Clients reconnect and fetch the gap from Postgres via standard reconnect flow + +## Scale Considerations + +Designed for small scale (under 100 concurrent users, occasional messages). At this scale: + +- A single DO per room handles all connections easily +- Supabase PostgREST is sufficient (no connection pooling needed) +- Polling for unread counts (every 30s) is fine +- No need for message queues, caches, or read replicas diff --git a/docs/superpowers/specs/2026-04-12-chatcn-integration-design.md b/docs/superpowers/specs/2026-04-12-chatcn-integration-design.md new file mode 100644 index 0000000..e873db9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-chatcn-integration-design.md @@ -0,0 +1,187 @@ +# Integrate chatcn as @xtablo/chat-ui Package + +## Overview + +Replace the chatscope chat UI library in xtablo-source with chatcn, integrated as a new shared workspace package `@xtablo/chat-ui`. This replaces the rendering layer only — the chat data layer (`useChat` hook, WebSocket, REST) is unchanged. + +## Context + +- **chatcn** is a React 19 / Tailwind CSS 4 chat component library with messages, composer, reactions, threads, typing indicators, 5 layouts, security utilities, and custom hooks. +- **xtablo-source** is a Turborepo monorepo with pnpm workspaces. Chat UI currently uses `@chatscope/chat-ui-kit-react` in a single component (`apps/main/src/components/ChatMessages.tsx`, 184 lines). +- The chat data layer (`useChat` hook with WebSocket + REST to the chat-worker service) is cleanly separated from the UI and stays unchanged. + +## Approach + +Copy-and-adapt: copy chatcn's chat component source files into a new `packages/chat-ui/` workspace package, adapting imports and theming to match xtablo's existing patterns. + +## Package Structure + +``` +packages/chat-ui/ +├── package.json # @xtablo/chat-ui +├── tsconfig.json +└── src/ + ├── index.ts # Barrel export + ├── components/ + │ ├── chat.tsx # Core: ChatProvider, ChatMessage, ChatMessages, ChatComposer, etc. + │ ├── features.tsx # ChatForwardDialog, ChatEditComposer, ChatSearch, etc. + │ └── layouts.tsx # FullMessenger, ChatWidget, InlineChat, ChatBoard, LiveChat, etc. + ├── hooks.ts # groupMessages, useAutoScroll, useAutoResize, useTypingIndicator, etc. + ├── security.ts # sanitizeUrl, validateFile, sanitizeFileName, etc. + └── types.ts # ChatUser, ChatMessageData, ChatConfig, MessageGroup, etc. +``` + +Source-only package (no build step), same pattern as `@xtablo/shared` and `@xtablo/ui`. + +### package.json exports + +```json +{ + "name": "@xtablo/chat-ui", + "version": "0.0.1", + "private": true, + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks": "./src/hooks.ts", + "./security": "./src/security.ts", + "./types": "./src/types.ts" + }, + "dependencies": { + "@xtablo/shared": "workspace:*", + "@xtablo/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "lucide-react": "*", + "date-fns": "*" + } +} +``` + +### tsconfig.json + +Mirrors `packages/ui/tsconfig.json`: target ES2022, module ESNext, jsx react-jsx, strict true, moduleResolution bundler, declaration true. + +## Dependencies + +All dependencies are already present in the monorepo — no new packages to install: + +| Dependency | Source | +|---|---| +| `@xtablo/shared` | Workspace (for `cn()` utility) | +| `@xtablo/ui` | Workspace (for Button, Dialog, Avatar primitives) | +| `lucide-react` | Already in monorepo | +| `date-fns` | Already in monorepo | +| `clsx` | Via `@xtablo/shared` | +| `tailwind-merge` | Via `@xtablo/shared` | + +**Dropped:** `@base-ui/react` (only used in chatcn's scaffolding `button.tsx`, not in any chat component). + +## Import Remapping + +When copying chatcn source files into the package, apply these systematic import changes: + +| chatcn import | @xtablo/chat-ui import | +|---|---| +| `import { cn } from "@/lib/utils"` | `import { cn } from "@xtablo/shared/lib/cn"` | +| `import { Button } from "@/components/ui/button"` | `import { Button } from "@xtablo/ui/components/button"` | +| Other `@/components/ui/*` imports (Dialog, Avatar, etc.) | Corresponding `@xtablo/ui/components/*` import | + +## Theming + +Strip chatcn's custom theming system entirely and remap to xtablo's existing Tailwind design tokens. + +### What gets removed + +- `ChatTheme` type (`"lunar" | "aurora" | "ember" | "midnight"`) +- `data-chat-theme` attribute on components +- `ChatProvider`'s theme prop +- All `var(--chat-*)` CSS custom property references +- chatcn's globals.css theme definitions (not copied) + +### Token mapping + +All `var(--chat-*)` references in component files are replaced with Tailwind utility classes: + +| chatcn token | Tailwind class | +|---|---| +| `--chat-bg-app`, `--chat-bg-main` | `bg-background` | +| `--chat-bg-sidebar` | `bg-sidebar` | +| `--chat-bubble-outgoing` | `bg-primary` | +| `--chat-bubble-incoming` | `bg-muted` | +| `--chat-text-outgoing` | `text-primary-foreground` | +| `--chat-text-incoming` | `text-foreground` | +| `--chat-accent` | `bg-accent` / `text-accent-foreground` | +| `--chat-border` | `border-border` | +| `--chat-text-primary` | `text-foreground` | +| `--chat-text-secondary` | `text-muted-foreground` | + +This ensures automatic light/dark mode support via xtablo's existing `.dark` class. + +## Component Adaptation + +### What stays unchanged + +- All chatcn types (`ChatUser`, `ChatMessageData`, `ChatConfig`, `MessageGroup`, `MessageListItem`, `TypingUser`) +- All hooks (`groupMessages`, `useAutoScroll`, `useAutoResize`, `useTypingIndicator`, `formatTimestamp`, `formatDateLabel`) +- All security utilities (`sanitizeUrl`, `validateFile`, `sanitizeFileName`, etc.) +- All component props and APIs + +### What changes + +- Imports remapped (see above) +- Theme references replaced with Tailwind classes (see above) +- `ChatTheme` type removed from `types.ts` +- Theme prop removed from `ChatProvider` + +## Replacing ChatMessages.tsx + +The current `apps/main/src/components/ChatMessages.tsx` is rewritten to use `@xtablo/chat-ui`. + +### Data transformation + +The app-level component transforms xtablo's data model to chatcn's types: + +| Current (xtablo) | chatcn equivalent | +|---|---| +| `ChatMessage.userId` + `Member.name` | `ChatMessageData.senderId` + `senderName` | +| `ChatMessage.text` | `ChatMessageData.text` | +| `ChatMessage.createdAt` (ISO string) | `ChatMessageData.timestamp` (Date) | +| `ChatMessage.optimistic` | `ChatMessageData.status: "sending"` | +| `Member.avatar_url` | `ChatUser.avatar` / `ChatMessageData.senderAvatar` | +| `typingUsers: string[]` | `TypingUser[]` (with id + name resolved from members) | + +### New component structure + +```tsx + + + + +``` + +### Localization + +The package stays i18n-agnostic. French strings currently hardcoded in `ChatMessages.tsx` ("Aujourd'hui", "Hier", "écrit...") are handled at the app level: +- Date labels: override chatcn's `formatDateLabel` with a custom function using i18next +- Typing indicator text: pass translated string as a prop +- Placeholder text: pass via `ChatComposer` placeholder prop + +## Cleanup + +After the integration: +- Remove `@chatscope/chat-ui-kit-react` from `apps/main/package.json` +- Remove `@chatscope/chat-ui-kit-styles` from `apps/main/package.json` +- Remove the chatscope CSS import from `ChatMessages.tsx` +- Add `@xtablo/chat-ui` to `apps/main/package.json` as a workspace dependency + +## Out of Scope + +- **Mobile app:** `xtablo-expo` uses `stream-chat-expo` — unchanged +- **Chat data layer:** `useChat` hook, WebSocket connections, chat-worker service — unchanged +- **ChatChannelPreview and ChatHeader:** Already pure Tailwind, no chatscope — unchanged +- **TabloDiscussionSection:** Consumes `ChatMessages` with the same props interface — unchanged +- **New feature adoption:** Reactions, threads, search, pinned messages, etc. are available in the package but not wired up. Future work. +- **Stream Chat CSS:** `.str-chat` overrides in `main.css` — unrelated, unchanged diff --git a/docs/superpowers/specs/2026-04-15-client-magic-links-design.md b/docs/superpowers/specs/2026-04-15-client-magic-links-design.md new file mode 100644 index 0000000..ffabd8c --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-client-magic-links-design.md @@ -0,0 +1,223 @@ +# Client Magic Links — Design Spec + +## Overview + +Replace the temporary user invitation model with a magic link system for external client access. Clients access tablos via a dedicated portal at `clients.xtablo.com` (`apps/clients`), authenticated through Supabase passwordless magic links. Tablo view components are extracted into a shared `packages/tablo-views` package consumed by both `apps/main` and `apps/clients`. + +Temporary users remain untouched during the transition period. + +## Data Model + +### New column: `profiles.is_client` + +- `is_client: boolean NOT NULL DEFAULT false` +- Marks users created via client magic link invites +- Distinct from `is_temporary` — clean separation for the transition +- Excluded from billing (`getBillableMemberCount` filters out `is_client` users) + +### New table: `client_invites` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | serial PK | | +| `tablo_id` | text FK -> tablos | | +| `invited_email` | varchar(255) | | +| `invited_by` | uuid FK -> profiles | | +| `invite_token` | text | URL-safe token for the magic link | +| `expires_at` | timestamptz | Default: `now() + interval '30 days'` | +| `is_pending` | boolean DEFAULT true | Flipped to false on acceptance | +| `created_at` | timestamptz DEFAULT now() | | + +RLS policies: +- Admins (invite senders) can read/manage their invites +- Client users can read their own invites by email match + +### Existing table: `tablo_access` + +No schema changes. Client users get a standard row with `is_admin: false`, `is_active: true`. Access revocation uses the existing `is_active = false` pattern. + +## Magic Link Invitation Flow + +### Sending an invite (admin in `apps/main`) + +1. Admin opens tablo share dialog, enters client email +2. `POST /api/v1/tablos/:tabloId/client-invites` — validates admin access, creates `client_invites` row with generated token and `expires_at = now() + 30 days` +3. If no Supabase account exists for that email, the API creates one via `supabase.auth.admin.createUser({ email })` and sets `is_client: true` on the resulting profile row. A `tablo_access` row is pre-granted (`is_admin: false`, `is_active: true`). +4. API calls `supabase.auth.admin.generateLink({ type: 'magiclink', email, options: { redirectTo: 'https://clients.xtablo.com/auth/callback?token=' } })` to generate the magic link +5. Supabase sends the magic link email to the client + +### Client clicks the link + +1. Supabase verifies the auth token, redirects to `clients.xtablo.com/auth/callback?token=` +2. Callback page exchanges the Supabase auth token for a session +3. The `invite_token` is used to call `POST /api/v1/client-invites/:token/accept` — marks invite as accepted (`is_pending: false`), confirms `tablo_access` is active +4. Client is redirected to `clients.xtablo.com/tablo/:tabloId` + +### Expiration and renewal + +- Expired invites (past `expires_at`) are rejected at acceptance time with a clear error message +- Admins can re-invite the same email, creating a new `client_invites` row with a fresh 30-day window +- Admins are warned in the UI when the expiration is soon (less than 5 days) +- Admin can revoke access by setting `tablo_access.is_active = false` + +### Returning clients + +- Active session + valid `tablo_access` = direct access, no re-invitation needed +- Expired session requires a new magic link from the admin + +## API Permission Scoping + +### Middleware + +New middleware variant: `clientUserCheckMiddleware` — returns `403` for `is_client` users on non-client-accessible routes. + +### Client-accessible endpoints + +- `GET /api/v1/tablos/:tabloId` — view tablo details +- `GET /api/v1/tablo-data/:tabloId/*` — tasks, etapes, events, files metadata +- `GET /api/v1/tablo-files/:tabloId/*` — file downloads +- `POST /api/v1/tablo-files/:tabloId/upload` — file uploads +- Chat endpoints (messages, typing, presence via WebSocket) +- `GET /api/v1/user/me` — own profile + +### Blocked for client users + +- Tablo CRUD (create, update, delete) +- Invite management (sending/cancelling invites) +- Organization endpoints +- Billing/Stripe endpoints +- Settings, user management + +### Billing + +`getBillableMemberCount` updated to exclude `is_client` users (same pattern as `is_temporary`). + +### RLS policies + +New row-level policies on `client_invites`: +- Admins can manage invites they created +- Clients can read their own invites (by email match) + +## `packages/tablo-views` — Shared Package + +Source-only package (TypeScript directly, no build step). Same pattern as `@xtablo/shared` and `@xtablo/ui`. + +### Structure + +``` +packages/tablo-views/ +├── package.json (@xtablo/tablo-views) +├── tsconfig.json +└── src/ + ├── TabloOverviewSection.tsx + ├── TabloEtapesSection.tsx + ├── TabloTasksSection.tsx + ├── TabloFilesSection.tsx + ├── TabloDiscussionSection.tsx + ├── TabloEventsSection.tsx + ├── TabloRoadmapSection.tsx + ├── components/ (shared sub-components these sections depend on) + └── hooks/ (data-fetching hooks for tablo views, including useChat) +``` + +### What moves from `apps/main` + +- The 7 tab section components +- Sub-components they directly depend on (task cards, file list items, gantt chart, etc.) +- Data-fetching hooks used exclusively by these views (including `useChat` from `apps/main/src/hooks/useChat.ts`) + +### What stays in `apps/main` + +- `TabloDetailsPage` (page shell with tab navigation, share dialog, invite management) +- Layout, navigation, routing +- App-level providers + +### Dependencies + +`@xtablo/tablo-views` depends on: +- `@xtablo/ui` +- `@xtablo/shared` +- `@xtablo/shared-types` +- `@xtablo/chat-ui` + +Consumed by both `apps/main` and `apps/clients`. + +### Refactor in `apps/main` + +`TabloDetailsPage` imports sections from `@xtablo/tablo-views` instead of local files. Behavior stays identical — this is a move, not a rewrite. + +## `apps/clients` — Client Portal App + +### Structure + +``` +apps/clients/ +├── package.json (@xtablo/clients) +├── vite.config.ts +├── wrangler.toml (clients.xtablo.com) +├── worker/index.ts +├── index.html +├── tsconfig.json +├── tsconfig.app.json +└── src/ + ├── main.tsx + ├── App.tsx + ├── routes.tsx + ├── pages/ + │ ├── AuthCallback.tsx + │ └── ClientTabloPage.tsx + └── components/ + └── ClientLayout.tsx +``` + +### Cloudflare Worker + +`wrangler.toml` routes `clients.xtablo.com` with SPA not-found handling. Same asset-serving pattern as `apps/main` and `apps/external`. + +### Layout + +`ClientLayout.tsx` — no sidebar. Minimal top bar with: +- Tablo name and color +- Client user avatar and name +- Logout action + +### Routes + +| Path | Component | Purpose | +|------|-----------|---------| +| `/auth/callback` | `AuthCallback` | Supabase magic link redirect + invite token acceptance | +| `/tablo/:tabloId` | `ClientTabloPage` | Scoped tablo view with all tabs | +| `/` | Redirect | To `/tablo/:tabloId` if one tablo, or simple list if multiple | + +### `ClientTabloPage` + +Renders the same tab system as `TabloDetailsPage` using components from `@xtablo/tablo-views`. Differences from `apps/main`: +- No share/invite dialog +- No tablo settings or delete actions +- No admin-only actions in the UI +- File section: download and upload enabled, no delete + +### Providers + +`QueryClientProvider`, `SessionProvider`, `ThemeProvider`, i18n — same setup as other apps. No `UserStoreProvider` or organization context (clients don't belong to orgs). + +### Dev server + +Port 5175 via `pnpm dev:clients`. + +## Chat Integration + +Client users get real Supabase accounts, so chat works with minimal changes: + +- **Authentication:** Same JWT-based auth for WebSocket connections +- **Identity:** Profile row (name, optional avatar) used for chat display. Profile seeded with invited email on creation. Client can update display name on first access. +- **Permissions:** Client users can send messages and see typing indicators in tablo discussions they have access to. Tablo ID maps to channel ID. +- **`@xtablo/chat-ui`:** No changes needed. Components are already app-agnostic. +- **`useChat` hook:** Moves to `packages/tablo-views/src/hooks/` so both apps can use it. + +## Migration Strategy + +- Temporary users (`is_temporary`) remain untouched +- Existing tablo invitations continue to work via `apps/main` +- New client invites use the magic link flow via `apps/clients` +- Once all clients have migrated to magic links, a future phase removes `is_temporary` and related code diff --git a/package.json b/package.json index e181a97..5366d4f 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,13 @@ "dev": "turbo dev", "dev:main": "turbo dev --filter=@xtablo/main", "dev:external": "turbo dev --filter=@xtablo/external", + "dev:clients": "turbo dev --filter=@xtablo/clients", "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:chat": "turbo deploy --filter=@xtablo/chat-worker", "deploy:external": "turbo deploy --filter=@xtablo/external", + "deploy:clients": "turbo deploy:prod --filter=@xtablo/clients", "lint": "turbo lint", "lint:fix": "turbo lint:fix", "format": "turbo format", diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json new file mode 100644 index 0000000..cbc5fba --- /dev/null +++ b/packages/chat-ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "@xtablo/chat-ui", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./chat-ui.css": "./src/chat-ui.css", + "./components/*": "./src/components/*.tsx", + "./hooks": "./src/hooks.ts", + "./security": "./src/security.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "dependencies": { + "@xtablo/shared": "workspace:*", + "@xtablo/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "lucide-react": "*", + "date-fns": "*" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0" + } +} diff --git a/packages/chat-ui/src/chat-ui.css b/packages/chat-ui/src/chat-ui.css new file mode 100644 index 0000000..0398317 --- /dev/null +++ b/packages/chat-ui/src/chat-ui.css @@ -0,0 +1,100 @@ +/* @xtablo/chat-ui — Animations and utility classes */ + +/* ─── Message entry ─────────────────────────────────────────────── */ +@keyframes chat-message-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ─── Toolbar entrance ──────────────────────────────────────────── */ +@keyframes chat-toolbar-enter { + from { opacity: 0; transform: scale(0.95) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* ─── Reaction pop ──────────────────────────────────────────────── */ +@keyframes chat-reaction-pop { + 0% { transform: scale(0); opacity: 0; } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } +} + +/* ─── Typing indicator dots ─────────────────────────────────────── */ +@keyframes chat-typing-pulse { + 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-4px); } +} + +/* ─── Cursor blink (streaming) ──────────────────────────────────── */ +@keyframes chat-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ─── Read receipt status color transition ───────────────────────── */ +@keyframes chat-status-read-in { + from { color: var(--color-muted-foreground); } + to { color: var(--color-primary); } +} + +/* ─── Utility classes ───────────────────────────────────────────── */ +@layer base { + .chat-message { + animation: chat-message-enter 250ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-typing-dot { + animation: chat-typing-pulse 1.4s ease-in-out infinite; + } + + .chat-toolbar-enter { + transform-origin: bottom center; + } + + .group\/message:hover .chat-toolbar-enter { + animation: chat-toolbar-enter 150ms ease-out; + } + + .chat-reaction-pop { + animation: chat-reaction-pop 200ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-status-read { + animation: chat-status-read-in 400ms ease-out; + } + + .chat-streaming-cursor { + animation: chat-cursor-blink 1s step-end infinite; + } + + .chat-content-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; + } + + .chat-drop-overlay { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in oklch, var(--color-background) 80%, transparent); + border: 2px dashed var(--color-primary); + border-radius: 12px; + backdrop-filter: blur(4px); + } +} + +/* ─── Reduced motion ────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .chat-message, + .chat-typing-dot, + .chat-toolbar-enter, + .chat-reaction-pop, + .chat-status-read { + animation: none; + } +} diff --git a/packages/chat-ui/src/components/chat.tsx b/packages/chat-ui/src/components/chat.tsx new file mode 100644 index 0000000..a4e8549 --- /dev/null +++ b/packages/chat-ui/src/components/chat.tsx @@ -0,0 +1,1409 @@ +import * as React from "react" +import { cn } from "@xtablo/shared" +import { + Check, + CheckCheck, + ArrowUp, + ChevronDown, + Clock, + AlertCircle, + Reply, + SmilePlus, + MoreHorizontal, + Pin, + Pencil, + Trash2, + X, + Paperclip, + // Image as ImageIcon, + // Smile, + Upload, + // Plus, + Play, + Pause, + Mic, +} from "lucide-react" +import { createPortal } from "react-dom" +import type { + ChatUser, + ChatConfig, + ChatLabels, + ChatMessageData, + MessageGroup, + TypingUser, +} from "../types" +import { + groupMessages, + useAutoScroll, + useAutoResize, + useTypingIndicator, + formatTimestamp, +} from "../hooks" + +// ─── Context ────────────────────────────────────────────────────────────────── + +const ChatContext = React.createContext(null) + +function useChatContext() { + const ctx = React.useContext(ChatContext) + if (!ctx) + throw new Error("Chat components must be wrapped in ") + return ctx +} + +// ─── ChatProvider ───────────────────────────────────────────────────────────── + +interface ChatProviderProps { + currentUser: ChatUser + dateFormat?: "relative" | "absolute" | "time-only" + messageGroupingInterval?: number + labels?: ChatLabels + onReactionAdd?: (messageId: string, emoji: string) => void + onReactionRemove?: (messageId: string, emoji: string) => void + onReply?: (message: ChatMessageData) => void + onEdit?: (message: ChatMessageData) => void + onDelete?: (messageId: string) => void + onPin?: (messageId: string) => void + children: React.ReactNode + style?: React.CSSProperties + className?: string +} + +function ChatProvider({ + currentUser, + dateFormat = "relative", + messageGroupingInterval = 120, + labels, + onReactionAdd, + onReactionRemove, + onReply, + onEdit, + onDelete, + onPin, + children, + style, + className, +}: ChatProviderProps) { + const config = React.useMemo( + () => ({ + currentUser, + dateFormat, + messageGroupingInterval, + labels, + onReactionAdd, + onReactionRemove, + onReply, + onEdit, + onDelete, + onPin, + }), + [currentUser, dateFormat, messageGroupingInterval, labels, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin] + ) + + return ( + +
+ {children} +
+
+ ) +} + +// ─── Quick emoji picker (6 common reactions) ────────────────────────────────── + +const QUICK_REACTIONS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F62E}", "\u{1F64F}", "\u{1F525}"] + +function QuickReactionPicker({ + onSelect, + onClose, +}: { + onSelect: (emoji: string) => void + onClose: () => void +}) { + return ( +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} +
+ ) +} + +// ─── ChatMessageActions (hover toolbar) ─────────────────────────────────────── + +interface ChatMessageActionsProps { + message: ChatMessageData + isOutgoing: boolean +} + +function ChatMessageActions({ message, isOutgoing }: ChatMessageActionsProps) { + const { onReply, onReactionAdd, onEdit, onDelete, onPin } = useChatContext() + const [showReactions, setShowReactions] = React.useState(false) + const [showMore, setShowMore] = React.useState(false) + + return ( +
+ {/* Reply */} + + + {/* React — opens quick picker */} +
+ + {showReactions && ( +
+ + onReactionAdd?.(message.id, emoji) + } + onClose={() => setShowReactions(false)} + /> +
+ )} +
+ + {/* More — dropdown */} +
+ + {showMore && ( +
setShowMore(false)} + > + {isOutgoing && ( + + )} + + {isOutgoing && ( + + )} +
+ )} +
+
+ ) +} + +// ─── ChatMessageReply (quoted reply inside bubble) ──────────────────────────── + +function ChatMessageReply({ + replyTo, + isOutgoing, +}: { + replyTo: NonNullable + isOutgoing: boolean +}) { + // Outgoing bubbles set text color via --chat-bubble-outgoing-text which may + // be white (Lunar, Midnight) or dark (Aurora, Ember). Using `text-inherit` + // + opacity lets the reply quote inherit that color and stay visible against + // the bubble background regardless of theme. + return ( +
+
+ + {replyTo.senderName} + + + {replyTo.text} + +
+
+ ) +} + +// ─── ChatMessage ────────────────────────────────────────────────────────────── + +interface ChatMessageProps { + message: ChatMessageData + isOutgoing: boolean + position: "solo" | "first" | "middle" | "last" + showSender?: boolean + showAvatar?: boolean + className?: string +} + +// ─── Voice Message ───────────────────────────────────────────────────────── + +function ChatVoiceMessage({ voice, isOutgoing }: { voice: NonNullable; isOutgoing: boolean }) { + const [playing, setPlaying] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const progressRef = React.useRef(0) + + React.useEffect(() => { + progressRef.current = progress + }, [progress]) + + const totalMins = Math.floor(voice.duration / 60) + const totalSecs = Math.floor(voice.duration % 60) + const elapsed = progress * voice.duration + const elapsedMins = Math.floor(elapsed / 60) + const elapsedSecs = Math.floor(elapsed % 60) + const timeLabel = playing || progress > 0 + ? `${elapsedMins}:${elapsedSecs.toString().padStart(2, "0")}` + : `${totalMins}:${totalSecs.toString().padStart(2, "0")}` + + const progressIndex = Math.floor(progress * voice.waveform.length) + + React.useEffect(() => { + if (!playing) return + const fps = 20 + const step = 1 / (voice.duration * fps) + const id = setInterval(() => { + const next = progressRef.current + step + if (next >= 1) { + setProgress(0) + setPlaying(false) + clearInterval(id) + } else { + setProgress(next) + } + }, 1000 / fps) + return () => clearInterval(id) + }, [playing, voice.duration]) + + const toggle = () => { + if (!playing && progress === 0) setProgress(0) + setPlaying((p) => !p) + } + + return ( +
+ +
+ {voice.waveform.map((v, i) => { + const played = i < progressIndex + return ( +
+ ) + })} +
+ {timeLabel} +
+ ) +} + +function ChatMessage({ + message, + isOutgoing, + position, + showSender = false, + showAvatar = false, + className, +}: ChatMessageProps) { + const timestamp = new Date(message.timestamp) + const { currentUser } = useChatContext() + const radiusClass = getBubbleRadius(isOutgoing, position) + const [lightboxImage, setLightboxImage] = React.useState(null) + + return ( +
+ {/* Avatar slot — 32px, only for incoming, only on last/solo */} + {!isOutgoing ? ( +
+ {showAvatar && message.senderAvatar ? ( + {message.senderName} + ) : showAvatar ? ( +
+ {message.senderName.charAt(0).toUpperCase()} +
+ ) : null} +
+ ) : null} + + {/* Bubble + reactions column */} +
+ {/* Sender name — only first in group, incoming */} + {showSender && !isOutgoing && ( + + {message.senderName} + + )} + + {/* Bubble — relative for hover toolbar positioning */} +
+ {/* Hover actions toolbar — disabled for now */} + {/* */} + +
+ {/* Quoted reply */} + {message.replyTo && ( + + )} + + {/* Text content */} + {message.text && ( +

+ {message.text} +

+ )} + + {/* Images */} + {message.images && message.images.length > 0 && ( +
+ {message.images.map((img, idx) => ( + + ))} +
+ )} + + {/* Code block */} + {message.code && ( +
+
+ {message.code.language} +
+
+                  
+                    {message.code.code}
+                  
+                
+
+ )} + + {/* File attachments */} + {message.files && message.files.length > 0 && ( +
+ {message.files.map((file, idx) => ( +
+
+ +
+
+

{file.name}

+

{file.size < 1024 ? `${file.size} B` : file.size < 1048576 ? `${(file.size / 1024).toFixed(0)} KB` : `${(file.size / 1048576).toFixed(1)} MB`}

+
+
+ ))} +
+ )} + + {/* Link preview */} + {message.linkPreview && ( + + {message.linkPreview.image && ( + + )} +
+

{message.linkPreview.title}

+

{message.linkPreview.description}

+

{message.linkPreview.url}

+
+
+ )} + + {/* Voice message */} + {message.voice && ( + + )} + + {/* Inline timestamp + status + edited label */} +
+ {message.isEdited && ( + edited + )} + + {isOutgoing && message.status && ( + + )} +
+
+ + {/* Pin indicator */} + {message.isPinned && ( +
+ +
+ )} +
+ + {/* Reactions bar */} + {message.reactions && message.reactions.length > 0 && ( + + )} + + {/* Read receipts (group chat) — small stacked avatars */} + {message.readBy && message.readBy.length > 0 && ( + + )} +
+ + {/* Image lightbox */} + {lightboxImage && typeof document !== "undefined" && createPortal( +
setLightboxImage(null)} + > + + e.stopPropagation()} + /> +
, + document.body + )} +
+ ) +} + +// ─── Bubble radius helper ───────────────────────────────────────────────────── + +function getBubbleRadius( + isOutgoing: boolean, + position: "solo" | "first" | "middle" | "last" +): string { + if (isOutgoing) { + switch (position) { + case "solo": + return "rounded-[18px_18px_4px_18px]" + case "first": + return "rounded-[18px_18px_4px_18px]" + case "middle": + return "rounded-[18px_4px_4px_18px]" + case "last": + return "rounded-[18px_4px_18px_18px]" + } + } else { + switch (position) { + case "solo": + return "rounded-[18px_18px_18px_4px]" + case "first": + return "rounded-[18px_18px_18px_4px]" + case "middle": + return "rounded-[4px_18px_18px_4px]" + case "last": + return "rounded-[4px_18px_18px_18px]" + } + } +} + +// ─── ChatMessageStatus ──────────────────────────────────────────────────────── + +function ChatMessageStatus({ + status, +}: { + status: NonNullable +}) { + switch (status) { + case "sending": + return + case "sent": + return + case "delivered": + return + case "read": + return ( + + ) + case "failed": + return ( + + ) + } +} + +// ─── ChatMessageReactions (interactive) ─────────────────────────────────────── + +function ChatMessageReactions({ + messageId, + reactions, + isOutgoing, + currentUserId, +}: { + messageId: string + reactions: NonNullable + isOutgoing: boolean + currentUserId: string +}) { + const { onReactionAdd, onReactionRemove } = useChatContext() + + return ( +
+ {reactions.map((r) => { + const hasReacted = r.userIds.includes(currentUserId) + return ( + + ) + })} + {/* Add reaction button — visible on hover */} + +
+ ) +} + +// ─── ChatReadReceipts (group chat — stacked mini avatars) ───────────────────── + +function ChatReadReceipts({ + readBy, + isOutgoing, +}: { + readBy: NonNullable + isOutgoing: boolean +}) { + const maxVisible = 3 + const visible = readBy.slice(0, maxVisible) + const overflow = readBy.length - maxVisible + + return ( +
+
+ {visible.map((user) => ( +
+ {user.avatar ? ( + {user.name} + ) : ( + user.name.charAt(0).toUpperCase() + )} +
+ ))} +
+ {overflow > 0 && ( + + +{overflow} + + )} +
+ ) +} + +// ─── ChatMessageGroup ───────────────────────────────────────────────────────── + +interface ChatMessageGroupProps { + group: MessageGroup + className?: string +} + +function ChatMessageGroup({ group, className }: ChatMessageGroupProps) { + const len = group.messages.length + + return ( +
+ {group.messages.map((msg, i) => { + const position: "solo" | "first" | "middle" | "last" = + len === 1 + ? "solo" + : i === 0 + ? "first" + : i === len - 1 + ? "last" + : "middle" + + return ( + + ) + })} +
+ ) +} + +// ─── ChatDateSeparator ──────────────────────────────────────────────────────── + +interface ChatDateSeparatorProps { + label: string + className?: string +} + +function ChatDateSeparator({ label, className }: ChatDateSeparatorProps) { + return ( +
+
+ + {label} + +
+
+ ) +} + +// ─── ChatSystemMessage ──────────────────────────────────────────────────────── + +interface ChatSystemMessageProps { + message: ChatMessageData + className?: string +} + +function ChatSystemMessage({ message, className }: ChatSystemMessageProps) { + return ( +
+ + {message.text || message.systemEvent} + +
+ ) +} + +// ─── ChatTypingIndicator ────────────────────────────────────────────────────── + +interface ChatTypingIndicatorProps { + users: TypingUser[] + className?: string +} + +function ChatTypingIndicator({ users, className }: ChatTypingIndicatorProps) { + const { labels } = useChatContext() + if (users.length === 0) return null + + const label = + users.length === 1 + ? (labels?.typingOne?.replace("{{name}}", users[0]!.name) ?? `${users[0]!.name} is typing`) + : users.length === 2 + ? (labels?.typingTwo?.replace("{{name1}}", users[0]!.name).replace("{{name2}}", users[1]!.name) ?? `${users[0]!.name} and ${users[1]!.name} are typing`) + : (labels?.typingMany ?? "Several people are typing") + + return ( +
+ {/* Avatar */} +
+ {users[0]!.avatar ? ( + {users[0]!.name} + ) : ( + users[0]!.name.charAt(0).toUpperCase() + )} +
+ +
+ {/* Label */} + + {label} + + + {/* Dots bubble */} +
+ + + +
+
+
+ ) +} + +// ─── ChatReplyPreview (bar above composer) ──────────────────────────────────── + +interface ChatReplyPreviewProps { + replyingTo: ChatMessageData + onCancel: () => void + className?: string +} + +function ChatReplyPreview({ + replyingTo, + onCancel, + className, +}: ChatReplyPreviewProps) { + return ( +
+
+
+ + {replyingTo.senderName} + + + {replyingTo.text} + +
+ +
+ ) +} + +// ─── ChatMessages (scroll container) ────────────────────────────────────────── + +interface ChatMessagesProps { + messages: ChatMessageData[] + typingUsers?: TypingUser[] + className?: string + onLoadMore?: () => Promise + hasMore?: boolean +} + +function ChatMessages({ + messages, + typingUsers = [], + className, +}: ChatMessagesProps) { + const { currentUser, messageGroupingInterval, labels } = useChatContext() + const { containerRef, scrollToBottom, isAtBottom, unseenCount } = + useAutoScroll(messages) + + const items = React.useMemo( + () => groupMessages(messages, currentUser.id, messageGroupingInterval), + [messages, currentUser.id, messageGroupingInterval] + ) + + return ( +
+ {/* Scrollable area */} +
+
+ {items.map((item, i) => { + switch (item.type) { + case "date": + return ( + + ) + case "system": + return ( + + ) + case "group": + return ( + + ) + } + })} + + {/* Typing indicator at the bottom */} + {typingUsers.length > 0 && ( + + )} +
+
+ + {/* Scroll-to-bottom FAB with unread badge */} + +
+ ) +} + +// ─── File preview item ──────────────────────────────────────────────────────── + +interface FilePreviewItem { + file: File + id: string + preview?: string // data URL for images + progress?: number // 0-100 +} + +function ChatFilePreview({ + item, + onRemove, +}: { + item: FilePreviewItem + onRemove: () => void +}) { + const isImage = item.file.type.startsWith("image/") + + return ( +
+ {isImage && item.preview ? ( +
+ {item.file.name} +
+ ) : ( +
+
+ +
+
+

{item.file.name}

+

{(item.file.size / 1024).toFixed(0)} KB

+
+
+ )} + {/* Progress bar */} + {item.progress !== undefined && item.progress < 100 && ( +
+
+
+ )} + {/* Remove button */} + +
+ ) +} + +// ─── ChatComposer ───────────────────────────────────────────────────────────── + +interface ChatComposerProps { + onSend?: (text: string) => void + onTyping?: (isTyping: boolean) => void + onFileUpload?: (files: File[]) => void + onVoiceRecord?: () => void + placeholder?: string + disabled?: boolean + replyingTo?: ChatMessageData | null + onCancelReply?: () => void + className?: string +} + +function ChatComposer({ + onSend, + onTyping, + onFileUpload, + onVoiceRecord, + placeholder = "Message", + disabled = false, + replyingTo, + onCancelReply, + className, +}: ChatComposerProps) { + const [value, setValue] = React.useState("") + const [files, setFiles] = React.useState([]) + const [isDragging, setIsDragging] = React.useState(false) + // const [showAttachMenu, setShowAttachMenu] = React.useState(false) + const { textareaRef, resize } = useAutoResize({ maxRows: 6 }) + const { handleKeyDown: handleTypingKeyDown, stopTyping } = + useTypingIndicator({ onTypingChange: onTyping }) + // const fileInputRef = React.useRef(null) + // const imageInputRef = React.useRef(null) + const hasContent = value.trim().length > 0 || files.length > 0 + + const addFiles = React.useCallback((newFiles: FileList | File[]) => { + const arr = Array.from(newFiles) + const items: FilePreviewItem[] = arr.map((f) => ({ + file: f, + id: `${f.name}-${Date.now()}-${Math.random()}`, + progress: undefined, + })) + + // Generate image previews + items.forEach((item) => { + if (item.file.type.startsWith("image/")) { + const reader = new FileReader() + reader.onload = (e) => { + setFiles((prev) => + prev.map((f) => f.id === item.id ? { ...f, preview: e.target?.result as string } : f) + ) + } + reader.readAsDataURL(item.file) + } + }) + + setFiles((prev) => [...prev, ...items]) + onFileUpload?.(arr) + }, [onFileUpload]) + + const removeFile = React.useCallback((id: string) => { + setFiles((prev) => prev.filter((f) => f.id !== id)) + }, []) + + const handleSend = React.useCallback(() => { + const trimmed = value.trim() + if ((!trimmed && files.length === 0) || disabled) return + if (trimmed) onSend?.(trimmed) + setValue("") + setFiles([]) + stopTyping() + if (textareaRef.current) textareaRef.current.style.height = "auto" + }, [value, files, disabled, onSend, textareaRef, stopTyping]) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + handleTypingKeyDown() + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend() } + if (e.key === "Escape" && replyingTo) onCancelReply?.() + }, + [handleSend, handleTypingKeyDown, replyingTo, onCancelReply] + ) + + // Paste upload + const handlePaste = React.useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + const imageFiles: File[] = [] + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile() + if (file) imageFiles.push(file) + } + } + if (imageFiles.length > 0) { + addFiles(imageFiles) + } + }, + [addFiles] + ) + + // Drag-and-drop handlers (on the composer container) + const handleDragOver = React.useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + const handleDragLeave = React.useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + const handleDrop = React.useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + if (e.dataTransfer.files.length > 0) { + addFiles(e.dataTransfer.files) + } + }, + [addFiles] + ) + + return ( +
+ {/* Drop overlay */} + {isDragging && ( +
+
+ + Drop files to upload +
+
+ )} + + {/* Reply preview bar */} + {replyingTo && ( + onCancelReply?.()} /> + )} + + {/* File preview strip */} + {files.length > 0 && ( +
+ {files.map((f) => ( + removeFile(f.id)} /> + ))} +
+ )} + + {/* Composer body — frosted glass */} +
+
+ {/* Input row */} +
+ {/* + button with attachment popout — disabled until file upload is implemented +
+ + + {showAttachMenu && ( +
+ + + +
+ )} +
+ + { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} /> + { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} /> + */} + +
+