mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(den): count pending invites and restore org drafts (#1302)
Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { eq, sql } from "@openwork-ee/den-db/drizzle"
|
||||
import { MemberTable, OrganizationTable, WorkerTable } from "@openwork-ee/den-db/schema"
|
||||
import { and, eq, gt, sql } from "@openwork-ee/den-db/drizzle"
|
||||
import { InvitationTable, MemberTable, OrganizationTable, WorkerTable } from "@openwork-ee/den-db/schema"
|
||||
import { db } from "./db.js"
|
||||
|
||||
export const DEFAULT_ORGANIZATION_LIMITS = {
|
||||
@@ -116,6 +116,15 @@ async function countOrganizationMembers(organizationId: OrganizationId) {
|
||||
return Number(rows[0]?.count ?? 0)
|
||||
}
|
||||
|
||||
async function countPendingOrganizationInvitations(organizationId: OrganizationId) {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(InvitationTable)
|
||||
.where(and(eq(InvitationTable.organizationId, organizationId), eq(InvitationTable.status, "pending"), gt(InvitationTable.expiresAt, new Date())))
|
||||
|
||||
return Number(rows[0]?.count ?? 0)
|
||||
}
|
||||
|
||||
async function countOrganizationWorkers(organizationId: OrganizationId) {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -129,7 +138,7 @@ export async function getOrganizationLimitStatus(organizationId: OrganizationId,
|
||||
const metadata = await getOrInitializeOrganizationMetadata(organizationId)
|
||||
const currentCount =
|
||||
limitType === "members"
|
||||
? await countOrganizationMembers(organizationId)
|
||||
? (await countOrganizationMembers(organizationId)) + (await countPendingOrganizationInvitations(organizationId))
|
||||
: await countOrganizationWorkers(organizationId)
|
||||
|
||||
const limit = metadata.limits[limitType]
|
||||
|
||||
@@ -51,17 +51,6 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
|
||||
}, 409)
|
||||
}
|
||||
|
||||
const memberLimit = await getOrganizationLimitStatus(payload.organization.id, "members")
|
||||
if (memberLimit.exceeded) {
|
||||
return c.json({
|
||||
error: "org_limit_reached",
|
||||
limitType: "members",
|
||||
limit: memberLimit.limit,
|
||||
currentCount: memberLimit.currentCount,
|
||||
message: `This workspace currently supports up to ${memberLimit.limit} members. Contact support to increase the limit.`,
|
||||
}, 409)
|
||||
}
|
||||
|
||||
const existingInvitation = await db
|
||||
.select()
|
||||
.from(InvitationTable)
|
||||
@@ -75,6 +64,19 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingInvitation[0]) {
|
||||
const memberLimit = await getOrganizationLimitStatus(payload.organization.id, "members")
|
||||
if (memberLimit.exceeded) {
|
||||
return c.json({
|
||||
error: "org_limit_reached",
|
||||
limitType: "members",
|
||||
limit: memberLimit.limit,
|
||||
currentCount: memberLimit.currentCount,
|
||||
message: `This workspace currently supports up to ${memberLimit.limit} members. Contact support to increase the limit.`,
|
||||
}, 409)
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
|
||||
const invitationId = existingInvitation[0]?.id ?? createInvitationId()
|
||||
|
||||
|
||||
@@ -9,9 +9,55 @@ import { useDenFlow } from "../_providers/den-flow-provider";
|
||||
|
||||
type SettingsTab = "profile" | "organizations";
|
||||
|
||||
const PENDING_ORG_DRAFT_STORAGE_KEY = "openwork-den-pending-org-draft";
|
||||
|
||||
function readPendingOrgDraft(userEmail: string | null | undefined) {
|
||||
if (typeof window === "undefined" || !userEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(PENDING_ORG_DRAFT_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown; email?: unknown };
|
||||
if (typeof parsed.name !== "string" || typeof parsed.email !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.email === userEmail ? parsed.name.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingOrgDraft(name: string, userEmail: string | null | undefined) {
|
||||
if (typeof window === "undefined" || !userEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
PENDING_ORG_DRAFT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
name,
|
||||
email: userEmail,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function clearPendingOrgDraft() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(PENDING_ORG_DRAFT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function OrganizationScreen() {
|
||||
const router = useRouter();
|
||||
const { user, sessionHydrated, signOut } = useDenFlow();
|
||||
const { user, sessionHydrated, signOut, billingSummary } = useDenFlow();
|
||||
const [orgs, setOrgs] = useState<DenOrgSummary[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -20,6 +66,7 @@ export function OrganizationScreen() {
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createBusy, setCreateBusy] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [hasPendingOrgDraft, setHasPendingOrgDraft] = useState(false);
|
||||
|
||||
const userDisplayName = useMemo(() => {
|
||||
const trimmedName = user?.name?.trim();
|
||||
@@ -35,6 +82,8 @@ export function OrganizationScreen() {
|
||||
|
||||
const activeOrg = useMemo(() => orgs.find((org) => org.isActive) ?? null, [orgs]);
|
||||
const showDirectCreateFlow = orgs.length === 0;
|
||||
const returningToSavedDraft = showDirectCreateFlow && hasPendingOrgDraft;
|
||||
const draftReadyAfterCheckout = returningToSavedDraft && Boolean(billingSummary?.hasActivePlan);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionHydrated) return;
|
||||
@@ -74,6 +123,28 @@ export function OrganizationScreen() {
|
||||
};
|
||||
}, [sessionHydrated, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.email) {
|
||||
setHasPendingOrgDraft(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showDirectCreateFlow) {
|
||||
clearPendingOrgDraft();
|
||||
setHasPendingOrgDraft(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const savedName = readPendingOrgDraft(user.email);
|
||||
if (!savedName) {
|
||||
setHasPendingOrgDraft(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateName((current) => current || savedName);
|
||||
setHasPendingOrgDraft(true);
|
||||
}, [showDirectCreateFlow, user?.email]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = createName.trim();
|
||||
@@ -89,6 +160,8 @@ export function OrganizationScreen() {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 402) {
|
||||
writePendingOrgDraft(trimmed, user?.email);
|
||||
setHasPendingOrgDraft(true);
|
||||
setCreateBusy(false);
|
||||
router.push("/checkout");
|
||||
return;
|
||||
@@ -106,6 +179,8 @@ export function OrganizationScreen() {
|
||||
throw new Error("Organization was created, but no slug was returned.");
|
||||
}
|
||||
|
||||
clearPendingOrgDraft();
|
||||
setHasPendingOrgDraft(false);
|
||||
router.push(getOrgDashboardRoute(nextSlug));
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "Failed to create organization.");
|
||||
@@ -153,9 +228,15 @@ export function OrganizationScreen() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-gray-400">OpenWork Cloud</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-gray-950">Create your first organization.</h1>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-gray-950">
|
||||
{returningToSavedDraft ? "Finish creating your organization." : "Create your first organization."}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-sm leading-6 text-gray-500">
|
||||
Name the workspace you want to set up for your team. If billing is enabled, the plan step comes right after this.
|
||||
{draftReadyAfterCheckout
|
||||
? "Your plan is ready. Confirm the workspace name below and create the workspace to finish setup."
|
||||
: returningToSavedDraft
|
||||
? "We saved your workspace name so you can pick up right where you left off."
|
||||
: "Name the workspace you want to set up for your team. If billing is enabled, the plan step comes right after this."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +269,7 @@ export function OrganizationScreen() {
|
||||
disabled={createBusy || !createName.trim()}
|
||||
className="rounded-2xl bg-gray-900 px-5 py-3 text-sm font-medium text-white transition-colors hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{createBusy ? "Creating..." : "Continue"}
|
||||
{createBusy ? "Creating..." : returningToSavedDraft ? "Create organization" : "Continue"}
|
||||
</button>
|
||||
<p className="text-sm text-gray-500">Signed in as {user?.email ?? "your account"}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user