From 14520618d1adc9a55262281ec312aa7c97fb98dd Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 23:36:03 +0000 Subject: [PATCH] more Free/Pro plan limitations --- packages/backend/src/db/queries/index.ts | 1 + .../backend/src/db/queries/organisations.ts | 10 +++ packages/backend/src/db/queries/sprints.ts | 10 ++- .../organisation/member-time-tracking.ts | 22 +++-- .../backend/src/routes/organisation/update.ts | 20 ++++- packages/backend/src/routes/sprint/create.ts | 17 ++++ packages/backend/src/routes/user/update.ts | 15 +++- .../backend/src/routes/user/upload-avatar.ts | 35 +++++++- packages/frontend/src/components/account.tsx | 41 ++++++++-- .../frontend/src/components/organisations.tsx | 81 +++++++++++-------- .../frontend/src/components/pricing-card.tsx | 7 ++ .../frontend/src/components/sprint-form.tsx | 20 ++++- .../frontend/src/components/upload-avatar.tsx | 71 +++++++++++++++- packages/shared/src/contract.ts | 1 + 14 files changed, 296 insertions(+), 55 deletions(-) diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index f04f630..b61f22f 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -14,4 +14,5 @@ export const FREE_TIER_LIMITS = { projectsPerOrganisation: 1, issuesPerOrganisation: 100, membersPerOrganisation: 5, + sprintsPerProject: 5, } as const; diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index 0585bfb..2882e37 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -152,3 +152,13 @@ export async function getUserOrganisationCount(userId: number): Promise .where(eq(OrganisationMember.userId, userId)); return result?.count ?? 0; } + +export async function getOrganisationOwner(organisationId: number) { + const [owner] = await db + .select({ userId: OrganisationMember.userId }) + .from(OrganisationMember) + .where( + and(eq(OrganisationMember.organisationId, organisationId), eq(OrganisationMember.role, "owner")), + ); + return owner; +} diff --git a/packages/backend/src/db/queries/sprints.ts b/packages/backend/src/db/queries/sprints.ts index b539cc5..b6ef4a8 100644 --- a/packages/backend/src/db/queries/sprints.ts +++ b/packages/backend/src/db/queries/sprints.ts @@ -1,5 +1,5 @@ import { Issue, Sprint } from "@sprint/shared"; -import { and, desc, eq, gte, lte, ne } from "drizzle-orm"; +import { and, desc, eq, gte, lte, ne, sql } from "drizzle-orm"; import { db } from "../client"; export async function createSprint( @@ -72,3 +72,11 @@ export async function deleteSprint(sprintId: number) { await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId)); await db.delete(Sprint).where(eq(Sprint.id, sprintId)); } + +export async function getProjectSprintCount(projectId: number) { + const result = await db + .select({ count: sql`count(*)::int` }) + .from(Sprint) + .where(eq(Sprint.projectId, projectId)); + return result[0]?.count ?? 0; +} diff --git a/packages/backend/src/routes/organisation/member-time-tracking.ts b/packages/backend/src/routes/organisation/member-time-tracking.ts index c5813ba..1bdbc5e 100644 --- a/packages/backend/src/routes/organisation/member-time-tracking.ts +++ b/packages/backend/src/routes/organisation/member-time-tracking.ts @@ -5,6 +5,8 @@ import { getOrganisationById, getOrganisationMemberRole, getOrganisationMemberTimedSessions, + getOrganisationOwner, + getUserById, } from "../../db/queries"; import { errorResponse, parseQueryParams } from "../../validation"; @@ -37,22 +39,30 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest) return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403); } + // check if organisation owner has pro subscription + const owner = await getOrganisationOwner(organisationId); + const ownerUser = owner ? await getUserById(owner.userId) : null; + const isPro = ownerUser?.plan === "pro"; + const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate); const enriched = sessions.map((session) => { const timestamps = session.timestamps.map((t) => new Date(t)); + const actualWorkTimeMs = calculateWorkTimeMs(timestamps); + const actualBreakTimeMs = calculateBreakTimeMs(timestamps); + return { id: session.id, userId: session.userId, issueId: session.issueId, issueNumber: session.issueNumber, projectKey: session.projectKey, - timestamps: session.timestamps, - endedAt: session.endedAt, - createdAt: session.createdAt, - workTimeMs: calculateWorkTimeMs(timestamps), - breakTimeMs: calculateBreakTimeMs(timestamps), - isRunning: session.endedAt === null && isTimerRunning(timestamps), + timestamps: isPro ? session.timestamps : [], + endedAt: isPro ? session.endedAt : null, + createdAt: isPro ? session.createdAt : null, + workTimeMs: isPro ? actualWorkTimeMs : 0, + breakTimeMs: isPro ? actualBreakTimeMs : 0, + isRunning: isPro ? session.endedAt === null && isTimerRunning(timestamps) : false, }; }); diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts index 6fae359..7745dfa 100644 --- a/packages/backend/src/routes/organisation/update.ts +++ b/packages/backend/src/routes/organisation/update.ts @@ -1,6 +1,11 @@ import { OrgUpdateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; -import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries"; +import { + getOrganisationById, + getOrganisationMemberRole, + getSubscriptionByUserId, + updateOrganisation, +} from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function organisationUpdate(req: AuthedRequest) { @@ -22,6 +27,19 @@ export default async function organisationUpdate(req: AuthedRequest) { return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403); } + // block free users from updating features + if (features !== undefined) { + const subscription = await getSubscriptionByUserId(req.userId); + const isPro = subscription?.status === "active"; + if (!isPro) { + return errorResponse( + "Feature toggling is only available on Pro. Upgrade to customize features.", + "FEATURE_TOGGLE_PRO_ONLY", + 403, + ); + } + } + if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) { return errorResponse( "at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided", diff --git a/packages/backend/src/routes/sprint/create.ts b/packages/backend/src/routes/sprint/create.ts index 9d535e8..3a6291f 100644 --- a/packages/backend/src/routes/sprint/create.ts +++ b/packages/backend/src/routes/sprint/create.ts @@ -2,8 +2,11 @@ import { SprintCreateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { createSprint, + FREE_TIER_LIMITS, getOrganisationMemberRole, getProjectByID, + getProjectSprintCount, + getSubscriptionByUserId, hasOverlappingSprints, } from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; @@ -28,6 +31,20 @@ export default async function sprintCreate(req: AuthedRequest) { return errorResponse("Only owners and admins can create sprints", "PERMISSION_DENIED", 403); } + // check free tier sprint limit + const subscription = await getSubscriptionByUserId(req.userId); + const isPro = subscription?.status === "active"; + if (!isPro) { + const sprintCount = await getProjectSprintCount(projectId); + if (sprintCount >= FREE_TIER_LIMITS.sprintsPerProject) { + return errorResponse( + `Free tier limited to ${FREE_TIER_LIMITS.sprintsPerProject} sprints per project. Upgrade to Pro for unlimited sprints.`, + "SPRINT_LIMIT_REACHED", + 403, + ); + } + } + const start = new Date(startDate); const end = new Date(endDate); diff --git a/packages/backend/src/routes/user/update.ts b/packages/backend/src/routes/user/update.ts index 7ef2af5..c1c3ce4 100644 --- a/packages/backend/src/routes/user/update.ts +++ b/packages/backend/src/routes/user/update.ts @@ -1,7 +1,7 @@ import { UserUpdateRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { hashPassword } from "../../auth/utils"; -import { getUserById } from "../../db/queries"; +import { getSubscriptionByUserId, getUserById } from "../../db/queries"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function update(req: AuthedRequest) { @@ -23,6 +23,19 @@ export default async function update(req: AuthedRequest) { ); } + // block free users from changing icon preference + if (iconPreference !== undefined && iconPreference !== user.iconPreference) { + const subscription = await getSubscriptionByUserId(req.userId); + const isPro = subscription?.status === "active"; + if (!isPro) { + return errorResponse( + "icon style customization is only available on Pro. Upgrade to customize your icon style.", + "ICON_STYLE_PRO_ONLY", + 403, + ); + } + } + let passwordHash: string | undefined; if (password !== undefined) { passwordHash = await hashPassword(password); diff --git a/packages/backend/src/routes/user/upload-avatar.ts b/packages/backend/src/routes/user/upload-avatar.ts index 21792d1..bdfe994 100644 --- a/packages/backend/src/routes/user/upload-avatar.ts +++ b/packages/backend/src/routes/user/upload-avatar.ts @@ -1,13 +1,23 @@ import { randomUUID } from "node:crypto"; -import type { BunRequest } from "bun"; import sharp from "sharp"; +import type { AuthedRequest } from "../../auth/middleware"; +import { getSubscriptionByUserId } from "../../db/queries"; import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3"; const MAX_FILE_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; const TARGET_SIZE = 256; -export default async function uploadAvatar(req: BunRequest) { +async function isAnimatedGIF(buffer: Buffer): Promise { + try { + const metadata = await sharp(buffer).metadata(); + return metadata.pages !== undefined && metadata.pages > 1; + } catch { + return false; + } +} + +export default async function uploadAvatar(req: AuthedRequest) { if (req.method !== "POST") { return new Response("method not allowed", { status: 405 }); } @@ -29,14 +39,31 @@ export default async function uploadAvatar(req: BunRequest) { }); } + const inputBuffer = Buffer.from(await file.arrayBuffer()); + + // check if user is pro + const subscription = await getSubscriptionByUserId(req.userId); + const isPro = subscription?.status === "active"; + + // block animated avatars for free users + if (!isPro && file.type === "image/gif") { + const animated = await isAnimatedGIF(inputBuffer); + if (animated) { + return new Response( + JSON.stringify({ + error: "Animated avatars are only available on Pro. Upgrade to upload animated avatars.", + }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + } + const isGIF = file.type === "image/gif"; const outputExtension = isGIF ? "gif" : "png"; const outputMimeType = isGIF ? "image/gif" : "image/png"; let resizedBuffer: Buffer; try { - const inputBuffer = Buffer.from(await file.arrayBuffer()); - if (isGIF) { resizedBuffer = await sharp(inputBuffer, { animated: true }) .resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" }) diff --git a/packages/frontend/src/components/account.tsx b/packages/frontend/src/components/account.tsx index 192e5a1..a88e3cd 100644 --- a/packages/frontend/src/components/account.tsx +++ b/packages/frontend/src/components/account.tsx @@ -16,6 +16,9 @@ import { useUpdateUser } from "@/lib/query/hooks"; import { parseError } from "@/lib/server"; import { cn } from "@/lib/utils"; +// icon style is locked to pixel for free users +const DEFAULT_ICON_STYLE: IconStyle = "pixel"; + function Account({ trigger }: { trigger?: ReactNode }) { const { user: currentUser, setUser } = useAuthenticatedSession(); const updateUser = useUpdateUser(); @@ -35,7 +38,12 @@ function Account({ trigger }: { trigger?: ReactNode }) { setName(currentUser.name); setUsername(currentUser.username); setAvatarUrl(currentUser.avatarURL || null); - setIconPreference((currentUser.iconPreference as IconStyle) ?? "pixel"); + // free users are locked to pixel icon style + const effectiveIconStyle = + currentUser.plan === "pro" + ? ((currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE) + : DEFAULT_ICON_STYLE; + setIconPreference(effectiveIconStyle); setPassword(""); setError(""); @@ -51,11 +59,13 @@ function Account({ trigger }: { trigger?: ReactNode }) { } try { + // only send iconPreference for pro users + const effectiveIconPreference = currentUser.plan === "pro" ? iconPreference : undefined; const data = await updateUser.mutateAsync({ name: name.trim(), password: password.trim() || undefined, avatarURL, - iconPreference, + iconPreference: effectiveIconPreference, }); setError(""); setUser(data); @@ -131,9 +141,22 @@ function Account({ trigger }: { trigger?: ReactNode }) {
- - setIconPreference(v as IconStyle)} + disabled={currentUser.plan !== "pro"} + > + @@ -157,6 +180,14 @@ function Account({ trigger }: { trigger?: ReactNode }) { + {currentUser.plan !== "pro" && ( + + + Upgrade to Pro + {" "} + to customize icon style + + )}
diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index b053efc..0133903 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -58,7 +58,7 @@ import { } from "@/lib/query/hooks"; import { queryKeys } from "@/lib/query/keys"; import { apiClient } from "@/lib/server"; -import { capitalise, formatDuration, unCamelCase } from "@/lib/utils"; +import { capitalise, cn, formatDuration, unCamelCase } from "@/lib/utils"; import { Switch } from "./ui/switch"; const FREE_TIER_LIMITS = { @@ -943,36 +943,40 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { {isAdmin && (
- - - - - - date && setFromDate(date)} - autoFocus - /> - - - - - - - - downloadTimeTrackingData("csv")}> - Download CSV - - downloadTimeTrackingData("json")}> - Download JSON - - - + {isPro && ( + <> + + + + + + date && setFromDate(date)} + autoFocus + /> + + + + + + + + downloadTimeTrackingData("csv")}> + Download CSV + + downloadTimeTrackingData("json")}> + Download JSON + + + + + )}
)} @@ -990,7 +994,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
- {isAdmin && ( + {isAdmin && isPro && ( {formatDuration(member.totalTimeMs)} @@ -1518,6 +1522,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {

Features

+ {!isPro && ( +
+ Feature toggling is only available on Pro.{" "} + + Upgrade to customize features. + +
+ )}
{Object.keys(DEFAULT_FEATURES).map((feature) => (
@@ -1539,9 +1551,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { ); await invalidateOrganisations(); }} + disabled={!isPro} color={"#ff0000"} /> - {unCamelCase(feature)} + + {unCamelCase(feature)} +
))}
diff --git a/packages/frontend/src/components/pricing-card.tsx b/packages/frontend/src/components/pricing-card.tsx index 0fe2c22..6eed351 100644 --- a/packages/frontend/src/components/pricing-card.tsx +++ b/packages/frontend/src/components/pricing-card.tsx @@ -90,8 +90,11 @@ export const pricingTiers: PricingTier[] = [ features: [ "1 organisation (owned or joined)", "1 project", + "5 sprints", "100 issues", "Up to 5 team members", + "Static avatars only", + "Pixel icon style", "Email support", ], cta: "Get started free", @@ -109,7 +112,11 @@ export const pricingTiers: PricingTier[] = [ "Everything in starter", "Unlimited organisations", "Unlimited projects", + "Unlimited sprints", "Unlimited issues", + "Animated avatars", + "Custom icon styles", + "Feature toggling", "Advanced time tracking & reports", "Custom issue statuses", "Priority email support", diff --git a/packages/frontend/src/components/sprint-form.tsx b/packages/frontend/src/components/sprint-form.tsx index a4b418d..d550879 100644 --- a/packages/frontend/src/components/sprint-form.tsx +++ b/packages/frontend/src/components/sprint-form.tsx @@ -1,6 +1,7 @@ import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared"; import { type FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { FreeTierLimit } from "@/components/free-tier-limit"; import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -21,6 +22,7 @@ import { parseError } from "@/lib/server"; import { cn } from "@/lib/utils"; const SPRINT_NAME_MAX_LENGTH = 64; +const FREE_TIER_SPRINT_LIMIT = 5; const getStartOfDay = (date: Date) => { const next = new Date(date); @@ -301,6 +303,16 @@ export function SprintForm({ )}
+ {!isEdit && ( + = FREE_TIER_SPRINT_LIMIT} + /> + )} +