From 76f497d2c8eafacc518f80c4633c47fbea011603 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 8 Mar 2026 21:43:31 +0100 Subject: [PATCH] Keep founder plan during invites --- .../api/src/__tests__/helpers/billing.test.ts | 5 + apps/api/src/helpers/billing.ts | 31 ++++- apps/api/src/helpers/helpers.ts | 4 +- apps/api/src/routers/user.ts | 88 ++++++++++++++ apps/main/src/hooks/organization.ts | 16 +++ apps/main/src/hooks/stripe.ts | 4 +- apps/main/src/locales/en/settings.json | 10 +- apps/main/src/locales/fr/settings.json | 10 +- apps/main/src/pages/settings.tsx | 110 +++++++++++++++++- ...14500_add_organization_invites_history.sql | 75 ++++++++++++ 10 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/20260308114500_add_organization_invites_history.sql diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts index 6316b60..fc2f934 100644 --- a/apps/api/src/__tests__/helpers/billing.test.ts +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -12,11 +12,13 @@ describe("billing helpers", () => { id: "owner-user", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + plan: "annual", }, { id: "late-user", created_at: "2026-01-02T10:00:00.000Z", is_temporary: false, + plan: "solo", }, ]); @@ -29,16 +31,19 @@ describe("billing helpers", () => { id: "user-1", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + plan: "solo", }, { id: "temp-1", created_at: "2026-01-02T10:00:00.000Z", is_temporary: true, + plan: "solo", }, { id: "user-2", created_at: "2026-01-03T10:00:00.000Z", is_temporary: null, + plan: "team", }, ]); diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts index 3ad4866..5bcaf63 100644 --- a/apps/api/src/helpers/billing.ts +++ b/apps/api/src/helpers/billing.ts @@ -7,6 +7,7 @@ type BillingProfileRow = { id: string; created_at: string | null; is_temporary: boolean | null; + plan: string | null; }; type StripeCustomerRow = { @@ -141,6 +142,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu return null; }; +const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => { + const normalized = (plan ?? "").toLowerCase(); + + if (normalized === "annual" || normalized === "beta" || normalized === "founder") { + return "annual"; + } + + if (normalized === "team" || normalized === "standard") { + return "team"; + } + + return "solo"; +}; + const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => { const parts = [ price?.metadata?.plan, @@ -159,7 +174,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => { const { data, error } = await supabase .from("profiles") - .select("id, created_at, is_temporary") + .select("id, created_at, is_temporary, plan") .eq("organization_id", organizationId) .order("created_at", { ascending: true }); @@ -177,7 +192,8 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: const resolveActiveSubscription = async ( supabase: SupabaseClient, - ownerUserId: string + ownerUserId: string, + ownerProfilePlan: string | null ): Promise<{ plan: BillingPlan | null; quantity: number }> => { const { data: customers, error: customersError } = await supabase .schema("stripe") @@ -213,6 +229,8 @@ const resolveActiveSubscription = async ( 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") @@ -297,7 +315,7 @@ const resolveActiveSubscription = async ( }); if (candidates.length === 0) { - return { plan: "team", quantity: 1 }; + return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 }; } candidates.sort((a, b) => { @@ -318,8 +336,9 @@ const resolveActiveSubscription = async ( const winner = candidates[0]; return { - // Backward-compatible default for unknown paid subscriptions. - plan: winner.plan ?? "team", + // 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, }; }; @@ -354,7 +373,7 @@ export const getOrganizationBillingState = async ( const requiredPlan = resolveRequiredPlan(memberCount); try { - const activeSubscription = await resolveActiveSubscription(supabase, owner.id); + const activeSubscription = await resolveActiveSubscription(supabase, owner.id, owner.plan); return { data: { diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 4b0833b..eba5984 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -20,7 +20,9 @@ const PLAN_WEIGHT: Record = { export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => { if (!plan) return "solo"; - if (plan === "annual" || plan === "beta") return "annual"; + if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") { + return "annual"; + } if (plan === "team" || plan === "standard") return "team"; return "solo"; }; diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 0d8d0a0..b0df3cc 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -7,6 +7,8 @@ import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../help import type { AuthEnv } from "../types/app.types.js"; const factory = createFactory(); +const isMissingRelationError = (code: string | undefined) => + code === "42P01" || code === "PGRST205"; const signUpToStream = factory.createHandlers(async (c) => { const { id } = c.get("user"); @@ -339,6 +341,80 @@ const getOrganization = factory.createHandlers(async (c) => { return c.json({ error: "Failed to resolve organization billing state" }, 500); } + let invitesSent: Array<{ + id: number; + invited_email: string; + invited_user_id: string | null; + created_at: string; + invited_member: { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + } | null; + }> = []; + + const { data: invitesData, error: invitesError } = await supabase + .from("organization_invites") + .select( + ` + id, + invited_email, + invited_user_id, + created_at, + invited_member:profiles!organization_invites_invited_user_id_fkey( + id, + email, + name, + first_name, + last_name, + avatar_url + ) + ` + ) + .eq("organization_id", organizationId) + .eq("invited_by", user.id) + .order("created_at", { ascending: false }); + + if (invitesError && !isMissingRelationError(invitesError.code)) { + return c.json({ error: "Failed to load organization invites" }, 500); + } + + if (!invitesError && invitesData) { + invitesSent = invitesData.map((invite) => { + const invitedMemberRaw = invite.invited_member as + | { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + } + | { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + }[] + | null; + + return { + id: invite.id as number, + invited_email: (invite.invited_email as string) ?? "", + invited_user_id: (invite.invited_user_id as string | null) ?? null, + created_at: (invite.created_at as string) ?? new Date().toISOString(), + invited_member: Array.isArray(invitedMemberRaw) + ? (invitedMemberRaw[0] ?? null) + : invitedMemberRaw, + }; + }); + } + return c.json({ organization: { id: organization.id, @@ -356,6 +432,7 @@ const getOrganization = factory.createHandlers(async (c) => { active_subscription_plan: billingState.active_subscription_plan, active_subscription_quantity: billingState.active_subscription_quantity, is_billing_owner: billingState.owner_user_id === user.id, + invites_sent: invitesSent, }); }); @@ -564,6 +641,17 @@ const inviteToOrganization = factory.createHandlers(async (c) => { } } + const { error: inviteHistoryError } = await supabase.from("organization_invites").insert({ + organization_id: organizationId, + invited_by: user.id, + invited_email: recipientEmail, + invited_user_id: invitedUser.userId, + }); + + if (inviteHistoryError && !isMissingRelationError(inviteHistoryError.code)) { + console.error("Failed to store organization invite history:", inviteHistoryError); + } + return c.json({ message: "Invitation sent successfully", limits: { diff --git a/apps/main/src/hooks/organization.ts b/apps/main/src/hooks/organization.ts index d567a8c..c33e5e6 100644 --- a/apps/main/src/hooks/organization.ts +++ b/apps/main/src/hooks/organization.ts @@ -25,6 +25,7 @@ export interface OrganizationMember { export interface OrganizationResponse { organization: OrganizationSummary; members: OrganizationMember[]; + invites_sent: OrganizationInvite[]; trial_starts_at: string; trial_ends_at: string; is_trial_expired: boolean; @@ -35,6 +36,21 @@ export interface OrganizationResponse { is_billing_owner: boolean; } +export interface OrganizationInvite { + id: number; + invited_email: string; + invited_user_id: string | null; + created_at: string; + invited_member: { + id: string; + email: string | null; + name: string | null; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + } | null; +} + export const useOrganization = () => { const api = useAuthedApi(); diff --git a/apps/main/src/hooks/stripe.ts b/apps/main/src/hooks/stripe.ts index 6d1d0d1..b46b4e9 100644 --- a/apps/main/src/hooks/stripe.ts +++ b/apps/main/src/hooks/stripe.ts @@ -18,7 +18,9 @@ export type CheckoutPlan = "solo" | "team" | "founder"; export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => { if (!plan) return "solo"; - if (plan === "annual" || plan === "beta") return "annual"; + if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") { + return "annual"; + } if (plan === "team" || plan === "standard") return "team"; return "solo"; }; diff --git a/apps/main/src/locales/en/settings.json b/apps/main/src/locales/en/settings.json index c641985..85ea421 100644 --- a/apps/main/src/locales/en/settings.json +++ b/apps/main/src/locales/en/settings.json @@ -49,7 +49,15 @@ "emailPlaceholder": "name@company.com", "hint": "The invited user will get credentials by email.", "invite": "Send invite", - "inviting": "Sending..." + "inviting": "Sending...", + "invitedByYouTitle": "Users invited by you", + "noInvitesYet": "You have not invited anyone yet.", + "invitedOn": "Invited on {{date}}", + "membersTitle": "Other organization members", + "noOtherMembers": "No other members in your organization yet.", + "joinedOn": "Joined on {{date}}", + "unknownDate": "unknown date", + "unknownUser": "Unknown user" }, "cookies": { "title": "Cookie Preferences", diff --git a/apps/main/src/locales/fr/settings.json b/apps/main/src/locales/fr/settings.json index af553a2..53f7d86 100644 --- a/apps/main/src/locales/fr/settings.json +++ b/apps/main/src/locales/fr/settings.json @@ -49,7 +49,15 @@ "emailPlaceholder": "nom@entreprise.com", "hint": "L'utilisateur invité recevra ses identifiants par email.", "invite": "Envoyer l'invitation", - "inviting": "Envoi..." + "inviting": "Envoi...", + "invitedByYouTitle": "Utilisateurs invités par vous", + "noInvitesYet": "Vous n'avez encore invité personne.", + "invitedOn": "Invité le {{date}}", + "membersTitle": "Autres membres de l'organisation", + "noOtherMembers": "Aucun autre membre dans votre organisation pour le moment.", + "joinedOn": "A rejoint le {{date}}", + "unknownDate": "date inconnue", + "unknownUser": "Utilisateur inconnu" }, "cookies": { "title": "Préférences des cookies", diff --git a/apps/main/src/pages/settings.tsx b/apps/main/src/pages/settings.tsx index 74ef094..17ddeb7 100644 --- a/apps/main/src/pages/settings.tsx +++ b/apps/main/src/pages/settings.tsx @@ -39,7 +39,7 @@ import { useUser } from "../providers/UserStoreProvider"; export default function SettingsPage() { const user = useUser(); - const { t } = useTranslation(["settings", "common"]); + const { t, i18n } = useTranslation(["settings", "common"]); const { introduction, updateIntroduction, @@ -70,6 +70,35 @@ export default function SettingsPage() { organizationData?.organization?.plan === "annual" ? "founder" : organizationData?.organization?.plan; + const invitedByCurrentUser = organizationData?.invites_sent || []; + const organizationMembers = (organizationData?.members || []).filter( + (member) => member.id !== user?.id + ); + + const getDisplayName = (input: { + first_name?: string | null; + last_name?: string | null; + name?: string | null; + email?: string | null; + }) => { + const combinedName = [input.first_name, input.last_name].filter(Boolean).join(" ").trim(); + if (combinedName) { + return combinedName; + } + + if (input.name) { + return input.name; + } + + return input.email || t("settings:teamInvite.unknownUser"); + }; + + const formatDate = (date: string) => + new Date(date).toLocaleDateString(i18n.language || undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); useEffect(() => { if (organizationData?.organization?.name) { @@ -362,7 +391,7 @@ export default function SettingsPage() { {t("settings:teamInvite.title")} {t("settings:teamInvite.description")} - +
+ +
+

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

+ {invitedByCurrentUser.length === 0 ? ( +

+ {t("settings:teamInvite.noInvitesYet")} +

+ ) : ( +
+ {invitedByCurrentUser.map((invite) => { + const member = invite.invited_member; + const displayName = getDisplayName({ + first_name: member?.first_name, + last_name: member?.last_name, + name: member?.name, + email: member?.email || invite.invited_email, + }); + + return ( +
+
+

{displayName}

+

+ {member?.email || invite.invited_email} +

+
+

+ {t("settings:teamInvite.invitedOn", { + date: formatDate(invite.created_at), + })} +

+
+ ); + })} +
+ )} +
+ +
+

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

+ {organizationMembers.length === 0 ? ( +

+ {t("settings:teamInvite.noOtherMembers")} +

+ ) : ( +
+ {organizationMembers.map((member) => ( +
+
+

+ {getDisplayName({ + first_name: member.first_name, + last_name: member.last_name, + name: member.name, + email: member.email, + })} +

+

{member.email}

+
+

+ {t("settings:teamInvite.joinedOn", { + date: member.created_at + ? formatDate(member.created_at) + : t("settings:teamInvite.unknownDate"), + })} +

+
+ ))} +
+ )} +
diff --git a/supabase/migrations/20260308114500_add_organization_invites_history.sql b/supabase/migrations/20260308114500_add_organization_invites_history.sql new file mode 100644 index 0000000..dab0106 --- /dev/null +++ b/supabase/migrations/20260308114500_add_organization_invites_history.sql @@ -0,0 +1,75 @@ +-- Track organization invitations sent by members so Settings can display invite history. + +CREATE TABLE IF NOT EXISTS public.organization_invites ( + id bigserial PRIMARY KEY, + organization_id integer NOT NULL, + invited_by uuid NOT NULL, + invited_email character varying(255) NOT NULL, + invited_user_id uuid, + created_at timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE public.organization_invites + DROP CONSTRAINT IF EXISTS organization_invites_organization_id_fkey; + +ALTER TABLE public.organization_invites + ADD CONSTRAINT organization_invites_organization_id_fkey + FOREIGN KEY (organization_id) + REFERENCES public.organizations(id) + ON DELETE CASCADE; + +ALTER TABLE public.organization_invites + DROP CONSTRAINT IF EXISTS organization_invites_invited_by_fkey; + +ALTER TABLE public.organization_invites + ADD CONSTRAINT organization_invites_invited_by_fkey + FOREIGN KEY (invited_by) + REFERENCES public.profiles(id) + ON DELETE CASCADE; + +ALTER TABLE public.organization_invites + DROP CONSTRAINT IF EXISTS organization_invites_invited_user_id_fkey; + +ALTER TABLE public.organization_invites + ADD CONSTRAINT organization_invites_invited_user_id_fkey + FOREIGN KEY (invited_user_id) + REFERENCES public.profiles(id) + ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS organization_invites_organization_id_idx + ON public.organization_invites (organization_id); + +CREATE INDEX IF NOT EXISTS organization_invites_invited_by_idx + ON public.organization_invites (invited_by); + +CREATE INDEX IF NOT EXISTS organization_invites_created_at_idx + ON public.organization_invites (created_at DESC); + +COMMENT ON TABLE public.organization_invites IS 'History of organization invitations sent by members.'; +COMMENT ON COLUMN public.organization_invites.organization_id IS 'Organization where the invite was sent.'; +COMMENT ON COLUMN public.organization_invites.invited_by IS 'Profile ID of the member who sent the invite.'; +COMMENT ON COLUMN public.organization_invites.invited_email IS 'Email that was invited.'; +COMMENT ON COLUMN public.organization_invites.invited_user_id IS 'Profile ID created for the invited email, when available.'; + +ALTER TABLE public.organization_invites ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can view organization invites they sent" ON public.organization_invites; +CREATE POLICY "Users can view organization invites they sent" + ON public.organization_invites + FOR SELECT + USING ( + organization_id = public.current_user_organization_id() + AND invited_by = auth.uid() + ); + +DROP POLICY IF EXISTS "Users can insert organization invites they sent" ON public.organization_invites; +CREATE POLICY "Users can insert organization invites they sent" + ON public.organization_invites + FOR INSERT + WITH CHECK ( + organization_id = public.current_user_organization_id() + AND invited_by = auth.uid() + ); + +GRANT SELECT, INSERT ON public.organization_invites TO authenticated; +GRANT USAGE, SELECT ON SEQUENCE public.organization_invites_id_seq TO authenticated;