Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
143 changes: 143 additions & 0 deletions ee/apps/den-api/src/organization-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { eq, sql } from "@openwork-ee/den-db/drizzle"
import { MemberTable, OrganizationTable, WorkerTable } from "@openwork-ee/den-db/schema"
import { db } from "./db.js"

export const DEFAULT_ORGANIZATION_LIMITS = {
members: 5,
workers: 1,
} as const

export type OrganizationLimitType = keyof typeof DEFAULT_ORGANIZATION_LIMITS

export type OrganizationLimits = {
members: number
workers: number
}

type OrganizationId = typeof OrganizationTable.$inferSelect.id

export type OrganizationMetadata = {
limits: OrganizationLimits
} & Record<string, unknown>

type OrganizationMetadataInput = Record<string, unknown> | string | null | undefined

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

function normalizePositiveInteger(value: unknown, fallback: number) {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
return value
}

if (typeof value === "string") {
const parsed = Number(value)
if (Number.isInteger(parsed) && parsed > 0) {
return parsed
}
}

return fallback
}

function parseMetadata(input: OrganizationMetadataInput): Record<string, unknown> {
if (!input) {
return {}
}

if (typeof input === "string") {
try {
const parsed = JSON.parse(input) as unknown
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}

return isRecord(input) ? input : {}
}

export function normalizeOrganizationMetadata(input: OrganizationMetadataInput): {
metadata: OrganizationMetadata
changed: boolean
} {
const parsed = parseMetadata(input)
const rawLimits = isRecord(parsed.limits) ? parsed.limits : null
const members = normalizePositiveInteger(rawLimits?.members, DEFAULT_ORGANIZATION_LIMITS.members)
const workers = normalizePositiveInteger(rawLimits?.workers ?? rawLimits?.Workers, DEFAULT_ORGANIZATION_LIMITS.workers)

const metadata: OrganizationMetadata = {
...parsed,
limits: {
members,
workers,
},
} as OrganizationMetadata

const changed =
!isRecord(parsed.limits) ||
Object.keys(parsed).length === 0 ||
rawLimits?.members !== members ||
(rawLimits?.workers ?? rawLimits?.Workers) !== workers

return { metadata, changed }
}

export function serializeOrganizationMetadata(metadata: OrganizationMetadataInput) {
const parsed = parseMetadata(metadata)
return Object.keys(parsed).length > 0 ? JSON.stringify(parsed) : null
}

export async function getOrInitializeOrganizationMetadata(organizationId: OrganizationId) {
const rows = await db
.select({ metadata: OrganizationTable.metadata })
.from(OrganizationTable)
.where(eq(OrganizationTable.id, organizationId))
.limit(1)

const { metadata, changed } = normalizeOrganizationMetadata(rows[0]?.metadata)
if (changed) {
await db
.update(OrganizationTable)
.set({ metadata })
.where(eq(OrganizationTable.id, organizationId))
}

return metadata
}

async function countOrganizationMembers(organizationId: OrganizationId) {
const rows = await db
.select({ count: sql<number>`count(*)` })
.from(MemberTable)
.where(eq(MemberTable.organizationId, organizationId))

return Number(rows[0]?.count ?? 0)
}

async function countOrganizationWorkers(organizationId: OrganizationId) {
const rows = await db
.select({ count: sql<number>`count(*)` })
.from(WorkerTable)
.where(eq(WorkerTable.org_id, organizationId))

return Number(rows[0]?.count ?? 0)
}

export async function getOrganizationLimitStatus(organizationId: OrganizationId, limitType: OrganizationLimitType) {
const metadata = await getOrInitializeOrganizationMetadata(organizationId)
const currentCount =
limitType === "members"
? await countOrganizationMembers(organizationId)
: await countOrganizationWorkers(organizationId)

const limit = metadata.limits[limitType]

return {
metadata,
currentCount,
limit,
exceeded: currentCount >= limit,
}
}
18 changes: 13 additions & 5 deletions ee/apps/den-api/src/orgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@openwork-ee/den-db/schema"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import { db } from "./db.js"
import { DEFAULT_ORGANIZATION_LIMITS, serializeOrganizationMetadata } from "./organization-limits.js"
import { denDefaultDynamicOrganizationRoles, denOrganizationStaticRoles } from "./organization-access.js"

type UserId = typeof AuthUserTable.$inferSelect.id
Expand Down Expand Up @@ -401,16 +402,23 @@ async function createOrganizationRecord(input: {
userId: UserId
name: string
logo?: string | null
metadata?: string | null
metadata?: Record<string, unknown> | null
}) {
const organizationId = createDenTypeId("organization")
const metadata =
input.metadata ?? {
limits: {
members: DEFAULT_ORGANIZATION_LIMITS.members,
workers: DEFAULT_ORGANIZATION_LIMITS.workers,
},
}

await db.insert(OrganizationTable).values({
id: organizationId,
name: input.name,
slug: organizationId,
logo: input.logo ?? null,
metadata: input.metadata ?? null,
metadata,
})

await db.insert(MemberTable).values({
Expand Down Expand Up @@ -511,7 +519,7 @@ export async function listUserOrgs(userId: UserId) {
name: row.organization.name,
slug: row.organization.slug,
logo: row.organization.logo,
metadata: row.organization.metadata,
metadata: serializeOrganizationMetadata(row.organization.metadata),
role: row.role,
orgMemberId: row.membershipId,
membershipId: row.membershipId,
Expand All @@ -524,7 +532,7 @@ export async function resolveUserOrganizations(input: {
activeOrganizationId?: string | null
userId: UserId
}) {
await ensurePersonalOrganizationForUser(input.userId)
await ensureUserOrgAccess({ userId: input.userId })

const orgs = await listUserOrgs(input.userId)

Expand Down Expand Up @@ -628,7 +636,7 @@ export async function getOrganizationContextForUser(input: {
name: organization.name,
slug: organization.slug,
logo: organization.logo,
metadata: organization.metadata,
metadata: serializeOrganizationMetadata(organization.metadata),
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,
},
Expand Down
25 changes: 25 additions & 0 deletions ee/apps/den-api/src/routes/org/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { OrganizationTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { requireCloudWorkerAccess } from "../../billing/polar.js"
import { db } from "../../db.js"
import { env } from "../../env.js"
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization } from "../../orgs.js"
import { getRequiredUserEmail } from "../../user.js"
Expand All @@ -27,6 +29,29 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
const user = c.get("user")
const session = c.get("session")
const input = c.req.valid("json")
const email = getRequiredUserEmail(user)

if (!email) {
return c.json({ error: "user_email_required" }, 400)
}

const access = await requireCloudWorkerAccess({
userId: normalizeDenTypeId("user", user.id),
email,
name: user.name ?? user.email ?? "OpenWork User",
})

if (!access.allowed) {
return c.json({
error: "payment_required",
message: "Creating a workspace requires an active OpenWork Cloud plan.",
polar: {
checkoutUrl: access.checkoutUrl,
productId: env.polar.productId,
benefitId: env.polar.benefitId,
},
}, 402)
}

const organizationId = await createOrganizationForUser({
userId: normalizeDenTypeId("user", user.id),
Expand Down
12 changes: 12 additions & 0 deletions ee/apps/den-api/src/routes/org/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod"
import { db } from "../../db.js"
import { sendDenOrganizationInvitationEmail } from "../../email.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { getOrganizationLimitStatus } from "../../organization-limits.js"
import { listAssignableRoles } from "../../orgs.js"
import type { OrgRouteVariables } from "./shared.js"
import { buildInvitationLink, createInvitationId, ensureInviteManager, idParamSchema, normalizeRoleName, orgIdParamSchema } from "./shared.js"
Expand Down Expand Up @@ -50,6 +51,17 @@ 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)
Expand Down
34 changes: 10 additions & 24 deletions ee/apps/den-api/src/routes/workers/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,18 @@ import { WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { db } from "../../db.js"
import { env } from "../../env.js"
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js"
import { getRequiredUserEmail } from "../../user.js"
import { getOrganizationLimitStatus } from "../../organization-limits.js"
import type { WorkerRouteVariables } from "./shared.js"
import {
continueCloudProvisioning,
countUserCloudWorkers,
createWorkerSchema,
deleteWorkerCascade,
getLatestWorkerInstance,
getWorkerByIdForOrg,
getWorkerTokensAndConnect,
listWorkersQuerySchema,
parseWorkerIdParam,
requireCloudAccessOrPayment,
toInstanceResponse,
toWorkerResponse,
token,
Expand Down Expand Up @@ -68,27 +65,16 @@ export function registerWorkerCoreRoutes<T extends { Variables: WorkerRouteVaria
return c.json({ error: "workspace_path_required" }, 400)
}

if (input.destination === "cloud" && !env.devMode && (await countUserCloudWorkers(user.id)) > 0) {
const email = getRequiredUserEmail(user)
if (!email) {
return c.json({ error: "user_email_required" }, 400)
}

const access = await requireCloudAccessOrPayment({
userId: user.id,
email,
name: user.name ?? user.email ?? "OpenWork User",
})
if (!access.allowed) {
if (input.destination === "cloud") {
const workerLimit = await getOrganizationLimitStatus(orgId, "workers")
if (workerLimit.exceeded) {
return c.json({
error: "payment_required",
message: "Additional cloud workers require an active Den Cloud plan.",
polar: {
checkoutUrl: access.checkoutUrl,
productId: env.polar.productId,
benefitId: env.polar.benefitId,
},
}, 402)
error: "org_limit_reached",
limitType: "workers",
limit: workerLimit.limit,
currentCount: workerLimit.currentCount,
message: `This workspace currently supports up to ${workerLimit.limit} workers. Contact support to increase the limit.`,
}, 409)
}
}

Expand Down
Loading
Loading