diff --git a/apps/api/cloudbuild.yaml b/apps/api/cloudbuild.yaml index 90f055a..1bb3df7 100644 --- a/apps/api/cloudbuild.yaml +++ b/apps/api/cloudbuild.yaml @@ -14,7 +14,7 @@ steps: - '--region' - 'europe-west1' - '--set-env-vars' - - 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS' + - 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,REVENUECAT_WEBHOOK_AUTH_HEADER=$_REVENUECAT_WEBHOOK_AUTH_HEADER,REVENUECAT_SOLO_PRODUCT_ID=$_REVENUECAT_SOLO_PRODUCT_ID,REVENUECAT_ANNUAL_PRODUCT_ID=$_REVENUECAT_ANNUAL_PRODUCT_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS' images: - 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA' diff --git a/apps/api/src/__tests__/config/revenuecat-config.test.ts b/apps/api/src/__tests__/config/revenuecat-config.test.ts new file mode 100644 index 0000000..bc88fb8 --- /dev/null +++ b/apps/api/src/__tests__/config/revenuecat-config.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createConfig } from "../../config.js"; +import type { Secrets } from "../../secrets.js"; + +const baseSecrets: Secrets = { + adminTokenSigningSecret: "admin-token-signing-secret", + clientAuthJwtSecret: "client-auth-jwt-secret", + supabaseServiceRoleKey: "service-role-from-secret-manager", + supabaseConnectionString: "postgres://secret-manager", + supabaseCaCert: "ca-cert", + emailClientSecret: "email-client-secret", + emailRefreshToken: "email-refresh-token", + r2AccessKeyId: "r2-access-key-id", + r2SecretAccessKey: "r2-secret-access-key", + stripeSecretKey: "sk_live_secret_manager", + stripeWebhookSecret: "whsec_live_secret_manager", + stripeSecretKeyStaging: "sk_live_staging_secret_manager", + stripeWebhookSecretStaging: "whsec_live_staging_secret_manager", +}; + +const originalEnv = { ...process.env }; + +const setRequiredBaseEnv = () => { + process.env.NODE_ENV = "test"; + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key-for-tests"; + process.env.SUPABASE_CONNECTION_STRING = "postgres://localhost/test"; + process.env.SUPABASE_CA_CERT = "test-ca-cert"; + process.env.STRIPE_SECRET_KEY = "sk_test_env_override"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_env_override"; + process.env.STRIPE_SOLO_PRICE_ID = "price_solo"; + process.env.STRIPE_TEAM_PRICE_ID = "price_team"; + process.env.STRIPE_FOUNDER_PRICE_ID = "price_founder"; + process.env.EMAIL_USER = "test@xtablo.com"; + process.env.EMAIL_CLIENT_ID = "client-id"; + process.env.EMAIL_CLIENT_SECRET = "email-client-secret"; + process.env.EMAIL_REFRESH_TOKEN = "email-refresh-token"; + process.env.R2_ACCOUNT_ID = "r2-account"; + process.env.R2_ACCESS_KEY_ID = "r2-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "r2-secret"; + process.env.ADMIN_TOKEN_SIGNING_SECRET = "admin-signing-secret"; + process.env.REVENUECAT_WEBHOOK_AUTH_HEADER = "Bearer revenuecat-secret"; + process.env.REVENUECAT_SOLO_PRODUCT_ID = "solo_ios_monthly"; + process.env.REVENUECAT_ANNUAL_PRODUCT_ID = "annual_ios"; +}; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("createConfig revenuecat env", () => { + it("loads revenuecat webhook auth and product ids in test mode", () => { + setRequiredBaseEnv(); + + const config = createConfig(baseSecrets); + + expect(config.REVENUECAT_WEBHOOK_AUTH_HEADER).toBe("Bearer revenuecat-secret"); + expect(config.REVENUECAT_SOLO_PRODUCT_ID).toBe("solo_ios_monthly"); + expect(config.REVENUECAT_ANNUAL_PRODUCT_ID).toBe("annual_ios"); + }); + + it("throws when the revenuecat webhook auth header is missing", () => { + setRequiredBaseEnv(); + process.env.REVENUECAT_WEBHOOK_AUTH_HEADER = ""; + + expect(() => createConfig(baseSecrets)).toThrow( + "Missing required environment variable: REVENUECAT_WEBHOOK_AUTH_HEADER" + ); + }); + + it("throws when the annual product id is missing", () => { + setRequiredBaseEnv(); + process.env.REVENUECAT_ANNUAL_PRODUCT_ID = ""; + + expect(() => createConfig(baseSecrets)).toThrow( + "Missing required environment variable: REVENUECAT_ANNUAL_PRODUCT_ID" + ); + }); +}); diff --git a/apps/api/src/__tests__/helpers/appleBilling.test.ts b/apps/api/src/__tests__/helpers/appleBilling.test.ts new file mode 100644 index 0000000..975a7d6 --- /dev/null +++ b/apps/api/src/__tests__/helpers/appleBilling.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + mapAppleProductToPlan, + normalizeAppleSubscriptionStatus, + toAppleBillingCandidate, + type AppleBillingCandidate, +} from "../../helpers/appleBilling.js"; + +describe("apple billing helpers", () => { + const productConfig = { + annualProductId: "annual_ios", + soloProductId: "solo_ios_monthly", + }; + + it("maps the configured solo product id to the solo billing plan", () => { + expect(mapAppleProductToPlan("solo_ios_monthly", productConfig)).toBe("solo"); + }); + + it("maps the configured annual product id to the annual billing plan", () => { + expect(mapAppleProductToPlan("annual_ios", productConfig)).toBe("annual"); + }); + + it("returns null for unmapped products", () => { + expect(mapAppleProductToPlan("team_ios_monthly", productConfig)).toBeNull(); + }); + + it("normalizes expiration-like events to expired", () => { + expect(normalizeAppleSubscriptionStatus("EXPIRATION", false)).toBe("expired"); + expect(normalizeAppleSubscriptionStatus("CANCELLATION", true)).toBe("canceled"); + }); + + it("treats initial purchases as active until further lifecycle changes arrive", () => { + expect(normalizeAppleSubscriptionStatus("INITIAL_PURCHASE", false)).toBe("active"); + expect(normalizeAppleSubscriptionStatus("RENEWAL", false)).toBe("active"); + }); + + it("always shapes Apple billing candidates with quantity one", () => { + const candidate = toAppleBillingCandidate({ + currentPeriodEnd: "2026-06-01T00:00:00.000Z", + plan: "annual", + status: "active", + }) as AppleBillingCandidate; + + expect(candidate).toMatchObject({ + plan: "annual", + quantity: 1, + status: "active", + }); + expect(candidate.currentPeriodEnd).toBe(Date.parse("2026-06-01T00:00:00.000Z") / 1000); + }); +}); diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts index f58205d..311e6e7 100644 --- a/apps/api/src/__tests__/helpers/billing.test.ts +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -4,6 +4,7 @@ import { getOrganizationOwner, getTrialWindow, parseTrialRolloutDate, + selectBestBillingCandidate, } from "../../helpers/billing.js"; describe("billing helpers", () => { @@ -91,4 +92,54 @@ describe("billing helpers", () => { expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z"); expect(result.isTrialExpired).toBe(true); }); + + it("prefers annual access over lower-tier plans when selecting the winning entitlement", () => { + const winner = selectBestBillingCandidate( + [ + { + currentPeriodEnd: 100, + plan: "solo", + quantity: 1, + status: "active", + }, + { + currentPeriodEnd: 50, + plan: "annual", + quantity: 1, + status: "canceled", + }, + ], + "solo" + ); + + expect(winner).toEqual({ plan: "annual", quantity: 1 }); + }); + + it("prefers team over solo when both are valid", () => { + const winner = selectBestBillingCandidate( + [ + { + currentPeriodEnd: 100, + plan: "solo", + quantity: 1, + status: "active", + }, + { + currentPeriodEnd: 80, + plan: "team", + quantity: 3, + status: "active", + }, + ], + "solo" + ); + + expect(winner).toEqual({ plan: "team", quantity: 3 }); + }); + + it("returns no active subscription when no billing candidates exist", () => { + const winner = selectBestBillingCandidate([], "team"); + + expect(winner).toEqual({ plan: null, quantity: 0 }); + }); }); diff --git a/apps/api/src/__tests__/helpers/organizationMembers.test.ts b/apps/api/src/__tests__/helpers/organizationMembers.test.ts new file mode 100644 index 0000000..7007517 --- /dev/null +++ b/apps/api/src/__tests__/helpers/organizationMembers.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadOrganizationMembers } from "../../helpers/organizationMembers.js"; + +const buildSupabase = ({ + fallbackResult, + primaryResult, +}: { + primaryResult: { data: unknown; error: { message: string } | null }; + fallbackResult?: { data: unknown; error: { message: string } | null }; +}) => { + const single = vi.fn().mockResolvedValue( + fallbackResult ?? { + data: null, + error: { message: "fallback failed" }, + } + ); + const order = vi.fn().mockResolvedValue(primaryResult); + const primaryEq = vi.fn(() => ({ order })); + const fallbackEq = vi.fn(() => ({ single })); + const select = vi + .fn() + .mockImplementationOnce(() => ({ eq: primaryEq })) + .mockImplementationOnce(() => ({ eq: fallbackEq })); + const from = vi.fn(() => ({ select })); + + return { + supabase: { + from, + }, + spies: { + from, + order, + primaryEq, + fallbackEq, + select, + single, + }, + }; +}; + +describe("loadOrganizationMembers", () => { + it("returns organization members when the primary query succeeds", async () => { + const members = [{ email: "owner@example.com", id: "owner-id" }]; + const { spies, supabase } = buildSupabase({ + primaryResult: { + data: members, + error: null, + }, + }); + + const result = await loadOrganizationMembers(supabase as never, { + organizationId: 42, + userId: "owner-id", + }); + + expect(result.data).toEqual(members); + expect(result.error).toBeNull(); + expect(spies.from).toHaveBeenCalledTimes(1); + expect(spies.primaryEq).toHaveBeenCalledWith("organization_id", 42); + expect(spies.order).toHaveBeenCalledWith("created_at", { ascending: true }); + }); + + it("falls back to the current user profile when the members query fails", async () => { + const fallbackMember = { email: "owner@example.com", id: "owner-id" }; + const { spies, supabase } = buildSupabase({ + primaryResult: { + data: null, + error: { message: "members query failed" }, + }, + fallbackResult: { + data: fallbackMember, + error: null, + }, + }); + + const result = await loadOrganizationMembers(supabase as never, { + organizationId: 42, + userId: "owner-id", + }); + + expect(result.data).toEqual([fallbackMember]); + expect(result.error).toBeNull(); + expect(spies.from).toHaveBeenCalledTimes(2); + expect(spies.fallbackEq).toHaveBeenCalledWith("id", "owner-id"); + expect(spies.single).toHaveBeenCalledTimes(1); + }); + + it("returns the fallback error when both queries fail", async () => { + const { supabase } = buildSupabase({ + primaryResult: { + data: null, + error: { message: "members query failed" }, + }, + fallbackResult: { + data: null, + error: { message: "fallback profile failed" }, + }, + }); + + const result = await loadOrganizationMembers(supabase as never, { + organizationId: 42, + userId: "owner-id", + }); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ message: "fallback profile failed" }); + }); +}); diff --git a/apps/api/src/__tests__/routes/revenuecat.test.ts b/apps/api/src/__tests__/routes/revenuecat.test.ts new file mode 100644 index 0000000..14c7f53 --- /dev/null +++ b/apps/api/src/__tests__/routes/revenuecat.test.ts @@ -0,0 +1,145 @@ +import { createClient } from "@supabase/supabase-js"; +import { testClient } from "hono/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createConfig } from "../../config.js"; +import { MiddlewareManager } from "../../middlewares/middleware.js"; +import { getMainRouter } from "../../routers/index.js"; +import { getTestUser } from "../helpers/testUtils.js"; + +describe("RevenueCat Webhook Endpoint", () => { + const config = createConfig(); + MiddlewareManager.initialize(config); + const app = getMainRouter(config); + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const ownerUser = getTestUser("owner"); + const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + beforeEach(async () => { + await adminClient.from("apple_subscription_events").delete().neq("id", 0); + await adminClient.from("apple_subscriptions").delete().eq("owner_user_id", ownerUser.userId); + await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId); + }); + + it("rejects webhook calls without the configured authorization header", async () => { + const res = await client["revenuecat-webhook"].$post({ + json: { + event: { + id: "unauthorized-event", + type: "INITIAL_PURCHASE", + }, + }, + }); + + expect(res.status).toBe(401); + }); + + it("persists a mapped Apple subscription once and treats retries as duplicates", async () => { + const eventId = `event-${Date.now()}`; + const originalTransactionId = `orig-${Date.now()}`; + + const payload = { + event: { + aliases: [], + app_user_id: ownerUser.userId, + environment: "SANDBOX", + expiration_at_ms: Date.now() + 86400000, + id: eventId, + original_app_user_id: ownerUser.userId, + original_transaction_id: originalTransactionId, + product_id: config.REVENUECAT_SOLO_PRODUCT_ID, + purchased_at_ms: Date.now(), + store: "APP_STORE", + transaction_id: `txn-${Date.now()}`, + type: "INITIAL_PURCHASE", + }, + }; + + const firstRes = await client["revenuecat-webhook"].$post( + { json: payload }, + { + headers: { + authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER, + "Content-Type": "application/json", + }, + } + ); + + expect(firstRes.status).toBe(200); + + const secondRes = await client["revenuecat-webhook"].$post( + { json: payload }, + { + headers: { + authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER, + "Content-Type": "application/json", + }, + } + ); + + expect(secondRes.status).toBe(200); + expect(await secondRes.json()).toMatchObject({ duplicate: true, received: true }); + + const { data: appleSubscriptions } = await adminClient + .from("apple_subscriptions") + .select("owner_user_id, plan, status, original_transaction_id") + .eq("original_transaction_id", originalTransactionId); + + expect(appleSubscriptions).toHaveLength(1); + expect(appleSubscriptions?.[0]).toMatchObject({ + owner_user_id: ownerUser.userId, + plan: "solo", + status: "active", + original_transaction_id: originalTransactionId, + }); + + const { data: appleEvents } = await adminClient + .from("apple_subscription_events") + .select("event_id") + .eq("event_id", eventId); + + expect(appleEvents).toHaveLength(1); + }); + + it("ignores unmapped products without creating entitlement rows", async () => { + const res = await client["revenuecat-webhook"].$post( + { + json: { + event: { + app_user_id: ownerUser.userId, + environment: "SANDBOX", + expiration_at_ms: Date.now() + 86400000, + id: `ignored-${Date.now()}`, + original_transaction_id: `orig-ignored-${Date.now()}`, + product_id: "team_ios_monthly", + purchased_at_ms: Date.now(), + store: "APP_STORE", + transaction_id: `txn-ignored-${Date.now()}`, + type: "INITIAL_PURCHASE", + }, + }, + }, + { + headers: { + authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER, + "Content-Type": "application/json", + }, + } + ); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ignored: true, reason: "unmapped_product" }); + + const { data: rows } = await adminClient + .from("apple_subscriptions") + .select("id") + .eq("owner_user_id", ownerUser.userId); + + expect(rows ?? []).toHaveLength(0); + }); +}); diff --git a/apps/api/src/__tests__/routes/user.test.ts b/apps/api/src/__tests__/routes/user.test.ts index a3fe08a..f3223e7 100644 --- a/apps/api/src/__tests__/routes/user.test.ts +++ b/apps/api/src/__tests__/routes/user.test.ts @@ -226,6 +226,84 @@ describe("User Endpoint", () => { }); }); + describe("GET /organization - Billing State", () => { + it("returns Apple-backed active access for the authenticated owner", async () => { + const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + const organizationName = `Apple Billing Org ${Date.now()}`; + const originalTransactionId = `org-apple-${Date.now()}`; + + const { data: organization, error: organizationError } = await adminClient + .from("organizations") + .insert({ + name: organizationName, + }) + .select("id") + .single(); + expect(organizationError).toBeNull(); + + const organizationId = organization?.id as number; + const { error: profileUpdateError } = await adminClient + .from("profiles") + .update({ organization_id: organizationId }) + .eq("id", ownerUser.userId); + expect(profileUpdateError).toBeNull(); + + await adminClient.from("apple_subscriptions").delete().eq("owner_user_id", ownerUser.userId); + await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId); + + const { error: customerError } = await adminClient.from("apple_customers").insert({ + revenuecat_app_user_id: ownerUser.userId, + user_id: ownerUser.userId, + }); + expect(customerError).toBeNull(); + + const { error: subscriptionError } = await adminClient.from("apple_subscriptions").insert({ + current_period_end: new Date(Date.now() + 86400000).toISOString(), + current_period_start: new Date().toISOString(), + environment: "SANDBOX", + original_transaction_id: originalTransactionId, + owner_user_id: ownerUser.userId, + plan: "annual", + revenuecat_app_user_id: ownerUser.userId, + status: "active", + store: "APP_STORE", + store_product_id: "annual_ios", + }); + expect(subscriptionError).toBeNull(); + + const res = await client.users.organization.$get( + {}, + { + headers: { + Authorization: `Bearer ${ownerUser.accessToken}`, + "Content-Type": "application/json", + }, + } + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.active_subscription_plan).toBe("annual"); + expect(data.is_billing_owner).toBe(true); + + await adminClient + .from("profiles") + .update({ organization_id: null }) + .eq("id", ownerUser.userId); + await adminClient + .from("apple_subscriptions") + .delete() + .eq("original_transaction_id", originalTransactionId); + await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId); + await adminClient.from("organizations").delete().eq("id", organizationId); + }); + }); + // DELETE /me must run last — it hard-deletes the auth user, making ownerUser unusable for subsequent tests describe("DELETE /me - Delete Account", () => { it("should return 401 when unauthenticated", async () => { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 6cedd72..76dc4a3 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -13,6 +13,9 @@ export interface AppConfig { STRIPE_SOLO_PRICE_ID: string; STRIPE_TEAM_PRICE_ID: string; STRIPE_FOUNDER_PRICE_ID: string; + REVENUECAT_WEBHOOK_AUTH_HEADER: string; + REVENUECAT_SOLO_PRODUCT_ID: string; + REVENUECAT_ANNUAL_PRODUCT_ID: string; EMAIL_USER: string; EMAIL_CLIENT_ID: string; EMAIL_CLIENT_SECRET: string; @@ -133,6 +136,18 @@ export function createConfig(secrets?: Secrets): AppConfig { "STRIPE_FOUNDER_PRICE_ID", process.env.STRIPE_FOUNDER_PRICE_ID ), + REVENUECAT_WEBHOOK_AUTH_HEADER: validateEnvVar( + "REVENUECAT_WEBHOOK_AUTH_HEADER", + process.env.REVENUECAT_WEBHOOK_AUTH_HEADER + ), + REVENUECAT_SOLO_PRODUCT_ID: validateEnvVar( + "REVENUECAT_SOLO_PRODUCT_ID", + process.env.REVENUECAT_SOLO_PRODUCT_ID + ), + REVENUECAT_ANNUAL_PRODUCT_ID: validateEnvVar( + "REVENUECAT_ANNUAL_PRODUCT_ID", + process.env.REVENUECAT_ANNUAL_PRODUCT_ID + ), EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER), EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID), EMAIL_CLIENT_SECRET: isTestMode diff --git a/apps/api/src/helpers/appleBilling.ts b/apps/api/src/helpers/appleBilling.ts new file mode 100644 index 0000000..bbf3be5 --- /dev/null +++ b/apps/api/src/helpers/appleBilling.ts @@ -0,0 +1,105 @@ +export type AppleBillingStatus = + | "active" + | "expired" + | "canceled" + | "refunded" + | "revoked" + | "unknown"; + +export type AppleBillingCandidate = { + currentPeriodEnd: number; + plan: "solo" | "annual"; + quantity: 1; + status: AppleBillingStatus; +}; + +export const APPLE_ACCESSIBLE_STATUSES: AppleBillingStatus[] = ["active", "canceled"]; + +export function mapAppleProductToPlan( + productId: string | null | undefined, + config: { + annualProductId: string; + soloProductId: string; + } +): "solo" | "annual" | null { + if (!productId) { + return null; + } + + if (productId === config.soloProductId) { + return "solo"; + } + + if (productId === config.annualProductId) { + return "annual"; + } + + return null; +} + +export function normalizeAppleSubscriptionStatus( + eventType: string | null | undefined, + cancelAtPeriodEnd: boolean +): AppleBillingStatus { + const normalizedEventType = (eventType ?? "").trim().toUpperCase(); + + if (normalizedEventType === "EXPIRATION") { + return "expired"; + } + + if (normalizedEventType === "CANCELLATION") { + return cancelAtPeriodEnd ? "canceled" : "revoked"; + } + + if (normalizedEventType === "BILLING_ISSUE") { + return "canceled"; + } + + if (normalizedEventType === "SUBSCRIPTION_PAUSED") { + return "canceled"; + } + + if (normalizedEventType === "UNCANCELLATION") { + return "active"; + } + + if ( + normalizedEventType === "INITIAL_PURCHASE" || + normalizedEventType === "NON_RENEWING_PURCHASE" || + normalizedEventType === "PRODUCT_CHANGE" || + normalizedEventType === "RENEWAL" || + normalizedEventType === "TRANSFER" + ) { + return "active"; + } + + return "unknown"; +} + +export function toAppleBillingCandidate(input: { + currentPeriodEnd: string | null; + now?: Date; + plan: "solo" | "annual"; + status: AppleBillingStatus; +}): AppleBillingCandidate | null { + if (!APPLE_ACCESSIBLE_STATUSES.includes(input.status)) { + return null; + } + + const currentPeriodEndMs = input.currentPeriodEnd ? Date.parse(input.currentPeriodEnd) : NaN; + if (Number.isNaN(currentPeriodEndMs)) { + return null; + } + + const now = input.now ?? new Date(); + if (currentPeriodEndMs <= now.getTime()) { + return null; + } + + return { + currentPeriodEnd: Math.floor(currentPeriodEndMs / 1000), + plan: input.plan, + quantity: 1, + status: input.status, + }; +} diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts index 9e1d8dd..3ca6818 100644 --- a/apps/api/src/helpers/billing.ts +++ b/apps/api/src/helpers/billing.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { toAppleBillingCandidate, type AppleBillingCandidate } from "./appleBilling.js"; export type BillingPlan = "solo" | "team" | "annual"; export type RequiredBillingPlan = "solo" | "team"; @@ -41,6 +42,19 @@ type StripeProductRow = { metadata: Record | null; }; +type AppleSubscriptionRow = { + plan: string; + status: string; + current_period_end: string | null; +}; + +export type BillingCandidate = { + currentPeriodEnd: number; + plan: BillingPlan; + quantity: number; + status: string | null; +}; + export type OrganizationBillingState = { owner_user_id: string; member_count: number; @@ -56,6 +70,7 @@ export type OrganizationBillingState = { 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 RESTRICTED_SCHEMA_ERROR_FRAGMENT = "The schema must be one of the following"; const parseTrialDays = () => { const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10); @@ -114,6 +129,7 @@ const statusWeight = (status: string | null | undefined) => { if (status === "active") return 3; if (status === "past_due") return 2; if (status === "trialing") return 1; + if (status === "canceled") return 1; return 0; }; @@ -147,6 +163,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu return null; }; +const normalizeApplePlan = (plan: string | null | undefined): "solo" | "annual" | null => { + const normalized = (plan ?? "").toLowerCase(); + + if (normalized === "annual") { + return "annual"; + } + + if (normalized === "solo") { + return "solo"; + } + + return null; +}; + const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => { const normalized = (plan ?? "").toLowerCase(); @@ -195,157 +225,202 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: return { error: null, profiles }; }; +const isRestrictedSchemaError = (message: string | null | undefined) => + Boolean(message?.includes(RESTRICTED_SCHEMA_ERROR_FRAGMENT)); + +export const selectBestBillingCandidate = ( + candidates: BillingCandidate[], + ownerFallbackPlan: BillingPlan +): { plan: BillingPlan | null; quantity: number } => { + if (candidates.length === 0) { + return { plan: null, quantity: 0 }; + } + + const sortedCandidates = [...candidates].sort((a, b) => { + const byPlanWeight = planWeight(b.plan) - planWeight(a.plan); + if (byPlanWeight !== 0) return byPlanWeight; + + const byStatusWeight = statusWeight(b.status) - statusWeight(a.status); + if (byStatusWeight !== 0) return byStatusWeight; + + return b.currentPeriodEnd - a.currentPeriodEnd; + }); + + const winner = sortedCandidates[0]; + return { + plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan), + quantity: winner.quantity, + }; +}; + 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 + let stripeCandidates: BillingCandidate[] = []; + try { + const { data: customers, error: customersError } = await supabase .schema("stripe") - .from("prices") - .select("id, lookup_key, metadata, product") - .in("id", priceIds); + .from("customers") + .select("id, metadata") + .limit(1000); - if (pricesError) { - throw new Error(pricesError.message); + if (customersError) { + throw new Error(customersError.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)) - ) + const customer = (customers as StripeCustomerRow[] | null)?.find( + (candidate) => candidate.metadata?.user_id === ownerUserId ); - if (productIds.length > 0) { - const { data: products, error: productsError } = await supabase + if (customer) { + const { data: subscriptions, error: subscriptionsError } = await supabase .schema("stripe") - .from("products") - .select("id, name, metadata") - .in("id", productIds); + .from("subscriptions") + .select("id, status, created, current_period_end") + .eq("customer", customer.id) + .in("status", ACTIVE_BILLING_STATUSES); - if (productsError) { - throw new Error(productsError.message); + if (subscriptionsError) { + throw new Error(subscriptionsError.message); } - const normalizedProducts = (products ?? []) as StripeProductRow[]; - productsById = new Map(normalizedProducts.map((product) => [product.id, product])); + const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[]; + + if (normalizedSubscriptions.length > 0) { + 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])); + } + } + + stripeCandidates = normalizedSubscriptions.flatMap((subscription) => { + const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id); + + if (relatedItems.length === 0) { + return [ + { + currentPeriodEnd: subscription.current_period_end ?? subscription.created ?? 0, + plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, + quantity: 1, + status: subscription.status, + }, + ]; + } + + 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 { + currentPeriodEnd: subscription.current_period_end ?? subscription.created ?? 0, + plan: + inferBillingPlan(hint) ?? + (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan), + quantity: Math.max(1, item.quantity ?? 1), + status: subscription.status, + }; + }); + }); + } + } + } catch (error) { + if (!isRestrictedSchemaError(error instanceof Error ? error.message : null)) { + throw error; } } - const candidates = normalizedSubscriptions.flatMap((subscription) => { - const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id); + const { data: appleSubscriptions, error: appleSubscriptionsError } = await supabase + .from("apple_subscriptions") + .select("plan, status, current_period_end") + .eq("owner_user_id", ownerUserId); + + if (appleSubscriptionsError) { + throw new Error(appleSubscriptionsError.message); + } + + const appleCandidates: BillingCandidate[] = ((appleSubscriptions ?? []) as AppleSubscriptionRow[]) + .flatMap((subscription) => { + const plan = normalizeApplePlan(subscription.plan); + if (!plan) { + return []; + } + + const candidate = toAppleBillingCandidate({ + currentPeriodEnd: subscription.current_period_end, + plan, + status: subscription.status as AppleBillingCandidate["status"], + }); + + if (!candidate) { + return []; + } - if (relatedItems.length === 0) { return [ { - subscription, - plan: null as BillingPlan | null, - quantity: 1, + currentPeriodEnd: candidate.currentPeriodEnd, + plan: candidate.plan, + quantity: candidate.quantity, + status: candidate.status, }, ]; - } - - 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, - }; + return selectBestBillingCandidate([...stripeCandidates, ...appleCandidates], ownerFallbackPlan); }; export const getOrganizationBillingState = async ( diff --git a/apps/api/src/helpers/organizationMembers.ts b/apps/api/src/helpers/organizationMembers.ts new file mode 100644 index 0000000..aed12fc --- /dev/null +++ b/apps/api/src/helpers/organizationMembers.ts @@ -0,0 +1,56 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export type OrganizationMember = { + avatar_url: string | null; + created_at: string; + email: string | null; + first_name: string | null; + id: string; + last_name: string | null; + name: string | null; + plan: string | null; +}; + +type LoadOrganizationMembersOptions = { + organizationId: number; + userId: string; +}; + +const organizationMemberSelect = + "id, email, name, first_name, last_name, avatar_url, created_at, plan"; + +export const loadOrganizationMembers = async ( + supabase: SupabaseClient, + { organizationId, userId }: LoadOrganizationMembersOptions +) => { + const { data, error } = await supabase + .from("profiles") + .select(organizationMemberSelect) + .eq("organization_id", organizationId) + .order("created_at", { ascending: true }); + + if (!error) { + return { + data: (data as OrganizationMember[] | null) ?? [], + error: null, + }; + } + + const fallback = await supabase + .from("profiles") + .select(organizationMemberSelect) + .eq("id", userId) + .single(); + + if (fallback.error || !fallback.data) { + return { + data: null, + error: fallback.error ?? error, + }; + } + + return { + data: [fallback.data as OrganizationMember], + error: null, + }; +}; diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts index 342505b..1718e42 100644 --- a/apps/api/src/routers/index.ts +++ b/apps/api/src/routers/index.ts @@ -9,6 +9,7 @@ import { getPublicClientInvitesRouter } from "./clientInvites.js"; import { getClientPortalRouter } from "./clientPortal.js"; import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js"; import { getPublicRouter } from "./public.js"; +import { getRevenueCatWebhookRouter } from "./revenuecat.js"; import { getStripeWebhookRouter } from "./stripe.js"; import { getTaskRouter } from "./tasks.js"; @@ -33,6 +34,7 @@ export const getMainRouter = (config: AppConfig) => { mainRouter.route("/tasks", getTaskRouter()); // webhooks + mainRouter.route("/revenuecat-webhook", getRevenueCatWebhookRouter(config)); mainRouter.route("/stripe-webhook", getStripeWebhookRouter()); // admin routes diff --git a/apps/api/src/routers/revenuecat.ts b/apps/api/src/routers/revenuecat.ts new file mode 100644 index 0000000..b170177 --- /dev/null +++ b/apps/api/src/routers/revenuecat.ts @@ -0,0 +1,223 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import type { AppConfig } from "../config.js"; +import { + mapAppleProductToPlan, + normalizeAppleSubscriptionStatus, +} from "../helpers/appleBilling.js"; +import type { BaseEnv } from "../types/app.types.js"; + +type RevenueCatWebhookEvent = { + aliases?: string[] | null; + app_user_id?: string | null; + environment?: string | null; + expiration_at_ms?: number | null; + id?: string | null; + original_app_user_id?: string | null; + original_transaction_id?: string | null; + product_id?: string | null; + purchased_at_ms?: number | null; + store?: string | null; + transaction_id?: string | null; + type?: string | null; +}; + +type RevenueCatWebhookBody = { + api_version?: string; + event?: RevenueCatWebhookEvent; +}; + +const factory = createFactory(); + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const toIsoTimestamp = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null; + +const collectCandidateAppUserIds = (event: RevenueCatWebhookEvent) => { + const uniqueIds = new Set(); + + for (const rawValue of [event.app_user_id, event.original_app_user_id, ...(event.aliases ?? [])]) { + const value = rawValue?.trim(); + if (!value || !UUID_PATTERN.test(value)) { + continue; + } + + uniqueIds.add(value); + } + + return Array.from(uniqueIds); +}; + +const resolveUserIdFromRevenueCatEvent = async ( + supabase: BaseEnv["Variables"]["supabase"], + event: RevenueCatWebhookEvent +) => { + const candidateIds = collectCandidateAppUserIds(event); + if (candidateIds.length > 0) { + const { data: profiles, error } = await supabase + .from("profiles") + .select("id") + .in("id", candidateIds) + .limit(1); + + if (error) { + throw new Error(error.message); + } + + const directMatch = (profiles ?? [])[0]; + if (directMatch?.id) { + return directMatch.id as string; + } + } + + const lookupIds = [event.app_user_id, event.original_app_user_id, ...(event.aliases ?? [])].filter( + (value): value is string => Boolean(value?.trim()) + ); + if (lookupIds.length === 0) { + return null; + } + + const { data: appleCustomers, error: appleCustomersError } = await supabase + .from("apple_customers") + .select("user_id, revenuecat_app_user_id") + .in("revenuecat_app_user_id", lookupIds) + .limit(1); + + if (appleCustomersError) { + throw new Error(appleCustomersError.message); + } + + return ((appleCustomers ?? [])[0]?.user_id as string | undefined) ?? null; +}; + +const handleRevenueCatWebhook = (config: AppConfig) => + factory.createHandlers(async (c) => { + const authorization = c.req.header("authorization"); + if (authorization !== config.REVENUECAT_WEBHOOK_AUTH_HEADER) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = (await c.req.json()) as RevenueCatWebhookBody; + const event = body.event; + if (!event?.id || !event.type) { + return c.json({ error: "Invalid RevenueCat payload" }, 400); + } + + const supabase = c.get("supabase"); + + const { data: existingEvent, error: existingEventError } = await supabase + .from("apple_subscription_events") + .select("id") + .eq("event_id", event.id) + .maybeSingle(); + + if (existingEventError) { + return c.json({ error: existingEventError.message }, 500); + } + + if (existingEvent?.id) { + return c.json({ received: true, duplicate: true }); + } + + const { error: insertEventError } = await supabase.from("apple_subscription_events").insert({ + environment: event.environment ?? null, + event_id: event.id, + event_type: event.type, + payload: body, + }); + + if (insertEventError) { + return c.json({ error: insertEventError.message }, 500); + } + + const mappedPlan = mapAppleProductToPlan(event.product_id, { + annualProductId: config.REVENUECAT_ANNUAL_PRODUCT_ID, + soloProductId: config.REVENUECAT_SOLO_PRODUCT_ID, + }); + + if (!mappedPlan) { + await supabase + .from("apple_subscription_events") + .update({ processed_at: new Date().toISOString() }) + .eq("event_id", event.id); + + return c.json({ ignored: true, received: true, reason: "unmapped_product" }); + } + + const ownerUserId = await resolveUserIdFromRevenueCatEvent(supabase, event); + if (!ownerUserId) { + await supabase + .from("apple_subscription_events") + .update({ processed_at: new Date().toISOString() }) + .eq("event_id", event.id); + + return c.json({ ignored: true, received: true, reason: "unknown_user" }); + } + + const normalizedStatus = normalizeAppleSubscriptionStatus(event.type, event.type === "CANCELLATION"); + + const revenuecatAppUserId = event.app_user_id ?? event.original_app_user_id ?? ownerUserId; + const currentPeriodEnd = toIsoTimestamp(event.expiration_at_ms); + const currentPeriodStart = toIsoTimestamp(event.purchased_at_ms); + + const { error: appleCustomerError } = await supabase.from("apple_customers").upsert( + { + last_seen_environment: event.environment ?? null, + original_app_user_id: event.original_app_user_id ?? null, + revenuecat_app_user_id: revenuecatAppUserId, + updated_at: new Date().toISOString(), + user_id: ownerUserId, + }, + { onConflict: "user_id" } + ); + + if (appleCustomerError) { + return c.json({ error: appleCustomerError.message }, 500); + } + + if (event.original_transaction_id) { + const { error: appleSubscriptionError } = await supabase.from("apple_subscriptions").upsert( + { + cancel_at_period_end: normalizedStatus === "canceled", + current_period_end: currentPeriodEnd, + current_period_start: currentPeriodStart, + environment: event.environment ?? "PRODUCTION", + last_event_type: event.type, + original_transaction_id: event.original_transaction_id, + owner_user_id: ownerUserId, + plan: mappedPlan, + raw_customer_id: event.app_user_id ?? null, + revenuecat_app_user_id: revenuecatAppUserId, + revoked_at: + normalizedStatus === "revoked" || normalizedStatus === "refunded" + ? new Date().toISOString() + : null, + status: normalizedStatus, + store: event.store ?? "APP_STORE", + store_product_id: event.product_id ?? "", + transaction_id: event.transaction_id ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: "original_transaction_id" } + ); + + if (appleSubscriptionError) { + return c.json({ error: appleSubscriptionError.message }, 500); + } + } + + await supabase + .from("apple_subscription_events") + .update({ processed_at: new Date().toISOString() }) + .eq("event_id", event.id); + + return c.json({ received: true }); + }); + +export const getRevenueCatWebhookRouter = (config: AppConfig) => { + const router = new Hono(); + router.post("/", ...handleRevenueCatWebhook(config)); + return router; +}; diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 69f1e8e..845fce0 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { createFactory } from "hono/factory"; import { getOrganizationBillingState } from "../helpers/billing.js"; import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js"; +import { loadOrganizationMembers } from "../helpers/organizationMembers.js"; import { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js"; import type { AuthEnv } from "../types/app.types.js"; @@ -198,11 +199,10 @@ const getOrganization = factory.createHandlers(async (c) => { 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, plan") - .eq("organization_id", organizationId) - .order("created_at", { ascending: true }); + const { data: members, error: membersError } = await loadOrganizationMembers(supabase, { + organizationId, + userId: user.id, + }); if (membersError) { return c.json({ error: "Failed to load organization members" }, 500); diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index d1fa778..23acff0 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -1,1290 +1,1728 @@ -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "13.0.4"; - }; + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } public: { Tables: { + admin_audit_log: { + Row: { + action: string + after: Json | null + before: Json | null + created_at: string + id: number + operator_email: string + operator_id: string + role: string + target_id: string + target_type: string + } + Insert: { + action: string + after?: Json | null + before?: Json | null + created_at?: string + id?: number + operator_email: string + operator_id: string + role: string + target_id: string + target_type: string + } + Update: { + action?: string + after?: Json | null + before?: Json | null + created_at?: string + id?: number + operator_email?: string + operator_id?: string + role?: string + target_id?: string + target_type?: string + } + Relationships: [] + } + apple_customers: { + Row: { + created_at: string + id: number + last_seen_environment: string | null + original_app_user_id: string | null + revenuecat_app_user_id: string + updated_at: string + user_id: string + } + Insert: { + created_at?: string + id?: number + last_seen_environment?: string | null + original_app_user_id?: string | null + revenuecat_app_user_id: string + updated_at?: string + user_id: string + } + Update: { + created_at?: string + id?: number + last_seen_environment?: string | null + original_app_user_id?: string | null + revenuecat_app_user_id?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "apple_customers_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + apple_subscription_events: { + Row: { + environment: string | null + event_id: string + event_type: string + id: number + payload: Json + processed_at: string | null + received_at: string + } + Insert: { + environment?: string | null + event_id: string + event_type: string + id?: number + payload: Json + processed_at?: string | null + received_at?: string + } + Update: { + environment?: string | null + event_id?: string + event_type?: string + id?: number + payload?: Json + processed_at?: string | null + received_at?: string + } + Relationships: [] + } + apple_subscriptions: { + Row: { + cancel_at_period_end: boolean + created_at: string + current_period_end: string | null + current_period_start: string | null + environment: string + id: number + last_event_type: string | null + original_transaction_id: string + owner_user_id: string + plan: string + raw_customer_id: string | null + revenuecat_app_user_id: string + revoked_at: string | null + status: string + store: string + store_product_id: string + transaction_id: string | null + updated_at: string + } + Insert: { + cancel_at_period_end?: boolean + created_at?: string + current_period_end?: string | null + current_period_start?: string | null + environment: string + id?: number + last_event_type?: string | null + original_transaction_id: string + owner_user_id: string + plan: string + raw_customer_id?: string | null + revenuecat_app_user_id: string + revoked_at?: string | null + status: string + store?: string + store_product_id: string + transaction_id?: string | null + updated_at?: string + } + Update: { + cancel_at_period_end?: boolean + created_at?: string + current_period_end?: string | null + current_period_start?: string | null + environment?: string + id?: number + last_event_type?: string | null + original_transaction_id?: string + owner_user_id?: string + plan?: string + raw_customer_id?: string | null + revenuecat_app_user_id?: string + revoked_at?: string | null + status?: string + store?: string + store_product_id?: string + transaction_id?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "apple_subscriptions_owner_user_id_fkey" + columns: ["owner_user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } availabilities: { Row: { - availability_data: Json; - created_at: string; - exceptions: Json | null; - id: number; - updated_at: string; - user_id: string; - }; + availability_data: Json + created_at: string + exceptions: Json | null + id: number + updated_at: string + user_id: string + } Insert: { - availability_data?: Json; - created_at?: string; - exceptions?: Json | null; - id?: number; - updated_at?: string; - user_id: string; - }; + availability_data?: Json + created_at?: string + exceptions?: Json | null + id?: number + updated_at?: string + user_id: string + } Update: { - availability_data?: Json; - created_at?: string; - exceptions?: Json | null; - id?: number; - updated_at?: string; - user_id?: string; - }; - Relationships: []; - }; + availability_data?: Json + created_at?: string + exceptions?: Json | null + id?: number + updated_at?: string + user_id?: string + } + Relationships: [] + } calendar_subscriptions: { Row: { - created_at: string | null; - id: string; - tablo_id: string; - token: string; - }; + created_at: string | null + id: string + tablo_id: string + token: string + } Insert: { - created_at?: string | null; - id?: string; - tablo_id: string; - token: string; - }; + created_at?: string | null + id?: string + tablo_id: string + token: string + } Update: { - created_at?: string | null; - id?: string; - tablo_id?: string; - token?: string; - }; + created_at?: string | null + id?: string + tablo_id?: string + token?: string + } Relationships: [ { - foreignKeyName: "calendar_subscriptions_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: true; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "calendar_subscriptions_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: true + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "calendar_subscriptions_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: true; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "calendar_subscriptions_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: true + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "calendar_subscriptions_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: true; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "calendar_subscriptions_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: true + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; + ] + } + channel_read_state: { + Row: { + channel_id: string + last_read_at: string + user_id: string + } + Insert: { + channel_id: string + last_read_at?: string + user_id: string + } + Update: { + channel_id?: string + last_read_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "channel_read_state_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] + }, + { + foreignKeyName: "channel_read_state_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] + }, + { + foreignKeyName: "channel_read_state_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] + }, + ] + } client_access: { Row: { - client_id: string; - created_at: string; - granted_at: string; - granted_by: string; - id: number; - revoked_at: string | null; - tablo_id: string; - }; + client_id: string + created_at: string + granted_at: string + granted_by: string + id: number + revoked_at: string | null + tablo_id: string + } Insert: { - client_id: string; - created_at?: string; - granted_at?: string; - granted_by: string; - id?: number; - revoked_at?: string | null; - tablo_id: string; - }; + client_id: string + created_at?: string + granted_at?: string + granted_by: string + id?: number + revoked_at?: string | null + tablo_id: string + } Update: { - client_id?: string; - created_at?: string; - granted_at?: string; - granted_by?: string; - id?: number; - revoked_at?: string | null; - tablo_id?: string; - }; + client_id?: string + created_at?: string + granted_at?: string + granted_by?: string + id?: number + revoked_at?: string | null + tablo_id?: string + } Relationships: [ { - foreignKeyName: "client_access_client_id_fkey"; - columns: ["client_id"]; - isOneToOne: false; - referencedRelation: "clients"; - referencedColumns: ["id"]; + foreignKeyName: "client_access_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] }, { - foreignKeyName: "client_access_granted_by_fkey"; - columns: ["granted_by"]; - isOneToOne: false; - referencedRelation: "profiles"; - referencedColumns: ["id"]; + foreignKeyName: "client_access_granted_by_fkey" + columns: ["granted_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] }, { - foreignKeyName: "client_access_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "client_access_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "client_access_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "client_access_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "client_access_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "client_access_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; - client_magic_links: { - Row: { - client_id: string; - consumed_at: string | null; - created_at: string; - created_by: string | null; - email: string; - expires_at: string; - id: number; - jti: string | null; - purpose: string; - redirect_to: string | null; - tablo_id: string | null; - token_hash: string | null; - }; - Insert: { - client_id: string; - consumed_at?: string | null; - created_at?: string; - created_by?: string | null; - email: string; - expires_at: string; - id?: number; - jti?: string | null; - purpose: string; - redirect_to?: string | null; - tablo_id?: string | null; - token_hash?: string | null; - }; - Update: { - client_id?: string; - consumed_at?: string | null; - created_at?: string; - created_by?: string | null; - email?: string; - expires_at?: string; - id?: number; - jti?: string | null; - purpose?: string; - redirect_to?: string | null; - tablo_id?: string | null; - token_hash?: string | null; - }; - Relationships: [ - { - foreignKeyName: "client_magic_links_client_id_fkey"; - columns: ["client_id"]; - isOneToOne: false; - referencedRelation: "clients"; - referencedColumns: ["id"]; - }, - { - foreignKeyName: "client_magic_links_created_by_fkey"; - columns: ["created_by"]; - isOneToOne: false; - referencedRelation: "profiles"; - referencedColumns: ["id"]; - }, - { - foreignKeyName: "client_magic_links_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; - }, - { - foreignKeyName: "client_magic_links_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; - }, - { - foreignKeyName: "client_magic_links_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; - }, - ]; - }; - clients: { - Row: { - created_at: string; - email: string; - first_name: string | null; - id: string; - last_login_at: string | null; - last_name: string | null; - normalized_email: string; - phone: string | null; - updated_at: string; - }; - Insert: { - created_at?: string; - email: string; - first_name?: string | null; - id?: string; - last_login_at?: string | null; - last_name?: string | null; - normalized_email: string; - phone?: string | null; - updated_at?: string; - }; - Update: { - created_at?: string; - email?: string; - first_name?: string | null; - id?: string; - last_login_at?: string | null; - last_name?: string | null; - normalized_email?: string; - phone?: string | null; - updated_at?: string; - }; - Relationships: []; - }; + ] + } client_invites: { Row: { - created_at: string; - expires_at: string; - id: number; - invite_type: string; - invited_by: string; - invited_email: string; - invite_token: string; - is_pending: boolean; - cancelled_at: string | null; - setup_completed_at: string | null; - tablo_id: string; - used_at: string | null; - }; + cancelled_at: string | null + created_at: string + expires_at: string + id: number + invite_token: string + invite_type: string + invited_by: string + invited_email: string + is_pending: boolean + setup_completed_at: string | null + tablo_id: string + used_at: string | null + } Insert: { - created_at?: string; - expires_at?: string; - id?: number; - invite_type?: string; - invited_by: string; - invited_email: string; - invite_token: string; - is_pending?: boolean; - cancelled_at?: string | null; - setup_completed_at?: string | null; - tablo_id: string; - used_at?: string | null; - }; + cancelled_at?: string | null + created_at?: string + expires_at?: string + id?: number + invite_token: string + invite_type?: string + invited_by: string + invited_email: string + is_pending?: boolean + setup_completed_at?: string | null + tablo_id: string + used_at?: string | null + } Update: { - created_at?: string; - expires_at?: string; - id?: number; - invite_type?: string; - invited_by?: string; - invited_email?: string; - invite_token?: string; - is_pending?: boolean; - cancelled_at?: string | null; - setup_completed_at?: string | null; - tablo_id?: string; - used_at?: string | null; - }; + cancelled_at?: string | null + created_at?: string + expires_at?: string + id?: number + invite_token?: string + invite_type?: string + invited_by?: string + invited_email?: string + is_pending?: boolean + setup_completed_at?: string | null + tablo_id?: string + used_at?: string | null + } Relationships: [ { - foreignKeyName: "client_invites_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "client_invites_invited_by_fkey" + columns: ["invited_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] }, { - foreignKeyName: "client_invites_invited_by_fkey"; - columns: ["invited_by"]; - isOneToOne: false; - referencedRelation: "profiles"; - referencedColumns: ["id"]; + foreignKeyName: "client_invites_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, - ]; - }; + { + foreignKeyName: "client_invites_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] + }, + { + foreignKeyName: "client_invites_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] + }, + ] + } + client_magic_links: { + Row: { + client_id: string + consumed_at: string | null + created_at: string + created_by: string | null + email: string + expires_at: string + id: number + jti: string | null + purpose: string + redirect_to: string | null + tablo_id: string | null + token_hash: string | null + } + Insert: { + client_id: string + consumed_at?: string | null + created_at?: string + created_by?: string | null + email: string + expires_at: string + id?: number + jti?: string | null + purpose: string + redirect_to?: string | null + tablo_id?: string | null + token_hash?: string | null + } + Update: { + client_id?: string + consumed_at?: string | null + created_at?: string + created_by?: string | null + email?: string + expires_at?: string + id?: number + jti?: string | null + purpose?: string + redirect_to?: string | null + tablo_id?: string | null + token_hash?: string | null + } + Relationships: [ + { + foreignKeyName: "client_magic_links_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + { + foreignKeyName: "client_magic_links_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] + }, + ] + } + clients: { + Row: { + created_at: string + email: string + first_name: string | null + id: string + last_login_at: string | null + last_name: string | null + normalized_email: string + phone: string | null + updated_at: string + } + Insert: { + created_at?: string + email: string + first_name?: string | null + id?: string + last_login_at?: string | null + last_name?: string | null + normalized_email: string + phone?: string | null + updated_at?: string + } + Update: { + created_at?: string + email?: string + first_name?: string | null + id?: string + last_login_at?: string | null + last_name?: string | null + normalized_email?: string + phone?: string | null + updated_at?: string + } + Relationships: [] + } devis: { Row: { - client_email: string; - created_at: string; - date: string; - due_date: string; - id: string; - items: Json; - notes: string | null; - number: string; - status: Database["public"]["Enums"]["devis_status"]; - subtotal: number; - tax: number; - terms: string | null; - total: number; - updated_at: string; - user_id: string; - }; + client_email: string + created_at: string + date: string + due_date: string + id: string + items: Json + notes: string | null + number: string + status: Database["public"]["Enums"]["devis_status"] + subtotal: number + tax: number + terms: string | null + total: number + updated_at: string + user_id: string + } Insert: { - client_email: string; - created_at?: string; - date: string; - due_date: string; - id?: string; - items?: Json; - notes?: string | null; - number: string; - status?: Database["public"]["Enums"]["devis_status"]; - subtotal: number; - tax: number; - terms?: string | null; - total: number; - updated_at?: string; - user_id: string; - }; + client_email: string + created_at?: string + date: string + due_date: string + id?: string + items?: Json + notes?: string | null + number: string + status?: Database["public"]["Enums"]["devis_status"] + subtotal: number + tax: number + terms?: string | null + total: number + updated_at?: string + user_id: string + } Update: { - client_email?: string; - created_at?: string; - date?: string; - due_date?: string; - id?: string; - items?: Json; - notes?: string | null; - number?: string; - status?: Database["public"]["Enums"]["devis_status"]; - subtotal?: number; - tax?: number; - terms?: string | null; - total?: number; - updated_at?: string; - user_id?: string; - }; - Relationships: []; - }; + client_email?: string + created_at?: string + date?: string + due_date?: string + id?: string + items?: Json + notes?: string | null + number?: string + status?: Database["public"]["Enums"]["devis_status"] + subtotal?: number + tax?: number + terms?: string | null + total?: number + updated_at?: string + user_id?: string + } + Relationships: [] + } event_types: { Row: { - config: Json; - created_at: string | null; - deleted_at: string | null; - id: string; - is_active: boolean; - standard_name: string | null; - updated_at: string | null; - user_id: string; - }; + config: Json + created_at: string | null + deleted_at: string | null + id: string + is_active: boolean + standard_name: string | null + updated_at: string | null + user_id: string + } Insert: { - config?: Json; - created_at?: string | null; - deleted_at?: string | null; - id?: string; - is_active?: boolean; - standard_name?: string | null; - updated_at?: string | null; - user_id: string; - }; + config?: Json + created_at?: string | null + deleted_at?: string | null + id?: string + is_active?: boolean + standard_name?: string | null + updated_at?: string | null + user_id: string + } Update: { - config?: Json; - created_at?: string | null; - deleted_at?: string | null; - id?: string; - is_active?: boolean; - standard_name?: string | null; - updated_at?: string | null; - user_id?: string; - }; - Relationships: []; - }; + config?: Json + created_at?: string | null + deleted_at?: string | null + id?: string + is_active?: boolean + standard_name?: string | null + updated_at?: string | null + user_id?: string + } + Relationships: [] + } events: { Row: { - created_at: string | null; - created_by: string; - deleted_at: string | null; - description: string | null; - end_time: string | null; - id: string; - start_date: string; - start_time: string; - tablo_id: string; - title: string; - }; + created_at: string | null + created_by: string + deleted_at: string | null + description: string | null + end_time: string | null + id: string + start_date: string + start_time: string + tablo_id: string + title: string + } Insert: { - created_at?: string | null; - created_by: string; - deleted_at?: string | null; - description?: string | null; - end_time?: string | null; - id?: string; - start_date: string; - start_time: string; - tablo_id: string; - title: string; - }; + created_at?: string | null + created_by: string + deleted_at?: string | null + description?: string | null + end_time?: string | null + id?: string + start_date: string + start_time: string + tablo_id: string + title: string + } Update: { - created_at?: string | null; - created_by?: string; - deleted_at?: string | null; - description?: string | null; - end_time?: string | null; - id?: string; - start_date?: string; - start_time?: string; - tablo_id?: string; - title?: string; - }; + created_at?: string | null + created_by?: string + deleted_at?: string | null + description?: string | null + end_time?: string | null + id?: string + start_date?: string + start_time?: string + tablo_id?: string + title?: string + } Relationships: [ { - foreignKeyName: "fk_events_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "fk_events_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "fk_events_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_events_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_events_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_events_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; + ] + } feedbacks: { Row: { - created_at: string | null; - fd_type: string; - id: number; - message: string; - user_id: string; - }; + created_at: string | null + fd_type: string + id: number + message: string + user_id: string + } Insert: { - created_at?: string | null; - fd_type: string; - id?: number; - message: string; - user_id: string; - }; + created_at?: string | null + fd_type: string + id?: number + message: string + user_id: string + } Update: { - created_at?: string | null; - fd_type?: string; - id?: number; - message?: string; - user_id?: string; - }; - Relationships: []; - }; + created_at?: string | null + fd_type?: string + id?: number + message?: string + user_id?: string + } + Relationships: [] + } + messages: { + Row: { + channel_id: string + created_at: string + deleted_at: string | null + id: string + text: string + updated_at: string | null + user_id: string + } + Insert: { + channel_id: string + created_at?: string + deleted_at?: string | null + id?: string + text: string + updated_at?: string | null + user_id: string + } + Update: { + channel_id?: string + created_at?: string + deleted_at?: string | null + id?: string + text?: string + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "messages_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] + }, + { + foreignKeyName: "messages_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] + }, + { + foreignKeyName: "messages_channel_id_fkey" + columns: ["channel_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] + }, + ] + } note_access: { Row: { - created_at: string | null; - id: number; - is_active: boolean | null; - note_id: string; - tablo_id: string | null; - updated_at: string | null; - user_id: string; - }; + created_at: string | null + id: number + is_active: boolean | null + note_id: string + tablo_id: string | null + updated_at: string | null + user_id: string + } Insert: { - created_at?: string | null; - id?: number; - is_active?: boolean | null; - note_id: string; - tablo_id?: string | null; - updated_at?: string | null; - user_id: string; - }; + created_at?: string | null + id?: number + is_active?: boolean | null + note_id: string + tablo_id?: string | null + updated_at?: string | null + user_id: string + } Update: { - created_at?: string | null; - id?: number; - is_active?: boolean | null; - note_id?: string; - tablo_id?: string | null; - updated_at?: string | null; - user_id?: string; - }; + created_at?: string | null + id?: number + is_active?: boolean | null + note_id?: string + tablo_id?: string | null + updated_at?: string | null + user_id?: string + } Relationships: [ { - foreignKeyName: "fk_note_access_note_id"; - columns: ["note_id"]; - isOneToOne: false; - referencedRelation: "notes"; - referencedColumns: ["id"]; + foreignKeyName: "fk_note_access_note_id" + columns: ["note_id"] + isOneToOne: false + referencedRelation: "notes" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_note_access_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "fk_note_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "fk_note_access_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_note_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_note_access_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_note_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; + ] + } notes: { Row: { - content: string | null; - created_at: string | null; - deleted_at: string | null; - id: string; - title: string; - updated_at: string | null; - user_id: string; - }; + content: string | null + created_at: string | null + deleted_at: string | null + id: string + title: string + updated_at: string | null + user_id: string + } Insert: { - content?: string | null; - created_at?: string | null; - deleted_at?: string | null; - id?: string; - title: string; - updated_at?: string | null; - user_id: string; - }; + content?: string | null + created_at?: string | null + deleted_at?: string | null + id?: string + title: string + updated_at?: string | null + user_id: string + } Update: { - content?: string | null; - created_at?: string | null; - deleted_at?: string | null; - id?: string; - title?: string; - updated_at?: string | null; - user_id?: string; - }; - Relationships: []; - }; + content?: string | null + created_at?: string | null + deleted_at?: string | null + id?: string + title?: string + updated_at?: string | null + user_id?: string + } + Relationships: [] + } notifications: { Row: { - action_type: string; - actor_id: string | null; - created_at: string; - entity_id: string; - entity_type: string; - id: string; - message: Json; - metadata: Json | null; - read_at: string | null; - user_id: string; - }; + action_type: string + actor_id: string | null + created_at: string + entity_id: string + entity_type: string + id: string + message: Json + metadata: Json | null + read_at: string | null + user_id: string + } Insert: { - action_type: string; - actor_id?: string | null; - created_at?: string; - entity_id: string; - entity_type: string; - id?: string; - message?: Json; - metadata?: Json | null; - read_at?: string | null; - user_id: string; - }; + action_type: string + actor_id?: string | null + created_at?: string + entity_id: string + entity_type: string + id?: string + message?: Json + metadata?: Json | null + read_at?: string | null + user_id: string + } Update: { - action_type?: string; - actor_id?: string | null; - created_at?: string; - entity_id?: string; - entity_type?: string; - id?: string; - message?: Json; - metadata?: Json | null; - read_at?: string | null; - user_id?: string; - }; - Relationships: []; - }; + action_type?: string + actor_id?: string | null + created_at?: string + entity_id?: string + entity_type?: string + id?: string + message?: Json + metadata?: Json | null + read_at?: string | null + user_id?: string + } + Relationships: [] + } + organization_invites: { + Row: { + created_at: string + id: number + invited_by: string + invited_email: string + invited_user_id: string | null + organization_id: number + } + Insert: { + created_at?: string + id?: number + invited_by: string + invited_email: string + invited_user_id?: string | null + organization_id: number + } + Update: { + created_at?: string + id?: number + invited_by?: string + invited_email?: string + invited_user_id?: string | null + organization_id?: number + } + Relationships: [ + { + foreignKeyName: "organization_invites_invited_by_fkey" + columns: ["invited_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "organization_invites_invited_user_id_fkey" + columns: ["invited_user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "organization_invites_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + organizations: { + Row: { + created_at: string + deleted_at: string | null + id: number + internal_uuid: string + logo_url: string | null + name: string + updated_at: string + } + Insert: { + created_at?: string + deleted_at?: string | null + id?: number + internal_uuid?: string + logo_url?: string | null + name: string + updated_at?: string + } + Update: { + created_at?: string + deleted_at?: string | null + id?: number + internal_uuid?: string + logo_url?: string | null + name?: string + updated_at?: string + } + Relationships: [] + } profiles: { Row: { - avatar_url: string | null; - client_onboarded_at: string | null; - created_at: string | null; - email: string | null; - first_name: string | null; - id: string; - is_client: boolean; - last_name: string | null; - last_signed_in: string | null; - name: string | null; - plan: Database["public"]["Enums"]["subscription_plan"] | null; - short_user_id: string; - }; + avatar_url: string | null + client_onboarded_at: string | null + created_at: string | null + email: string | null + first_name: string | null + id: string + is_client: boolean + last_name: string | null + last_signed_in: string | null + name: string | null + organization_id: number + plan: Database["public"]["Enums"]["subscription_plan"] | null + short_user_id: string + } Insert: { - avatar_url?: string | null; - client_onboarded_at?: string | null; - created_at?: string | null; - email?: string | null; - first_name?: string | null; - id: string; - is_client?: boolean; - last_name?: string | null; - last_signed_in?: string | null; - name?: string | null; - plan?: Database["public"]["Enums"]["subscription_plan"] | null; - short_user_id: string; - }; + avatar_url?: string | null + client_onboarded_at?: string | null + created_at?: string | null + email?: string | null + first_name?: string | null + id: string + is_client?: boolean + last_name?: string | null + last_signed_in?: string | null + name?: string | null + organization_id: number + plan?: Database["public"]["Enums"]["subscription_plan"] | null + short_user_id: string + } Update: { - avatar_url?: string | null; - client_onboarded_at?: string | null; - created_at?: string | null; - email?: string | null; - first_name?: string | null; - id?: string; - is_client?: boolean; - last_name?: string | null; - last_signed_in?: string | null; - name?: string | null; - plan?: Database["public"]["Enums"]["subscription_plan"] | null; - short_user_id?: string; - }; - Relationships: []; - }; + avatar_url?: string | null + client_onboarded_at?: string | null + created_at?: string | null + email?: string | null + first_name?: string | null + id?: string + is_client?: boolean + last_name?: string | null + last_signed_in?: string | null + name?: string | null + organization_id?: number + plan?: Database["public"]["Enums"]["subscription_plan"] | null + short_user_id?: string + } + Relationships: [ + { + foreignKeyName: "profiles_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } shared_notes: { Row: { - created_at: string | null; - is_public: boolean | null; - note_id: string; - updated_at: string | null; - user_id: string; - }; + created_at: string | null + is_public: boolean | null + note_id: string + updated_at: string | null + user_id: string + } Insert: { - created_at?: string | null; - is_public?: boolean | null; - note_id: string; - updated_at?: string | null; - user_id: string; - }; + created_at?: string | null + is_public?: boolean | null + note_id: string + updated_at?: string | null + user_id: string + } Update: { - created_at?: string | null; - is_public?: boolean | null; - note_id?: string; - updated_at?: string | null; - user_id?: string; - }; + created_at?: string | null + is_public?: boolean | null + note_id?: string + updated_at?: string | null + user_id?: string + } Relationships: [ { - foreignKeyName: "fk_shared_notes_note_id"; - columns: ["note_id"]; - isOneToOne: true; - referencedRelation: "notes"; - referencedColumns: ["id"]; + foreignKeyName: "fk_shared_notes_note_id" + columns: ["note_id"] + isOneToOne: true + referencedRelation: "notes" + referencedColumns: ["id"] }, - ]; - }; + ] + } tablo_access: { Row: { - created_at: string | null; - granted_by: string; - id: number; - is_active: boolean | null; - is_admin: boolean | null; - tablo_id: string; - user_id: string; - }; + created_at: string | null + granted_by: string + id: number + is_active: boolean | null + is_admin: boolean | null + tablo_id: string + user_id: string + } Insert: { - created_at?: string | null; - granted_by: string; - id?: number; - is_active?: boolean | null; - is_admin?: boolean | null; - tablo_id: string; - user_id: string; - }; + created_at?: string | null + granted_by: string + id?: number + is_active?: boolean | null + is_admin?: boolean | null + tablo_id: string + user_id: string + } Update: { - created_at?: string | null; - granted_by?: string; - id?: number; - is_active?: boolean | null; - is_admin?: boolean | null; - tablo_id?: string; - user_id?: string; - }; + created_at?: string | null + granted_by?: string + id?: number + is_active?: boolean | null + is_admin?: boolean | null + tablo_id?: string + user_id?: string + } Relationships: [ { - foreignKeyName: "fk_tablo_access_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "fk_tablo_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "fk_tablo_access_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_tablo_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_tablo_access_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_tablo_access_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_tablo_access_user_id_from_profiles"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "profiles"; - referencedColumns: ["id"]; + foreignKeyName: "fk_tablo_access_user_id_from_profiles" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] }, - ]; - }; + ] + } tablo_invites: { Row: { - created_at: string; - id: number; - invite_token: string; - invited_by: string; - invited_email: string; - is_pending: boolean; - tablo_id: string; - }; + created_at: string + id: number + invite_token: string + invited_by: string + invited_email: string + is_pending: boolean + tablo_id: string + } Insert: { - created_at?: string; - id?: number; - invite_token: string; - invited_by: string; - invited_email: string; - is_pending?: boolean; - tablo_id: string; - }; + created_at?: string + id?: number + invite_token: string + invited_by: string + invited_email: string + is_pending?: boolean + tablo_id: string + } Update: { - created_at?: string; - id?: number; - invite_token?: string; - invited_by?: string; - invited_email?: string; - is_pending?: boolean; - tablo_id?: string; - }; + created_at?: string + id?: number + invite_token?: string + invited_by?: string + invited_email?: string + is_pending?: boolean + tablo_id?: string + } Relationships: [ { - foreignKeyName: "fk_tablo_invitations_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "fk_tablo_invitations_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "fk_tablo_invitations_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_tablo_invitations_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "fk_tablo_invitations_tablo_id"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "fk_tablo_invitations_tablo_id" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; + ] + } tablos: { Row: { - color: string | null; - created_at: string | null; - deleted_at: string | null; - id: string; - image: string | null; - layout_overview_v1: Json | null; - name: string; - owner_id: string; - position: number; - status: string | null; - updated_at: string | null; - }; + color: string | null + created_at: string | null + deleted_at: string | null + id: string + image: string | null + layout_overview_v1: Json | null + name: string + organization_id: number + owner_id: string + position: number + status: string | null + updated_at: string | null + } Insert: { - color?: string | null; - created_at?: string | null; - deleted_at?: string | null; - id?: string; - image?: string | null; - layout_overview_v1?: Json | null; - name: string; - owner_id: string; - position?: number; - status?: string | null; - updated_at?: string | null; - }; + color?: string | null + created_at?: string | null + deleted_at?: string | null + id?: string + image?: string | null + layout_overview_v1?: Json | null + name: string + organization_id: number + owner_id: string + position?: number + status?: string | null + updated_at?: string | null + } Update: { - color?: string | null; - created_at?: string | null; - deleted_at?: string | null; - id?: string; - image?: string | null; - layout_overview_v1?: Json | null; - name?: string; - owner_id?: string; - position?: number; - status?: string | null; - updated_at?: string | null; - }; - Relationships: []; - }; - tasks: { - Row: { - assignee_id: string | null; - created_at: string; - deleted_at: string | null; - description: string | null; - due_date: string | null; - id: string; - is_parent: boolean; - parent_task_id: string | null; - position: number; - status: Database["public"]["Enums"]["task_status"]; - tablo_id: string; - title: string; - updated_at: string; - }; - Insert: { - assignee_id?: string | null; - created_at?: string; - deleted_at?: string | null; - description?: string | null; - due_date?: string | null; - id?: string; - is_parent?: boolean; - parent_task_id?: string | null; - position?: number; - status?: Database["public"]["Enums"]["task_status"]; - tablo_id: string; - title: string; - updated_at?: string; - }; - Update: { - assignee_id?: string | null; - created_at?: string; - deleted_at?: string | null; - description?: string | null; - due_date?: string | null; - id?: string; - is_parent?: boolean; - parent_task_id?: string | null; - position?: number; - status?: Database["public"]["Enums"]["task_status"]; - tablo_id?: string; - title?: string; - updated_at?: string; - }; + color?: string | null + created_at?: string | null + deleted_at?: string | null + id?: string + image?: string | null + layout_overview_v1?: Json | null + name?: string + organization_id?: number + owner_id?: string + position?: number + status?: string | null + updated_at?: string | null + } Relationships: [ { - foreignKeyName: "tasks_parent_task_id_fkey"; - columns: ["parent_task_id"]; - isOneToOne: false; - referencedRelation: "tasks"; - referencedColumns: ["id"]; + foreignKeyName: "tablos_organization_id_fkey" + columns: ["organization_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + tasks: { + Row: { + assignee_id: string | null + created_at: string + deleted_at: string | null + description: string | null + due_date: string | null + id: string + is_parent: boolean + parent_task_id: string | null + position: number + status: Database["public"]["Enums"]["task_status"] + tablo_id: string + title: string + updated_at: string + } + Insert: { + assignee_id?: string | null + created_at?: string + deleted_at?: string | null + description?: string | null + due_date?: string | null + id?: string + is_parent?: boolean + parent_task_id?: string | null + position?: number + status?: Database["public"]["Enums"]["task_status"] + tablo_id: string + title: string + updated_at?: string + } + Update: { + assignee_id?: string | null + created_at?: string + deleted_at?: string | null + description?: string | null + due_date?: string | null + id?: string + is_parent?: boolean + parent_task_id?: string | null + position?: number + status?: Database["public"]["Enums"]["task_status"] + tablo_id?: string + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "tasks_parent_task_id_fkey" + columns: ["parent_task_id"] + isOneToOne: false + referencedRelation: "tasks" + referencedColumns: ["id"] }, { - foreignKeyName: "tasks_parent_task_id_fkey"; - columns: ["parent_task_id"]; - isOneToOne: false; - referencedRelation: "tasks_with_assignee"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_parent_task_id_fkey" + columns: ["parent_task_id"] + isOneToOne: false + referencedRelation: "tasks_with_assignee" + referencedColumns: ["id"] }, { - foreignKeyName: "tasks_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "tasks_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "tasks_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "tasks_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; + ] + } user_introductions: { Row: { - config: Json; - created_at: string | null; - updated_at: string | null; - user_id: string; - }; + config: Json + created_at: string | null + updated_at: string | null + user_id: string + } Insert: { - config?: Json; - created_at?: string | null; - updated_at?: string | null; - user_id: string; - }; + config?: Json + created_at?: string | null + updated_at?: string | null + user_id: string + } Update: { - config?: Json; - created_at?: string | null; - updated_at?: string | null; - user_id?: string; - }; - Relationships: []; - }; - }; + config?: Json + created_at?: string | null + updated_at?: string | null + user_id?: string + } + Relationships: [] + } + } Views: { events_and_tablos: { Row: { - description: string | null; - end_time: string | null; - event_id: string | null; - start_date: string | null; - start_time: string | null; - tablo_color: string | null; - tablo_id: string | null; - tablo_name: string | null; - tablo_status: string | null; - title: string | null; - }; - Relationships: []; - }; + description: string | null + end_time: string | null + event_id: string | null + start_date: string | null + start_time: string | null + tablo_color: string | null + tablo_id: string | null + tablo_name: string | null + tablo_status: string | null + title: string | null + } + Relationships: [] + } tasks_with_assignee: { Row: { - assignee_avatar: string | null; - assignee_id: string | null; - assignee_name: string | null; - created_at: string | null; - description: string | null; - due_date: string | null; - id: string | null; - is_parent: boolean | null; - parent_task_id: string | null; - position: number | null; - status: Database["public"]["Enums"]["task_status"] | null; - tablo_id: string | null; - title: string | null; - updated_at: string | null; - }; + assignee_avatar: string | null + assignee_id: string | null + assignee_name: string | null + created_at: string | null + description: string | null + due_date: string | null + id: string | null + is_parent: boolean | null + parent_task_id: string | null + position: number | null + status: Database["public"]["Enums"]["task_status"] | null + tablo_id: string | null + title: string | null + updated_at: string | null + } Relationships: [ { - foreignKeyName: "tasks_parent_task_id_fkey"; - columns: ["parent_task_id"]; - isOneToOne: false; - referencedRelation: "tasks"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_parent_task_id_fkey" + columns: ["parent_task_id"] + isOneToOne: false + referencedRelation: "tasks" + referencedColumns: ["id"] }, { - foreignKeyName: "tasks_parent_task_id_fkey"; - columns: ["parent_task_id"]; - isOneToOne: false; - referencedRelation: "tasks_with_assignee"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_parent_task_id_fkey" + columns: ["parent_task_id"] + isOneToOne: false + referencedRelation: "tasks_with_assignee" + referencedColumns: ["id"] }, { - foreignKeyName: "tasks_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "events_and_tablos"; - referencedColumns: ["tablo_id"]; + foreignKeyName: "tasks_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "events_and_tablos" + referencedColumns: ["tablo_id"] }, { - foreignKeyName: "tasks_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "tablos"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "tablos" + referencedColumns: ["id"] }, { - foreignKeyName: "tasks_tablo_id_fkey"; - columns: ["tablo_id"]; - isOneToOne: false; - referencedRelation: "user_tablos"; - referencedColumns: ["id"]; + foreignKeyName: "tasks_tablo_id_fkey" + columns: ["tablo_id"] + isOneToOne: false + referencedRelation: "user_tablos" + referencedColumns: ["id"] }, - ]; - }; + ] + } user_tablos: { Row: { - access_level: string | null; - color: string | null; - created_at: string | null; - deleted_at: string | null; - id: string | null; - image: string | null; - is_admin: boolean | null; - name: string | null; - position: number | null; - status: string | null; - user_id: string | null; - }; + access_level: string | null + color: string | null + created_at: string | null + deleted_at: string | null + id: string | null + image: string | null + is_admin: boolean | null + name: string | null + position: number | null + status: string | null + user_id: string | null + } Relationships: [ { - foreignKeyName: "fk_tablo_access_user_id_from_profiles"; - columns: ["user_id"]; - isOneToOne: false; - referencedRelation: "profiles"; - referencedColumns: ["id"]; + foreignKeyName: "fk_tablo_access_user_id_from_profiles" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] }, - ]; - }; - }; + ] + } + } Functions: { compute_tablo_status: { - Args: { tablo_id_param: string }; - Returns: string; - }; - generate_random_string: { Args: { length?: number }; Returns: string }; + Args: { tablo_id_param: string } + Returns: string + } + create_personal_organization: { Args: never; Returns: number } + current_user_organization_id: { Args: never; Returns: number } + generate_cool_organization_name: { Args: never; Returns: string } + generate_random_string: { Args: { length?: number }; Returns: string } get_my_active_subscription: { - Args: never; + Args: never Returns: { - billing_interval: string; - cancel_at_period_end: boolean; - currency: string; - current_period_end: string; - current_period_start: string; - first_name: string; - last_name: string; - plan: Database["public"]["Enums"]["subscription_plan"]; - product_name: string; - status: string; - subscription_id: string; - unit_amount: number; - user_email: string; - user_id: string; - }[]; - }; + billing_interval: string + cancel_at_period_end: boolean + currency: string + current_period_end: string + current_period_start: string + first_name: string + last_name: string + plan: Database["public"]["Enums"]["subscription_plan"] + product_name: string + status: string + subscription_id: string + unit_amount: number + user_email: string + user_id: string + }[] + } get_stripe_prices: { - Args: never; + Args: never Returns: { - active: boolean; - created: number; - currency: string; - id: string; - metadata: Json; - product: string; - recurring: Json; - unit_amount: number; - }[]; - }; + active: boolean + created: number + currency: string + id: string + metadata: Json + product: string + recurring: Json + unit_amount: number + }[] + } get_stripe_products: { - Args: never; + Args: never Returns: { - active: boolean; - created: number; - description: string; - id: string; - metadata: Json; - name: string; - }[]; - }; + active: boolean + created: number + description: string + id: string + metadata: Json + name: string + }[] + } get_user_stripe_customer: { - Args: never; + Args: never Returns: { - created: number; - email: string; - id: string; - metadata: Json; - user_id: string; - }[]; - }; + created: number + email: string + id: string + metadata: Json + user_id: string + }[] + } get_user_stripe_customer_id: { - Args: { user_uuid: string }; - Returns: string; - }; + Args: { user_uuid: string } + Returns: string + } get_user_stripe_subscriptions: { - Args: never; + Args: never Returns: { - cancel_at_period_end: boolean; - canceled_at: number; - created: number; - current_period_end: number; - current_period_start: number; - customer: string; - id: string; - metadata: Json; - price_id: string; - quantity: number; - status: string; - trial_end: Json; - trial_start: Json; - user_id: string; - }[]; - }; + cancel_at_period_end: boolean + canceled_at: number + created: number + current_period_end: number + current_period_start: number + customer: string + id: string + metadata: Json + price_id: string + quantity: number + status: string + trial_end: Json + trial_start: Json + user_id: string + }[] + } get_user_subscription_status: { - Args: { user_uuid: string }; + Args: { user_uuid: string } Returns: { - cancel_at_period_end: boolean; - current_period_end: number; - current_period_start: number; - plan: Database["public"]["Enums"]["subscription_plan"]; - price_id: string; - product_name: string; - status: string; - subscription_id: string; - }[]; - }; - is_paying_user: { Args: { user_uuid: string }; Returns: boolean }; - }; + cancel_at_period_end: boolean + current_period_end: number + current_period_start: number + plan: Database["public"]["Enums"]["subscription_plan"] + price_id: string + product_name: string + status: string + subscription_id: string + }[] + } + is_freemium_available: { Args: never; Returns: boolean } + is_paying_user: { Args: { user_uuid: string }; Returns: boolean } + } Enums: { - devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"; - subscription_plan: "none" | "trial" | "standard" | "beta" | "free"; - task_status: "todo" | "in_progress" | "in_review" | "done"; - }; + devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired" + subscription_plan: + | "none" + | "trial" + | "standard" + | "beta" + | "free" + | "solo" + | "team" + | "annual" + task_status: "todo" | "in_progress" | "in_review" | "done" + } CompositeTypes: { time_range: { - start_time: string | null; - end_time: string | null; - }; - }; - }; -}; + start_time: string | null + end_time: string | null + } + } + } +} -type DatabaseWithoutInternals = Omit; +type DatabaseWithoutInternals = Omit -type DefaultSchema = DatabaseWithoutInternals[Extract]; +type DefaultSchema = DatabaseWithoutInternals[Extract] export type Tables< DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R; + Row: infer R } ? R : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R; + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R } ? R : never - : never; + : never export type TablesInsert< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I; + Insert: infer I } ? I : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I; + Insert: infer I } ? I : never - : never; + : never export type TablesUpdate< DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U; + Update: infer U } ? U : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U; + Update: infer U } ? U : never - : never; + : never export type Enums< DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never, > = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never; + : never export type CompositeTypes< PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never, > = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals; + schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never; + : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { devis_status: ["draft", "sent", "accepted", "rejected", "expired"], - subscription_plan: ["none", "trial", "standard", "beta", "free"], + subscription_plan: [ + "none", + "trial", + "standard", + "beta", + "free", + "solo", + "team", + "annual", + ], task_status: ["todo", "in_progress", "in_review", "done"], }, }, -} as const; +} as const + diff --git a/supabase/migrations/20260502120000_add_apple_billing_tables.sql b/supabase/migrations/20260502120000_add_apple_billing_tables.sql new file mode 100644 index 0000000..13e1de5 --- /dev/null +++ b/supabase/migrations/20260502120000_add_apple_billing_tables.sql @@ -0,0 +1,52 @@ +create table public.apple_customers ( + id bigint generated by default as identity primary key, + user_id uuid not null references public.profiles (id) on delete cascade, + revenuecat_app_user_id text not null, + original_app_user_id text, + last_seen_environment text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id), + unique (revenuecat_app_user_id) +); + +create table public.apple_subscriptions ( + id bigint generated by default as identity primary key, + owner_user_id uuid not null references public.profiles (id) on delete cascade, + revenuecat_app_user_id text not null, + store_product_id text not null, + plan text not null, + status text not null, + environment text not null, + store text not null default 'app_store', + original_transaction_id text not null, + transaction_id text, + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at_period_end boolean not null default false, + revoked_at timestamptz, + raw_customer_id text, + last_event_type text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (original_transaction_id) +); + +create index idx_apple_subscriptions_owner_user_id + on public.apple_subscriptions (owner_user_id); + +create index idx_apple_subscriptions_status + on public.apple_subscriptions (status); + +create index idx_apple_subscriptions_current_period_end + on public.apple_subscriptions (current_period_end); + +create table public.apple_subscription_events ( + id bigint generated by default as identity primary key, + event_id text not null unique, + event_type text not null, + environment text, + payload jsonb not null, + received_at timestamptz not null default now(), + processed_at timestamptz +); diff --git a/supabase/tests/database/13_apple_billing.test.sql b/supabase/tests/database/13_apple_billing.test.sql new file mode 100644 index 0000000..70e26c2 --- /dev/null +++ b/supabase/tests/database/13_apple_billing.test.sql @@ -0,0 +1,101 @@ +begin; +select plan(17); + +-- ============================================================================ +-- Table Existence +-- ============================================================================ + +SELECT has_table('public', 'apple_customers', 'apple_customers table should exist'); +SELECT has_table('public', 'apple_subscriptions', 'apple_subscriptions table should exist'); +SELECT has_table( + 'public', + 'apple_subscription_events', + 'apple_subscription_events table should exist' +); + +-- ============================================================================ +-- Column Coverage +-- ============================================================================ + +SELECT has_column('public', 'apple_customers', 'user_id', 'apple_customers should have user_id'); +SELECT has_column( + 'public', + 'apple_customers', + 'revenuecat_app_user_id', + 'apple_customers should have revenuecat_app_user_id' +); +SELECT has_column( + 'public', + 'apple_subscriptions', + 'owner_user_id', + 'apple_subscriptions should have owner_user_id' +); +SELECT has_column( + 'public', + 'apple_subscriptions', + 'original_transaction_id', + 'apple_subscriptions should have original_transaction_id' +); +SELECT has_column( + 'public', + 'apple_subscription_events', + 'event_id', + 'apple_subscription_events should have event_id' +); + +-- ============================================================================ +-- Primary Keys + Constraints +-- ============================================================================ + +SELECT has_pk('public', 'apple_customers', 'apple_customers should have primary key'); +SELECT has_pk('public', 'apple_subscriptions', 'apple_subscriptions should have primary key'); +SELECT has_pk( + 'public', + 'apple_subscription_events', + 'apple_subscription_events should have primary key' +); + +SELECT has_index( + 'public', + 'apple_customers', + 'apple_customers_user_id_key', + 'apple_customers.user_id unique constraint should exist' +); +SELECT has_index( + 'public', + 'apple_customers', + 'apple_customers_revenuecat_app_user_id_key', + 'apple_customers.revenuecat_app_user_id unique constraint should exist' +); +SELECT has_index( + 'public', + 'apple_subscriptions', + 'apple_subscriptions_original_transaction_id_key', + 'apple_subscriptions.original_transaction_id unique constraint should exist' +); + +-- ============================================================================ +-- Performance Indexes +-- ============================================================================ + +SELECT has_index( + 'public', + 'apple_subscriptions', + 'idx_apple_subscriptions_owner_user_id', + 'apple_subscriptions owner_user_id index should exist' +); +SELECT has_index( + 'public', + 'apple_subscriptions', + 'idx_apple_subscriptions_status', + 'apple_subscriptions status index should exist' +); +SELECT has_index( + 'public', + 'apple_subscriptions', + 'idx_apple_subscriptions_current_period_end', + 'apple_subscriptions current_period_end index should exist' +); + +select * from finish(); +rollback; diff --git a/xtablo-expo/app/(app)/(tabs)/settings.tsx b/xtablo-expo/app/(app)/(tabs)/settings.tsx index 9e99eb5..47961f8 100644 --- a/xtablo-expo/app/(app)/(tabs)/settings.tsx +++ b/xtablo-expo/app/(app)/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { View, Text, @@ -9,6 +9,7 @@ import { Switch, Alert, Linking, + ActivityIndicator, } from "react-native"; import { LinearGradient } from "expo-linear-gradient"; import { useAuthStore } from "@/stores/auth"; @@ -38,11 +39,26 @@ import { } from "@/constants/colors"; import { useThemeColor } from "@/hooks/useThemeColor"; import { useColorScheme } from "@/hooks/useColorScheme"; +import { + hasOrganizationBillingAccess, + shouldShowInAppBillingPaywall, + useOrganizationBilling, +} from "@/hooks/organization"; +import { BillingPaywall } from "@/components/BillingPaywall"; +import { + canUseInAppPurchases, + getBillingPackageOptions, + isPurchaseCancelledError, + purchaseBillingPackage, + restoreBillingPurchases, + type BillingPackageOption, +} from "@/lib/purchases"; export default function SettingsScreen() { const signOut = useAuthStore((state) => state.signOut); const user = useUser(); const colorScheme = useColorScheme(); + const organizationBillingQuery = useOrganizationBilling(); // Theme-aware colors const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background"); @@ -61,8 +77,72 @@ export default function SettingsScreen() { const [pushNotifications, setPushNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true); const [biometricAuth, setBiometricAuth] = useState(false); + const [billingPackages, setBillingPackages] = useState([]); + const [billingError, setBillingError] = useState(null); + const [isLoadingPackages, setIsLoadingPackages] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const [isSyncingBilling, setIsSyncingBilling] = useState(false); const isDeletingAccount = React.useRef(false); + const organizationBilling = organizationBillingQuery.data; + const hasBillingAccess = organizationBilling + ? hasOrganizationBillingAccess(organizationBilling) + : false; + const canShowInAppPaywall = organizationBilling + ? shouldShowInAppBillingPaywall(organizationBilling) + : false; + + const visibleBillingPackages = useMemo(() => { + if (!organizationBilling) { + return []; + } + + return billingPackages.filter((pkg) => + organizationBilling.required_plan === "team" ? pkg.plan === "annual" : true + ); + }, [billingPackages, organizationBilling]); + + useEffect(() => { + let cancelled = false; + + const loadBillingPackages = async () => { + if (!user.id || !canShowInAppPaywall || !canUseInAppPurchases()) { + setBillingPackages([]); + setBillingError(null); + setIsLoadingPackages(false); + return; + } + + setBillingError(null); + setIsLoadingPackages(true); + + try { + const packages = await getBillingPackageOptions(user.id); + if (!cancelled) { + setBillingPackages(packages); + } + } catch (error) { + console.error("Failed to load RevenueCat packages:", error); + if (!cancelled) { + setBillingError( + "Impossible de charger les offres Apple pour le moment. Réessayez dans quelques instants." + ); + } + } finally { + if (!cancelled) { + setIsLoadingPackages(false); + } + } + }; + + loadBillingPackages(); + + return () => { + cancelled = true; + }; + }, [canShowInAppPaywall, user.id]); + const handleSignOut = () => { Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [ { @@ -197,6 +277,162 @@ export default function SettingsScreen() { false ); + const waitForBillingSync = async () => { + setIsSyncingBilling(true); + setBillingError(null); + + try { + for (let attempt = 0; attempt < 6; attempt += 1) { + const result = await organizationBillingQuery.refetch(); + if (result.data && hasOrganizationBillingAccess(result.data)) { + return true; + } + + if (attempt < 5) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + setBillingError( + "L'achat a bien ete recu, mais la synchronisation serveur prend plus de temps que prevu." + ); + return false; + } finally { + setIsSyncingBilling(false); + } + }; + + const handlePurchase = async (pkg: BillingPackageOption) => { + if (!user.id || isPurchasing || isSyncingBilling) { + return; + } + + setBillingError(null); + setIsPurchasing(true); + + try { + await purchaseBillingPackage(user.id, pkg.package); + await waitForBillingSync(); + } catch (error) { + if (!isPurchaseCancelledError(error)) { + console.error("Apple purchase failed:", error); + setBillingError("Le paiement Apple a echoue. Merci de reessayer."); + } + } finally { + setIsPurchasing(false); + } + }; + + const handleRestorePurchases = async () => { + if (!user.id || isRestoring || isSyncingBilling) { + return; + } + + setBillingError(null); + setIsRestoring(true); + + try { + await restoreBillingPurchases(user.id); + await waitForBillingSync(); + } catch (error) { + console.error("Apple restore failed:", error); + setBillingError("La restauration des achats a echoue. Merci de reessayer."); + } finally { + setIsRestoring(false); + } + }; + + const renderBillingContent = () => { + if (organizationBillingQuery.isLoading) { + return ( + + + + Chargement de votre statut d'abonnement… + + + ); + } + + if (organizationBillingQuery.error || !organizationBilling) { + return ( + + + Impossible de charger la facturation de l'organisation. + + + ); + } + + if (hasBillingAccess && organizationBilling.active_subscription_plan) { + const subscriptionLabel = + organizationBilling.active_subscription_plan === "annual" + ? "Annuel" + : organizationBilling.active_subscription_plan === "team" + ? "Equipe" + : "Solo"; + + return renderSettingsItem( + , + "Abonnement actif", + `${subscriptionLabel} • ${organizationBilling.organization.name}`, + undefined, + undefined, + false + ); + } + + if (hasBillingAccess && !organizationBilling.is_trial_expired) { + const trialEndsAt = new Date(organizationBilling.trial_ends_at).toLocaleDateString("fr-FR", { + day: "numeric", + month: "long", + }); + + return renderSettingsItem( + , + "Essai actif", + `Votre acces d'essai reste actif jusqu'au ${trialEndsAt}.`, + undefined, + undefined, + false + ); + } + + if (!canUseInAppPurchases()) { + return ( + + + Les achats integres ne sont disponibles que sur iPhone avec une build de developpement + native. + + + ); + } + + return ( + + + + ); + }; + return ( )} + {renderSettingsSection("Abonnement", renderBillingContent())} + {/* {renderSettingsSection( "Notifications", <> @@ -531,4 +769,18 @@ const styles = StyleSheet.create({ bottomSpacing: { height: 100, }, + billingBox: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + billingErrorText: { + color: "#b91c1c", + fontSize: 14, + lineHeight: 20, + }, + billingHelperText: { + fontSize: 14, + lineHeight: 20, + marginTop: 10, + }, }); diff --git a/xtablo-expo/app/signup.tsx b/xtablo-expo/app/signup.tsx index 8b30c12..ca4f25d 100644 --- a/xtablo-expo/app/signup.tsx +++ b/xtablo-expo/app/signup.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { StyleSheet, View, Text, Image } from "react-native"; +import { Alert, StyleSheet, View, Text, Image } from "react-native"; import { Button, Input } from "@rn-vui/themed"; import { useAuthStore } from "@/stores/auth"; import { Link } from "expo-router"; @@ -30,6 +30,19 @@ export default function SignUp() { ? require("@/assets/images/logo_white.png") : require("@/assets/images/logo.png"); + const handleSignUp = async () => { + try { + await signUp(email, password, firstName, lastName, companyName); + } catch (error) { + Alert.alert( + "Erreur", + error instanceof Error + ? error.message + : "Impossible de créer votre compte pour le moment." + ); + } + }; + return ( @@ -91,7 +104,7 @@ export default function SignUp() {