Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
51e7277
feat(db): add hasProSeat column to organization_members
richiemcilroy Mar 3, 2026
7673b00
feat(utils): add calculateProSeats helper for Pro seat tracking
richiemcilroy Mar 3, 2026
179ead4
refactor(invites): rewrite send-invites with transactions and dedup
richiemcilroy Mar 3, 2026
6f5ad5c
feat(org): add get-subscription-details server action
richiemcilroy Mar 3, 2026
2d1354e
feat(org): add toggle-pro-seat server action
richiemcilroy Mar 3, 2026
272923c
feat(org): add seat quantity management server action
richiemcilroy Mar 3, 2026
7e7520f
refactor(invites): rewrite invite accept with transactions and Pro se…
richiemcilroy Mar 3, 2026
a6c7cb4
feat(dashboard): add invite dialog state to dashboard context
richiemcilroy Mar 3, 2026
9faadfd
refactor(org): simplify InviteDialog and remove seat-limit checks
richiemcilroy Mar 3, 2026
a6143bc
feat(org): add Pro seat toggle and mutation patterns to MembersCard
richiemcilroy Mar 3, 2026
da98ca6
feat(org): add BillingSummaryCard and SeatManagementCard components
richiemcilroy Mar 3, 2026
3e55f5f
feat(org): add settings nav and layout with auth guard
richiemcilroy Mar 3, 2026
8611afd
refactor(org): restructure organization settings into sub-pages
richiemcilroy Mar 3, 2026
20b5e46
feat(navbar): add MemberAvatars component with invite prompt
richiemcilroy Mar 3, 2026
c081e04
feat(navbar): integrate MemberAvatars and org settings sub-routes
richiemcilroy Mar 3, 2026
5519f91
fix(invites): deduplicate emails after normalization to prevent dupli…
richiemcilroy Mar 3, 2026
bf8db1c
fix(seats): add idempotency guard to toggleProSeat for already-assign…
richiemcilroy Mar 3, 2026
f858a56
fix(seats): add row lock on allMembers query to prevent race condition
richiemcilroy Mar 3, 2026
b3a63b8
fix(billing): handle Stripe/DB divergence on seat quantity update fai…
richiemcilroy Mar 3, 2026
c6fd14a
fix(invite): handle invalid JSON body gracefully in accept route
richiemcilroy Mar 3, 2026
2e5442c
fix(invite): prevent silent Pro seat escalation on re-accept by exist…
richiemcilroy Mar 3, 2026
1fa3fd8
fix(invite): always switch activeOrganizationId on invite accept
richiemcilroy Mar 3, 2026
f3ab753
fix(invite): handle cleanup failure on failed email delivery
richiemcilroy Mar 3, 2026
c82516f
fix(seats): add upper-bound guard on seat increment button
richiemcilroy Mar 3, 2026
024bf20
fix(billing): remove unused field and use nullish coalescing for quan…
richiemcilroy Mar 3, 2026
2af60c4
fix(billing): use nullish coalescing for subscription quantity
richiemcilroy Mar 3, 2026
65547e6
fix(seats): update thirdPartyStripeSubscriptionId to remaining org on…
richiemcilroy Mar 3, 2026
968703b
fix(invite): hide invite slots from non-owner members in sidebar
richiemcilroy Mar 3, 2026
89b0d4f
fix(billing): show error state instead of upsell card on query failure
richiemcilroy Mar 3, 2026
aa9d327
fix(seats): surface preview errors and disable confirm on failure
richiemcilroy Mar 3, 2026
7caa889
fix(seats): null out subscription ID when no remaining Pro-seat org f…
richiemcilroy Mar 3, 2026
1a1b41f
fix(billing): narrow Stripe IDs to non-null before preview API call
richiemcilroy Mar 3, 2026
516031a
fix(invite): validate emails client-side and surface failed deliveries
richiemcilroy Mar 3, 2026
c1c743c
fix(seats): show loading state during debounce and block confirm unti…
richiemcilroy Mar 3, 2026
15d94d3
fix(billing): calculate proration from line items instead of total am…
richiemcilroy Mar 3, 2026
df8a6b0
fix(settings): hide billing label in settings nav for self-hosted ins…
richiemcilroy Mar 3, 2026
3eeb701
fix(invite): reset dialog state on close and remove redundant clear
richiemcilroy Mar 3, 2026
4610f9c
fix(seats): distinguish prorated charge, credit, and zero adjustment …
richiemcilroy Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/web/actions/organization/get-subscription-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { organizations, users } from "@cap/database/schema";
import { stripe } from "@cap/utils";
import type { Organisation } from "@cap/web-domain";
import { eq } from "drizzle-orm";

