From 7c15ff3275e1019547649c57194b64c797ec0650 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 4 Mar 2026 21:47:44 +0100 Subject: [PATCH 1/4] Organization support --- .../api/src/__tests__/helpers/helpers.test.ts | 20 +- apps/api/src/__tests__/routes/tablo.test.ts | 45 +- apps/api/src/helpers/helpers.ts | 71 +++- apps/api/src/routers/invite.ts | 61 ++- apps/api/src/routers/tablo.ts | 60 ++- apps/api/src/routers/user.ts | 283 +++++++++++++ apps/main/src/components/NavigationBar.tsx | 167 +++----- apps/main/src/components/SubscriptionCard.tsx | 108 ++--- apps/main/src/hooks/organization.ts | 94 +++++ apps/main/src/hooks/stripe.ts | 46 +-- apps/main/src/locales/en/settings.json | 19 + apps/main/src/locales/fr/settings.json | 19 + apps/main/src/pages/settings.tsx | 97 ++++- ...add_organizations_and_org_owned_tablos.sql | 389 ++++++++++++++++++ ...e_profile_plan_mapping_for_new_billing.sql | 145 +++++++ 15 files changed, 1374 insertions(+), 250 deletions(-) create mode 100644 apps/main/src/hooks/organization.ts create mode 100644 supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql create mode 100644 supabase/migrations/20260304224500_update_profile_plan_mapping_for_new_billing.sql diff --git a/apps/api/src/__tests__/helpers/helpers.test.ts b/apps/api/src/__tests__/helpers/helpers.test.ts index e20836e..b93cbeb 100644 --- a/apps/api/src/__tests__/helpers/helpers.test.ts +++ b/apps/api/src/__tests__/helpers/helpers.test.ts @@ -4,13 +4,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_TABLO_LIMIT, verifyTabloLimitForUser } from "../../helpers/helpers.js"; const createSupabaseMock = ({ - profileData = { plan: "free" }, + profileData = { organization_id: 1 }, + organizationPlans = [{ plan: "free" }], profileError = null, + organizationPlanError = null, tabloCount = 0, tabloError = null, } = {}) => { const profileSingle = vi.fn(async () => ({ data: profileData, error: profileError })); - const profileEq = vi.fn(() => ({ single: profileSingle })); + const profileEq = vi.fn((column: string) => { + if (column === "id") { + return { single: profileSingle }; + } + return Promise.resolve({ data: organizationPlans, error: organizationPlanError }); + }); const profileSelect = vi.fn(() => ({ eq: profileEq })); const tabloEq = vi.fn(async () => ({ count: tabloCount, error: tabloError })); @@ -74,7 +81,8 @@ describe("verifyTabloLimitForUser", () => { it("denies free users that reached the tablo limit", async () => { const supabase = createSupabaseMock({ - profileData: { plan: "free" }, + profileData: { organization_id: 1 }, + organizationPlans: [{ plan: "free" }], tabloCount: MAX_TABLO_LIMIT, }); const ctx = createContext(supabase, user); @@ -88,7 +96,8 @@ describe("verifyTabloLimitForUser", () => { it("allows free users below the limit to proceed", async () => { const belowLimitCount = Math.max(0, MAX_TABLO_LIMIT - 1); const supabase = createSupabaseMock({ - profileData: { plan: "free" }, + profileData: { organization_id: 1 }, + organizationPlans: [{ plan: "free" }], tabloCount: belowLimitCount, }); const ctx = createContext(supabase, user); @@ -101,7 +110,8 @@ describe("verifyTabloLimitForUser", () => { it("skips tablo count check for non-free plans", async () => { const supabase = createSupabaseMock({ - profileData: { plan: "pro" }, + profileData: { organization_id: 1 }, + organizationPlans: [{ plan: "standard" }], }); const ctx = createContext(supabase, user); diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index ca1288e..14be4ea 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -252,14 +252,49 @@ describe("Tablo Endpoint", () => { it("should block free plan users who reached the tablo limit", async () => { const { data: profileData } = await supabaseAdmin .from("profiles") - .select("plan") + .select("plan, organization_id") .eq("id", ownerUser.userId) .single(); const originalPlan = profileData?.plan ?? "standard"; + const ownerOrganizationId = profileData?.organization_id; + + if (!ownerOrganizationId) { + throw new Error("owner organization_id is required for this test"); + } await supabaseAdmin.from("profiles").update({ plan: "free" }).eq("id", ownerUser.userId); + const fillerTablos: Array<{ + owner_id: string; + organization_id: number; + name: string; + status: string; + color: string; + }> = []; + + // Ensure the organization has at least 10 tablos before creation attempt. + const { count: existingCount } = await supabaseAdmin + .from("tablos") + .select("id", { count: "exact", head: true }) + .eq("organization_id", ownerOrganizationId) + .is("deleted_at", null); + + const missingTablos = Math.max(0, 10 - (existingCount || 0)); + for (let i = 0; i < missingTablos; i += 1) { + fillerTablos.push({ + owner_id: ownerUser.userId, + organization_id: ownerOrganizationId, + name: `Limit filler ${i}`, + status: "todo", + color: "#DDDDEE", + }); + } + + if (fillerTablos.length > 0) { + await supabaseAdmin.from("tablos").insert(fillerTablos); + } + try { const res = await createTabloRequest(ownerUser, client, { name: "Free Limit Tablo", @@ -289,10 +324,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo updated successfully"); - - // Note: The current update logic has a bug where it checks if tablo.name !== updatedTablo.name - // after the update, which will always be false. So channel.update is never called. - // This should be fixed in the production code to compare with the original name. }); it("should deny temp user from updating their own tablo (regularUserCheck blocks temporary users)", async () => { @@ -310,7 +341,7 @@ describe("Tablo Endpoint", () => { name: "Should Not Update", }); - expect(res.status).toBe(500); + expect(res.status).toBe(403); }); it("should deny temp user from updating owner's tablo (regularUserCheck blocks temporary users)", async () => { @@ -338,7 +369,7 @@ describe("Tablo Endpoint", () => { name: "Should Not Update", }); - expect(res.status).toBe(500); + expect(res.status).toBe(403); }); }); diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 6a94c3b..3a71f4b 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -1,12 +1,56 @@ import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import type { SupabaseClient } from "@supabase/supabase-js"; -import type { EventAndTablo, Tables } from "@xtablo/shared-types"; +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 = 1; +export const MAX_TABLO_LIMIT = 10; +export const MAX_TEAM_MEMBER_LIMIT = 3; + +export type NormalizedPlan = "solo" | "team" | "annual"; + +const PLAN_WEIGHT: Record = { + solo: 1, + team: 2, + annual: 3, +}; + +export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => { + if (!plan) return "solo"; + + if (plan === "annual" || plan === "beta") return "annual"; + if (plan === "team" || plan === "standard") return "team"; + return "solo"; +}; + +export const getHighestPlan = (plans: Array): NormalizedPlan => { + const normalizedPlans = plans.map((plan) => normalizePlan(plan)); + if (normalizedPlans.length === 0) { + return "solo"; + } + + return normalizedPlans.sort((a, b) => PLAN_WEIGHT[b] - PLAN_WEIGHT[a])[0]!; +}; + +export const getOrganizationPlan = async ( + supabase: SupabaseClient, + organizationId: number +): Promise<{ plan: NormalizedPlan; error?: string }> => { + const { data: plans, error } = await supabase + .from("profiles") + .select("plan") + .eq("organization_id", organizationId); + + if (error) { + return { plan: "solo", error: error.message }; + } + + return { + plan: getHighestPlan((plans || []).map((profile) => profile.plan)), + }; +}; export const generateICSFromEvents = ( events: EventAndTablo[], @@ -184,10 +228,10 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => { const supabase = c.get("supabase"); const user = c.get("user"); - // Get user profile to check subscription status + // Get user profile to check organization and subscription status const { data: profile, error: profileError } = await supabase .from("profiles") - .select("*") + .select("organization_id") .eq("id", user.id) .single(); @@ -195,13 +239,26 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => { return c.json({ error: "Failed to get user profile" }, 500); } - const userProfile = profile as Tables<"profiles">; + const userProfile = profile as { organization_id: number | null }; - if (userProfile.plan === "free") { + if (!userProfile.organization_id) { + return c.json({ error: "No organization found for current user" }, 400); + } + + const { plan, error: planError } = await getOrganizationPlan( + supabase, + userProfile.organization_id + ); + if (planError) { + return c.json({ error: "Failed to resolve organization plan" }, 500); + } + + if (plan === "solo") { const { count, error: countError } = await supabase .from("tablos") .select("id", { count: "exact" }) - .eq("owner_id", user.id); + .eq("organization_id", userProfile.organization_id) + .is("deleted_at", null); const tabloCount = count as number; diff --git a/apps/api/src/routers/invite.ts b/apps/api/src/routers/invite.ts index 5d7311c..28c8a37 100644 --- a/apps/api/src/routers/invite.ts +++ b/apps/api/src/routers/invite.ts @@ -1,7 +1,7 @@ import type { Database, TablesInsert } from "@xtablo/shared-types"; import { Hono } from "hono"; import { createFactory } from "hono/factory"; -import { createInvitedUser } from "../helpers/helpers.js"; +import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js"; import type { EventTypeConfig } from "../helpers/slots.js"; import type { MaybeAuthEnv } from "../types/app.types.js"; @@ -27,7 +27,7 @@ const bookSlot = factory.createHandlers(async (c) => { // TODO: Verify that the owner_id is correct const { data: ownerData, error: ownerError } = await supabase .from("profiles") - .select("id, name, email") + .select("id, name, email, organization_id") .eq("short_user_id", data.owner_short_id) .single(); @@ -83,8 +83,23 @@ const bookSlot = factory.createHandlers(async (c) => { id: string; name: string; email: string; + organization_id: number | null; }; const ownerId = ownerDataTyped.id; + const ownerOrganizationId = ownerDataTyped.organization_id; + + if (!ownerOrganizationId) { + return c.json({ error: "Owner organization not found" }, 500); + } + + const { plan: organizationPlan, error: organizationPlanError } = await getOrganizationPlan( + supabase, + ownerOrganizationId + ); + + if (organizationPlanError) { + return c.json({ error: "Failed to resolve organization plan" }, 500); + } const bookerUserDataTyped = bookerUser as { id: string; @@ -121,11 +136,11 @@ const bookSlot = factory.createHandlers(async (c) => { ` id, name, - owner_id, + organization_id, tablo_access!inner(user_id) ` ) - .eq("owner_id", ownerId) + .eq("organization_id", ownerOrganizationId) .eq("tablo_access.user_id", bookerUserDataTyped.id) .is("deleted_at", null) .limit(1); @@ -138,6 +153,28 @@ const bookSlot = factory.createHandlers(async (c) => { let tabloData: { id: string; name: string } | null = null; if (!existingTablo.length) { + if (organizationPlan === "solo") { + const { count: tabloCount, error: tabloCountError } = await supabase + .from("tablos") + .select("id", { count: "exact", head: true }) + .eq("organization_id", ownerOrganizationId) + .is("deleted_at", null); + + if (tabloCountError) { + return c.json({ error: "Failed to validate organization tablo limit" }, 500); + } + + if ((tabloCount || 0) >= MAX_TABLO_LIMIT) { + return c.json( + { + error: + "Your organization has reached the Solo plan tablo limit. Upgrade your plan to create more tablos.", + }, + 403 + ); + } + } + // Create the tablo with the specified owner const { data: insertedTablo, error } = await supabase .from("tablos") @@ -146,6 +183,7 @@ const bookSlot = factory.createHandlers(async (c) => { color: "bg-blue-500", status: "todo", owner_id: ownerId, + organization_id: ownerOrganizationId, }) .select() .single(); @@ -182,11 +220,24 @@ const bookSlot = factory.createHandlers(async (c) => { } // 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: [ownerId, bookerUserDataTyped.id], + members: channelMembers, }); await channel.create(); diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index a888b17..6152593 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -12,7 +12,7 @@ import { generateToken } from "../helpers/token.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; import type { AuthEnv } from "../types/app.types.js"; -type PostTablo = Omit & { +type PostTablo = Omit & { events?: EventInsertInTablo[]; }; @@ -26,11 +26,22 @@ 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: [user.id], + members: channelMembers, }); await channel.create(); @@ -72,23 +96,32 @@ const updateTablo = (middlewareManager: ReturnType; - - const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name; - if (error) { return c.json({ error: error.message }, 500); } + const updatedTablo = update as Tables<"tablos">; + const isUpdatingName = tablo.name !== undefined; + if (isUpdatingName) { const channel = streamServerClient.channel("messaging", updatedTablo.id); try { @@ -112,12 +145,6 @@ const deleteTablo = factory.createHandlers(async (c) => { const { id } = data; - const { error } = await supabase - .from("tablos") - .update({ deleted_at: new Date().toISOString() }) - .eq("id", id) - .eq("owner_id", user.id); - // Verify that the user has admin access to this tablo const { data: tabloAccess, error: accessError } = await supabase .from("tablo_access") @@ -131,6 +158,11 @@ const deleteTablo = factory.createHandlers(async (c) => { return c.json({ error: "You are not authorized to delete this tablo" }, 403); } + const { error } = await supabase + .from("tablos") + .update({ deleted_at: new Date().toISOString() }) + .eq("id", id); + if (error) { return c.json({ error: error.message }, 500); } diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index b3b0b9b..4fabbe1 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -2,6 +2,12 @@ import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@a import type { Tables } from "@xtablo/shared-types"; import { Hono } from "hono"; import { createFactory } from "hono/factory"; +import { + createInvitedUser, + getOrganizationPlan, + MAX_TABLO_LIMIT, + MAX_TEAM_MEMBER_LIMIT, +} from "../helpers/helpers.js"; import type { AuthEnv } from "../types/app.types.js"; const factory = createFactory(); @@ -260,6 +266,280 @@ const deleteAvatar = factory.createHandlers(async (c) => { }); }); +const getOrganization = factory.createHandlers(async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("organization_id") + .eq("id", user.id) + .single(); + + if (profileError || !profile?.organization_id) { + return c.json({ error: "Failed to resolve your organization" }, 500); + } + + const organizationId = profile.organization_id; + + const { data: organization, error: organizationError } = await supabase + .from("organizations") + .select("id, name") + .eq("id", organizationId) + .single(); + + if (organizationError || !organization) { + return c.json({ error: "Organization not found" }, 404); + } + + const { data: members, error: membersError } = await supabase + .from("profiles") + .select("id, email, name, first_name, last_name, avatar_url, created_at, is_temporary, plan") + .eq("organization_id", organizationId) + .order("created_at", { ascending: true }); + + if (membersError) { + return c.json({ error: "Failed to load organization members" }, 500); + } + + const { count: tabloCount, error: tabloCountError } = await supabase + .from("tablos") + .select("id", { count: "exact", head: true }) + .eq("organization_id", organizationId) + .is("deleted_at", null); + + if (tabloCountError) { + return c.json({ error: "Failed to load organization tablos" }, 500); + } + + const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId); + if (planError) { + return c.json({ error: "Failed to resolve organization plan" }, 500); + } + + return c.json({ + organization: { + id: organization.id, + name: organization.name, + plan, + member_count: members?.length || 0, + tablo_count: tabloCount || 0, + }, + members: members || [], + }); +}); + +const updateOrganization = factory.createHandlers(async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const body = await c.req.json(); + const rawName = typeof body?.name === "string" ? body.name : ""; + const name = rawName.trim(); + + if (name.length < 2 || name.length > 100) { + return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400); + } + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("organization_id, is_temporary") + .eq("id", user.id) + .single(); + + if (profileError || !profile?.organization_id) { + return c.json({ error: "Failed to resolve your organization" }, 500); + } + + if (profile.is_temporary) { + return c.json({ error: "Temporary users cannot update organization settings" }, 403); + } + + const { error: updateError } = await supabase + .from("organizations") + .update({ name }) + .eq("id", profile.organization_id); + + if (updateError) { + return c.json({ error: updateError.message }, 500); + } + + return c.json({ message: "Organization updated successfully" }); +}); + +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(); + + if (!recipientEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) { + return c.json({ error: "A valid email is required" }, 400); + } + + const { data: senderProfile, error: senderError } = await supabase + .from("profiles") + .select("organization_id, email, is_temporary") + .eq("id", user.id) + .single(); + + if (senderError || !senderProfile?.organization_id || !senderProfile?.email) { + return c.json({ error: "Failed to resolve your organization" }, 500); + } + + if (senderProfile.is_temporary) { + return c.json({ error: "Temporary users cannot invite organization members" }, 403); + } + + if (senderProfile.email.toLowerCase() === recipientEmail) { + return c.json({ error: "You cannot invite yourself" }, 400); + } + + const organizationId = senderProfile.organization_id; + const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId); + + if (planError) { + return c.json({ error: "Failed to resolve organization plan" }, 500); + } + + const { data: existingUser, error: existingUserError } = await supabase + .from("profiles") + .select("id, organization_id") + .eq("email", recipientEmail) + .maybeSingle(); + + if (existingUserError) { + return c.json({ error: existingUserError.message }, 500); + } + + if (existingUser) { + if (existingUser.organization_id === organizationId) { + return c.json({ message: "User is already in your organization" }); + } + + return c.json({ error: "This email already belongs to another organization" }, 409); + } + + const { count: membersCount, error: membersCountError } = await supabase + .from("profiles") + .select("id", { count: "exact", head: true }) + .eq("organization_id", organizationId); + + if (membersCountError) { + return c.json({ error: "Failed to check organization size" }, 500); + } + + if (plan === "solo" && (membersCount || 0) >= 1) { + return c.json( + { + error: `Solo plan allows a single user only. Upgrade to Team to invite collaborators.`, + }, + 403 + ); + } + + if (plan === "team" && (membersCount || 0) >= MAX_TEAM_MEMBER_LIMIT) { + return c.json( + { + error: `Team plan allows up to ${MAX_TEAM_MEMBER_LIMIT} users. Upgrade to Annual to invite more.`, + }, + 403 + ); + } + + const invitedUser = await createInvitedUser( + supabase, + streamServerClient, + transporter, + recipientEmail, + senderProfile.email + ); + + if (!invitedUser.success || !invitedUser.userId) { + return c.json({ error: invitedUser.error || "Failed to create invited user" }, 500); + } + + const { data: createdProfile, error: createdProfileError } = await supabase + .from("profiles") + .select("organization_id") + .eq("id", invitedUser.userId) + .single(); + + if (createdProfileError) { + return c.json({ error: "Invited user was created, but profile update failed" }, 500); + } + + const oldOrganizationId = createdProfile.organization_id; + + const { error: assignOrganizationError } = await supabase + .from("profiles") + .update({ organization_id: organizationId }) + .eq("id", invitedUser.userId); + + if (assignOrganizationError) { + return c.json({ error: "Failed to assign invited user to your organization" }, 500); + } + + const { data: organizationTablos, error: organizationTablosError } = await supabase + .from("tablos") + .select("id") + .eq("organization_id", organizationId) + .is("deleted_at", null); + + if (organizationTablosError) { + return c.json({ error: "Failed to sync organization access" }, 500); + } + + if ((organizationTablos || []).length > 0) { + const accessRows = (organizationTablos || []).map((tablo) => ({ + tablo_id: tablo.id, + user_id: invitedUser.userId!, + granted_by: user.id, + is_active: true, + is_admin: true, + })); + + const { error: accessError } = await supabase.from("tablo_access").upsert(accessRows, { + onConflict: "tablo_id,user_id", + }); + + if (accessError) { + return c.json({ error: "Failed to sync invited user tablo permissions" }, 500); + } + } + + 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") + .select("id", { count: "exact", head: true }) + .eq("organization_id", oldOrganizationId); + + if ((oldOrgMembersCount || 0) === 0) { + await supabase.from("organizations").delete().eq("id", oldOrganizationId); + } + } + + return c.json({ + message: "Invitation sent successfully", + limits: { + plan, + max_tablos_for_solo: MAX_TABLO_LIMIT, + max_members_for_team: MAX_TEAM_MEMBER_LIMIT, + }, + }); +}); + export const getUserRouter = () => { const userRouter = new Hono(); @@ -268,6 +548,9 @@ export const getUserRouter = () => { userRouter.post("/mark-temporary", ...markTemporary); userRouter.post("/profile/avatar", ...uploadAvatar); userRouter.delete("/profile/avatar", ...deleteAvatar); + userRouter.get("/organization", ...getOrganization); + userRouter.patch("/organization", ...updateOrganization); + userRouter.post("/organization/invite", ...inviteToOrganization); return userRouter; }; diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index 0aebea1..195544c 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -13,7 +13,6 @@ import { import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography"; import { cva, type VariantProps } from "class-variance-authority"; import { - AlertCircle, CalendarCheckIcon, CalendarIcon, Circle, @@ -43,14 +42,14 @@ import { Waves, Zap, } from "lucide-react"; -import { useTablosList } from "../hooks/tablos"; import { useState } from "react"; import { Separator } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link as RouterLink, useLocation } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { useLogout } from "../hooks/auth"; -import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe"; +import { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe"; +import { useTablosList } from "../hooks/tablos"; import { isProd, isStaging } from "../lib/env"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; import { getXtabloIcon } from "../utils/iconHelpers"; @@ -79,7 +78,10 @@ function NavLink({ isActive, children }: NavLinkProps) { "*:data-[ui=notification-badge]:font-semibold", isActive ? "bg-purple-100 dark:bg-purple-900/30 font-semibold text-[#804EEC] dark:text-purple-300 *:data-[ui=notification-badge]:bg-transparent" - : ["font-medium", "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"] + : [ + "font-medium", + "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker", + ] )} > {children} @@ -99,7 +101,8 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { const itemVariants = cva("", { variants: { variant: { - default: "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white", + default: + "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white", destructive: "text-red-500/80 focus:bg-red-500/80 focus:text-white", }, }, @@ -301,17 +304,28 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean function getTabloIcon(color: string | null | undefined) { switch (color) { - case "bg-blue-500": return Zap; - case "bg-green-500": return Leaf; - case "bg-purple-500": return Gem; - case "bg-red-500": return Flame; - case "bg-yellow-500": return Star; - case "bg-indigo-500": return Compass; - case "bg-pink-500": return Heart; - case "bg-teal-500": return Waves; - case "bg-orange-500": return Sun; - case "bg-cyan-500": return Sparkles; - default: return FolderIcon; + case "bg-blue-500": + return Zap; + case "bg-green-500": + return Leaf; + case "bg-purple-500": + return Gem; + case "bg-red-500": + return Flame; + case "bg-yellow-500": + return Star; + case "bg-indigo-500": + return Compass; + case "bg-pink-500": + return Heart; + case "bg-teal-500": + return Waves; + case "bg-orange-500": + return Sun; + case "bg-cyan-500": + return Sparkles; + default: + return FolderIcon; } } @@ -372,16 +386,17 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { const isReadOnly = useIsReadOnlyUser(); const user = useUser(); const { t } = useTranslation("navigation"); - const { daysRemaining } = useTrialExpiration(); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); - const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || ""; + const TEAM_MONTHLY_PRICE_ID = + import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID || + import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || + ""; - // Show upsell when user is still in trial or using freemium tier - const shouldShowTrialUpsell = - daysRemaining !== null && user.plan === "none" && !user.is_temporary; - const shouldShowFreemiumUpsell = user.plan === "free" && !user.is_temporary; - const isUrgent = daysRemaining !== null && daysRemaining <= 3; + const currentPlan = normalizeBillingPlan(user.plan); + + // Show upsell when organization is on Solo plan + const shouldShowSoloUpsell = currentPlan === "solo" && !user.is_temporary; type List = T[]; @@ -484,12 +499,16 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { className="w-full" aria-label={isCollapsed ? label : undefined} > -
+
{icon} @@ -507,45 +526,23 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { {!isCollapsed && }
    - {/* Trial upsell message */} - {shouldShowTrialUpsell && !isCollapsed && ( + {/* Solo upsell message */} + {shouldShowSoloUpsell && !isCollapsed && (
  • - {isUrgent ? ( - - ) : ( - - )} +
    -

    - {daysRemaining === 0 - ? "Dernier jour d'essai" - : `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restant${daysRemaining > 1 ? "s" : ""}`} +

    + Plan Solo

    -

    - {isUrgent ? "Essayer Starter maintenant" : "Essayer Starter"} +

    + Passez au plan Team pour inviter jusqu'à 3 utilisateurs.

    @@ -553,64 +550,20 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { size="sm" onClick={() => createCheckout({ - priceId: STANDARD_MONTHLY_PRICE_ID, + priceId: TEAM_MONTHLY_PRICE_ID, successUrl: `${window.location.origin}?upgraded=true`, cancelUrl: `${window.location.origin}?canceled=true`, }) } - disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID} - className={twMerge( - "w-full h-7 text-xs gap-1", - isUrgent - ? "bg-linear-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600" - : "bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600" - )} + disabled={checkoutPending || !TEAM_MONTHLY_PRICE_ID} + className="w-full h-7 text-xs gap-1 bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600" > {checkoutPending ? ( "..." ) : ( <> - Mettre à niveau - - )} - -
    -
  • - )} - {/* Freemium upsell message */} - {shouldShowFreemiumUpsell && !isCollapsed && ( -
  • -
    -
    - -
    -

    - Plan Freemium -

    -

    - Passer au plan Starter pour profiter de projets illimités. -

    -
    -
    - @@ -648,11 +601,15 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { aria-label={isCollapsed ? "Feedback" : undefined} >
    - + + diff --git a/apps/main/src/components/SubscriptionCard.tsx b/apps/main/src/components/SubscriptionCard.tsx index e824628..0070923 100644 --- a/apps/main/src/components/SubscriptionCard.tsx +++ b/apps/main/src/components/SubscriptionCard.tsx @@ -1,4 +1,3 @@ -import { pluralize } from "@xtablo/shared"; import { Badge } from "@xtablo/ui/components/badge"; import { Button } from "@xtablo/ui/components/button"; import { @@ -9,14 +8,13 @@ import { CardTitle, } from "@xtablo/ui/components/card"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; -import { useMemo } from "react"; import { + normalizeBillingPlan, useCancelSubscription, useCreateCheckoutSession, useCreatePortalSession, useReactivateSubscription, useSubscription, - useTrialExpiration, } from "../hooks/stripe"; import { useUser } from "../providers/UserStoreProvider"; @@ -39,36 +37,33 @@ export function SubscriptionCard() { const { mutate: reactivateSubscription, isPending: reactivatePending } = useReactivateSubscription(); - const { daysRemaining } = useTrialExpiration(); - - const daysRemainingValue = useMemo(() => { - if (!daysRemaining) return 7; - return daysRemaining; - }, [daysRemaining]); - - const isPaying = user.plan === "trial" || user.plan === "standard"; - const isBeta = user.plan === "beta"; - const isFreemium = user.plan === "free"; - - const showTrialBanner = user.plan === "none"; + const normalizedPlan = normalizeBillingPlan(user.plan); + const isPaying = normalizedPlan === "team" || normalizedPlan === "annual"; + const isAnnual = normalizedPlan === "annual"; + const isSolo = normalizedPlan === "solo"; // Replace with your actual price ID from Stripe Dashboard const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || ""; - const standardPriceId = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || ""; + const teamPriceId = + import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID || + import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || + ""; + const annualPriceId = import.meta.env.VITE_STRIPE_ANNUAL_PRICE_ID || ""; + const soloPriceId = import.meta.env.VITE_STRIPE_SOLO_PRICE_ID || ""; const priceId = allowedInfiniteUsers.includes(user.email!) && infinitePriceId ? infinitePriceId - : standardPriceId; + : teamPriceId || annualPriceId || soloPriceId; const getStatusBadge = () => { - // Check for beta plan first - if (isBeta) { + // Annual plan badge + if (isAnnual) { return ( - Beta + Annual ); } @@ -124,11 +119,11 @@ export function SubscriptionCard() { {getStatusBadge()}
    - {isBeta - ? "Vous avez accès à toutes les fonctionnalités gratuitement en tant que bêta-testeur" + {isAnnual + ? "Vous disposez du plan Annual avec limites illimitées" : isPaying ? "Gérez votre abonnement et votre facturation" - : "Passez à Starter pour débloquer toutes les fonctionnalités"} + : "Passez au plan Team ou Annual pour travailler en équipe"} @@ -138,24 +133,23 @@ export function SubscriptionCard() {
    ) : ( <> - {/* Beta Plan */} - {isBeta && ( + {/* Annual Plan */} + {isAnnual && (

    - Plan Beta + Plan Annual

    - Accès gratuit et illimité à toutes les fonctionnalités + Utilisateurs et tablos illimités

    - Merci de faire partie de nos bêta-testeurs ! Votre retour nous aide à - améliorer XTablo. + Votre organisation bénéficie des capacités maximales.

    @@ -163,16 +157,15 @@ export function SubscriptionCard() {
    )} - {showTrialBanner && ( + {isSolo && (

    - Accès gratuit pendant 7 jours + Plan Solo

    - Il vous reste {daysRemainingValue} {pluralize("jour", daysRemainingValue)}{" "} - pour passer au plan Starter. + 1 utilisateur et 10 tablos maximum pour votre organisation.

    @@ -195,46 +188,7 @@ export function SubscriptionCard() { ) : ( <> - Passer au plan Starter - - )} - -
    - )} - - {isFreemium && ( -
    -
    -
    -

    - Plan Freemium -

    -

    - Un seul tablo disponible gratuitement, passez au plan Starter pour profiter de - toutes les fonctionnalités. -

    -
    -
    - @@ -248,10 +202,10 @@ export function SubscriptionCard() {

    - Plan Starter + Plan {isAnnual ? "Annual" : "Team"}

    - Toutes les fonctionnalités débloquées + Collaboration d'organisation activée

    {subscription.current_period_end && ( @@ -312,7 +266,7 @@ export function SubscriptionCard() { Abonnement en cours d'annulation

    - Votre abonnement Starter sera annulé le{" "} + Votre abonnement {isAnnual ? "Annual" : "Team"} sera annulé le{" "} {subscription.current_period_end && new Date(subscription.current_period_end * 1000).toLocaleDateString( "fr-FR", @@ -324,7 +278,7 @@ export function SubscriptionCard() { )}

    - Vous aurez accès aux fonctionnalités Starter jusqu'à cette date. + Vous gardez vos droits actuels jusqu'à cette date.

    diff --git a/apps/main/src/hooks/organization.ts b/apps/main/src/hooks/organization.ts new file mode 100644 index 0000000..c61f980 --- /dev/null +++ b/apps/main/src/hooks/organization.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@xtablo/shared"; +import { useAuthedApi } from "./auth"; + +export interface OrganizationSummary { + id: number; + name: string; + plan: string; + member_count: number; + tablo_count: number; +} + +export interface OrganizationMember { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + created_at: string | null; + is_temporary: boolean; + plan: string | null; +} + +export interface OrganizationResponse { + organization: OrganizationSummary; + members: OrganizationMember[]; +} + +export const useOrganization = () => { + const api = useAuthedApi(); + + return useQuery({ + queryKey: ["organization"], + queryFn: async () => { + const { data } = await api.get("/api/v1/users/organization"); + return data; + }, + }); +}; + +export const useUpdateOrganization = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (name: string) => { + const { data } = await api.patch("/api/v1/users/organization", { name }); + return data; + }, + onSuccess: () => { + toast.add({ + title: "Organisation mise à jour", + description: "Le nom de l'organisation a bien été enregistré", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["organization"] }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible de mettre à jour l'organisation", + type: "error", + }); + }, + }); +}; + +export const useInviteOrganizationUser = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (email: string) => { + const { data } = await api.post("/api/v1/users/organization/invite", { email }); + return data; + }, + onSuccess: () => { + toast.add({ + title: "Invitation envoyée", + description: "L'utilisateur a été invité dans votre organisation", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["organization"] }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible d'envoyer l'invitation", + type: "error", + }); + }, + }); +}; diff --git a/apps/main/src/hooks/stripe.ts b/apps/main/src/hooks/stripe.ts index ec6d4f9..3ba71ba 100644 --- a/apps/main/src/hooks/stripe.ts +++ b/apps/main/src/hooks/stripe.ts @@ -15,7 +15,15 @@ import { useAuthedApi } from "./auth"; // Initialize Stripe const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""); -const TRIAL_DURATION = 7 * 24 * 60 * 60 * 1000; +export type BillingPlan = "solo" | "team" | "annual"; + +export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => { + if (!plan) return "solo"; + + if (plan === "annual" || plan === "beta") return "annual"; + if (plan === "team" || plan === "standard") return "team"; + return "solo"; +}; /** * Hook to get user's subscription status from Supabase @@ -54,7 +62,8 @@ export function useSubscription() { export function useIsPayingUser() { const user = useUser(); - const isPaying = user.plan === "trial" || user.plan === "standard"; + const normalizedPlan = normalizeBillingPlan(user.plan); + const isPaying = normalizedPlan === "team" || normalizedPlan === "annual"; // Direct access from user profile (fastest) return { @@ -66,16 +75,11 @@ export function useIsPayingUser() { export const useIsPastTrial = () => { const user = useMaybeUser(); - // Calculate if user is past trial inline - // User is past trial if: plan is 'none' AND created more than 7 days ago + // Trial model is deprecated in favor of explicit billing plans. + // Keep this hook returning false to avoid blocking existing users unexpectedly. const isPastTrial = useMemo(() => { - if (!user) return false; - - const isPastTrial = - user.plan === "none" && - new Date(user.created_at || "") < new Date(Date.now() - TRIAL_DURATION); - - return isPastTrial; + void user; + return false; }, [user]); return { isPastTrial, isLoading: !user }; @@ -88,24 +92,8 @@ export const useIsPastTrial = () => { export const useTrialExpiration = () => { const user = useMaybeUser(); - const trialExpiration = useMemo(() => { - if (!user?.created_at) return null; - - const creationDate = new Date(user.created_at); - const expirationDate = new Date(creationDate.getTime() + TRIAL_DURATION); - - return expirationDate; - }, [user?.created_at]); - - const daysRemaining = useMemo(() => { - if (!trialExpiration) return null; - - const now = new Date(); - const diffInMs = trialExpiration.getTime() - now.getTime(); - const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24)); - - return Math.max(0, diffInDays); - }, [trialExpiration]); + // Trial model is deprecated in favor of explicit billing plans. + const daysRemaining = useMemo(() => null as number | null, [user]); return { daysRemaining, isLoading: !user }; }; diff --git a/apps/main/src/locales/en/settings.json b/apps/main/src/locales/en/settings.json index 13f35f7..c641985 100644 --- a/apps/main/src/locales/en/settings.json +++ b/apps/main/src/locales/en/settings.json @@ -32,6 +32,25 @@ "placeholder": "Write your introduction message here...", "emailDescription": "This message will be sent by email to people you invite" }, + "organization": { + "title": "Organization", + "description": "Manage your team workspace", + "name": "Organization name", + "namePlaceholder": "Your organization name", + "currentPlan": "Current plan: {{plan}}", + "stats": "{{members}} member(s) · {{tablos}} tablo(s)", + "save": "Save organization", + "saving": "Saving..." + }, + "teamInvite": { + "title": "Invite a teammate", + "description": "Invite a new user by email to join your organization", + "emailLabel": "Teammate email", + "emailPlaceholder": "name@company.com", + "hint": "The invited user will get credentials by email.", + "invite": "Send invite", + "inviting": "Sending..." + }, "cookies": { "title": "Cookie Preferences", "description": "Manage your cookie and tracking preferences. Changes will take effect after page reload.", diff --git a/apps/main/src/locales/fr/settings.json b/apps/main/src/locales/fr/settings.json index b2aeb3d..af553a2 100644 --- a/apps/main/src/locales/fr/settings.json +++ b/apps/main/src/locales/fr/settings.json @@ -32,6 +32,25 @@ "placeholder": "Écrivez votre message d'introduction ici...", "emailDescription": "Ce message sera envoyé par email aux personnes que vous invitez" }, + "organization": { + "title": "Organisation", + "description": "Gérez votre espace d'équipe", + "name": "Nom de l'organisation", + "namePlaceholder": "Nom de votre organisation", + "currentPlan": "Plan actuel : {{plan}}", + "stats": "{{members}} membre(s) · {{tablos}} tablo(s)", + "save": "Enregistrer l'organisation", + "saving": "Enregistrement..." + }, + "teamInvite": { + "title": "Inviter un collaborateur", + "description": "Invitez un nouvel utilisateur par email dans votre organisation", + "emailLabel": "Email du collaborateur", + "emailPlaceholder": "nom@entreprise.com", + "hint": "L'utilisateur invité recevra ses identifiants par email.", + "invite": "Envoyer l'invitation", + "inviting": "Envoi..." + }, "cookies": { "title": "Préférences des cookies", "description": "Gérez vos préférences en matière de cookies et de suivi. Les modifications prendront effet après rechargement de la page.", diff --git a/apps/main/src/pages/settings.tsx b/apps/main/src/pages/settings.tsx index c8a2a46..44502a0 100644 --- a/apps/main/src/pages/settings.tsx +++ b/apps/main/src/pages/settings.tsx @@ -23,11 +23,16 @@ import { Switch } from "@xtablo/ui/components/switch"; import { Textarea } from "@xtablo/ui/components/textarea"; import { TypographyH3, TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography"; import { CameraIcon, CookieIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { LanguageSelector } from "../components/LanguageSelector"; import { SubscriptionCard } from "../components/SubscriptionCard"; import { useIntroduction } from "../hooks/intros"; +import { + useInviteOrganizationUser, + useOrganization, + useUpdateOrganization, +} from "../hooks/organization"; import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile"; import { useCookieConsent } from "../hooks/useCookieConsent"; import { useUser } from "../providers/UserStoreProvider"; @@ -45,6 +50,11 @@ export default function SettingsPage() { const { mutate: uploadAvatar } = useUploadAvatar(); const { mutateAsync: removeAvatar, isPending: removeAvatarPending } = useRemoveAvatar(); const { preferences, saveConsent } = useCookieConsent(); + const { data: organizationData, isLoading: organizationLoading } = useOrganization(); + const { mutate: updateOrganization, isPending: updateOrganizationPending } = + useUpdateOrganization(); + const { mutate: inviteOrganizationUser, isPending: inviteOrganizationUserPending } = + useInviteOrganizationUser(); const [firstName, setFirstName] = useState(user?.first_name || ""); const [lastName, setLastName] = useState(user?.last_name || ""); @@ -53,8 +63,16 @@ export default function SettingsPage() { const [imageToCrop, setImageToCrop] = useState(null); const [isCropDialogOpen, setIsCropDialogOpen] = useState(false); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [organizationName, setOrganizationName] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); const fileInputRef = useRef(null); + useEffect(() => { + if (organizationData?.organization?.name) { + setOrganizationName(organizationData.organization.name); + } + }, [organizationData?.organization?.name]); + const handleAvatarChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { @@ -294,6 +312,83 @@ export default function SettingsPage() { + + + {t("settings:organization.title")} + {t("settings:organization.description")} + + +
    + + setOrganizationName(e.target.value)} + placeholder={t("settings:organization.namePlaceholder")} + disabled={organizationLoading} + /> +

    + {t("settings:organization.currentPlan", { + plan: organizationData?.organization?.plan || "solo", + })} +

    +

    + {t("settings:organization.stats", { + members: organizationData?.organization?.member_count || 0, + tablos: organizationData?.organization?.tablo_count || 0, + })} +

    +
    + +
    + +
    +
    +
    + + + + {t("settings:teamInvite.title")} + {t("settings:teamInvite.description")} + + +
    + + setInviteEmail(e.target.value)} + placeholder={t("settings:teamInvite.emailPlaceholder")} + /> +

    {t("settings:teamInvite.hint")}

    +
    + +
    + +
    +
    +
    + {/* Subscription Section */} diff --git a/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql b/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql new file mode 100644 index 0000000..7ce81ee --- /dev/null +++ b/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql @@ -0,0 +1,389 @@ +-- Organizations: team collaboration model +-- - Every user belongs to exactly one organization +-- - Tablos are created by users but owned by organizations +-- - Organization members automatically get admin access to organization tablos + +-- ============================================================================ +-- Organizations +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.organizations ( + id SERIAL PRIMARY KEY, + internal_uuid uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE, + name text NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.organizations IS 'Organizations grouping users from the same company for shared tablo access.'; +COMMENT ON COLUMN public.organizations.id IS 'Primary key (SERIAL), external references should use this value.'; +COMMENT ON COLUMN public.organizations.internal_uuid IS 'Internal backup UUID for organization records.'; + +DROP TRIGGER IF EXISTS update_organizations_updated_at ON public.organizations; +CREATE TRIGGER update_organizations_updated_at + BEFORE UPDATE ON public.organizations + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +-- Random two-word English organization names +CREATE OR REPLACE FUNCTION public.generate_cool_organization_name() RETURNS text +LANGUAGE plpgsql +AS $$ +DECLARE + adjectives text[] := ARRAY[ + 'Amber', 'Arctic', 'Atlas', 'Azure', 'Bold', 'Bright', 'Cobalt', 'Cosmic', + 'Crisp', 'Dynamic', 'Electric', 'Emerald', 'Epic', 'Golden', 'Grand', 'Iron', + 'Ivory', 'Lunar', 'Modern', 'Nova', 'Ocean', 'Orbit', 'Prime', 'Quantum', + 'Rapid', 'Royal', 'Silver', 'Solar', 'Summit', 'Swift', 'Ultra', 'Urban', 'Velvet' + ]; + nouns text[] := ARRAY[ + 'Bridge', 'Cloud', 'Collective', 'Compass', 'Craft', 'Forge', 'Frontier', 'Harbor', + 'Horizon', 'House', 'Labs', 'Line', 'Logic', 'Matrix', 'Minds', 'Network', + 'Nexus', 'Peak', 'Pioneer', 'Pulse', 'Reach', 'Rocket', 'Scope', 'Signal', + 'Spark', 'Sphere', 'Stack', 'Studio', 'Systems', 'Tower', 'Ventures', 'Works', 'Yard' + ]; +BEGIN + RETURN + adjectives[1 + floor(random() * array_length(adjectives, 1))::integer] + || ' ' || + nouns[1 + floor(random() * array_length(nouns, 1))::integer]; +END; +$$; + +COMMENT ON FUNCTION public.generate_cool_organization_name() IS 'Returns a random two-word English organization name.'; + +CREATE OR REPLACE FUNCTION public.create_personal_organization() RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + new_org_id integer; +BEGIN + INSERT INTO public.organizations (name) + VALUES (public.generate_cool_organization_name()) + RETURNING id INTO new_org_id; + + RETURN new_org_id; +END; +$$; + +ALTER FUNCTION public.create_personal_organization() OWNER TO postgres; + +-- ============================================================================ +-- Profiles: one organization per user +-- ============================================================================ + +ALTER TABLE public.profiles + ADD COLUMN IF NOT EXISTS organization_id integer; + +-- Existing users: create one personal organization each +UPDATE public.profiles +SET organization_id = public.create_personal_organization() +WHERE organization_id IS NULL; + +-- Ensure future profile inserts always receive an organization +CREATE OR REPLACE FUNCTION public.ensure_profile_organization() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + IF NEW.organization_id IS NULL THEN + NEW.organization_id := public.create_personal_organization(); + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.ensure_profile_organization() OWNER TO postgres; + +DROP TRIGGER IF EXISTS trigger_ensure_profile_organization ON public.profiles; +CREATE TRIGGER trigger_ensure_profile_organization + BEFORE INSERT ON public.profiles + FOR EACH ROW + EXECUTE FUNCTION public.ensure_profile_organization(); + +ALTER TABLE public.profiles + ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE public.profiles + DROP CONSTRAINT IF EXISTS profiles_organization_id_fkey; + +ALTER TABLE public.profiles + ADD CONSTRAINT profiles_organization_id_fkey + FOREIGN KEY (organization_id) + REFERENCES public.organizations(id) + ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS profiles_organization_id_idx ON public.profiles (organization_id); + +COMMENT ON COLUMN public.profiles.organization_id IS 'Organization the user belongs to. A user can only belong to one organization.'; + +-- ============================================================================ +-- Tablos: owned by organizations +-- ============================================================================ + +ALTER TABLE public.tablos + ADD COLUMN IF NOT EXISTS organization_id integer; + +-- Backfill existing tablos using owner's organization +UPDATE public.tablos t +SET organization_id = p.organization_id +FROM public.profiles p +WHERE p.id = t.owner_id + AND t.organization_id IS NULL; + +-- Ensure future tablos inherit owner's organization when omitted +CREATE OR REPLACE FUNCTION public.set_tablo_organization_from_owner() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + owner_organization_id integer; +BEGIN + IF NEW.organization_id IS NULL THEN + SELECT p.organization_id + INTO owner_organization_id + FROM public.profiles p + WHERE p.id = NEW.owner_id + LIMIT 1; + + IF owner_organization_id IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = '23503', + MESSAGE = 'Tablo owner has no organization'; + END IF; + + NEW.organization_id := owner_organization_id; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.set_tablo_organization_from_owner() OWNER TO postgres; + +DROP TRIGGER IF EXISTS trigger_set_tablo_organization ON public.tablos; +CREATE TRIGGER trigger_set_tablo_organization + BEFORE INSERT ON public.tablos + FOR EACH ROW + EXECUTE FUNCTION public.set_tablo_organization_from_owner(); + +ALTER TABLE public.tablos + ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE public.tablos + DROP CONSTRAINT IF EXISTS tablos_organization_id_fkey; + +ALTER TABLE public.tablos + ADD CONSTRAINT tablos_organization_id_fkey + FOREIGN KEY (organization_id) + REFERENCES public.organizations(id) + ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS tablos_organization_id_idx ON public.tablos (organization_id); + +COMMENT ON COLUMN public.tablos.organization_id IS 'Organization that owns the tablo.'; + +-- ============================================================================ +-- Access synchronization for organization members +-- ============================================================================ + +-- Replace owner-only access creation with org-wide admin access creation +CREATE OR REPLACE FUNCTION public.create_tablo_access_for_owner() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.tablo_access ( + tablo_id, + user_id, + granted_by, + is_active, + is_admin + ) + SELECT + NEW.id, + p.id, + NEW.owner_id, + TRUE, + TRUE + FROM public.profiles p + WHERE p.organization_id = NEW.organization_id + ON CONFLICT (tablo_id, user_id) + DO UPDATE SET + is_active = TRUE, + is_admin = TRUE, + granted_by = EXCLUDED.granted_by; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.create_tablo_access_for_owner() OWNER TO postgres; + +-- Existing data: ensure every org member has admin access to org tablos +INSERT INTO public.tablo_access ( + tablo_id, + user_id, + granted_by, + is_active, + is_admin +) +SELECT + t.id, + p.id, + t.owner_id, + TRUE, + TRUE +FROM public.tablos t +JOIN public.profiles p + ON p.organization_id = t.organization_id +WHERE t.deleted_at IS NULL +ON CONFLICT (tablo_id, user_id) +DO UPDATE SET + is_active = TRUE, + is_admin = TRUE, + granted_by = EXCLUDED.granted_by; + +-- When a user joins/moves organization, sync tablo_access automatically +CREATE OR REPLACE FUNCTION public.sync_org_member_tablo_access() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN + UPDATE public.tablo_access ta + SET is_active = FALSE + WHERE ta.user_id = NEW.id + AND ta.is_admin = TRUE + AND EXISTS ( + SELECT 1 + FROM public.tablos t + WHERE t.id = ta.tablo_id + AND t.organization_id = OLD.organization_id + ); + END IF; + + INSERT INTO public.tablo_access ( + tablo_id, + user_id, + granted_by, + is_active, + is_admin + ) + SELECT + t.id, + NEW.id, + NEW.id, + TRUE, + TRUE + FROM public.tablos t + WHERE t.organization_id = NEW.organization_id + AND t.deleted_at IS NULL + ON CONFLICT (tablo_id, user_id) + DO UPDATE SET + is_active = TRUE, + is_admin = TRUE, + granted_by = EXCLUDED.granted_by; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.sync_org_member_tablo_access() OWNER TO postgres; + +DROP TRIGGER IF EXISTS trigger_sync_org_member_tablo_access ON public.profiles; +CREATE TRIGGER trigger_sync_org_member_tablo_access + AFTER INSERT OR UPDATE OF organization_id ON public.profiles + FOR EACH ROW + EXECUTE FUNCTION public.sync_org_member_tablo_access(); + +-- ============================================================================ +-- RLS updates +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.current_user_organization_id() RETURNS integer +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT p.organization_id + FROM public.profiles p + WHERE p.id = auth.uid(); +$$; + +ALTER FUNCTION public.current_user_organization_id() OWNER TO postgres; + +-- Organizations RLS +ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can view their own organization" ON public.organizations; +CREATE POLICY "Users can view their own organization" + ON public.organizations + FOR SELECT + TO authenticated + USING (id = public.current_user_organization_id()); + +DROP POLICY IF EXISTS "Users can update their own organization" ON public.organizations; +CREATE POLICY "Users can update their own organization" + ON public.organizations + FOR UPDATE + TO authenticated + USING (id = public.current_user_organization_id()) + WITH CHECK (id = public.current_user_organization_id()); + +-- Tablos RLS: organization members have owner-level access +DROP POLICY IF EXISTS "Users can view tablos they have access to" ON public.tablos; +CREATE POLICY "Users can view tablos they have access to" + ON public.tablos + FOR SELECT + TO authenticated + USING ( + organization_id = public.current_user_organization_id() + OR EXISTS ( + SELECT 1 + FROM public.tablo_access ta + WHERE ta.tablo_id = tablos.id + AND ta.user_id = auth.uid() + AND ta.is_active = TRUE + ) + ); + +DROP POLICY IF EXISTS "Users can insert own tablos" ON public.tablos; +CREATE POLICY "Users can insert own tablos" + ON public.tablos + FOR INSERT + TO authenticated + WITH CHECK ( + owner_id = auth.uid() + AND ( + organization_id = public.current_user_organization_id() + OR organization_id IS NULL + ) + ); + +DROP POLICY IF EXISTS "Users can update own tablos" ON public.tablos; +CREATE POLICY "Users can update own tablos" + ON public.tablos + FOR UPDATE + TO authenticated + USING (organization_id = public.current_user_organization_id()) + WITH CHECK (organization_id = public.current_user_organization_id()); + +-- Tasks RLS already relies on tablo_access membership. Org-level tablo_access sync above +-- gives all organization members full access (admin) to organization tasks. + +-- ============================================================================ +-- Plan aliases for new billing model +-- ============================================================================ + +ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'solo'; +ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'team'; +ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'annual'; + +COMMENT ON TYPE public.subscription_plan IS 'Billing plans: solo (1 user, 10 tablos), team (3 users, unlimited tablos), annual (unlimited). Legacy values are kept for compatibility.'; + +GRANT SELECT, UPDATE ON public.organizations TO authenticated; +GRANT EXECUTE ON FUNCTION public.current_user_organization_id() TO authenticated; diff --git a/supabase/migrations/20260304224500_update_profile_plan_mapping_for_new_billing.sql b/supabase/migrations/20260304224500_update_profile_plan_mapping_for_new_billing.sql new file mode 100644 index 0000000..b45816c --- /dev/null +++ b/supabase/migrations/20260304224500_update_profile_plan_mapping_for_new_billing.sql @@ -0,0 +1,145 @@ +-- Update Stripe subscription -> profile.plan mapping for the new billing model +-- Plans are now: solo, team, annual. +-- Legacy plan values can still exist, but new webhook sync should assign the new values. + +CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_customer_id text; + v_plan_hint text; + v_has_paid_subscription boolean := FALSE; + v_plan public.subscription_plan := 'solo'; +BEGIN + -- Resolve customer id from the changed stripe row + IF TG_TABLE_NAME = 'subscriptions' THEN + v_customer_id := NEW.customer; + ELSIF TG_TABLE_NAME = 'subscription_items' THEN + SELECT s.customer + INTO v_customer_id + FROM stripe.subscriptions s + WHERE s.id = NEW.subscription; + ELSE + RETURN NEW; + END IF; + + IF v_customer_id IS NULL THEN + RETURN NEW; + END IF; + + -- Resolve application user id from Stripe customer metadata + SELECT (c.metadata->>'user_id')::uuid + INTO v_user_id + FROM stripe.customers c + WHERE c.id = v_customer_id; + + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + -- Pick the best active/trialing subscription for this user and infer plan from + -- price/product metadata or lookup key. Fall back to Team when paid but unmapped. + WITH candidate_subscriptions AS ( + SELECT + s.status::text AS status, + lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) AS plan_hint, + CASE s.status::text + WHEN 'active' THEN 3 + WHEN 'past_due' THEN 2 + WHEN 'trialing' THEN 1 + ELSE 0 + END AS status_weight, + CASE + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%annual%' THEN 3 + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%team%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%standard%' THEN 2 + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%solo%' THEN 1 + ELSE 0 + END AS plan_weight + FROM stripe.subscriptions s + JOIN stripe.customers c + ON c.id = s.customer + LEFT JOIN stripe.subscription_items si + ON si.subscription = s.id + LEFT JOIN stripe.prices pr + ON pr.id = si.price + LEFT JOIN stripe.products pd + ON pd.id = pr.product + WHERE (c.metadata->>'user_id')::uuid = v_user_id + AND s.status::text IN ('active', 'past_due', 'trialing') + AND ( + si.current_period_end IS NULL + OR to_timestamp(si.current_period_end) > now() + ) + ) + SELECT + TRUE, + cs.plan_hint + INTO v_has_paid_subscription, v_plan_hint + FROM candidate_subscriptions cs + ORDER BY cs.plan_weight DESC, cs.status_weight DESC + LIMIT 1; + + IF v_has_paid_subscription THEN + IF v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN + v_plan := 'annual'; + ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN + v_plan := 'team'; + ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN + v_plan := 'solo'; + ELSE + -- paid but unmapped => Team by default (backward compatibility) + v_plan := 'team'; + END IF; + ELSE + v_plan := 'solo'; + END IF; + + UPDATE public.profiles + SET plan = v_plan + WHERE id = v_user_id; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres; + +COMMENT ON FUNCTION public.update_profile_subscription_status() IS +'Maps Stripe subscription state to profile.plan using new billing plans (solo, team, annual).'; From 521772becbd88af85ce059e0953c9c1ab315d9d0 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 4 Mar 2026 21:52:50 +0100 Subject: [PATCH 2/4] Implement recovery mechanism for orphaned tablos in organization migration - Add a safety net to handle legacy inconsistencies by assigning orphaned tablos to a dedicated recovery organization. - Ensure that the NOT NULL constraint can be applied to the organization_id field in the tablos table. --- ...add_organizations_and_org_owned_tablos.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql b/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql index 7ce81ee..2eb6daf 100644 --- a/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql +++ b/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql @@ -133,6 +133,25 @@ FROM public.profiles p WHERE p.id = t.owner_id AND t.organization_id IS NULL; +-- Safety net for legacy inconsistencies: some historical tablos can reference +-- owners that do not have a profile row anymore. Assign these orphaned rows to +-- a dedicated recovery organization so the NOT NULL constraint can be applied. +DO $$ +DECLARE + recovery_organization_id integer; +BEGIN + IF EXISTS (SELECT 1 FROM public.tablos WHERE organization_id IS NULL) THEN + INSERT INTO public.organizations (name) + VALUES ('Recovered Legacy Workspace') + RETURNING id INTO recovery_organization_id; + + UPDATE public.tablos t + SET organization_id = recovery_organization_id + WHERE t.organization_id IS NULL; + END IF; +END; +$$; + -- Ensure future tablos inherit owner's organization when omitted CREATE OR REPLACE FUNCTION public.set_tablo_organization_from_owner() RETURNS trigger LANGUAGE plpgsql From 0c4e9c1301db54d11e1a5d18197caf2f1c520020 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 4 Mar 2026 21:54:37 +0100 Subject: [PATCH 3/4] Enhance recovery mechanism for orphaned tablos by managing triggers - Introduce logic to temporarily disable the notify_users_on_tablos trigger during the update of orphaned tablos to prevent unwanted notifications. - Ensure that the trigger is re-enabled after the update, even in case of exceptions, maintaining database integrity. --- ...add_organizations_and_org_owned_tablos.sql | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql b/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql index 2eb6daf..1bb82d1 100644 --- a/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql +++ b/supabase/migrations/20260304221500_add_organizations_and_org_owned_tablos.sql @@ -139,15 +139,40 @@ WHERE p.id = t.owner_id DO $$ DECLARE recovery_organization_id integer; + has_notify_trigger boolean; BEGIN IF EXISTS (SELECT 1 FROM public.tablos WHERE organization_id IS NULL) THEN INSERT INTO public.organizations (name) VALUES ('Recovered Legacy Workspace') RETURNING id INTO recovery_organization_id; - UPDATE public.tablos t - SET organization_id = recovery_organization_id - WHERE t.organization_id IS NULL; + SELECT EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgrelid = 'public.tablos'::regclass + AND tgname = 'notify_users_on_tablos' + AND NOT tgisinternal + ) INTO has_notify_trigger; + + BEGIN + IF has_notify_trigger THEN + EXECUTE 'ALTER TABLE public.tablos DISABLE TRIGGER notify_users_on_tablos'; + END IF; + + UPDATE public.tablos t + SET organization_id = recovery_organization_id + WHERE t.organization_id IS NULL; + + IF has_notify_trigger THEN + EXECUTE 'ALTER TABLE public.tablos ENABLE TRIGGER notify_users_on_tablos'; + END IF; + EXCEPTION + WHEN OTHERS THEN + IF has_notify_trigger THEN + EXECUTE 'ALTER TABLE public.tablos ENABLE TRIGGER notify_users_on_tablos'; + END IF; + RAISE; + END; END IF; END; $$; From 28d0b938faf380b9885f4d59edbce977b0d3f0de Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 4 Mar 2026 22:09:10 +0100 Subject: [PATCH 4/4] Fix various issues --- apps/api/src/routers/tablo.ts | 175 +++++++++++++++++- .../src/components/TabloHeaderActions.tsx | 27 ++- .../src/components/TabloMembersSection.tsx | 17 +- apps/main/src/components/kanban/types.ts | 1 + apps/main/src/hooks/tablo_invites.ts | 38 +++- apps/main/src/hooks/tablos.ts | 8 +- apps/main/src/pages/tablo-details.tsx | 108 ++++++++--- ..._allow_shared_tablo_profile_visibility.sql | 22 +++ 8 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 6152593..4a12e77 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -18,6 +18,79 @@ 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, verifyTabloLimitForUser, async (c) => { const user = c.get("user"); @@ -274,6 +347,13 @@ 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", }); @@ -320,6 +400,77 @@ ${introEmail ? `

    ${introEmail}

    ` : ""} }); }); +const cancelPendingInvite = ( + middlewareManager: ReturnType +) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + 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("tablo_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); + } + + const { error: cancelError } = await supabase + .from("tablo_invites") + .update({ is_pending: false }) + .eq("id", inviteId) + .eq("tablo_id", tabloId); + + if (cancelError) { + return c.json({ error: cancelError.message }, 500); + } + + const { data: invitedProfile } = await supabase + .from("profiles") + .select("id, is_temporary") + .eq("email", invite.invited_email) + .maybeSingle(); + + // Temporary invitees are pre-added to tablo_access. Revoke this access when invite is cancelled. + if (invitedProfile?.id && invitedProfile.is_temporary) { + const { error: revokeAccessError } = await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", invitedProfile.id); + + if (revokeAccessError) { + return c.json({ error: revokeAccessError.message }, 500); + } + + try { + const channel = streamServerClient.channel("messaging", tabloId); + await channel.removeMembers([invitedProfile.id]); + } catch (error) { + console.error("error removing cancelled invitee from channel", error); + } + } + + return c.json({ message: "Invite cancelled successfully" }); + }); + const joinTablo = factory.createHandlers(async (c) => { const { token } = await c.req.json(); @@ -346,6 +497,13 @@ 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, @@ -359,22 +517,20 @@ const joinTablo = factory.createHandlers(async (c) => { if (tabloAccessError) { console.error("tabloAccessError", tabloAccessError); - // Check if it's a conflict error (user already has access) - if (tabloAccessError.code === "23505") { - return c.json({ error: "User already has access to this tablo" }, 409); + // If user already has access, continue to sync invite + chat membership. + if (tabloAccessError.code !== "23505") { + return c.json({ error: tabloAccessError.message }, 500); } - - return c.json({ error: tabloAccessError.message }, 500); } // Mark invite as accepted instead of deleting (maintains audit trail) await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id); try { - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.addMembers([joiner.id]); + 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 }); @@ -401,7 +557,7 @@ const getTabloMembers = factory.createHandlers(async (c) => { const { data, error } = await supabase .from("tablo_access") - .select("is_admin, profiles(id, name, email)") + .select("is_admin, profiles(id, name, email, avatar_url)") .eq("tablo_id", tablo_id) .eq("is_active", true); @@ -411,6 +567,7 @@ const getTabloMembers = factory.createHandlers(async (c) => { id: string; name: string; email: string; + avatar_url: string | null; }; }[]; @@ -423,6 +580,7 @@ const getTabloMembers = factory.createHandlers(async (c) => { ...member.profiles, is_admin: member.is_admin, email: member.profiles.email, + avatar_url: member.profiles.avatar_url, })), }); }); @@ -555,6 +713,7 @@ export const getTabloRouter = (config: AppConfig) => { tabloRouter.patch("/update", ...updateTablo(middlewareManager)); tabloRouter.delete("/delete", ...deleteTablo); tabloRouter.post("/invite/:tabloId", ...inviteToTablo(config, middlewareManager)); + tabloRouter.delete("/invite/:tabloId/:inviteId", ...cancelPendingInvite(middlewareManager)); tabloRouter.post("/join", ...joinTablo); tabloRouter.get("/members/:tablo_id", ...getTabloMembers); tabloRouter.post("/leave", ...leaveTablo); diff --git a/apps/main/src/components/TabloHeaderActions.tsx b/apps/main/src/components/TabloHeaderActions.tsx index 90d5c72..0160717 100644 --- a/apps/main/src/components/TabloHeaderActions.tsx +++ b/apps/main/src/components/TabloHeaderActions.tsx @@ -11,12 +11,12 @@ import { } from "@xtablo/ui/components/dialog"; import { Input } from "@xtablo/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover"; -import { Settings, Share2 } from "lucide-react"; +import { Loader2, Settings, Share2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ClickOutside } from "./ClickOutside"; import { ImageColorPicker } from "./ImageColorPicker"; import { useInviteUser } from "../hooks/invite"; -import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; import { useTabloMembers, useUpdateTablo } from "../hooks/tablos"; import { useUser } from "../providers/UserStoreProvider"; @@ -42,6 +42,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) // Fetch members and invites for share dialog const { data: members } = useTabloMembers(tablo?.id || ""); const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || ""); + const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); useEffect(() => { @@ -109,8 +110,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) {filteredMembers && filteredMembers.length > 0 && (
    {filteredMembers.slice(0, 3).map((member) => { - const isCurrentUser = member.id === currentUser.id; - const avatarUrl = isCurrentUser ? currentUser.avatar_url : null; + const avatarUrl = + member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null); return (
    +
    ))}
@@ -287,8 +302,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
{filteredMembers.map((member, index) => { - const isCurrentUser = member.id === currentUser.id; - const avatarUrl = isCurrentUser ? currentUser.avatar_url : null; + const avatarUrl = + member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null); return (
(En attente)
+
))}
diff --git a/apps/main/src/components/kanban/types.ts b/apps/main/src/components/kanban/types.ts index ca521ff..1d32465 100644 --- a/apps/main/src/components/kanban/types.ts +++ b/apps/main/src/components/kanban/types.ts @@ -2,5 +2,6 @@ export interface TabloMember { id: string; name: string; email: string; + avatar_url: string | null; is_admin: boolean; } diff --git a/apps/main/src/hooks/tablo_invites.ts b/apps/main/src/hooks/tablo_invites.ts index 6445c8c..c6d26bf 100644 --- a/apps/main/src/hooks/tablo_invites.ts +++ b/apps/main/src/hooks/tablo_invites.ts @@ -1,7 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@xtablo/shared"; import { Database } from "@xtablo/shared/types/database.types"; import { supabase } from "../lib/supabase"; import { useUser } from "../providers/UserStoreProvider"; +import { useAuthedApi } from "./auth"; type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"]; @@ -49,3 +51,37 @@ export const usePendingTabloInvitesByTablo = (tabloId: string) => { enabled: !!user.id && !!tabloId, }); }; + +export const useCancelTabloInvite = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => { + await api.delete(`/api/v1/tablos/invite/${tabloId}/${inviteId}`); + }, + onSuccess: (_data, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["tablo-invites", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["tablo-members", tabloId] }); + toast.add( + { + title: "Invitation retirée", + description: "L'invitation en attente a été supprimée", + type: "success", + }, + { timeout: 3000 } + ); + }, + onError: (error) => { + console.error("Error cancelling invite:", error); + toast.add( + { + title: "Erreur", + description: "Impossible de retirer l'invitation", + type: "error", + }, + { timeout: 5000 } + ); + }, + }); +}; diff --git a/apps/main/src/hooks/tablos.ts b/apps/main/src/hooks/tablos.ts index a359b07..d2c1eaf 100644 --- a/apps/main/src/hooks/tablos.ts +++ b/apps/main/src/hooks/tablos.ts @@ -52,7 +52,13 @@ export const useTabloMembers = (tabloId: string) => { queryKey: ["tablo-members", tabloId], queryFn: async () => { const { data } = await api.get<{ - members: { id: string; name: string; is_admin: boolean; email: string }[]; + members: { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; + }[]; }>(`/api/v1/tablos/members/${tabloId}`); return data.members; }, diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 3b22ceb..7a4183c 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -57,10 +57,14 @@ import { TabloFilesSection } from "../components/TabloFilesSection"; import { TabloTasksSection } from "../components/TabloTasksSection"; import { useInviteUser } from "../hooks/invite"; import { useTabloFileNames } from "../hooks/tablo_data"; -import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { + useCancelTabloInvite, + usePendingTabloInvitesByTablo, +} from "../hooks/tablo_invites"; import { useTabloMembers, useTablosList } from "../hooks/tablos"; import { useAllTasks, + useCreateEtape, useCreateTask, useTabloEtapes, useUpdateTask, @@ -181,6 +185,8 @@ export const TabloDetailsPage = () => { const currentUser = useUser(); const { data: members } = useTabloMembers(tabloId ?? ""); const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? ""); + const { mutate: cancelInvite, isPending: isCancellingInvite } = + useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); const isEmailValid = (email: string): boolean => { @@ -580,6 +586,7 @@ export const TabloDetailsPage = () => { etapes={etapes} tabloTasks={tabloTasks} tabloId={tabloId ?? ""} + isAdmin={isAdmin} /> )} @@ -670,6 +677,20 @@ export const TabloDetailsPage = () => { {invite.invited_email} + ))} @@ -684,10 +705,11 @@ export const TabloDetailsPage = () => {
{filteredMembers.map((member) => { - const isCurrentUser = member.id === currentUser.id; - const avatarUrl = isCurrentUser - ? currentUser.avatar_url - : null; + const avatarUrl = + member.avatar_url ?? + (member.id === currentUser.id + ? currentUser.avatar_url + : null); return (
>( new Set(etapes.map((e) => e.id)), @@ -739,8 +763,11 @@ function EtapesSection({ 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) => { @@ -766,6 +793,24 @@ function EtapesSection({ 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", @@ -788,23 +833,43 @@ function EtapesSection({ }, }; - if (etapes.length === 0) { - return ( -
- -

- Aucune étape -

-

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

-
- ); - } - return (
- {etapes.map((etape, index) => { + {isAdmin && ( +
+ setNewEtapeTitle(event.target.value)} + placeholder="Nom de la nouvelle étape..." + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleAddEtape(); + } + }} + className="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, ); @@ -1030,7 +1095,8 @@ function EtapesSection({ )}
); - })} + }) + )}
); } diff --git a/supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql b/supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql new file mode 100644 index 0000000..000a9e1 --- /dev/null +++ b/supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql @@ -0,0 +1,22 @@ +-- Allow users to read profiles of collaborators that share at least one active tablo. +-- This unblocks assignee avatars/names in tasks_with_assignee while keeping profile +-- visibility scoped to collaboration relationships. + +DROP POLICY IF EXISTS "Users can view shared tablo member profiles" ON public.profiles; + +CREATE POLICY "Users can view shared tablo member profiles" + ON public.profiles + FOR SELECT + TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.tablo_access viewer_access + JOIN public.tablo_access member_access + ON member_access.tablo_id = viewer_access.tablo_id + WHERE viewer_access.user_id = auth.uid() + AND viewer_access.is_active = TRUE + AND member_access.user_id = profiles.id + AND member_access.is_active = TRUE + ) + );