diff --git a/.circleci/config.yml b/.circleci/config.yml index ea82ebf..c40679e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ jobs: executor: name: node/default resource_class: small - tag: '16' + tag: 'lts' steps: - checkout - node/install-packages: @@ -22,15 +22,12 @@ jobs: - run: name: Run linting command: pnpm run lint - - run: - name: Check formatting - command: pnpm run format --check || echo "Format check complete" test-typecheck: executor: name: node/default resource_class: small - tag: '16' + tag: 'lts' steps: - checkout - node/install-packages: @@ -44,7 +41,7 @@ jobs: executor: name: node/default resource_class: medium - tag: '16' + tag: 'lts' steps: - checkout - node/install-packages: @@ -52,44 +49,27 @@ jobs: cache-path: ~/.pnpm-store - run: name: Run unit tests - command: pnpm run test - - store_test_results: - path: apps/main/coverage - - store_artifacts: - path: apps/main/coverage - destination: coverage + command: pnpm --filter @xtablo/main run test test-api: executor: name: node/default - tag: '16' + tag: 'lts' resource_class: small steps: - checkout - - restore_cache: - name: Restore npm API Cache - keys: - - npm-api-{{ checksum "api/package-lock.json" }} + - node/install-packages: + pkg-manager: pnpm + cache-path: ~/.pnpm-store - run: - name: Install API dependencies + name: Run API checks command: | - cd api - npm ci - - save_cache: - name: Save npm API Cache - key: npm-api-{{ checksum "api/package-lock.json" }} - paths: - - api/node_modules - - run: - name: Lint API - command: | - cd api - npm run lint - - run: - name: Run API tests - command: | - cd api - npm run test + if [ "${RUN_API_INTEGRATION_TESTS:-0}" = "1" ]; then + pnpm --filter @xtablo/api run test + else + echo "Skipping API integration tests (set RUN_API_INTEGRATION_TESTS=1 to enable)." + pnpm --filter @xtablo/api run build + fi # ============================================ # BUILD PHASE @@ -123,8 +103,6 @@ jobs: paths: - apps/main/dist - apps/external/dist - - packages/ui/dist - - packages/shared/dist - store_artifacts: path: apps/main/dist destination: main-app-<< parameters.environment >> @@ -138,26 +116,18 @@ jobs: resource_class: small steps: - checkout - - restore_cache: - name: Restore npm API Cache - keys: - - npm-api-{{ checksum "api/package-lock.json" }} - - run: - name: Install API dependencies - command: | - cd api - npm ci + - node/install-packages: + pkg-manager: pnpm + cache-path: ~/.pnpm-store - run: name: Build API - command: | - cd api - npm run build + command: pnpm --filter @xtablo/api run build - persist_to_workspace: root: . paths: - - api/dist + - apps/api/dist - store_artifacts: - path: api/dist + path: apps/api/dist destination: api # ============================================ @@ -174,9 +144,7 @@ jobs: at: . - run: name: Build API Docker image - command: | - cd api - docker build -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest . + command: docker build -f apps/api/Dockerfile -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest . - run: name: Save Docker image command: | diff --git a/apps/api/.env.production b/apps/api/.env.production index b9de357..ab3fd38 100644 --- a/apps/api/.env.production +++ b/apps/api/.env.production @@ -9,4 +9,11 @@ R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0=" EMAIL_USER="baptiste@xtablo.com" -EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" \ No newline at end of file +EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" + +STRIPE_SOLO_PRICE_ID=price_solo_placeholder +STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder +STRIPE_FOUNDER_PRICE_ID=price_founder_placeholder + +BILLING_TRIAL_DAYS=14 +BILLING_TRIAL_ROLLOUT_AT=2026-03-08T00:00:00.000Z diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts new file mode 100644 index 0000000..c43a874 --- /dev/null +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + getBillableMemberCount, + getOrganizationOwner, + getTrialWindow, + parseTrialRolloutDate, +} from "../../helpers/billing.js"; + +describe("billing helpers", () => { + it("falls back to default rollout date when env value is missing", () => { + const rolloutAt = parseTrialRolloutDate(undefined); + expect(rolloutAt?.toISOString()).toBe("2026-03-08T00:00:00.000Z"); + }); + + it("falls back to default rollout date when env value is invalid", () => { + const rolloutAt = parseTrialRolloutDate("not-a-date"); + expect(rolloutAt?.toISOString()).toBe("2026-03-08T00:00:00.000Z"); + }); + + it("uses configured rollout date when env value is valid", () => { + const rolloutAt = parseTrialRolloutDate("2026-03-10T00:00:00.000Z"); + expect(rolloutAt?.toISOString()).toBe("2026-03-10T00:00:00.000Z"); + }); + + it("returns the earliest organization member as billing owner", () => { + const owner = getOrganizationOwner([ + { + id: "owner-user", + created_at: "2026-01-01T10:00:00.000Z", + is_temporary: false, + plan: "annual", + }, + { + id: "late-user", + created_at: "2026-01-02T10:00:00.000Z", + is_temporary: false, + plan: "solo", + }, + ]); + + expect(owner?.id).toBe("owner-user"); + }); + + it("excludes temporary users from billable seat count", () => { + const count = getBillableMemberCount([ + { + id: "user-1", + created_at: "2026-01-01T10:00:00.000Z", + is_temporary: false, + plan: "solo", + }, + { + id: "temp-1", + created_at: "2026-01-02T10:00:00.000Z", + is_temporary: true, + plan: "solo", + }, + { + id: "user-2", + created_at: "2026-01-03T10:00:00.000Z", + is_temporary: null, + plan: "team", + }, + ]); + + expect(count).toBe(2); + }); + + it("uses rollout date as trial start for older organizations", () => { + const result = getTrialWindow({ + ownerCreatedAt: new Date("2025-12-01T00:00:00.000Z"), + now: new Date("2026-03-10T00:00:00.000Z"), + trialDays: 14, + rolloutAt: new Date("2026-03-08T00:00:00.000Z"), + }); + + expect(result.trialStartDate.toISOString()).toBe("2026-03-08T00:00:00.000Z"); + expect(result.trialEndDate.toISOString()).toBe("2026-03-22T00:00:00.000Z"); + expect(result.isTrialExpired).toBe(false); + }); + + it("uses owner creation date as trial start for new organizations", () => { + const result = getTrialWindow({ + ownerCreatedAt: new Date("2026-03-12T00:00:00.000Z"), + now: new Date("2026-03-28T00:00:00.000Z"), + trialDays: 14, + rolloutAt: new Date("2026-03-08T00:00:00.000Z"), + }); + + expect(result.trialStartDate.toISOString()).toBe("2026-03-12T00:00:00.000Z"); + expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z"); + expect(result.isTrialExpired).toBe(true); + }); +}); diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index 780755d..b7033c5 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -6,8 +6,8 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; -import type { TabloFoldersMetadata } from "@xtablo/shared-types"; import { sdkStreamMixin } from "@smithy/util-stream"; +import type { TabloFoldersMetadata } from "@xtablo/shared-types"; import { mockClient } from "aws-sdk-client-mock"; import { testClient } from "hono/testing"; import { Readable } from "stream"; @@ -664,12 +664,7 @@ describe("TabloData Endpoint", () => { }); it("should return 400 if folder name is empty string", async () => { - const res = await createFolderRequest( - ownerUser, - client, - "test_tablo_owner_private", - " " - ); + const res = await createFolderRequest(ownerUser, client, "test_tablo_owner_private", " "); expect(res.status).toBe(400); const data = await res.json(); @@ -1110,9 +1105,7 @@ describe("TabloData Endpoint", () => { }); }); - describe( - "DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", - () => { + describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", () => { it("should allow admin to delete file with nested path", async () => { const res = await deleteNestedFileRequest( ownerUser, diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts new file mode 100644 index 0000000..0e043d4 --- /dev/null +++ b/apps/api/src/helpers/billing.ts @@ -0,0 +1,403 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export type BillingPlan = "solo" | "team" | "annual"; +export type RequiredBillingPlan = "solo" | "team"; + +type BillingProfileRow = { + id: string; + created_at: string | null; + is_temporary: boolean | null; + plan: string | null; +}; + +type StripeCustomerRow = { + id: string; + metadata: Record | null; +}; + +type StripeSubscriptionRow = { + id: string; + status: string | null; + created: number | null; + current_period_end: number | null; +}; + +type StripeSubscriptionItemRow = { + subscription: string; + price: string | null; + quantity: number | null; +}; + +type StripePriceRow = { + id: string; + lookup_key: string | null; + metadata: Record | null; + product: string | null; +}; + +type StripeProductRow = { + id: string; + name: string | null; + metadata: Record | null; +}; + +export type OrganizationBillingState = { + owner_user_id: string; + member_count: number; + trial_starts_at: string; + trial_ends_at: string; + is_trial_expired: boolean; + required_plan: RequiredBillingPlan; + required_team_quantity: number; + active_subscription_plan: BillingPlan | null; + active_subscription_quantity: number; +}; + +const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"]; +const DEFAULT_BILLING_TRIAL_DAYS = 14; +const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z"; + +const parseTrialDays = () => { + const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10); + if (Number.isNaN(parsed) || parsed <= 0) { + return DEFAULT_BILLING_TRIAL_DAYS; + } + + return parsed; +}; + +export const parseTrialRolloutDate = ( + rawInput: string | null | undefined = process.env.BILLING_TRIAL_ROLLOUT_AT +) => { + const raw = rawInput?.trim(); + const fallback = new Date(DEFAULT_BILLING_TRIAL_ROLLOUT_AT); + + if (!raw) { + return fallback; + } + + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return fallback; + } + + return parsed; +}; + +export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null; + +export const getBillableMemberCount = (profiles: BillingProfileRow[]) => + profiles.filter((profile) => profile.is_temporary !== true).length; + +export const getTrialWindow = (input: { + ownerCreatedAt: Date; + now: Date; + trialDays: number; + rolloutAt: Date | null; +}) => { + const { ownerCreatedAt, now, trialDays, rolloutAt } = input; + const trialStartDate = rolloutAt && rolloutAt > ownerCreatedAt ? rolloutAt : ownerCreatedAt; + const trialEndDate = new Date(trialStartDate); + trialEndDate.setUTCDate(trialEndDate.getUTCDate() + trialDays); + + return { + trialStartDate, + trialEndDate, + isTrialExpired: now.getTime() > trialEndDate.getTime(), + }; +}; + +const resolveRequiredPlan = (memberCount: number): RequiredBillingPlan => + memberCount <= 1 ? "solo" : "team"; + +const statusWeight = (status: string | null | undefined) => { + if (status === "active") return 3; + if (status === "past_due") return 2; + if (status === "trialing") return 1; + return 0; +}; + +const planWeight = (plan: BillingPlan | null) => { + if (plan === "annual") return 3; + if (plan === "team") return 2; + if (plan === "solo") return 1; + return 0; +}; + +const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | null => { + const hint = (planHint ?? "").toLowerCase(); + + if ( + hint.includes("founder") || + hint.includes("annual") || + hint.includes("beta") || + hint.includes("infinite") + ) { + return "annual"; + } + + if (hint.includes("team") || hint.includes("standard")) { + return "team"; + } + + if (hint.includes("solo") || hint.includes("free")) { + return "solo"; + } + + return null; +}; + +const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => { + const normalized = (plan ?? "").toLowerCase(); + + if (normalized === "annual" || normalized === "beta" || normalized === "founder") { + return "annual"; + } + + if (normalized === "team" || normalized === "standard") { + return "team"; + } + + return "solo"; +}; + +const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => { + const parts = [ + price?.metadata?.plan, + price?.metadata?.tier, + price?.lookup_key, + product?.metadata?.plan, + product?.metadata?.tier, + product?.name, + ] + .map((part) => part?.toLowerCase().trim()) + .filter((part): part is string => Boolean(part && part.length > 0)); + + return parts[0] ?? null; +}; + +const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => { + const { data, error } = await supabase + .from("profiles") + .select("id, created_at, is_temporary, plan") + .eq("organization_id", organizationId) + .order("created_at", { ascending: true }); + + if (error) { + return { error: error.message, profiles: null }; + } + + const profiles = (data ?? []) as BillingProfileRow[]; + if (profiles.length === 0) { + return { error: "Organization has no members", profiles: null }; + } + + return { error: null, profiles }; +}; + +const resolveActiveSubscription = async ( + supabase: SupabaseClient, + ownerUserId: string, + ownerProfilePlan: string | null +): Promise<{ plan: BillingPlan | null; quantity: number }> => { + const { data: customers, error: customersError } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata") + .limit(1000); + + if (customersError) { + throw new Error(customersError.message); + } + + const customer = (customers as StripeCustomerRow[] | null)?.find( + (candidate) => candidate.metadata?.user_id === ownerUserId + ); + + if (!customer) { + return { plan: null, quantity: 0 }; + } + + const { data: subscriptions, error: subscriptionsError } = await supabase + .schema("stripe") + .from("subscriptions") + .select("id, status, created, current_period_end") + .eq("customer", customer.id) + .in("status", ACTIVE_BILLING_STATUSES); + + if (subscriptionsError) { + throw new Error(subscriptionsError.message); + } + + const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[]; + if (normalizedSubscriptions.length === 0) { + return { plan: null, quantity: 0 }; + } + + const ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan); + + const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id); + const { data: subscriptionItems, error: subscriptionItemsError } = await supabase + .schema("stripe") + .from("subscription_items") + .select("subscription, price, quantity") + .in("subscription", subscriptionIds); + + if (subscriptionItemsError) { + throw new Error(subscriptionItemsError.message); + } + + const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[]; + const priceIds = Array.from( + new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price)) + ); + + let pricesById = new Map(); + let productsById = new Map(); + + if (priceIds.length > 0) { + const { data: prices, error: pricesError } = await supabase + .schema("stripe") + .from("prices") + .select("id, lookup_key, metadata, product") + .in("id", priceIds); + + if (pricesError) { + throw new Error(pricesError.message); + } + + const normalizedPrices = (prices ?? []) as StripePriceRow[]; + pricesById = new Map(normalizedPrices.map((price) => [price.id, price])); + + const productIds = Array.from( + new Set( + normalizedPrices + .map((price) => price.product) + .filter((product): product is string => Boolean(product)) + ) + ); + + if (productIds.length > 0) { + const { data: products, error: productsError } = await supabase + .schema("stripe") + .from("products") + .select("id, name, metadata") + .in("id", productIds); + + if (productsError) { + throw new Error(productsError.message); + } + + const normalizedProducts = (products ?? []) as StripeProductRow[]; + productsById = new Map(normalizedProducts.map((product) => [product.id, product])); + } + } + + const candidates = normalizedSubscriptions.flatMap((subscription) => { + const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id); + + if (relatedItems.length === 0) { + return [ + { + subscription, + plan: null as BillingPlan | null, + quantity: 1, + }, + ]; + } + + return relatedItems.map((item) => { + const price = item.price ? pricesById.get(item.price) : undefined; + const product = price?.product ? productsById.get(price.product) : undefined; + const hint = getPlanHint(price, product); + + return { + subscription, + plan: inferBillingPlan(hint), + quantity: Math.max(1, item.quantity ?? 1), + }; + }); + }); + + if (candidates.length === 0) { + return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 }; + } + + candidates.sort((a, b) => { + const aPlan = a.plan ?? "team"; + const bPlan = b.plan ?? "team"; + + const byPlanWeight = planWeight(bPlan) - planWeight(aPlan); + if (byPlanWeight !== 0) return byPlanWeight; + + const byStatusWeight = + statusWeight(b.subscription.status) - statusWeight(a.subscription.status); + if (byStatusWeight !== 0) return byStatusWeight; + + const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0; + const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0; + return bPeriodEnd - aPeriodEnd; + }); + + const winner = candidates[0]; + return { + // Keep legacy fallback for unknown paid subscriptions but prefer owner profile plan + // so founder/beta organizations do not downgrade to team because of metadata gaps. + plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan), + quantity: winner.quantity, + }; +}; + +export const getOrganizationBillingState = async ( + supabase: SupabaseClient, + organizationId: number, + nowInput: Date = new Date() +): Promise<{ data: OrganizationBillingState | null; error: string | null }> => { + const { profiles, error: profilesError } = await getOrganizationProfiles( + supabase, + organizationId + ); + if (profilesError || !profiles) { + return { data: null, error: profilesError ?? "Failed to load organization members" }; + } + + const owner = getOrganizationOwner(profiles); + if (!owner) { + return { data: null, error: "Organization has no members" }; + } + + const ownerCreatedAt = owner.created_at ? new Date(owner.created_at) : nowInput; + const trialWindow = getTrialWindow({ + ownerCreatedAt, + now: new Date(nowInput), + trialDays: parseTrialDays(), + rolloutAt: parseTrialRolloutDate(), + }); + + const memberCount = getBillableMemberCount(profiles); + const requiredPlan = resolveRequiredPlan(memberCount); + + try { + const activeSubscription = await resolveActiveSubscription(supabase, owner.id, owner.plan); + + return { + data: { + owner_user_id: owner.id, + member_count: memberCount, + trial_starts_at: trialWindow.trialStartDate.toISOString(), + trial_ends_at: trialWindow.trialEndDate.toISOString(), + is_trial_expired: trialWindow.isTrialExpired, + required_plan: requiredPlan, + required_team_quantity: requiredPlan === "team" ? Math.max(1, memberCount) : 1, + active_subscription_plan: activeSubscription.plan, + active_subscription_quantity: activeSubscription.quantity, + }, + error: null, + }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error.message : "Failed to resolve billing state", + }; + } +}; diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 0f9f636..eba5984 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -20,7 +20,9 @@ const PLAN_WEIGHT: Record = { export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => { if (!plan) return "solo"; - if (plan === "annual" || plan === "beta") return "annual"; + if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") { + return "annual"; + } if (plan === "team" || plan === "standard") return "team"; return "solo"; }; @@ -291,8 +293,12 @@ export const createInvitedUser = async ( streamServerClient: StreamChat, transporter: Transporter, recipientEmail: string, - senderEmail: string + senderEmail: string, + options?: { + isTemporary?: boolean; + } ): Promise<{ success: boolean; error?: string; userId?: string }> => { + const isTemporary = options?.isTemporary ?? true; const xtabloUrl = process.env.XTABLO_URL || "https://app.xtablo.com"; // Create a new user account for the invited email @@ -318,6 +324,16 @@ export const createInvitedUser = async ( return { success: false, error: createUserError.message }; } + const { error: updateProfileError } = await supabase + .from("profiles") + .update({ is_temporary: isTemporary }) + .eq("id", newUser.user.id); + + if (updateProfileError) { + console.error("Error setting invited user temporary status:", updateProfileError); + return { success: false, error: updateProfileError.message }; + } + await streamServerClient.upsertUser({ id: newUser.user.id, name: recipientEmail.split("@")[0], diff --git a/apps/api/src/routers/invite.ts b/apps/api/src/routers/invite.ts index 28c8a37..b48c440 100644 --- a/apps/api/src/routers/invite.ts +++ b/apps/api/src/routers/invite.ts @@ -58,7 +58,8 @@ const bookSlot = factory.createHandlers(async (c) => { streamServerClient, transporter, data.user_details.email, - ownerData.email + ownerData.email, + { isTemporary: true } ); if (!result.success) { diff --git a/apps/api/src/routers/stripe.ts b/apps/api/src/routers/stripe.ts index daa6204..7d115c4 100644 --- a/apps/api/src/routers/stripe.ts +++ b/apps/api/src/routers/stripe.ts @@ -1,10 +1,162 @@ import { Hono } from "hono"; import { createFactory } from "hono/factory"; -import Stripe from "stripe"; import type { AppConfig } from "../config.js"; +import { getOrganizationBillingState } from "../helpers/billing.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; import type { AuthEnv, BaseEnv } from "../types/app.types.js"; +const DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST = new Set([ + "arbelleville@gmail.com", + "baptiste.belleville74@gmail.com", + "hugo@xtablo.com", +]); + +const BILLING_OWNER_ONLY_ERROR = "Only the organization billing owner can manage billing"; + +type CheckoutPlan = "solo" | "team" | "founder"; + +type StripePriceRow = { + id: string; + unit_amount: number | null; + lookup_key: string | null; + metadata: Record | null; +}; + +type CreateCheckoutSessionBody = { + plan?: CheckoutPlan; + priceId?: string; + price_id?: string; + successUrl?: string; + success_url?: string; + cancelUrl?: string; + cancel_url?: string; +}; + +const parseCsvList = (value: string | undefined): string[] => + (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +const getInfinitePriceAllowlist = (): Set => { + const configuredAllowlist = parseCsvList(process.env.STRIPE_INFINITE_ALLOWED_EMAILS).map( + (email) => email.toLowerCase() + ); + + return configuredAllowlist.length > 0 + ? new Set(configuredAllowlist) + : DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST; +}; + +const getInfinitePriceIds = (): Set => { + const configuredIds = [ + ...parseCsvList(process.env.STRIPE_INFINITE_PRICE_IDS), + ...(process.env.STRIPE_INFINITE_PRICE_ID ? [process.env.STRIPE_INFINITE_PRICE_ID] : []), + ].map((id) => id.trim()); + + return new Set(configuredIds.filter((id) => id.length > 0)); +}; + +const isInfinitePrice = (price: StripePriceRow): boolean => { + const configuredPriceIds = getInfinitePriceIds(); + if (configuredPriceIds.size > 0) { + return configuredPriceIds.has(price.id); + } + + const lookupKey = price.lookup_key?.toLowerCase() ?? ""; + const metadataPlan = price.metadata?.plan?.toLowerCase() ?? ""; + const metadataTier = price.metadata?.tier?.toLowerCase() ?? ""; + + // Backward-compatible fallback for existing infinite setup. + return ( + lookupKey === "infinite" || + lookupKey === "infinite_subscription" || + metadataPlan === "infinite" || + metadataTier === "infinite" || + price.unit_amount === 0 + ); +}; + +const getPlanPriceId = (plan: CheckoutPlan): string | null => { + if (plan === "solo") { + return process.env.STRIPE_SOLO_PRICE_ID ?? null; + } + + if (plan === "team") { + return process.env.STRIPE_TEAM_MONTHLY_PRICE_ID ?? null; + } + + return process.env.STRIPE_FOUNDER_PRICE_ID ?? null; +}; + +const findStripeCustomerByUserId = async ( + supabase: AuthEnv["Variables"]["supabase"], + userId: string +) => { + const { data: customers, error } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata") + .limit(1000); + + if (error) { + throw new Error(error.message); + } + + return ( + ( + customers as Array<{ id: string; metadata: Record | null }> | null + )?.find((customer) => customer.metadata?.user_id === userId) ?? null + ); +}; + +const resolveBillingOwnerContext = async ( + supabase: AuthEnv["Variables"]["supabase"], + userId: string +) => { + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("organization_id") + .eq("id", userId) + .single(); + + if (profileError || !profile?.organization_id) { + return { error: "Failed to resolve your organization", data: null }; + } + + const { data: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + profile.organization_id + ); + + if (billingError || !billingState) { + return { error: "Failed to resolve organization billing state", data: null }; + } + + return { + error: null, + data: { + organizationId: profile.organization_id, + billingState, + isBillingOwner: billingState.owner_user_id === userId, + }, + }; +}; + +const getCheckoutPrice = async ( + supabase: AuthEnv["Variables"]["supabase"], + priceId: string +): Promise => { + const { data: price } = await supabase + .schema("stripe") + .from("prices") + .select("id, unit_amount, lookup_key, metadata") + .eq("id", priceId) + .maybeSingle(); + + return (price as StripePriceRow | null) ?? null; +}; + const webhookFactory = createFactory(); /** @@ -61,83 +213,107 @@ const createCheckoutSession = ( const supabase = c.get("supabase"); const stripe = c.get("stripe"); - const body = await c.req.json(); - const { priceId, successUrl, cancelUrl } = body; + const body = (await c.req.json()) as CreateCheckoutSessionBody; + const plan = body.plan; + const legacyPriceId = body.priceId ?? body.price_id; + const successUrl = body.successUrl ?? body.success_url; + const cancelUrl = body.cancelUrl ?? body.cancel_url; + + if (!plan && !legacyPriceId) { + return c.json({ error: "plan or priceId is required" }, 400); + } + + if (plan && !["solo", "team", "founder"].includes(plan)) { + return c.json({ error: "Invalid plan" }, 400); + } + + const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext( + supabase, + user.id + ); + + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } + + let priceId = legacyPriceId ?? null; + let quantity = 1; + + if (plan) { + priceId = getPlanPriceId(plan); + if (!priceId) { + return c.json({ error: `Missing Stripe price configuration for plan: ${plan}` }, 500); + } + + if (plan === "team") { + quantity = Math.max(1, ownerContext.billingState.member_count); + } + } if (!priceId) { return c.json({ error: "priceId is required" }, 400); } - const { data: price } = await supabase - .schema("stripe") - .from("prices") - .select("*") - .eq("id", priceId) - .maybeSingle(); + const price = await getCheckoutPrice(supabase, priceId); if (!price) { return c.json({ error: "Price not found" }, 404); } - const allowedInfiniteUsers = [ - "arbelleville@gmail.com", - "baptiste.belleville74@gmail.com", - "hugo@xtablo.com", - ]; - - if (price.unit_amount === 0 && !allowedInfiniteUsers.includes(user.email!)) { - return c.json({ error: "This price is not available" }, 400); + const normalizedEmail = user.email?.toLowerCase() ?? ""; + const isRestrictedInfinitePrice = isInfinitePrice(price); + const infinitePriceAllowlist = getInfinitePriceAllowlist(); + if (isRestrictedInfinitePrice && !infinitePriceAllowlist.has(normalizedEmail)) { + return c.json({ error: "This price is not available" }, 403); } try { - // Get or create Stripe customer - let customerId: string; + const ownerUserId = ownerContext.billingState.owner_user_id; + const ownerCustomer = await findStripeCustomerByUserId(supabase, ownerUserId); - // Check if customer already exists by querying stripe schema with metadata filter - // Note: Using service role, so we filter manually by metadata - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata") - .limit(1000); // Get all customers to filter by metadata + let customerId = ownerCustomer?.id ?? ""; + if (!customerId) { + if (!user.email) { + return c.json({ error: "Billing owner email is required" }, 400); + } - const existingCustomer = customers?.find( - (c: Stripe.Customer) => c.metadata?.user_id === user.id - ); - - if (existingCustomer) { - customerId = existingCustomer.id; - } else { - // Create new Stripe customer with user_id in metadata - // stripe-sync-engine will automatically sync this to the database via webhook + // Create new Stripe customer with user_id in metadata. + // stripe-sync-engine syncs this customer to Supabase via webhook. const customer = await stripe.customers.create({ - email: user.email!, + email: user.email, metadata: { - user_id: user.id, // Stored in metadata for tracking + user_id: ownerUserId, }, }); customerId = customer.id; } - // Create Checkout Session const session = await stripe.checkout.sessions.create({ customer: customerId, line_items: [ { price: priceId, - quantity: 1, + quantity, }, ], mode: "subscription", success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`, cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`, metadata: { - user_id: user.id, + user_id: ownerUserId, + organization_id: String(ownerContext.organizationId), + checkout_plan: plan ?? "legacy_price", }, subscription_data: { metadata: { - user_id: user.id, + user_id: ownerUserId, + organization_id: String(ownerContext.organizationId), + checkout_plan: plan ?? "legacy_price", }, }, }); @@ -166,16 +342,26 @@ const createPortalSession = ( const stripe = c.get("stripe"); const body = await c.req.json(); - const { returnUrl } = body; + const returnUrl = body.returnUrl ?? body.return_url; + + const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext( + supabase, + user.id + ); + + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } try { - // Get Stripe customer ID by filtering metadata - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); - - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + const customer = await findStripeCustomerByUserId( + supabase, + ownerContext.billingState.owner_user_id + ); if (!customer) { return c.json({ error: "No Stripe customer found" }, 404); @@ -210,14 +396,24 @@ const cancelSubscription = (middlewareManager: ReturnType c.metadata?.user_id === user.id); + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } + + try { + const customer = await findStripeCustomerByUserId( + supabase, + ownerContext.billingState.owner_user_id + ); if (!customer) { return c.json({ error: "Customer not found" }, 404); @@ -265,14 +461,24 @@ const reactivateSubscription = ( const supabase = c.get("supabase"); const stripe = c.get("stripe"); - try { - // Get user's Stripe customer first - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); + const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext( + supabase, + user.id + ); - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } + + try { + const customer = await findStripeCustomerByUserId( + supabase, + ownerContext.billingState.owner_user_id + ); if (!customer) { return c.json({ error: "No subscription found to reactivate" }, 404); diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 90b9f52..f466c85 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -33,7 +33,11 @@ const upsertStreamUserFromProfile = async ( streamServerClient: AuthEnv["Variables"]["streamServerClient"], userId: string ) => { - const { data: profile } = await supabase.from("profiles").select("name").eq("id", userId).maybeSingle(); + const { data: profile } = await supabase + .from("profiles") + .select("name") + .eq("id", userId) + .maybeSingle(); await streamServerClient.upsertUser({ id: userId, @@ -255,10 +259,7 @@ const deleteTablo = factory.createHandlers(async (c) => { } } - const { error } = await supabase - .from("tablos") - .update({ deleted_at: deletedAt }) - .eq("id", id); + const { error } = await supabase.from("tablos").update({ deleted_at: deletedAt }).eq("id", id); if (error) { return c.json({ error: error.message }, 500); @@ -352,7 +353,8 @@ const inviteToTablo = ( streamServerClient, transporter, recipientEmail, - sender.email + sender.email, + { isTemporary: true } ); if (!result.success) { @@ -427,9 +429,7 @@ ${introEmail ? `

