more Free/Pro plan limitations

This commit is contained in:
2026-01-28 23:36:03 +00:00
parent 7f3cb7c890
commit 14520618d1
14 changed files with 296 additions and 55 deletions

View File

@@ -14,4 +14,5 @@ export const FREE_TIER_LIMITS = {
projectsPerOrganisation: 1,
issuesPerOrganisation: 100,
membersPerOrganisation: 5,
sprintsPerProject: 5,
} as const;

View File

@@ -152,3 +152,13 @@ export async function getUserOrganisationCount(userId: number): Promise<number>
.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;
}

View File

@@ -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<number>`count(*)::int` })
.from(Sprint)
.where(eq(Sprint.projectId, projectId));
return result[0]?.count ?? 0;
}

View File

@@ -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,
};
});

View File

@@ -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",

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<boolean> {
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" })