diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index 8b5b9e2..f04f630 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -7,3 +7,11 @@ export * from "./sprints"; export * from "./subscriptions"; export * from "./timed-sessions"; export * from "./users"; + +// free tier limits +export const FREE_TIER_LIMITS = { + organisationsPerUser: 1, + projectsPerOrganisation: 1, + issuesPerOrganisation: 100, + membersPerOrganisation: 5, +} as const; diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index 8466685..41ccadd 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -259,6 +259,25 @@ export async function getIssueAssigneeCount(issueId: number): Promise { return result?.count ?? 0; } +export async function getOrganisationIssueCount(organisationId: number): Promise { + const { Project } = await import("@sprint/shared"); + + const projects = await db + .select({ id: Project.id }) + .from(Project) + .where(eq(Project.organisationId, organisationId)); + const projectIds = projects.map((p) => p.id); + + if (projectIds.length === 0) return 0; + + const [result] = await db + .select({ count: sql`COUNT(*)` }) + .from(Issue) + .where(inArray(Issue.projectId, projectIds)); + + return result?.count ?? 0; +} + export async function isIssueAssignee(issueId: number, userId: number): Promise { const [assignee] = await db .select({ id: IssueAssignee.id }) diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index c56e5be..0585bfb 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -1,5 +1,5 @@ import { Organisation, OrganisationMember, User } from "@sprint/shared"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { db } from "../client"; export async function createOrganisation(name: string, slug: string, description?: string) { @@ -144,3 +144,11 @@ export async function updateOrganisationMemberRole(organisationId: number, userI .returning(); return member; } + +export async function getUserOrganisationCount(userId: number): Promise { + const [result] = await db + .select({ count: sql`COUNT(*)` }) + .from(OrganisationMember) + .where(eq(OrganisationMember.userId, userId)); + return result?.count ?? 0; +} diff --git a/packages/backend/src/db/queries/projects.ts b/packages/backend/src/db/queries/projects.ts index 8e53eab..ad1f4b4 100644 --- a/packages/backend/src/db/queries/projects.ts +++ b/packages/backend/src/db/queries/projects.ts @@ -1,5 +1,5 @@ import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { db } from "../client"; export async function createProject(key: string, name: string, creatorId: number, organisationId: number) { @@ -82,3 +82,11 @@ export async function getProjectsByOrganisationId(organisationId: number) { .leftJoin(Organisation, eq(Project.organisationId, Organisation.id)); return projects; } + +export async function getOrganisationProjectCount(organisationId: number): Promise { + const [result] = await db + .select({ count: sql`COUNT(*)` }) + .from(Project) + .where(eq(Project.organisationId, organisationId)); + return result?.count ?? 0; +} diff --git a/packages/backend/src/routes/issue/create.ts b/packages/backend/src/routes/issue/create.ts index 34cfb32..274106c 100644 --- a/packages/backend/src/routes/issue/create.ts +++ b/packages/backend/src/routes/issue/create.ts @@ -1,6 +1,13 @@ import { IssueCreateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { createIssue, getOrganisationMemberRole, getProjectByID } from "../../db/queries"; +import { + createIssue, + FREE_TIER_LIMITS, + getOrganisationIssueCount, + getOrganisationMemberRole, + getProjectByID, + getUserById, +} from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function issueCreate(req: AuthedRequest) { @@ -26,6 +33,19 @@ export default async function issueCreate(req: AuthedRequest) { ); } + // check free tier limit + const user = await getUserById(req.userId); + if (user && user.plan !== "pro") { + const issueCount = await getOrganisationIssueCount(project.organisationId); + if (issueCount >= FREE_TIER_LIMITS.issuesPerOrganisation) { + return errorResponse( + `free tier is limited to ${FREE_TIER_LIMITS.issuesPerOrganisation} issues per organisation. upgrade to pro for unlimited issues.`, + "FREE_TIER_ISSUE_LIMIT", + 403, + ); + } + } + const issue = await createIssue( project.id, title, diff --git a/packages/backend/src/routes/organisation/add-member.ts b/packages/backend/src/routes/organisation/add-member.ts index 8b60f66..1b06abc 100644 --- a/packages/backend/src/routes/organisation/add-member.ts +++ b/packages/backend/src/routes/organisation/add-member.ts @@ -2,8 +2,10 @@ import { OrgAddMemberRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { createOrganisationMember, + FREE_TIER_LIMITS, getOrganisationById, getOrganisationMemberRole, + getOrganisationMembers, getUserById, } from "../../db/queries"; import { updateSeatCount } from "../../lib/seats"; @@ -39,6 +41,19 @@ export default async function organisationAddMember(req: AuthedRequest) { return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403); } + // check free tier member limit + const requester = await getUserById(req.userId); + if (requester && requester.plan !== "pro") { + const members = await getOrganisationMembers(organisationId); + if (members.length >= FREE_TIER_LIMITS.membersPerOrganisation) { + return errorResponse( + `free tier is limited to ${FREE_TIER_LIMITS.membersPerOrganisation} members per organisation. upgrade to pro for unlimited members.`, + "FREE_TIER_MEMBER_LIMIT", + 403, + ); + } + } + const member = await createOrganisationMember(organisationId, userId, role); // update seat count if the requester is the owner diff --git a/packages/backend/src/routes/organisation/create.ts b/packages/backend/src/routes/organisation/create.ts index f9258ea..7fe40d6 100644 --- a/packages/backend/src/routes/organisation/create.ts +++ b/packages/backend/src/routes/organisation/create.ts @@ -1,6 +1,12 @@ import { OrgCreateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { createOrganisationWithOwner, getOrganisationBySlug } from "../../db/queries"; +import { + createOrganisationWithOwner, + FREE_TIER_LIMITS, + getOrganisationBySlug, + getUserById, + getUserOrganisationCount, +} from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function organisationCreate(req: AuthedRequest) { @@ -14,6 +20,19 @@ export default async function organisationCreate(req: AuthedRequest) { return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409); } + // check free tier limit + const user = await getUserById(req.userId); + if (user && user.plan !== "pro") { + const orgCount = await getUserOrganisationCount(req.userId); + if (orgCount >= FREE_TIER_LIMITS.organisationsPerUser) { + return errorResponse( + `free tier is limited to ${FREE_TIER_LIMITS.organisationsPerUser} organisation. upgrade to pro for unlimited organisations.`, + "FREE_TIER_ORG_LIMIT", + 403, + ); + } + } + const organisation = await createOrganisationWithOwner(name, slug, req.userId, description); return Response.json(organisation); diff --git a/packages/backend/src/routes/project/create.ts b/packages/backend/src/routes/project/create.ts index 5fcfd25..4fead4a 100644 --- a/packages/backend/src/routes/project/create.ts +++ b/packages/backend/src/routes/project/create.ts @@ -1,6 +1,13 @@ import { ProjectCreateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { createProject, getOrganisationMemberRole, getProjectByKey, getUserById } from "../../db/queries"; +import { + createProject, + FREE_TIER_LIMITS, + getOrganisationMemberRole, + getOrganisationProjectCount, + getProjectByKey, + getUserById, +} from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function projectCreate(req: AuthedRequest) { @@ -22,7 +29,19 @@ export default async function projectCreate(req: AuthedRequest) { return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403); } + // check free tier limit const creator = await getUserById(req.userId); + if (creator && creator.plan !== "pro") { + const projectCount = await getOrganisationProjectCount(organisationId); + if (projectCount >= FREE_TIER_LIMITS.projectsPerOrganisation) { + return errorResponse( + `free tier is limited to ${FREE_TIER_LIMITS.projectsPerOrganisation} project per organisation. upgrade to pro for unlimited projects.`, + "FREE_TIER_PROJECT_LIMIT", + 403, + ); + } + } + if (!creator) { return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404); } diff --git a/packages/frontend/src/components/free-tier-limit.tsx b/packages/frontend/src/components/free-tier-limit.tsx new file mode 100644 index 0000000..933d3ae --- /dev/null +++ b/packages/frontend/src/components/free-tier-limit.tsx @@ -0,0 +1,93 @@ +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { cn } from "@/lib/utils"; + +export function FreeTierLimit({ + current, + limit, + itemName, + isPro, + className, + showUpgrade = true, +}: { + current: number; + limit: number; + itemName: string; + isPro: boolean; + className?: string; + showUpgrade?: boolean; +}) { + if (isPro) return null; + + const percentage = Math.min((current / limit) * 100, 100); + const isAtLimit = current >= limit; + const isNearLimit = percentage >= 80 && !isAtLimit; + + return ( +
+
+ + {current} / {limit} {itemName} + {current !== 1 ? "s" : ""} + + {isAtLimit && Limit reached} + {isNearLimit && Almost at limit} +
+
+
+
+ {isAtLimit && showUpgrade && ( +
+ + Upgrade to Pro for unlimited {itemName}s + +
+ )} +
+ ); +} + +interface FreeTierLimitBadgeProps { + current: number; + limit: number; + itemName: string; + isPro: boolean; + className?: string; +} + +export function FreeTierLimitBadge({ current, limit, itemName, isPro, className }: FreeTierLimitBadgeProps) { + if (isPro) return null; + + const isAtLimit = current >= limit; + const percentage = (current / limit) * 100; + const isNearLimit = percentage >= 80 && !isAtLimit; + + return ( +
+ + + {current}/{limit} {itemName} + {current !== 1 ? "s" : ""} + +
+ ); +} diff --git a/packages/frontend/src/components/issue-form.tsx b/packages/frontend/src/components/issue-form.tsx index f438892..337bcb1 100644 --- a/packages/frontend/src/components/issue-form.tsx +++ b/packages/frontend/src/components/issue-form.tsx @@ -2,6 +2,7 @@ import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/sh import { type FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { FreeTierLimit } from "@/components/free-tier-limit"; import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { useAuthenticatedSession } from "@/components/session-provider"; import { SprintSelect } from "@/components/sprint-select"; @@ -23,6 +24,7 @@ import { Label } from "@/components/ui/label"; import { SelectTrigger } from "@/components/ui/select"; import { useCreateIssue, + useIssues, useOrganisationMembers, useSelectedOrganisation, useSelectedProject, @@ -31,14 +33,21 @@ import { import { parseError } from "@/lib/server"; import { cn, issueID } from "@/lib/utils"; +const FREE_TIER_ISSUE_LIMIT = 100; + export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { const { user } = useAuthenticatedSession(); const selectedOrganisation = useSelectedOrganisation(); const selectedProject = useSelectedProject(); const { data: sprints = [] } = useSprints(selectedProject?.Project.id); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); + const { data: issues = [] } = useIssues(selectedProject?.Project.id); const createIssue = useCreateIssue(); + const isPro = user.plan === "pro"; + const issueCount = issues.length; + const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT; + const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record< @@ -138,7 +147,17 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { {trigger || ( - )} @@ -149,6 +168,18 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { Create Issue + {!isPro && selectedProject && ( +
+ +
+ )} +
{(typeOptions.length > 0 || statusOptions.length > 0) && ( @@ -270,10 +301,16 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { type="submit" disabled={ submitting || + isAtIssueLimit || ((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) && submitAttempted) || (description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted) } + title={ + isAtIssueLimit + ? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited." + : undefined + } > {submitting ? "Creating..." : "Create"} diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx index f462eee..2253cfb 100644 --- a/packages/frontend/src/components/organisation-select.tsx +++ b/packages/frontend/src/components/organisation-select.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { FreeTierLimit } from "@/components/free-tier-limit"; import { OrganisationForm } from "@/components/organisation-form"; import { useSelection } from "@/components/selection-provider"; +import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import { Select, @@ -17,6 +19,8 @@ import { useOrganisations } from "@/lib/query/hooks"; import { cn } from "@/lib/utils"; import OrgIcon from "./org-icon"; +const FREE_TIER_ORG_LIMIT = 1; + export function OrganisationSelect({ placeholder = "Select Organisation", contentClass, @@ -40,6 +44,11 @@ export function OrganisationSelect({ const [pendingOrganisationId, setPendingOrganisationId] = useState(null); const { data: organisationsData = [] } = useOrganisations(); const { selectedOrganisationId, selectOrganisation } = useSelection(); + const { user } = useAuthenticatedSession(); + + const isPro = user.plan === "pro"; + const orgCount = organisationsData.length; + const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT; const organisations = useMemo( () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), @@ -107,9 +116,31 @@ export function OrganisationSelect({ {organisations.length > 0 && } + {!isPro && ( +
+ +
+ )} + + } diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index fac2d29..b053efc 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -8,8 +8,10 @@ import { } from "@sprint/shared"; import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; import { toast } from "sonner"; import { AddMember } from "@/components/add-member"; +import { FreeTierLimit } from "@/components/free-tier-limit"; import OrgIcon from "@/components/org-icon"; import { OrganisationForm } from "@/components/organisation-form"; import { OrganisationSelect } from "@/components/organisation-select"; @@ -42,6 +44,7 @@ import { useDeleteOrganisation, useDeleteProject, useDeleteSprint, + useIssues, useOrganisationMembers, useOrganisationMemberTimeTracking, useOrganisations, @@ -58,6 +61,13 @@ import { apiClient } from "@/lib/server"; import { capitalise, formatDuration, unCamelCase } from "@/lib/utils"; import { Switch } from "./ui/switch"; +const FREE_TIER_LIMITS = { + organisationsPerUser: 1, + projectsPerOrganisation: 1, + issuesPerOrganisation: 100, + membersPerOrganisation: 5, +} as const; + function Organisations({ trigger }: { trigger?: ReactNode }) { const { user } = useAuthenticatedSession(); const queryClient = useQueryClient(); @@ -66,6 +76,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { const { data: projectsData = [] } = useProjects(selectedOrganisationId); const { data: sprints = [] } = useSprints(selectedProjectId); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId); + const { data: issues = [] } = useIssues(selectedProjectId); const updateOrganisation = useUpdateOrganisation(); const updateMemberRole = useUpdateOrganisationMemberRole(); const removeMember = useRemoveOrganisationMember(); @@ -75,6 +86,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { const replaceIssueStatus = useReplaceIssueStatus(); const replaceIssueType = useReplaceIssueType(); + const isPro = user.plan === "pro"; + const orgCount = organisationsData.length; + const projectCount = projectsData.length; + const issueCount = issues.length; + const memberCount = membersData.length; + const organisations = useMemo( () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), [organisationsData], @@ -823,6 +840,49 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {

No description

)}
+ + {/* Free tier limits section */} + {!isPro && ( +
+
+

Plan Limits

+ +
+
+ + + + +
+
+ )} + {isAdmin && (
{isAdmin && ( - m.User.username)} - onSuccess={(user) => { - toast.success( - `${user.name} added to ${selectedOrganisation.Organisation.name} successfully`, - { - dismissible: false, - }, - ); + <> + {!isPro && ( +
+ = FREE_TIER_LIMITS.membersPerOrganisation} + /> +
+ )} + m.User.username)} + onSuccess={(user) => { + toast.success( + `${user.name} added to ${selectedOrganisation.Organisation.name} successfully`, + { + dismissible: false, + }, + ); - void invalidateMembers(); - }} - trigger={ - - } - /> + void invalidateMembers(); + }} + trigger={ + + } + /> + )}
diff --git a/packages/frontend/src/components/project-select.tsx b/packages/frontend/src/components/project-select.tsx index 06b6ecb..618dde7 100644 --- a/packages/frontend/src/components/project-select.tsx +++ b/packages/frontend/src/components/project-select.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from "react"; +import { FreeTierLimit } from "@/components/free-tier-limit"; import { ProjectForm } from "@/components/project-form"; import { useSelection } from "@/components/selection-provider"; +import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import { Select, @@ -14,6 +16,8 @@ import { } from "@/components/ui/select"; import { useProjects } from "@/lib/query/hooks"; +const FREE_TIER_PROJECT_LIMIT = 1; + export function ProjectSelect({ placeholder = "Select Project", showLabel = false, @@ -29,6 +33,11 @@ export function ProjectSelect({ const [pendingProjectId, setPendingProjectId] = useState(null); const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection(); const { data: projectsData = [] } = useProjects(selectedOrganisationId); + const { user } = useAuthenticatedSession(); + + const isPro = user.plan === "pro"; + const projectCount = projectsData.length; + const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT; const projects = useMemo( () => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)), @@ -81,10 +90,35 @@ export function ProjectSelect({ ))} {projects.length > 0 && } + + {!isPro && selectedOrganisationId && ( +
+ +
+ )} + + } diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 55a68a5..96559a3 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -10,11 +10,6 @@ import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; const faqs = [ - { - question: "Can I switch plans?", - answer: - "Yes, you can upgrade or downgrade at any time. Changes take effect immediately, and we'll prorate any charges.", - }, { question: "What payment methods do you accept?", answer: "We accept all major credit cards.", diff --git a/packages/frontend/src/pages/Plans.tsx b/packages/frontend/src/pages/Plans.tsx index 5e2bda0..aff6b2e 100644 --- a/packages/frontend/src/pages/Plans.tsx +++ b/packages/frontend/src/pages/Plans.tsx @@ -216,7 +216,7 @@ export default function Plans() { {user && isProUser && ( -
+

Cancel subscription

@@ -264,7 +264,7 @@ export default function Plans() { )} {/* trust signals */} -
+

Secure & Encrypted

@@ -286,33 +286,6 @@ export default function Plans() {

30-day no-risk policy

- - {/* FAQ */} -
-

Frequently Asked Questions

-
-
-

Can I switch plans?

-

- Yes, you can upgrade or downgrade at any time. Changes take effect immediately. -

-
-
-

What happens when I add team members?

-

- Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your - billing automatically. -

-
-
-

Can I cancel my subscription?

-

- Absolutely. Cancel anytime with no questions asked. You'll keep access until the end of your - billing period. -

-
-
-