mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
more Free/Pro plan limitations
This commit is contained in:
@@ -14,4 +14,5 @@ export const FREE_TIER_LIMITS = {
|
||||
projectsPerOrganisation: 1,
|
||||
issuesPerOrganisation: 100,
|
||||
membersPerOrganisation: 5,
|
||||
sprintsPerProject: 5,
|
||||
} as const;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user