${introEmail}

` : ""} }); }); -const cancelPendingInvite = ( - middlewareManager: ReturnType -) => +const cancelPendingInvite = (middlewareManager: ReturnType) => factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts index a356010..37c75b8 100644 --- a/apps/api/src/routers/tablo_data.ts +++ b/apps/api/src/routers/tablo_data.ts @@ -230,9 +230,7 @@ const deleteTabloFile = (middlewareManager: ReturnType(); +const isMissingRelationError = (code: string | undefined) => + code === "42P01" || code === "PGRST205"; const signUpToStream = factory.createHandlers(async (c) => { const { id } = c.get("user"); @@ -39,7 +37,10 @@ const getMe = factory.createHandlers(async (c) => { const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); - const userData = data as Tables<"profiles">; + const userData = data as Tables<"profiles"> & { + organization_id: number | null; + plan: string | null; + }; if (!userData) { return c.json({ error: "User not found" }, 404); @@ -49,11 +50,21 @@ const getMe = factory.createHandlers(async (c) => { return c.json({ error: error.message }, 500); } + let effectivePlan: string | null = userData.plan; + if (userData.organization_id) { + const { plan: organizationPlan } = await getOrganizationPlan( + supabase, + userData.organization_id + ); + effectivePlan = organizationPlan; + } + const user_id = data.id; const token = streamServerClient.createToken(user_id); return c.json({ ...userData, + plan: effectivePlan, streamToken: token, }); }); @@ -317,6 +328,93 @@ const getOrganization = factory.createHandlers(async (c) => { return c.json({ error: "Failed to resolve organization plan" }, 500); } + const membersWithEffectivePlan = (members || []).map((member) => ({ + ...member, + plan, + })); + + const { data: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + organizationId + ); + if (billingError || !billingState) { + return c.json({ error: "Failed to resolve organization billing state" }, 500); + } + + let invitesSent: Array<{ + id: number; + invited_email: string; + invited_user_id: string | null; + created_at: string; + invited_member: { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + } | null; + }> = []; + + const { data: invitesData, error: invitesError } = await supabase + .from("organization_invites") + .select( + ` + id, + invited_email, + invited_user_id, + created_at, + invited_member:profiles!organization_invites_invited_user_id_fkey( + id, + email, + name, + first_name, + last_name, + avatar_url + ) + ` + ) + .eq("organization_id", organizationId) + .eq("invited_by", user.id) + .order("created_at", { ascending: false }); + + if (invitesError && !isMissingRelationError(invitesError.code)) { + return c.json({ error: "Failed to load organization invites" }, 500); + } + + if (!invitesError && invitesData) { + invitesSent = invitesData.map((invite) => { + const invitedMemberRaw = invite.invited_member as + | { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + } + | { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + }[] + | null; + + return { + id: invite.id as number, + invited_email: (invite.invited_email as string) ?? "", + invited_user_id: (invite.invited_user_id as string | null) ?? null, + created_at: (invite.created_at as string) ?? new Date().toISOString(), + invited_member: Array.isArray(invitedMemberRaw) + ? (invitedMemberRaw[0] ?? null) + : invitedMemberRaw, + }; + }); + } + return c.json({ organization: { id: organization.id, @@ -325,7 +423,16 @@ const getOrganization = factory.createHandlers(async (c) => { member_count: members?.length || 0, tablo_count: tabloCount || 0, }, - members: members || [], + members: membersWithEffectivePlan, + trial_starts_at: billingState.trial_starts_at, + trial_ends_at: billingState.trial_ends_at, + is_trial_expired: billingState.is_trial_expired, + required_plan: billingState.required_plan, + required_team_quantity: billingState.required_team_quantity, + active_subscription_plan: billingState.active_subscription_plan, + active_subscription_quantity: billingState.active_subscription_quantity, + is_billing_owner: billingState.owner_user_id === user.id, + invites_sent: invitesSent, }); }); @@ -398,10 +505,12 @@ const inviteToOrganization = factory.createHandlers(async (c) => { } 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: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + organizationId + ); + if (billingError || !billingState) { + return c.json({ error: "Failed to resolve organization billing state" }, 500); } const { data: existingUser, error: existingUserError } = await supabase @@ -422,31 +531,32 @@ const inviteToOrganization = factory.createHandlers(async (c) => { 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 (billingState.is_trial_expired && billingState.active_subscription_plan !== "annual") { + const requiredSeatsForInvite = billingState.member_count + 1; - if (membersCountError) { - return c.json({ error: "Failed to check organization size" }, 500); - } + if (billingState.active_subscription_plan === "team") { + if (billingState.active_subscription_quantity < requiredSeatsForInvite) { + return c.json( + { + error: + "Your Teams subscription does not have enough seats for this invite. Please increase seats in billing and try again.", + }, + 403 + ); + } + } else { + const noSubscriptionMessage = + billingState.required_plan === "solo" + ? "Your trial has ended. Solo allows 1 member only. Upgrade to Teams to invite more members." + : "Your trial has ended. An active Teams subscription is required to invite members. Please subscribe or increase seats in billing."; - 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 - ); + return c.json( + { + error: noSubscriptionMessage, + }, + 403 + ); + } } const invitedUser = await createInvitedUser( @@ -454,7 +564,8 @@ const inviteToOrganization = factory.createHandlers(async (c) => { streamServerClient, transporter, recipientEmail, - senderProfile.email + senderProfile.email, + { isTemporary: false } ); if (!invitedUser.success || !invitedUser.userId) { @@ -475,7 +586,7 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const { error: assignOrganizationError } = await supabase .from("profiles") - .update({ organization_id: organizationId }) + .update({ organization_id: organizationId, is_temporary: false }) .eq("id", invitedUser.userId); if (assignOrganizationError) { @@ -530,16 +641,162 @@ const inviteToOrganization = factory.createHandlers(async (c) => { } } + const { error: inviteHistoryError } = await supabase.from("organization_invites").insert({ + organization_id: organizationId, + invited_by: user.id, + invited_email: recipientEmail, + invited_user_id: invitedUser.userId, + }); + + if (inviteHistoryError && !isMissingRelationError(inviteHistoryError.code)) { + console.error("Failed to store organization invite history:", inviteHistoryError); + } + return c.json({ message: "Invitation sent successfully", limits: { - plan, + plan: billingState.active_subscription_plan ?? billingState.required_plan, max_tablos_for_solo: MAX_TABLO_LIMIT, - max_members_for_team: MAX_TEAM_MEMBER_LIMIT, + required_team_quantity_for_next_invite: billingState.member_count + 1, }, }); }); +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) { + return c.json({ error: "Member id is required" }, 400); + } + + const { data: actorProfile, error: actorProfileError } = await supabase + .from("profiles") + .select("organization_id, is_temporary") + .eq("id", user.id) + .single(); + + if (actorProfileError || !actorProfile?.organization_id) { + return c.json({ error: "Failed to resolve your organization" }, 500); + } + + if (actorProfile.is_temporary) { + return c.json({ error: "Temporary users cannot manage organization members" }, 403); + } + + const organizationId = actorProfile.organization_id; + const { data: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + organizationId + ); + + if (billingError || !billingState) { + return c.json({ error: "Failed to resolve organization billing state" }, 500); + } + + if (billingState.owner_user_id !== user.id) { + return c.json({ error: "Only the organization creator can remove members" }, 403); + } + + if (memberId === billingState.owner_user_id) { + return c.json({ error: "The organization creator cannot be removed" }, 400); + } + + const { data: memberProfile, error: memberProfileError } = await supabase + .from("profiles") + .select("id, email, name, first_name, last_name, organization_id") + .eq("id", memberId) + .maybeSingle(); + + if (memberProfileError) { + return c.json({ error: memberProfileError.message }, 500); + } + + if (!memberProfile || memberProfile.organization_id !== organizationId) { + return c.json({ error: "Member not found in your organization" }, 404); + } + + const baseName = + [memberProfile.first_name, memberProfile.last_name].filter(Boolean).join(" ").trim() || + memberProfile.name?.trim() || + memberProfile.email?.split("@")[0]?.trim() || + "Personal"; + + const { data: newOrganization, error: newOrganizationError } = await supabase + .from("organizations") + .insert({ name: `${baseName}'s Workspace` }) + .select("id") + .single(); + + if (newOrganizationError || !newOrganization) { + return c.json({ error: "Failed to create a workspace for this member" }, 500); + } + + const { error: assignError } = await supabase + .from("profiles") + .update({ organization_id: newOrganization.id }) + .eq("id", memberId); + + if (assignError) { + return c.json({ error: "Failed to remove member from organization" }, 500); + } + + const { error: transferOwnershipError } = await supabase + .from("tablos") + .update({ owner_id: user.id }) + .eq("organization_id", organizationId) + .eq("owner_id", memberId); + + if (transferOwnershipError) { + return c.json({ error: "Failed to transfer ownership for member tablos" }, 500); + } + + const { data: organizationTablos, error: tablosError } = await supabase + .from("tablos") + .select("id") + .eq("organization_id", organizationId); + + if (tablosError) { + return c.json({ error: "Failed to synchronize organization access" }, 500); + } + + const tabloIds = (organizationTablos || []).map((tablo) => tablo.id); + if (tabloIds.length > 0) { + const { error: removeAccessError } = await supabase + .from("tablo_access") + .delete() + .eq("user_id", memberId) + .in("tablo_id", tabloIds); + + if (removeAccessError) { + 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 + .from("organization_invites") + .delete() + .eq("organization_id", organizationId) + .eq("invited_user_id", memberId); + + if (inviteCleanupError && !isMissingRelationError(inviteCleanupError.code)) { + console.error("Failed to clean organization invite history:", inviteCleanupError); + } + + return c.json({ message: "Member removed successfully" }); +}); + export const getUserRouter = () => { const userRouter = new Hono(); @@ -551,6 +808,7 @@ export const getUserRouter = () => { userRouter.get("/organization", ...getOrganization); userRouter.patch("/organization", ...updateOrganization); userRouter.post("/organization/invite", ...inviteToOrganization); + userRouter.delete("/organization/members/:memberId", ...removeOrganizationMember); return userRouter; }; diff --git a/apps/main/.env.production b/apps/main/.env.production index 44de9ae..da8e895 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -10,4 +10,9 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpX VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF VITE_STRIPE_INFINITE_PRICE_ID=price_1SXHp8AmcXPHW4mTbus6j4Za -VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app \ No newline at end of file +VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app +VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder + +VITE_STRIPE_FOUNDER_PRICE_ID=price_annual_placeholder + +VITE_STRIPE_SOLO_PRICE_ID=price_solo_placeholder diff --git a/apps/main/.env.staging b/apps/main/.env.staging index 067d257..8fdd5e7 100644 --- a/apps/main/.env.staging +++ b/apps/main/.env.staging @@ -9,4 +9,10 @@ VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0 VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva -VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app \ No newline at end of file +VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app + +VITE_STRIPE_SOLO_PRICE_ID=price_1T8iT4Ato3YQ7YhIYCIIk0RA +VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_1T8hZfAto3YQ7YhIRK9YUSub +VITE_STRIPE_FOUNDER_PRICE_ID=price_1T8hawAto3YQ7YhIrVyKHggH + +VITE_STRIPE_INFINITE_PRICE_ID=price_infinite_placeholder diff --git a/apps/main/src/App.tsx b/apps/main/src/App.tsx index 6fbbe7d..c2d2ebd 100644 --- a/apps/main/src/App.tsx +++ b/apps/main/src/App.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; import { Toaster } from "@xtablo/ui/components/sonner"; import { BrowserRouter as Router, useRoutes } from "react-router-dom"; import { CookieBanner } from "./components/CookieBanner"; +import { PendingFounderCheckout } from "./components/PendingFounderCheckout"; import { TrialUpsellModal } from "./components/TrialUpsellModal"; import { UpgradePanel } from "./components/UpgradePanel"; import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext"; @@ -29,6 +30,7 @@ const Routes = () => { return ( + {appElement} diff --git a/apps/main/src/components/ActionCard.tsx b/apps/main/src/components/ActionCard.tsx index 7336c78..c7de041 100644 --- a/apps/main/src/components/ActionCard.tsx +++ b/apps/main/src/components/ActionCard.tsx @@ -40,20 +40,20 @@ export function ActionCard({ : isPrimary ? "bg-primary text-white hover:shadow-lg" : "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md", - className, + className )} >
{icon} @@ -65,7 +65,7 @@ export function ActionCard({ {label} @@ -79,7 +79,7 @@ export function ActionCard({

{description} diff --git a/apps/main/src/components/DashboardActionCards.tsx b/apps/main/src/components/DashboardActionCards.tsx index 515446e..62638c6 100644 --- a/apps/main/src/components/DashboardActionCards.tsx +++ b/apps/main/src/components/DashboardActionCards.tsx @@ -5,12 +5,14 @@ import { ActionCard } from "./ActionCard"; export interface DashboardActionCardsProps { onCreateProject?: () => void; onCreateTask?: () => void; + onInviteTeam?: () => void; onSendMessage?: () => void; } export function DashboardActionCards({ onCreateProject, onCreateTask, + onInviteTeam, onSendMessage, }: DashboardActionCardsProps) { const { t } = useTranslation("pages"); @@ -35,8 +37,7 @@ export function DashboardActionCards({ icon={} label={t("dashboard.actionCards.inviteTeam.label")} description={t("dashboard.actionCards.inviteTeam.description")} - disabled - badge="Bientôt" + onClick={onInviteTeam} /> = { +const STATUS_BADGE: Record = { todo: { - className: - "bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400", + className: "bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400", labelKey: "dashboard.taskList.status.todo", }, in_progress: { - className: - "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400", + className: "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400", labelKey: "dashboard.taskList.status.inProgress", }, in_review: { - className: - "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400", + className: "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400", labelKey: "dashboard.taskList.status.inReview", }, done: { - className: - "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400", + className: "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400", labelKey: "dashboard.taskList.status.done", }, }; @@ -76,7 +69,7 @@ function TaskRow({ "w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0", isDone ? "bg-purple-600 border-purple-600" - : "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500", + : "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500" )} onClick={(e) => { e.stopPropagation(); @@ -92,7 +85,7 @@ function TaskRow({ "text-sm font-medium truncate", isDone ? "line-through text-gray-400 dark:text-gray-500" - : "text-gray-900 dark:text-gray-100", + : "text-gray-900 dark:text-gray-100" )} > {task.title} @@ -105,7 +98,7 @@ function TaskRow({

@@ -128,7 +121,7 @@ function TaskRow({ {t(badge.labelKey)} @@ -146,14 +139,10 @@ export function DashboardTaskList() { const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); // Filter to tasks assigned to the current user, limited to recent ones - const myTasks = - allTasks - ?.filter((task) => task.assignee_id === user.id) - .slice(0, 7) ?? []; + const myTasks = allTasks?.filter((task) => task.assignee_id === user.id).slice(0, 7) ?? []; const handleToggleDone = (task: TaskWithTablo) => { - const newStatus: TaskStatus = - task.status === "done" ? "todo" : "done"; + const newStatus: TaskStatus = task.status === "done" ? "todo" : "done"; updateTask.mutate({ id: task.id, status: newStatus }); }; @@ -177,11 +166,7 @@ export function DashboardTaskList() {
{myTasks.map((task) => ( - + ))}
diff --git a/apps/main/src/components/ExceptionModal.tsx b/apps/main/src/components/ExceptionModal.tsx index 65acae3..65aafd9 100644 --- a/apps/main/src/components/ExceptionModal.tsx +++ b/apps/main/src/components/ExceptionModal.tsx @@ -41,7 +41,7 @@ export const ExceptionModal = ({ }) => { const { t } = useTranslation("components"); const form = useForm>({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: zodResolver typing is incompatible with current zod type in this package. resolver: zodResolver(formSchema as any), defaultValues: { exceptionType: "day", diff --git a/apps/main/src/components/InviteOrganizationModal.tsx b/apps/main/src/components/InviteOrganizationModal.tsx new file mode 100644 index 0000000..96d67dd --- /dev/null +++ b/apps/main/src/components/InviteOrganizationModal.tsx @@ -0,0 +1,89 @@ +import { Button } from "@xtablo/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@xtablo/ui/components/dialog"; +import { Input } from "@xtablo/ui/components/input"; +import { Label } from "@xtablo/ui/components/label"; +import { Loader2Icon, UserPlus } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useInviteOrganizationUser } from "../hooks/organization"; + +const isEmailValid = (email: string) => /\S+@\S+\.\S+/.test(email.trim()); + +export function InviteOrganizationModal({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (nextOpen: boolean) => void; +}) { + const { t } = useTranslation(["settings", "common"]); + const { mutate: inviteOrganizationUser, isPending } = useInviteOrganizationUser(); + const [inviteEmail, setInviteEmail] = useState(""); + + const close = () => { + setInviteEmail(""); + onOpenChange(false); + }; + + const submit = () => { + const email = inviteEmail.trim(); + if (!isEmailValid(email)) { + return; + } + + inviteOrganizationUser(email, { + onSuccess: () => { + close(); + }, + }); + }; + + return ( + + + + + + {t("settings:teamInvite.title")} + + {t("settings:teamInvite.description")} + + +
+ + setInviteEmail(e.target.value)} + placeholder={t("settings:teamInvite.emailPlaceholder")} + /> +

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

+
+ + + + + +
+
+ ); +} diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index c174544..28c518f 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -1,7 +1,7 @@ // shadcn components import { cn } from "@xtablo/shared/lib/cn.ts"; -import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar"; import { Button } from "@xtablo/ui/components/button"; import { DropdownMenu, @@ -13,8 +13,6 @@ import { import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography"; import { cva, type VariantProps } from "class-variance-authority"; import { - CalendarIcon, - Circle, Compass, ConstructionIcon, CreditCard, @@ -38,6 +36,7 @@ import { SquareKanban, Star, Sun, + UserMinus, Waves, Zap, } from "lucide-react"; @@ -47,7 +46,8 @@ 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 { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe"; +import { useOrganization, useRemoveOrganizationMember } from "../hooks/organization"; +import { useCreateCheckoutSession } from "../hooks/stripe"; import { useTablosList } from "../hooks/tablos"; import { isProd, isStaging } from "../lib/env"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; @@ -91,7 +91,28 @@ function NavLink({ isActive, children }: NavLinkProps) { export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { const user = useUser(); const { mutate: logout } = useLogout(); + const { data: organizationData } = useOrganization(); + const { mutate: removeOrganizationMember, isPending: isRemovingMember } = + useRemoveOrganizationMember(); const { t } = useTranslation("navigation"); + const members = organizationData?.members ?? []; + const canRemoveMembers = organizationData?.is_billing_owner ?? false; + + const getDisplayName = (input: { + first_name?: string | null; + last_name?: string | null; + name?: string | null; + email?: string | null; + }) => { + const combined = [input.first_name, input.last_name].filter(Boolean).join(" ").trim(); + if (combined) { + return combined; + } + if (input.name) { + return input.name; + } + return input.email || t("organizationMenu.unknownUser", "Utilisateur"); + }; const MenuSeparator = () => { return ; @@ -134,7 +155,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { + )} +
+ )) + )} +
@@ -202,13 +266,12 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { /> - - +