export type SubscriptionDetails = {
planName: string;
status: string;
billingInterval: "month" | "year";
pricePerSeat: number;
currentQuantity: number;
currentPeriodEnd: number;
currency: string;
};

export async function getSubscriptionDetails(
organizationId: Organisation.OrganisationId,
): Promise<SubscriptionDetails | null> {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");

const [organization] = await db()
.select()
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);

if (!organization) throw new Error("Organization not found");
if (organization.ownerId !== user.id)
throw new Error("Only the owner can view subscription details");

const [owner] = await db()
.select({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stripeSubscriptionStatus is selected but unused here; removing it avoids unused-data drift.

Suggested change
.select({
.select({
stripeSubscriptionId: users.stripeSubscriptionId,
})

stripeSubscriptionId: users.stripeSubscriptionId,
})
.from(users)
.where(eq(users.id, user.id))
.limit(1);

if (!owner?.stripeSubscriptionId) {
return null;
}

const subscription = await stripe().subscriptions.retrieve(
owner.stripeSubscriptionId,
);

if (subscription.status !== "active" && subscription.status !== "trialing") {
return null;
}

const item = subscription.items.data[0];
if (!item) return null;

const price = item.price;
const unitAmount = price.unit_amount ?? 0;
const interval = price.recurring?.interval === "year" ? "year" : "month";
const pricePerSeat =
interval === "year" ? unitAmount / 100 / 12 : unitAmount / 100;

return {
planName: "Cap Pro",
status: subscription.status,
billingInterval: interval,
pricePerSeat,
currentQuantity: item.quantity ?? 1,
currentPeriodEnd: subscription.current_period_end,
currency: price.currency,
};
}
153 changes: 123 additions & 30 deletions apps/web/actions/organization/send-invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import { getCurrentUser } from "@cap/database/auth/session";
import { sendEmail } from "@cap/database/emails/config";
import { OrganizationInvite } from "@cap/database/emails/organization-invite";
import { nanoId } from "@cap/database/helpers";
import { organizationInvites, organizations } from "@cap/database/schema";
import {
organizationInvites,
organizationMembers,
organizations,
users,
} from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import type { Organisation } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function sendOrganizationInvites(
Expand All @@ -21,48 +26,136 @@ export async function sendOrganizationInvites(
throw new Error("Unauthorized");
}

const organization = await db()
const [organization] = await db()
.select()
.from(organizations)
.where(eq(organizations.id, organizationId));

if (!organization || organization.length === 0) {
if (!organization) {
throw new Error("Organization not found");
}

if (organization[0]?.ownerId !== user.id) {
throw new Error("Only the owner can send organization invites");
if (organization.ownerId !== user.id) {
throw new Error("Only the organization owner can send invites");
}

const MAX_INVITES = 50;
if (invitedEmails.length > MAX_INVITES) {
throw new Error(`Cannot send more than ${MAX_INVITES} invites at once`);
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const validEmails = invitedEmails.filter((email) =>
emailRegex.test(email.trim()),
const validEmails = Array.from(
new Set(
invitedEmails
.map((email) => email.trim().toLowerCase())
.filter((email) => emailRegex.test(email)),
),
);

if (validEmails.length === 0) {
return { success: true, failedEmails: [] as string[] };
}

const inviteRecords = await db().transaction(async (tx) => {
const [existingInvites, existingMembers] = await Promise.all([
tx
.select({ invitedEmail: organizationInvites.invitedEmail })
.from(organizationInvites)
.where(
and(
eq(organizationInvites.organizationId, organizationId),
inArray(organizationInvites.invitedEmail, validEmails),
),
),
tx
.select({ email: users.email })
.from(organizationMembers)
.innerJoin(users, eq(organizationMembers.userId, users.id))
.where(
and(
eq(organizationMembers.organizationId, organizationId),
inArray(users.email, validEmails),
),
),
]);

const existingInviteEmails = new Set(
existingInvites.map((i) => i.invitedEmail.toLowerCase()),
);

const existingMemberEmails = new Set(
existingMembers.map((m) => m.email.toLowerCase()),
);

const emailsToInvite = validEmails.filter(
(email) =>
!existingInviteEmails.has(email) && !existingMemberEmails.has(email),
);

const records = emailsToInvite.map((email) => ({
id: nanoId(),
email,
}));

if (records.length > 0) {
await tx.insert(organizationInvites).values(
records.map((r) => ({
id: r.id,
organizationId: organizationId,
invitedEmail: r.email,
invitedByUserId: user.id,
role: "member" as const,
})),
);
}

return records;
});

const emailResults = await Promise.allSettled(
inviteRecords.map((record) => {
const inviteUrl = `${serverEnv().WEB_URL}/invite/${record.id}`;
return sendEmail({
email: record.email,
subject: `Invitation to join ${organization.name} on Cap`,
react: OrganizationInvite({
email: record.email,
url: inviteUrl,
organizationName: organization.name,
}),
});
}),
);

const failedInvites = inviteRecords.filter(
(_, i) => emailResults[i]?.status === "rejected",
);
const failedEmails = failedInvites.map((r) => r.email);

for (const email of validEmails) {
const inviteId = nanoId();
await db().insert(organizationInvites).values({
id: inviteId,
organizationId: organizationId,
invitedEmail: email.trim(),
invitedByUserId: user.id,
role: "member",
});

// Send invitation email
const inviteUrl = `${serverEnv().WEB_URL}/invite/${inviteId}`;
await sendEmail({
email: email.trim(),
subject: `Invitation to join ${organization[0].name} on Cap`,
react: OrganizationInvite({
email: email.trim(),
url: inviteUrl,
organizationName: organization[0].name,
}),
});
if (failedInvites.length > 0) {
try {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
} catch (cleanupError) {
console.error(
"Failed to clean up invite records after email delivery failure:",
{
failedInviteIds: failedInvites.map((r) => r.id),
failedEmails,
error: cleanupError,
},
);
}
}
Comment on lines +136 to 156
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled cleanup failure can orphan invite records

When email delivery fails, the subsequent db().delete() cleanup (lines 136–145) runs outside the transaction with no error handling. If this delete fails due to a transient DB error, the undelivered invite records will persist indefinitely — creating a data integrity gap. A user who somehow obtains the invite ID (e.g., from a server log) could still accept the invite without having been notified.

Wrap the cleanup in a try/catch to detect and log the failure:

Suggested change
if (failedInvites.length > 0) {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
}
if (failedInvites.length > 0) {
try {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
} catch (cleanupError) {
console.error(
"CRITICAL: Failed to clean up undelivered invite records:",
failedInvites.map((r) => r.id),
cleanupError,
);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/send-invites.ts
Line: 136-145

Comment:
**Unhandled cleanup failure can orphan invite records**

When email delivery fails, the subsequent `db().delete()` cleanup (lines 136–145) runs outside the transaction with no error handling. If this delete fails due to a transient DB error, the undelivered invite records will persist indefinitely — creating a data integrity gap. A user who somehow obtains the invite ID (e.g., from a server log) could still accept the invite without having been notified.

Wrap the cleanup in a try/catch to detect and log the failure:

```suggestion
if (failedInvites.length > 0) {
  try {
    await db()
      .delete(organizationInvites)
      .where(
        inArray(
          organizationInvites.id,
          failedInvites.map((r) => r.id),
        ),
      );
  } catch (cleanupError) {
    console.error(
      "CRITICAL: Failed to clean up undelivered invite records:",
      failedInvites.map((r) => r.id),
      cleanupError,
    );
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.


revalidatePath("/dashboard/settings/organization");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since settings now live under /dashboard/settings/organization/*, this won’t invalidate the tab routes. Revalidating the layout should cover /billing, /preferences, etc.

Suggested change
revalidatePath("/dashboard/settings/organization");
revalidatePath("/dashboard/settings/organization", "layout");


return { success: true };
return { success: true, failedEmails };
}
Loading
Loading